Fix receive and send

This commit is contained in:
2026-03-16 06:48:29 +00:00
parent 9ef1720e1f
commit dd275593cd
28 changed files with 1918 additions and 769 deletions

4
.gitignore vendored
View File

@@ -7,4 +7,6 @@ dist/
*.db-shm
*.db-wal
*.sqlite
resolvedTemplate.json
*.sqlite-journal
resolvedTemplate.json
mnemonic-*

Binary file not shown.

248
package-lock.json generated
View File

@@ -10,6 +10,7 @@
"license": "ISC",
"dependencies": {
"@bitauth/libauth": "^3.0.0",
"@electrum-cash/protocol": "^2.3.1",
"@xo-cash/engine": "file:../engine",
"@xo-cash/state": "file:../state",
"@xo-cash/templates": "file:../templates",
@@ -17,15 +18,13 @@
"better-sqlite3": "^12.6.2",
"clipboardy": "^5.1.0",
"ink": "^6.6.0",
"ink-select-input": "^6.0.0",
"ink-spinner": "^5.0.0",
"ink-text-input": "^6.0.0",
"react": "^19.2.4"
"react": "^19.2.4",
"zod": "^4.3.6"
},
"devDependencies": {
"@types/better-sqlite3": "^7.6.13",
"@types/node": "^25.0.10",
"@types/react": "^18.3.18",
"@types/react": "^19.2.14",
"tsx": "^4.21.0",
"typescript": "^5.9.3"
}
@@ -42,21 +41,28 @@
"@xo-cash/crypto": "0.0.1",
"@xo-cash/primitives": "0.0.1",
"@xo-cash/state": "0.0.1",
"@xo-cash/templates": "0.0.1",
"@xo-cash/types": "0.0.1",
"@xo-cash/utils": "0.0.1",
"eventemitter3": "^5.0.1"
},
"devDependencies": {
"@generalprotocols/eslint-config": "^1.0.1",
"@types/node": "^20.10.0",
"del-cli": "^7.0.0",
"eslint": "^8.57.1",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-prettier": "^5.5.4",
"@chalp/eslint-airbnb": "^1.3.0",
"@generalprotocols/cspell-dictionary": "^1.0.1",
"@stylistic/eslint-plugin": "^5.7.0",
"@typescript-eslint/eslint-plugin": "^8.53.1",
"@typescript-eslint/parser": "^8.53.1",
"@vitest/coverage-v8": "^4.0.17",
"@viz-kit/esbuild-analyzer": "^1.0.0",
"@xo-cash/eslint-config": "1.0.1",
"cspell": "^9.6.0",
"eslint": "^9.39.2",
"prettier": "^3.6.2",
"typedoc": "^0.28.15",
"typescript": "^5.3.2"
"tsdown": "^0.20.0-beta.4",
"typedoc": "^0.28.16",
"typedoc-plugin-coverage": "^4.0.2",
"typescript": "^5.3.2",
"typescript-eslint": "^8.53.1",
"vitest": "^4.0.17"
},
"engines": {
"node": ">=18.0.0"
@@ -78,6 +84,7 @@
"@chalp/eslint-airbnb": "^1.3.0",
"@generalprotocols/cspell-dictionary": "^1.0.1",
"@stylistic/eslint-plugin": "^5.7.0",
"@types/better-sqlite3": "^7.6.13",
"@types/node": "^20.10.0",
"@typescript-eslint/eslint-plugin": "^8.53.1",
"@typescript-eslint/parser": "^8.53.1",
@@ -180,6 +187,54 @@
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
}
},
"node_modules/@electrum-cash/debug-logs": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@electrum-cash/debug-logs/-/debug-logs-1.0.0.tgz",
"integrity": "sha512-GU/CvRR9lZ0d8gy9CXGW7f//OHCIydBavv9q+JcxjGj8Xr7HwlGqHx+Wzhx9y3YmJrXfExpgClcd++gTjdEmzA==",
"license": "MIT",
"dependencies": {
"debug": "^4.3.7"
}
},
"node_modules/@electrum-cash/network": {
"version": "4.2.2",
"resolved": "https://registry.npmjs.org/@electrum-cash/network/-/network-4.2.2.tgz",
"integrity": "sha512-v2Wwt2o0VBBALPArx2dJEDvSqewKjiTW5KAd+jEXxxgdSxFygjJIrFcXryKOCv2CDbpE5+lXAogPAjx6FqW/nw==",
"license": "MIT",
"dependencies": {
"@electrum-cash/debug-logs": "^1.0.0",
"@electrum-cash/web-socket": "^1.2.3",
"async-mutex": "^0.5.0",
"debug": "^4.3.2",
"eventemitter3": "^5.0.1",
"lossless-json": "^4.0.1"
}
},
"node_modules/@electrum-cash/protocol": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/@electrum-cash/protocol/-/protocol-2.3.1.tgz",
"integrity": "sha512-fVahMlWAl4hfbLba8yM5ko/D4Rc0FpyQ20rILzjOyj1R0VymmaJ3SuiiHmvTbe2enZFyjKTV4v2oL+7bs6YNkw==",
"license": "MIT",
"dependencies": {
"@bitauth/libauth": "^3.0.0",
"@electrum-cash/network": "^4.2.1"
}
},
"node_modules/@electrum-cash/web-socket": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@electrum-cash/web-socket/-/web-socket-1.2.3.tgz",
"integrity": "sha512-sFWujTt98mvsMvE6gWjG44evkF3PcZhG1JffkkEUAVan+c9X51wkiWr3hkjPeIW0WfNpMTrFkvpGqVYmRj1RCA==",
"license": "MIT",
"dependencies": {
"@electrum-cash/debug-logs": "^1.0.0",
"@monsterbitar/isomorphic-ws": "^5.3.0",
"@types/ws": "^8.5.5",
"async-mutex": "^0.5.0",
"eventemitter3": "^5.0.1",
"lossless-json": "^4.0.1",
"ws": "^8.13.0"
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.27.2",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz",
@@ -622,6 +677,15 @@
"node": ">=18"
}
},
"node_modules/@monsterbitar/isomorphic-ws": {
"version": "5.3.1",
"resolved": "https://registry.npmjs.org/@monsterbitar/isomorphic-ws/-/isomorphic-ws-5.3.1.tgz",
"integrity": "sha512-BWfWUffbg3uO4K6Cyokg9ff43lPaXAOZcCnNe1lcjCjUMDVrRAb5qEHG5qeJp3ud2SPYbORaNsls5as6SR3oig==",
"license": "MIT",
"peerDependencies": {
"ws": "*"
}
},
"node_modules/@sec-ant/readable-stream": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/@sec-ant/readable-stream/-/readable-stream-0.4.1.tgz",
@@ -654,30 +718,30 @@
"version": "25.0.10",
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.0.10.tgz",
"integrity": "sha512-zWW5KPngR/yvakJgGOmZ5vTBemDoSqF3AcV/LrO5u5wTWyEAVVh+IT39G4gtyAkh3CtTZs8aX/yRM82OfzHJRg==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~7.16.0"
}
},
"node_modules/@types/prop-types": {
"version": "15.7.15",
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
"integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==",
"devOptional": true,
"license": "MIT"
},
"node_modules/@types/react": {
"version": "18.3.27",
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.27.tgz",
"integrity": "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==",
"version": "19.2.14",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
"devOptional": true,
"license": "MIT",
"dependencies": {
"@types/prop-types": "*",
"csstype": "^3.2.2"
}
},
"node_modules/@types/ws": {
"version": "8.18.1",
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
"integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==",
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@xo-cash/engine": {
"resolved": "../engine",
"link": true
@@ -733,6 +797,15 @@
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/async-mutex": {
"version": "0.5.0",
"resolved": "https://registry.npmjs.org/async-mutex/-/async-mutex-0.5.0.tgz",
"integrity": "sha512-1A94B18jkJ3DYq284ohPxoXbfTA5HsQ7/Mf4DEhcyLx3Bz27Rh59iScbB6EPiP+B+joue6YCxcMXSbFC1tZKwA==",
"license": "MIT",
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/auto-bind": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/auto-bind/-/auto-bind-5.0.1.tgz",
@@ -868,18 +941,6 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/cli-spinners": {
"version": "2.9.2",
"resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-2.9.2.tgz",
"integrity": "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==",
"license": "MIT",
"engines": {
"node": ">=6"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/cli-truncate": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-5.1.1.tgz",
@@ -973,6 +1034,23 @@
"devOptional": true,
"license": "MIT"
},
"node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/decompress-response": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz",
@@ -1094,6 +1172,12 @@
"node": ">=8"
}
},
"node_modules/eventemitter3": {
"version": "5.0.4",
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz",
"integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==",
"license": "MIT"
},
"node_modules/execa": {
"version": "9.6.1",
"resolved": "https://registry.npmjs.org/execa/-/execa-9.6.1.tgz",
@@ -1318,56 +1402,6 @@
}
}
},
"node_modules/ink-select-input": {
"version": "6.2.0",
"resolved": "https://registry.npmjs.org/ink-select-input/-/ink-select-input-6.2.0.tgz",
"integrity": "sha512-304fZXxkpYxJ9si5lxRCaX01GNlmPBgOZumXXRnPYbHW/iI31cgQynqk2tRypGLOF1cMIwPUzL2LSm6q4I5rQQ==",
"license": "MIT",
"dependencies": {
"figures": "^6.1.0",
"to-rotated": "^1.0.0"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"ink": ">=5.0.0",
"react": ">=18.0.0"
}
},
"node_modules/ink-spinner": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/ink-spinner/-/ink-spinner-5.0.0.tgz",
"integrity": "sha512-EYEasbEjkqLGyPOUc8hBJZNuC5GvXGMLu0w5gdTNskPc7Izc5vO3tdQEYnzvshucyGCBXc86ig0ujXPMWaQCdA==",
"license": "MIT",
"dependencies": {
"cli-spinners": "^2.7.0"
},
"engines": {
"node": ">=14.16"
},
"peerDependencies": {
"ink": ">=4.0.0",
"react": ">=18.0.0"
}
},
"node_modules/ink-text-input": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/ink-text-input/-/ink-text-input-6.0.0.tgz",
"integrity": "sha512-Fw64n7Yha5deb1rHY137zHTAbSTNelUKuB5Kkk2HACXEtwIHBCf9OH2tP/LQ9fRYTl1F0dZgbW0zPnZk6FA9Lw==",
"license": "MIT",
"dependencies": {
"chalk": "^5.3.0",
"type-fest": "^4.18.2"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"ink": ">=5",
"react": ">=18"
}
},
"node_modules/ink/node_modules/signal-exit": {
"version": "3.0.7",
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz",
@@ -1537,6 +1571,12 @@
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
"license": "ISC"
},
"node_modules/lossless-json": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/lossless-json/-/lossless-json-4.3.0.tgz",
"integrity": "sha512-ToxOC+SsduRmdSuoLZLYAr5zy1Qu7l5XhmPWM3zefCZ5IcrzW/h108qbJUKfOlDlhvhjUK84+8PSVX0kxnit0g==",
"license": "MIT"
},
"node_modules/mimic-fn": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz",
@@ -1573,6 +1613,12 @@
"integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==",
"license": "MIT"
},
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/napi-build-utils": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz",
@@ -2067,17 +2113,11 @@
"node": ">=6"
}
},
"node_modules/to-rotated": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/to-rotated/-/to-rotated-1.0.0.tgz",
"integrity": "sha512-KsEID8AfgUy+pxVRLsWp0VzCa69wxzUDZnzGbyIST/bcgcrMvTYoFBX/QORH4YApoD89EDuUovx4BTdpOn319Q==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD"
},
"node_modules/tsx": {
"version": "4.21.0",
@@ -2141,7 +2181,6 @@
"version": "7.16.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz",
"integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==",
"dev": true,
"license": "MIT"
},
"node_modules/unicorn-magic": {
@@ -2253,6 +2292,15 @@
"resolved": "https://registry.npmjs.org/yoga-layout/-/yoga-layout-3.2.1.tgz",
"integrity": "sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ==",
"license": "MIT"
},
"node_modules/zod": {
"version": "4.3.6",
"resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz",
"integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
}
}
}

View File

@@ -22,6 +22,7 @@
"description": "XO Wallet CLI - Terminal User Interface for XO crypto wallet",
"dependencies": {
"@bitauth/libauth": "^3.0.0",
"@electrum-cash/protocol": "^2.3.1",
"@xo-cash/engine": "file:../engine",
"@xo-cash/state": "file:../state",
"@xo-cash/templates": "file:../templates",
@@ -29,15 +30,13 @@
"better-sqlite3": "^12.6.2",
"clipboardy": "^5.1.0",
"ink": "^6.6.0",
"ink-select-input": "^6.0.0",
"ink-spinner": "^5.0.0",
"ink-text-input": "^6.0.0",
"react": "^19.2.4"
"react": "^19.2.4",
"zod": "^4.3.6"
},
"devDependencies": {
"@types/better-sqlite3": "^7.6.13",
"@types/node": "^25.0.10",
"@types/react": "^18.3.18",
"@types/react": "^19.2.14",
"tsx": "^4.21.0",
"typescript": "^5.9.3"
}

View File

@@ -76,6 +76,8 @@ export class App {
// Wait for the app to exit
await this.inkInstance.waitUntilExit();
process.exit(0);
}
/**

View File

@@ -10,6 +10,7 @@ import { Invitation } from './invitation.js';
import { Storage } from './storage.js';
import { SyncServer } from '../utils/sync-server.js';
import { HistoryService } from './history.js';
import { ElectrumService } from './electrum.js';
import { EventEmitter } from '../utils/event-emitter.js';
@@ -26,6 +27,8 @@ export interface AppConfig {
syncServerUrl: string;
engineConfig: XOEngineOptions;
invitationStoragePath: string;
electrumHost?: string;
electrumApplicationIdentifier?: string;
}
export class AppService extends EventEmitter<AppEventMap> {
@@ -33,6 +36,7 @@ export class AppService extends EventEmitter<AppEventMap> {
public storage: Storage;
public config: AppConfig;
public history: HistoryService;
public electrum: ElectrumService;
public invitations: Invitation[] = [];
@@ -68,15 +72,21 @@ export class AppService extends EventEmitter<AppEventMap> {
const walletStorage = await storage.child(seedHash.slice(0, 8))
// Create the app service
return new AppService(engine, walletStorage, config);
const electrum = new ElectrumService({
host: config.electrumHost,
applicationIdentifier: config.electrumApplicationIdentifier,
});
return new AppService(engine, walletStorage, config, electrum);
}
constructor(engine: Engine, storage: Storage, config: AppConfig) {
constructor(engine: Engine, storage: Storage, config: AppConfig, electrum: ElectrumService) {
super();
this.engine = engine;
this.storage = storage;
this.config = config;
this.electrum = electrum;
this.history = new HistoryService(engine, this.invitations);
}
@@ -89,6 +99,7 @@ export class AppService extends EventEmitter<AppEventMap> {
engine: this.engine,
syncServer: invitationSyncServer,
storage: invitationStorage,
electrum: this.electrum,
};
// Create the invitation

46
src/services/electrum.ts Normal file
View File

@@ -0,0 +1,46 @@
import { fetchTransactionBlockHeight, initializeElectrumClient } from '@electrum-cash/protocol';
export interface ElectrumServiceConfig {
host?: string;
applicationIdentifier?: string;
}
/**
* Small Electrum adapter used by CLI services.
* Keeps connection logic in one place and exposes a tiny API.
*/
export class ElectrumService {
private readonly host: string;
private readonly applicationIdentifier: string;
private clientPromise?: ReturnType<typeof initializeElectrumClient>;
constructor(config: ElectrumServiceConfig = {}) {
this.host = config.host ?? process.env['ELECTRUM_HOST'] ?? 'bch.imaginary.cash';
this.applicationIdentifier = 'xo-cli';
}
private async getClient() {
if (!this.clientPromise) {
this.clientPromise = initializeElectrumClient(this.applicationIdentifier, this.host);
}
return this.clientPromise;
}
/**
* Returns true when the transaction is known by Electrum
* (confirmed or currently in mempool).
*/
async hasSeenTransaction(transactionHash: string): Promise<boolean> {
try {
const client = await this.getClient();
const height = await fetchTransactionBlockHeight(client, transactionHash);
// Electrum returns numbers for known transactions
// (e.g. >0 confirmed, 0/-1 unconfirmed variants).
return typeof height === 'number';
} catch {
return false;
}
}
}

View File

@@ -1,247 +1,582 @@
/**
* History Service - Derives wallet history from invitations and UTXOs.
*
* Provides a unified view of wallet activity including:
* - UTXO reservations (from invitation commits that reference our UTXOs as inputs)
* - UTXOs we own (with descriptions derived from template outputs)
*/
import { type Engine, compileCashAssemblyString } from '@xo-cash/engine';
import type { XOInvitation, XOInvitationVariableValue, XOTemplate } from '@xo-cash/types';
import type { UnspentOutputData } from '@xo-cash/state';
import type { Invitation } from './invitation.js';
import { binToHex } from '@bitauth/libauth';
import { compileCashAssemblyString, type Engine } from '@xo-cash/engine';
import type { UnspentOutputData } from '@xo-cash/state';
import type { XOInvitation, XOInvitationCommit, XOInvitationVariableValue, XOTemplate } from '@xo-cash/types';
import type { Invitation } from './invitation.js';
/**
* Types of history events.
*/
export type HistoryItemType =
| 'utxo_received'
| 'utxo_reserved'
| 'invitation_created';
export type HistoryEntryKind = 'invitation' | 'utxo';
/**
* A single item in the wallet history.
*/
export interface HistoryItem {
/** Unique identifier for this history item. */
id: string;
/** Unix timestamp of when the event occurred (if available). */
timestamp?: number;
/** The type of history event. */
type: HistoryItemType;
/** Human-readable description derived from the template. */
export interface HistoryDescriptionParts {
template: string;
role: string;
outputIdentifier: string;
description: string;
/** The value in satoshis (for UTXO-related events). */
valueSatoshis?: bigint;
/** The invitation identifier this event relates to (if applicable). */
valueSatoshis?: number;
}
export interface HistoryUtxoItem {
kind: 'utxo';
id: string;
invitationIdentifier?: string;
/** The template identifier for reference. */
templateIdentifier?: string;
/** The UTXO outpoint (for UTXO-related events). */
outpoint?: {
templateIdentifier: string;
outputIdentifier: string;
outpoint: {
txid: string;
index: number;
};
/** Whether this UTXO is reserved. */
valueSatoshis?: bigint;
reserved?: boolean;
direction: 'input' | 'output' | 'standalone';
description: string;
descriptionParts: HistoryDescriptionParts;
}
export interface HistoryInvitationItem {
kind: 'invitation';
id: string;
createdAtTimestamp: number;
templateIdentifier: string;
invitationIdentifier: string;
roles: string[];
description: string;
descriptionParts: {
template: string;
roles: string[];
description: string;
};
inputs: HistoryUtxoItem[];
outputs: HistoryUtxoItem[];
}
export type HistoryItem = HistoryInvitationItem | HistoryUtxoItem;
interface InvitationContext {
invitation: Invitation;
template: XOTemplate | null;
variables: Record<string, XOInvitationVariableValue>;
walletEntityIdentifier?: string;
}
interface UtxoOriginContext {
invitationIdentifier: string;
roleIdentifier?: string;
}
/**
* Service for deriving wallet history from invitations and UTXOs.
*
* This service takes the engine and invitations array as dependencies
* and derives history events from them. Since invitations is passed
* by reference, getHistory() always sees the current data.
*/
export class HistoryService {
/**
* Creates a new HistoryService.
*
* @param engine - The XO engine instance for querying UTXOs and templates.
* @param invitations - The array of invitations to derive history from.
*/
constructor(
private engine: Engine,
private invitations: Invitation[]
) {}
/**
* Gets the wallet history derived from invitations and UTXOs.
*
* @returns Array of history items sorted by timestamp (newest first), then UTXOs without timestamps.
*/
async getHistory(): Promise<HistoryItem[]> {
const items: HistoryItem[] = [];
// 1. Get all our UTXOs
const allUtxos = await this.engine.listUnspentOutputsData();
// Create a map for quick UTXO lookup by outpoint
const utxoMap = new Map<string, UnspentOutputData>();
const ownOutpoints = new Set<string>();
const ownLockingBytecodes = new Set<string>();
const invitationByOrigin = new Map<string, UtxoOriginContext>();
const outpointValueSatoshis = new Map<string, bigint>();
for (const utxo of allUtxos) {
const key = `${utxo.outpointTransactionHash}:${utxo.outpointIndex}`;
utxoMap.set(key, utxo);
const outpointKey = this.getOutpointKey(utxo.outpointTransactionHash, utxo.outpointIndex);
ownOutpoints.add(outpointKey);
ownLockingBytecodes.add(utxo.lockingBytecode);
outpointValueSatoshis.set(outpointKey, BigInt(utxo.valueSatoshis));
}
// 2. Process invitations to find UTXO reservations from commits
const contexts = new Map<string, InvitationContext>();
for (const invitation of this.invitations) {
const invData = invitation.data;
// Add invitation created event
const template = await this.engine.getTemplate(invData.templateIdentifier);
const invDescription = template
? this.deriveInvitationDescription(invData, template)
: 'Unknown action';
items.push({
id: `inv-${invData.invitationIdentifier}`,
timestamp: invData.createdAtTimestamp,
type: 'invitation_created',
description: invDescription,
invitationIdentifier: invData.invitationIdentifier,
templateIdentifier: invData.templateIdentifier,
const variables = this.extractInvitationVariables(invitation.data);
const template = await this.engine.getTemplate(invitation.data.templateIdentifier) ?? null;
const walletEntityIdentifier = this.resolveWalletEntityIdentifier(invitation, ownOutpoints, ownLockingBytecodes);
contexts.set(invitation.data.invitationIdentifier, {
invitation,
template,
variables,
walletEntityIdentifier,
});
// Check each commit for inputs that reference our UTXOs
for (const commit of invData.commits) {
const commitInputs = commit.data.inputs ?? [];
for (const input of commitInputs) {
// Input's outpointTransactionHash could be Uint8Array or string
const txHash = input.outpointTransactionHash
? (input.outpointTransactionHash instanceof Uint8Array
? binToHex(input.outpointTransactionHash)
: String(input.outpointTransactionHash))
: undefined;
if (!txHash || input.outpointIndex === undefined) continue;
const utxoKey = `${txHash}:${input.outpointIndex}`;
const matchingUtxo = utxoMap.get(utxoKey);
// If this input references one of our UTXOs, it's a reservation event
if (matchingUtxo) {
const utxoTemplate = await this.engine.getTemplate(matchingUtxo.templateIdentifier);
const utxoDescription = utxoTemplate
? this.deriveUtxoDescription(matchingUtxo, utxoTemplate)
: 'Unknown UTXO';
items.push({
id: `reserved-${commit.commitIdentifier}-${utxoKey}`,
timestamp: invData.createdAtTimestamp, // Use invitation timestamp as proxy
type: 'utxo_reserved',
description: `Reserved for: ${invDescription}`,
valueSatoshis: BigInt(matchingUtxo.valueSatoshis),
invitationIdentifier: invData.invitationIdentifier,
templateIdentifier: matchingUtxo.templateIdentifier,
outpoint: {
txid: txHash,
index: input.outpointIndex,
},
reserved: true,
});
}
this.indexInvitationOutputsByUtxoOrigin(invitationByOrigin, invitation);
}
const usedUtxoIds = new Set<string>();
const invitationItems: HistoryInvitationItem[] = [];
for (const context of contexts.values()) {
const invitation = context.invitation.data;
const templateName = context.template?.name ?? 'UnknownTemplate';
const invitationOutputs = this.buildWalletOutputItemsForInvitation(
context,
allUtxos,
invitationByOrigin,
usedUtxoIds,
);
const roles = this.deriveWalletRolesForInvitation(context, invitationOutputs);
const invitationInputs = this.buildWalletInputItemsForInvitation(
context,
roles[0],
invitationOutputs.length > 0,
outpointValueSatoshis,
);
const invitationDescription = this.deriveInvitationDescription(invitation, context.template, context.variables, roles[0]);
invitationItems.push({
kind: 'invitation',
id: `inv-${invitation.invitationIdentifier}`,
createdAtTimestamp: invitation.createdAtTimestamp,
templateIdentifier: invitation.templateIdentifier,
invitationIdentifier: invitation.invitationIdentifier,
roles,
description: invitationDescription,
descriptionParts: {
template: templateName,
roles,
description: invitationDescription,
},
inputs: invitationInputs,
outputs: invitationOutputs,
});
}
invitationItems.sort((a, b) => b.createdAtTimestamp - a.createdAtTimestamp);
const standaloneUtxos: HistoryUtxoItem[] = [];
for (const utxo of allUtxos) {
const utxoId = this.getUtxoId(utxo);
if (usedUtxoIds.has(utxoId)) continue;
const template = await this.engine.getTemplate(utxo.templateIdentifier) ?? null;
const inferredRole = this.inferRoleFromOutputIdentifier(utxo.outputIdentifier);
const description = this.deriveUtxoDescription(utxo, template, {}, inferredRole);
standaloneUtxos.push(this.buildUtxoHistoryItem(
utxo,
description,
template?.name ?? 'UnknownTemplate',
inferredRole,
'standalone',
));
}
return [ ...invitationItems, ...standaloneUtxos ];
}
private buildWalletOutputItemsForInvitation(
context: InvitationContext,
allUtxos: UnspentOutputData[],
invitationByOrigin: Map<string, UtxoOriginContext>,
usedUtxoIds: Set<string>
): HistoryUtxoItem[] {
const invitationId = context.invitation.data.invitationIdentifier;
const outputs: HistoryUtxoItem[] = [];
for (const utxo of allUtxos) {
const resolvedInvitationId = this.resolveInvitationIdentifierForUtxo(utxo, invitationByOrigin);
if (resolvedInvitationId !== invitationId) continue;
const role = this.resolveRoleIdentifierForUtxo(utxo, invitationByOrigin)
?? this.inferRoleFromOutputIdentifier(utxo.outputIdentifier)
?? 'receiver';
const description = this.deriveUtxoDescription(utxo, context.template, context.variables, role);
outputs.push(this.buildUtxoHistoryItem(utxo, description, context.template?.name ?? 'UnknownTemplate', role, 'output'));
usedUtxoIds.add(this.getUtxoId(utxo));
}
return outputs;
}
private buildWalletInputItemsForInvitation(
context: InvitationContext,
walletRole?: string,
hasWalletOutputs: boolean = false,
outpointValueSatoshis: Map<string, bigint> = new Map(),
): HistoryUtxoItem[] {
const invitation = context.invitation.data;
const commits = invitation.commits ?? [];
const commitsByEntity = context.walletEntityIdentifier
? commits.filter((commit) => commit.entityIdentifier === context.walletEntityIdentifier)
: [];
const commitsByRole = walletRole
? commits.filter((commit) => this.deriveCommitRoleIdentifier(commit, invitation, context.template) === walletRole)
: [];
let relevantCommits = commitsByEntity.filter((commit) => (commit.data.inputs?.length ?? 0) > 0);
if (relevantCommits.length === 0) {
relevantCommits = commitsByRole.filter((commit) => (commit.data.inputs?.length ?? 0) > 0);
}
if (relevantCommits.length === 0 && walletRole === 'sender') {
relevantCommits = commits.filter((commit) => (commit.data.inputs?.length ?? 0) > 0);
}
// Sender fallback only when no wallet outputs were matched.
if (relevantCommits.length === 0 && !hasWalletOutputs) {
relevantCommits = commits.filter((commit) => (commit.data.inputs?.length ?? 0) > 0);
}
const txDescription = this.deriveTransactionActivityDescription(
invitation,
context.template,
context.variables,
walletRole,
);
const inputs: HistoryUtxoItem[] = [];
for (const commit of relevantCommits) {
for (const input of commit.data.inputs ?? []) {
const txHash = input.outpointTransactionHash
? (input.outpointTransactionHash instanceof Uint8Array
? binToHex(input.outpointTransactionHash)
: String(input.outpointTransactionHash))
: 'unknown-tx';
const inputIndex = input.outpointIndex ?? -1;
const inputIdentifier = input.inputIdentifier ?? 'input';
const inputDescription = this.deriveInputDescription(inputIdentifier, context.template, context.variables);
const templateName = context.template?.name ?? 'UnknownTemplate';
const role = walletRole ?? 'sender';
const inputValue = this.resolveInputSatoshis(txHash, inputIndex, outpointValueSatoshis, context.variables);
inputs.push({
kind: 'utxo',
id: `input-${invitation.invitationIdentifier}-${commit.commitIdentifier}-${txHash}:${inputIndex}-${inputIdentifier}`,
invitationIdentifier: invitation.invitationIdentifier,
templateIdentifier: invitation.templateIdentifier,
outputIdentifier: inputIdentifier,
outpoint: {
txid: txHash,
index: inputIndex,
},
direction: 'input',
valueSatoshis: inputValue,
description: `${txDescription} - ${inputDescription}`,
descriptionParts: {
template: templateName,
role,
outputIdentifier: inputIdentifier,
description: `${txDescription} - ${inputDescription}`,
valueSatoshis: inputValue !== undefined ? Number(inputValue) : undefined,
},
});
}
}
return inputs;
}
private buildUtxoHistoryItem(
utxo: UnspentOutputData,
description: string,
templateName: string,
roleIdentifier: string | undefined,
direction: HistoryUtxoItem['direction']
): HistoryUtxoItem {
return {
kind: 'utxo',
id: this.getUtxoId(utxo),
invitationIdentifier: utxo.invitationIdentifier || undefined,
templateIdentifier: utxo.templateIdentifier,
outputIdentifier: utxo.outputIdentifier,
outpoint: {
txid: utxo.outpointTransactionHash,
index: utxo.outpointIndex,
},
valueSatoshis: BigInt(utxo.valueSatoshis),
reserved: utxo.reserved,
direction,
description,
descriptionParts: {
template: templateName,
role: roleIdentifier ?? 'unknown',
outputIdentifier: utxo.outputIdentifier,
description,
valueSatoshis: utxo.valueSatoshis,
},
};
}
private deriveWalletRolesForInvitation(
context: InvitationContext,
outputs: HistoryUtxoItem[]
): string[] {
const roles = new Set<string>();
for (const output of outputs) {
const outputRole = output.descriptionParts.role;
if (outputRole && outputRole !== 'unknown') {
roles.add(outputRole);
}
}
if (roles.size === 0 && outputs.length > 0) {
roles.add('receiver');
}
const hasInputCommit = (context.walletEntityIdentifier
? context.invitation.data.commits.filter((c) => c.entityIdentifier === context.walletEntityIdentifier)
: context.invitation.data.commits
).some((c) => (c.data.inputs?.length ?? 0) > 0);
if (hasInputCommit) roles.add('sender');
if (!hasInputCommit && outputs.length === 0 && context.invitation.data.commits.some((c) => (c.data.inputs?.length ?? 0) > 0)) {
roles.add('sender');
}
if (roles.size === 0) {
const inferred = this.extractInvitationRoleIdentifier(context.invitation.data, context.template, context.walletEntityIdentifier);
if (inferred) roles.add(inferred);
}
return roles.size > 0 ? Array.from(roles) : [ 'unknown' ];
}
private extractInvitationVariables(invitation: XOInvitation): Record<string, XOInvitationVariableValue> {
const committedVariables = invitation.commits.flatMap(c => c.data.variables ?? []);
return committedVariables.reduce((acc, variable) => {
if (!variable.variableIdentifier) return acc;
acc[variable.variableIdentifier] = variable.value;
return acc;
}, {} as Record<string, XOInvitationVariableValue>);
}
private indexInvitationOutputsByUtxoOrigin(
invitationByUtxoOrigin: Map<string, UtxoOriginContext>,
invitation: Invitation
): void {
for (const commit of invitation.data.commits) {
for (const output of commit.data.outputs ?? []) {
if (!output.outputIdentifier || !output.lockingBytecode) continue;
const lockingBytecodeHex = this.toLockingBytecodeHex(output.lockingBytecode);
const key = this.getUtxoOriginKey(invitation.data.templateIdentifier, output.outputIdentifier, lockingBytecodeHex);
invitationByUtxoOrigin.set(key, {
invitationIdentifier: invitation.data.invitationIdentifier,
roleIdentifier: output.roleIdentifier,
});
}
}
}
private resolveInvitationIdentifierForUtxo(
utxo: UnspentOutputData,
invitationByUtxoOrigin: Map<string, UtxoOriginContext>
): string | undefined {
if (utxo.invitationIdentifier) return utxo.invitationIdentifier;
const originKey = this.getUtxoOriginKey(utxo.templateIdentifier, utxo.outputIdentifier, utxo.lockingBytecode);
return invitationByUtxoOrigin.get(originKey)?.invitationIdentifier;
}
private resolveRoleIdentifierForUtxo(
utxo: UnspentOutputData,
invitationByUtxoOrigin: Map<string, UtxoOriginContext>
): string | undefined {
const originKey = this.getUtxoOriginKey(utxo.templateIdentifier, utxo.outputIdentifier, utxo.lockingBytecode);
return invitationByUtxoOrigin.get(originKey)?.roleIdentifier;
}
private resolveWalletEntityIdentifier(
invitation: Invitation,
ownUtxoOutpointKeys: Set<string>,
ownLockingBytecodes: Set<string>
): string | undefined {
const scores = new Map<string, number>();
const addScore = (entityIdentifier: string, delta: number): void => {
scores.set(entityIdentifier, (scores.get(entityIdentifier) ?? 0) + delta);
};
for (const commit of invitation.data.commits) {
for (const input of commit.data.inputs ?? []) {
const txHash = input.outpointTransactionHash
? (input.outpointTransactionHash instanceof Uint8Array
? binToHex(input.outpointTransactionHash)
: String(input.outpointTransactionHash))
: undefined;
if (!txHash || input.outpointIndex === undefined) continue;
if (ownUtxoOutpointKeys.has(this.getOutpointKey(txHash, input.outpointIndex))) {
addScore(commit.entityIdentifier, 3);
}
}
for (const output of commit.data.outputs ?? []) {
const lockingBytecodeHex = output.lockingBytecode ? this.toLockingBytecodeHex(output.lockingBytecode) : undefined;
if (!lockingBytecodeHex) continue;
if (ownLockingBytecodes.has(lockingBytecodeHex)) {
addScore(commit.entityIdentifier, 2);
}
}
}
// 3. Add all UTXOs as "received" events (without timestamps)
for (const utxo of allUtxos) {
const template = await this.engine.getTemplate(utxo.templateIdentifier);
const description = template
? this.deriveUtxoDescription(utxo, template)
: 'Unknown output';
items.push({
id: `utxo-${utxo.outpointTransactionHash}:${utxo.outpointIndex}`,
// No timestamp available for UTXOs
type: 'utxo_received',
description,
valueSatoshis: BigInt(utxo.valueSatoshis),
templateIdentifier: utxo.templateIdentifier,
outpoint: {
txid: utxo.outpointTransactionHash,
index: utxo.outpointIndex,
},
reserved: utxo.reserved,
invitationIdentifier: utxo.invitationIdentifier || undefined,
});
}
// Sort: items with timestamps first (newest first), then items without timestamps
return items.sort((a, b) => {
// Both have timestamps: sort by timestamp descending
if (a.timestamp !== undefined && b.timestamp !== undefined) {
return b.timestamp - a.timestamp;
let bestEntity: string | undefined;
let bestScore = 0;
for (const [ entity, score ] of scores.entries()) {
if (score > bestScore) {
bestScore = score;
bestEntity = entity;
}
// Only a has timestamp: a comes first
if (a.timestamp !== undefined) return -1;
// Only b has timestamp: b comes first
if (b.timestamp !== undefined) return 1;
// Neither has timestamp: maintain order
return 0;
});
}
return bestEntity;
}
/**
* Derives a human-readable description for a UTXO from its template output definition.
*
* @param utxo - The UTXO data.
* @param template - The template definition.
* @returns Human-readable description string.
*/
private deriveUtxoDescription(utxo: UnspentOutputData, template: XOTemplate): string {
const outputDef = template.outputs?.[utxo.outputIdentifier];
if (!outputDef) {
return `[${template.name}] ${utxo.outputIdentifier} output`;
private deriveUtxoDescription(
utxo: UnspentOutputData,
template: XOTemplate | null,
variables: Record<string, XOInvitationVariableValue>,
roleIdentifier?: string
): string {
const templateName = template?.name ?? 'UnknownTemplate';
const role = roleIdentifier ?? 'unknown';
const outputDef = template?.outputs?.[utxo.outputIdentifier];
let detail = outputDef?.name ?? utxo.outputIdentifier;
if (outputDef?.description) {
try {
detail = compileCashAssemblyString(outputDef.description, variables);
} catch {
detail = this.interpolateSimpleCashAssemblyVariables(outputDef.description, variables);
}
}
// Start with the output name or identifier
let description = outputDef.name || utxo.outputIdentifier;
// If there's a description, parse it and replace variable placeholders
if (outputDef.description) {
description = compileCashAssemblyString(outputDef.description, {})
}
return description;
return `[${templateName}:${role}] ${detail}`;
}
/**
* Derives a human-readable description from an invitation and its template.
* Parses the transaction description and replaces variable placeholders.
*
* @param invitation - The invitation data.
* @param template - The template definition.
* @returns Human-readable description string.
*/
private deriveInvitationDescription(invitation: XOInvitation, template: XOTemplate): string {
private deriveInvitationDescription(
invitation: XOInvitation,
template: XOTemplate | null,
variables: Record<string, XOInvitationVariableValue>,
roleIdentifier?: string
): string {
if (!template) return invitation.actionIdentifier;
const action = template.actions?.[invitation.actionIdentifier];
const transactionName = action?.transaction;
const transaction = transactionName ? template.transactions?.[transactionName] : null;
if (!transaction?.description) {
return action?.name ?? invitation.actionIdentifier;
const role = roleIdentifier ?? 'unknown';
const baseTemplate = transaction?.description ?? action?.description ?? action?.name ?? invitation.actionIdentifier;
let detail = baseTemplate;
try {
detail = compileCashAssemblyString(baseTemplate, variables);
} catch {
detail = this.interpolateSimpleCashAssemblyVariables(baseTemplate, variables);
}
const committedVariables = invitation.commits.flatMap(c => c.data.variables ?? []);
const formattedVariables = committedVariables.reduce((acc, v) => {
acc[v.variableIdentifier ?? ''] = v.value;
return acc;
}, {} as Record<string, XOInvitationVariableValue>);
return `[${template.name}:${role}] ${detail}`;
}
const description = compileCashAssemblyString(transaction.description, formattedVariables);
return description;
private deriveInputDescription(
inputIdentifier: string,
template: XOTemplate | null,
variables: Record<string, XOInvitationVariableValue>
): string {
if (inputIdentifier === 'input') return 'Funding input';
const inputDef = template?.inputs?.[inputIdentifier];
if (!inputDef) return inputIdentifier;
if (!inputDef.description) return inputDef.name ?? inputIdentifier;
try {
return compileCashAssemblyString(inputDef.description, variables);
} catch {
return this.interpolateSimpleCashAssemblyVariables(inputDef.description, variables);
}
}
private deriveTransactionActivityDescription(
invitation: XOInvitation,
template: XOTemplate | null,
variables: Record<string, XOInvitationVariableValue>,
roleIdentifier?: string
): string {
if (!template) return invitation.actionIdentifier;
const action = template.actions?.[invitation.actionIdentifier];
const transactionName = action?.transaction;
const transaction = transactionName ? template.transactions?.[transactionName] : null;
const roleData = roleIdentifier ? transaction?.roles?.[roleIdentifier] : undefined;
const descriptionTemplate = roleData?.description
?? transaction?.description
?? roleData?.name
?? transaction?.name
?? action?.name
?? invitation.actionIdentifier;
try {
return compileCashAssemblyString(descriptionTemplate, variables);
} catch {
return this.interpolateSimpleCashAssemblyVariables(descriptionTemplate, variables);
}
}
private deriveCommitRoleIdentifier(
commit: XOInvitationCommit,
invitation: XOInvitation,
template: XOTemplate | null
): string | undefined {
const explicitRoles = new Set<string>();
for (const input of commit.data.inputs ?? []) {
if (input.roleIdentifier) explicitRoles.add(input.roleIdentifier);
}
for (const output of commit.data.outputs ?? []) {
if (output.roleIdentifier) explicitRoles.add(output.roleIdentifier);
}
for (const variable of commit.data.variables ?? []) {
if (variable.roleIdentifier) explicitRoles.add(variable.roleIdentifier);
}
if (explicitRoles.size === 1) return Array.from(explicitRoles)[0];
const action = template?.actions?.[invitation.actionIdentifier];
if ((commit.data.inputs?.length ?? 0) > 0 && action?.roles?.sender) return 'sender';
if ((commit.data.variables?.length ?? 0) > 0 && action?.roles?.receiver) return 'receiver';
return undefined;
}
private extractInvitationRoleIdentifier(
invitation: XOInvitation,
template: XOTemplate | null,
walletEntityIdentifier?: string
): string | undefined {
if (walletEntityIdentifier) {
const commits = invitation.commits.filter((commit) => commit.entityIdentifier === walletEntityIdentifier);
for (const commit of commits) {
const role = this.deriveCommitRoleIdentifier(commit, invitation, template);
if (role) return role;
}
}
return undefined;
}
private inferRoleFromOutputIdentifier(outputIdentifier: string): string | undefined {
const normalized = outputIdentifier.toLowerCase();
if (normalized.includes('receive') || normalized.includes('request')) return 'receiver';
if (normalized.includes('change') || normalized.includes('send')) return 'sender';
return undefined;
}
private resolveInputSatoshis(
txHash: string,
index: number,
outpointValueSatoshis: Map<string, bigint>,
variables: Record<string, XOInvitationVariableValue>
): bigint | undefined {
const outpointKey = this.getOutpointKey(txHash, index);
const matchedValue = outpointValueSatoshis.get(outpointKey);
if (matchedValue !== undefined) return matchedValue;
const requestedSatoshis = variables.requestedSatoshis;
if (requestedSatoshis !== undefined) {
try {
return BigInt(String(requestedSatoshis));
} catch {
return undefined;
}
}
return undefined;
}
private getUtxoId(utxo: UnspentOutputData): string {
return `utxo-${utxo.outpointTransactionHash}:${utxo.outpointIndex}`;
}
private getOutpointKey(txid: string, index: number): string {
return `${txid}:${index}`;
}
private getUtxoOriginKey(templateIdentifier: string, outputIdentifier: string, lockingBytecodeHex: string): string {
return `${templateIdentifier}:${outputIdentifier}:${lockingBytecodeHex}`;
}
private toLockingBytecodeHex(lockingBytecode: string | Uint8Array): string {
if (typeof lockingBytecode === 'string') return lockingBytecode;
return binToHex(lockingBytecode);
}
private interpolateSimpleCashAssemblyVariables(
text: string,
variables: Record<string, XOInvitationVariableValue>
): string {
return text.replace(/\$\(<([^>]+)>\)/g, (match, variableIdentifier: string) => {
if (!Object.prototype.hasOwnProperty.call(variables, variableIdentifier)) return match;
return String(variables[variableIdentifier]);
});
}
}

View File

@@ -1,11 +1,13 @@
import type { AcceptInvitationParameters, AppendInvitationParameters, Engine, FindSuitableResourcesParameters } from '@xo-cash/engine';
import { hasInvitationExpired } from '@xo-cash/engine';
import { hasInvitationExpired, mergeInvitationCommits } from '@xo-cash/engine';
import type { XOInvitation, XOInvitationCommit, XOInvitationInput, XOInvitationOutput, XOInvitationVariable, XOInvitationVariableValue } from '@xo-cash/types';
import type { UnspentOutputData } from '@xo-cash/state';
import { binToHex, encodeTransaction, generateTransaction, hashTransaction, hexToBin } from '@bitauth/libauth';
import type { SSEvent } from '../utils/sse-client.js';
import type { SyncServer } from '../utils/sync-server.js';
import type { Storage } from './storage.js';
import type { ElectrumService } from './electrum.js';
import { EventEmitter } from '../utils/event-emitter.js'
import { decodeExtendedJsonObject } from '../utils/ext-json.js';
@@ -20,6 +22,7 @@ export type InvitationDependencies = {
syncServer: SyncServer;
storage: Storage;
engine: Engine;
electrum: ElectrumService;
}
export class Invitation extends EventEmitter<InvitationEventMap> {
@@ -87,16 +90,7 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
* TODO: This should be a composite with the sync server (probably. We currently double handle this work, which is stupid)
*/
private storage: Storage;
/**
* True after we have successfully called sign() on this invitation (session-only, not persisted).
*/
private _weHaveSigned = false;
/**
* True after we have successfully called broadcast() on this invitation (session-only, not persisted).
*/
private _broadcasted = false;
private electrum: ElectrumService;
/**
* The status of the invitation (last emitted word: pending, actionable, signed, ready, complete, expired, unknown).
@@ -116,6 +110,7 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
this.engine = dependencies.engine;
this.syncServer = dependencies.syncServer;
this.storage = dependencies.storage;
this.electrum = dependencies.electrum;
// I cannot express this enough, but the event handler does not need a clean up.
// There is this beautiful thing called a "garbage collector". Once this class is removed from scope (removed from the invitations array) all the references
@@ -217,21 +212,14 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
/**
* Internal status computation: returns a single word.
* NOTE: This could be a Enum-like object as well. May be a nice improvement. - DO NOT USE TS ENUM, THEY ARENT NATIVELY SUPPORTED IN NODE.JS
* - expired: any commit has expired
* - complete: we have broadcast this invitation
* - expired: any commit has expired
* - ready: no missing requirements and we have signed (ready to broadcast)
* - signed: we have signed but there are still missing parts (waiting for others)
* - actionable: you can provide data (missing requirements and/or you can sign)
* - unknown: template/action not found or error
*/
private async computeStatusInternal(): Promise<string> {
if (hasInvitationExpired(this.data)) {
return 'expired';
}
if (this._broadcasted) {
return 'complete';
}
let missingReqs;
try {
missingReqs = await this.engine.listMissingRequirements(this.data);
@@ -245,15 +233,74 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
(missingReqs.outputs?.length ?? 0) > 0 ||
(missingReqs.roles !== undefined && Object.keys(missingReqs.roles).length > 0);
if (!hasMissing && this._weHaveSigned) {
const hasSignedCommit = this.hasSignedCommitInInvitation();
if (!hasMissing) {
const transactionHash = await this.deriveTransactionHash();
if (transactionHash && await this.electrum.hasSeenTransaction(transactionHash)) {
return 'complete';
}
}
if (hasInvitationExpired(this.data)) {
return 'expired';
}
if (!hasMissing && hasSignedCommit) {
return 'ready';
}
if (hasMissing && this._weHaveSigned) {
if (hasMissing && hasSignedCommit) {
return 'signed';
}
return 'actionable';
}
private hasSignedCommitInInvitation(): boolean {
for (const commit of this.data.commits) {
for (const input of commit.data.inputs ?? []) {
if (!input.mergesWith) continue;
if (input.unlockingBytecode === undefined) continue;
return true;
}
}
return false;
}
/**
* Build the transaction to get the TX hash, this is so we can check its status on the blockchain.
* TODO: Remove this. This should be part of the engine. The code is virtually identical to `executeAction` except it doesnt throw if the invitation is expired
* @returns txHash or undefined if the transaction could not be built
*/
private async deriveTransactionHash(): Promise<string | undefined> {
try {
const template = await this.engine.getTemplate(this.data.templateIdentifier);
if (!template) return undefined;
const mergedCommit = mergeInvitationCommits(this.data, template);
if (!mergedCommit) return undefined;
const transactionResult = generateTransaction({
version: mergedCommit.transactionVersion,
locktime: mergedCommit.transactionLocktime,
// @ts-expect-error merged inputs include additional invitation metadata.
inputs: mergedCommit.inputs,
// @ts-expect-error merged outputs include additional invitation metadata.
outputs: mergedCommit.outputs,
});
if (!transactionResult.success) return undefined;
const transactionHex = binToHex(encodeTransaction(transactionResult.transaction));
const rawHash: unknown = hashTransaction(hexToBin(transactionHex));
if (typeof rawHash === 'string') return rawHash;
if (rawHash instanceof Uint8Array) return binToHex(rawHash);
return undefined;
} catch {
return undefined;
}
}
/**
* Update the status of the invitation and emit the new single-word status.
*/
@@ -291,7 +338,6 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
await this.storage.set(this.data.invitationIdentifier, signedInvitation);
this.data = signedInvitation;
this._weHaveSigned = true;
// Update the status of the invitation
await this.updateStatus();
@@ -306,8 +352,6 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
broadcastTransaction: true,
});
this._broadcasted = true;
// Update the status of the invitation
await this.updateStatus();
}

View File

@@ -137,7 +137,11 @@ function MainContent(): React.ReactElement {
if (dialog?.visible) return;
// Quit on 'q' or Ctrl+C
if (input === 'q' || (key.ctrl && input === 'c')) {
if (
// Commenting out 'q'. Its annoying me - It activates in text inputs.
// input === 'q'
(key.ctrl && input === 'c')
) {
appContext.exit();
exit();
}
@@ -179,8 +183,8 @@ function MainContent(): React.ReactElement {
export function App({ config }: AppProps): React.ReactElement {
const { exit } = useApp();
// Cleanup will be handled by React when components unmount
const handleExit = () => {
// Cleanup will be handled by React when components unmount
exit();
};

View File

@@ -4,7 +4,7 @@
import React, { useRef, useState } from 'react';
import { Box, Text, useInput, measureElement } from 'ink';
import TextInput from 'ink-text-input';
import TextInput from './TextInput.js';
import { colors } from '../theme.js';
/**
@@ -29,6 +29,7 @@ export function DialogWrapper({
borderColor = colors.primary,
children,
width = 60,
backgroundColor = colors.bg,
}: DialogWrapperProps): React.ReactElement {
const ref = useRef<any>(null);
const [height, setHeight] = useState<number | null>(null);
@@ -51,9 +52,12 @@ export function DialogWrapper({
flexDirection="column"
width={width}
height={height}
backgroundColor={backgroundColor}
>
{Array.from({ length: height }).map((_, i) => (
<Text key={i}>{' '.repeat(width)}</Text>
<Text key={i} backgroundColor={backgroundColor}>
{' '.repeat(width)}
</Text>
))}
</Box>
)}
@@ -67,6 +71,7 @@ export function DialogWrapper({
paddingX={2}
paddingY={1}
width={width}
backgroundColor={backgroundColor}
>
<Text color={borderColor} bold>
{title}

View File

@@ -4,7 +4,7 @@
import React from 'react';
import { Box, Text } from 'ink';
import TextInput from 'ink-text-input';
import TextInput from './TextInput.js';
import { colors } from '../theme.js';
/**

View File

@@ -9,7 +9,7 @@
import React, { useState, useMemo, useCallback } from 'react';
import { Box, Text, useInput } from 'ink';
import TextInput from 'ink-text-input';
import TextInput from './TextInput.js';
import { colors } from '../theme.js';
// =============================================================================

View File

@@ -116,9 +116,15 @@ interface LoadingProps {
}
export function Loading({ message = 'Loading...' }: LoadingProps): React.ReactElement {
// Simple spinner using Ink's spinner component
const Spinner = require('ink-spinner').default;
// Was using ink-spinner, but its not updated for react 19.
// Just putting nothing here for now
const Spinner = (props: any) => {
return (
<></>
);
};
return (
<Box>
<Text color={colors.primary}>

View File

@@ -0,0 +1,216 @@
import React, {useState, useEffect} from 'react';
import {Text, useInput} from 'ink';
import chalk from 'chalk';
import type {Except} from 'type-fest';
export type Props = {
/**
* Text to display when `value` is empty.
*/
readonly placeholder?: string;
/**
* Listen to user's input. Useful in case there are multiple input components
* at the same time and input must be "routed" to a specific component.
*/
readonly focus?: boolean; // eslint-disable-line react/boolean-prop-naming
/**
* Replace all chars and mask the value. Useful for password inputs.
*/
readonly mask?: string;
/**
* Whether to show cursor and allow navigation inside text input with arrow keys.
*/
readonly showCursor?: boolean; // eslint-disable-line react/boolean-prop-naming
/**
* Highlight pasted text
*/
readonly highlightPastedText?: boolean; // eslint-disable-line react/boolean-prop-naming
/**
* Value to display in a text input.
*/
readonly value: string;
/**
* Function to call when value updates.
*/
readonly onChange: (value: string) => void;
/**
* Function to call when `Enter` is pressed, where first argument is a value of the input.
*/
readonly onSubmit?: (value: string) => void;
};
function TextInput({
value: originalValue,
placeholder = '',
focus = true,
mask,
highlightPastedText = false,
showCursor = true,
onChange,
onSubmit,
}: Props) {
const [state, setState] = useState({
cursorOffset: (originalValue || '').length,
cursorWidth: 0,
});
const {cursorOffset, cursorWidth} = state;
useEffect(() => {
setState(previousState => {
if (!focus || !showCursor) {
return previousState;
}
const newValue = originalValue || '';
if (previousState.cursorOffset > newValue.length - 1) {
return {
cursorOffset: newValue.length,
cursorWidth: 0,
};
}
return previousState;
});
}, [originalValue, focus, showCursor]);
const cursorActualWidth = highlightPastedText ? cursorWidth : 0;
const value = mask ? mask.repeat(originalValue.length) : originalValue;
let renderedValue = value;
let renderedPlaceholder = placeholder ? chalk.grey(placeholder) : undefined;
// Fake mouse cursor, because it's too inconvenient to deal with actual cursor and ansi escapes
if (showCursor && focus) {
renderedPlaceholder =
placeholder.length > 0
? chalk.inverse(placeholder[0]) + chalk.grey(placeholder.slice(1))
: chalk.inverse(' ');
renderedValue = value.length > 0 ? '' : chalk.inverse(' ');
let i = 0;
for (const char of value) {
renderedValue +=
i >= cursorOffset - cursorActualWidth && i <= cursorOffset
? chalk.inverse(char)
: char;
i++;
}
if (value.length > 0 && cursorOffset === value.length) {
renderedValue += chalk.inverse(' ');
}
}
useInput(
(input, key) => {
if (
key.upArrow ||
key.downArrow ||
(key.ctrl && input === 'c') ||
key.tab ||
(key.shift && key.tab)
) {
return;
}
if (key.return) {
if (onSubmit) {
onSubmit(originalValue);
}
return;
}
let nextCursorOffset = cursorOffset;
let nextValue = originalValue;
let nextCursorWidth = 0;
if (key.leftArrow) {
if (showCursor) {
nextCursorOffset--;
}
} else if (key.rightArrow) {
if (showCursor) {
nextCursorOffset++;
}
} else if (key.backspace || key.delete) {
if (cursorOffset > 0) {
nextValue =
originalValue.slice(0, cursorOffset - 1) +
originalValue.slice(cursorOffset, originalValue.length);
nextCursorOffset--;
}
} else {
nextValue =
originalValue.slice(0, cursorOffset) +
input +
originalValue.slice(cursorOffset, originalValue.length);
nextCursorOffset += input.length;
if (input.length > 1) {
nextCursorWidth = input.length;
}
}
if (cursorOffset < 0) {
nextCursorOffset = 0;
}
if (cursorOffset > originalValue.length) {
nextCursorOffset = originalValue.length;
}
setState({
cursorOffset: nextCursorOffset,
cursorWidth: nextCursorWidth,
});
if (nextValue !== originalValue) {
onChange(nextValue);
}
},
{isActive: focus},
);
return (
<Text>
{placeholder
? value.length > 0
? renderedValue
: renderedPlaceholder
: renderedValue}
</Text>
);
}
export default TextInput;
type UncontrolledProps = {
/**
* Initial value.
*/
readonly initialValue?: string;
} & Except<Props, 'value' | 'onChange'>;
export function UncontrolledTextInput({
initialValue = '',
...props
}: UncontrolledProps) {
const [value, setValue] = useState(initialValue);
return <TextInput {...props} value={value} onChange={setValue} />;
}

View File

@@ -1,7 +1,6 @@
import React from "react";
import { Box, Text } from "ink";
import TextInput from "ink-text-input";
import { formatSatoshis } from "../theme.js";
import TextInput from "./TextInput.js";
interface VariableInputFieldProps {
variable: {

View File

@@ -6,7 +6,7 @@
import React, { useState, useCallback } from 'react';
import { Box, Text, useInput } from 'ink';
import TextInput from 'ink-text-input';
import TextInput from '../components/TextInput.js';
import { Button } from '../components/Button.js';
import { useNavigation } from '../hooks/useNavigation.js';
import { useAppContext, useStatus } from '../hooks/useAppContext.js';

View File

@@ -21,10 +21,7 @@ import type { XOTemplate } from '@xo-cash/types';
import {
formatTemplateListItem,
formatActionListItem,
deduplicateStartingActions,
getTemplateRoles,
getRolesForAction,
type UniqueStartingAction,
} from '../../utils/template-utils.js';
/**
@@ -33,7 +30,7 @@ import {
interface TemplateItem {
template: XOTemplate;
templateIdentifier: string;
startingActions: UniqueStartingAction[];
availableActions: TemplateActionItem[];
}
/**
@@ -42,9 +39,17 @@ interface TemplateItem {
type TemplateListItem = ListItemData<TemplateItem>;
/**
* Action list item with UniqueStartingAction value.
* Action list item with available action value.
*/
type ActionListItem = ListItemData<UniqueStartingAction>;
type ActionListItem = ListItemData<TemplateActionItem>;
interface TemplateActionItem {
actionIdentifier: string;
name: string;
description?: string;
roles: string[];
source: 'starting' | 'next' | 'starting+next';
}
/**
* Template List Screen Component.
@@ -76,19 +81,90 @@ export function TemplateListScreen(): React.ReactElement {
setStatus('Loading templates...');
const templateList = await appService.engine.listImportedTemplates();
const allUtxos = await appService.engine.listUnspentOutputsData();
const ownedOutputsByTemplate = new Map<string, Set<string>>();
for (const utxo of allUtxos) {
const existing = ownedOutputsByTemplate.get(utxo.templateIdentifier) ?? new Set<string>();
existing.add(utxo.outputIdentifier);
ownedOutputsByTemplate.set(utxo.templateIdentifier, existing);
}
const loadedTemplates = await Promise.all(
templateList.map(async (template) => {
const templateIdentifier = generateTemplateIdentifier(template);
const rawStartingActions = await appService.engine.listStartingActions(templateIdentifier);
const actionMap = new Map<string, TemplateActionItem>();
// Use utility function to deduplicate actions
const startingActions = deduplicateStartingActions(template, rawStartingActions);
for (const startingAction of rawStartingActions) {
const existing = actionMap.get(startingAction.action);
if (existing) {
if (!existing.roles.includes(startingAction.role)) {
existing.roles.push(startingAction.role);
}
continue;
}
const actionDef = template.actions?.[startingAction.action];
actionMap.set(startingAction.action, {
actionIdentifier: startingAction.action,
name: actionDef?.name || startingAction.action,
description: actionDef?.description,
roles: [startingAction.role],
source: 'starting',
});
}
const ownedOutputIdentifiers = ownedOutputsByTemplate.get(templateIdentifier) ?? new Set<string>();
for (const outputIdentifier of ownedOutputIdentifiers) {
const outputDef = template.outputs?.[outputIdentifier];
if (!outputDef || typeof outputDef.lockscript !== 'string') continue;
const lockingScriptDefinition = (template.lockingScripts as Record<string, unknown> | undefined)?.[outputDef.lockscript] as
| { roles?: Record<string, { actions?: Array<{ action?: string; role?: string } | string> }> }
| undefined;
if (!lockingScriptDefinition?.roles) continue;
for (const [lockscriptRoleId, lockscriptRoleDef] of Object.entries(lockingScriptDefinition.roles)) {
for (const actionSpec of lockscriptRoleDef.actions ?? []) {
const actionIdentifier = typeof actionSpec === 'string'
? actionSpec
: actionSpec.action;
if (!actionIdentifier) continue;
const roleIdentifier = typeof actionSpec === 'string'
? lockscriptRoleId
: (actionSpec.role ?? lockscriptRoleId);
const existing = actionMap.get(actionIdentifier);
if (existing) {
if (!existing.roles.includes(roleIdentifier)) {
existing.roles.push(roleIdentifier);
}
if (existing.source === 'starting') {
existing.source = 'starting+next';
}
continue;
}
const actionDef = template.actions?.[actionIdentifier];
actionMap.set(actionIdentifier, {
actionIdentifier,
name: actionDef?.name || actionIdentifier,
description: actionDef?.description,
roles: [roleIdentifier],
source: 'next',
});
}
}
}
const availableActions = Array.from(actionMap.values()).sort((a, b) => a.name.localeCompare(b.name));
return {
template,
templateIdentifier,
startingActions,
availableActions,
};
})
);
@@ -111,7 +187,7 @@ export function TemplateListScreen(): React.ReactElement {
// Get current template and its actions
const currentTemplate = templates[selectedTemplateIndex];
const currentActions = currentTemplate?.startingActions ?? [];
const currentActions = currentTemplate?.availableActions ?? [];
/**
* Build template list items for ScrollableList.
@@ -137,12 +213,17 @@ export function TemplateListScreen(): React.ReactElement {
const formatted = formatActionListItem(
action.actionIdentifier,
currentTemplate?.template?.actions?.[action.actionIdentifier],
action.roleCount,
action.roles.length,
index
);
const sourceSuffix = action.source === 'next'
? ' [next]'
: action.source === 'starting+next'
? ' [start+next]'
: '';
return {
key: action.actionIdentifier,
label: formatted.label,
label: `${formatted.label}${sourceSuffix}`,
description: formatted.description,
value: action,
hidden: !formatted.isValid,
@@ -171,6 +252,7 @@ export function TemplateListScreen(): React.ReactElement {
navigate('wizard', {
templateIdentifier: currentTemplate.templateIdentifier,
actionIdentifier: action.actionIdentifier,
actionRoles: action.roles,
template: currentTemplate.template,
});
}, [currentTemplate, navigate]);
@@ -267,7 +349,7 @@ export function TemplateListScreen(): React.ReactElement {
paddingX={1}
flexGrow={1}
>
<Text color={colors.primary} bold> Starting Actions </Text>
<Text color={colors.primary} bold> Available Actions </Text>
{isLoading ? (
<Box marginTop={1}>
<Text color={colors.textMuted}>Loading...</Text>
@@ -283,7 +365,7 @@ export function TemplateListScreen(): React.ReactElement {
onSelect={setSelectedActionIndex}
onActivate={handleActionActivate}
focus={focusedPanel === 'actions'}
emptyMessage="No starting actions available"
emptyMessage="No actions available"
renderItem={renderActionItem}
/>
)}
@@ -339,9 +421,6 @@ export function TemplateListScreen(): React.ReactElement {
const action = currentActions[selectedActionIndex];
if (!action) return null;
// Get roles that can start this action using utility function
const availableRoles = getRolesForAction(currentTemplate.template, action.actionIdentifier);
return (
<>
<Text color={colors.text} bold>
@@ -351,16 +430,24 @@ export function TemplateListScreen(): React.ReactElement {
{action.description || 'No description available'}
</Text>
{/* List available roles for this action */}
{availableRoles.length > 0 && (
{/* List roles available for this action in current context */}
{action.roles.length > 0 && (
<Box marginTop={1} flexDirection="column">
<Text color={colors.text}>Available Roles:</Text>
{availableRoles.map((role) => (
<Text key={role.roleId} color={colors.textMuted}>
{' '}- {role.name}
{role.description ? `: ${role.description}` : ''}
</Text>
))}
{action.roles.map((roleId) => {
const roleDef = currentTemplate.template.roles?.[roleId];
const roleName = typeof roleDef === 'object' ? roleDef?.name ?? roleId : roleId;
const roleDescription = typeof roleDef === 'object' ? roleDef?.description : undefined;
return (
<Text key={roleId} color={colors.textMuted}>
{' '}- {roleName}
{roleDescription ? `: ${roleDescription}` : ''}
</Text>
);
})}
<Text color={colors.textMuted}>
{' '}Source: {action.source}
</Text>
</Box>
)}
</>
@@ -370,7 +457,7 @@ export function TemplateListScreen(): React.ReactElement {
) : focusedPanel === 'actions' && !currentTemplate ? (
<Text color={colors.textMuted}>Select a template first</Text>
) : focusedPanel === 'actions' && currentActions.length === 0 ? (
<Text color={colors.textMuted}>No starting actions available</Text>
<Text color={colors.textMuted}>No actions available</Text>
) : null}
</Box>
</Box>

View File

@@ -19,9 +19,10 @@ import { generateTemplateIdentifier } from '@xo-cash/engine';
// Import utility functions
import {
formatHistoryListItem,
buildHistoryDisplayRows,
getHistoryItemColorName,
formatHistoryDate,
type HistoryDisplayRow,
type HistoryColorName,
} from '../../utils/history-utils.js';
@@ -58,9 +59,9 @@ const menuItems: ListItemData<string>[] = [
];
/**
* History list item with HistoryItem value.
* History list item with display row value.
*/
type HistoryListItem = ListItemData<HistoryItem>;
type HistoryListItem = ListItemData<HistoryDisplayRow>;
/**
* Wallet State Screen Component.
@@ -196,15 +197,14 @@ export function WalletStateScreen(): React.ReactElement {
* Build history list items for ScrollableList.
*/
const historyListItems = useMemo((): HistoryListItem[] => {
return history.map(item => {
const formatted = formatHistoryListItem(item, false);
return buildHistoryDisplayRows(history).map(row => {
return {
key: item.id,
label: formatted.label,
description: formatted.description,
value: item,
color: formatted.color,
hidden: !formatted.isValid,
key: row.id,
label: row.label,
description: row.description,
value: row,
color: getHistoryItemColorName(row, false),
hidden: false,
};
});
}, [history]);
@@ -224,49 +224,63 @@ export function WalletStateScreen(): React.ReactElement {
isSelected: boolean,
isFocused: boolean
): React.ReactNode => {
const historyItem = item.value;
if (!historyItem) return null;
const row = item.value;
if (!row) return null;
const colorName = getHistoryItemColorName(historyItem.type, isFocused);
const colorName = getHistoryItemColorName(row, isFocused);
const itemColor = isFocused ? colors.focus : getHistoryColor(colorName);
const dateStr = formatHistoryDate(historyItem.timestamp);
const dateStr = formatHistoryDate(row.timestamp);
const indicator = isFocused ? '▸ ' : ' ';
const groupingPrefix = row.isNested ? ' -> ' : '';
// Format based on type
if (historyItem.type === 'invitation_created') {
if (row.type === 'invitation') {
return (
<Box flexDirection="row" justifyContent="space-between">
<Text color={itemColor}>
{indicator}[Invitation] {historyItem.description}
{indicator}[Invitation] {row.label}
</Text>
{dateStr && <Text color={colors.textMuted}>{dateStr}</Text>}
</Box>
);
} else if (historyItem.type === 'utxo_reserved') {
const sats = historyItem.valueSatoshis ?? 0n;
}
if (row.type === 'invitation_input') {
return (
<Box flexDirection="row" justifyContent="space-between">
<Box>
<Text color={itemColor}>
{indicator}[Reserved] {formatSatoshis(sats)}
{indicator}{groupingPrefix}[Input] {row.label}
</Text>
<Text color={colors.textMuted}> {historyItem.description}</Text>
{row.description && <Text color={colors.textMuted}> {row.description}</Text>}
</Box>
{dateStr && <Text color={colors.textMuted}>{dateStr}</Text>}
</Box>
);
} else if (historyItem.type === 'utxo_received') {
const sats = historyItem.valueSatoshis ?? 0n;
const reservedTag = historyItem.reserved ? ' [Reserved]' : '';
}
if (row.type === 'invitation_output') {
const sats = row.utxo?.valueSatoshis ?? 0n;
return (
<Box flexDirection="row" justifyContent="space-between">
<Box flexDirection="row">
<Text color={itemColor}>
{indicator}{formatSatoshis(sats)}
</Text>
<Text color={colors.textMuted}>
{' '}{historyItem.description}{reservedTag}
{indicator}{groupingPrefix}[Output] {formatSatoshis(sats)}
</Text>
{row.description && <Text color={colors.textMuted}> {row.description}</Text>}
</Box>
{dateStr && <Text color={colors.textMuted}>{dateStr}</Text>}
</Box>
);
}
if (row.type === 'utxo') {
const sats = row.utxo?.valueSatoshis ?? 0n;
const reservedTag = row.utxo?.reserved ? ' [Reserved]' : '';
return (
<Box flexDirection="row" justifyContent="space-between">
<Box flexDirection="row">
<Text color={itemColor}>{indicator}{formatSatoshis(sats)}</Text>
{row.description && <Text color={colors.textMuted}> {row.description}{reservedTag}</Text>}
</Box>
{dateStr && <Text color={colors.textMuted}>{dateStr}</Text>}
</Box>
@@ -277,7 +291,7 @@ export function WalletStateScreen(): React.ReactElement {
return (
<Box flexDirection="row" justifyContent="space-between">
<Text color={itemColor}>
{indicator}{historyItem.type}: {historyItem.description}
{indicator}{row.label}
</Text>
{dateStr && <Text color={colors.textMuted}>{dateStr}</Text>}
</Box>

View File

@@ -205,7 +205,13 @@ export function ActionWizardScreen(): React.ReactElement {
/>
);
case 'publish':
return <PublishStep invitationId={wizard.invitationId} />;
return (
<PublishStep
invitationId={wizard.invitationId}
requirementsComplete={wizard.requirementsComplete}
hasSignedAndBroadcasted={wizard.hasSignedAndBroadcasted}
/>
);
default:
return null;
}
@@ -284,7 +290,9 @@ export function ActionWizardScreen(): React.ReactElement {
</Box>
<Button
label={
wizard.currentStepData?.type === "publish" ? "Done" : "Next"
wizard.currentStepData?.type === "publish"
? (wizard.canSignAndBroadcast ? "Sign & Broadcast" : "Done")
: "Next"
}
focused={
wizard.focusArea === "buttons" &&

View File

@@ -4,15 +4,19 @@ import { colors } from '../../../theme.js';
interface PublishStepProps {
invitationId: string | null;
requirementsComplete: boolean;
hasSignedAndBroadcasted: boolean;
}
export function PublishStep({
invitationId,
requirementsComplete,
hasSignedAndBroadcasted,
}: PublishStepProps): React.ReactElement {
return (
<Box flexDirection='column'>
<Text color={colors.success} bold>
Invitation Created & Published!
Invitation Ready
</Text>
<Box marginTop={1} flexDirection='column'>
@@ -30,9 +34,19 @@ export function PublishStep({
</Box>
<Box marginTop={1}>
<Text color={colors.textMuted}>
Share this ID with the other party to complete the transaction.
</Text>
{hasSignedAndBroadcasted ? (
<Text color={colors.success}>
Transaction signed and broadcasted.
</Text>
) : requirementsComplete ? (
<Text color={colors.textMuted}>
Requirements are complete. Use the Sign & Broadcast button to finalize.
</Text>
) : (
<Text color={colors.warning}>
Requirements are incomplete. Complete missing requirements before signing.
</Text>
)}
</Box>
<Box marginTop={1}>

View File

@@ -4,6 +4,15 @@ import { useAppContext, useStatus } from '../../hooks/useAppContext.js';
import { formatSatoshis } from '../../theme.js';
import { copyToClipboard } from '../../utils/clipboard.js';
import type { XOTemplate, XOInvitation, XOTemplateTransactionOutput } from '@xo-cash/types';
import {
autoSelectGreedyUtxos,
getTransactionOutputIdentifier,
isInvitationRequirementsComplete,
mapUnspentOutputsToSelectable,
resolveActionRoles,
resolveProvidedLockingBytecodeHex,
roleRequiresInputs,
} from '../../../utils/invitation-flow.js';
import type {
WizardStep,
VariableInput,
@@ -22,6 +31,7 @@ export function useActionWizard() {
const templateIdentifier = navData.templateIdentifier as string | undefined;
const actionIdentifier = navData.actionIdentifier as string | undefined;
const template = navData.template as XOTemplate | undefined;
const actionRolesFromNavigation = navData.actionRoles as string[] | undefined;
// ── Role selection state ────────────────────────────────────────
const [roleIdentifier, setRoleIdentifier] = useState<string | undefined>();
@@ -32,14 +42,20 @@ export function useActionWizard() {
* `start` entries filtered to the current action.
*/
const availableRoles = useMemo(() => {
if (!template || !actionIdentifier) return [];
const starts = template.start ?? [];
const roleIds = starts
.filter((s) => s.action === actionIdentifier)
.map((s) => s.role);
// Deduplicate while preserving order
return [...new Set(roleIds)];
}, [template, actionIdentifier]);
return resolveActionRoles(template, actionIdentifier, actionRolesFromNavigation);
}, [template, actionIdentifier, actionRolesFromNavigation]);
const effectiveRoleForFlow = roleIdentifier ?? (
availableRoles.length === 1 ? availableRoles[0] : undefined
);
// Keep role state aligned when only one role exists for the selected action.
// This preserves existing UI bindings that read roleIdentifier directly.
useEffect(() => {
if (!roleIdentifier && availableRoles.length === 1) {
setRoleIdentifier(availableRoles[0]);
}
}, [roleIdentifier, availableRoles]);
// ── Wizard state ─────────────────────────────────────────────────
const [steps, setSteps] = useState<WizardStep[]>([]);
@@ -57,6 +73,8 @@ export function useActionWizard() {
// ── Invitation ───────────────────────────────────────────────────
const [invitation, setInvitation] = useState<XOInvitation | null>(null);
const [invitationId, setInvitationId] = useState<string | null>(null);
const [requirementsComplete, setRequirementsComplete] = useState(false);
const [hasSignedAndBroadcasted, setHasSignedAndBroadcasted] = useState(false);
// ── UI state ─────────────────────────────────────────────────────
const [focusedInput, setFocusedInput] = useState(0);
@@ -78,9 +96,19 @@ export function useActionWizard() {
const textInputHasFocus =
currentStepData?.type === 'variables' && focusArea === 'content';
// Whether the wizard actually includes an inputs step — this determines if
// the creator provided funding and therefore can sign & broadcast locally.
const wizardCollectedInputs = steps.some((s) => s.type === 'inputs');
const canSignAndBroadcast =
currentStepData?.type === 'publish'
&& wizardCollectedInputs
&& requirementsComplete
&& !hasSignedAndBroadcasted;
// ── Initialization ───────────────────────────────────────────────
// Builds the wizard steps dynamically based on the selected role.
// Re-runs when roleIdentifier changes to add role-specific steps.
// Re-runs when role selection changes to add role-specific steps.
useEffect(() => {
if (!template || !actionIdentifier) {
showError('Missing wizard data');
@@ -89,14 +117,17 @@ export function useActionWizard() {
}
const wizardSteps: WizardStep[] = [];
const shouldShowRoleSelection = availableRoles.length > 1;
// Always start with role selection
wizardSteps.push({ name: 'Select Role', type: 'role-select' });
// Only require explicit role selection when the action is actually ambiguous.
if (shouldShowRoleSelection) {
wizardSteps.push({ name: 'Select Role', type: 'role-select' });
}
// Add role-specific steps only after role is selected
if (roleIdentifier) {
if (effectiveRoleForFlow) {
const act = template.actions?.[actionIdentifier];
const role = act?.roles?.[roleIdentifier];
const role = act?.roles?.[effectiveRoleForFlow];
const requirements = role?.requirements;
// Add variables step if needed
@@ -116,8 +147,23 @@ export function useActionWizard() {
setVariables(varInputs);
}
// Add inputs step if role requires slots (funding inputs)
if (requirements?.slots && requirements.slots.min > 0) {
// Determine whether the creator should provide inputs during this wizard.
//
// Single-role actions (e.g. "send"): the creator is the sole participant,
// so we collect inputs here if the role needs them at all.
//
// Multi-role actions (e.g. "receive"): the creator is setting up the
// invitation for another party to accept. We only collect inputs during
// creation if the role EXPLICITLY requires them (slots.min > 0).
// Implicit inputs (transaction-level) are assumed to be provided later
// by the accepting party.
const totalActionRoles = Object.keys(act?.roles ?? {}).length;
const isSingleRoleAction = totalActionRoles <= 1;
const shouldCollectInputs =
isSingleRoleAction && roleRequiresInputs(template, actionIdentifier, effectiveRoleForFlow);
if (shouldCollectInputs) {
wizardSteps.push({ name: 'Select UTXOs', type: 'inputs' });
}
}
@@ -127,11 +173,12 @@ export function useActionWizard() {
wizardSteps.push({ name: 'Publish', type: 'publish' });
setSteps(wizardSteps);
setStatus(roleIdentifier ? `${actionIdentifier}/${roleIdentifier}` : actionIdentifier);
setStatus(effectiveRoleForFlow ? `${actionIdentifier}/${effectiveRoleForFlow}` : actionIdentifier);
}, [
template,
actionIdentifier,
roleIdentifier,
availableRoles.length,
effectiveRoleForFlow,
showError,
goBack,
setStatus,
@@ -141,12 +188,12 @@ export function useActionWizard() {
// This runs after the main useEffect has rebuilt steps, ensuring
// we advance to the correct step (variables, inputs, or review).
useEffect(() => {
if (roleIdentifier && currentStep === 0 && steps[0]?.type === 'role-select') {
if (effectiveRoleForFlow && currentStep === 0 && steps[0]?.type === 'role-select') {
setCurrentStep(1);
setFocusArea('content');
setFocusedInput(0);
}
}, [roleIdentifier, currentStep, steps]);
}, [effectiveRoleForFlow, currentStep, steps]);
// ── Update a single variable value ───────────────────────────────
const updateVariable = useCallback((index: number, value: string) => {
@@ -195,6 +242,25 @@ export function useActionWizard() {
}
}, [invitationId, showInfo, showError]);
const refreshRequirementState = useCallback(async (identifier: string | null = invitationId) => {
if (!identifier || !appService) {
setRequirementsComplete(false);
return false;
}
const invitationInstance = appService.invitations.find(
(inv) => inv.data.invitationIdentifier === identifier
);
if (!invitationInstance) {
setRequirementsComplete(false);
return false;
}
const complete = await isInvitationRequirementsComplete(invitationInstance);
setRequirementsComplete(complete);
return complete;
}, [appService, invitationId]);
// ── Load available UTXOs for the inputs step ────────────────────
const loadAvailableUtxos = useCallback(async () => {
if (!invitation || !templateIdentifier || !appService || !invitationId) {
@@ -225,49 +291,19 @@ export function useActionWizard() {
throw new Error('Invitation not found');
}
// Query for suitable resources
// Query for suitable resources.
// NOTE: Even for single-role actions we still keep the user in the loop for inputs:
// we only surface UTXOs the engine/template currently considers "selectable" and let
// the user confirm them in the inputs step. If selectable semantics evolve, revisit here.
const unspentOutputs = await invitationInstance.findSuitableResources({
templateIdentifier,
outputIdentifier: 'receiveOutput',
});
// Map to selectable UTXOs
const utxos: SelectableUTXO[] = unspentOutputs.map((utxo: any) => ({
outpointTransactionHash: utxo.outpointTransactionHash,
outpointIndex: utxo.outpointIndex,
valueSatoshis: BigInt(utxo.valueSatoshis),
lockingBytecode: utxo.lockingBytecode
? typeof utxo.lockingBytecode === 'string'
? utxo.lockingBytecode
: Buffer.from(utxo.lockingBytecode).toString('hex')
: undefined,
selected: false,
}));
// Auto-select UTXOs greedily until the requirement is met
let accumulated = 0n;
const seenLockingBytecodes = new Set<string>();
for (const utxo of utxos) {
if (
utxo.lockingBytecode &&
seenLockingBytecodes.has(utxo.lockingBytecode)
) {
continue;
}
if (utxo.lockingBytecode) {
seenLockingBytecodes.add(utxo.lockingBytecode);
}
utxo.selected = true;
accumulated += utxo.valueSatoshis;
if (accumulated >= requested + fee) {
break;
}
}
setAvailableUtxos(utxos);
// Map to selectable UTXOs and pre-select greedily.
const mappedUtxos = mapUnspentOutputsToSelectable(unspentOutputs);
const autoSelectedUtxos = autoSelectGreedyUtxos(mappedUtxos, requested + fee);
setAvailableUtxos(autoSelectedUtxos as SelectableUTXO[]);
setStatus('Ready');
} catch (error) {
showError(
@@ -301,7 +337,7 @@ export function useActionWizard() {
*/
const createInvitationWithVariables = useCallback(
async (roleId?: string): Promise<boolean> => {
const effectiveRole = roleId ?? roleIdentifier;
const effectiveRole = roleId ?? effectiveRoleForFlow;
if (
!templateIdentifier ||
@@ -350,6 +386,14 @@ export function useActionWizard() {
inv = invitationInstance.data;
}
const variableValuesByIdentifier = variables.reduce((acc, variable) => {
if (typeof variable.value === 'string' && variable.value.trim().length > 0) {
acc[variable.id] = variable.value;
}
return acc;
}, {} as Record<string, string>);
// Add template-required outputs for the current role
const act = template.actions?.[actionIdentifier];
const transaction = act?.transaction
@@ -358,17 +402,26 @@ export function useActionWizard() {
if (transaction?.outputs && transaction.outputs.length > 0) {
setStatus('Adding required outputs...');
const outputsToAdd = await Promise.all(transaction.outputs.map(async (output: XOTemplateTransactionOutput) => {
const outputIdentifier = getTransactionOutputIdentifier(output);
if (!outputIdentifier) {
throw new Error('Invalid transaction output definition');
}
const outputsToAdd = await Promise.all(transaction.outputs.map(
async (output: XOTemplateTransactionOutput) => ({
// TODO: Fix this. Currently, there is a type mismatch due to branches/versions of the libraries
outputIdentifier: output as unknown as string,
// roleIdentifier: roleIdentifier,
const providedLockingBytecodeHex = resolveProvidedLockingBytecodeHex(
template,
outputIdentifier,
variableValuesByIdentifier,
);
// TODO: This feels like an odd requirement? Shouldnt this be handled in the engine?
lockingBytecode: await invitationInstance.generateLockingBytecode(output as unknown as string, roleIdentifier),
})
));
const lockingBytecodeHex = providedLockingBytecodeHex
?? await invitationInstance.generateLockingBytecode(outputIdentifier, effectiveRole);
return {
outputIdentifier,
lockingBytecode: lockingBytecodeHex,
};
}));
// TODO: Clean this up. Suggestions: 1. Convert to bytes above. 2. Have addOuputs accept a hex string. 3. Have addOutputs handling the lockscript generation
await invitationInstance.addOutputs(outputsToAdd.map((output) => ({
@@ -381,6 +434,7 @@ export function useActionWizard() {
}
setInvitation(inv);
await refreshRequirementState(invId);
setStatus('Invitation created');
return true;
} catch (error) {
@@ -395,15 +449,51 @@ export function useActionWizard() {
[
templateIdentifier,
actionIdentifier,
roleIdentifier,
effectiveRoleForFlow,
template,
variables,
appService,
showError,
setStatus,
refreshRequirementState,
]
);
// Ensure invitation exists before entering input/review/publish stages.
useEffect(() => {
const ensureInvitation = async () => {
if (!currentStepData) return;
if (currentStepData.type !== 'inputs' && currentStepData.type !== 'review' && currentStepData.type !== 'publish') {
return;
}
if (invitationId) {
if (currentStepData.type === 'inputs' && availableUtxos.length === 0 && !isProcessing) {
await loadAvailableUtxos();
}
return;
}
if (!effectiveRoleForFlow || isProcessing) return;
const success = await createInvitationWithVariables(effectiveRoleForFlow);
if (!success) return;
if (currentStepData.type === 'inputs') {
await loadAvailableUtxos();
}
};
ensureInvitation().catch(() => {});
}, [
currentStepData,
invitationId,
effectiveRoleForFlow,
isProcessing,
createInvitationWithVariables,
loadAvailableUtxos,
availableUtxos.length,
]);
// ── Add selected inputs + change output to the invitation ───────
const addInputsAndOutputs = useCallback(async () => {
if (!invitationId || !invitation || !appService) return;
@@ -459,6 +549,7 @@ export function useActionWizard() {
];
await invitationInstance.addOutputs(outputs);
await refreshRequirementState(invitationId);
setCurrentStep((prev) => prev + 1);
setStatus('Inputs and outputs added');
@@ -480,14 +571,15 @@ export function useActionWizard() {
appService,
showError,
setStatus,
refreshRequirementState,
]);
// ── Publish the invitation ──────────────────────────────────────
const publishInvitation = useCallback(async () => {
// ── Move to publish step ────────────────────────────────────────
const advanceToPublishStep = useCallback(async () => {
if (!invitationId || !appService) return;
setIsProcessing(true);
setStatus('Publishing invitation...');
setStatus('Preparing publish step...');
try {
const invitationInstance = appService.invitations.find(
@@ -498,23 +590,61 @@ export function useActionWizard() {
throw new Error('Invitation not found');
}
// Already tracked and synced via SSE from createInvitation
await refreshRequirementState(invitationId);
setCurrentStep((prev) => prev + 1);
setStatus('Invitation published');
setStatus('Ready to publish');
} catch (error) {
showError(
`Failed to publish: ${error instanceof Error ? error.message : String(error)}`
`Failed to prepare publish step: ${error instanceof Error ? error.message : String(error)}`
);
} finally {
setIsProcessing(false);
}
}, [invitationId, appService, showError, setStatus]);
}, [invitationId, appService, showError, setStatus, refreshRequirementState]);
// ── Sign and broadcast from publish step ────────────────────────
const signAndBroadcastInvitation = useCallback(async () => {
if (!invitationId || !appService) return;
setIsProcessing(true);
setStatus('Signing invitation...');
try {
const invitationInstance = appService.invitations.find(
(inv) => inv.data.invitationIdentifier === invitationId
);
if (!invitationInstance) {
throw new Error('Invitation not found');
}
const complete = await refreshRequirementState(invitationId);
if (!complete) {
showError('Invitation requirements are not complete yet.');
return;
}
if (!wizardCollectedInputs) {
showError('This action does not require funding inputs, so it cannot be signed and broadcasted here.');
return;
}
await invitationInstance.sign();
setStatus('Broadcasting transaction...');
await invitationInstance.broadcast();
setHasSignedAndBroadcasted(true);
setStatus('Transaction signed and broadcasted');
showInfo('Transaction signed and broadcasted.');
await refreshRequirementState(invitationId);
} catch (error) {
showError(`Failed to sign and broadcast: ${error instanceof Error ? error.message : String(error)}`);
} finally {
setIsProcessing(false);
}
}, [invitationId, appService, setStatus, showError, showInfo, refreshRequirementState, wizardCollectedInputs]);
// ── Navigate to the next step ───────────────────────────────────
const nextStep = useCallback(async () => {
if (currentStep >= steps.length - 1) return;
const stepType = currentStepData?.type;
if (currentStep >= steps.length - 1 && stepType !== 'publish') return;
// ── Role selection ──────────────────────────────────────────
if (stepType === 'role-select') {
@@ -531,7 +661,19 @@ export function useActionWizard() {
const hasVariables =
requirements?.variables && requirements.variables.length > 0;
const hasSlots = requirements?.slots && requirements.slots.min > 0;
// Mirror the inputs-step inference from the step-building effect:
// single-role → any inputs; multi-role → explicit requirements only.
const totalActionRoles = Object.keys(act?.roles ?? {}).length;
const roleExplicitlyNeedsInputs =
(requirements?.slots && requirements.slots.min > 0)
|| (act?.requirements?.roles?.find(
(r: { role: string; slots?: { min?: number } }) => r.role === selectedRole,
)?.slots?.min ?? 0) > 0;
const hasSlots = totalActionRoles <= 1
? roleRequiresInputs(template, actionIdentifier, selectedRole)
: roleExplicitlyNeedsInputs;
// If there is no variables step, the invitation must be created now
// because the variables step would normally handle it.
@@ -582,17 +724,38 @@ export function useActionWizard() {
// ── Inputs ──────────────────────────────────────────────────
if (stepType === 'inputs') {
if (!invitationId) {
const success = await createInvitationWithVariables();
if (!success) return;
await loadAvailableUtxos();
return;
}
await addInputsAndOutputs();
return;
}
// ── Review ──────────────────────────────────────────────────
if (stepType === 'review') {
await publishInvitation();
if (!invitationId) {
const success = await createInvitationWithVariables();
if (!success) return;
}
await advanceToPublishStep();
return;
}
// ── Generic advance (e.g. publish → done) ───────────────────
// ── Publish ─────────────────────────────────────────────────
if (stepType === 'publish') {
if (canSignAndBroadcast) {
await signAndBroadcastInvitation();
return;
}
// Done should exit the wizard, not advance past the final step.
goBack();
return;
}
// ── Generic advance ─────────────────────────────────────────
setCurrentStep((prev) => prev + 1);
setFocusArea('content');
setFocusedInput(0);
@@ -600,6 +763,7 @@ export function useActionWizard() {
currentStep,
steps,
currentStepData,
canSignAndBroadcast,
availableRoles,
selectedRoleIndex,
template,
@@ -609,7 +773,11 @@ export function useActionWizard() {
createInvitationWithVariables,
loadAvailableUtxos,
addInputsAndOutputs,
publishInvitation,
advanceToPublishStep,
requirementsComplete,
hasSignedAndBroadcasted,
signAndBroadcastInvitation,
goBack,
]);
// ── Navigate to the previous step ──────────────────────────────
@@ -667,6 +835,9 @@ export function useActionWizard() {
// Invitation
invitation,
invitationId,
requirementsComplete,
hasSignedAndBroadcasted,
canSignAndBroadcast,
// UI focus
focusedInput,

View File

@@ -6,10 +6,11 @@
* Shows required, selected, and change amounts.
*/
import React, { useState, useEffect, useCallback } from 'react';
import React, { useState, useEffect, useCallback, useRef } from 'react';
import { Box, Text, useInput } from 'ink';
import { colors, formatSatoshis } from '../../../../theme.js';
import type { InputsSelectStepProps, SelectableUTXO } from '../types.js';
import { autoSelectGreedyUtxos, mapUnspentOutputsToSelectable } from '../../../../../utils/invitation-flow.js';
/** Default fee estimate in satoshis. */
const DEFAULT_FEE = 500n;
@@ -64,34 +65,9 @@ export function InputsSelectStep({
outputIdentifier: 'receiveOutput',
});
// Map to selectable UTXOs
const selectable: SelectableUTXO[] = unspentOutputs.map((utxo: any) => ({
outpointTransactionHash: utxo.outpointTransactionHash,
outpointIndex: utxo.outpointIndex,
valueSatoshis: BigInt(utxo.valueSatoshis),
lockingBytecode: utxo.lockingBytecode
? typeof utxo.lockingBytecode === 'string'
? utxo.lockingBytecode
: Buffer.from(utxo.lockingBytecode).toString('hex')
: undefined,
selected: false,
}));
// Greedy auto-select, skipping duplicate locking bytecodes
let accumulated = 0n;
const seenBytecodes = new Set<string>();
for (const utxo of selectable) {
if (utxo.lockingBytecode && seenBytecodes.has(utxo.lockingBytecode)) continue;
if (utxo.lockingBytecode) seenBytecodes.add(utxo.lockingBytecode);
utxo.selected = true;
accumulated += utxo.valueSatoshis;
if (accumulated >= required + fee) break;
}
setUtxos(selectable);
const selectable = mapUnspentOutputsToSelectable(unspentOutputs);
const autoSelected = autoSelectGreedyUtxos(selectable, required + fee);
setUtxos(autoSelected as SelectableUTXO[]);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
@@ -99,9 +75,15 @@ export function InputsSelectStep({
}
}, [invitation, computeRequiredAmount, fee]);
// Load UTXOs on mount
// Load UTXOs once on mount. We use a ref guard to prevent re-firing when
// `loadUtxos` identity changes due to parent re-renders — each re-fire
// flashes the loading state, causing the visible flicker bug.
const hasLoadedRef = useRef(false);
useEffect(() => {
if (isActive) loadUtxos();
if (isActive && !hasLoadedRef.current) {
hasLoadedRef.current = true;
loadUtxos();
}
}, [isActive, loadUtxos]);
/**

View File

@@ -2,6 +2,7 @@
* Cross-platform clipboard utility with multiple fallback methods.
*/
import clipboardy from 'clipboardy';
import { exec } from 'child_process';
import { promisify } from 'util';
@@ -50,8 +51,7 @@ export async function copyToClipboard(text: string): Promise<void> {
// Fallback to clipboardy
try {
const clipboard = await import('clipboardy');
await clipboard.default.write(text);
clipboardy.writeSync(text);
return;
} catch {
// clipboardy also failed

View File

@@ -0,0 +1,170 @@
/*
* Handles BCH Mnemonic parsing to/from URL form.
* Pulled directly from the old stack package.
*/
import { z } from 'zod';
export type BCHMnemonicURLRaw = {
entropy: Uint8Array;
passphrase?: string;
language?: (typeof BCHMnemonicURL.SUPPORTED_LANGUAGES)[number];
comment?: string;
path?: string;
startHeight?: number;
};
/**
* Handles BCHMnemonic URLs
*/
export class BCHMnemonicURL {
static PROTOCOL = 'bch-mnemonic';
/**
* Check if a URL is a valid wallet backup URL
*
* @param url The URL to check
* @returns True if the URL is a valid wallet backup URL, false otherwise
*/
public static canHandle(urlStr: string): boolean {
try {
BCHMnemonicURL.fromURL(urlStr);
return true;
} catch (_error) {
return false;
}
}
/**
* Creates a BCHMnemonic from a URL-encoded string
* @param urlStr - The URL-encoded mnemonic string
* @returns A new BCHMnemonic instance
* @throws Error if the URL format is invalid or entropy is invalid
*/
static fromURL(urlStr: string): BCHMnemonicURL {
const url = new URL(urlStr);
if (url.protocol !== `${BCHMnemonicURL.PROTOCOL}:`) {
throw new Error(`Invalid URL protocol: ${url.protocol}`);
}
// Decode the entropy.
const entropy = new Uint8Array(Buffer.from(url.pathname, 'base64'));
// Pick out our encoding keys from the URL
const params = BCHMnemonicURL.schema.parse(
Object.fromEntries(url.searchParams.entries()),
);
// Create and return the backup with validated parameters
return BCHMnemonicURL.fromRaw({
entropy,
language: params[BCHMnemonicURL.ENCODING_KEYS.language],
comment: params[BCHMnemonicURL.ENCODING_KEYS.comment],
path: params[BCHMnemonicURL.ENCODING_KEYS.path],
startHeight: params[BCHMnemonicURL.ENCODING_KEYS.startHeight],
});
}
/**
* Create a new WalletBackup from a raw object
*
* @param raw - The raw object to create the WalletBackup from
* @returns The created WalletBackup
*/
static fromRaw(raw: BCHMnemonicURLRaw): BCHMnemonicURL {
// Add entropy validation
if (!raw.entropy || raw.entropy.length === 0) {
throw new Error('Invalid entropy: must be non-empty');
}
// Validate entropy length (typically 16, 20, 24, 28, or 32 bytes for BIP39)
const validLengths = [16, 20, 24, 28, 32];
if (!validLengths.includes(raw.entropy.length)) {
throw new Error(`Invalid entropy length: ${raw.entropy.length} bytes`);
}
return new BCHMnemonicURL(raw);
}
constructor(protected raw: BCHMnemonicURLRaw) {}
toObject() {
return this.raw;
}
/**
* Encode the backup into a URL encoding
*
* @param prefix - The prefix to use for the URL encoding
* @returns The URL encoding of the backup
*/
toURL(): string {
// Conver the mnemonic words into the entropy used to derive the mnemonic words
const entropyBase64 = Buffer.from(this.raw.entropy).toString('base64');
// Create a new URL object with the prefix and the base64 encoded mnemonic
const url = new URL(`${BCHMnemonicURL.PROTOCOL}:${entropyBase64}`);
// Add the raw values to the url encoded string. Only add the values that are defined.
if (this.raw.language !== undefined) {
url.searchParams.set(
BCHMnemonicURL.ENCODING_KEYS.language,
this.raw.language,
);
}
if (this.raw.comment !== undefined) {
url.searchParams.set(
BCHMnemonicURL.ENCODING_KEYS.comment,
this.raw.comment,
);
}
if (this.raw.path !== undefined) {
url.searchParams.set(BCHMnemonicURL.ENCODING_KEYS.path, this.raw.path);
}
if (this.raw.startHeight !== undefined) {
url.searchParams.set(
BCHMnemonicURL.ENCODING_KEYS.startHeight,
this.raw.startHeight.toString(),
);
}
return url.toString();
}
static ENCODING_KEYS = {
language: 'l',
passphrase: 'p',
comment: 'c',
path: 'd',
startHeight: 'h',
} as const;
static SUPPORTED_LANGUAGES = [
'en',
'zh-CN',
'zh-TW',
'ja',
'es',
'pt',
'ko',
'fr',
'it',
'cs',
] as const;
/**
* Zod schema for validating URL parameters
*/
static schema = z.object({
[BCHMnemonicURL.ENCODING_KEYS.language]: z
.enum(BCHMnemonicURL.SUPPORTED_LANGUAGES)
.optional(),
[BCHMnemonicURL.ENCODING_KEYS.passphrase]: z.string().optional(),
[BCHMnemonicURL.ENCODING_KEYS.comment]: z.string().optional(),
[BCHMnemonicURL.ENCODING_KEYS.path]: z.string().optional(),
[BCHMnemonicURL.ENCODING_KEYS.startHeight]: z.coerce.number().optional(),
});
}

View File

@@ -1,259 +1,92 @@
/**
* History utility functions.
*
* Pure functions for parsing and formatting wallet history data.
* These functions have no React dependencies and can be used
* in both TUI and CLI contexts.
*/
import type { HistoryItem, HistoryInvitationItem, HistoryUtxoItem } from '../services/history.js';
import type { HistoryItem, HistoryItemType } from '../services/history.js';
/**
* Color names for history item types.
* These are semantic color names that can be mapped to actual colors
* by the consuming application (TUI or CLI).
*/
export type HistoryColorName = 'info' | 'warning' | 'success' | 'error' | 'muted' | 'text';
/**
* Formatted history list item data.
*/
export interface FormattedHistoryItem {
/** The display label for the history item */
export type HistoryRowType = 'invitation' | 'invitation_input' | 'invitation_output' | 'utxo';
export interface HistoryDisplayRow {
id: string;
type: HistoryRowType;
label: string;
/** Optional secondary description */
description?: string;
/** The formatted date string */
dateStr?: string;
/** The semantic color name for this item type */
color: HistoryColorName;
/** The history item type */
type: HistoryItemType;
/** Whether the item data is valid */
isValid: boolean;
timestamp?: number;
isNested: boolean;
utxo?: HistoryUtxoItem;
invitation?: HistoryInvitationItem;
}
/**
* Get the semantic color name for a history item type.
*
* @param type - The history item type
* @param isSelected - Whether the item is currently selected
* @returns A semantic color name
*/
export function getHistoryItemColorName(type: HistoryItemType, isSelected: boolean = false): HistoryColorName {
if (isSelected) return 'info'; // Use focus color when selected
switch (type) {
case 'invitation_created':
return 'text';
case 'utxo_reserved':
return 'warning';
case 'utxo_received':
return 'success';
default:
return 'text';
}
}
/**
* Format a satoshi value for display.
*
* @param satoshis - The value in satoshis
* @returns Formatted string with BCH amount
*/
export function formatSatoshisValue(satoshis: bigint | number): string {
const value = typeof satoshis === 'bigint' ? satoshis : BigInt(satoshis);
const bch = Number(value) / 100_000_000;
return `${bch.toFixed(8)} BCH (${value.toLocaleString()} sats)`;
}
/**
* Format a timestamp for display.
*
* @param timestamp - Unix timestamp in milliseconds
* @returns Formatted date string or undefined
*/
export function formatHistoryDate(timestamp?: number): string | undefined {
if (!timestamp) return undefined;
return new Date(timestamp).toLocaleDateString();
}
/**
* Format a history item for display in a list.
*
* @param item - The history item to format
* @param isSelected - Whether the item is currently selected
* @returns Formatted item data for display
*/
export function formatHistoryListItem(
item: HistoryItem | null | undefined,
isSelected: boolean = false
): FormattedHistoryItem {
if (!item) {
return {
label: '',
description: undefined,
dateStr: undefined,
color: 'muted',
type: 'utxo_received',
isValid: false,
};
}
const dateStr = formatHistoryDate(item.timestamp);
const color = getHistoryItemColorName(item.type, isSelected);
switch (item.type) {
case 'invitation_created':
return {
label: `[Invitation] ${item.description}`,
description: undefined,
dateStr,
color,
type: item.type,
isValid: true,
};
case 'utxo_reserved': {
const satsStr = item.valueSatoshis !== undefined
? formatSatoshisValue(item.valueSatoshis)
: 'Unknown amount';
return {
label: `[Reserved] ${satsStr}`,
description: item.description,
dateStr,
color,
type: item.type,
isValid: true,
};
}
case 'utxo_received': {
const satsStr = item.valueSatoshis !== undefined
? formatSatoshisValue(item.valueSatoshis)
: 'Unknown amount';
const reservedTag = item.reserved ? ' [Reserved]' : '';
return {
label: satsStr,
description: `${item.description}${reservedTag}`,
dateStr,
color,
type: item.type,
isValid: true,
};
}
default:
return {
label: `${item.type}: ${item.description}`,
description: undefined,
dateStr,
color: 'text',
type: item.type,
isValid: true,
};
}
}
/**
* Get a type label for display.
*
* @param type - The history item type
* @returns Human-readable type label
*/
export function getHistoryTypeLabel(type: HistoryItemType): string {
switch (type) {
case 'invitation_created':
return 'Invitation';
case 'utxo_reserved':
return 'Reserved';
case 'utxo_received':
return 'Received';
default:
return type;
}
}
/**
* Calculate scrolling window indices for a list.
*
* @param selectedIndex - Currently selected index
* @param totalItems - Total number of items
* @param maxVisible - Maximum visible items
* @returns Start and end indices for the visible window
*/
export function calculateScrollWindow(
selectedIndex: number,
totalItems: number,
maxVisible: number
): { startIndex: number; endIndex: number } {
const halfWindow = Math.floor(maxVisible / 2);
let startIndex = Math.max(0, selectedIndex - halfWindow);
const endIndex = Math.min(totalItems, startIndex + maxVisible);
// Adjust start if we're near the end
if (endIndex - startIndex < maxVisible) {
startIndex = Math.max(0, endIndex - maxVisible);
}
return { startIndex, endIndex };
}
/**
* Check if a history item is a UTXO-related event.
*
* @param item - The history item to check
* @returns True if the item is UTXO-related
*/
export function isUtxoEvent(item: HistoryItem): boolean {
return item.type === 'utxo_received' || item.type === 'utxo_reserved';
}
/**
* Filter history items by type.
*
* @param items - Array of history items
* @param types - Types to include
* @returns Filtered array
*/
export function filterHistoryByType(
items: HistoryItem[],
types: HistoryItemType[]
): HistoryItem[] {
return items.filter(item => types.includes(item.type));
}
/**
* Get summary statistics for history items.
*
* @param items - Array of history items
* @returns Summary statistics
*/
export function getHistorySummary(items: HistoryItem[]): {
totalReceived: bigint;
totalReserved: bigint;
invitationCount: number;
utxoCount: number;
} {
let totalReceived = 0n;
let totalReserved = 0n;
let invitationCount = 0;
let utxoCount = 0;
export function buildHistoryDisplayRows(items: HistoryItem[]): HistoryDisplayRow[] {
const rows: HistoryDisplayRow[] = [];
for (const item of items) {
switch (item.type) {
case 'invitation_created':
invitationCount++;
break;
case 'utxo_reserved':
totalReserved += item.valueSatoshis ?? 0n;
break;
case 'utxo_received':
totalReceived += item.valueSatoshis ?? 0n;
utxoCount++;
break;
if (item.kind === 'invitation') {
rows.push({
id: item.id,
type: 'invitation',
label: item.description,
timestamp: item.createdAtTimestamp,
isNested: false,
invitation: item,
});
for (const input of item.inputs) {
const satsPrefix = input.valueSatoshis !== undefined ? `${input.valueSatoshis.toLocaleString()} sats ` : '';
rows.push({
id: `${item.id}-input-${input.id}`,
type: 'invitation_input',
label: `${satsPrefix}${input.outpoint.txid}:${input.outpoint.index}`,
description: input.description,
isNested: true,
utxo: input,
invitation: item,
});
}
for (const output of item.outputs) {
rows.push({
id: `${item.id}-output-${output.id}`,
type: 'invitation_output',
label: output.valueSatoshis !== undefined ? `${output.valueSatoshis.toLocaleString()} sats` : 'Output',
description: output.description,
isNested: true,
utxo: output,
invitation: item,
});
}
continue;
}
rows.push({
id: item.id,
type: 'utxo',
label: item.valueSatoshis !== undefined ? `${item.valueSatoshis.toLocaleString()} sats` : 'UTXO',
description: item.description,
isNested: false,
utxo: item,
});
}
return { totalReceived, totalReserved, invitationCount, utxoCount };
return rows;
}
export function getHistoryItemColorName(row: HistoryDisplayRow, isSelected: boolean = false): HistoryColorName {
if (isSelected) return 'info';
switch (row.type) {
case 'invitation':
return 'text';
case 'invitation_input':
return 'error';
case 'invitation_output':
return 'success';
case 'utxo':
return row.utxo?.reserved ? 'warning' : 'success';
default:
return 'text';
}
}

View File

@@ -0,0 +1,152 @@
import type { XOTemplate, XOTemplateTransactionOutput } from '@xo-cash/types';
import type { Invitation } from '../services/invitation.js';
export interface SelectableUtxoLike {
outpointTransactionHash: string;
outpointIndex: number;
valueSatoshis: bigint;
lockingBytecode?: string;
selected: boolean;
}
export const hasMissingRequirements = (missingRequirements: {
variables?: string[];
inputs?: string[];
outputs?: string[];
roles?: Record<string, unknown>;
}): boolean => {
return (
(missingRequirements.variables?.length ?? 0) > 0
|| (missingRequirements.inputs?.length ?? 0) > 0
|| (missingRequirements.outputs?.length ?? 0) > 0
|| (missingRequirements.roles !== undefined && Object.keys(missingRequirements.roles).length > 0)
);
};
export const isInvitationRequirementsComplete = async (invitation: Invitation): Promise<boolean> => {
const missingRequirements = await invitation.getMissingRequirements();
return !hasMissingRequirements(missingRequirements);
};
export const resolveActionRoles = (
template: XOTemplate | undefined,
actionIdentifier: string | undefined,
rolesFromNavigation?: string[],
): string[] => {
if (rolesFromNavigation && rolesFromNavigation.length > 0) {
return [ ...new Set(rolesFromNavigation) ];
}
if (!template || !actionIdentifier) return [];
const starts = template.start ?? [];
const roleIds = starts
.filter((entry) => entry.action === actionIdentifier)
.map((entry) => entry.role);
return [ ...new Set(roleIds) ];
};
export const roleRequiresInputs = (
template: XOTemplate | undefined,
actionIdentifier: string | undefined,
roleIdentifier: string | undefined,
): boolean => {
if (!template || !actionIdentifier || !roleIdentifier) return false;
const action = template.actions?.[actionIdentifier];
if (!action) return false;
const actionRole = action.roles?.[roleIdentifier];
const roleSlotsMin = actionRole?.requirements?.slots?.min ?? 0;
if (roleSlotsMin > 0) return true;
// Some templates specify slot/input requirements at action.requirements.roles
// instead of role.requirements. Respect those as well.
const roleRequirement = action.requirements?.roles?.find((requirement) => requirement.role === roleIdentifier);
const actionLevelSlotsMin = roleRequirement?.slots?.min ?? 0;
if (actionLevelSlotsMin > 0) return true;
const transactionIdentifier = action.transaction;
const transaction = transactionIdentifier ? template.transactions?.[transactionIdentifier] : undefined;
const roleInputs = transaction?.roles?.[roleIdentifier]?.inputs;
return (roleInputs?.length ?? 0) > 0;
};
export const getTransactionOutputIdentifier = (output: XOTemplateTransactionOutput): string | undefined => {
if (typeof output === 'string') return output;
if (output && typeof output === 'object' && 'output' in output && typeof output.output === 'string') {
return output.output;
}
return undefined;
};
export const normalizeLockingBytecodeHex = (value: string): string => value.trim().replace(/^0x/i, '');
export const resolveProvidedLockingBytecodeHex = (
template: XOTemplate,
outputIdentifier: string,
variableValues: Record<string, string>,
): string | undefined => {
const outputDefinition = template.outputs?.[outputIdentifier];
if (!outputDefinition || typeof outputDefinition.lockscript !== 'string') return undefined;
const lockingScriptDefinition = (template.lockingScripts as Record<string, unknown> | undefined)?.[outputDefinition.lockscript] as
| { lockingScript?: string }
| undefined;
const scriptIdentifier = lockingScriptDefinition?.lockingScript;
if (!scriptIdentifier) return undefined;
const scriptExpression = (template.scripts as Record<string, unknown> | undefined)?.[scriptIdentifier];
if (typeof scriptExpression !== 'string') return undefined;
const directVariableMatch = scriptExpression.match(/^<\s*([A-Za-z0-9_]+)\s*>$/);
if (!directVariableMatch) return undefined;
const variableIdentifier = directVariableMatch[1];
if (!variableIdentifier) return undefined;
const providedValue = variableValues[variableIdentifier];
if (!providedValue) return undefined;
return normalizeLockingBytecodeHex(providedValue);
};
export const mapUnspentOutputsToSelectable = (unspentOutputs: any[]): SelectableUtxoLike[] => {
return unspentOutputs.map((utxo: any) => ({
outpointTransactionHash: utxo.outpointTransactionHash,
outpointIndex: utxo.outpointIndex,
valueSatoshis: BigInt(utxo.valueSatoshis),
lockingBytecode: utxo.lockingBytecode
? typeof utxo.lockingBytecode === 'string'
? utxo.lockingBytecode
: Buffer.from(utxo.lockingBytecode).toString('hex')
: undefined,
selected: false,
}));
};
export const autoSelectGreedyUtxos = (
utxos: SelectableUtxoLike[],
requiredWithFee: bigint,
): SelectableUtxoLike[] => {
let accumulated = 0n;
const seenLockingBytecodes = new Set<string>();
for (const utxo of utxos) {
if (utxo.lockingBytecode && seenLockingBytecodes.has(utxo.lockingBytecode)) {
continue;
}
if (utxo.lockingBytecode) {
seenLockingBytecodes.add(utxo.lockingBytecode);
}
utxo.selected = true;
accumulated += utxo.valueSatoshis;
if (accumulated >= requiredWithFee) {
break;
}
}
return utxos;
};

View File

@@ -84,6 +84,7 @@ export function getStateColorName(state: string): StateColorName {
return 'warning';
case 'ready':
case 'signed':
case 'complete':
case 'broadcast':
case 'completed':
return 'success';