Massive changes. I dont know what happens. Rewrote the action wizard again. Fixed several bugs related to the utxo selection. QR codes were added for address. Add support for data results. Experiment with other methods of role extraction

This commit is contained in:
2026-03-22 13:20:46 +00:00
parent be52f73e64
commit a28d43a68b
35 changed files with 2226 additions and 1169 deletions

392
package-lock.json generated
View File

@@ -18,12 +18,14 @@
"better-sqlite3": "^12.6.2",
"clipboardy": "^5.1.0",
"ink": "^6.6.0",
"qrcode": "^1.5.4",
"react": "^19.2.4",
"zod": "^4.3.6"
},
"devDependencies": {
"@types/better-sqlite3": "^7.6.13",
"@types/node": "^25.0.10",
"@types/qrcode": "^1.5.6",
"@types/react": "^19.2.14",
"tsx": "^4.21.0",
"typescript": "^5.9.3"
@@ -35,14 +37,15 @@
"license": "MIT",
"dependencies": {
"@bitauth/libauth": "^3.1.0-next.8",
"@electrum-cash/application": "^0.2.2-development.11641378031",
"@electrum-cash/network": "^4.1.4",
"@electrum-cash/protocol": "^2.3.0-development.12185537348",
"@xo-cash/crypto": "0.0.1",
"@xo-cash/primitives": "0.0.1",
"@xo-cash/state": "0.0.1",
"@xo-cash/types": "0.0.1",
"@xo-cash/utils": "0.0.1",
"@electrum-cash/application": "^0.2.3-development.13424909069",
"@electrum-cash/network": "^4.2.2",
"@electrum-cash/protocol": "^2.3.1",
"@electrum-cash/servers": "^3.1.0",
"@xo-cash/crypto": "file:../crypto",
"@xo-cash/primitives": "file:../primitives",
"@xo-cash/state": "file:../state",
"@xo-cash/types": "file:../types",
"@xo-cash/utils": "file:../utils",
"eventemitter3": "^5.0.1"
},
"devDependencies": {
@@ -110,7 +113,7 @@
"version": "0.0.1",
"license": "MIT",
"dependencies": {
"@xo-cash/types": "0.0.1"
"@xo-cash/types": "0.0.1-development.13504604083"
},
"devDependencies": {
"@chalp/eslint-airbnb": "^1.3.0",
@@ -130,9 +133,6 @@
"typescript": "^5.3.2",
"typescript-eslint": "^8.53.1",
"vitest": "^4.0.17"
},
"engines": {
"node": ">=18.0.0"
}
},
"../types": {
@@ -160,9 +160,6 @@
"typescript": "^5.3.2",
"typescript-eslint": "^8.53.1",
"vitest": "^4.0.17"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/@alcalzone/ansi-tokenize": {
@@ -723,6 +720,16 @@
"undici-types": "~7.16.0"
}
},
"node_modules/@types/qrcode": {
"version": "1.5.6",
"resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.6.tgz",
"integrity": "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/react": {
"version": "19.2.14",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
@@ -896,6 +903,15 @@
"ieee754": "^1.1.13"
}
},
"node_modules/camelcase": {
"version": "5.3.1",
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
"integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/chalk": {
"version": "5.6.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz",
@@ -992,6 +1008,96 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/cliui": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz",
"integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==",
"license": "ISC",
"dependencies": {
"string-width": "^4.2.0",
"strip-ansi": "^6.0.0",
"wrap-ansi": "^6.2.0"
}
},
"node_modules/cliui/node_modules/ansi-regex": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/cliui/node_modules/ansi-styles": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
"license": "MIT",
"dependencies": {
"color-convert": "^2.0.1"
},
"engines": {
"node": ">=8"
},
"funding": {
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/cliui/node_modules/emoji-regex": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
"license": "MIT"
},
"node_modules/cliui/node_modules/is-fullwidth-code-point": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/cliui/node_modules/string-width": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"license": "MIT",
"dependencies": {
"emoji-regex": "^8.0.0",
"is-fullwidth-code-point": "^3.0.0",
"strip-ansi": "^6.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/cliui/node_modules/strip-ansi": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"license": "MIT",
"dependencies": {
"ansi-regex": "^5.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/cliui/node_modules/wrap-ansi": {
"version": "6.2.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
"integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==",
"license": "MIT",
"dependencies": {
"ansi-styles": "^4.0.0",
"string-width": "^4.1.0",
"strip-ansi": "^6.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/code-excerpt": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/code-excerpt/-/code-excerpt-4.0.0.tgz",
@@ -1004,6 +1110,24 @@
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
}
},
"node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
"license": "MIT",
"dependencies": {
"color-name": "~1.1.4"
},
"engines": {
"node": ">=7.0.0"
}
},
"node_modules/color-name": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
"license": "MIT"
},
"node_modules/convert-to-spaces": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/convert-to-spaces/-/convert-to-spaces-2.0.1.tgz",
@@ -1051,6 +1175,15 @@
}
}
},
"node_modules/decamelize": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz",
"integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/decompress-response": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz",
@@ -1084,6 +1217,12 @@
"node": ">=8"
}
},
"node_modules/dijkstrajs": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz",
"integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==",
"license": "MIT"
},
"node_modules/emoji-regex": {
"version": "10.6.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz",
@@ -1234,6 +1373,19 @@
"integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==",
"license": "MIT"
},
"node_modules/find-up": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
"integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
"license": "MIT",
"dependencies": {
"locate-path": "^5.0.0",
"path-exists": "^4.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/fs-constants": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz",
@@ -1255,6 +1407,15 @@
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/get-caller-file": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
"license": "ISC",
"engines": {
"node": "6.* || 8.* || >= 10.*"
}
},
"node_modules/get-east-asian-width": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.4.0.tgz",
@@ -1571,6 +1732,18 @@
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
"license": "ISC"
},
"node_modules/locate-path": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
"integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
"license": "MIT",
"dependencies": {
"p-locate": "^4.1.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/lossless-json": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/lossless-json/-/lossless-json-4.3.0.tgz",
@@ -1689,6 +1862,42 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/p-limit": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
"integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
"license": "MIT",
"dependencies": {
"p-try": "^2.0.0"
},
"engines": {
"node": ">=6"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/p-locate": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
"integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
"license": "MIT",
"dependencies": {
"p-limit": "^2.2.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/p-try": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
"integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/parse-ms": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/parse-ms/-/parse-ms-4.0.0.tgz",
@@ -1710,6 +1919,15 @@
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
}
},
"node_modules/path-exists": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/path-key": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
@@ -1719,6 +1937,15 @@
"node": ">=8"
}
},
"node_modules/pngjs": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz",
"integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==",
"license": "MIT",
"engines": {
"node": ">=10.13.0"
}
},
"node_modules/powershell-utils": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/powershell-utils/-/powershell-utils-0.2.0.tgz",
@@ -1782,6 +2009,23 @@
"once": "^1.3.1"
}
},
"node_modules/qrcode": {
"version": "1.5.4",
"resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz",
"integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==",
"license": "MIT",
"dependencies": {
"dijkstrajs": "^1.0.1",
"pngjs": "^5.0.0",
"yargs": "^15.3.1"
},
"bin": {
"qrcode": "bin/qrcode"
},
"engines": {
"node": ">=10.13.0"
}
},
"node_modules/rc": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz",
@@ -1835,6 +2079,21 @@
"node": ">= 6"
}
},
"node_modules/require-directory": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/require-main-filename": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz",
"integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==",
"license": "ISC"
},
"node_modules/resolve-pkg-maps": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz",
@@ -1905,6 +2164,12 @@
"node": ">=10"
}
},
"node_modules/set-blocking": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
"integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==",
"license": "ISC"
},
"node_modules/shebang-command": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
@@ -2216,6 +2481,12 @@
"node": ">= 8"
}
},
"node_modules/which-module": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz",
"integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==",
"license": "ISC"
},
"node_modules/widest-line": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/widest-line/-/widest-line-5.0.0.tgz",
@@ -2275,6 +2546,97 @@
}
}
},
"node_modules/y18n": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz",
"integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==",
"license": "ISC"
},
"node_modules/yargs": {
"version": "15.4.1",
"resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz",
"integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==",
"license": "MIT",
"dependencies": {
"cliui": "^6.0.0",
"decamelize": "^1.2.0",
"find-up": "^4.1.0",
"get-caller-file": "^2.0.1",
"require-directory": "^2.1.1",
"require-main-filename": "^2.0.0",
"set-blocking": "^2.0.0",
"string-width": "^4.2.0",
"which-module": "^2.0.0",
"y18n": "^4.0.0",
"yargs-parser": "^18.1.2"
},
"engines": {
"node": ">=8"
}
},
"node_modules/yargs-parser": {
"version": "18.1.3",
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz",
"integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==",
"license": "ISC",
"dependencies": {
"camelcase": "^5.0.0",
"decamelize": "^1.2.0"
},
"engines": {
"node": ">=6"
}
},
"node_modules/yargs/node_modules/ansi-regex": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/yargs/node_modules/emoji-regex": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
"license": "MIT"
},
"node_modules/yargs/node_modules/is-fullwidth-code-point": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/yargs/node_modules/string-width": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"license": "MIT",
"dependencies": {
"emoji-regex": "^8.0.0",
"is-fullwidth-code-point": "^3.0.0",
"strip-ansi": "^6.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/yargs/node_modules/strip-ansi": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"license": "MIT",
"dependencies": {
"ansi-regex": "^5.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/yoctocolors": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/yoctocolors/-/yoctocolors-2.1.2.tgz",

View File

@@ -30,12 +30,14 @@
"better-sqlite3": "^12.6.2",
"clipboardy": "^5.1.0",
"ink": "^6.6.0",
"qrcode": "^1.5.4",
"react": "^19.2.4",
"zod": "^4.3.6"
},
"devDependencies": {
"@types/better-sqlite3": "^7.6.13",
"@types/node": "^25.0.10",
"@types/qrcode": "^1.5.6",
"@types/react": "^19.2.14",
"tsx": "^4.21.0",
"typescript": "^5.9.3"

View File

@@ -17,6 +17,7 @@ import { EventEmitter } from '../utils/event-emitter.js';
// TODO: Remove this. Exists to hash the seed for database namespace.
import { createHash } from 'crypto';
import { p2pkhTemplate } from '@xo-cash/templates';
import { hexToBin } from '@bitauth/libauth';
export type AppEventMap = {
'invitation-added': Invitation;
@@ -58,6 +59,8 @@ export class AppService extends EventEmitter<AppEventMap> {
// Import the default P2PKH template
await engine.importTemplate(p2pkhTemplate);
console.log('p2pkhTemplate', JSON.stringify(p2pkhTemplate.transactions, null, 2));
// Set default locking parameters for P2PKH
// To my knowledge, this doesnt generate any lockscript, so discovery of funds will not work automatically.
// TODO: Add discovery for funds in the first index? Or until we return 0 TXs?
@@ -77,6 +80,25 @@ export class AppService extends EventEmitter<AppEventMap> {
applicationIdentifier: config.electrumApplicationIdentifier,
});
// TEMP because testing is painful
// Remove all reserved UTXOs on startup
// First, get every unspent output
const allUnspentOutputs = await engine.listUnspentOutputsData();
// Get a set of all the invitation identifiers
const allInvitationIdentifiers = new Set(allUnspentOutputs.map(output => output.invitationIdentifier));
// Iterate over the invitation identifiers and unreserve the outputs
for (const invitationIdentifier of allInvitationIdentifiers) {
// Get the outputs for the invitation
const outputs = allUnspentOutputs.filter(output => output.invitationIdentifier === invitationIdentifier);
// Unreserve the outputs
await engine.unreserveResources(outputs.map(output => ({
outpointTransactionHash: hexToBin(output.outpointTransactionHash),
outpointIndex: output.outpointIndex,
})), invitationIdentifier);
}
return new AppService(engine, walletStorage, config, electrum);
}

View File

@@ -68,6 +68,55 @@ export class HistoryService {
private invitations: Invitation[]
) {}
async extractEntities(invitation: XOInvitation): Promise<string[]> {
const entities = new Set<string>();
for (const commit of invitation.commits) {
entities.add(commit.entityIdentifier);
}
return Array.from(entities);
}
// Entities are currently static per invitation. So, we can try to match the roles to entities by:
// Iterating through each commit, extract the entity into a Map<entityId: string, roles: string[]>.
// While we iterate through the commits, if we see a role declaration in the commit, we save that role onto the entity's roles array.
// After we have iterated through all the commits, we can return the Map<entityId: string, roles: string[]>.
async matchRolesToEntities(invitation: XOInvitation, entities: string[]): Promise<Record<string, string[]>> {
const entitiesMap = new Map<string, Set<string>>();
for (const entity of entities) {
entitiesMap.set(entity, new Set());
}
// First pass, we are just going to try and find roleIdentifer values in the inputs, outputs, and variables.
// TODO: Update this once the invitations use XPubs
for (const commit of invitation.commits) {
const entity = commit.entityIdentifier;
const roles = entitiesMap.get(entity) ?? new Set();
for (const input of commit.data.inputs ?? []) {
if (input.roleIdentifier) roles.add(input.roleIdentifier);
}
for (const output of commit.data.outputs ?? []) {
if (output.roleIdentifier) roles.add(output.roleIdentifier);
}
for (const variable of commit.data.variables ?? []) {
if (variable.roleIdentifier) roles.add(variable.roleIdentifier);
}
}
// TODO: We might be able to use the lockingBytecodes that we have generated to infer which role we are. But the templates dont tell us which role is responsible for a particular output.
// I.e, if we dont know what role an output was from, we cant match it using the lockingBytecode to a role.
// Example: 2 inputs to a TX for the same amount. We dont know whether we would be Sender1 or Sender2.
// So, for now we are just going to rely on the roleIdentifiers that we have found in the first pass.
// Format into a record for easier access.
const entitiesRecord: Record<string, string[]> = {};
for (const [entity, roles] of entitiesMap.entries()) {
entitiesRecord[entity] = Array.from(roles);
}
return entitiesRecord;
}
async getHistory(): Promise<HistoryItem[]> {
const allUtxos = await this.engine.listUnspentOutputsData();
const ownOutpoints = new Set<string>();
@@ -94,6 +143,10 @@ export class HistoryService {
walletEntityIdentifier,
});
this.indexInvitationOutputsByUtxoOrigin(invitationByOrigin, invitation);
const entities = await this.extractEntities(invitation.data);
const entitiesRecord = await this.matchRolesToEntities(invitation.data, entities);
console.log(entitiesRecord);
}
const usedUtxoIds = new Set<string>();
@@ -288,6 +341,13 @@ export class HistoryService {
};
}
/**
* TODO: This is completely incorrect. It should be deriving the roles of the user based on the entity IDs in the commits, then mapping each commit to Inputs/Outputs so we know what belongs to the user.
* There are a few changes that will need to be made to make this work:
* 1. Provide a way to derive all entity IDs of the user for the invitation (If we are going with XPub)
* 2. Provide a way to get only the User's commits (and their inputs/outputs)
* 3. (Maybe) Include role on inputs and outputs - This one might be fine with just using the commit entity id
*/
private deriveWalletRolesForInvitation(
context: InvitationContext,
outputs: HistoryUtxoItem[]

View File

@@ -412,7 +412,7 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
await this.syncServer.publishInvitation(this.data);
}
async findSuitableResources(options: FindSuitableResourcesParameters): Promise<UnspentOutputData[]> {
async findSuitableResources(options: Partial<FindSuitableResourcesParameters> = {}): Promise<UnspentOutputData[]> {
// Find the suitable resources
const { unspentOutputs } = await this.engine.findSuitableResources(this.data, options);

View File

@@ -80,8 +80,12 @@ function StatusBar(): React.ReactElement {
function DialogOverlay(): React.ReactElement | null {
const { dialog, setDialog } = useDialog();
// 'custom' dialogs are rendered and managed by the screen itself;
// we only handle input for the built-in dialog types.
const isBuiltInDialog = dialog?.visible === true && dialog.type !== 'custom';
useInput((input, key) => {
if (!dialog?.visible) return;
if (!isBuiltInDialog) return;
if (key.return || input === 'y' || input === 'Y') {
if (dialog.type === 'confirm' && dialog.onConfirm) {
@@ -92,9 +96,9 @@ function DialogOverlay(): React.ReactElement | null {
} else if (key.escape || input === 'n' || input === 'N') {
dialog.onCancel?.();
}
}, { isActive: dialog?.visible ?? false });
}, { isActive: isBuiltInDialog });
if (!dialog?.visible) return null;
if (!isBuiltInDialog) return null;
const borderColor = dialog.type === 'error' ? colors.error :
dialog.type === 'confirm' ? colors.warning :

View File

@@ -0,0 +1,198 @@
/**
* QR Code component for displaying scannable QR codes in the terminal.
*
* Uses the lower-half-block character (▄) exclusively for rendering. The top
* half of each cell is controlled via backgroundColor and the bottom half via
* the foreground color. This avoids the sub-pixel seams that occur when mixing
* different Unicode block characters (█, ▀, ▄, space) across adjacent rows.
*/
import React, { useMemo } from 'react';
import { Box, Text } from 'ink';
import QRCodeLib from 'qrcode';
import { DialogWrapper } from './Dialog.js';
import { colors } from '../theme.js';
/** Color used for light (background) QR modules. */
const LIGHT = 'white';
/** Color used for dark (data) QR modules. Must match the dialog/terminal bg. */
const DARK = colors.bg as string;
/** Default quiet zone size in modules (QR spec recommends 4, 2 is usually sufficient). */
const QUIET_ZONE = 2;
/**
* A run of consecutive characters in a rendered QR row that share the
* same foreground/background color pair.
*/
interface ColorSpan {
/** The repeated ▄ characters for this span. */
chars: string;
/** Foreground color (controls the bottom half of each cell). */
fg: string;
/** Background color (controls the top half of each cell). */
bg: string;
}
/**
* Props for the QRCode component.
*/
interface QRCodeProps {
/** The data to encode in the QR code. */
value: string;
/** Whether to wrap the QR code in a DialogWrapper. */
dialog?: boolean;
/** Dialog title (only used when dialog is true). Defaults to "QR Code". */
dialogTitle?: string;
/** Whether to display the raw encoded value as copyable text above the QR code. */
showValue?: boolean;
}
/**
* Generates the QR code module matrix with a quiet zone border.
*
* @param value - The string to encode.
* @param quietZone - Number of light-module rows/columns to add around the QR data.
* @returns A 2D array where `true` means dark module and `false` means light module.
*/
function generateMatrix(value: string, quietZone: number = QUIET_ZONE): boolean[][] {
const qr = QRCodeLib.create(value, { errorCorrectionLevel: 'M' });
const { size, data } = qr.modules;
const totalSize = size + quietZone * 2;
const matrix: boolean[][] = [];
for (let row = 0; row < totalSize; row++) {
const matrixRow: boolean[] = [];
for (let col = 0; col < totalSize; col++) {
const qrRow = row - quietZone;
const qrCol = col - quietZone;
const insideData = qrRow >= 0 && qrRow < size && qrCol >= 0 && qrCol < size;
// Quiet zone modules are always light (false).
matrixRow.push(insideData ? data[qrRow * size + qrCol] === 1 : false);
}
matrix.push(matrixRow);
}
return matrix;
}
/**
* Converts a pair of module rows into an array of {@link ColorSpan}s.
*
* Every cell uses the `▄` (lower half block) character. The foreground color
* paints the bottom half and the backgroundColor paints the top half, giving
* us artifact-free rendering with a single glyph.
*
* Consecutive cells that share the same color pair are merged into one span
* to keep the element count low.
*
* @param matrix - The full module matrix.
* @param row - The index of the top row in the pair (the bottom row is row + 1).
* @returns An array of color spans for this terminal line.
*/
function buildRowSpans(matrix: boolean[][], row: number): ColorSpan[] {
const width = matrix[0]?.length ?? 0;
const spans: ColorSpan[] = [];
for (let col = 0; col < width; col++) {
const topDark = matrix[row]?.[col] ?? false;
const bottomDark = matrix[row + 1]?.[col] ?? false;
// ▄ lower-half block: foreground = bottom color, backgroundColor = top color
const fg = bottomDark ? DARK : LIGHT;
const bg = topDark ? DARK : LIGHT;
const last = spans[spans.length - 1];
if (last && last.fg === fg && last.bg === bg) {
last.chars += '▄';
} else {
spans.push({ chars: '▄', fg, bg });
}
}
return spans;
}
/**
* Renders the full module matrix into an array of span-arrays, one per
* terminal row (each covering two QR module rows).
*
* @param matrix - The 2D dark/light module matrix from {@link generateMatrix}.
*/
function renderMatrix(matrix: boolean[][]): ColorSpan[][] {
const rows: ColorSpan[][] = [];
const height = matrix.length;
for (let row = 0; row < height; row += 2) {
rows.push(buildRowSpans(matrix, row));
}
return rows;
}
/**
* Displays a scannable QR code in the terminal.
*
* Supports optional dialog wrapping via the `dialog` prop and an optional
* copyable text display of the encoded value via `showValue`.
*
* @example
* ```tsx
* // Minimal usage
* <QRCode value="bitcoincash:qr..." />
*
* // Inside a dialog with the raw value shown
* <QRCode value="bitcoincash:qr..." dialog dialogTitle="Receive Address" showValue />
* ```
*/
export function QRCode({
value,
dialog = false,
dialogTitle = 'QR Code',
showValue = false,
}: QRCodeProps): React.ReactElement {
const { rows, moduleCount } = useMemo(() => {
const matrix = generateMatrix(value);
return {
rows: renderMatrix(matrix),
moduleCount: matrix[0]?.length ?? 0,
};
}, [value]);
const qrContent = (
<Box flexDirection="column" alignItems="center">
{showValue && (
<Box marginBottom={1} width={moduleCount}>
<Text color={colors.textMuted} wrap="wrap">{value}</Text>
</Box>
)}
<Box flexDirection="column">
{rows.map((spans, i) => (
<Text key={i}>
{spans.map((span, j) => (
<Text key={j} color={span.fg} backgroundColor={span.bg}>
{span.chars}
</Text>
))}
</Text>
))}
</Box>
</Box>
);
if (dialog) {
const dialogWidth = Math.max(moduleCount + 8, 40);
return (
<DialogWrapper title={dialogTitle} borderColor={colors.primary} width={dialogWidth}>
{qrContent}
</DialogWrapper>
);
}
return qrContent;
}

View File

@@ -16,3 +16,4 @@ export {
} from './List.js';
export { InputDialog, ConfirmDialog, MessageDialog } from './Dialog.js';
export { ProgressBar, StepIndicator, Loading, type Step } from './ProgressBar.js';
export { QRCode } from './QRCode.js';

View File

@@ -55,10 +55,7 @@ function loadMnemonicFiles(): MnemonicFileEntry[] {
const parsed = BCHMnemonicURL.fromURL(content);
const raw = parsed.toObject();
console.log(raw);
const mnemonicResult = encodeBip39Mnemonic(raw.entropy);
console.log(mnemonicResult);
if (typeof mnemonicResult === 'string') continue;
/** Use the URL comment as the label, falling back to a cleaned-up filename. */

View File

@@ -10,12 +10,14 @@
import React, { useState, useEffect, useCallback, useMemo } from 'react';
import { Box, Text, useInput } from 'ink';
import { ScrollableList, type ListItemData } from '../components/List.js';
import { QRCode } from '../components/QRCode.js';
import { useNavigation } from '../hooks/useNavigation.js';
import { useAppContext, useStatus } from '../hooks/useAppContext.js';
import { useAppContext, useStatus, useDialog } from '../hooks/useAppContext.js';
import { colors, formatSatoshis, formatHex, logoSmall } from '../theme.js';
import type { HistoryItem } from '../../services/history.js';
import { generateTemplateIdentifier } from '@xo-cash/engine';
import { hexToBin, lockingBytecodeToCashAddress } from '@bitauth/libauth';
// Import utility functions
import {
@@ -71,6 +73,7 @@ export function WalletStateScreen(): React.ReactElement {
const { navigate } = useNavigation();
const { appService, showError, showInfo } = useAppContext();
const { setStatus } = useStatus();
const { setDialog } = useDialog();
// State
const [balance, setBalance] = useState<{ totalSatoshis: bigint; utxoCount: number } | null>(null);
@@ -80,6 +83,9 @@ export function WalletStateScreen(): React.ReactElement {
const [selectedHistoryIndex, setSelectedHistoryIndex] = useState(0);
const [isLoading, setIsLoading] = useState(true);
/** Cash address to display in the QR code dialog (null when dialog is hidden). */
const [qrAddress, setQrAddress] = useState<string | null>(null);
/**
* Refreshes wallet state.
*/
@@ -122,7 +128,7 @@ export function WalletStateScreen(): React.ReactElement {
}, [refresh]);
/**
* Generates a new receiving address.
* Generates a new receiving address and displays it as a QR code.
*/
const generateNewAddress = useCallback(async () => {
if (!appService) {
@@ -145,21 +151,33 @@ export function WalletStateScreen(): React.ReactElement {
// Generate the template identifier
const templateId = generateTemplateIdentifier(p2pkhTemplate);
// Generate the locking bytecode
const lockingBytecode = await appService.engine.generateLockingBytecode(
// Generate the locking bytecode (returned as a hex string)
const lockingBytecodeHex = await appService.engine.generateLockingBytecode(
templateId,
'receiveOutput',
'receiver',
);
showInfo(`New address generated!\n\nLocking bytecode:\n${formatHex(lockingBytecode, 40)}`);
// Convert the locking bytecode to a BCH cash address for display and QR encoding.
const result = lockingBytecodeToCashAddress({ bytecode: hexToBin(lockingBytecodeHex), prefix: 'bitcoincash' });
if (typeof result === 'string') {
showError(`Failed to encode address: ${result}`);
return;
}
console.log(result);
setQrAddress(result.address);
setDialog({ visible: true, type: 'custom', message: '' });
setStatus('Address generated');
// Refresh to show updated state
await refresh();
} catch (error) {
showError(`Failed to generate address: ${error instanceof Error ? error.message : String(error)}`);
}
}, [appService, setStatus, showInfo, showError, refresh]);
}, [appService, setStatus, showError, refresh]);
/**
* Handles menu action.
@@ -209,8 +227,16 @@ export function WalletStateScreen(): React.ReactElement {
});
}, [history]);
// Handle keyboard navigation between panels
// Handle keyboard navigation between panels and QR dialog dismissal
useInput((input, key) => {
if (qrAddress) {
if (key.escape || key.return) {
setQrAddress(null);
setDialog(null);
}
return;
}
if (key.tab) {
setFocusedPanel(prev => prev === 'menu' ? 'history' : 'menu');
}
@@ -400,6 +426,26 @@ export function WalletStateScreen(): React.ReactElement {
Tab: Switch focus Enter: Select : Navigate Esc: Back
</Text>
</Box>
{/* QR Code dialog overlay for generated addresses */}
{qrAddress && (
<Box
position="absolute"
flexDirection="column"
alignItems="center"
width="100%"
>
<QRCode
value={qrAddress}
dialog
dialogTitle="Receive Address"
showValue
/>
<Box justifyContent="center" marginTop={1}>
<Text color={colors.textMuted}>Press Enter or Esc to close</Text>
</Box>
</Box>
)}
</Box>
);
}

View File

@@ -1,149 +1,22 @@
import React from 'react';
import { Box, Text, useInput } from 'ink';
import { Box, Text } from 'ink';
import { StepIndicator, type Step } from '../../components/ProgressBar.js';
import { Button } from '../../components/Button.js';
import { colors, logoSmall } from '../../theme.js';
import { useActionWizard } from './useActionWizard.js';
import { useActionWizard } from './hooks/useActionWizard.js';
import { useWizardKeyboard } from './hooks/useWizardKeyboard.js';
// Steps
import { InfoStep } from './steps/InfoStep.js';
import { RoleSelectStep } from './steps/RoleSelectStep.js';
import { VariablesStep } from './steps/VariablesStep.js';
import { InputsStep } from './steps/InputsStep.js';
import { ReviewStep } from './steps/ReviewStep.js';
import { PublishStep } from './steps/PublishStep.js';
import { DataResultStep } from './steps/DataResultStep.js';
export function ActionWizardScreen(): React.ReactElement {
const wizard = useActionWizard();
// ── Keyboard handling ──────────────────────────────────────────
useInput(
(input, key) => {
// Tab to cycle between content area and button bar
if (key.tab) {
if (wizard.focusArea === 'content') {
// Within the role-select step, tab through roles first
if (
wizard.currentStepData?.type === 'role-select' &&
wizard.availableRoles.length > 0
) {
if (
wizard.selectedRoleIndex <
wizard.availableRoles.length - 1
) {
wizard.setSelectedRoleIndex((prev) => prev + 1);
return;
}
}
// Within the inputs step, tab through UTXOs first
if (
wizard.currentStepData?.type === 'inputs' &&
wizard.availableUtxos.length > 0
) {
if (
wizard.selectedUtxoIndex <
wizard.availableUtxos.length - 1
) {
wizard.setSelectedUtxoIndex((prev) => prev + 1);
return;
}
}
// Move focus down to the button bar
wizard.setFocusArea('buttons');
wizard.setFocusedButton('next');
} else {
// Cycle through buttons, then wrap back to content
if (wizard.focusedButton === 'back') {
wizard.setFocusedButton('cancel');
} else if (wizard.focusedButton === 'cancel') {
wizard.setFocusedButton('next');
} else {
wizard.setFocusArea('content');
wizard.setFocusedInput(0);
wizard.setSelectedUtxoIndex(0);
wizard.setSelectedRoleIndex(0);
}
}
return;
}
// Arrow keys for role selection in the content area
if (
wizard.focusArea === 'content' &&
wizard.currentStepData?.type === 'role-select'
) {
if (key.upArrow) {
wizard.setSelectedRoleIndex((p) => Math.max(0, p - 1));
} else if (key.downArrow) {
wizard.setSelectedRoleIndex((p) =>
Math.min(wizard.availableRoles.length - 1, p + 1)
);
}
return;
}
// Arrow keys for UTXO selection in the content area
if (
wizard.focusArea === 'content' &&
wizard.currentStepData?.type === 'inputs'
) {
if (key.upArrow) {
wizard.setSelectedUtxoIndex((p) => Math.max(0, p - 1));
} else if (key.downArrow) {
wizard.setSelectedUtxoIndex((p) =>
Math.min(wizard.availableUtxos.length - 1, p + 1)
);
} else if (key.return || input === ' ') {
wizard.toggleUtxoSelection(wizard.selectedUtxoIndex);
}
return;
}
// Arrow keys in button bar
if (wizard.focusArea === 'buttons') {
if (key.leftArrow) {
wizard.setFocusedButton((p) =>
p === 'next' ? 'cancel' : p === 'cancel' ? 'back' : 'back'
);
} else if (key.rightArrow) {
wizard.setFocusedButton((p) =>
p === 'back' ? 'cancel' : p === 'cancel' ? 'next' : 'next'
);
}
// Enter on a button
if (key.return) {
if (wizard.focusedButton === 'back') wizard.previousStep();
else if (wizard.focusedButton === 'cancel') wizard.cancel();
else if (wizard.focusedButton === 'next') wizard.nextStep();
}
}
// 'c' to copy invitation ID on the publish step
if (
input === 'c' &&
wizard.currentStepData?.type === 'publish' &&
wizard.invitationId
) {
wizard.copyId();
}
// 'a' to select all UTXOs
if (input === 'a' && wizard.currentStepData?.type === 'inputs') {
wizard.setAvailableUtxos((p) =>
p.map((u) => ({ ...u, selected: true }))
);
}
// 'n' to deselect all UTXOs
if (input === 'n' && wizard.currentStepData?.type === 'inputs') {
wizard.setAvailableUtxos((p) =>
p.map((u) => ({ ...u, selected: false }))
);
}
},
{ isActive: !wizard.textInputHasFocus }
);
useWizardKeyboard(wizard);
// ── Step router ────────────────────────────────────────────────
const renderStep = () => {
@@ -152,15 +25,6 @@ export function ActionWizardScreen(): React.ReactElement {
}
switch (wizard.currentStepData?.type) {
case 'info':
return (
<InfoStep
template={wizard.template!}
actionIdentifier={wizard.actionIdentifier!}
roleIdentifier={wizard.roleIdentifier!}
actionName={wizard.actionName}
/>
);
case 'role-select':
return (
<RoleSelectStep
@@ -212,6 +76,14 @@ export function ActionWizardScreen(): React.ReactElement {
hasSignedAndBroadcasted={wizard.hasSignedAndBroadcasted}
/>
);
case 'result':
return (
<DataResultStep
actionName={wizard.actionName}
variables={wizard.variables}
dataResults={wizard.dataResults}
/>
);
default:
return null;
}
@@ -278,7 +150,7 @@ export function ActionWizardScreen(): React.ReactElement {
wizard.focusArea === "buttons" &&
wizard.focusedButton === "back"
}
disabled={wizard.currentStepData?.type === "publish"}
disabled={wizard.isLastStep}
/>
<Button
label="Cancel"
@@ -289,11 +161,7 @@ export function ActionWizardScreen(): React.ReactElement {
/>
</Box>
<Button
label={
wizard.currentStepData?.type === "publish"
? (wizard.canSignAndBroadcast ? "Sign & Broadcast" : "Done")
: "Next"
}
label={wizard.nextButtonLabel}
focused={
wizard.focusArea === "buttons" &&
wizard.focusedButton === "next"
@@ -313,4 +181,4 @@ export function ActionWizardScreen(): React.ReactElement {
</Box>
</Box>
);
}
}

View File

@@ -0,0 +1,40 @@
import type { FlowContext, StepType } from '../types.js';
import { WizardFlow } from './WizardFlow.js';
/**
* Flow strategy for data-only actions (e.g. sign, verify).
*
* These actions produce computed data rather than a transaction.
* No invitation, UTXOs, or fees are involved — just variables in,
* data result out.
*
* NOTE: Engine-level data action execution is not yet implemented.
* The result step is currently stubbed.
*/
export class DataWizardFlow extends WizardFlow {
readonly type = 'data' as const;
/** The data field identifiers this action produces (from action.data). */
readonly dataOutputs: string[];
constructor(dataOutputs: string[]) {
super();
this.dataOutputs = dataOutputs;
}
getStepTypes(context: FlowContext): StepType[] {
const steps: StepType[] = [];
if (context.availableRoles.length > 1) steps.push('role-select');
if (context.hasVariables) steps.push('variables');
steps.push('result');
return steps;
}
canFinalize(): boolean {
return false;
}
getFinalActionLabel(): string {
return 'Done';
}
}

View File

@@ -0,0 +1,36 @@
import type { FlowContext, StepType } from '../types.js';
import { WizardFlow } from './WizardFlow.js';
/**
* Flow strategy for transaction-based actions.
*
* Handles both single-role actions (sendSatoshis, burn) where the
* creator provides inputs and signs locally, and multi-role actions
* (receive, request) where the creator publishes an invitation for
* another party to complete.
*/
export class TransactionWizardFlow extends WizardFlow {
readonly type = 'transaction' as const;
getStepTypes(context: FlowContext): StepType[] {
const steps: StepType[] = [];
if (context.availableRoles.length > 1) steps.push('role-select');
if (context.hasVariables) steps.push('variables');
if (context.shouldCollectInputs) steps.push('inputs');
steps.push('review');
steps.push('publish');
return steps;
}
canFinalize(context: FlowContext): boolean {
return (
context.requirementsComplete &&
context.wizardCollectedInputs &&
!context.hasSignedAndBroadcasted
);
}
getFinalActionLabel(context: FlowContext): string {
return this.canFinalize(context) ? 'Sign & Broadcast' : 'Done';
}
}

View File

@@ -0,0 +1,22 @@
import type { FlowContext, StepType } from '../types.js';
/**
* Abstract strategy that defines the shape of a wizard flow.
*
* Subclasses declare which steps are needed, whether the action can be
* finalized, and what the final button should say. They hold no React
* state — the orchestrator hook wires domain hooks to the step configs
* produced from these methods.
*/
export abstract class WizardFlow {
abstract readonly type: 'transaction' | 'data';
/** Determine which step types this flow needs given the current context. */
abstract getStepTypes(context: FlowContext): StepType[];
/** Whether the action can be finalized (e.g. signed & broadcast). */
abstract canFinalize(context: FlowContext): boolean;
/** Label for the primary action button on the final step. */
abstract getFinalActionLabel(context: FlowContext): string;
}

View File

@@ -0,0 +1,21 @@
import type { XOTemplateAction } from '@xo-cash/types';
import { TransactionWizardFlow } from './TransactionWizardFlow.js';
import { DataWizardFlow } from './DataWizardFlow.js';
import type { WizardFlow } from './WizardFlow.js';
export { WizardFlow } from './WizardFlow.js';
export { TransactionWizardFlow } from './TransactionWizardFlow.js';
export { DataWizardFlow } from './DataWizardFlow.js';
/**
* Inspect a template action and return the appropriate wizard flow strategy.
*
* Actions with `data` fields and no `transaction` are data-only flows.
* Everything else uses the transaction flow.
*/
export function createWizardFlow(action: XOTemplateAction): WizardFlow {
if (action.data?.length && !action.transaction) {
return new DataWizardFlow(action.data);
}
return new TransactionWizardFlow();
}

View File

@@ -0,0 +1,417 @@
import { useState, useMemo, useCallback, useEffect } from 'react';
import { useNavigation } from '../../../hooks/useNavigation.js';
import { useAppContext, useStatus } from '../../../hooks/useAppContext.js';
import { copyToClipboard } from '../../../utils/clipboard.js';
import { roleRequiresInputs } from '../../../../utils/invitation-flow.js';
import type { XOTemplate } from '@xo-cash/types';
import type { StepConfig, FlowContext, DataResult } from '../types.js';
import { createWizardFlow, type WizardFlow, DataWizardFlow } from '../flows/index.js';
import { useRoleSelection } from './useRoleSelection.js';
import { useVariableInputs } from './useVariableInputs.js';
import { useUtxoSelection } from './useUtxoSelection.js';
import { useInvitationManager } from './useInvitationManager.js';
import { useWizardFocus } from './useWizardFocus.js';
import { useWizardSteps } from './useWizardSteps.js';
/**
* Thin orchestrator that composes domain hooks and wires them
* to step configs produced by the WizardFlow strategy.
*
* This replaces the original 861-line god-hook.
*/
export function useActionWizard() {
const { goBack, data: navData } = useNavigation();
const { appService, showError, showInfo } = useAppContext();
const { setStatus } = useStatus();
if (!appService) {
throw new Error('AppService not initialized');
}
// ── Navigation data ───────────────────────────────────────────
const templateIdentifier = navData.templateIdentifier as string | undefined;
const actionIdentifier = navData.actionIdentifier as string | undefined;
const template = navData.template as XOTemplate | undefined;
const actionRolesFromNavigation = navData.actionRoles as string[] | undefined;
// ── Derived template data ─────────────────────────────────────
const action = template?.actions?.[actionIdentifier ?? ''];
const actionName = action?.name || actionIdentifier || 'Unknown';
// ── Flow strategy ─────────────────────────────────────────────
const flow = useMemo<WizardFlow>(() => {
// Create a default action if no action is found
if (!action) {
return createWizardFlow({ name: '', description: '' });
}
// Create the flow from the action
return createWizardFlow(action);
}, [action]);
// ── Domain hooks ──────────────────────────────────────────────
const roleSelection = useRoleSelection(template, actionIdentifier, actionRolesFromNavigation);
const variableInputs = useVariableInputs();
const utxoSelection = useUtxoSelection();
const invitationManager = useInvitationManager({ appService, showError, showInfo, setStatus });
const focus = useWizardFocus();
// ── Data results (data-only flows) ────────────────────────────
const [dataResults, setDataResults] = useState<DataResult[]>([]);
// ── Initialize variables when role becomes available ──────────
useEffect(() => {
if (template && actionIdentifier && roleSelection.effectiveRole) {
const act = template.actions?.[actionIdentifier];
const role = act?.roles?.[roleSelection.effectiveRole];
const varIds = role?.requirements?.variables;
if (varIds && varIds.length > 0) {
variableInputs.initFromTemplate(template, actionIdentifier, roleSelection.effectiveRole);
}
}
}, [template, actionIdentifier, roleSelection.effectiveRole, variableInputs.initFromTemplate]);
// ── Determine whether creator should provide inputs ───────────
const shouldCollectInputs = useMemo(() => {
if (flow.type !== 'transaction') return false;
if (!template || !actionIdentifier || !roleSelection.effectiveRole) return false;
const act = template.actions?.[actionIdentifier];
const totalActionRoles = Object.keys(act?.roles ?? {}).length;
const isSingleRoleAction = totalActionRoles <= 1;
return isSingleRoleAction && roleRequiresInputs(template, actionIdentifier, roleSelection.effectiveRole);
}, [flow.type, template, actionIdentifier, roleSelection.effectiveRole]);
// ── Build flow context for strategy methods ───────────────────
const flowContext = useMemo<FlowContext>(() => ({
availableRoles: roleSelection.availableRoles,
hasVariables: variableInputs.variables.length > 0,
shouldCollectInputs,
requirementsComplete: invitationManager.requirementsComplete,
wizardCollectedInputs: shouldCollectInputs,
hasSignedAndBroadcasted: invitationManager.hasSignedAndBroadcasted,
}), [
roleSelection.availableRoles,
variableInputs.variables.length,
shouldCollectInputs,
invitationManager.requirementsComplete,
invitationManager.hasSignedAndBroadcasted,
]);
// ── Handle Enter inside a TextInput ───────────────────────────
const handleTextInputSubmit = useCallback(() => {
if (focus.focusedInput < variableInputs.variables.length - 1) {
focus.setFocusedInput((prev) => prev + 1);
} else {
focus.moveToButtons();
}
}, [focus.focusedInput, variableInputs.variables.length, focus.setFocusedInput, focus.moveToButtons]);
// ── Copy invitation ID to clipboard ───────────────────────────
const copyId = useCallback(async () => {
if (!invitationManager.invitationId) return;
try {
await copyToClipboard(invitationManager.invitationId);
showInfo(`Copied to clipboard!\n\n${invitationManager.invitationId}`);
} catch (error) {
showError(
`Failed to copy: ${error instanceof Error ? error.message : String(error)}`,
);
}
}, [invitationManager.invitationId, showInfo, showError]);
// ── Helper: create invitation if it doesn't exist yet ─────────
const ensureInvitation = useCallback(async (roleId?: string): Promise<string | null> => {
if (invitationManager.invitationId) return invitationManager.invitationId;
const role = roleId ?? roleSelection.effectiveRole;
if (!templateIdentifier || !actionIdentifier || !role || !template) return null;
return invitationManager.createWithVariables(
templateIdentifier, actionIdentifier, role, template, variableInputs.variables,
);
}, [
invitationManager.invitationId,
invitationManager.createWithVariables,
roleSelection.effectiveRole,
templateIdentifier,
actionIdentifier,
template,
variableInputs.variables,
]);
// ── Helper: load UTXOs after invitation is created ────────────
const loadUtxosForInvitation = useCallback(async (invId: string) => {
if (!appService || !templateIdentifier) return;
const instance = appService.invitations.find(
(inv) => inv.data.invitationIdentifier === invId,
);
if (instance) {
invitationManager.setIsProcessing(true);
try {
await utxoSelection.loadUtxos(instance, templateIdentifier, variableInputs.variables, setStatus);
} finally {
invitationManager.setIsProcessing(false);
}
}
}, [appService, templateIdentifier, variableInputs.variables, utxoSelection.loadUtxos, invitationManager.setIsProcessing, setStatus]);
// ── Build step configs from flow strategy ─────────────────────
const stepConfigs = useMemo<StepConfig[]>(() => {
const stepTypes = flow.getStepTypes(flowContext);
return stepTypes.map((type): StepConfig => {
switch (type) {
case 'role-select':
return {
type,
name: 'Select Role',
validate: () => {
const selectedRole = roleSelection.availableRoles[roleSelection.selectedRoleIndex];
return selectedRole ? null : 'Please select a role';
},
onNext: async () => {
const selectedRole = roleSelection.availableRoles[roleSelection.selectedRoleIndex];
if (!selectedRole) return false;
// Initialize variables for this role immediately
if (template && actionIdentifier) {
const act = template.actions?.[actionIdentifier];
const role = act?.roles?.[selectedRole];
const hasVars = (role?.requirements?.variables?.length ?? 0) > 0;
if (hasVars) {
variableInputs.initFromTemplate(template, actionIdentifier, selectedRole);
}
// If no variables step follows, create the invitation now (transaction flows only)
if (!hasVars && flow.type === 'transaction') {
if (templateIdentifier && template) {
const invId = await invitationManager.createWithVariables(
templateIdentifier, actionIdentifier, selectedRole, template, [],
);
if (!invId) return false;
// Pre-load UTXOs if the inputs step follows
const totalRoles = Object.keys(act?.roles ?? {}).length;
const needsInputs = totalRoles <= 1 && roleRequiresInputs(template, actionIdentifier, selectedRole);
if (needsInputs) {
await loadUtxosForInvitation(invId);
}
}
}
}
roleSelection.setRoleIdentifier(selectedRole);
focus.resetToContent();
return true;
},
};
case 'variables':
return {
type,
name: 'Variables',
validate: () => variableInputs.validate(),
onNext: async () => {
if (flow.type === 'transaction') {
if (!templateIdentifier || !actionIdentifier || !template || !roleSelection.effectiveRole) return false;
const invId = await invitationManager.createWithVariables(
templateIdentifier, actionIdentifier, roleSelection.effectiveRole,
template, variableInputs.variables,
);
if (!invId) return false;
// Pre-load UTXOs if the inputs step follows
if (shouldCollectInputs) {
await loadUtxosForInvitation(invId);
}
}
// For data flows, just advance — variables are used in the result step
focus.resetToContent();
return true;
},
};
case 'inputs':
return {
type,
name: 'Select UTXOs',
validate: () => utxoSelection.validate(),
onNext: async () => {
const selectedUtxos = utxoSelection.availableUtxos.filter((u) => u.selected);
const success = await invitationManager.addInputsAndOutputs(selectedUtxos, utxoSelection.changeAmount);
if (success) focus.resetToContent();
return success;
},
};
case 'review':
return {
type,
name: 'Review',
validate: () => null,
onNext: async () => {
// Ensure invitation exists (covers the case where no prior step created it)
const invId = await ensureInvitation();
if (!invId) return false;
await invitationManager.refreshRequirements(invId);
focus.resetToContent();
return true;
},
};
case 'publish':
return {
type,
name: 'Publish',
validate: () => null,
onNext: async () => {
if (flow.canFinalize(flowContext)) {
await invitationManager.signAndBroadcast();
// Stay on publish step (it's the last step, stepper won't advance)
return true;
}
goBack();
return true;
},
};
case 'result':
return {
type,
name: 'Result',
validate: () => null,
onNext: async () => {
// Data-only flows: populate stubbed results, then exit
if (flow instanceof DataWizardFlow) {
const results: DataResult[] = flow.dataOutputs.map((dataId) => {
const dataDef = template?.data?.[dataId];
return {
id: dataId,
name: dataDef?.hint ?? dataId,
type: dataDef?.type ?? 'unknown',
hint: dataDef?.hint,
value: null, // Engine-level data execution not yet implemented
};
});
setDataResults(results);
}
goBack();
return true;
},
};
default:
return { type, name: type, validate: () => null, onNext: async () => true };
}
});
}, [
flow, flowContext, roleSelection, variableInputs, utxoSelection,
invitationManager, focus, template, templateIdentifier, actionIdentifier,
shouldCollectInputs, ensureInvitation, loadUtxosForInvitation, goBack, setStatus,
]);
// ── Step navigation ───────────────────────────────────────────
const stepper = useWizardSteps(stepConfigs, goBack, showError);
// ── Set initial status ────────────────────────────────────────
useEffect(() => {
if (!template || !actionIdentifier) {
showError('Missing wizard data');
goBack();
return;
}
setStatus(
roleSelection.effectiveRole
? `${actionIdentifier}/${roleSelection.effectiveRole}`
: actionIdentifier,
);
}, [template, actionIdentifier, roleSelection.effectiveRole, showError, goBack, setStatus]);
// ── Convenience derived values ────────────────────────────────
const textInputHasFocus =
stepper.currentStepData?.type === 'variables' && focus.focusArea === 'content';
const canSignAndBroadcast = flow.canFinalize(flowContext);
const isLastStep = stepper.currentStep >= stepper.steps.length - 1;
const lastStepType = stepper.currentStepData?.type;
const nextButtonLabel =
lastStepType === 'publish'
? flow.getFinalActionLabel(flowContext)
: lastStepType === 'result'
? 'Done'
: 'Next';
// ── Public API ────────────────────────────────────────────────
return {
// Meta
template,
templateIdentifier,
actionIdentifier,
roleIdentifier: roleSelection.effectiveRole,
action,
actionName,
flow,
flowContext,
// Role selection
availableRoles: roleSelection.availableRoles,
selectedRoleIndex: roleSelection.selectedRoleIndex,
setSelectedRoleIndex: roleSelection.setSelectedRoleIndex,
// Steps
steps: stepper.steps,
currentStep: stepper.currentStep,
currentStepData: stepper.currentStepData,
// Variables
variables: variableInputs.variables,
updateVariable: variableInputs.updateVariable,
handleTextInputSubmit,
// UTXOs
availableUtxos: utxoSelection.availableUtxos,
selectedUtxoIndex: utxoSelection.selectedUtxoIndex,
setSelectedUtxoIndex: utxoSelection.setSelectedUtxoIndex,
requiredAmount: utxoSelection.requiredAmount,
fee: utxoSelection.fee,
selectedAmount: utxoSelection.selectedAmount,
changeAmount: utxoSelection.changeAmount,
toggleUtxoSelection: utxoSelection.toggleSelection,
selectAll: utxoSelection.selectAll,
deselectAll: utxoSelection.deselectAll,
// Invitation
invitation: invitationManager.invitation,
invitationId: invitationManager.invitationId,
requirementsComplete: invitationManager.requirementsComplete,
hasSignedAndBroadcasted: invitationManager.hasSignedAndBroadcasted,
canSignAndBroadcast,
// Data results
dataResults,
// UI focus
focusedInput: focus.focusedInput,
setFocusedInput: focus.setFocusedInput,
focusedButton: focus.focusedButton,
setFocusedButton: focus.setFocusedButton,
focusArea: focus.focusArea,
setFocusArea: focus.setFocusArea,
isProcessing: invitationManager.isProcessing,
textInputHasFocus,
nextButtonLabel,
isLastStep,
// Actions
nextStep: stepper.nextStep,
previousStep: stepper.previousStep,
cancel: stepper.cancel,
copyId,
} as const;
}
/** Convenience type so other files can type the return value. */
export type ActionWizardState = ReturnType<typeof useActionWizard>;

View File

@@ -0,0 +1,259 @@
import { useState, useCallback } from 'react';
import type { XOTemplate, XOInvitation, XOTemplateTransactionOutput } from '@xo-cash/types';
import type { VariableInput, SelectableUTXO } from '../types.js';
import {
getTransactionOutputIdentifier,
isInvitationRequirementsComplete,
resolveProvidedLockingBytecodeHex,
} from '../../../../utils/invitation-flow.js';
import type { AppService } from '../../../../services/app.js';
interface InvitationManagerDeps {
appService: AppService;
showError: (msg: string) => void;
showInfo: (msg: string) => void;
setStatus: (msg: string) => void;
}
/**
* Manages the full invitation lifecycle for transaction-based actions:
* creation, variable persistence, output generation, input addition,
* signing, and broadcasting.
*
* Only relevant for TransactionWizardFlow — data flows bypass this entirely.
*/
export function useInvitationManager(deps: InvitationManagerDeps) {
const { appService, showError, showInfo, setStatus } = deps;
const [invitation, setInvitation] = useState<XOInvitation | null>(null);
const [invitationId, setInvitationId] = useState<string | null>(null);
const [requirementsComplete, setRequirementsComplete] = useState(false);
const [hasSignedAndBroadcasted, setHasSignedAndBroadcasted] = useState(false);
const [isProcessing, setIsProcessing] = useState(false);
/** Re-check whether all invitation requirements are satisfied. */
const refreshRequirements = useCallback(async (
identifier: string | null = invitationId,
): Promise<boolean> => {
if (!identifier || !appService) {
setRequirementsComplete(false);
return false;
}
const instance = appService.invitations.find(
(inv: any) => inv.data.invitationIdentifier === identifier,
);
if (!instance) {
setRequirementsComplete(false);
return false;
}
const complete = await isInvitationRequirementsComplete(instance);
setRequirementsComplete(complete);
return complete;
}, [appService, invitationId]);
/**
* Create an invitation, persist variable values, and add
* template-required transaction outputs.
*
* @returns The invitation identifier on success, or null on failure.
*/
const createWithVariables = useCallback(async (
templateIdentifier: string,
actionIdentifier: string,
roleIdentifier: string,
template: XOTemplate,
variables: VariableInput[],
): Promise<string | null> => {
if (!appService) return null;
setIsProcessing(true);
setStatus('Creating invitation...');
try {
// Create via the engine
const xoInvitation = await appService.engine.createInvitation({
templateIdentifier,
actionIdentifier,
});
// Wrap and track
const invitationInstance = await appService.createInvitation(xoInvitation);
let inv = invitationInstance.data;
const invId = inv.invitationIdentifier;
setInvitationId(invId);
// Persist variable values
if (variables.length > 0) {
setStatus('Adding variables...');
const variableData = variables.map((v) => {
const isNumeric =
['integer', 'number', 'satoshis'].includes(v.type) ||
(v.hint && ['satoshis', 'amount'].includes(v.hint));
return {
variableIdentifier: v.id,
roleIdentifier,
value: isNumeric ? BigInt(v.value || '0') : v.value,
};
});
await invitationInstance.addVariables(variableData);
inv = invitationInstance.data;
}
// Build variable values lookup for output resolution
const variableValuesByIdentifier = variables.reduce((acc, variable) => {
if (typeof variable.value === 'string' && variable.value.trim().length > 0) {
acc[variable.id] = variable.value;
}
return acc;
}, {} as Record<string, string>);
// Add template-required transaction outputs
const act = template.actions?.[actionIdentifier];
const transaction = act?.transaction
? template.transactions?.[act.transaction]
: null;
if (transaction?.outputs && transaction.outputs.length > 0) {
setStatus('Adding required outputs...');
const outputsToAdd = await Promise.all(
transaction.outputs.map(async (output: XOTemplateTransactionOutput) => {
const outputIdentifier = getTransactionOutputIdentifier(output);
if (!outputIdentifier) {
throw new Error('Invalid transaction output definition');
}
const providedHex = resolveProvidedLockingBytecodeHex(
template,
outputIdentifier,
variableValuesByIdentifier,
);
const lockingBytecodeHex =
providedHex ?? await invitationInstance.generateLockingBytecode(outputIdentifier, roleIdentifier);
return { outputIdentifier, lockingBytecode: lockingBytecodeHex };
}),
);
// TODO: Clean this up. Suggestions: 1. Convert to bytes above. 2. Have addOutputs accept a hex string. 3. Have addOutputs handle lockscript generation.
await invitationInstance.addOutputs(
outputsToAdd.map((output) => ({
outputIdentifier: output.outputIdentifier,
lockingBytecode: new Uint8Array(Buffer.from(output.lockingBytecode, 'hex')),
})),
);
inv = invitationInstance.data;
}
setInvitation(inv);
await refreshRequirements(invId);
setStatus('Invitation created');
return invId;
} catch (error) {
showError(
`Failed to create invitation: ${error instanceof Error ? error.message : String(error)}`,
);
return null;
} finally {
setIsProcessing(false);
}
}, [appService, showError, setStatus, refreshRequirements]);
/**
* Add the selected UTXOs as inputs and a change output to the invitation.
*
* @returns true on success, false on failure.
*/
const addInputsAndOutputs = useCallback(async (
selectedUtxos: SelectableUTXO[],
changeAmount: bigint,
): Promise<boolean> => {
if (!invitationId || !appService) return false;
setIsProcessing(true);
setStatus('Adding inputs and outputs...');
try {
const instance = appService.invitations.find(
(inv: any) => inv.data.invitationIdentifier === invitationId,
);
if (!instance) throw new Error('Invitation not found');
const inputs = selectedUtxos.map((utxo) => ({
outpointTransactionHash: new Uint8Array(
Buffer.from(utxo.outpointTransactionHash, 'hex'),
),
outpointIndex: utxo.outpointIndex,
}));
await instance.addInputs(inputs);
await instance.addOutputs([{ valueSatoshis: changeAmount }]);
await refreshRequirements(invitationId);
setStatus('Inputs and outputs added');
return true;
} catch (error) {
showError(
`Failed to add inputs/outputs: ${error instanceof Error ? error.message : String(error)}`,
);
return false;
} finally {
setIsProcessing(false);
}
}, [invitationId, appService, showError, setStatus, refreshRequirements]);
/** Sign the invitation and broadcast the transaction. */
const signAndBroadcast = useCallback(async (): Promise<boolean> => {
if (!invitationId || !appService) return false;
setIsProcessing(true);
setStatus('Signing invitation...');
try {
const instance = appService.invitations.find(
(inv: any) => inv.data.invitationIdentifier === invitationId,
);
if (!instance) throw new Error('Invitation not found');
const complete = await refreshRequirements(invitationId);
if (!complete) {
showError('Invitation requirements are not complete yet.');
return false;
}
await instance.sign();
setStatus('Broadcasting transaction...');
await instance.broadcast();
setHasSignedAndBroadcasted(true);
setStatus('Transaction signed and broadcasted');
showInfo('Transaction signed and broadcasted.');
await refreshRequirements(invitationId);
return true;
} catch (error) {
showError(
`Failed to sign and broadcast: ${error instanceof Error ? error.message : String(error)}`,
);
return false;
} finally {
setIsProcessing(false);
}
}, [invitationId, appService, setStatus, showError, showInfo, refreshRequirements]);
return {
invitation,
invitationId,
requirementsComplete,
hasSignedAndBroadcasted,
isProcessing,
setIsProcessing,
refreshRequirements,
createWithVariables,
addInputsAndOutputs,
signAndBroadcast,
} as const;
}
export type InvitationManagerState = ReturnType<typeof useInvitationManager>;

View File

@@ -0,0 +1,46 @@
import { useState, useEffect, useMemo } from 'react';
import type { XOTemplate } from '@xo-cash/types';
import { resolveActionRoles } from '../../../../utils/invitation-flow.js';
/**
* Manages role selection state for the wizard.
*
* Derives the list of available roles from the template and auto-selects
* when only one role exists for the action.
*/
export function useRoleSelection(
template: XOTemplate | undefined,
actionIdentifier: string | undefined,
actionRolesFromNavigation: string[] | undefined,
) {
const [roleIdentifier, setRoleIdentifier] = useState<string | undefined>();
const [selectedRoleIndex, setSelectedRoleIndex] = useState(0);
/** Roles that can start this action, derived from template start entries. */
const availableRoles = useMemo(() => {
return resolveActionRoles(template, actionIdentifier, actionRolesFromNavigation);
}, [template, actionIdentifier, actionRolesFromNavigation]);
/** The role to use for the flow — either explicitly selected or auto-selected when only one exists. */
const effectiveRole = roleIdentifier ?? (
availableRoles.length === 1 ? availableRoles[0] : undefined
);
// Auto-select when only one role exists.
useEffect(() => {
if (!roleIdentifier && availableRoles.length === 1) {
setRoleIdentifier(availableRoles[0]);
}
}, [roleIdentifier, availableRoles]);
return {
roleIdentifier,
setRoleIdentifier,
selectedRoleIndex,
setSelectedRoleIndex,
availableRoles,
effectiveRole,
} as const;
}
export type RoleSelectionState = ReturnType<typeof useRoleSelection>;

View File

@@ -0,0 +1,117 @@
import { useState, useCallback, useMemo } from 'react';
import type { SelectableUTXO, VariableInput } from '../types.js';
import type { Invitation } from '../../../../services/invitation.js';
import { formatSatoshis } from '../../../theme.js';
import {
autoSelectGreedyUtxos,
mapUnspentOutputsToSelectable,
} from '../../../../utils/invitation-flow.js';
/**
* Manages UTXO selection state for the wizard's inputs step.
*
* Only active for transaction flows that require the creator
* to provide funding inputs.
*/
export function useUtxoSelection() {
const [availableUtxos, setAvailableUtxos] = useState<SelectableUTXO[]>([]);
const [selectedUtxoIndex, setSelectedUtxoIndex] = useState(0);
const [requiredAmount, setRequiredAmount] = useState<bigint>(0n);
const [fee, setFee] = useState<bigint>(500n);
const selectedAmount = useMemo(
() => availableUtxos.filter((u) => u.selected).reduce((sum, u) => sum + u.valueSatoshis, 0n),
[availableUtxos],
);
const changeAmount = useMemo(
() => selectedAmount - requiredAmount - fee,
[selectedAmount, requiredAmount, fee],
);
/** Toggle the selected state of a single UTXO. */
const toggleSelection = useCallback((index: number) => {
setAvailableUtxos((prev) => {
const updated = [...prev];
const utxo = updated[index];
if (utxo) {
updated[index] = { ...utxo, selected: !utxo.selected };
}
return updated;
});
}, []);
/** Select all available UTXOs. */
const selectAll = useCallback(() => {
setAvailableUtxos((prev) => prev.map((u) => ({ ...u, selected: true })));
}, []);
/** Deselect all UTXOs. */
const deselectAll = useCallback(() => {
setAvailableUtxos((prev) => prev.map((u) => ({ ...u, selected: false })));
}, []);
/**
* Query the invitation instance for suitable UTXOs and auto-select
* greedily to meet the required amount.
*/
const loadUtxos = useCallback(async (
invitationInstance: Invitation,
templateIdentifier: string,
variables: VariableInput[],
setStatus: (msg: string) => void,
): Promise<void> => {
setStatus('Finding suitable UTXOs...');
// Derive required amount from variables that look like satoshi/amount fields.
const requestedVar = variables.find(
(v) =>
v.id.toLowerCase().includes('satoshi') ||
v.id.toLowerCase().includes('amount'),
);
const requested = requestedVar ? BigInt(requestedVar.value || '0') : 0n;
setRequiredAmount(requested);
const unspentOutputs = await invitationInstance.findSuitableResources({
templateIdentifier,
});
const mapped = mapUnspentOutputsToSelectable(unspentOutputs);
const autoSelected = autoSelectGreedyUtxos(mapped, requested + fee);
setAvailableUtxos(autoSelected as SelectableUTXO[]);
setStatus('Ready');
}, [fee]);
/** Validate that the selection meets the required amounts. */
const validate = useCallback((): string | null => {
const selected = availableUtxos.filter((u) => u.selected);
if (selected.length === 0) {
return 'Please select at least one UTXO';
}
if (selectedAmount < requiredAmount + fee) {
return `Insufficient funds. Need ${formatSatoshis(requiredAmount + fee)}, selected ${formatSatoshis(selectedAmount)}`;
}
if (changeAmount < 546n) {
return `Change amount (${changeAmount}) is below dust threshold (546 sats)`;
}
return null;
}, [availableUtxos, selectedAmount, requiredAmount, fee, changeAmount]);
return {
availableUtxos,
setAvailableUtxos,
selectedUtxoIndex,
setSelectedUtxoIndex,
requiredAmount,
fee,
selectedAmount,
changeAmount,
toggleSelection,
selectAll,
deselectAll,
loadUtxos,
validate,
} as const;
}
export type UtxoSelectionState = ReturnType<typeof useUtxoSelection>;

View File

@@ -0,0 +1,70 @@
import { useState, useCallback } from 'react';
import type { XOTemplate } from '@xo-cash/types';
import type { VariableInput } from '../types.js';
/**
* Manages the variable input state for the wizard's variables step.
*
* Populates variables from the template's action/role requirements
* and provides validation + update helpers.
*/
export function useVariableInputs() {
const [variables, setVariables] = useState<VariableInput[]>([]);
/**
* Populate the variable list from the template's role requirements.
* Calling this again replaces the current variables entirely.
*/
const initFromTemplate = useCallback((
template: XOTemplate,
actionIdentifier: string,
roleIdentifier: string,
) => {
const action = template.actions?.[actionIdentifier];
const role = action?.roles?.[roleIdentifier];
const varIds = role?.requirements?.variables ?? [];
const varInputs: VariableInput[] = varIds.map((varId) => {
const varDef = template.variables?.[varId];
return {
id: varId,
name: varDef?.name || varId,
type: varDef?.type || 'string',
hint: varDef?.hint,
value: '',
};
});
setVariables(varInputs);
}, []);
/** Update a single variable's value by index. */
const updateVariable = useCallback((index: number, value: string) => {
setVariables((prev) => {
const updated = [...prev];
const variable = updated[index];
if (variable) {
updated[index] = { ...variable, value };
}
return updated;
});
}, []);
/** Returns an error message if any required variable is empty, or null if valid. */
const validate = useCallback((): string | null => {
const emptyVars = variables.filter((v) => !v.value || v.value.trim() === '');
if (emptyVars.length > 0) {
return `Please enter values for: ${emptyVars.map((v) => v.name).join(', ')}`;
}
return null;
}, [variables]);
return {
variables,
setVariables,
initFromTemplate,
updateVariable,
validate,
} as const;
}
export type VariableInputsState = ReturnType<typeof useVariableInputs>;

View File

@@ -0,0 +1,37 @@
import { useState, useCallback } from 'react';
import type { FocusArea, ButtonFocus } from '../types.js';
/**
* Manages which area of the wizard UI has keyboard focus and
* which specific element within that area is highlighted.
*/
export function useWizardFocus() {
const [focusArea, setFocusArea] = useState<FocusArea>('content');
const [focusedButton, setFocusedButton] = useState<ButtonFocus>('next');
const [focusedInput, setFocusedInput] = useState(0);
/** Reset focus to the content area at the first element. */
const resetToContent = useCallback(() => {
setFocusArea('content');
setFocusedInput(0);
}, []);
/** Move focus to the button bar. */
const moveToButtons = useCallback((button: ButtonFocus = 'next') => {
setFocusArea('buttons');
setFocusedButton(button);
}, []);
return {
focusArea,
setFocusArea,
focusedButton,
setFocusedButton,
focusedInput,
setFocusedInput,
resetToContent,
moveToButtons,
} as const;
}
export type WizardFocusState = ReturnType<typeof useWizardFocus>;

View File

@@ -0,0 +1,149 @@
import { useInput } from 'ink';
import type { ActionWizardState } from './useActionWizard.js';
/**
* Keyboard input handler for the action wizard.
*
* Dispatches key presses to step-specific handlers based on the
* current step type and focus area. Extracted from the screen
* component to keep it purely presentational.
*/
export function useWizardKeyboard(wizard: ActionWizardState): void {
useInput(
(input, key) => {
// ── Tab: cycle through content and button bar ─────────
if (key.tab) {
handleTab(wizard);
return;
}
// ── Content-area: step-specific input handling ────────
if (wizard.focusArea === 'content') {
if (wizard.currentStepData?.type === 'role-select') {
handleRoleSelectInput(wizard, input, key);
return;
}
if (wizard.currentStepData?.type === 'inputs') {
handleInputsStepInput(wizard, input, key);
return;
}
}
// ── Button bar navigation + activation ────────────────
if (wizard.focusArea === 'buttons') {
handleButtonBarInput(wizard, key);
}
// ── Global shortcuts ──────────────────────────────────
if (input === 'c' && wizard.currentStepData?.type === 'publish' && wizard.invitationId) {
wizard.copyId();
}
if (input === 'a' && wizard.currentStepData?.type === 'inputs') {
wizard.selectAll();
}
if (input === 'n' && wizard.currentStepData?.type === 'inputs') {
wizard.deselectAll();
}
},
{ isActive: !wizard.textInputHasFocus },
);
}
// ── Tab cycling ─────────────────────────────────────────────────
function handleTab(wizard: ActionWizardState): void {
if (wizard.focusArea === 'content') {
// Within role-select, tab through roles before moving to buttons
if (
wizard.currentStepData?.type === 'role-select' &&
wizard.availableRoles.length > 0 &&
wizard.selectedRoleIndex < wizard.availableRoles.length - 1
) {
wizard.setSelectedRoleIndex((prev) => prev + 1);
return;
}
// Within inputs, tab through UTXOs before moving to buttons
if (
wizard.currentStepData?.type === 'inputs' &&
wizard.availableUtxos.length > 0 &&
wizard.selectedUtxoIndex < wizard.availableUtxos.length - 1
) {
wizard.setSelectedUtxoIndex((prev) => prev + 1);
return;
}
// Move to button bar
wizard.setFocusArea('buttons');
wizard.setFocusedButton('next');
} else {
// Cycle through buttons, then wrap back to content
if (wizard.focusedButton === 'back') {
wizard.setFocusedButton('cancel');
} else if (wizard.focusedButton === 'cancel') {
wizard.setFocusedButton('next');
} else {
wizard.setFocusArea('content');
wizard.setFocusedInput(0);
wizard.setSelectedUtxoIndex(0);
wizard.setSelectedRoleIndex(0);
}
}
}
// ── Role-select step ────────────────────────────────────────────
function handleRoleSelectInput(
wizard: ActionWizardState,
_input: string,
key: { upArrow: boolean; downArrow: boolean },
): void {
if (key.upArrow) {
wizard.setSelectedRoleIndex((p) => Math.max(0, p - 1));
} else if (key.downArrow) {
wizard.setSelectedRoleIndex((p) =>
Math.min(wizard.availableRoles.length - 1, p + 1),
);
}
}
// ── Inputs step (UTXO selection) ────────────────────────────────
function handleInputsStepInput(
wizard: ActionWizardState,
input: string,
key: { upArrow: boolean; downArrow: boolean; return: boolean },
): void {
if (key.upArrow) {
wizard.setSelectedUtxoIndex((p) => Math.max(0, p - 1));
} else if (key.downArrow) {
wizard.setSelectedUtxoIndex((p) =>
Math.min(wizard.availableUtxos.length - 1, p + 1),
);
} else if (key.return || input === ' ') {
wizard.toggleUtxoSelection(wizard.selectedUtxoIndex);
}
}
// ── Button bar ──────────────────────────────────────────────────
function handleButtonBarInput(
wizard: ActionWizardState,
key: { leftArrow: boolean; rightArrow: boolean; return: boolean },
): void {
if (key.leftArrow) {
wizard.setFocusedButton((p) =>
p === 'next' ? 'cancel' : p === 'cancel' ? 'back' : 'back',
);
} else if (key.rightArrow) {
wizard.setFocusedButton((p) =>
p === 'back' ? 'cancel' : p === 'cancel' ? 'next' : 'next',
);
}
if (key.return) {
if (wizard.focusedButton === 'back') wizard.previousStep();
else if (wizard.focusedButton === 'cancel') wizard.cancel();
else if (wizard.focusedButton === 'next') wizard.nextStep();
}
}

View File

@@ -0,0 +1,73 @@
import { useState, useCallback, useMemo } from 'react';
import type { StepConfig, WizardStep } from '../types.js';
/**
* Generic step navigation driven by an array of StepConfig objects.
*
* The orchestrator builds the StepConfig[] from the flow strategy
* and domain hooks; this hook just manages the step index and
* delegates validate/onNext to the current config.
*/
export function useWizardSteps(
stepConfigs: StepConfig[],
onCancel: () => void,
showError: (msg: string) => void,
) {
const [currentStep, setCurrentStep] = useState(0);
/** Flat step descriptors for the progress indicator. */
const steps: WizardStep[] = useMemo(
() => stepConfigs.map((c) => ({ name: c.name, type: c.type })),
[stepConfigs],
);
const currentStepData = steps[currentStep];
const currentConfig = stepConfigs[currentStep];
/** Validate the current step, run its onNext, then advance if not the last step. */
const nextStep = useCallback(async () => {
const config = stepConfigs[currentStep];
if (!config) return;
const error = config.validate();
if (error) {
showError(error);
return;
}
const success = await config.onNext();
if (!success) return;
// Don't advance past the last step — the final step's onNext handles exit.
if (currentStep < stepConfigs.length - 1) {
setCurrentStep((prev) => prev + 1);
}
}, [currentStep, stepConfigs, showError]);
/** Go back one step, or cancel the wizard if already at the first step. */
const previousStep = useCallback(() => {
if (currentStep <= 0) {
onCancel();
return;
}
setCurrentStep((prev) => prev - 1);
}, [currentStep, onCancel]);
/** Cancel the wizard entirely. */
const cancel = useCallback(() => {
onCancel();
}, [onCancel]);
return {
steps,
currentStep,
setCurrentStep,
currentStepData,
currentConfig,
nextStep,
previousStep,
cancel,
} as const;
}
export type WizardStepsState = ReturnType<typeof useWizardSteps>;

View File

@@ -1,4 +1,5 @@
export * from './ActionWizardScreen.js';
export * from './useActionWizard.js';
export * from './hooks/useActionWizard.js';
export * from './types.js';
export * from './steps/index.js';
export * from './steps/index.js';
export * from './flows/index.js';

View File

@@ -0,0 +1,81 @@
import React from 'react';
import { Box, Text } from 'ink';
import { colors } from '../../../theme.js';
import type { VariableInput, DataResult } from '../types.js';
interface DataResultStepProps {
actionName: string;
variables: VariableInput[];
dataResults: DataResult[];
}
/**
* Displays the result of a data-only action (e.g. sign, verify).
*
* NOTE: Engine-level data action execution is not yet implemented.
* The computed values are stubbed until the engine supports evaluating
* CashASM data expressions outside of a transaction context.
*/
export function DataResultStep({
actionName,
variables,
dataResults,
}: DataResultStepProps): React.ReactElement {
return (
<Box flexDirection="column">
<Text color={colors.primary} bold>
{actionName} Result
</Text>
{/* Variables that were provided */}
{variables.length > 0 && (
<Box marginTop={1} flexDirection="column">
<Text color={colors.text}>Provided values:</Text>
{variables.map((v) => (
<Text key={v.id} color={colors.textMuted}>
{' '}{v.name}: {v.value || '(empty)'}
</Text>
))}
</Box>
)}
{/* Computed data results */}
<Box marginTop={1} flexDirection="column">
<Text color={colors.text}>Output:</Text>
{dataResults.length === 0 ? (
<Text color={colors.warning}>
{' '}Engine support for data actions is not yet implemented.
</Text>
) : (
dataResults.map((result) => (
<Box key={result.id} flexDirection="column" marginTop={0}>
<Text color={colors.textMuted}>
{' '}{result.name} ({result.type}):
</Text>
{result.value !== null ? (
<Box
borderStyle="single"
borderColor={colors.primary}
paddingX={1}
marginLeft={2}
>
<Text color={colors.accent}>{result.value}</Text>
</Box>
) : (
<Text color={colors.warning} dimColor>
{' '}Pending engine data execution not yet available
</Text>
)}
</Box>
))
)}
</Box>
<Box marginTop={1}>
<Text color={colors.textMuted} dimColor>
Press Done to exit.
</Text>
</Box>
</Box>
);
}

View File

@@ -1,52 +0,0 @@
import React from 'react';
import { Box, Text } from 'ink';
import { colors } from '../../../theme.js';
import type { WizardStepProps } from '../types.js';
type Props = Pick<
WizardStepProps,
'template' | 'actionIdentifier' | 'roleIdentifier' | 'actionName'
>;
export function InfoStep({
template,
actionIdentifier,
roleIdentifier,
actionName,
}: Props): React.ReactElement {
const action = template?.actions?.[actionIdentifier];
const role = action?.roles?.[roleIdentifier];
return (
<Box flexDirection='column'>
<Text color={colors.primary} bold>
Action: {actionName}
</Text>
<Text color={colors.textMuted}>
{action?.description || 'No description'}
</Text>
<Box marginTop={1}>
<Text color={colors.text}>Your Role: </Text>
<Text color={colors.accent}>{roleIdentifier}</Text>
</Box>
{role?.requirements && (
<Box marginTop={1} flexDirection='column'>
<Text color={colors.text}>Requirements:</Text>
{role.requirements.variables?.map((v) => (
<Text key={v} color={colors.textMuted}>
{' '} Variable: {v}
</Text>
))}
{role.requirements.slots && (
<Text color={colors.textMuted}>
{' '} Slots: {role.requirements.slots.min} min (UTXO selection
required)
</Text>
)}
</Box>
)}
</Box>
);
}

View File

@@ -1,18 +1,17 @@
import React from 'react';
import { Box, Text } from 'ink';
import { colors, formatSatoshis, formatHex } from '../../../theme.js';
import type { WizardStepProps } from '../types.js';
import type { SelectableUTXO, FocusArea } from '../types.js';
type Props = Pick<
WizardStepProps,
| 'availableUtxos'
| 'selectedUtxoIndex'
| 'requiredAmount'
| 'fee'
| 'selectedAmount'
| 'changeAmount'
| 'focusArea'
>;
interface Props {
availableUtxos: SelectableUTXO[];
selectedUtxoIndex: number;
requiredAmount: bigint;
fee: bigint;
selectedAmount: bigint;
changeAmount: bigint;
focusArea: FocusArea;
}
export function InputsStep({
availableUtxos,

View File

@@ -2,16 +2,15 @@ import React from 'react';
import { Box, Text } from 'ink';
import { colors } from '../../../theme.js';
import { VariableInputField } from '../../../components/VariableInputField.js';
import type { WizardStepProps } from '../types.js';
import type { VariableInput, FocusArea } from '../types.js';
type Props = Pick<
WizardStepProps,
| 'variables'
| 'updateVariable'
| 'handleTextInputSubmit'
| 'focusArea'
| 'focusedInput'
>;
interface Props {
variables: VariableInput[];
updateVariable: (index: number, value: string) => void;
handleTextInputSubmit: () => void;
focusArea: FocusArea;
focusedInput: number;
}
export function VariablesStep({
variables,

View File

@@ -1,6 +1,6 @@
export * from './InfoStep.js';
export * from './RoleSelectStep.js';
export * from './VariablesStep.js';
export * from './InputsStep.js';
export * from './ReviewStep.js';
export * from './PublishStep.js';
export * from './PublishStep.js';
export * from './DataResultStep.js';

View File

@@ -1,12 +1,44 @@
import type { XOTemplate } from '@xo-cash/types';
/**
* Shared types for the action wizard.
*/
export type StepType = 'info' | 'role-select' | 'variables' | 'inputs' | 'review' | 'publish';
/** Supported step types in the wizard. */
export type StepType = 'role-select' | 'variables' | 'inputs' | 'review' | 'publish' | 'result';
/** A step displayed in the wizard's progress indicator. */
export interface WizardStep {
name: string;
type: StepType;
}
/**
* Configuration for a single wizard step.
* The flow strategy determines which steps exist; the orchestrator
* wires validate/onNext to the appropriate domain hooks.
*/
export interface StepConfig {
type: StepType;
name: string;
/** Return an error message if the step is invalid, or null if OK to proceed. */
validate: () => string | null;
/** Execute transition logic. Return true on success, false to stay on this step. */
onNext: () => Promise<boolean>;
}
/**
* Context passed to WizardFlow strategy methods so they can
* determine steps and finalization state without holding React state.
*/
export interface FlowContext {
availableRoles: string[];
hasVariables: boolean;
shouldCollectInputs: boolean;
requirementsComplete: boolean;
wizardCollectedInputs: boolean;
hasSignedAndBroadcasted: boolean;
}
/** Variable input state for the variables step. */
export interface VariableInput {
id: string;
name: string;
@@ -15,6 +47,7 @@ export interface VariableInput {
value: string;
}
/** A UTXO that can be toggled for transaction funding. */
export interface SelectableUTXO {
outpointTransactionHash: string;
outpointIndex: number;
@@ -23,40 +56,18 @@ export interface SelectableUTXO {
selected: boolean;
}
/** Which area of the wizard UI currently has keyboard focus. */
export type FocusArea = 'content' | 'buttons';
/** Which button in the bottom bar is focused. */
export type ButtonFocus = 'back' | 'cancel' | 'next';
/**
* The 'downward' contract — what every step component receives.
*/
export interface WizardStepProps {
// Data
template: XOTemplate;
actionIdentifier: string;
roleIdentifier: string;
actionName: string;
// Variable state
variables: VariableInput[];
updateVariable: (index: number, value: string) => void;
// UTXO state
availableUtxos: SelectableUTXO[];
selectedUtxoIndex: number;
requiredAmount: bigint;
fee: bigint;
selectedAmount: bigint;
changeAmount: bigint;
toggleUtxoSelection: (index: number) => void;
// Invitation
invitationId: string | null;
// Focus
focusArea: FocusArea;
focusedInput: number;
// Callbacks
handleTextInputSubmit: () => void;
copyId: () => Promise<void>;
}
/** A computed data result from a data-only action. */
export interface DataResult {
id: string;
name: string;
type: string;
hint?: string;
/** null when the engine hasn't computed the value yet. */
value: string | null;
}

View File

@@ -1,861 +0,0 @@
import { useState, useEffect, useCallback, useMemo } from 'react';
import { useNavigation } from '../../hooks/useNavigation.js';
import { useAppContext, useStatus } from '../../hooks/useAppContext.js';
import { formatSatoshis } from '../../theme.js';
import { copyToClipboard } from '../../utils/clipboard.js';
import type { XOTemplate, XOInvitation, XOTemplateTransactionOutput } from '@xo-cash/types';
import {
autoSelectGreedyUtxos,
getTransactionOutputIdentifier,
isInvitationRequirementsComplete,
mapUnspentOutputsToSelectable,
resolveActionRoles,
resolveProvidedLockingBytecodeHex,
roleRequiresInputs,
} from '../../../utils/invitation-flow.js';
import type {
WizardStep,
VariableInput,
SelectableUTXO,
FocusArea,
ButtonFocus,
} from './types.js';
export function useActionWizard() {
const { navigate, goBack, data: navData } = useNavigation();
const { appService, showError, showInfo } = useAppContext();
const { setStatus } = useStatus();
// ── Navigation data ──────────────────────────────────────────────
// Role is no longer passed via navigation — it is selected in the wizard.
const templateIdentifier = navData.templateIdentifier as string | undefined;
const actionIdentifier = navData.actionIdentifier as string | undefined;
const template = navData.template as XOTemplate | undefined;
const actionRolesFromNavigation = navData.actionRoles as string[] | undefined;
// ── Role selection state ────────────────────────────────────────
const [roleIdentifier, setRoleIdentifier] = useState<string | undefined>();
const [selectedRoleIndex, setSelectedRoleIndex] = useState(0);
/**
* Roles that can start this action, derived from the template's
* `start` entries filtered to the current action.
*/
const availableRoles = useMemo(() => {
return resolveActionRoles(template, actionIdentifier, actionRolesFromNavigation);
}, [template, actionIdentifier, actionRolesFromNavigation]);
const effectiveRoleForFlow = roleIdentifier ?? (
availableRoles.length === 1 ? availableRoles[0] : undefined
);
// Keep role state aligned when only one role exists for the selected action.
// This preserves existing UI bindings that read roleIdentifier directly.
useEffect(() => {
if (!roleIdentifier && availableRoles.length === 1) {
setRoleIdentifier(availableRoles[0]);
}
}, [roleIdentifier, availableRoles]);
// ── Wizard state ─────────────────────────────────────────────────
const [steps, setSteps] = useState<WizardStep[]>([]);
const [currentStep, setCurrentStep] = useState(0);
// ── Variable inputs ──────────────────────────────────────────────
const [variables, setVariables] = useState<VariableInput[]>([]);
// ── UTXO selection ───────────────────────────────────────────────
const [availableUtxos, setAvailableUtxos] = useState<SelectableUTXO[]>([]);
const [selectedUtxoIndex, setSelectedUtxoIndex] = useState(0);
const [requiredAmount, setRequiredAmount] = useState<bigint>(0n);
const [fee, setFee] = useState<bigint>(500n);
// ── Invitation ───────────────────────────────────────────────────
const [invitation, setInvitation] = useState<XOInvitation | null>(null);
const [invitationId, setInvitationId] = useState<string | null>(null);
const [requirementsComplete, setRequirementsComplete] = useState(false);
const [hasSignedAndBroadcasted, setHasSignedAndBroadcasted] = useState(false);
// ── UI state ─────────────────────────────────────────────────────
const [focusedInput, setFocusedInput] = useState(0);
const [focusedButton, setFocusedButton] = useState<ButtonFocus>('next');
const [focusArea, setFocusArea] = useState<FocusArea>('content');
const [isProcessing, setIsProcessing] = useState(false);
// ── Derived values ───────────────────────────────────────────────
const currentStepData = steps[currentStep];
const action = template?.actions?.[actionIdentifier ?? ''];
const actionName = action?.name || actionIdentifier || 'Unknown';
const selectedAmount = availableUtxos
.filter((u) => u.selected)
.reduce((sum, u) => sum + u.valueSatoshis, 0n);
const changeAmount = selectedAmount - requiredAmount - fee;
const textInputHasFocus =
currentStepData?.type === 'variables' && focusArea === 'content';
// Whether the wizard actually includes an inputs step — this determines if
// the creator provided funding and therefore can sign & broadcast locally.
const wizardCollectedInputs = steps.some((s) => s.type === 'inputs');
const canSignAndBroadcast =
currentStepData?.type === 'publish'
&& wizardCollectedInputs
&& requirementsComplete
&& !hasSignedAndBroadcasted;
// ── Initialization ───────────────────────────────────────────────
// Builds the wizard steps dynamically based on the selected role.
// Re-runs when role selection changes to add role-specific steps.
useEffect(() => {
if (!template || !actionIdentifier) {
showError('Missing wizard data');
goBack();
return;
}
const wizardSteps: WizardStep[] = [];
const shouldShowRoleSelection = availableRoles.length > 1;
// Only require explicit role selection when the action is actually ambiguous.
if (shouldShowRoleSelection) {
wizardSteps.push({ name: 'Select Role', type: 'role-select' });
}
// Add role-specific steps only after role is selected
if (effectiveRoleForFlow) {
const act = template.actions?.[actionIdentifier];
const role = act?.roles?.[effectiveRoleForFlow];
const requirements = role?.requirements;
// Add variables step if needed
if (requirements?.variables && requirements.variables.length > 0) {
wizardSteps.push({ name: 'Variables', type: 'variables' });
const varInputs = requirements.variables.map((varId) => {
const varDef = template.variables?.[varId];
return {
id: varId,
name: varDef?.name || varId,
type: varDef?.type || 'string',
hint: varDef?.hint,
value: '',
};
});
setVariables(varInputs);
}
// Determine whether the creator should provide inputs during this wizard.
//
// Single-role actions (e.g. "send"): the creator is the sole participant,
// so we collect inputs here if the role needs them at all.
//
// Multi-role actions (e.g. "receive"): the creator is setting up the
// invitation for another party to accept. We only collect inputs during
// creation if the role EXPLICITLY requires them (slots.min > 0).
// Implicit inputs (transaction-level) are assumed to be provided later
// by the accepting party.
const totalActionRoles = Object.keys(act?.roles ?? {}).length;
const isSingleRoleAction = totalActionRoles <= 1;
const shouldCollectInputs =
isSingleRoleAction && roleRequiresInputs(template, actionIdentifier, effectiveRoleForFlow);
if (shouldCollectInputs) {
wizardSteps.push({ name: 'Select UTXOs', type: 'inputs' });
}
}
// Always add review and publish at the end
wizardSteps.push({ name: 'Review', type: 'review' });
wizardSteps.push({ name: 'Publish', type: 'publish' });
setSteps(wizardSteps);
setStatus(effectiveRoleForFlow ? `${actionIdentifier}/${effectiveRoleForFlow}` : actionIdentifier);
}, [
template,
actionIdentifier,
availableRoles.length,
effectiveRoleForFlow,
showError,
goBack,
setStatus,
]);
// ── Auto-advance from role-select after role is chosen ──────────
// This runs after the main useEffect has rebuilt steps, ensuring
// we advance to the correct step (variables, inputs, or review).
useEffect(() => {
if (effectiveRoleForFlow && currentStep === 0 && steps[0]?.type === 'role-select') {
setCurrentStep(1);
setFocusArea('content');
setFocusedInput(0);
}
}, [effectiveRoleForFlow, currentStep, steps]);
// ── Update a single variable value ───────────────────────────────
const updateVariable = useCallback((index: number, value: string) => {
setVariables((prev) => {
const updated = [...prev];
const variable = updated[index];
if (variable) {
updated[index] = { ...variable, value };
}
return updated;
});
}, []);
// ── Toggle a UTXO's selected state ──────────────────────────────
const toggleUtxoSelection = useCallback((index: number) => {
setAvailableUtxos((prev) => {
const updated = [...prev];
const utxo = updated[index];
if (utxo) {
updated[index] = { ...utxo, selected: !utxo.selected };
}
return updated;
});
}, []);
// ── Handle Enter inside a TextInput ─────────────────────────────
const handleTextInputSubmit = useCallback(() => {
if (focusedInput < variables.length - 1) {
setFocusedInput((prev) => prev + 1);
} else {
setFocusArea('buttons');
setFocusedButton('next');
}
}, [focusedInput, variables.length]);
// ── Copy invitation ID to clipboard ─────────────────────────────
const copyId = useCallback(async () => {
if (!invitationId) return;
try {
await copyToClipboard(invitationId);
showInfo(`Copied to clipboard!\n\n${invitationId}`);
} catch (error) {
showError(
`Failed to copy: ${error instanceof Error ? error.message : String(error)}`
);
}
}, [invitationId, showInfo, showError]);
const refreshRequirementState = useCallback(async (identifier: string | null = invitationId) => {
if (!identifier || !appService) {
setRequirementsComplete(false);
return false;
}
const invitationInstance = appService.invitations.find(
(inv) => inv.data.invitationIdentifier === identifier
);
if (!invitationInstance) {
setRequirementsComplete(false);
return false;
}
const complete = await isInvitationRequirementsComplete(invitationInstance);
setRequirementsComplete(complete);
return complete;
}, [appService, invitationId]);
// ── Load available UTXOs for the inputs step ────────────────────
const loadAvailableUtxos = useCallback(async () => {
if (!invitation || !templateIdentifier || !appService || !invitationId) {
return;
}
try {
setIsProcessing(true);
setStatus('Finding suitable UTXOs...');
// Determine required amount from variables
const requestedVar = variables.find(
(v) =>
v.id.toLowerCase().includes('satoshi') ||
v.id.toLowerCase().includes('amount')
);
const requested = requestedVar
? BigInt(requestedVar.value || '0')
: 0n;
setRequiredAmount(requested);
// Find the tracked invitation instance
const invitationInstance = appService.invitations.find(
(inv) => inv.data.invitationIdentifier === invitationId
);
if (!invitationInstance) {
throw new Error('Invitation not found');
}
// Query for suitable resources.
// NOTE: Even for single-role actions we still keep the user in the loop for inputs:
// we only surface UTXOs the engine/template currently considers "selectable" and let
// the user confirm them in the inputs step. If selectable semantics evolve, revisit here.
const unspentOutputs = await invitationInstance.findSuitableResources({
templateIdentifier,
outputIdentifier: 'receiveOutput',
});
// Map to selectable UTXOs and pre-select greedily.
const mappedUtxos = mapUnspentOutputsToSelectable(unspentOutputs);
const autoSelectedUtxos = autoSelectGreedyUtxos(mappedUtxos, requested + fee);
setAvailableUtxos(autoSelectedUtxos as SelectableUTXO[]);
setStatus('Ready');
} catch (error) {
showError(
`Failed to load UTXOs: ${error instanceof Error ? error.message : String(error)}`
);
} finally {
setIsProcessing(false);
}
}, [
invitation,
templateIdentifier,
variables,
appService,
invitationId,
fee,
showError,
setStatus,
]);
// ── Create invitation and persist variables ─────────────────────
/**
* Creates an invitation, optionally persists variable values,
* and adds template-required outputs.
*
* Accepts an explicit `roleId` to avoid stale-closure issues
* when called immediately after setting role state.
*
* Does NOT advance the wizard step — the caller is responsible.
*
* @returns `true` on success, `false` on failure.
*/
const createInvitationWithVariables = useCallback(
async (roleId?: string): Promise<boolean> => {
const effectiveRole = roleId ?? effectiveRoleForFlow;
if (
!templateIdentifier ||
!actionIdentifier ||
!effectiveRole ||
!template ||
!appService
) {
return false;
}
setIsProcessing(true);
setStatus('Creating invitation...');
try {
// Create via the engine
const xoInvitation = await appService.engine.createInvitation({
templateIdentifier,
actionIdentifier,
});
// Wrap and track
const invitationInstance =
await appService.createInvitation(xoInvitation);
let inv = invitationInstance.data;
const invId = inv.invitationIdentifier;
setInvitationId(invId);
setStatus('Adding variables...');
// Persist variable values
if (variables.length > 0) {
const variableData = variables.map((v) => {
const isNumeric =
['integer', 'number', 'satoshis'].includes(v.type) ||
(v.hint && ['satoshis', 'amount'].includes(v.hint));
return {
variableIdentifier: v.id,
roleIdentifier: effectiveRole,
value: isNumeric ? BigInt(v.value || '0') : v.value,
};
});
await invitationInstance.addVariables(variableData);
inv = invitationInstance.data;
}
const variableValuesByIdentifier = variables.reduce((acc, variable) => {
if (typeof variable.value === 'string' && variable.value.trim().length > 0) {
acc[variable.id] = variable.value;
}
return acc;
}, {} as Record<string, string>);
// Add template-required outputs for the current role
const act = template.actions?.[actionIdentifier];
const transaction = act?.transaction
? template.transactions?.[act.transaction]
: null;
if (transaction?.outputs && transaction.outputs.length > 0) {
setStatus('Adding required outputs...');
const outputsToAdd = await Promise.all(transaction.outputs.map(async (output: XOTemplateTransactionOutput) => {
const outputIdentifier = getTransactionOutputIdentifier(output);
if (!outputIdentifier) {
throw new Error('Invalid transaction output definition');
}
const providedLockingBytecodeHex = resolveProvidedLockingBytecodeHex(
template,
outputIdentifier,
variableValuesByIdentifier,
);
const lockingBytecodeHex = providedLockingBytecodeHex
?? await invitationInstance.generateLockingBytecode(outputIdentifier, effectiveRole);
return {
outputIdentifier,
lockingBytecode: lockingBytecodeHex,
};
}));
// TODO: Clean this up. Suggestions: 1. Convert to bytes above. 2. Have addOuputs accept a hex string. 3. Have addOutputs handling the lockscript generation
await invitationInstance.addOutputs(outputsToAdd.map((output) => ({
outputIdentifier: output.outputIdentifier,
// roleIdentifier: output.roleIdentifier,
lockingBytecode: new Uint8Array(Buffer.from(output.lockingBytecode, 'hex')),
})));
inv = invitationInstance.data;
}
setInvitation(inv);
await refreshRequirementState(invId);
setStatus('Invitation created');
return true;
} catch (error) {
showError(
`Failed to create invitation: ${error instanceof Error ? error.message : String(error)}`
);
return false;
} finally {
setIsProcessing(false);
}
},
[
templateIdentifier,
actionIdentifier,
effectiveRoleForFlow,
template,
variables,
appService,
showError,
setStatus,
refreshRequirementState,
]
);
// Ensure invitation exists before entering input/review/publish stages.
useEffect(() => {
const ensureInvitation = async () => {
if (!currentStepData) return;
if (currentStepData.type !== 'inputs' && currentStepData.type !== 'review' && currentStepData.type !== 'publish') {
return;
}
if (invitationId) {
if (currentStepData.type === 'inputs' && availableUtxos.length === 0 && !isProcessing) {
await loadAvailableUtxos();
}
return;
}
if (!effectiveRoleForFlow || isProcessing) return;
const success = await createInvitationWithVariables(effectiveRoleForFlow);
if (!success) return;
if (currentStepData.type === 'inputs') {
await loadAvailableUtxos();
}
};
ensureInvitation().catch(() => {});
}, [
currentStepData,
invitationId,
effectiveRoleForFlow,
isProcessing,
createInvitationWithVariables,
loadAvailableUtxos,
availableUtxos.length,
]);
// ── Add selected inputs + change output to the invitation ───────
const addInputsAndOutputs = useCallback(async () => {
if (!invitationId || !invitation || !appService) return;
const selectedUtxos = availableUtxos.filter((u) => u.selected);
if (selectedUtxos.length === 0) {
showError('Please select at least one UTXO');
return;
}
if (selectedAmount < requiredAmount + fee) {
showError(
`Insufficient funds. Need ${formatSatoshis(requiredAmount + fee)}, selected ${formatSatoshis(selectedAmount)}`
);
return;
}
if (changeAmount < 546n) {
showError(
`Change amount (${changeAmount}) is below dust threshold (546 sats)`
);
return;
}
setIsProcessing(true);
setStatus('Adding inputs and outputs...');
try {
const invitationInstance = appService.invitations.find(
(inv) => inv.data.invitationIdentifier === invitationId
);
if (!invitationInstance) {
throw new Error('Invitation not found');
}
// Add selected inputs
const inputs = selectedUtxos.map((utxo) => ({
outpointTransactionHash: new Uint8Array(
Buffer.from(utxo.outpointTransactionHash, 'hex')
),
outpointIndex: utxo.outpointIndex,
}));
await invitationInstance.addInputs(inputs);
// Add change output
const outputs = [
{
valueSatoshis: changeAmount,
},
];
await invitationInstance.addOutputs(outputs);
await refreshRequirementState(invitationId);
setCurrentStep((prev) => prev + 1);
setStatus('Inputs and outputs added');
} catch (error) {
showError(
`Failed to add inputs/outputs: ${error instanceof Error ? error.message : String(error)}`
);
} finally {
setIsProcessing(false);
}
}, [
invitationId,
invitation,
availableUtxos,
selectedAmount,
requiredAmount,
fee,
changeAmount,
appService,
showError,
setStatus,
refreshRequirementState,
]);
// ── Move to publish step ────────────────────────────────────────
const advanceToPublishStep = useCallback(async () => {
if (!invitationId || !appService) return;
setIsProcessing(true);
setStatus('Preparing publish step...');
try {
const invitationInstance = appService.invitations.find(
(inv) => inv.data.invitationIdentifier === invitationId
);
if (!invitationInstance) {
throw new Error('Invitation not found');
}
await refreshRequirementState(invitationId);
setCurrentStep((prev) => prev + 1);
setStatus('Ready to publish');
} catch (error) {
showError(
`Failed to prepare publish step: ${error instanceof Error ? error.message : String(error)}`
);
} finally {
setIsProcessing(false);
}
}, [invitationId, appService, showError, setStatus, refreshRequirementState]);
// ── Sign and broadcast from publish step ────────────────────────
const signAndBroadcastInvitation = useCallback(async () => {
if (!invitationId || !appService) return;
setIsProcessing(true);
setStatus('Signing invitation...');
try {
const invitationInstance = appService.invitations.find(
(inv) => inv.data.invitationIdentifier === invitationId
);
if (!invitationInstance) {
throw new Error('Invitation not found');
}
const complete = await refreshRequirementState(invitationId);
if (!complete) {
showError('Invitation requirements are not complete yet.');
return;
}
if (!wizardCollectedInputs) {
showError('This action does not require funding inputs, so it cannot be signed and broadcasted here.');
return;
}
await invitationInstance.sign();
setStatus('Broadcasting transaction...');
await invitationInstance.broadcast();
setHasSignedAndBroadcasted(true);
setStatus('Transaction signed and broadcasted');
showInfo('Transaction signed and broadcasted.');
await refreshRequirementState(invitationId);
} catch (error) {
showError(`Failed to sign and broadcast: ${error instanceof Error ? error.message : String(error)}`);
} finally {
setIsProcessing(false);
}
}, [invitationId, appService, setStatus, showError, showInfo, refreshRequirementState, wizardCollectedInputs]);
// ── Navigate to the next step ───────────────────────────────────
const nextStep = useCallback(async () => {
const stepType = currentStepData?.type;
if (currentStep >= steps.length - 1 && stepType !== 'publish') return;
// ── Role selection ──────────────────────────────────────────
if (stepType === 'role-select') {
const selectedRole = availableRoles[selectedRoleIndex];
if (!selectedRole) {
showError('Please select a role');
return;
}
// Check what the selected role requires
const act = template?.actions?.[actionIdentifier ?? ''];
const role = act?.roles?.[selectedRole];
const requirements = role?.requirements;
const hasVariables =
requirements?.variables && requirements.variables.length > 0;
// Mirror the inputs-step inference from the step-building effect:
// single-role → any inputs; multi-role → explicit requirements only.
const totalActionRoles = Object.keys(act?.roles ?? {}).length;
const roleExplicitlyNeedsInputs =
(requirements?.slots && requirements.slots.min > 0)
|| (act?.requirements?.roles?.find(
(r: { role: string; slots?: { min?: number } }) => r.role === selectedRole,
)?.slots?.min ?? 0) > 0;
const hasSlots = totalActionRoles <= 1
? roleRequiresInputs(template, actionIdentifier, selectedRole)
: roleExplicitlyNeedsInputs;
// If there is no variables step, the invitation must be created now
// because the variables step would normally handle it.
if (!hasVariables) {
const success = await createInvitationWithVariables(selectedRole);
if (!success) return;
// If we're going to the inputs step, load UTXOs
if (hasSlots) {
setTimeout(() => loadAvailableUtxos(), 100);
}
}
// Set role — this triggers the useEffect to rebuild steps and advance
setRoleIdentifier(selectedRole);
return;
}
// ── Variables ───────────────────────────────────────────────
if (stepType === 'variables') {
const emptyVars = variables.filter(
(v) => !v.value || v.value.trim() === ''
);
if (emptyVars.length > 0) {
showError(
`Please enter values for: ${emptyVars.map((v) => v.name).join(', ')}`
);
return;
}
// Create the invitation and persist the variable values
const success = await createInvitationWithVariables();
if (!success) return;
// Advance, optionally kicking off UTXO loading
const nextStepType = steps[currentStep + 1]?.type;
if (nextStepType === 'inputs') {
setCurrentStep((prev) => prev + 1);
setTimeout(() => loadAvailableUtxos(), 100);
} else {
setCurrentStep((prev) => prev + 1);
}
setFocusArea('content');
setFocusedInput(0);
return;
}
// ── Inputs ──────────────────────────────────────────────────
if (stepType === 'inputs') {
if (!invitationId) {
const success = await createInvitationWithVariables();
if (!success) return;
await loadAvailableUtxos();
return;
}
await addInputsAndOutputs();
return;
}
// ── Review ──────────────────────────────────────────────────
if (stepType === 'review') {
if (!invitationId) {
const success = await createInvitationWithVariables();
if (!success) return;
}
await advanceToPublishStep();
return;
}
// ── Publish ─────────────────────────────────────────────────
if (stepType === 'publish') {
if (canSignAndBroadcast) {
await signAndBroadcastInvitation();
return;
}
// Done should exit the wizard, not advance past the final step.
goBack();
return;
}
// ── Generic advance ─────────────────────────────────────────
setCurrentStep((prev) => prev + 1);
setFocusArea('content');
setFocusedInput(0);
}, [
currentStep,
steps,
currentStepData,
canSignAndBroadcast,
availableRoles,
selectedRoleIndex,
template,
actionIdentifier,
variables,
showError,
createInvitationWithVariables,
loadAvailableUtxos,
addInputsAndOutputs,
advanceToPublishStep,
requirementsComplete,
hasSignedAndBroadcasted,
signAndBroadcastInvitation,
goBack,
]);
// ── Navigate to the previous step ──────────────────────────────
const previousStep = useCallback(() => {
if (currentStep <= 0) {
goBack();
return;
}
setCurrentStep((prev) => prev - 1);
setFocusArea('content');
setFocusedInput(0);
}, [currentStep, goBack]);
// ── Cancel the wizard entirely ──────────────────────────────────
const cancel = useCallback(() => {
goBack();
}, [goBack]);
// ── Public API ──────────────────────────────────────────────────
return {
// Navigation / meta
template,
templateIdentifier,
actionIdentifier,
roleIdentifier,
action,
actionName,
// Role selection
availableRoles,
selectedRoleIndex,
setSelectedRoleIndex,
// Steps
steps,
currentStep,
currentStepData,
// Variables
variables,
updateVariable,
handleTextInputSubmit,
// UTXOs
availableUtxos,
setAvailableUtxos,
selectedUtxoIndex,
setSelectedUtxoIndex,
requiredAmount,
fee,
selectedAmount,
changeAmount,
toggleUtxoSelection,
// Invitation
invitation,
invitationId,
requirementsComplete,
hasSignedAndBroadcasted,
canSignAndBroadcast,
// UI focus
focusedInput,
setFocusedInput,
focusedButton,
setFocusedButton,
focusArea,
setFocusArea,
isProcessing,
textInputHasFocus,
// Actions
nextStep,
previousStep,
cancel,
copyId,
} as const;
}
/** Convenience type so other files can type the return value. */
export type ActionWizardState = ReturnType<typeof useActionWizard>;

View File

@@ -122,11 +122,6 @@ export function InvitationImportFlow({
const changeAmountSats = totalSelected - requiredSats - DEFAULT_FEE;
setChangeAmount(changeAmountSats);
console.log('totalSelected:', totalSelected);
console.log('requiredAmount:', requiredSats);
console.log('DEFAULT_FEE:', DEFAULT_FEE);
console.log('changeAmount:', changeAmount);
// Add the change output if it exceeds the dust threshold
if (changeAmountSats >= DUST_THRESHOLD) {
await invitation?.addOutputs([{
@@ -145,12 +140,6 @@ export function InvitationImportFlow({
return (raw && typeof raw === 'object' && 'name' in raw) ? String(raw.name) : selectedRole;
})();
showInfo(
`Invitation imported and accepted!\n\n` +
`Role: ${roleName}\n` +
`Template: ${template?.name ?? invitation?.data.templateIdentifier ?? 'Unknown'}\n` +
`Action: ${invitation?.data.actionIdentifier ?? 'Unknown'}`
);
setStatus('Ready');
onClose();
}, [selectedRole, template, invitation, showInfo, setStatus, onClose]);

View File

@@ -11,6 +11,7 @@ import { Box, Text, useInput } from 'ink';
import { colors, formatSatoshis } from '../../../../theme.js';
import type { InputsSelectStepProps, SelectableUTXO } from '../types.js';
import { autoSelectGreedyUtxos, mapUnspentOutputsToSelectable } from '../../../../../utils/invitation-flow.js';
import type { UnspentOutputData } from '@xo-cash/state';
/** Default fee estimate in satoshis. */
const DEFAULT_FEE = 500n;
@@ -60,12 +61,54 @@ export function InputsSelectStep({
const required = await computeRequiredAmount();
setRequiredAmount(required);
const unspentOutputs = await invitation.findSuitableResources({
templateIdentifier: invitation.data.templateIdentifier,
outputIdentifier: 'receiveOutput',
});
const template = await appService.engine.getTemplate(invitation.data.templateIdentifier);
if (!template) {
throw new Error('Template not found');
}
const selectable = mapUnspentOutputsToSelectable(unspentOutputs);
// Get the action that we are calling from the template
const action = template.actions[invitation.data.actionIdentifier];
if (!action) {
throw new Error('Action not found');
}
if (!action.transaction) {
throw new Error('Action does not have a transaction');
}
// Get the transaction that the action is creating
const transaction = template.transactions?.[action.transaction];
if (!transaction) {
throw new Error(`Transaction not found for action: ${action.transaction}`);
}
if (!transaction.outputs) {
throw new Error(`Transaction does not have outputs`);
}
// Create a set to store all the output identifiers
const outputIdentifiers = new Set<string>();
for (const output of transaction.outputs) {
outputIdentifiers.add(output.output);
}
console.log('outputIdentifiers', Array.from(outputIdentifiers));
// Create a map of the utxoID to suitable resource
const utxoIdToSuitableResource = new Map<string, UnspentOutputData>();
for (const outputIdentifier of outputIdentifiers) {
const suitableResources = await invitation.findSuitableResources({
outputIdentifier,
});
console.log('suitableResources', outputIdentifier, JSON.stringify(suitableResources, null, 2));
for (const suitableResource of suitableResources) {
utxoIdToSuitableResource.set(suitableResource.outpointTransactionHash + ':' + suitableResource.outpointIndex, suitableResource);
}
}
console.log('utxoIdToSuitableResource', JSON.stringify(utxoIdToSuitableResource, null, 2));
const selectable = mapUnspentOutputsToSelectable(Array.from(utxoIdToSuitableResource.values()));
const autoSelected = autoSelectGreedyUtxos(selectable, required + fee);
setUtxos(autoSelected as SelectableUTXO[]);
} catch (err) {

View File

@@ -80,8 +80,8 @@ export interface AppContextType {
export interface DialogState {
/** Whether dialog is visible */
visible: boolean;
/** Dialog type */
type: 'error' | 'info' | 'confirm';
/** Dialog type. Use 'custom' when a screen renders its own dialog overlay. */
type: 'error' | 'info' | 'confirm' | 'custom';
/** Dialog message */
message: string;
/** Callback for confirm dialog */

View File

@@ -1,7 +1,7 @@
import type { XOInvitation } from "@xo-cash/types";
import { EventEmitter } from "./event-emitter.js";
import { SSESession, type SSEvent } from "./sse-client.js";
import { decodeExtendedJson, decodeExtendedJsonObject, encodeExtendedJson, encodeExtendedJsonObject } from "./ext-json.js";
import { decodeExtendedJson, encodeExtendedJson } from "./ext-json.js";
export type SyncServerEventMap = {
'connected': void;