From dd275593cd39a3a08a5cf36e0555e526f65437d8 Mon Sep 17 00:00:00 2001 From: Harvmaster Date: Mon, 16 Mar 2026 06:48:29 +0000 Subject: [PATCH] Fix receive and send --- .gitignore | 4 +- Electrum.sqlite-journal | Bin 25136 -> 0 bytes package-lock.json | 248 +++--- package.json | 9 +- src/app.ts | 2 + src/services/app.ts | 15 +- src/services/electrum.ts | 46 ++ src/services/history.ts | 751 +++++++++++++----- src/services/invitation.ts | 92 ++- src/tui/App.tsx | 8 +- src/tui/components/Dialog.tsx | 9 +- src/tui/components/Input.tsx | 2 +- src/tui/components/List.tsx | 2 +- src/tui/components/ProgressBar.tsx | 12 +- src/tui/components/TextInput.tsx | 216 +++++ src/tui/components/VariableInputField.tsx | 3 +- src/tui/screens/SeedInput.tsx | 2 +- src/tui/screens/TemplateList.tsx | 139 +++- src/tui/screens/WalletState.tsx | 74 +- .../action-wizard/ActionWizardScreen.tsx | 12 +- .../action-wizard/steps/PublishStep.tsx | 22 +- .../screens/action-wizard/useActionWizard.ts | 333 ++++++-- .../steps/InputsSelectStep.tsx | 44 +- src/tui/utils/clipboard.ts | 4 +- src/utils/bch-mnemonic-url.ts | 170 ++++ src/utils/history-utils.ts | 315 ++------ src/utils/invitation-flow.ts | 152 ++++ src/utils/invitation-utils.ts | 1 + 28 files changed, 1918 insertions(+), 769 deletions(-) delete mode 100644 Electrum.sqlite-journal create mode 100644 src/services/electrum.ts create mode 100644 src/tui/components/TextInput.tsx create mode 100644 src/utils/bch-mnemonic-url.ts create mode 100644 src/utils/invitation-flow.ts 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 87d26252ce39eae35af2d3ec07ed1d1769f3bbad..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 25136 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 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 {