diff --git a/.gitignore b/.gitignore index bb8de35..dfdc5d4 100644 --- a/.gitignore +++ b/.gitignore @@ -7,4 +7,6 @@ dist/ *.db-shm *.db-wal *.sqlite -resolvedTemplate.json \ No newline at end of file +*.sqlite-journal +resolvedTemplate.json +mnemonic-* \ No newline at end of file diff --git a/Electrum.sqlite-journal b/Electrum.sqlite-journal deleted file mode 100644 index 87d2625..0000000 Binary files a/Electrum.sqlite-journal and /dev/null differ diff --git a/package-lock.json b/package-lock.json index a6a3c19..af90f0c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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" + } } } } diff --git a/package.json b/package.json index 4354e37..d10c0b8 100644 --- a/package.json +++ b/package.json @@ -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" } diff --git a/src/app.ts b/src/app.ts index f7c548d..0cf30a3 100644 --- a/src/app.ts +++ b/src/app.ts @@ -76,6 +76,8 @@ export class App { // Wait for the app to exit await this.inkInstance.waitUntilExit(); + + process.exit(0); } /** diff --git a/src/services/app.ts b/src/services/app.ts index 7441ee3..3072f80 100644 --- a/src/services/app.ts +++ b/src/services/app.ts @@ -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 { @@ -33,6 +36,7 @@ export class AppService extends EventEmitter { public storage: Storage; public config: AppConfig; public history: HistoryService; + public electrum: ElectrumService; public invitations: Invitation[] = []; @@ -68,15 +72,21 @@ export class AppService extends EventEmitter { 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 { engine: this.engine, syncServer: invitationSyncServer, storage: invitationStorage, + electrum: this.electrum, }; // Create the invitation diff --git a/src/services/electrum.ts b/src/services/electrum.ts new file mode 100644 index 0000000..6c623d5 --- /dev/null +++ b/src/services/electrum.ts @@ -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; + + 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 { + 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; + } + } +} diff --git a/src/services/history.ts b/src/services/history.ts index 86dc016..41e2464 100644 --- a/src/services/history.ts +++ b/src/services/history.ts @@ -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; + 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 { - 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(); + const ownOutpoints = new Set(); + const ownLockingBytecodes = new Set(); + const invitationByOrigin = new Map(); + const outpointValueSatoshis = new Map(); + 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(); 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(); + 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, + usedUtxoIds: Set + ): 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 = 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(); + 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 { + 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); + } + + private indexInvitationOutputsByUtxoOrigin( + invitationByUtxoOrigin: Map, + 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 | 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 | undefined { + const originKey = this.getUtxoOriginKey(utxo.templateIdentifier, utxo.outputIdentifier, utxo.lockingBytecode); + return invitationByUtxoOrigin.get(originKey)?.roleIdentifier; + } + + private resolveWalletEntityIdentifier( + invitation: Invitation, + ownUtxoOutpointKeys: Set, + ownLockingBytecodes: Set + ): string | undefined { + const scores = new Map(); + 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, + 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, + 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); + return `[${template.name}:${role}] ${detail}`; + } - const description = compileCashAssemblyString(transaction.description, formattedVariables); - - return description; + private deriveInputDescription( + inputIdentifier: string, + template: XOTemplate | null, + variables: Record + ): 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, + 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(); + 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, + variables: Record + ): 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 { + return text.replace(/\$\(<([^>]+)>\)/g, (match, variableIdentifier: string) => { + if (!Object.prototype.hasOwnProperty.call(variables, variableIdentifier)) return match; + return String(variables[variableIdentifier]); + }); } } diff --git a/src/services/invitation.ts b/src/services/invitation.ts index ba4bd30..5aad491 100644 --- a/src/services/invitation.ts +++ b/src/services/invitation.ts @@ -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 { @@ -87,16 +90,7 @@ export class Invitation extends EventEmitter { * 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 { 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 { /** * 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 { - 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 { (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 { + 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 { 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 { broadcastTransaction: true, }); - this._broadcasted = true; - // Update the status of the invitation await this.updateStatus(); } diff --git a/src/tui/App.tsx b/src/tui/App.tsx index 6725f9f..59b37f2 100644 --- a/src/tui/App.tsx +++ b/src/tui/App.tsx @@ -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(); }; diff --git a/src/tui/components/Dialog.tsx b/src/tui/components/Dialog.tsx index 1fe216c..acb5409 100644 --- a/src/tui/components/Dialog.tsx +++ b/src/tui/components/Dialog.tsx @@ -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(null); const [height, setHeight] = useState(null); @@ -51,9 +52,12 @@ export function DialogWrapper({ flexDirection="column" width={width} height={height} + backgroundColor={backgroundColor} > {Array.from({ length: height }).map((_, i) => ( - {' '.repeat(width)} + + {' '.repeat(width)} + ))} )} @@ -67,6 +71,7 @@ export function DialogWrapper({ paddingX={2} paddingY={1} width={width} + backgroundColor={backgroundColor} > {title} diff --git a/src/tui/components/Input.tsx b/src/tui/components/Input.tsx index 0eab123..f5eee7b 100644 --- a/src/tui/components/Input.tsx +++ b/src/tui/components/Input.tsx @@ -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'; /** diff --git a/src/tui/components/List.tsx b/src/tui/components/List.tsx index f3cebf1..35a4c13 100644 --- a/src/tui/components/List.tsx +++ b/src/tui/components/List.tsx @@ -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'; // ============================================================================= diff --git a/src/tui/components/ProgressBar.tsx b/src/tui/components/ProgressBar.tsx index 4bf8a12..a094cfc 100644 --- a/src/tui/components/ProgressBar.tsx +++ b/src/tui/components/ProgressBar.tsx @@ -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 ( diff --git a/src/tui/components/TextInput.tsx b/src/tui/components/TextInput.tsx new file mode 100644 index 0000000..f834219 --- /dev/null +++ b/src/tui/components/TextInput.tsx @@ -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 ( + + {placeholder + ? value.length > 0 + ? renderedValue + : renderedPlaceholder + : renderedValue} + + ); +} + +export default TextInput; + +type UncontrolledProps = { + /** + * Initial value. + */ + readonly initialValue?: string; +} & Except; + +export function UncontrolledTextInput({ + initialValue = '', + ...props +}: UncontrolledProps) { + const [value, setValue] = useState(initialValue); + + return ; +} \ No newline at end of file diff --git a/src/tui/components/VariableInputField.tsx b/src/tui/components/VariableInputField.tsx index 2955228..e0524db 100644 --- a/src/tui/components/VariableInputField.tsx +++ b/src/tui/components/VariableInputField.tsx @@ -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: { diff --git a/src/tui/screens/SeedInput.tsx b/src/tui/screens/SeedInput.tsx index 9f5ccfa..5898be8 100644 --- a/src/tui/screens/SeedInput.tsx +++ b/src/tui/screens/SeedInput.tsx @@ -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'; diff --git a/src/tui/screens/TemplateList.tsx b/src/tui/screens/TemplateList.tsx index d4d707f..f537a6d 100644 --- a/src/tui/screens/TemplateList.tsx +++ b/src/tui/screens/TemplateList.tsx @@ -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; /** - * Action list item with UniqueStartingAction value. + * Action list item with available action value. */ -type ActionListItem = ListItemData; +type ActionListItem = ListItemData; + +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>(); + for (const utxo of allUtxos) { + const existing = ownedOutputsByTemplate.get(utxo.templateIdentifier) ?? new Set(); + 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(); - // 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(); + for (const outputIdentifier of ownedOutputIdentifiers) { + const outputDef = template.outputs?.[outputIdentifier]; + if (!outputDef || typeof outputDef.lockscript !== 'string') continue; + + const lockingScriptDefinition = (template.lockingScripts as Record | undefined)?.[outputDef.lockscript] as + | { roles?: Record }> } + | 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} > - Starting Actions + Available Actions {isLoading ? ( Loading... @@ -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 ( <> @@ -351,16 +430,24 @@ export function TemplateListScreen(): React.ReactElement { {action.description || 'No description available'} - {/* List available roles for this action */} - {availableRoles.length > 0 && ( + {/* List roles available for this action in current context */} + {action.roles.length > 0 && ( Available Roles: - {availableRoles.map((role) => ( - - {' '}- {role.name} - {role.description ? `: ${role.description}` : ''} - - ))} + {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 ( + + {' '}- {roleName} + {roleDescription ? `: ${roleDescription}` : ''} + + ); + })} + + {' '}Source: {action.source} + )} @@ -370,7 +457,7 @@ export function TemplateListScreen(): React.ReactElement { ) : focusedPanel === 'actions' && !currentTemplate ? ( Select a template first ) : focusedPanel === 'actions' && currentActions.length === 0 ? ( - No starting actions available + No actions available ) : null} diff --git a/src/tui/screens/WalletState.tsx b/src/tui/screens/WalletState.tsx index 3969738..49156c6 100644 --- a/src/tui/screens/WalletState.tsx +++ b/src/tui/screens/WalletState.tsx @@ -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[] = [ ]; /** - * History list item with HistoryItem value. + * History list item with display row value. */ -type HistoryListItem = ListItemData; +type HistoryListItem = ListItemData; /** * 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 ( - {indicator}[Invitation] {historyItem.description} + {indicator}[Invitation] {row.label} {dateStr && {dateStr}} ); - } else if (historyItem.type === 'utxo_reserved') { - const sats = historyItem.valueSatoshis ?? 0n; + } + + if (row.type === 'invitation_input') { return ( - {indicator}[Reserved] {formatSatoshis(sats)} + {indicator}{groupingPrefix}[Input] {row.label} - {historyItem.description} + {row.description && {row.description}} {dateStr && {dateStr}} ); - } 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 ( - {indicator}{formatSatoshis(sats)} - - - {' '}{historyItem.description}{reservedTag} + {indicator}{groupingPrefix}[Output] {formatSatoshis(sats)} + {row.description && {row.description}} + + {dateStr && {dateStr}} + + ); + } + + if (row.type === 'utxo') { + const sats = row.utxo?.valueSatoshis ?? 0n; + const reservedTag = row.utxo?.reserved ? ' [Reserved]' : ''; + return ( + + + {indicator}{formatSatoshis(sats)} + {row.description && {row.description}{reservedTag}} {dateStr && {dateStr}} @@ -277,7 +291,7 @@ export function WalletStateScreen(): React.ReactElement { return ( - {indicator}{historyItem.type}: {historyItem.description} + {indicator}{row.label} {dateStr && {dateStr}} diff --git a/src/tui/screens/action-wizard/ActionWizardScreen.tsx b/src/tui/screens/action-wizard/ActionWizardScreen.tsx index 27d3286..ae97d2b 100644 --- a/src/tui/screens/action-wizard/ActionWizardScreen.tsx +++ b/src/tui/screens/action-wizard/ActionWizardScreen.tsx @@ -205,7 +205,13 @@ export function ActionWizardScreen(): React.ReactElement { /> ); case 'publish': - return ; + return ( + + ); default: return null; } @@ -284,7 +290,9 @@ export function ActionWizardScreen(): React.ReactElement {