Compare commits
7 Commits
601c3db9d0
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 66e9918e04 | |||
| 38a0ac436b | |||
| 2c547f766a | |||
| ef169e76db | |||
| df57f1b9ad | |||
| da096af0fa | |||
| eb1bf9020e |
7
.gitignore
vendored
7
.gitignore
vendored
@@ -2,4 +2,9 @@ __sysdb__.sqlite
|
|||||||
Electrum.sqlite
|
Electrum.sqlite
|
||||||
XO.sqlite
|
XO.sqlite
|
||||||
node_modules/
|
node_modules/
|
||||||
dist/
|
dist/
|
||||||
|
*.db
|
||||||
|
*.db-shm
|
||||||
|
*.db-wal
|
||||||
|
*.sqlite
|
||||||
|
resolvedTemplate.json
|
||||||
631
package-lock.json
generated
631
package-lock.json
generated
@@ -14,14 +14,16 @@
|
|||||||
"@xo-cash/state": "file:../state",
|
"@xo-cash/state": "file:../state",
|
||||||
"@xo-cash/templates": "file:../templates",
|
"@xo-cash/templates": "file:../templates",
|
||||||
"@xo-cash/types": "file:../types",
|
"@xo-cash/types": "file:../types",
|
||||||
|
"better-sqlite3": "^12.6.2",
|
||||||
"clipboardy": "^5.1.0",
|
"clipboardy": "^5.1.0",
|
||||||
"ink": "^5.1.0",
|
"ink": "^6.6.0",
|
||||||
"ink-select-input": "^6.0.0",
|
"ink-select-input": "^6.0.0",
|
||||||
"ink-spinner": "^5.0.0",
|
"ink-spinner": "^5.0.0",
|
||||||
"ink-text-input": "^6.0.0",
|
"ink-text-input": "^6.0.0",
|
||||||
"react": "^18.3.1"
|
"react": "^19.2.4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/better-sqlite3": "^7.6.13",
|
||||||
"@types/node": "^25.0.10",
|
"@types/node": "^25.0.10",
|
||||||
"@types/react": "^18.3.18",
|
"@types/react": "^18.3.18",
|
||||||
"tsx": "^4.21.0",
|
"tsx": "^4.21.0",
|
||||||
@@ -157,16 +159,16 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@alcalzone/ansi-tokenize": {
|
"node_modules/@alcalzone/ansi-tokenize": {
|
||||||
"version": "0.1.3",
|
"version": "0.2.4",
|
||||||
"resolved": "https://registry.npmjs.org/@alcalzone/ansi-tokenize/-/ansi-tokenize-0.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/@alcalzone/ansi-tokenize/-/ansi-tokenize-0.2.4.tgz",
|
||||||
"integrity": "sha512-3yWxPTq3UQ/FY9p1ErPxIyfT64elWaMvM9lIHnaqpyft63tkxodF5aUElYHrdisWve5cETkh1+KBw1yJuW0aRw==",
|
"integrity": "sha512-HTgrrTgZ9Jgeo6Z3oqbQ7lifOVvRR14vaDuBGPPUxk9Thm+vObaO4QfYYYWw4Zo5CWQDBEfsinFA6Gre+AqwNQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"ansi-styles": "^6.2.1",
|
"ansi-styles": "^6.2.1",
|
||||||
"is-fullwidth-code-point": "^4.0.0"
|
"is-fullwidth-code-point": "^5.0.0"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=14.13.1"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@bitauth/libauth": {
|
"node_modules/@bitauth/libauth": {
|
||||||
@@ -638,6 +640,16 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/better-sqlite3": {
|
||||||
|
"version": "7.6.13",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-7.6.13.tgz",
|
||||||
|
"integrity": "sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/node": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/node": {
|
"node_modules/@types/node": {
|
||||||
"version": "25.0.10",
|
"version": "25.0.10",
|
||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.0.10.tgz",
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.0.10.tgz",
|
||||||
@@ -733,6 +745,84 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/base64-js": {
|
||||||
|
"version": "1.5.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
|
||||||
|
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/feross"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "patreon",
|
||||||
|
"url": "https://www.patreon.com/feross"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "consulting",
|
||||||
|
"url": "https://feross.org/support"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/better-sqlite3": {
|
||||||
|
"version": "12.6.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.6.2.tgz",
|
||||||
|
"integrity": "sha512-8VYKM3MjCa9WcaSAI3hzwhmyHVlH8tiGFwf0RlTsZPWJ1I5MkzjiudCo4KC4DxOaL/53A5B1sI/IbldNFDbsKA==",
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"bindings": "^1.5.0",
|
||||||
|
"prebuild-install": "^7.1.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "20.x || 22.x || 23.x || 24.x || 25.x"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/bindings": {
|
||||||
|
"version": "1.5.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz",
|
||||||
|
"integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"file-uri-to-path": "1.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/bl": {
|
||||||
|
"version": "4.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz",
|
||||||
|
"integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"buffer": "^5.5.0",
|
||||||
|
"inherits": "^2.0.4",
|
||||||
|
"readable-stream": "^3.4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/buffer": {
|
||||||
|
"version": "5.7.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz",
|
||||||
|
"integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/feross"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "patreon",
|
||||||
|
"url": "https://www.patreon.com/feross"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "consulting",
|
||||||
|
"url": "https://feross.org/support"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"base64-js": "^1.3.1",
|
||||||
|
"ieee754": "^1.1.13"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/chalk": {
|
"node_modules/chalk": {
|
||||||
"version": "5.6.2",
|
"version": "5.6.2",
|
||||||
"resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz",
|
"resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz",
|
||||||
@@ -745,6 +835,12 @@
|
|||||||
"url": "https://github.com/chalk/chalk?sponsor=1"
|
"url": "https://github.com/chalk/chalk?sponsor=1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/chownr": {
|
||||||
|
"version": "1.1.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz",
|
||||||
|
"integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
"node_modules/cli-boxes": {
|
"node_modules/cli-boxes": {
|
||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/cli-boxes/-/cli-boxes-3.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/cli-boxes/-/cli-boxes-3.0.0.tgz",
|
||||||
@@ -785,35 +881,35 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/cli-truncate": {
|
"node_modules/cli-truncate": {
|
||||||
"version": "4.0.0",
|
"version": "5.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-5.1.1.tgz",
|
||||||
"integrity": "sha512-nPdaFdQ0h/GEigbPClz11D0v/ZJEwxmeVZGeMo3Z5StPtUTkA9o1lD6QwoirYiSDzbcwn2XcjwmCp68W1IS4TA==",
|
"integrity": "sha512-SroPvNHxUnk+vIW/dOSfNqdy1sPEFkrTk6TUtqLCnBlo3N7TNYYkzzN7uSD6+jVjrdO4+p8nH7JzH6cIvUem6A==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"slice-ansi": "^5.0.0",
|
"slice-ansi": "^7.1.0",
|
||||||
"string-width": "^7.0.0"
|
"string-width": "^8.0.0"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=20"
|
||||||
},
|
},
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/cli-truncate/node_modules/slice-ansi": {
|
"node_modules/cli-truncate/node_modules/string-width": {
|
||||||
"version": "5.0.0",
|
"version": "8.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-5.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/string-width/-/string-width-8.1.1.tgz",
|
||||||
"integrity": "sha512-FC+lgizVPfie0kkhqUScwRu1O/lF6NOgJmlCgK+/LYxDCTk8sGelYaHDhFcDN+Sn3Cv+3VSa4Byeo+IMCzpMgQ==",
|
"integrity": "sha512-KpqHIdDL9KwYk22wEOg/VIqYbrnLeSApsKT/bSj6Ez7pn3CftUiLAv2Lccpq1ALcpLV9UX1Ppn92npZWu2w/aw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"ansi-styles": "^6.0.0",
|
"get-east-asian-width": "^1.3.0",
|
||||||
"is-fullwidth-code-point": "^4.0.0"
|
"strip-ansi": "^7.1.0"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=20"
|
||||||
},
|
},
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/chalk/slice-ansi?sponsor=1"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/clipboardy": {
|
"node_modules/clipboardy": {
|
||||||
@@ -877,12 +973,54 @@
|
|||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/decompress-response": {
|
||||||
|
"version": "6.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz",
|
||||||
|
"integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"mimic-response": "^3.1.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/deep-extend": {
|
||||||
|
"version": "0.6.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz",
|
||||||
|
"integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=4.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/detect-libc": {
|
||||||
|
"version": "2.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
||||||
|
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/emoji-regex": {
|
"node_modules/emoji-regex": {
|
||||||
"version": "10.6.0",
|
"version": "10.6.0",
|
||||||
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz",
|
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz",
|
||||||
"integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==",
|
"integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/end-of-stream": {
|
||||||
|
"version": "1.4.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",
|
||||||
|
"integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"once": "^1.4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/environment": {
|
"node_modules/environment": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/environment/-/environment-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/environment/-/environment-1.1.0.tgz",
|
||||||
@@ -982,6 +1120,15 @@
|
|||||||
"url": "https://github.com/sindresorhus/execa?sponsor=1"
|
"url": "https://github.com/sindresorhus/execa?sponsor=1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/expand-template": {
|
||||||
|
"version": "2.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz",
|
||||||
|
"integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==",
|
||||||
|
"license": "(MIT OR WTFPL)",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/figures": {
|
"node_modules/figures": {
|
||||||
"version": "6.1.0",
|
"version": "6.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/figures/-/figures-6.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/figures/-/figures-6.1.0.tgz",
|
||||||
@@ -997,6 +1144,18 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/file-uri-to-path": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/fs-constants": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/fsevents": {
|
"node_modules/fsevents": {
|
||||||
"version": "2.3.3",
|
"version": "2.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||||
@@ -1053,6 +1212,12 @@
|
|||||||
"url": "https://github.com/privatenumber/get-tsconfig?sponsor=1"
|
"url": "https://github.com/privatenumber/get-tsconfig?sponsor=1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/github-from-package": {
|
||||||
|
"version": "0.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz",
|
||||||
|
"integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/human-signals": {
|
"node_modules/human-signals": {
|
||||||
"version": "8.0.1",
|
"version": "8.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/human-signals/-/human-signals-8.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/human-signals/-/human-signals-8.0.1.tgz",
|
||||||
@@ -1062,6 +1227,26 @@
|
|||||||
"node": ">=18.18.0"
|
"node": ">=18.18.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/ieee754": {
|
||||||
|
"version": "1.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
|
||||||
|
"integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/feross"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "patreon",
|
||||||
|
"url": "https://www.patreon.com/feross"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "consulting",
|
||||||
|
"url": "https://feross.org/support"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "BSD-3-Clause"
|
||||||
|
},
|
||||||
"node_modules/indent-string": {
|
"node_modules/indent-string": {
|
||||||
"version": "5.0.0",
|
"version": "5.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/indent-string/-/indent-string-5.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/indent-string/-/indent-string-5.0.0.tgz",
|
||||||
@@ -1074,31 +1259,42 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/inherits": {
|
||||||
|
"version": "2.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
||||||
|
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
|
"node_modules/ini": {
|
||||||
|
"version": "1.3.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz",
|
||||||
|
"integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
"node_modules/ink": {
|
"node_modules/ink": {
|
||||||
"version": "5.2.1",
|
"version": "6.6.0",
|
||||||
"resolved": "https://registry.npmjs.org/ink/-/ink-5.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/ink/-/ink-6.6.0.tgz",
|
||||||
"integrity": "sha512-BqcUyWrG9zq5HIwW6JcfFHsIYebJkWWb4fczNah1goUO0vv5vneIlfwuS85twyJ5hYR/y18FlAYUxrO9ChIWVg==",
|
"integrity": "sha512-QDt6FgJxgmSxAelcOvOHUvFxbIUjVpCH5bx+Slvc5m7IEcpGt3dYwbz/L+oRnqEGeRvwy1tineKK4ect3nW1vQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@alcalzone/ansi-tokenize": "^0.1.3",
|
"@alcalzone/ansi-tokenize": "^0.2.1",
|
||||||
"ansi-escapes": "^7.0.0",
|
"ansi-escapes": "^7.2.0",
|
||||||
"ansi-styles": "^6.2.1",
|
"ansi-styles": "^6.2.1",
|
||||||
"auto-bind": "^5.0.1",
|
"auto-bind": "^5.0.1",
|
||||||
"chalk": "^5.3.0",
|
"chalk": "^5.6.0",
|
||||||
"cli-boxes": "^3.0.0",
|
"cli-boxes": "^3.0.0",
|
||||||
"cli-cursor": "^4.0.0",
|
"cli-cursor": "^4.0.0",
|
||||||
"cli-truncate": "^4.0.0",
|
"cli-truncate": "^5.1.1",
|
||||||
"code-excerpt": "^4.0.0",
|
"code-excerpt": "^4.0.0",
|
||||||
"es-toolkit": "^1.22.0",
|
"es-toolkit": "^1.39.10",
|
||||||
"indent-string": "^5.0.0",
|
"indent-string": "^5.0.0",
|
||||||
"is-in-ci": "^1.0.0",
|
"is-in-ci": "^2.0.0",
|
||||||
"patch-console": "^2.0.0",
|
"patch-console": "^2.0.0",
|
||||||
"react-reconciler": "^0.29.0",
|
"react-reconciler": "^0.33.0",
|
||||||
"scheduler": "^0.23.0",
|
|
||||||
"signal-exit": "^3.0.7",
|
"signal-exit": "^3.0.7",
|
||||||
"slice-ansi": "^7.1.0",
|
"slice-ansi": "^7.1.0",
|
||||||
"stack-utils": "^2.0.6",
|
"stack-utils": "^2.0.6",
|
||||||
"string-width": "^7.2.0",
|
"string-width": "^8.1.0",
|
||||||
"type-fest": "^4.27.0",
|
"type-fest": "^4.27.0",
|
||||||
"widest-line": "^5.0.0",
|
"widest-line": "^5.0.0",
|
||||||
"wrap-ansi": "^9.0.0",
|
"wrap-ansi": "^9.0.0",
|
||||||
@@ -1106,12 +1302,12 @@
|
|||||||
"yoga-layout": "~3.2.1"
|
"yoga-layout": "~3.2.1"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=20"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@types/react": ">=18.0.0",
|
"@types/react": ">=19.0.0",
|
||||||
"react": ">=18.0.0",
|
"react": ">=19.0.0",
|
||||||
"react-devtools-core": "^4.19.1"
|
"react-devtools-core": "^6.1.2"
|
||||||
},
|
},
|
||||||
"peerDependenciesMeta": {
|
"peerDependenciesMeta": {
|
||||||
"@types/react": {
|
"@types/react": {
|
||||||
@@ -1178,6 +1374,22 @@
|
|||||||
"integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==",
|
"integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==",
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/ink/node_modules/string-width": {
|
||||||
|
"version": "8.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/string-width/-/string-width-8.1.1.tgz",
|
||||||
|
"integrity": "sha512-KpqHIdDL9KwYk22wEOg/VIqYbrnLeSApsKT/bSj6Ez7pn3CftUiLAv2Lccpq1ALcpLV9UX1Ppn92npZWu2w/aw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"get-east-asian-width": "^1.3.0",
|
||||||
|
"strip-ansi": "^7.1.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/is-docker": {
|
"node_modules/is-docker": {
|
||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/is-docker/-/is-docker-3.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/is-docker/-/is-docker-3.0.0.tgz",
|
||||||
@@ -1194,27 +1406,30 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/is-fullwidth-code-point": {
|
"node_modules/is-fullwidth-code-point": {
|
||||||
"version": "4.0.0",
|
"version": "5.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-5.1.0.tgz",
|
||||||
"integrity": "sha512-O4L094N2/dZ7xqVdrXhh9r1KODPJpFms8B5sGdJLPy664AgvXsreZUyCQQNItZRDlYug4xStLjNp/sz3HvBowQ==",
|
"integrity": "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"get-east-asian-width": "^1.3.1"
|
||||||
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=18"
|
||||||
},
|
},
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/is-in-ci": {
|
"node_modules/is-in-ci": {
|
||||||
"version": "1.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/is-in-ci/-/is-in-ci-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/is-in-ci/-/is-in-ci-2.0.0.tgz",
|
||||||
"integrity": "sha512-eUuAjybVTHMYWm/U+vBO1sY/JOCgoPCXRxzdju0K+K0BiGW0SChEL1MLC0PoCIR1OlPo5YAp8HuQoUlsWEICwg==",
|
"integrity": "sha512-cFeerHriAnhrQSbpAxL37W1wcJKUUX07HyLWZCW1URJT/ra3GyUTzBgUnh24TMVfNTV2Hij2HLxkPHFZfOZy5w==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"bin": {
|
"bin": {
|
||||||
"is-in-ci": "cli.js"
|
"is-in-ci": "cli.js"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=20"
|
||||||
},
|
},
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
@@ -1322,24 +1537,6 @@
|
|||||||
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
|
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
"node_modules/js-tokens": {
|
|
||||||
"version": "4.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
|
||||||
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/loose-envify": {
|
|
||||||
"version": "1.4.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
|
|
||||||
"integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"js-tokens": "^3.0.0 || ^4.0.0"
|
|
||||||
},
|
|
||||||
"bin": {
|
|
||||||
"loose-envify": "cli.js"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/mimic-fn": {
|
"node_modules/mimic-fn": {
|
||||||
"version": "2.1.0",
|
"version": "2.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz",
|
||||||
@@ -1349,6 +1546,51 @@
|
|||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/mimic-response": {
|
||||||
|
"version": "3.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz",
|
||||||
|
"integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/minimist": {
|
||||||
|
"version": "1.2.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
|
||||||
|
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/mkdirp-classic": {
|
||||||
|
"version": "0.5.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz",
|
||||||
|
"integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/napi-build-utils": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/node-abi": {
|
||||||
|
"version": "3.87.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.87.0.tgz",
|
||||||
|
"integrity": "sha512-+CGM1L1CgmtheLcBuleyYOn7NWPVu0s0EJH2C4puxgEZb9h8QpR9G2dBfZJOAUhi7VQxuBPMd0hiISWcTyiYyQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"semver": "^7.3.5"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/npm-run-path": {
|
"node_modules/npm-run-path": {
|
||||||
"version": "6.0.0",
|
"version": "6.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-6.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-6.0.0.tgz",
|
||||||
@@ -1377,6 +1619,15 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/once": {
|
||||||
|
"version": "1.4.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
|
||||||
|
"integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"wrappy": "1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/onetime": {
|
"node_modules/onetime": {
|
||||||
"version": "5.1.2",
|
"version": "5.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz",
|
||||||
@@ -1434,6 +1685,32 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/prebuild-install": {
|
||||||
|
"version": "7.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz",
|
||||||
|
"integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"detect-libc": "^2.0.0",
|
||||||
|
"expand-template": "^2.0.3",
|
||||||
|
"github-from-package": "0.0.0",
|
||||||
|
"minimist": "^1.2.3",
|
||||||
|
"mkdirp-classic": "^0.5.3",
|
||||||
|
"napi-build-utils": "^2.0.0",
|
||||||
|
"node-abi": "^3.3.0",
|
||||||
|
"pump": "^3.0.0",
|
||||||
|
"rc": "^1.2.7",
|
||||||
|
"simple-get": "^4.0.0",
|
||||||
|
"tar-fs": "^2.0.0",
|
||||||
|
"tunnel-agent": "^0.6.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"prebuild-install": "bin.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/pretty-ms": {
|
"node_modules/pretty-ms": {
|
||||||
"version": "9.3.0",
|
"version": "9.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/pretty-ms/-/pretty-ms-9.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/pretty-ms/-/pretty-ms-9.3.0.tgz",
|
||||||
@@ -1449,32 +1726,67 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/react": {
|
"node_modules/pump": {
|
||||||
"version": "18.3.1",
|
"version": "3.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/pump/-/pump-3.0.3.tgz",
|
||||||
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
|
"integrity": "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"loose-envify": "^1.1.0"
|
"end-of-stream": "^1.1.0",
|
||||||
|
"once": "^1.3.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/rc": {
|
||||||
|
"version": "1.2.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz",
|
||||||
|
"integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==",
|
||||||
|
"license": "(BSD-2-Clause OR MIT OR Apache-2.0)",
|
||||||
|
"dependencies": {
|
||||||
|
"deep-extend": "^0.6.0",
|
||||||
|
"ini": "~1.3.0",
|
||||||
|
"minimist": "^1.2.0",
|
||||||
|
"strip-json-comments": "~2.0.1"
|
||||||
},
|
},
|
||||||
|
"bin": {
|
||||||
|
"rc": "cli.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/react": {
|
||||||
|
"version": "19.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz",
|
||||||
|
"integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==",
|
||||||
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/react-reconciler": {
|
"node_modules/react-reconciler": {
|
||||||
"version": "0.29.2",
|
"version": "0.33.0",
|
||||||
"resolved": "https://registry.npmjs.org/react-reconciler/-/react-reconciler-0.29.2.tgz",
|
"resolved": "https://registry.npmjs.org/react-reconciler/-/react-reconciler-0.33.0.tgz",
|
||||||
"integrity": "sha512-zZQqIiYgDCTP/f1N/mAR10nJGrPD2ZR+jDSEsKWJHYC7Cm2wodlwbR3upZRdC3cjIjSlTLNVyO7Iu0Yy7t2AYg==",
|
"integrity": "sha512-KetWRytFv1epdpJc3J4G75I4WrplZE5jOL7Yq0p34+OVOKF4Se7WrdIdVC45XsSSmUTlht2FM/fM1FZb1mfQeA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"loose-envify": "^1.1.0",
|
"scheduler": "^0.27.0"
|
||||||
"scheduler": "^0.23.2"
|
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"react": "^18.3.1"
|
"react": "^19.2.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/readable-stream": {
|
||||||
|
"version": "3.6.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
|
||||||
|
"integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"inherits": "^2.0.3",
|
||||||
|
"string_decoder": "^1.1.1",
|
||||||
|
"util-deprecate": "^1.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/resolve-pkg-maps": {
|
"node_modules/resolve-pkg-maps": {
|
||||||
@@ -1509,13 +1821,42 @@
|
|||||||
"integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==",
|
"integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==",
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/safe-buffer": {
|
||||||
|
"version": "5.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
|
||||||
|
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/feross"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "patreon",
|
||||||
|
"url": "https://www.patreon.com/feross"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "consulting",
|
||||||
|
"url": "https://feross.org/support"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/scheduler": {
|
"node_modules/scheduler": {
|
||||||
"version": "0.23.2",
|
"version": "0.27.0",
|
||||||
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
|
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
|
||||||
"integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==",
|
"integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
|
||||||
"license": "MIT",
|
"license": "MIT"
|
||||||
"dependencies": {
|
},
|
||||||
"loose-envify": "^1.1.0"
|
"node_modules/semver": {
|
||||||
|
"version": "7.7.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
|
||||||
|
"integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
|
||||||
|
"license": "ISC",
|
||||||
|
"bin": {
|
||||||
|
"semver": "bin/semver.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/shebang-command": {
|
"node_modules/shebang-command": {
|
||||||
@@ -1551,6 +1892,51 @@
|
|||||||
"url": "https://github.com/sponsors/isaacs"
|
"url": "https://github.com/sponsors/isaacs"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/simple-concat": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/feross"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "patreon",
|
||||||
|
"url": "https://www.patreon.com/feross"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "consulting",
|
||||||
|
"url": "https://feross.org/support"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/simple-get": {
|
||||||
|
"version": "4.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz",
|
||||||
|
"integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/feross"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "patreon",
|
||||||
|
"url": "https://www.patreon.com/feross"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "consulting",
|
||||||
|
"url": "https://feross.org/support"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"decompress-response": "^6.0.0",
|
||||||
|
"once": "^1.3.1",
|
||||||
|
"simple-concat": "^1.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/slice-ansi": {
|
"node_modules/slice-ansi": {
|
||||||
"version": "7.1.2",
|
"version": "7.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-7.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-7.1.2.tgz",
|
||||||
@@ -1567,21 +1953,6 @@
|
|||||||
"url": "https://github.com/chalk/slice-ansi?sponsor=1"
|
"url": "https://github.com/chalk/slice-ansi?sponsor=1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/slice-ansi/node_modules/is-fullwidth-code-point": {
|
|
||||||
"version": "5.1.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-5.1.0.tgz",
|
|
||||||
"integrity": "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"get-east-asian-width": "^1.3.1"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/sponsors/sindresorhus"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/stack-utils": {
|
"node_modules/stack-utils": {
|
||||||
"version": "2.0.6",
|
"version": "2.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz",
|
||||||
@@ -1594,6 +1965,15 @@
|
|||||||
"node": ">=10"
|
"node": ">=10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/string_decoder": {
|
||||||
|
"version": "1.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
|
||||||
|
"integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"safe-buffer": "~5.2.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/string-width": {
|
"node_modules/string-width": {
|
||||||
"version": "7.2.0",
|
"version": "7.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz",
|
||||||
@@ -1638,6 +2018,15 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/strip-json-comments": {
|
||||||
|
"version": "2.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz",
|
||||||
|
"integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/system-architecture": {
|
"node_modules/system-architecture": {
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/system-architecture/-/system-architecture-0.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/system-architecture/-/system-architecture-0.1.0.tgz",
|
||||||
@@ -1650,6 +2039,34 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/tar-fs": {
|
||||||
|
"version": "2.1.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz",
|
||||||
|
"integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"chownr": "^1.1.1",
|
||||||
|
"mkdirp-classic": "^0.5.2",
|
||||||
|
"pump": "^3.0.0",
|
||||||
|
"tar-stream": "^2.1.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/tar-stream": {
|
||||||
|
"version": "2.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz",
|
||||||
|
"integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"bl": "^4.0.3",
|
||||||
|
"end-of-stream": "^1.4.1",
|
||||||
|
"fs-constants": "^1.0.0",
|
||||||
|
"inherits": "^2.0.3",
|
||||||
|
"readable-stream": "^3.1.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/to-rotated": {
|
"node_modules/to-rotated": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/to-rotated/-/to-rotated-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/to-rotated/-/to-rotated-1.0.0.tgz",
|
||||||
@@ -1682,6 +2099,18 @@
|
|||||||
"fsevents": "~2.3.3"
|
"fsevents": "~2.3.3"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/tunnel-agent": {
|
||||||
|
"version": "0.6.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz",
|
||||||
|
"integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"safe-buffer": "^5.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/type-fest": {
|
"node_modules/type-fest": {
|
||||||
"version": "4.41.0",
|
"version": "4.41.0",
|
||||||
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz",
|
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz",
|
||||||
@@ -1727,6 +2156,12 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/util-deprecate": {
|
||||||
|
"version": "1.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
||||||
|
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/which": {
|
"node_modules/which": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||||
@@ -1774,6 +2209,12 @@
|
|||||||
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
|
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/wrappy": {
|
||||||
|
"version": "1.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
|
||||||
|
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
"node_modules/ws": {
|
"node_modules/ws": {
|
||||||
"version": "8.19.0",
|
"version": "8.19.0",
|
||||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz",
|
"resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz",
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "tsx src/index.ts",
|
"dev": "SYNC_SERVER_URL=https://sync.xo.harvmaster.com tsx src/index.ts",
|
||||||
"build": "tsc",
|
"build": "tsc",
|
||||||
"start": "SYNC_SERVER_URL=https://sync.xo.harvmaster.com node dist/index.js",
|
"start": "SYNC_SERVER_URL=https://sync.xo.harvmaster.com node dist/index.js",
|
||||||
"test": "echo \"Error: no test specified\" && exit 1"
|
"test": "echo \"Error: no test specified\" && exit 1"
|
||||||
@@ -24,14 +24,16 @@
|
|||||||
"@xo-cash/state": "file:../state",
|
"@xo-cash/state": "file:../state",
|
||||||
"@xo-cash/templates": "file:../templates",
|
"@xo-cash/templates": "file:../templates",
|
||||||
"@xo-cash/types": "file:../types",
|
"@xo-cash/types": "file:../types",
|
||||||
|
"better-sqlite3": "^12.6.2",
|
||||||
"clipboardy": "^5.1.0",
|
"clipboardy": "^5.1.0",
|
||||||
"ink": "^5.1.0",
|
"ink": "^6.6.0",
|
||||||
"ink-select-input": "^6.0.0",
|
"ink-select-input": "^6.0.0",
|
||||||
"ink-spinner": "^5.0.0",
|
"ink-spinner": "^5.0.0",
|
||||||
"ink-text-input": "^6.0.0",
|
"ink-text-input": "^6.0.0",
|
||||||
"react": "^18.3.1"
|
"react": "^19.2.4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/better-sqlite3": "^7.6.13",
|
||||||
"@types/node": "^25.0.10",
|
"@types/node": "^25.0.10",
|
||||||
"@types/react": "^18.3.18",
|
"@types/react": "^18.3.18",
|
||||||
"tsx": "^4.21.0",
|
"tsx": "^4.21.0",
|
||||||
|
|||||||
97
src/app.ts
97
src/app.ts
@@ -1,72 +1,43 @@
|
|||||||
/**
|
/**
|
||||||
* Application bootstrap and lifecycle management.
|
* Application bootstrap and lifecycle management.
|
||||||
* Coordinates initialization of all CLI components.
|
* Simplified to render TUI immediately and let it handle AppService creation.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { render, type Instance } from 'ink';
|
import { render, type Instance } from 'ink';
|
||||||
import { App as AppComponent } from './tui/App.js';
|
import { App as AppComponent } from './tui/App.js';
|
||||||
import { WalletController } from './controllers/wallet-controller.js';
|
|
||||||
import { InvitationController } from './controllers/invitation-controller.js';
|
|
||||||
import { SyncClient } from './services/sync-client.js';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Configuration options for the CLI application.
|
* Configuration options for the CLI application.
|
||||||
*/
|
*/
|
||||||
export interface AppConfig {
|
export interface AppConfig {
|
||||||
/** URL of the sync server (default: http://localhost:3000) */
|
/** URL of the sync server (default: http://localhost:3000) */
|
||||||
syncServerUrl?: string;
|
syncServerUrl: string;
|
||||||
/** Database path for wallet state storage */
|
/** Database path for wallet state storage */
|
||||||
databasePath?: string;
|
databasePath: string;
|
||||||
/** Database filename */
|
/** Database filename */
|
||||||
databaseFilename?: string;
|
databaseFilename: string;
|
||||||
|
/** Path for invitation storage database */
|
||||||
|
invitationStoragePath: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Main application class that orchestrates all CLI components.
|
* Main application class that orchestrates the CLI.
|
||||||
|
* Renders the TUI immediately and passes config for later AppService creation.
|
||||||
*/
|
*/
|
||||||
export class App {
|
export class App {
|
||||||
/** Ink render instance */
|
/** Ink render instance */
|
||||||
private inkInstance: Instance | null = null;
|
private inkInstance: Instance | null = null;
|
||||||
|
|
||||||
/** Wallet controller for engine operations */
|
|
||||||
private walletController: WalletController;
|
|
||||||
|
|
||||||
/** Invitation controller for collaborative transactions */
|
|
||||||
private invitationController: InvitationController;
|
|
||||||
|
|
||||||
/** HTTP client for sync server communication */
|
|
||||||
private syncClient: SyncClient;
|
|
||||||
|
|
||||||
/** Application configuration */
|
/** Application configuration */
|
||||||
private config: Required<AppConfig>;
|
private config: AppConfig;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a new App instance.
|
* Creates a new App instance.
|
||||||
* @param config - Application configuration options
|
* @param config - Application configuration options
|
||||||
*/
|
*/
|
||||||
private constructor(config: AppConfig = {}) {
|
private constructor(config: AppConfig) {
|
||||||
// Set default configuration
|
this.config = config;
|
||||||
this.config = {
|
|
||||||
syncServerUrl: config.syncServerUrl ?? 'http://localhost:3000',
|
|
||||||
databasePath: config.databasePath ?? './',
|
|
||||||
databaseFilename: config.databaseFilename ?? 'xo-wallet',
|
|
||||||
};
|
|
||||||
|
|
||||||
// Initialize sync client
|
|
||||||
this.syncClient = new SyncClient(this.config.syncServerUrl);
|
|
||||||
|
|
||||||
// Initialize wallet controller (engine will be created when seed is provided)
|
|
||||||
this.walletController = new WalletController({
|
|
||||||
databasePath: this.config.databasePath,
|
|
||||||
databaseFilename: this.config.databaseFilename,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Initialize invitation controller
|
|
||||||
this.invitationController = new InvitationController(
|
|
||||||
this.walletController,
|
|
||||||
this.syncClient,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -74,22 +45,32 @@ export class App {
|
|||||||
* @param config - Application configuration options
|
* @param config - Application configuration options
|
||||||
* @returns Running App instance
|
* @returns Running App instance
|
||||||
*/
|
*/
|
||||||
static async create(config: AppConfig = {}): Promise<App> {
|
static async create(config: Partial<AppConfig> = {}): Promise<App> {
|
||||||
const app = new App(config);
|
// Set default configuration
|
||||||
|
const fullConfig: AppConfig = {
|
||||||
|
syncServerUrl: config.syncServerUrl ?? 'http://localhost:3000',
|
||||||
|
databasePath: config.databasePath ?? './',
|
||||||
|
databaseFilename: config.databaseFilename ?? 'xo-wallet.db',
|
||||||
|
invitationStoragePath: config.invitationStoragePath ?? './xo-invitations.db',
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log('Full config:', fullConfig);
|
||||||
|
|
||||||
|
const app = new App(fullConfig);
|
||||||
await app.start();
|
await app.start();
|
||||||
return app;
|
return app;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Starts the application.
|
* Starts the application.
|
||||||
* Renders the Ink-based TUI.
|
* Renders the Ink-based TUI immediately.
|
||||||
*/
|
*/
|
||||||
async start(): Promise<void> {
|
async start(): Promise<void> {
|
||||||
// Render the Ink app
|
// Render the Ink app with config
|
||||||
|
// TUI will handle AppService creation after seed input
|
||||||
this.inkInstance = render(
|
this.inkInstance = render(
|
||||||
React.createElement(AppComponent, {
|
React.createElement(AppComponent, {
|
||||||
walletController: this.walletController,
|
config: this.config,
|
||||||
invitationController: this.invitationController,
|
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -101,34 +82,10 @@ export class App {
|
|||||||
* Stops the application and cleans up resources.
|
* Stops the application and cleans up resources.
|
||||||
*/
|
*/
|
||||||
async stop(): Promise<void> {
|
async stop(): Promise<void> {
|
||||||
// Stop the wallet engine if running
|
|
||||||
await this.walletController.stop();
|
|
||||||
|
|
||||||
// Unmount Ink app
|
// Unmount Ink app
|
||||||
if (this.inkInstance) {
|
if (this.inkInstance) {
|
||||||
this.inkInstance.unmount();
|
this.inkInstance.unmount();
|
||||||
this.inkInstance = null;
|
this.inkInstance = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Gets the wallet controller for external access.
|
|
||||||
*/
|
|
||||||
getWalletController(): WalletController {
|
|
||||||
return this.walletController;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Gets the invitation controller for external access.
|
|
||||||
*/
|
|
||||||
getInvitationController(): InvitationController {
|
|
||||||
return this.invitationController;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Gets the sync client for external access.
|
|
||||||
*/
|
|
||||||
getSyncClient(): SyncClient {
|
|
||||||
return this.syncClient;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,293 +0,0 @@
|
|||||||
/**
|
|
||||||
* Invitation Controller - High-level interface for invitation management.
|
|
||||||
*
|
|
||||||
* Provides a simplified API for the TUI to interact with invitations,
|
|
||||||
* wrapping the InvitationFlowManager and coordinating with the WalletController.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { EventEmitter } from 'events';
|
|
||||||
import type { XOInvitation } from '@xo-cash/types';
|
|
||||||
import { InvitationFlowManager, type TrackedInvitation, type InvitationState } from '../services/invitation-flow.js';
|
|
||||||
import type { WalletController } from './wallet-controller.js';
|
|
||||||
import type { SyncClient } from '../services/sync-client.js';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Events emitted by the invitation controller.
|
|
||||||
*/
|
|
||||||
export interface InvitationControllerEvents {
|
|
||||||
'invitation-created': (invitationId: string) => void;
|
|
||||||
'invitation-updated': (invitationId: string) => void;
|
|
||||||
'invitation-state-changed': (invitationId: string, state: InvitationState) => void;
|
|
||||||
'error': (error: Error) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Controller for managing invitations in the TUI.
|
|
||||||
*/
|
|
||||||
export class InvitationController extends EventEmitter {
|
|
||||||
/** Flow manager for invitation lifecycle */
|
|
||||||
private flowManager: InvitationFlowManager;
|
|
||||||
|
|
||||||
/** Wallet controller reference */
|
|
||||||
private walletController: WalletController;
|
|
||||||
|
|
||||||
/** Sync client reference */
|
|
||||||
private syncClient: SyncClient;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates a new invitation controller.
|
|
||||||
* @param walletController - Wallet controller instance
|
|
||||||
* @param syncClient - Sync client instance
|
|
||||||
*/
|
|
||||||
constructor(walletController: WalletController, syncClient: SyncClient) {
|
|
||||||
super();
|
|
||||||
|
|
||||||
this.walletController = walletController;
|
|
||||||
this.syncClient = syncClient;
|
|
||||||
this.flowManager = new InvitationFlowManager(walletController, syncClient);
|
|
||||||
|
|
||||||
// Forward events from flow manager
|
|
||||||
this.flowManager.on('invitation-created', (invitation: XOInvitation) => {
|
|
||||||
this.emit('invitation-created', invitation.invitationIdentifier);
|
|
||||||
});
|
|
||||||
|
|
||||||
this.flowManager.on('invitation-updated', (invitationId: string) => {
|
|
||||||
this.emit('invitation-updated', invitationId);
|
|
||||||
});
|
|
||||||
|
|
||||||
this.flowManager.on('invitation-state-changed', (invitationId: string, state: InvitationState) => {
|
|
||||||
this.emit('invitation-state-changed', invitationId, state);
|
|
||||||
});
|
|
||||||
|
|
||||||
this.flowManager.on('error', (_invitationId: string, error: Error) => {
|
|
||||||
this.emit('error', error);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Invitation Creation Flow
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates a new invitation from a template action.
|
|
||||||
* @param templateIdentifier - Template ID
|
|
||||||
* @param actionIdentifier - Action ID
|
|
||||||
* @returns Created tracked invitation
|
|
||||||
*/
|
|
||||||
async createInvitation(
|
|
||||||
templateIdentifier: string,
|
|
||||||
actionIdentifier: string,
|
|
||||||
): Promise<TrackedInvitation> {
|
|
||||||
return this.flowManager.createInvitation(templateIdentifier, actionIdentifier);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Publishes an invitation to the sync server and starts listening for updates.
|
|
||||||
* @param invitationId - Invitation ID to publish
|
|
||||||
* @returns The invitation ID for sharing
|
|
||||||
*/
|
|
||||||
async publishAndSubscribe(invitationId: string): Promise<string> {
|
|
||||||
// Publish to sync server
|
|
||||||
await this.flowManager.publishInvitation(invitationId);
|
|
||||||
|
|
||||||
// Subscribe to SSE updates
|
|
||||||
await this.flowManager.subscribeToUpdates(invitationId);
|
|
||||||
|
|
||||||
return invitationId;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Invitation Import Flow
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Imports an invitation by ID from the sync server.
|
|
||||||
* @param invitationId - Invitation ID to import
|
|
||||||
* @returns Imported tracked invitation
|
|
||||||
*/
|
|
||||||
async importInvitation(invitationId: string): Promise<TrackedInvitation> {
|
|
||||||
return this.flowManager.importInvitation(invitationId);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Accepts an imported invitation (joins as participant).
|
|
||||||
* @param invitationId - Invitation ID to accept
|
|
||||||
* @returns Updated tracked invitation
|
|
||||||
*/
|
|
||||||
async acceptInvitation(invitationId: string): Promise<TrackedInvitation> {
|
|
||||||
return this.flowManager.acceptInvitation(invitationId);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Invitation Data Operations
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Appends inputs to an invitation.
|
|
||||||
* @param invitationId - Invitation ID
|
|
||||||
* @param inputs - Inputs to add
|
|
||||||
*/
|
|
||||||
async addInputs(
|
|
||||||
invitationId: string,
|
|
||||||
inputs: Array<{
|
|
||||||
outpointTransactionHash: string;
|
|
||||||
outpointIndex: number;
|
|
||||||
sequenceNumber?: number;
|
|
||||||
inputIdentifier?: string;
|
|
||||||
}>,
|
|
||||||
): Promise<TrackedInvitation> {
|
|
||||||
return this.flowManager.appendToInvitation(invitationId, { inputs });
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Appends outputs to an invitation.
|
|
||||||
* @param invitationId - Invitation ID
|
|
||||||
* @param outputs - Outputs to add
|
|
||||||
*/
|
|
||||||
async addOutputs(
|
|
||||||
invitationId: string,
|
|
||||||
outputs: Array<{
|
|
||||||
valueSatoshis?: bigint;
|
|
||||||
lockingBytecode?: Uint8Array;
|
|
||||||
outputIdentifier?: string;
|
|
||||||
roleIdentifier?: string;
|
|
||||||
}>,
|
|
||||||
): Promise<TrackedInvitation> {
|
|
||||||
return this.flowManager.appendToInvitation(invitationId, { outputs });
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Appends variables to an invitation.
|
|
||||||
* @param invitationId - Invitation ID
|
|
||||||
* @param variables - Variables to add
|
|
||||||
*/
|
|
||||||
async addVariables(
|
|
||||||
invitationId: string,
|
|
||||||
variables: Array<{
|
|
||||||
variableIdentifier: string;
|
|
||||||
value: bigint | boolean | number | string;
|
|
||||||
roleIdentifier?: string;
|
|
||||||
}>,
|
|
||||||
): Promise<TrackedInvitation> {
|
|
||||||
return this.flowManager.appendToInvitation(invitationId, { variables });
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Signing & Broadcasting
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Signs an invitation.
|
|
||||||
* @param invitationId - Invitation ID to sign
|
|
||||||
* @returns Updated tracked invitation
|
|
||||||
*/
|
|
||||||
async signInvitation(invitationId: string): Promise<TrackedInvitation> {
|
|
||||||
return this.flowManager.signInvitation(invitationId);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Broadcasts the transaction for an invitation.
|
|
||||||
* @param invitationId - Invitation ID
|
|
||||||
* @returns Transaction hash
|
|
||||||
*/
|
|
||||||
async broadcastTransaction(invitationId: string): Promise<string> {
|
|
||||||
return this.flowManager.broadcastTransaction(invitationId);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Queries
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Gets a tracked invitation by ID.
|
|
||||||
* @param invitationId - Invitation ID
|
|
||||||
* @returns Tracked invitation or undefined
|
|
||||||
*/
|
|
||||||
getInvitation(invitationId: string): TrackedInvitation | undefined {
|
|
||||||
return this.flowManager.get(invitationId);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Gets all tracked invitations.
|
|
||||||
* @returns Array of tracked invitations
|
|
||||||
*/
|
|
||||||
getAllInvitations(): TrackedInvitation[] {
|
|
||||||
return this.flowManager.getAll();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Gets the invitation data.
|
|
||||||
* @param invitationId - Invitation ID
|
|
||||||
* @returns The XOInvitation or undefined
|
|
||||||
*/
|
|
||||||
getInvitationData(invitationId: string): XOInvitation | undefined {
|
|
||||||
return this.flowManager.get(invitationId)?.invitation;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Gets the state of an invitation.
|
|
||||||
* @param invitationId - Invitation ID
|
|
||||||
* @returns Invitation state or undefined
|
|
||||||
*/
|
|
||||||
getInvitationState(invitationId: string): InvitationState | undefined {
|
|
||||||
return this.flowManager.get(invitationId)?.state;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Gets available roles for an invitation.
|
|
||||||
* @param invitationId - Invitation ID
|
|
||||||
* @returns Array of available role identifiers
|
|
||||||
*/
|
|
||||||
async getAvailableRoles(invitationId: string): Promise<string[]> {
|
|
||||||
const tracked = this.flowManager.get(invitationId);
|
|
||||||
if (!tracked) {
|
|
||||||
throw new Error(`Invitation not found: ${invitationId}`);
|
|
||||||
}
|
|
||||||
return this.walletController.getAvailableRoles(tracked.invitation);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Gets missing requirements for an invitation.
|
|
||||||
* @param invitationId - Invitation ID
|
|
||||||
* @returns Missing requirements
|
|
||||||
*/
|
|
||||||
async getMissingRequirements(invitationId: string) {
|
|
||||||
const tracked = this.flowManager.get(invitationId);
|
|
||||||
if (!tracked) {
|
|
||||||
throw new Error(`Invitation not found: ${invitationId}`);
|
|
||||||
}
|
|
||||||
return this.walletController.getMissingRequirements(tracked.invitation);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Gets requirements for an invitation.
|
|
||||||
* @param invitationId - Invitation ID
|
|
||||||
* @returns Requirements
|
|
||||||
*/
|
|
||||||
async getRequirements(invitationId: string) {
|
|
||||||
const tracked = this.flowManager.get(invitationId);
|
|
||||||
if (!tracked) {
|
|
||||||
throw new Error(`Invitation not found: ${invitationId}`);
|
|
||||||
}
|
|
||||||
return this.walletController.getRequirements(tracked.invitation);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Cleanup
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Stops tracking an invitation.
|
|
||||||
* @param invitationId - Invitation ID to stop tracking
|
|
||||||
*/
|
|
||||||
stopTracking(invitationId: string): void {
|
|
||||||
this.flowManager.untrack(invitationId);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Cleans up all resources.
|
|
||||||
*/
|
|
||||||
destroy(): void {
|
|
||||||
this.flowManager.destroy();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,408 +0,0 @@
|
|||||||
/**
|
|
||||||
* Wallet Controller - Orchestrates wallet operations via the XO Engine.
|
|
||||||
*
|
|
||||||
* Responsibilities:
|
|
||||||
* - Initializes Engine with user seed
|
|
||||||
* - Exposes wallet state queries (balances, UTXOs)
|
|
||||||
* - Delegates template/invitation operations to Engine
|
|
||||||
* - Emits state change events for UI updates
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { EventEmitter } from 'events';
|
|
||||||
import { Engine } from '@xo-cash/engine';
|
|
||||||
import type { XOInvitation, XOTemplate, XOTemplateStartingActions } from '@xo-cash/types';
|
|
||||||
import type { UnspentOutputData, LockingBytecodeData } from '@xo-cash/state';
|
|
||||||
import { p2pkhTemplate } from '@xo-cash/templates';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Configuration options for the wallet controller.
|
|
||||||
*/
|
|
||||||
export interface WalletControllerConfig {
|
|
||||||
/** Path for database storage */
|
|
||||||
databasePath?: string;
|
|
||||||
/** Database filename */
|
|
||||||
databaseFilename?: string;
|
|
||||||
/** Electrum application identifier */
|
|
||||||
electrumApplicationIdentifier?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Balance information for display.
|
|
||||||
*/
|
|
||||||
export interface WalletBalance {
|
|
||||||
/** Total satoshis across all UTXOs */
|
|
||||||
totalSatoshis: bigint;
|
|
||||||
/** Number of UTXOs */
|
|
||||||
utxoCount: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Events emitted by the wallet controller.
|
|
||||||
*/
|
|
||||||
export interface WalletControllerEvents {
|
|
||||||
'initialized': () => void;
|
|
||||||
'state-updated': () => void;
|
|
||||||
'error': (error: Error) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Controller for wallet operations.
|
|
||||||
*/
|
|
||||||
export class WalletController extends EventEmitter {
|
|
||||||
/** The XO Engine instance */
|
|
||||||
private engine: Engine | null = null;
|
|
||||||
|
|
||||||
/** Controller configuration */
|
|
||||||
private config: WalletControllerConfig;
|
|
||||||
|
|
||||||
/** Whether the wallet is initialized */
|
|
||||||
private initialized: boolean = false;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates a new wallet controller.
|
|
||||||
* @param config - Controller configuration options
|
|
||||||
*/
|
|
||||||
constructor(config: WalletControllerConfig = {}) {
|
|
||||||
super();
|
|
||||||
this.config = config;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Checks if the wallet is initialized.
|
|
||||||
*/
|
|
||||||
isInitialized(): boolean {
|
|
||||||
return this.initialized && this.engine !== null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Initializes the wallet with a seed phrase.
|
|
||||||
* @param seed - BIP39 seed phrase
|
|
||||||
*/
|
|
||||||
async initialize(seed: string): Promise<void> {
|
|
||||||
try {
|
|
||||||
// Create the engine with the provided seed
|
|
||||||
this.engine = await Engine.create(seed, {
|
|
||||||
databasePath: this.config.databasePath ?? './',
|
|
||||||
databaseFilename: this.config.databaseFilename ?? 'xo-wallet',
|
|
||||||
electrumApplicationIdentifier: this.config.electrumApplicationIdentifier ?? 'xo-wallet-cli',
|
|
||||||
});
|
|
||||||
|
|
||||||
// Import the default P2PKH template
|
|
||||||
await this.engine.importTemplate(p2pkhTemplate);
|
|
||||||
|
|
||||||
// Set default locking parameters for P2PKH
|
|
||||||
await this.engine.setDefaultLockingParameters(
|
|
||||||
await this.getTemplateIdentifier(p2pkhTemplate),
|
|
||||||
'receiveOutput',
|
|
||||||
'receiver',
|
|
||||||
);
|
|
||||||
|
|
||||||
// Generate an initial receiving address
|
|
||||||
const templateId = await this.getTemplateIdentifier(p2pkhTemplate);
|
|
||||||
await this.engine.generateLockingBytecode(templateId, 'receiveOutput', 'receiver');
|
|
||||||
|
|
||||||
this.initialized = true;
|
|
||||||
this.emit('initialized');
|
|
||||||
} catch (error) {
|
|
||||||
this.emit('error', error instanceof Error ? error : new Error(String(error)));
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Gets the template identifier from a template.
|
|
||||||
* @param template - The XO template
|
|
||||||
* @returns The template identifier
|
|
||||||
*/
|
|
||||||
private async getTemplateIdentifier(template: XOTemplate): Promise<string> {
|
|
||||||
// Import the utility to generate template identifier
|
|
||||||
const { generateTemplateIdentifier } = await import('@xo-cash/engine');
|
|
||||||
return generateTemplateIdentifier(template);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Stops the wallet engine and cleans up resources.
|
|
||||||
*/
|
|
||||||
async stop(): Promise<void> {
|
|
||||||
if (this.engine) {
|
|
||||||
await this.engine.stop();
|
|
||||||
this.engine = null;
|
|
||||||
this.initialized = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Gets the engine instance.
|
|
||||||
* @throws Error if engine is not initialized
|
|
||||||
*/
|
|
||||||
getEngine(): Engine {
|
|
||||||
if (!this.engine) {
|
|
||||||
throw new Error('Wallet not initialized. Please enter your seed phrase first.');
|
|
||||||
}
|
|
||||||
return this.engine;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Balance & UTXO Operations
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Gets the wallet balance.
|
|
||||||
* @returns Wallet balance information
|
|
||||||
*/
|
|
||||||
async getBalance(): Promise<WalletBalance> {
|
|
||||||
const engine = this.getEngine();
|
|
||||||
const utxos = await engine.listUnspentOutputsData();
|
|
||||||
|
|
||||||
const totalSatoshis = utxos.reduce(
|
|
||||||
(sum, utxo) => sum + BigInt(utxo.valueSatoshis),
|
|
||||||
BigInt(0),
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
|
||||||
totalSatoshis,
|
|
||||||
utxoCount: utxos.length,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Gets all unspent outputs.
|
|
||||||
* @returns Array of unspent output data
|
|
||||||
*/
|
|
||||||
async getUnspentOutputs(): Promise<UnspentOutputData[]> {
|
|
||||||
const engine = this.getEngine();
|
|
||||||
return engine.listUnspentOutputsData();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Gets locking bytecodes for a template.
|
|
||||||
* @param templateIdentifier - Template identifier
|
|
||||||
* @returns Array of locking bytecode data
|
|
||||||
*/
|
|
||||||
async getLockingBytecodes(templateIdentifier: string): Promise<LockingBytecodeData[]> {
|
|
||||||
const engine = this.getEngine();
|
|
||||||
return engine.listLockingBytecodesForTemplate(templateIdentifier);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Template Operations
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Gets all imported templates.
|
|
||||||
* @returns Array of templates
|
|
||||||
*/
|
|
||||||
async getTemplates(): Promise<XOTemplate[]> {
|
|
||||||
const engine = this.getEngine();
|
|
||||||
return engine.listImportedTemplates();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Gets a specific template by identifier.
|
|
||||||
* @param templateIdentifier - Template identifier
|
|
||||||
* @returns The template or undefined
|
|
||||||
*/
|
|
||||||
async getTemplate(templateIdentifier: string): Promise<XOTemplate | undefined> {
|
|
||||||
const engine = this.getEngine();
|
|
||||||
return engine.getTemplate(templateIdentifier);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Gets starting actions for a template.
|
|
||||||
* @param templateIdentifier - Template identifier
|
|
||||||
* @returns Starting actions
|
|
||||||
*/
|
|
||||||
async getStartingActions(templateIdentifier: string): Promise<XOTemplateStartingActions> {
|
|
||||||
const engine = this.getEngine();
|
|
||||||
return engine.listStartingActions(templateIdentifier);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Imports a template into the wallet.
|
|
||||||
* @param template - Template to import (JSON or object)
|
|
||||||
*/
|
|
||||||
async importTemplate(template: unknown): Promise<void> {
|
|
||||||
const engine = this.getEngine();
|
|
||||||
await engine.importTemplate(template);
|
|
||||||
this.emit('state-updated');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generates a new locking bytecode (receiving address).
|
|
||||||
* @param templateIdentifier - Template identifier
|
|
||||||
* @param outputIdentifier - Output identifier
|
|
||||||
* @param roleIdentifier - Role identifier
|
|
||||||
* @returns Generated locking bytecode as hex
|
|
||||||
*/
|
|
||||||
async generateLockingBytecode(
|
|
||||||
templateIdentifier: string,
|
|
||||||
outputIdentifier: string,
|
|
||||||
roleIdentifier?: string,
|
|
||||||
): Promise<string> {
|
|
||||||
const engine = this.getEngine();
|
|
||||||
const lockingBytecode = await engine.generateLockingBytecode(
|
|
||||||
templateIdentifier,
|
|
||||||
outputIdentifier,
|
|
||||||
roleIdentifier,
|
|
||||||
);
|
|
||||||
this.emit('state-updated');
|
|
||||||
return lockingBytecode;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Invitation Operations
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates a new invitation.
|
|
||||||
* @param templateIdentifier - Template identifier
|
|
||||||
* @param actionIdentifier - Action identifier
|
|
||||||
* @returns Created invitation
|
|
||||||
*/
|
|
||||||
async createInvitation(
|
|
||||||
templateIdentifier: string,
|
|
||||||
actionIdentifier: string,
|
|
||||||
): Promise<XOInvitation> {
|
|
||||||
const engine = this.getEngine();
|
|
||||||
return engine.createInvitation({
|
|
||||||
templateIdentifier,
|
|
||||||
actionIdentifier,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Accepts an invitation.
|
|
||||||
* @param invitation - Invitation to accept
|
|
||||||
* @returns Updated invitation
|
|
||||||
*/
|
|
||||||
async acceptInvitation(invitation: XOInvitation): Promise<XOInvitation> {
|
|
||||||
const engine = this.getEngine();
|
|
||||||
return engine.acceptInvitation(invitation);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Appends data to an invitation.
|
|
||||||
* @param invitation - Invitation to append to
|
|
||||||
* @param params - Data to append
|
|
||||||
* @returns Updated invitation
|
|
||||||
*/
|
|
||||||
async appendInvitation(
|
|
||||||
invitation: XOInvitation,
|
|
||||||
params: {
|
|
||||||
inputs?: Array<{
|
|
||||||
outpointTransactionHash?: string;
|
|
||||||
outpointIndex?: number;
|
|
||||||
sequenceNumber?: number;
|
|
||||||
mergesWith?: { commitIdentifier: string; index: number };
|
|
||||||
unlockingBytecode?: Uint8Array;
|
|
||||||
}>;
|
|
||||||
outputs?: Array<{
|
|
||||||
valueSatoshis?: bigint;
|
|
||||||
lockingBytecode?: Uint8Array;
|
|
||||||
outputIdentifier?: string;
|
|
||||||
roleIdentifier?: string;
|
|
||||||
mergesWith?: { commitIdentifier: string; index: number };
|
|
||||||
}>;
|
|
||||||
variables?: Array<{
|
|
||||||
variableIdentifier: string;
|
|
||||||
value: bigint | boolean | number | string;
|
|
||||||
roleIdentifier?: string;
|
|
||||||
}>;
|
|
||||||
},
|
|
||||||
): Promise<XOInvitation> {
|
|
||||||
const engine = this.getEngine();
|
|
||||||
// Cast through unknown to handle strict type checking from engine's AppendInvitationParameters
|
|
||||||
// The engine expects Uint8Array for outpointTransactionHash but we accept string for convenience
|
|
||||||
return engine.appendInvitation(invitation, params as unknown as Parameters<typeof engine.appendInvitation>[1]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Signs an invitation.
|
|
||||||
* @param invitation - Invitation to sign
|
|
||||||
* @returns Signed invitation
|
|
||||||
*/
|
|
||||||
async signInvitation(invitation: XOInvitation): Promise<XOInvitation> {
|
|
||||||
const engine = this.getEngine();
|
|
||||||
return engine.signInvitation(invitation);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validates an invitation.
|
|
||||||
* @param invitation - Invitation to validate
|
|
||||||
* @returns Whether the invitation is valid
|
|
||||||
*/
|
|
||||||
async isInvitationValid(invitation: XOInvitation): Promise<boolean> {
|
|
||||||
const engine = this.getEngine();
|
|
||||||
return engine.isInvitationValid(invitation);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Gets available roles for an invitation.
|
|
||||||
* @param invitation - Invitation to check
|
|
||||||
* @returns Array of available role identifiers
|
|
||||||
*/
|
|
||||||
async getAvailableRoles(invitation: XOInvitation): Promise<string[]> {
|
|
||||||
const engine = this.getEngine();
|
|
||||||
return engine.listAvailableRoles(invitation);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Gets requirements for an invitation.
|
|
||||||
* @param invitation - Invitation to check
|
|
||||||
* @returns Requirements information
|
|
||||||
*/
|
|
||||||
async getRequirements(invitation: XOInvitation) {
|
|
||||||
const engine = this.getEngine();
|
|
||||||
return engine.listRequirements(invitation);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Gets missing requirements for an invitation.
|
|
||||||
* @param invitation - Invitation to check
|
|
||||||
* @returns Missing requirements information
|
|
||||||
*/
|
|
||||||
async getMissingRequirements(invitation: XOInvitation) {
|
|
||||||
const engine = this.getEngine();
|
|
||||||
return engine.listMissingRequirements(invitation);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Finds suitable UTXOs for an invitation.
|
|
||||||
* @param invitation - Invitation to find resources for
|
|
||||||
* @param options - Search options
|
|
||||||
* @returns Suitable unspent outputs
|
|
||||||
*/
|
|
||||||
async findSuitableResources(
|
|
||||||
invitation: XOInvitation,
|
|
||||||
options: { templateIdentifier: string; outputIdentifier: string },
|
|
||||||
) {
|
|
||||||
const engine = this.getEngine();
|
|
||||||
return engine.findSuitableResources(invitation, options);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Transaction Operations
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Executes an action (broadcasts transaction).
|
|
||||||
* @param invitation - Invitation with completed transaction
|
|
||||||
* @param options - Execution options
|
|
||||||
* @returns Transaction hash
|
|
||||||
*/
|
|
||||||
async executeAction(
|
|
||||||
invitation: XOInvitation,
|
|
||||||
options: { broadcastTransaction?: boolean } = { broadcastTransaction: true },
|
|
||||||
): Promise<string> {
|
|
||||||
const engine = this.getEngine();
|
|
||||||
const txHash = await engine.executeAction(invitation, {
|
|
||||||
broadcastTransaction: options.broadcastTransaction ?? true,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (options.broadcastTransaction) {
|
|
||||||
this.emit('state-updated');
|
|
||||||
}
|
|
||||||
|
|
||||||
return txHash;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -20,7 +20,7 @@ async function main(): Promise<void> {
|
|||||||
await App.create({
|
await App.create({
|
||||||
syncServerUrl: process.env['SYNC_SERVER_URL'] ?? 'http://localhost:3000',
|
syncServerUrl: process.env['SYNC_SERVER_URL'] ?? 'http://localhost:3000',
|
||||||
databasePath: process.env['DB_PATH'] ?? './',
|
databasePath: process.env['DB_PATH'] ?? './',
|
||||||
databaseFilename: process.env['DB_FILENAME'] ?? 'xo-wallet',
|
databaseFilename: process.env['DB_FILENAME'] ?? 'xo-wallet.db',
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to start XO Wallet CLI:', error);
|
console.error('Failed to start XO Wallet CLI:', error);
|
||||||
|
|||||||
127
src/services/app.ts
Normal file
127
src/services/app.ts
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
import {
|
||||||
|
Engine,
|
||||||
|
type XOEngineOptions,
|
||||||
|
// This is temporary. Will likely be moved to where we import templates in the cli. I think that makes more sense as this is a library thing
|
||||||
|
generateTemplateIdentifier,
|
||||||
|
} from '@xo-cash/engine';
|
||||||
|
import type { XOInvitation } from '@xo-cash/types';
|
||||||
|
|
||||||
|
import { Invitation } from './invitation.js';
|
||||||
|
import { Storage } from './storage.js';
|
||||||
|
import { SyncServer } from '../utils/sync-server.js';
|
||||||
|
import { HistoryService } from './history.js';
|
||||||
|
|
||||||
|
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';
|
||||||
|
|
||||||
|
export type AppEventMap = {
|
||||||
|
'invitation-added': Invitation;
|
||||||
|
'invitation-removed': Invitation;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AppConfig {
|
||||||
|
syncServerUrl: string;
|
||||||
|
engineConfig: XOEngineOptions;
|
||||||
|
invitationStoragePath: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class AppService extends EventEmitter<AppEventMap> {
|
||||||
|
public engine: Engine;
|
||||||
|
public storage: Storage;
|
||||||
|
public config: AppConfig;
|
||||||
|
public history: HistoryService;
|
||||||
|
|
||||||
|
public invitations: Invitation[] = [];
|
||||||
|
|
||||||
|
static async create(seed: string, config: AppConfig): Promise<AppService> {
|
||||||
|
// Because of a bug that lets wallets read the unspents of other wallets, we are going to manually namespace the storage paths for the app.
|
||||||
|
// We are going to do this by computing a hash of the seed and prefixing the storage paths with it.
|
||||||
|
const seedHash = createHash('sha256').update(seed).digest('hex');
|
||||||
|
|
||||||
|
// We want to only prefix the file name
|
||||||
|
const prefixedStoragePath = `${seedHash.slice(0, 8)}-${config.engineConfig.databaseFilename}`;
|
||||||
|
|
||||||
|
// Create the engine
|
||||||
|
const engine = await Engine.create(seed, {
|
||||||
|
...config.engineConfig,
|
||||||
|
databaseFilename: prefixedStoragePath,
|
||||||
|
});
|
||||||
|
|
||||||
|
// TODO: We *technically* dont want this here, but we also need some initial templates for the wallet, so im doing it here
|
||||||
|
// Import the default P2PKH template
|
||||||
|
await engine.importTemplate(p2pkhTemplate);
|
||||||
|
|
||||||
|
// Set default locking parameters for P2PKH
|
||||||
|
await engine.setDefaultLockingParameters(
|
||||||
|
generateTemplateIdentifier(p2pkhTemplate),
|
||||||
|
'receiveOutput',
|
||||||
|
'receiver',
|
||||||
|
);
|
||||||
|
|
||||||
|
// Create our own storage for the invitations
|
||||||
|
const storage = await Storage.create(config.invitationStoragePath);
|
||||||
|
|
||||||
|
// Create the app service
|
||||||
|
return new AppService(engine, storage, config);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(engine: Engine, storage: Storage, config: AppConfig) {
|
||||||
|
super();
|
||||||
|
|
||||||
|
this.engine = engine;
|
||||||
|
this.storage = storage;
|
||||||
|
this.config = config;
|
||||||
|
this.history = new HistoryService(engine, this.invitations);
|
||||||
|
}
|
||||||
|
|
||||||
|
async createInvitation(invitation: XOInvitation | string): Promise<Invitation> {
|
||||||
|
// Make sure the engine has the template imported
|
||||||
|
const invitationStorage = this.storage.child('invitations')
|
||||||
|
const invitationSyncServer = new SyncServer(this.config.syncServerUrl, typeof invitation === 'string' ? invitation : invitation.invitationIdentifier);
|
||||||
|
|
||||||
|
const deps = {
|
||||||
|
engine: this.engine,
|
||||||
|
syncServer: invitationSyncServer,
|
||||||
|
storage: invitationStorage,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Create the invitation
|
||||||
|
const invitationInstance = await Invitation.create(invitation, deps);
|
||||||
|
|
||||||
|
// Add the invitation to the invitations array
|
||||||
|
await this.addInvitation(invitationInstance);
|
||||||
|
|
||||||
|
return invitationInstance;
|
||||||
|
}
|
||||||
|
|
||||||
|
async addInvitation(invitation: Invitation): Promise<void> {
|
||||||
|
// Add the invitation to the invitations array
|
||||||
|
this.invitations.push(invitation);
|
||||||
|
|
||||||
|
// Emit the invitation-added event
|
||||||
|
this.emit('invitation-added', invitation);
|
||||||
|
}
|
||||||
|
|
||||||
|
async removeInvitation(invitation: Invitation): Promise<void> {
|
||||||
|
// Remove the invitation from the invitations array
|
||||||
|
this.invitations = this.invitations.filter(i => i !== invitation);
|
||||||
|
|
||||||
|
// Emit the invitation-removed event
|
||||||
|
this.emit('invitation-removed', invitation);
|
||||||
|
}
|
||||||
|
|
||||||
|
async start(): Promise<void> {
|
||||||
|
// Get the invitations db
|
||||||
|
const invitationsDb = this.storage.child('invitations');
|
||||||
|
|
||||||
|
// Load invitations from storage
|
||||||
|
const invitations = await invitationsDb.all() as { key: string; value: XOInvitation }[];
|
||||||
|
|
||||||
|
await Promise.all(invitations.map(async ({ key }) => {
|
||||||
|
await this.createInvitation(key);
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
252
src/services/history.ts
Normal file
252
src/services/history.ts
Normal file
@@ -0,0 +1,252 @@
|
|||||||
|
/**
|
||||||
|
* History Service - Derives wallet history from invitations and UTXOs.
|
||||||
|
*
|
||||||
|
* Provides a unified view of wallet activity including:
|
||||||
|
* - UTXO reservations (from invitation commits that reference our UTXOs as inputs)
|
||||||
|
* - UTXOs we own (with descriptions derived from template outputs)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { Engine } from '@xo-cash/engine';
|
||||||
|
import type { XOInvitation, XOTemplate } from '@xo-cash/types';
|
||||||
|
import type { UnspentOutputData } from '@xo-cash/state';
|
||||||
|
import type { Invitation } from './invitation.js';
|
||||||
|
import { binToHex } from '@bitauth/libauth';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Types of history events.
|
||||||
|
*/
|
||||||
|
export type HistoryItemType =
|
||||||
|
| 'utxo_received'
|
||||||
|
| 'utxo_reserved'
|
||||||
|
| 'invitation_created';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A single item in the wallet history.
|
||||||
|
*/
|
||||||
|
export interface HistoryItem {
|
||||||
|
/** Unique identifier for this history item. */
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
/** Unix timestamp of when the event occurred (if available). */
|
||||||
|
timestamp?: number;
|
||||||
|
|
||||||
|
/** The type of history event. */
|
||||||
|
type: HistoryItemType;
|
||||||
|
|
||||||
|
/** Human-readable description derived from the template. */
|
||||||
|
description: string;
|
||||||
|
|
||||||
|
/** The value in satoshis (for UTXO-related events). */
|
||||||
|
valueSatoshis?: bigint;
|
||||||
|
|
||||||
|
/** The invitation identifier this event relates to (if applicable). */
|
||||||
|
invitationIdentifier?: string;
|
||||||
|
|
||||||
|
/** The template identifier for reference. */
|
||||||
|
templateIdentifier?: string;
|
||||||
|
|
||||||
|
/** The UTXO outpoint (for UTXO-related events). */
|
||||||
|
outpoint?: {
|
||||||
|
txid: string;
|
||||||
|
index: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Whether this UTXO is reserved. */
|
||||||
|
reserved?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Service for deriving wallet history from invitations and UTXOs.
|
||||||
|
*
|
||||||
|
* This service takes the engine and invitations array as dependencies
|
||||||
|
* and derives history events from them. Since invitations is passed
|
||||||
|
* by reference, getHistory() always sees the current data.
|
||||||
|
*/
|
||||||
|
export class HistoryService {
|
||||||
|
/**
|
||||||
|
* Creates a new HistoryService.
|
||||||
|
*
|
||||||
|
* @param engine - The XO engine instance for querying UTXOs and templates.
|
||||||
|
* @param invitations - The array of invitations to derive history from.
|
||||||
|
*/
|
||||||
|
constructor(
|
||||||
|
private engine: Engine,
|
||||||
|
private invitations: Invitation[]
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the wallet history derived from invitations and UTXOs.
|
||||||
|
*
|
||||||
|
* @returns Array of history items sorted by timestamp (newest first), then UTXOs without timestamps.
|
||||||
|
*/
|
||||||
|
async getHistory(): Promise<HistoryItem[]> {
|
||||||
|
const items: HistoryItem[] = [];
|
||||||
|
|
||||||
|
// 1. Get all our UTXOs
|
||||||
|
const allUtxos = await this.engine.listUnspentOutputsData();
|
||||||
|
|
||||||
|
// Create a map for quick UTXO lookup by outpoint
|
||||||
|
const utxoMap = new Map<string, UnspentOutputData>();
|
||||||
|
for (const utxo of allUtxos) {
|
||||||
|
const key = `${utxo.outpointTransactionHash}:${utxo.outpointIndex}`;
|
||||||
|
utxoMap.set(key, utxo);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Process invitations to find UTXO reservations from commits
|
||||||
|
for (const invitation of this.invitations) {
|
||||||
|
const invData = invitation.data;
|
||||||
|
|
||||||
|
// Add invitation created event
|
||||||
|
const template = await this.engine.getTemplate(invData.templateIdentifier);
|
||||||
|
const invDescription = template
|
||||||
|
? this.deriveInvitationDescription(invData, template)
|
||||||
|
: 'Unknown action';
|
||||||
|
|
||||||
|
items.push({
|
||||||
|
id: `inv-${invData.invitationIdentifier}`,
|
||||||
|
timestamp: invData.createdAtTimestamp,
|
||||||
|
type: 'invitation_created',
|
||||||
|
description: invDescription,
|
||||||
|
invitationIdentifier: invData.invitationIdentifier,
|
||||||
|
templateIdentifier: invData.templateIdentifier,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Check each commit for inputs that reference our UTXOs
|
||||||
|
for (const commit of invData.commits) {
|
||||||
|
const commitInputs = commit.data.inputs ?? [];
|
||||||
|
|
||||||
|
for (const input of commitInputs) {
|
||||||
|
// Input's outpointTransactionHash could be Uint8Array or string
|
||||||
|
const txHash = input.outpointTransactionHash
|
||||||
|
? (input.outpointTransactionHash instanceof Uint8Array
|
||||||
|
? binToHex(input.outpointTransactionHash)
|
||||||
|
: String(input.outpointTransactionHash))
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
if (!txHash || input.outpointIndex === undefined) continue;
|
||||||
|
|
||||||
|
const utxoKey = `${txHash}:${input.outpointIndex}`;
|
||||||
|
const matchingUtxo = utxoMap.get(utxoKey);
|
||||||
|
|
||||||
|
// If this input references one of our UTXOs, it's a reservation event
|
||||||
|
if (matchingUtxo) {
|
||||||
|
const utxoTemplate = await this.engine.getTemplate(matchingUtxo.templateIdentifier);
|
||||||
|
const utxoDescription = utxoTemplate
|
||||||
|
? this.deriveUtxoDescription(matchingUtxo, utxoTemplate)
|
||||||
|
: 'Unknown UTXO';
|
||||||
|
|
||||||
|
items.push({
|
||||||
|
id: `reserved-${commit.commitIdentifier}-${utxoKey}`,
|
||||||
|
timestamp: invData.createdAtTimestamp, // Use invitation timestamp as proxy
|
||||||
|
type: 'utxo_reserved',
|
||||||
|
description: `Reserved for: ${invDescription}`,
|
||||||
|
valueSatoshis: BigInt(matchingUtxo.valueSatoshis),
|
||||||
|
invitationIdentifier: invData.invitationIdentifier,
|
||||||
|
templateIdentifier: matchingUtxo.templateIdentifier,
|
||||||
|
outpoint: {
|
||||||
|
txid: txHash,
|
||||||
|
index: input.outpointIndex,
|
||||||
|
},
|
||||||
|
reserved: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Add all UTXOs as "received" events (without timestamps)
|
||||||
|
for (const utxo of allUtxos) {
|
||||||
|
const template = await this.engine.getTemplate(utxo.templateIdentifier);
|
||||||
|
const description = template
|
||||||
|
? this.deriveUtxoDescription(utxo, template)
|
||||||
|
: 'Unknown output';
|
||||||
|
|
||||||
|
items.push({
|
||||||
|
id: `utxo-${utxo.outpointTransactionHash}:${utxo.outpointIndex}`,
|
||||||
|
// No timestamp available for UTXOs
|
||||||
|
type: 'utxo_received',
|
||||||
|
description,
|
||||||
|
valueSatoshis: BigInt(utxo.valueSatoshis),
|
||||||
|
templateIdentifier: utxo.templateIdentifier,
|
||||||
|
outpoint: {
|
||||||
|
txid: utxo.outpointTransactionHash,
|
||||||
|
index: utxo.outpointIndex,
|
||||||
|
},
|
||||||
|
reserved: utxo.reserved,
|
||||||
|
invitationIdentifier: utxo.invitationIdentifier || undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort: items with timestamps first (newest first), then items without timestamps
|
||||||
|
return items.sort((a, b) => {
|
||||||
|
// Both have timestamps: sort by timestamp descending
|
||||||
|
if (a.timestamp !== undefined && b.timestamp !== undefined) {
|
||||||
|
return b.timestamp - a.timestamp;
|
||||||
|
}
|
||||||
|
// Only a has timestamp: a comes first
|
||||||
|
if (a.timestamp !== undefined) return -1;
|
||||||
|
// Only b has timestamp: b comes first
|
||||||
|
if (b.timestamp !== undefined) return 1;
|
||||||
|
// Neither has timestamp: maintain order
|
||||||
|
return 0;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Derives a human-readable description for a UTXO from its template output definition.
|
||||||
|
*
|
||||||
|
* @param utxo - The UTXO data.
|
||||||
|
* @param template - The template definition.
|
||||||
|
* @returns Human-readable description string.
|
||||||
|
*/
|
||||||
|
private deriveUtxoDescription(utxo: UnspentOutputData, template: XOTemplate): string {
|
||||||
|
const outputDef = template.outputs?.[utxo.outputIdentifier];
|
||||||
|
|
||||||
|
if (!outputDef) {
|
||||||
|
return `${utxo.outputIdentifier} output`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start with the output name or identifier
|
||||||
|
let description = outputDef.name || utxo.outputIdentifier;
|
||||||
|
|
||||||
|
// If there's a description, parse it and replace variable placeholders
|
||||||
|
if (outputDef.description) {
|
||||||
|
description = outputDef.description
|
||||||
|
// Replace <variableName> placeholders (we don't have variable values here, so just clean up)
|
||||||
|
.replace(/<([^>]+)>/g, (_, varId) => varId)
|
||||||
|
// Remove $() wrappers
|
||||||
|
.replace(/\$\(([^)]+)\)/g, '$1');
|
||||||
|
}
|
||||||
|
|
||||||
|
return description;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Derives a human-readable description from an invitation and its template.
|
||||||
|
* Parses the transaction description and replaces variable placeholders.
|
||||||
|
*
|
||||||
|
* @param invitation - The invitation data.
|
||||||
|
* @param template - The template definition.
|
||||||
|
* @returns Human-readable description string.
|
||||||
|
*/
|
||||||
|
private deriveInvitationDescription(invitation: XOInvitation, template: XOTemplate): string {
|
||||||
|
const action = template.actions?.[invitation.actionIdentifier];
|
||||||
|
const transactionName = action?.transaction;
|
||||||
|
const transaction = transactionName ? template.transactions?.[transactionName] : null;
|
||||||
|
|
||||||
|
if (!transaction?.description) {
|
||||||
|
return action?.name ?? invitation.actionIdentifier;
|
||||||
|
}
|
||||||
|
|
||||||
|
const committedVariables = invitation.commits.flatMap(c => c.data.variables ?? []);
|
||||||
|
|
||||||
|
return transaction.description
|
||||||
|
// Replace <variableName> with actual values
|
||||||
|
.replace(/<([^>]+)>/g, (match, varId) => {
|
||||||
|
const variable = committedVariables.find(v => v.variableIdentifier === varId);
|
||||||
|
return variable ? String(variable.value) : match;
|
||||||
|
})
|
||||||
|
// Remove the $() wrapper around variable expressions
|
||||||
|
.replace(/\$\(([^)]+)\)/g, '$1');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,435 +0,0 @@
|
|||||||
/**
|
|
||||||
* Invitation Flow Manager - Manages the collaborative invitation lifecycle.
|
|
||||||
*
|
|
||||||
* Responsibilities:
|
|
||||||
* - Coordinates between local Engine and remote sync-server
|
|
||||||
* - Subscribes to SSE for real-time updates
|
|
||||||
* - Tracks invitation state machine
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { EventEmitter } from 'events';
|
|
||||||
import type { XOInvitation } from '@xo-cash/types';
|
|
||||||
import { SSESession } from '../utils/sse-client.js';
|
|
||||||
import type { SyncClient } from './sync-client.js';
|
|
||||||
import type { WalletController } from '../controllers/wallet-controller.js';
|
|
||||||
import { decodeExtendedJsonObject } from '../utils/ext-json.js';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* States an invitation can be in.
|
|
||||||
*/
|
|
||||||
export type InvitationState =
|
|
||||||
| 'created' // Just created locally
|
|
||||||
| 'published' // Published to sync server
|
|
||||||
| 'pending' // Waiting for other party
|
|
||||||
| 'ready' // All requirements met, ready to sign
|
|
||||||
| 'signed' // Signed and ready to broadcast
|
|
||||||
| 'broadcast' // Transaction broadcast
|
|
||||||
| 'completed' // Transaction confirmed
|
|
||||||
| 'expired' // Invitation expired
|
|
||||||
| 'error'; // Error state
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Tracked invitation with state information.
|
|
||||||
*/
|
|
||||||
export interface TrackedInvitation {
|
|
||||||
/** The invitation data */
|
|
||||||
invitation: XOInvitation;
|
|
||||||
/** Current state */
|
|
||||||
state: InvitationState;
|
|
||||||
/** SSE session for updates (if subscribed) */
|
|
||||||
sseSession?: SSESession;
|
|
||||||
/** Timestamp when tracking started */
|
|
||||||
trackedAt: number;
|
|
||||||
/** Last update timestamp */
|
|
||||||
lastUpdatedAt: number;
|
|
||||||
/** Error message if in error state */
|
|
||||||
error?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Events emitted by the invitation flow manager.
|
|
||||||
*/
|
|
||||||
export interface InvitationFlowEvents {
|
|
||||||
'invitation-created': (invitation: XOInvitation) => void;
|
|
||||||
'invitation-updated': (invitationId: string, invitation: XOInvitation) => void;
|
|
||||||
'invitation-state-changed': (invitationId: string, state: InvitationState) => void;
|
|
||||||
'error': (invitationId: string, error: Error) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Manages the invitation workflow.
|
|
||||||
*/
|
|
||||||
export class InvitationFlowManager extends EventEmitter {
|
|
||||||
/** Map of tracked invitations by ID */
|
|
||||||
private trackedInvitations: Map<string, TrackedInvitation> = new Map();
|
|
||||||
|
|
||||||
/** Wallet controller reference */
|
|
||||||
private walletController: WalletController;
|
|
||||||
|
|
||||||
/** Sync client reference */
|
|
||||||
private syncClient: SyncClient;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates a new invitation flow manager.
|
|
||||||
* @param walletController - Wallet controller instance
|
|
||||||
* @param syncClient - Sync client instance
|
|
||||||
*/
|
|
||||||
constructor(walletController: WalletController, syncClient: SyncClient) {
|
|
||||||
super();
|
|
||||||
this.walletController = walletController;
|
|
||||||
this.syncClient = syncClient;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Invitation Tracking
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Starts tracking an invitation.
|
|
||||||
* @param invitation - Invitation to track
|
|
||||||
* @param initialState - Initial state (default: 'created')
|
|
||||||
*/
|
|
||||||
track(invitation: XOInvitation, initialState: InvitationState = 'created'): TrackedInvitation {
|
|
||||||
const tracked: TrackedInvitation = {
|
|
||||||
invitation,
|
|
||||||
state: initialState,
|
|
||||||
trackedAt: Date.now(),
|
|
||||||
lastUpdatedAt: Date.now(),
|
|
||||||
};
|
|
||||||
|
|
||||||
this.trackedInvitations.set(invitation.invitationIdentifier, tracked);
|
|
||||||
return tracked;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Gets a tracked invitation by ID.
|
|
||||||
* @param invitationId - Invitation ID
|
|
||||||
* @returns Tracked invitation or undefined
|
|
||||||
*/
|
|
||||||
get(invitationId: string): TrackedInvitation | undefined {
|
|
||||||
return this.trackedInvitations.get(invitationId);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Gets all tracked invitations.
|
|
||||||
* @returns Array of tracked invitations
|
|
||||||
*/
|
|
||||||
getAll(): TrackedInvitation[] {
|
|
||||||
return Array.from(this.trackedInvitations.values());
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Updates the state of a tracked invitation.
|
|
||||||
* @param invitationId - Invitation ID
|
|
||||||
* @param state - New state
|
|
||||||
*/
|
|
||||||
private updateState(invitationId: string, state: InvitationState): void {
|
|
||||||
const tracked = this.trackedInvitations.get(invitationId);
|
|
||||||
if (tracked) {
|
|
||||||
tracked.state = state;
|
|
||||||
tracked.lastUpdatedAt = Date.now();
|
|
||||||
this.emit('invitation-state-changed', invitationId, state);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Updates a tracked invitation with new data.
|
|
||||||
* @param invitation - Updated invitation
|
|
||||||
*/
|
|
||||||
private updateInvitation(invitation: XOInvitation): void {
|
|
||||||
const tracked = this.trackedInvitations.get(invitation.invitationIdentifier);
|
|
||||||
if (tracked) {
|
|
||||||
tracked.invitation = invitation;
|
|
||||||
tracked.lastUpdatedAt = Date.now();
|
|
||||||
this.emit('invitation-updated', invitation.invitationIdentifier, invitation);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Stops tracking an invitation.
|
|
||||||
* @param invitationId - Invitation ID
|
|
||||||
*/
|
|
||||||
untrack(invitationId: string): void {
|
|
||||||
const tracked = this.trackedInvitations.get(invitationId);
|
|
||||||
if (tracked?.sseSession) {
|
|
||||||
tracked.sseSession.close();
|
|
||||||
}
|
|
||||||
this.trackedInvitations.delete(invitationId);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Flow Operations
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates a new invitation and starts tracking it.
|
|
||||||
* @param templateIdentifier - Template ID
|
|
||||||
* @param actionIdentifier - Action ID
|
|
||||||
* @returns Created and tracked invitation
|
|
||||||
*/
|
|
||||||
async createInvitation(
|
|
||||||
templateIdentifier: string,
|
|
||||||
actionIdentifier: string,
|
|
||||||
): Promise<TrackedInvitation> {
|
|
||||||
// Create invitation via wallet controller
|
|
||||||
const invitation = await this.walletController.createInvitation(
|
|
||||||
templateIdentifier,
|
|
||||||
actionIdentifier,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Track the invitation
|
|
||||||
const tracked = this.track(invitation, 'created');
|
|
||||||
this.emit('invitation-created', invitation);
|
|
||||||
|
|
||||||
return tracked;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Publishes an invitation to the sync server.
|
|
||||||
* @param invitationId - Invitation ID to publish
|
|
||||||
* @returns Updated tracked invitation
|
|
||||||
*/
|
|
||||||
async publishInvitation(invitationId: string): Promise<TrackedInvitation> {
|
|
||||||
const tracked = this.trackedInvitations.get(invitationId);
|
|
||||||
if (!tracked) {
|
|
||||||
throw new Error(`Invitation not found: ${invitationId}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Post to sync server
|
|
||||||
await this.syncClient.postInvitation(tracked.invitation);
|
|
||||||
|
|
||||||
// Update state
|
|
||||||
this.updateState(invitationId, 'published');
|
|
||||||
|
|
||||||
return tracked;
|
|
||||||
} catch (error) {
|
|
||||||
tracked.state = 'error';
|
|
||||||
tracked.error = error instanceof Error ? error.message : String(error);
|
|
||||||
this.emit('error', invitationId, error instanceof Error ? error : new Error(String(error)));
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Subscribes to SSE updates for an invitation.
|
|
||||||
* @param invitationId - Invitation ID to subscribe to
|
|
||||||
*/
|
|
||||||
async subscribeToUpdates(invitationId: string): Promise<void> {
|
|
||||||
const tracked = this.trackedInvitations.get(invitationId);
|
|
||||||
if (!tracked) {
|
|
||||||
throw new Error(`Invitation not found: ${invitationId}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Close existing SSE session if any
|
|
||||||
if (tracked.sseSession) {
|
|
||||||
tracked.sseSession.close();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create new SSE session
|
|
||||||
const sseUrl = this.syncClient.getSSEUrl(invitationId);
|
|
||||||
|
|
||||||
tracked.sseSession = await SSESession.from(sseUrl, {
|
|
||||||
method: 'GET',
|
|
||||||
headers: {
|
|
||||||
'Accept': 'text/event-stream',
|
|
||||||
},
|
|
||||||
onMessage: (event) => {
|
|
||||||
this.handleSSEMessage(invitationId, event);
|
|
||||||
},
|
|
||||||
onError: (error) => {
|
|
||||||
console.error(`SSE error for ${invitationId}:`, error);
|
|
||||||
this.emit('error', invitationId, error instanceof Error ? error : new Error(String(error)));
|
|
||||||
},
|
|
||||||
onConnected: () => {
|
|
||||||
console.log(`SSE connected for invitation: ${invitationId}`);
|
|
||||||
},
|
|
||||||
onDisconnected: () => {
|
|
||||||
console.log(`SSE disconnected for invitation: ${invitationId}`);
|
|
||||||
},
|
|
||||||
attemptReconnect: true,
|
|
||||||
persistent: true,
|
|
||||||
retryDelay: 3000,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Update state to pending (waiting for updates)
|
|
||||||
this.updateState(invitationId, 'pending');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handles an SSE message for an invitation.
|
|
||||||
* @param invitationId - Invitation ID
|
|
||||||
* @param event - SSE event
|
|
||||||
*/
|
|
||||||
private handleSSEMessage(invitationId: string, event: { data: string; event?: string }): void {
|
|
||||||
try {
|
|
||||||
// Parse the event data
|
|
||||||
const parsed = JSON.parse(event.data) as { topic?: string; data?: unknown };
|
|
||||||
|
|
||||||
if (event.event === 'invitation-updated' || parsed.topic === 'invitation-updated') {
|
|
||||||
// Decode the invitation data (handles ExtJSON)
|
|
||||||
const invitationData = decodeExtendedJsonObject(parsed.data ?? parsed);
|
|
||||||
const invitation = invitationData as XOInvitation;
|
|
||||||
|
|
||||||
// Update tracked invitation
|
|
||||||
this.updateInvitation(invitation);
|
|
||||||
|
|
||||||
// Check if all requirements are met
|
|
||||||
this.checkInvitationState(invitationId);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`Error parsing SSE message for ${invitationId}:`, error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Checks and updates the state of an invitation based on its data.
|
|
||||||
* @param invitationId - Invitation ID to check
|
|
||||||
*/
|
|
||||||
private async checkInvitationState(invitationId: string): Promise<void> {
|
|
||||||
const tracked = this.trackedInvitations.get(invitationId);
|
|
||||||
if (!tracked) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Check missing requirements
|
|
||||||
const missing = await this.walletController.getMissingRequirements(tracked.invitation);
|
|
||||||
|
|
||||||
// If no missing inputs/outputs, it's ready to sign
|
|
||||||
const hasNoMissingInputs = !missing.inputs || missing.inputs.length === 0;
|
|
||||||
const hasNoMissingOutputs = !missing.outputs || missing.outputs.length === 0;
|
|
||||||
|
|
||||||
if (hasNoMissingInputs && hasNoMissingOutputs) {
|
|
||||||
this.updateState(invitationId, 'ready');
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
// Ignore errors during state check
|
|
||||||
console.error(`Error checking invitation state: ${error}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Imports an invitation from the sync server.
|
|
||||||
* @param invitationId - Invitation ID to import
|
|
||||||
* @returns Tracked invitation
|
|
||||||
*/
|
|
||||||
async importInvitation(invitationId: string): Promise<TrackedInvitation> {
|
|
||||||
// Fetch from sync server
|
|
||||||
const invitation = await this.syncClient.getInvitation(invitationId);
|
|
||||||
if (!invitation) {
|
|
||||||
throw new Error(`Invitation not found on server: ${invitationId}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Track the invitation
|
|
||||||
const tracked = this.track(invitation, 'pending');
|
|
||||||
|
|
||||||
return tracked;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Accepts an invitation (joins as a participant).
|
|
||||||
* @param invitationId - Invitation ID to accept
|
|
||||||
* @returns Updated tracked invitation
|
|
||||||
*/
|
|
||||||
async acceptInvitation(invitationId: string): Promise<TrackedInvitation> {
|
|
||||||
const tracked = this.trackedInvitations.get(invitationId);
|
|
||||||
if (!tracked) {
|
|
||||||
throw new Error(`Invitation not found: ${invitationId}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Accept via wallet controller
|
|
||||||
const updatedInvitation = await this.walletController.acceptInvitation(tracked.invitation);
|
|
||||||
this.updateInvitation(updatedInvitation);
|
|
||||||
|
|
||||||
return tracked;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Appends data to an invitation.
|
|
||||||
* @param invitationId - Invitation ID
|
|
||||||
* @param params - Data to append
|
|
||||||
* @returns Updated tracked invitation
|
|
||||||
*/
|
|
||||||
async appendToInvitation(
|
|
||||||
invitationId: string,
|
|
||||||
params: Parameters<WalletController['appendInvitation']>[1],
|
|
||||||
): Promise<TrackedInvitation> {
|
|
||||||
const tracked = this.trackedInvitations.get(invitationId);
|
|
||||||
if (!tracked) {
|
|
||||||
throw new Error(`Invitation not found: ${invitationId}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Append via wallet controller
|
|
||||||
const updatedInvitation = await this.walletController.appendInvitation(
|
|
||||||
tracked.invitation,
|
|
||||||
params,
|
|
||||||
);
|
|
||||||
this.updateInvitation(updatedInvitation);
|
|
||||||
|
|
||||||
// Publish update to sync server
|
|
||||||
await this.syncClient.updateInvitation(updatedInvitation);
|
|
||||||
|
|
||||||
return tracked;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Signs an invitation.
|
|
||||||
* @param invitationId - Invitation ID
|
|
||||||
* @returns Updated tracked invitation
|
|
||||||
*/
|
|
||||||
async signInvitation(invitationId: string): Promise<TrackedInvitation> {
|
|
||||||
const tracked = this.trackedInvitations.get(invitationId);
|
|
||||||
if (!tracked) {
|
|
||||||
throw new Error(`Invitation not found: ${invitationId}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sign via wallet controller
|
|
||||||
const signedInvitation = await this.walletController.signInvitation(tracked.invitation);
|
|
||||||
this.updateInvitation(signedInvitation);
|
|
||||||
this.updateState(invitationId, 'signed');
|
|
||||||
|
|
||||||
// Publish signed invitation to sync server
|
|
||||||
await this.syncClient.updateInvitation(signedInvitation);
|
|
||||||
|
|
||||||
return tracked;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Broadcasts the transaction for an invitation.
|
|
||||||
* @param invitationId - Invitation ID
|
|
||||||
* @returns Transaction hash
|
|
||||||
*/
|
|
||||||
async broadcastTransaction(invitationId: string): Promise<string> {
|
|
||||||
const tracked = this.trackedInvitations.get(invitationId);
|
|
||||||
if (!tracked) {
|
|
||||||
throw new Error(`Invitation not found: ${invitationId}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Execute action (broadcast)
|
|
||||||
const txHash = await this.walletController.executeAction(tracked.invitation, {
|
|
||||||
broadcastTransaction: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
this.updateState(invitationId, 'broadcast');
|
|
||||||
|
|
||||||
// Close SSE session since we're done
|
|
||||||
if (tracked.sseSession) {
|
|
||||||
tracked.sseSession.close();
|
|
||||||
delete tracked.sseSession;
|
|
||||||
}
|
|
||||||
|
|
||||||
return txHash;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Cleans up all resources.
|
|
||||||
*/
|
|
||||||
destroy(): void {
|
|
||||||
// Close all SSE sessions
|
|
||||||
for (const tracked of this.trackedInvitations.values()) {
|
|
||||||
if (tracked.sseSession) {
|
|
||||||
tracked.sseSession.close();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
this.trackedInvitations.clear();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
413
src/services/invitation.ts
Normal file
413
src/services/invitation.ts
Normal file
@@ -0,0 +1,413 @@
|
|||||||
|
import type { AcceptInvitationParameters, AppendInvitationParameters, Engine, FindSuitableResourcesParameters } from '@xo-cash/engine';
|
||||||
|
import { hasInvitationExpired } from '@xo-cash/engine';
|
||||||
|
import type { XOInvitation, XOInvitationCommit, XOInvitationInput, XOInvitationOutput, XOInvitationVariable } from '@xo-cash/types';
|
||||||
|
import type { UnspentOutputData } from '@xo-cash/state';
|
||||||
|
|
||||||
|
import type { SSEvent } from '../utils/sse-client.js';
|
||||||
|
import type { SyncServer } from '../utils/sync-server.js';
|
||||||
|
import type { Storage } from './storage.js';
|
||||||
|
|
||||||
|
import { EventEmitter } from '../utils/event-emitter.js'
|
||||||
|
import { decodeExtendedJsonObject } from '../utils/ext-json.js';
|
||||||
|
|
||||||
|
export type InvitationEventMap = {
|
||||||
|
'invitation-updated': XOInvitation;
|
||||||
|
'invitation-status-changed': string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type InvitationDependencies = {
|
||||||
|
syncServer: SyncServer;
|
||||||
|
storage: Storage;
|
||||||
|
engine: Engine;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Invitation extends EventEmitter<InvitationEventMap> {
|
||||||
|
/**
|
||||||
|
* Create an invitation and start the SSE Session required for it.
|
||||||
|
*/
|
||||||
|
static async create(invitation: XOInvitation | string, dependencies: InvitationDependencies): Promise<Invitation> {
|
||||||
|
// If the invitation is a string, its probably an invitation identifier.
|
||||||
|
// We will try to find the data then just call the create method again, but this time with the data.
|
||||||
|
if(typeof invitation === 'string') {
|
||||||
|
// Try to get the invitation from the storage
|
||||||
|
const invitationFromStorage = await dependencies.storage.get(invitation);
|
||||||
|
if (invitationFromStorage) {
|
||||||
|
console.log(`Invitation found in storage: ${invitation}`);
|
||||||
|
return this.create(invitationFromStorage, dependencies);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to get the invitation from the sync server
|
||||||
|
const invitationFromSyncServer = await dependencies.syncServer.getInvitation(invitation);
|
||||||
|
if (invitationFromSyncServer && invitationFromSyncServer.invitationIdentifier === invitation) {
|
||||||
|
console.log(`Invitation found in sync server: ${invitation}`);
|
||||||
|
return this.create(invitationFromSyncServer, dependencies);
|
||||||
|
}
|
||||||
|
|
||||||
|
// We cant find it. Throw an error.
|
||||||
|
throw new Error(`Invitation not found in local or remote storage: ${invitation}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const template = await dependencies.engine.getTemplate(invitation.templateIdentifier);
|
||||||
|
|
||||||
|
if (!template) {
|
||||||
|
throw new Error(`Template not found: ${invitation.templateIdentifier}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create the invitation
|
||||||
|
const invitationInstance = new Invitation(invitation, dependencies);
|
||||||
|
|
||||||
|
// Start the invitation and its tracking
|
||||||
|
await invitationInstance.start();
|
||||||
|
|
||||||
|
return invitationInstance;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The invitation data.
|
||||||
|
*/
|
||||||
|
public data: XOInvitation = {
|
||||||
|
invitationIdentifier: '',
|
||||||
|
commits: [],
|
||||||
|
createdAtTimestamp: 0,
|
||||||
|
templateIdentifier: '',
|
||||||
|
actionIdentifier: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The sync server instance.
|
||||||
|
*/
|
||||||
|
private syncServer: SyncServer;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The engine instance.
|
||||||
|
*/
|
||||||
|
private engine: Engine;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The storage instance.
|
||||||
|
* TODO: This should be a composite with the sync server (probably. We currently double handle this work, which is stupid)
|
||||||
|
*/
|
||||||
|
private storage: Storage;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True after we have successfully called sign() on this invitation (session-only, not persisted).
|
||||||
|
*/
|
||||||
|
private _weHaveSigned = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True after we have successfully called broadcast() on this invitation (session-only, not persisted).
|
||||||
|
*/
|
||||||
|
private _broadcasted = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The status of the invitation (last emitted word: pending, actionable, signed, ready, complete, expired, unknown).
|
||||||
|
*/
|
||||||
|
public status: string = 'unknown';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create an invitation and start the SSE Session required for it.
|
||||||
|
*/
|
||||||
|
constructor(
|
||||||
|
invitation: XOInvitation,
|
||||||
|
dependencies: InvitationDependencies
|
||||||
|
) {
|
||||||
|
super();
|
||||||
|
|
||||||
|
this.data = invitation;
|
||||||
|
this.engine = dependencies.engine;
|
||||||
|
this.syncServer = dependencies.syncServer;
|
||||||
|
this.storage = dependencies.storage;
|
||||||
|
|
||||||
|
// I cannot express this enough, but the event handler does not need a clean up.
|
||||||
|
// There is this beautiful thing called a "garbage collector". Once this class is removed from scope (removed from the invitations array) all the references
|
||||||
|
// will be removed, including the SSE Session (and therefore this handler).
|
||||||
|
this.syncServer.on('message', this.handleSSEMessage.bind(this));
|
||||||
|
}
|
||||||
|
|
||||||
|
async start(): Promise<void> {
|
||||||
|
// Connect to the sync server and get the invitation (in parallel)
|
||||||
|
const [_, invitation] = await Promise.all([
|
||||||
|
this.syncServer.connect(),
|
||||||
|
this.syncServer.getInvitation(this.data.invitationIdentifier),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// There is a chance we get SSE messages before the invitation is returned, so we want to combine any commits
|
||||||
|
const sseCommits = this.data.commits;
|
||||||
|
|
||||||
|
// Merge the commits
|
||||||
|
const combinedCommits = this.mergeCommits(sseCommits, invitation?.commits ?? []);
|
||||||
|
|
||||||
|
// Set the invitation data with the combined commits
|
||||||
|
this.data = { ...this.data, ...invitation, commits: combinedCommits };
|
||||||
|
|
||||||
|
// Store the invitation in the storage
|
||||||
|
await this.storage.set(this.data.invitationIdentifier, this.data);
|
||||||
|
|
||||||
|
// Publish the invitation to the sync server
|
||||||
|
this.syncServer.publishInvitation(this.data);
|
||||||
|
|
||||||
|
// Compute and emit initial status
|
||||||
|
await this.updateStatus();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle an SSE message.
|
||||||
|
*
|
||||||
|
* TODO: Invitation should sync up the initial data (top level) then everything after that should be the commits. This makes it easier to merge as we go instead of just having to overwrite the entire invitation.
|
||||||
|
* Why this level of thought is required is beyond me. We should be given a `mergeCommits` method or "something" that lets us take whole invitation and merge commits into it.
|
||||||
|
* NOTE: signInvitation does merge the commits... But we want to be able to add commits in *before* signing the invitation. So, we are just going to receive a single commit at a time, then just invitation.commits.push(commit); to get around this.
|
||||||
|
* I hope we dont end up with duplicate commits :/... We also dont have a way to list invitiations, which is an... interesting choice.
|
||||||
|
*/
|
||||||
|
private handleSSEMessage(event: SSEvent): void {
|
||||||
|
const data = JSON.parse(event.data) as { topic?: string; data?: unknown };
|
||||||
|
if (data.topic === 'invitation-updated') {
|
||||||
|
const invitation = decodeExtendedJsonObject(data.data) as XOInvitation;
|
||||||
|
|
||||||
|
if (invitation.invitationIdentifier !== this.data.invitationIdentifier) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter out commits that already exist (probably a faster way to do this. This is n^2)
|
||||||
|
const newCommits = this.mergeCommits(this.data.commits, invitation.commits);
|
||||||
|
|
||||||
|
// Set the new commits
|
||||||
|
this.data = { ...this.data, commits: newCommits };
|
||||||
|
|
||||||
|
// Calculate the new status of the invitation (fire-and-forget; handler is sync)
|
||||||
|
this.updateStatus().catch(() => {});
|
||||||
|
|
||||||
|
// Emit the updated event
|
||||||
|
this.emit('invitation-updated', this.data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Merge the commits
|
||||||
|
* @param initial - The initial commits
|
||||||
|
* @param additional - The additional commits
|
||||||
|
* @returns The merged commits
|
||||||
|
*/
|
||||||
|
private mergeCommits(initial: XOInvitationCommit[], additional: XOInvitationCommit[]): XOInvitationCommit[] {
|
||||||
|
// Create a map of the initial commits
|
||||||
|
const initialMap = new Map<string, XOInvitationCommit>();
|
||||||
|
for(const commit of initial) {
|
||||||
|
initialMap.set(commit.commitIdentifier, commit);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Merge the additional commits
|
||||||
|
// TODO: They are immutable? So, it should be fine to "ovewrite" existing commits as it should be the same data, right?
|
||||||
|
for(const commit of additional) {
|
||||||
|
initialMap.set(commit.commitIdentifier, commit);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return the merged commits
|
||||||
|
return Array.from(initialMap.values());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compute the invitation status as a single word: expired | complete | ready | signed | actionable | unknown.
|
||||||
|
*/
|
||||||
|
private async computeStatus(): Promise<string> {
|
||||||
|
try {
|
||||||
|
return await this.computeStatusInternal();
|
||||||
|
} catch (err) {
|
||||||
|
return `error (${err instanceof Error ? err.message : String(err)})`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Internal status computation: returns a single word.
|
||||||
|
* NOTE: This could be a Enum-like object as well. May be a nice improvement. - DO NOT USE TS ENUM, THEY ARENT NATIVELY SUPPORTED IN NODE.JS
|
||||||
|
* - expired: any commit has expired
|
||||||
|
* - complete: we have broadcast this invitation
|
||||||
|
* - ready: no missing requirements and we have signed (ready to broadcast)
|
||||||
|
* - signed: we have signed but there are still missing parts (waiting for others)
|
||||||
|
* - actionable: you can provide data (missing requirements and/or you can sign)
|
||||||
|
* - unknown: template/action not found or error
|
||||||
|
*/
|
||||||
|
private async computeStatusInternal(): Promise<string> {
|
||||||
|
if (hasInvitationExpired(this.data)) {
|
||||||
|
return 'expired';
|
||||||
|
}
|
||||||
|
if (this._broadcasted) {
|
||||||
|
return 'complete';
|
||||||
|
}
|
||||||
|
|
||||||
|
let missingReqs;
|
||||||
|
try {
|
||||||
|
missingReqs = await this.engine.listMissingRequirements(this.data);
|
||||||
|
} catch {
|
||||||
|
return 'unknown';
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasMissing =
|
||||||
|
(missingReqs.variables?.length ?? 0) > 0 ||
|
||||||
|
(missingReqs.inputs?.length ?? 0) > 0 ||
|
||||||
|
(missingReqs.outputs?.length ?? 0) > 0 ||
|
||||||
|
(missingReqs.roles !== undefined && Object.keys(missingReqs.roles).length > 0);
|
||||||
|
|
||||||
|
if (!hasMissing && this._weHaveSigned) {
|
||||||
|
return 'ready';
|
||||||
|
}
|
||||||
|
if (hasMissing && this._weHaveSigned) {
|
||||||
|
return 'signed';
|
||||||
|
}
|
||||||
|
return 'actionable';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update the status of the invitation and emit the new single-word status.
|
||||||
|
*/
|
||||||
|
private async updateStatus(): Promise<void> {
|
||||||
|
const status = await this.computeStatus();
|
||||||
|
this.status = status;
|
||||||
|
this.emit('invitation-status-changed', status);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Accept the invitation
|
||||||
|
*/
|
||||||
|
async accept(acceptParams?: AcceptInvitationParameters): Promise<void> {
|
||||||
|
// Accept the invitation
|
||||||
|
this.data = await this.engine.acceptInvitation(this.data, acceptParams);
|
||||||
|
|
||||||
|
// Sync the invitation to the sync server
|
||||||
|
this.syncServer.publishInvitation(this.data);
|
||||||
|
|
||||||
|
// Update the status of the invitation
|
||||||
|
await this.updateStatus();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sign the invitation
|
||||||
|
*/
|
||||||
|
async sign(): Promise<void> {
|
||||||
|
// Sign the invitation
|
||||||
|
const signedInvitation = await this.engine.signInvitation(this.data);
|
||||||
|
|
||||||
|
// Publish the signed invitation to the sync server
|
||||||
|
this.syncServer.publishInvitation(signedInvitation);
|
||||||
|
|
||||||
|
// Store the signed invitation in the storage
|
||||||
|
await this.storage.set(this.data.invitationIdentifier, signedInvitation);
|
||||||
|
|
||||||
|
this.data = signedInvitation;
|
||||||
|
this._weHaveSigned = true;
|
||||||
|
|
||||||
|
// Update the status of the invitation
|
||||||
|
await this.updateStatus();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Broadcast the invitation
|
||||||
|
*/
|
||||||
|
async broadcast(): Promise<void> {
|
||||||
|
// Broadcast the transaction (executeAction returns transaction hash when broadcastTransaction: true)
|
||||||
|
await this.engine.executeAction(this.data, {
|
||||||
|
broadcastTransaction: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
this._broadcasted = true;
|
||||||
|
|
||||||
|
// Update the status of the invitation
|
||||||
|
await this.updateStatus();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Append Operations
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Append a commit to the invitation
|
||||||
|
*/
|
||||||
|
async append(data: AppendInvitationParameters): Promise<void> {
|
||||||
|
// Append the commit to the invitation
|
||||||
|
this.data = await this.engine.appendInvitation(this.data, data);
|
||||||
|
|
||||||
|
// Sync the invitation to the sync server
|
||||||
|
await this.syncServer.publishInvitation(this.data);
|
||||||
|
|
||||||
|
// Store the invitation in the storage
|
||||||
|
await this.storage.set(this.data.invitationIdentifier, this.data);
|
||||||
|
|
||||||
|
// Update the status of the invitation
|
||||||
|
await this.updateStatus();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add inputs to the invitation
|
||||||
|
*/
|
||||||
|
async addInputs(inputs: XOInvitationInput[]): Promise<void> {
|
||||||
|
// Append the inputs to the invitation
|
||||||
|
await this.append({ inputs });
|
||||||
|
|
||||||
|
// Sync the invitation to the sync server
|
||||||
|
await this.syncServer.publishInvitation(this.data);
|
||||||
|
}
|
||||||
|
|
||||||
|
async addOutputs(outputs: XOInvitationOutput[]): Promise<void> {
|
||||||
|
// Add the outputs to the invitation
|
||||||
|
await this.append({ outputs });
|
||||||
|
|
||||||
|
// Sync the invitation to the sync server
|
||||||
|
await this.syncServer.publishInvitation(this.data);
|
||||||
|
}
|
||||||
|
|
||||||
|
async addVariables(variables: XOInvitationVariable[]): Promise<void> {
|
||||||
|
// Add the variables to the invitation
|
||||||
|
await this.append({ variables });
|
||||||
|
|
||||||
|
// Sync the invitation to the sync server
|
||||||
|
await this.syncServer.publishInvitation(this.data);
|
||||||
|
}
|
||||||
|
|
||||||
|
async findSuitableResources(options: FindSuitableResourcesParameters): Promise<UnspentOutputData[]> {
|
||||||
|
// Find the suitable resources
|
||||||
|
const { unspentOutputs } = await this.engine.findSuitableResources(this.data, options);
|
||||||
|
|
||||||
|
// Update the status of the invitation
|
||||||
|
await this.updateStatus();
|
||||||
|
|
||||||
|
// Return the suitable resources
|
||||||
|
return unspentOutputs;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Getters and Queries
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the missing requirements for the invitation
|
||||||
|
*/
|
||||||
|
async getMissingRequirements() {
|
||||||
|
return this.engine.listMissingRequirements(this.data);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the requirements for the invitation
|
||||||
|
*/
|
||||||
|
async getRequirements() {
|
||||||
|
return this.engine.listRequirements(this.data);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the available roles for the invitation
|
||||||
|
*/
|
||||||
|
async getAvailableRoles() {
|
||||||
|
return this.engine.listAvailableRoles(this.data);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the starting actions for the invitation
|
||||||
|
*/
|
||||||
|
async getStartingActions() {
|
||||||
|
return this.engine.listStartingActions(this.data.templateIdentifier);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the locking bytecode for the invitation
|
||||||
|
*/
|
||||||
|
async getLockingBytecode(outputIdentifier: string, roleIdentifier?: string): Promise<string> {
|
||||||
|
return this.engine.generateLockingBytecode(this.data.templateIdentifier, outputIdentifier, roleIdentifier);
|
||||||
|
}
|
||||||
|
}
|
||||||
106
src/services/storage.ts
Normal file
106
src/services/storage.ts
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
import Database from 'better-sqlite3';
|
||||||
|
import { decodeExtendedJson, encodeExtendedJson } from '../utils/ext-json.js';
|
||||||
|
|
||||||
|
export class Storage {
|
||||||
|
static async create(dbPath: string): Promise<Storage> {
|
||||||
|
// Create the database
|
||||||
|
const database = new Database(dbPath);
|
||||||
|
|
||||||
|
// Create the storage table if it doesn't exist
|
||||||
|
database.prepare('CREATE TABLE IF NOT EXISTS storage (key TEXT PRIMARY KEY, value TEXT)').run();
|
||||||
|
|
||||||
|
return new Storage(database, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly database: Database.Database,
|
||||||
|
private readonly basePath: string,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the full key with basePath prefix
|
||||||
|
*/
|
||||||
|
private getFullKey(key: string): string {
|
||||||
|
return this.basePath ? `${this.basePath}.${key}` : key;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Strip the basePath prefix from a key
|
||||||
|
*/
|
||||||
|
private stripBasePath(fullKey: string): string {
|
||||||
|
if (!this.basePath) return fullKey;
|
||||||
|
const prefix = `${this.basePath}.`;
|
||||||
|
return fullKey.startsWith(prefix) ? fullKey.slice(prefix.length) : fullKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
async set(key: string, value: any): Promise<void> {
|
||||||
|
// Encode the extended json object
|
||||||
|
const encodedValue = encodeExtendedJson(value);
|
||||||
|
|
||||||
|
// Insert or replace the value into the database with full key (including basePath)
|
||||||
|
const fullKey = this.getFullKey(key);
|
||||||
|
this.database.prepare('INSERT OR REPLACE INTO storage (key, value) VALUES (?, ?)').run(fullKey, encodedValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all key-value pairs from this storage namespace (shallow only - no nested children)
|
||||||
|
*/
|
||||||
|
async all(): Promise<{ key: string; value: any }[]> {
|
||||||
|
let query = 'SELECT key, value FROM storage';
|
||||||
|
const params: any[] = [];
|
||||||
|
|
||||||
|
if (this.basePath) {
|
||||||
|
// Filter by basePath prefix
|
||||||
|
query += ' WHERE key LIKE ?';
|
||||||
|
params.push(`${this.basePath}.%`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get all the rows from the database
|
||||||
|
const rows = await this.database.prepare(query).all(...params) as { key: string; value: any }[];
|
||||||
|
|
||||||
|
// Filter for shallow results (only direct children)
|
||||||
|
const filteredRows = rows.filter(row => {
|
||||||
|
const strippedKey = this.stripBasePath(row.key);
|
||||||
|
// Only include keys that don't have additional dots (no deeper nesting)
|
||||||
|
return !strippedKey.includes('.');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Decode the extended json objects and strip basePath from keys
|
||||||
|
return filteredRows.map(row => ({
|
||||||
|
key: this.stripBasePath(row.key),
|
||||||
|
value: decodeExtendedJson(row.value)
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
async get(key: string): Promise<any> {
|
||||||
|
// Get the row from the database using full key
|
||||||
|
const fullKey = this.getFullKey(key);
|
||||||
|
const row = await this.database.prepare('SELECT value FROM storage WHERE key = ?').get(fullKey) as { value: any };
|
||||||
|
|
||||||
|
// Return null if not found
|
||||||
|
if (!row) return null;
|
||||||
|
|
||||||
|
// Decode the extended json object
|
||||||
|
return decodeExtendedJson(row.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
async remove(key: string): Promise<void> {
|
||||||
|
// Delete using full key
|
||||||
|
const fullKey = this.getFullKey(key);
|
||||||
|
this.database.prepare('DELETE FROM storage WHERE key = ?').run(fullKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
async clear(): Promise<void> {
|
||||||
|
if (this.basePath) {
|
||||||
|
// Clear only items under this namespace
|
||||||
|
this.database.prepare('DELETE FROM storage WHERE key LIKE ?').run(`${this.basePath}.%`);
|
||||||
|
} else {
|
||||||
|
// Clear everything
|
||||||
|
this.database.prepare('DELETE FROM storage').run();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
child(key: string): Storage {
|
||||||
|
return new Storage(this.database, this.getFullKey(key));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,162 +0,0 @@
|
|||||||
/**
|
|
||||||
* Sync Server Client - HTTP client for sync-server communication.
|
|
||||||
*
|
|
||||||
* Handles:
|
|
||||||
* - Creating/updating invitations on the server
|
|
||||||
* - Fetching invitations by ID
|
|
||||||
* - ExtJSON encoding/decoding for data transfer
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type { XOInvitation } from '@xo-cash/types';
|
|
||||||
import { encodeExtendedJson, decodeExtendedJson } from '../utils/ext-json.js';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Response from the sync server.
|
|
||||||
*/
|
|
||||||
export interface SyncServerResponse<T> {
|
|
||||||
success: boolean;
|
|
||||||
data?: T;
|
|
||||||
error?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* HTTP client for sync-server communication.
|
|
||||||
*/
|
|
||||||
export class SyncClient {
|
|
||||||
/** Base URL of the sync server */
|
|
||||||
private baseUrl: string;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates a new sync client.
|
|
||||||
* @param baseUrl - Base URL of the sync server (e.g., http://localhost:3000)
|
|
||||||
*/
|
|
||||||
constructor(baseUrl: string) {
|
|
||||||
// Remove trailing slash if present
|
|
||||||
this.baseUrl = baseUrl.replace(/\/$/, '');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Makes an HTTP request to the sync server.
|
|
||||||
* @param method - HTTP method
|
|
||||||
* @param path - Request path
|
|
||||||
* @param body - Optional request body
|
|
||||||
* @returns Response data
|
|
||||||
*/
|
|
||||||
private async request<T>(
|
|
||||||
method: 'GET' | 'POST' | 'PUT' | 'DELETE',
|
|
||||||
path: string,
|
|
||||||
body?: unknown,
|
|
||||||
): Promise<T> {
|
|
||||||
const url = `${this.baseUrl}${path}`;
|
|
||||||
|
|
||||||
const headers: Record<string, string> = {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'Accept': 'application/json',
|
|
||||||
};
|
|
||||||
|
|
||||||
const options: RequestInit = {
|
|
||||||
method,
|
|
||||||
headers,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (body !== undefined) {
|
|
||||||
// Encode body using ExtJSON for proper BigInt and Uint8Array serialization
|
|
||||||
options.body = encodeExtendedJson(body);
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await fetch(url, options);
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const errorText = await response.text();
|
|
||||||
throw new Error(`HTTP ${response.status}: ${errorText}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const responseText = await response.text();
|
|
||||||
|
|
||||||
// Return empty object if no response body
|
|
||||||
if (!responseText) {
|
|
||||||
return {} as T;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Decode response using ExtJSON
|
|
||||||
return decodeExtendedJson(responseText) as T;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Invitation Operations
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Posts an invitation to the sync server (create or update).
|
|
||||||
* @param invitation - Invitation to post
|
|
||||||
* @returns The stored invitation
|
|
||||||
*/
|
|
||||||
async postInvitation(invitation: XOInvitation): Promise<XOInvitation> {
|
|
||||||
return this.request<XOInvitation>('POST', '/invitations', invitation);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Gets an invitation from the sync server.
|
|
||||||
* @param invitationIdentifier - Invitation ID to fetch
|
|
||||||
* @returns The invitation or undefined if not found
|
|
||||||
*/
|
|
||||||
async getInvitation(invitationIdentifier: string): Promise<XOInvitation | undefined> {
|
|
||||||
try {
|
|
||||||
// Use query parameter for GET request (can't have body)
|
|
||||||
const response = await this.request<XOInvitation>(
|
|
||||||
'GET',
|
|
||||||
`/invitations?invitationIdentifier=${encodeURIComponent(invitationIdentifier)}`
|
|
||||||
);
|
|
||||||
return response;
|
|
||||||
} catch (error) {
|
|
||||||
// Return undefined if not found (404)
|
|
||||||
if (error instanceof Error && error.message.includes('404')) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Updates an invitation on the sync server.
|
|
||||||
* @param invitation - Updated invitation
|
|
||||||
* @returns The updated invitation
|
|
||||||
*/
|
|
||||||
async updateInvitation(invitation: XOInvitation): Promise<XOInvitation> {
|
|
||||||
// Uses the same POST endpoint which handles both create and update
|
|
||||||
return this.postInvitation(invitation);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Health Check
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Checks if the sync server is healthy.
|
|
||||||
* @returns True if server is healthy
|
|
||||||
*/
|
|
||||||
async isHealthy(): Promise<boolean> {
|
|
||||||
try {
|
|
||||||
const response = await this.request<{ status: string }>('GET', '/health');
|
|
||||||
return response.status === 'ok';
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Gets the base URL of the sync server.
|
|
||||||
*/
|
|
||||||
getBaseUrl(): string {
|
|
||||||
return this.baseUrl;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Gets the SSE endpoint URL for an invitation.
|
|
||||||
* @param invitationId - Invitation ID to subscribe to
|
|
||||||
* @returns SSE endpoint URL
|
|
||||||
*/
|
|
||||||
getSSEUrl(invitationIdentifier: string): string {
|
|
||||||
return `${this.baseUrl}/invitations?invitationIdentifier=${encodeURIComponent(invitationIdentifier)}`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -7,24 +7,24 @@ import React from 'react';
|
|||||||
import { Box, Text, useApp, useInput } from 'ink';
|
import { Box, Text, useApp, useInput } from 'ink';
|
||||||
import { NavigationProvider, useNavigation } from './hooks/useNavigation.js';
|
import { NavigationProvider, useNavigation } from './hooks/useNavigation.js';
|
||||||
import { AppProvider, useAppContext, useDialog, useStatus } from './hooks/useAppContext.js';
|
import { AppProvider, useAppContext, useDialog, useStatus } from './hooks/useAppContext.js';
|
||||||
import type { WalletController } from '../controllers/wallet-controller.js';
|
import type { AppConfig } from '../app.js';
|
||||||
import type { InvitationController } from '../controllers/invitation-controller.js';
|
|
||||||
import { colors, logoSmall } from './theme.js';
|
import { colors, logoSmall } from './theme.js';
|
||||||
|
|
||||||
// Screen imports (will be created)
|
// Screen imports
|
||||||
import { SeedInputScreen } from './screens/SeedInput.js';
|
import { SeedInputScreen } from './screens/SeedInput.js';
|
||||||
import { WalletStateScreen } from './screens/WalletState.js';
|
import { WalletStateScreen } from './screens/WalletState.js';
|
||||||
import { TemplateListScreen } from './screens/TemplateList.js';
|
import { TemplateListScreen } from './screens/TemplateList.js';
|
||||||
import { ActionWizardScreen } from './screens/ActionWizard.js';
|
import { ActionWizardScreen } from './screens/action-wizard/ActionWizardScreen.js';
|
||||||
import { InvitationScreen } from './screens/Invitation.js';
|
import { InvitationScreen } from './screens/Invitation.js';
|
||||||
import { TransactionScreen } from './screens/Transaction.js';
|
import { TransactionScreen } from './screens/Transaction.js';
|
||||||
|
|
||||||
|
import { MessageDialog } from './components/Dialog.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Props for the App component.
|
* Props for the App component.
|
||||||
*/
|
*/
|
||||||
interface AppProps {
|
interface AppProps {
|
||||||
walletController: WalletController;
|
config: AppConfig;
|
||||||
invitationController: InvitationController;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -109,28 +109,14 @@ function DialogOverlay(): React.ReactElement | null {
|
|||||||
width="100%"
|
width="100%"
|
||||||
height="100%"
|
height="100%"
|
||||||
>
|
>
|
||||||
<Box
|
<MessageDialog
|
||||||
flexDirection="column"
|
title={dialog.type === 'error' ? '✗ Error' :
|
||||||
borderStyle="double"
|
dialog.type === 'confirm' ? '? Confirm' :
|
||||||
borderColor={borderColor}
|
'ℹ Info'}
|
||||||
paddingX={2}
|
message={dialog.message}
|
||||||
paddingY={1}
|
onClose={dialog.onCancel ?? (() => {})}
|
||||||
width={60}
|
type={dialog.type as 'error' | 'info' | 'success'}
|
||||||
>
|
/>
|
||||||
<Text color={borderColor} bold>
|
|
||||||
{dialog.type === 'error' ? '✗ Error' :
|
|
||||||
dialog.type === 'confirm' ? '? Confirm' :
|
|
||||||
'ℹ Info'}
|
|
||||||
</Text>
|
|
||||||
<Box marginY={1}>
|
|
||||||
<Text wrap="wrap">{dialog.message}</Text>
|
|
||||||
</Box>
|
|
||||||
<Text color={colors.textMuted}>
|
|
||||||
{dialog.type === 'confirm'
|
|
||||||
? 'Press Y to confirm, N or ESC to cancel'
|
|
||||||
: 'Press Enter or ESC to close'}
|
|
||||||
</Text>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -141,6 +127,7 @@ function DialogOverlay(): React.ReactElement | null {
|
|||||||
function MainContent(): React.ReactElement {
|
function MainContent(): React.ReactElement {
|
||||||
const { exit } = useApp();
|
const { exit } = useApp();
|
||||||
const { goBack, canGoBack } = useNavigation();
|
const { goBack, canGoBack } = useNavigation();
|
||||||
|
const { screen } = useNavigation();
|
||||||
const { dialog } = useDialog();
|
const { dialog } = useDialog();
|
||||||
const appContext = useAppContext();
|
const appContext = useAppContext();
|
||||||
|
|
||||||
@@ -158,6 +145,14 @@ function MainContent(): React.ReactElement {
|
|||||||
// Go back on Escape
|
// Go back on Escape
|
||||||
if (key.escape && canGoBack) {
|
if (key.escape && canGoBack) {
|
||||||
goBack();
|
goBack();
|
||||||
|
|
||||||
|
// If we went back to the seed input screen, remove the current engine
|
||||||
|
// TODO: This was to support going back to seed input then re-opening your seed, but there is a bug in the engine which prevents it from closing the current
|
||||||
|
// storage instance, giving us an error about the database already being opened.
|
||||||
|
if (screen === 'seed-input') {
|
||||||
|
appContext.appService?.engine.stop();
|
||||||
|
appContext.appService = null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -181,19 +176,17 @@ function MainContent(): React.ReactElement {
|
|||||||
* Main App component.
|
* Main App component.
|
||||||
* Sets up providers and renders the main content.
|
* Sets up providers and renders the main content.
|
||||||
*/
|
*/
|
||||||
export function App({ walletController, invitationController }: AppProps): React.ReactElement {
|
export function App({ config }: AppProps): React.ReactElement {
|
||||||
const { exit } = useApp();
|
const { exit } = useApp();
|
||||||
|
|
||||||
const handleExit = () => {
|
const handleExit = () => {
|
||||||
// Cleanup controllers if needed
|
// Cleanup will be handled by React when components unmount
|
||||||
walletController.stop();
|
|
||||||
exit();
|
exit();
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AppProvider
|
<AppProvider
|
||||||
walletController={walletController}
|
config={config}
|
||||||
invitationController={invitationController}
|
|
||||||
onExit={handleExit}
|
onExit={handleExit}
|
||||||
>
|
>
|
||||||
<NavigationProvider initialScreen="seed-input">
|
<NavigationProvider initialScreen="seed-input">
|
||||||
|
|||||||
@@ -2,8 +2,8 @@
|
|||||||
* Dialog components for modals, confirmations, and input dialogs.
|
* Dialog components for modals, confirmations, and input dialogs.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useState } from 'react';
|
import React, { useRef, useState } from 'react';
|
||||||
import { Box, Text, useInput } from 'ink';
|
import { Box, Text, useInput, measureElement } from 'ink';
|
||||||
import TextInput from 'ink-text-input';
|
import TextInput from 'ink-text-input';
|
||||||
import { colors } from '../theme.js';
|
import { colors } from '../theme.js';
|
||||||
|
|
||||||
@@ -19,30 +19,64 @@ interface DialogWrapperProps {
|
|||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
/** Dialog width */
|
/** Dialog width */
|
||||||
width?: number;
|
width?: number;
|
||||||
|
/** Dialog Background Color */
|
||||||
|
backgroundColor?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Base dialog wrapper component.
|
|
||||||
*/
|
|
||||||
function DialogWrapper({
|
function DialogWrapper({
|
||||||
title,
|
title,
|
||||||
borderColor = colors.primary,
|
borderColor = colors.primary,
|
||||||
children,
|
children,
|
||||||
width = 60,
|
width = 60,
|
||||||
}: DialogWrapperProps): React.ReactElement {
|
}: DialogWrapperProps): React.ReactElement {
|
||||||
|
const ref = useRef<any>(null);
|
||||||
|
const [height, setHeight] = useState<number | null>(null);
|
||||||
|
|
||||||
|
// measure after render
|
||||||
|
React.useLayoutEffect(() => {
|
||||||
|
if (ref.current) {
|
||||||
|
const { height } = measureElement(ref.current);
|
||||||
|
setHeight(height);
|
||||||
|
}
|
||||||
|
}, [children, title, width]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box flexDirection="column">
|
||||||
flexDirection="column"
|
|
||||||
borderStyle="double"
|
{/* Opaque backing layer */}
|
||||||
borderColor={borderColor}
|
{height !== null && (
|
||||||
paddingX={2}
|
<Box
|
||||||
paddingY={1}
|
position="absolute"
|
||||||
width={width}
|
flexDirection="column"
|
||||||
>
|
width={width}
|
||||||
<Text color={borderColor} bold>{title}</Text>
|
height={height}
|
||||||
<Box marginY={1} flexDirection="column">
|
>
|
||||||
{children}
|
{Array.from({ length: height }).map((_, i) => (
|
||||||
|
<Text key={i}>{' '.repeat(width)}</Text>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Actual dialog */}
|
||||||
|
<Box
|
||||||
|
ref={ref}
|
||||||
|
flexDirection="column"
|
||||||
|
borderStyle="double"
|
||||||
|
borderColor={borderColor}
|
||||||
|
paddingX={2}
|
||||||
|
paddingY={1}
|
||||||
|
width={width}
|
||||||
|
>
|
||||||
|
<Text color={borderColor} bold>
|
||||||
|
{title}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Box marginY={1} flexDirection="column">
|
||||||
|
{children}
|
||||||
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,462 @@
|
|||||||
/**
|
/**
|
||||||
* Selectable list component with keyboard navigation.
|
* List components with keyboard navigation.
|
||||||
|
*
|
||||||
|
* Provides:
|
||||||
|
* - ScrollableList: Full-featured list with grouping, filtering, and custom rendering
|
||||||
|
* - List: Basic selectable list (legacy, kept for backward compatibility)
|
||||||
|
* - SimpleList: Non-selectable list for display only
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React from 'react';
|
import React, { useState, useMemo, useCallback } from 'react';
|
||||||
import { Box, Text, useInput } from 'ink';
|
import { Box, Text, useInput } from 'ink';
|
||||||
|
import TextInput from 'ink-text-input';
|
||||||
import { colors } from '../theme.js';
|
import { colors } from '../theme.js';
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Types
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* List item type.
|
* Base list item data interface.
|
||||||
|
* Used by ScrollableList for item data.
|
||||||
|
*/
|
||||||
|
export interface ListItemData<T = unknown> {
|
||||||
|
/** Unique key for the item */
|
||||||
|
key: string;
|
||||||
|
/** Display label */
|
||||||
|
label: string;
|
||||||
|
/** Optional secondary text/description */
|
||||||
|
description?: string;
|
||||||
|
/** Optional value associated with item */
|
||||||
|
value?: T;
|
||||||
|
/** Whether item is disabled (can't be activated) */
|
||||||
|
disabled?: boolean;
|
||||||
|
/** Whether item should be hidden (not rendered, skipped in navigation) */
|
||||||
|
hidden?: boolean;
|
||||||
|
/** Custom color name for the item (semantic: 'info', 'warning', 'success', 'error', 'muted') */
|
||||||
|
color?: string;
|
||||||
|
/** Group identifier for grouping items */
|
||||||
|
group?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Group definition for organizing list items.
|
||||||
|
*/
|
||||||
|
export interface ListGroup {
|
||||||
|
/** Unique group identifier */
|
||||||
|
id: string;
|
||||||
|
/** Optional header text to display above group */
|
||||||
|
label?: string;
|
||||||
|
/** Whether to show a separator after this group */
|
||||||
|
separator?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Props for ScrollableList component.
|
||||||
|
*/
|
||||||
|
export interface ScrollableListProps<T> {
|
||||||
|
/** Array of list items */
|
||||||
|
items: ListItemData<T>[];
|
||||||
|
/** Currently selected index */
|
||||||
|
selectedIndex: number;
|
||||||
|
/** Handler called when selection changes */
|
||||||
|
onSelect: (index: number) => void;
|
||||||
|
/** Handler called when item is activated (Enter key) */
|
||||||
|
onActivate?: (item: ListItemData<T>, index: number) => void;
|
||||||
|
/** Whether the list is focused for keyboard input */
|
||||||
|
focus?: boolean;
|
||||||
|
/** Maximum number of visible items (enables scrolling). Default: 10 */
|
||||||
|
maxVisible?: number;
|
||||||
|
/** Whether to show a border around the list */
|
||||||
|
border?: boolean;
|
||||||
|
/** Optional label/title for the list */
|
||||||
|
label?: string;
|
||||||
|
/** Message to show when list is empty */
|
||||||
|
emptyMessage?: string;
|
||||||
|
/** Group definitions for organizing items */
|
||||||
|
groups?: ListGroup[];
|
||||||
|
/** Whether to enable filtering/search */
|
||||||
|
filterable?: boolean;
|
||||||
|
/** Placeholder text for filter input */
|
||||||
|
filterPlaceholder?: string;
|
||||||
|
/** Handler called when filter text changes */
|
||||||
|
onFilterChange?: (filter: string) => void;
|
||||||
|
/** Custom render function for items */
|
||||||
|
renderItem?: (item: ListItemData<T>, isSelected: boolean, isFocused: boolean) => React.ReactNode;
|
||||||
|
/** Whether to wrap around when navigating past ends */
|
||||||
|
wrapNavigation?: boolean;
|
||||||
|
/** Whether to show the scroll position indicator (e.g., "1-5 of 10"). Default: true */
|
||||||
|
showScrollIndicator?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Helper Functions
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map semantic color names to theme colors.
|
||||||
|
*/
|
||||||
|
function getColorFromName(colorName: string | undefined): string {
|
||||||
|
switch (colorName) {
|
||||||
|
case 'info':
|
||||||
|
return colors.info as string;
|
||||||
|
case 'warning':
|
||||||
|
return colors.warning as string;
|
||||||
|
case 'success':
|
||||||
|
return colors.success as string;
|
||||||
|
case 'error':
|
||||||
|
return colors.error as string;
|
||||||
|
case 'muted':
|
||||||
|
return colors.textMuted as string;
|
||||||
|
case 'accent':
|
||||||
|
return colors.accent as string;
|
||||||
|
case 'primary':
|
||||||
|
return colors.primary as string;
|
||||||
|
default:
|
||||||
|
return colors.text as string;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find the next valid (non-hidden) index in a direction.
|
||||||
|
*
|
||||||
|
* @param items - Array of items
|
||||||
|
* @param currentIndex - Current index
|
||||||
|
* @param direction - Direction to search (1 for down, -1 for up)
|
||||||
|
* @param wrap - Whether to wrap around at ends
|
||||||
|
* @returns Next valid index, or current if none found
|
||||||
|
*/
|
||||||
|
function findNextValidIndex<T>(
|
||||||
|
items: ListItemData<T>[],
|
||||||
|
currentIndex: number,
|
||||||
|
direction: 1 | -1,
|
||||||
|
wrap: boolean = false
|
||||||
|
): number {
|
||||||
|
if (items.length === 0) return 0;
|
||||||
|
|
||||||
|
// Count visible items
|
||||||
|
const visibleIndices = items
|
||||||
|
.map((item, idx) => ({ item, idx }))
|
||||||
|
.filter(({ item }) => !item.hidden)
|
||||||
|
.map(({ idx }) => idx);
|
||||||
|
|
||||||
|
if (visibleIndices.length === 0) return currentIndex;
|
||||||
|
|
||||||
|
// Find current position in visible indices
|
||||||
|
const currentVisiblePos = visibleIndices.indexOf(currentIndex);
|
||||||
|
|
||||||
|
if (currentVisiblePos === -1) {
|
||||||
|
// Current index is hidden, find nearest visible
|
||||||
|
return visibleIndices[0] ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate next position
|
||||||
|
let nextVisiblePos = currentVisiblePos + direction;
|
||||||
|
|
||||||
|
if (wrap) {
|
||||||
|
if (nextVisiblePos < 0) nextVisiblePos = visibleIndices.length - 1;
|
||||||
|
if (nextVisiblePos >= visibleIndices.length) nextVisiblePos = 0;
|
||||||
|
} else {
|
||||||
|
nextVisiblePos = Math.max(0, Math.min(visibleIndices.length - 1, nextVisiblePos));
|
||||||
|
}
|
||||||
|
|
||||||
|
return visibleIndices[nextVisiblePos] ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate scroll window for visible items.
|
||||||
|
*/
|
||||||
|
function calculateScrollWindow(
|
||||||
|
selectedIndex: number,
|
||||||
|
totalItems: number,
|
||||||
|
maxVisible: number
|
||||||
|
): { startIndex: number; endIndex: number } {
|
||||||
|
const halfWindow = Math.floor(maxVisible / 2);
|
||||||
|
let startIndex = Math.max(0, selectedIndex - halfWindow);
|
||||||
|
let endIndex = Math.min(totalItems, startIndex + maxVisible);
|
||||||
|
|
||||||
|
// Adjust start if we're near the end
|
||||||
|
if (endIndex - startIndex < maxVisible) {
|
||||||
|
startIndex = Math.max(0, endIndex - maxVisible);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { startIndex, endIndex };
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// ScrollableList Component
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Full-featured scrollable list with grouping, filtering, and custom rendering.
|
||||||
|
*/
|
||||||
|
export function ScrollableList<T>({
|
||||||
|
items,
|
||||||
|
selectedIndex,
|
||||||
|
onSelect,
|
||||||
|
onActivate,
|
||||||
|
focus = true,
|
||||||
|
maxVisible = 10,
|
||||||
|
border = false,
|
||||||
|
label,
|
||||||
|
emptyMessage = 'No items',
|
||||||
|
groups,
|
||||||
|
filterable = false,
|
||||||
|
filterPlaceholder = 'Filter...',
|
||||||
|
onFilterChange,
|
||||||
|
renderItem,
|
||||||
|
wrapNavigation = false,
|
||||||
|
showScrollIndicator = true,
|
||||||
|
}: ScrollableListProps<T>): React.ReactElement {
|
||||||
|
// Filter state
|
||||||
|
const [filterText, setFilterText] = useState('');
|
||||||
|
const [isFiltering, setIsFiltering] = useState(false);
|
||||||
|
|
||||||
|
// Filter items based on filter text
|
||||||
|
const filteredItems = useMemo(() => {
|
||||||
|
if (!filterText.trim()) return items;
|
||||||
|
const lowerFilter = filterText.toLowerCase();
|
||||||
|
return items.map(item => ({
|
||||||
|
...item,
|
||||||
|
hidden: item.hidden || !item.label.toLowerCase().includes(lowerFilter),
|
||||||
|
}));
|
||||||
|
}, [items, filterText]);
|
||||||
|
|
||||||
|
// Get visible (non-hidden) items count
|
||||||
|
const visibleCount = useMemo(() =>
|
||||||
|
filteredItems.filter(item => !item.hidden).length,
|
||||||
|
[filteredItems]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Handle keyboard navigation
|
||||||
|
useInput((input, key) => {
|
||||||
|
if (!focus) return;
|
||||||
|
|
||||||
|
// Toggle filter mode with '/'
|
||||||
|
if (filterable && input === '/' && !isFiltering) {
|
||||||
|
setIsFiltering(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exit filter mode with Escape
|
||||||
|
if (isFiltering && key.escape) {
|
||||||
|
setIsFiltering(false);
|
||||||
|
setFilterText('');
|
||||||
|
onFilterChange?.('');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Don't process navigation when filtering
|
||||||
|
if (isFiltering) return;
|
||||||
|
|
||||||
|
// Navigation
|
||||||
|
if (key.upArrow || input === 'k') {
|
||||||
|
const newIndex = findNextValidIndex(filteredItems, selectedIndex, -1, wrapNavigation);
|
||||||
|
onSelect(newIndex);
|
||||||
|
} else if (key.downArrow || input === 'j') {
|
||||||
|
const newIndex = findNextValidIndex(filteredItems, selectedIndex, 1, wrapNavigation);
|
||||||
|
onSelect(newIndex);
|
||||||
|
} else if (key.return && onActivate) {
|
||||||
|
const item = filteredItems[selectedIndex];
|
||||||
|
if (item && !item.disabled && !item.hidden) {
|
||||||
|
onActivate(item, selectedIndex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, { isActive: focus && !isFiltering });
|
||||||
|
|
||||||
|
// Handle filter text change
|
||||||
|
const handleFilterChange = useCallback((value: string) => {
|
||||||
|
setFilterText(value);
|
||||||
|
onFilterChange?.(value);
|
||||||
|
}, [onFilterChange]);
|
||||||
|
|
||||||
|
// Handle filter submit (Enter in filter mode)
|
||||||
|
const handleFilterSubmit = useCallback(() => {
|
||||||
|
setIsFiltering(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Render a single item
|
||||||
|
const renderListItem = (item: ListItemData<T>, index: number) => {
|
||||||
|
if (item.hidden) return null;
|
||||||
|
|
||||||
|
const isSelected = index === selectedIndex;
|
||||||
|
const isFocused = focus && isSelected;
|
||||||
|
|
||||||
|
// Use custom render if provided
|
||||||
|
if (renderItem) {
|
||||||
|
return (
|
||||||
|
<Box key={item.key}>
|
||||||
|
{renderItem(item, isSelected, isFocused)}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default rendering
|
||||||
|
const itemColor = isFocused
|
||||||
|
? colors.focus
|
||||||
|
: item.disabled
|
||||||
|
? colors.textMuted
|
||||||
|
: getColorFromName(item.color);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box key={item.key}>
|
||||||
|
<Text
|
||||||
|
color={itemColor as string}
|
||||||
|
bold={isSelected}
|
||||||
|
dimColor={item.disabled}
|
||||||
|
>
|
||||||
|
{isFocused ? '▸ ' : ' '}
|
||||||
|
{item.label}
|
||||||
|
</Text>
|
||||||
|
{item.description && (
|
||||||
|
<Text color={colors.textMuted} dimColor> {item.description}</Text>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get all visible (non-hidden) items with their original indices
|
||||||
|
const visibleItemsWithIndices = useMemo(() => {
|
||||||
|
return filteredItems
|
||||||
|
.map((item, idx) => ({ item, idx }))
|
||||||
|
.filter(({ item }) => !item.hidden);
|
||||||
|
}, [filteredItems]);
|
||||||
|
|
||||||
|
// Calculate scroll window based on visible items only
|
||||||
|
const scrollWindow = useMemo(() => {
|
||||||
|
// Find position of selected index in visible items
|
||||||
|
const selectedVisiblePos = visibleItemsWithIndices.findIndex(({ idx }) => idx === selectedIndex);
|
||||||
|
const effectivePos = selectedVisiblePos >= 0 ? selectedVisiblePos : 0;
|
||||||
|
|
||||||
|
const halfWindow = Math.floor(maxVisible / 2);
|
||||||
|
let start = Math.max(0, effectivePos - halfWindow);
|
||||||
|
let end = Math.min(visibleItemsWithIndices.length, start + maxVisible);
|
||||||
|
|
||||||
|
// Adjust start if we're near the end
|
||||||
|
if (end - start < maxVisible) {
|
||||||
|
start = Math.max(0, end - maxVisible);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { start, end };
|
||||||
|
}, [visibleItemsWithIndices, selectedIndex, maxVisible]);
|
||||||
|
|
||||||
|
// Get the slice of visible items to display
|
||||||
|
const displayItems = useMemo(() => {
|
||||||
|
return visibleItemsWithIndices.slice(scrollWindow.start, scrollWindow.end);
|
||||||
|
}, [visibleItemsWithIndices, scrollWindow]);
|
||||||
|
|
||||||
|
// Render content based on grouping
|
||||||
|
const renderContent = () => {
|
||||||
|
// Show empty message if no visible items
|
||||||
|
if (visibleCount === 0) {
|
||||||
|
return <Text color={colors.textMuted} dimColor>{emptyMessage}</Text>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If groups are defined, render grouped (but still respect maxVisible)
|
||||||
|
if (groups && groups.length > 0) {
|
||||||
|
// Get display item indices for quick lookup
|
||||||
|
const displayIndices = new Set(displayItems.map(({ idx }) => idx));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box flexDirection="column">
|
||||||
|
{groups.map((group, groupIndex) => {
|
||||||
|
// Filter to only items that are in this group AND in the display window
|
||||||
|
const groupItems = displayItems.filter(({ item }) => item.group === group.id);
|
||||||
|
|
||||||
|
if (groupItems.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box key={group.id} flexDirection="column">
|
||||||
|
{/* Group label */}
|
||||||
|
{group.label && (
|
||||||
|
<Text color={colors.textMuted} bold>{group.label}</Text>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Group items */}
|
||||||
|
{groupItems.map(({ item, idx }) => renderListItem(item, idx))}
|
||||||
|
|
||||||
|
{/* Separator - only show if there are more groups with items after this */}
|
||||||
|
{group.separator && groupIndex < groups.length - 1 && (
|
||||||
|
<Box marginY={1}>
|
||||||
|
<Text color={colors.textMuted}>────────────────────────</Text>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// No grouping - render with scroll window
|
||||||
|
return (
|
||||||
|
<Box flexDirection="column">
|
||||||
|
{displayItems.map(({ item, idx }) => renderListItem(item, idx))}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const borderColor = focus ? colors.focus : colors.border;
|
||||||
|
|
||||||
|
const content = (
|
||||||
|
<Box flexDirection="column">
|
||||||
|
{/* Filter input */}
|
||||||
|
{filterable && isFiltering && (
|
||||||
|
<Box marginBottom={1}>
|
||||||
|
<Text color={colors.info}>Filter: </Text>
|
||||||
|
<TextInput
|
||||||
|
value={filterText}
|
||||||
|
onChange={handleFilterChange}
|
||||||
|
onSubmit={handleFilterSubmit}
|
||||||
|
placeholder={filterPlaceholder}
|
||||||
|
focus={isFiltering}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* List content */}
|
||||||
|
{renderContent()}
|
||||||
|
|
||||||
|
{/* Scroll indicator */}
|
||||||
|
{showScrollIndicator && visibleCount > maxVisible && (
|
||||||
|
<Text color={colors.textMuted} dimColor>
|
||||||
|
{scrollWindow.start + 1}-{scrollWindow.end} of {visibleCount}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Filter hint */}
|
||||||
|
{filterable && !isFiltering && (
|
||||||
|
<Text color={colors.textMuted} dimColor>
|
||||||
|
Press '/' to filter
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box flexDirection="column">
|
||||||
|
{label && <Text color={colors.primary} bold>{label}</Text>}
|
||||||
|
{border ? (
|
||||||
|
<Box
|
||||||
|
borderStyle="single"
|
||||||
|
borderColor={borderColor}
|
||||||
|
paddingX={1}
|
||||||
|
flexDirection="column"
|
||||||
|
>
|
||||||
|
{content}
|
||||||
|
</Box>
|
||||||
|
) : content}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Legacy List Component (kept for backward compatibility)
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Legacy list item type.
|
||||||
|
* @deprecated Use ListItemData instead
|
||||||
*/
|
*/
|
||||||
export interface ListItem<T = unknown> {
|
export interface ListItem<T = unknown> {
|
||||||
/** Unique key for the item */
|
/** Unique key for the item */
|
||||||
@@ -23,7 +472,8 @@ export interface ListItem<T = unknown> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Props for the List component.
|
* Props for the legacy List component.
|
||||||
|
* @deprecated Use ScrollableListProps instead
|
||||||
*/
|
*/
|
||||||
interface ListProps<T> {
|
interface ListProps<T> {
|
||||||
/** List items */
|
/** List items */
|
||||||
@@ -46,6 +496,7 @@ interface ListProps<T> {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Selectable list with keyboard navigation.
|
* Selectable list with keyboard navigation.
|
||||||
|
* @deprecated Use ScrollableList instead
|
||||||
*/
|
*/
|
||||||
export function List<T>({
|
export function List<T>({
|
||||||
items,
|
items,
|
||||||
@@ -132,8 +583,12 @@ export function List<T>({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// SimpleList Component
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Simple inline list for displaying items without selection.
|
* Props for SimpleList component.
|
||||||
*/
|
*/
|
||||||
interface SimpleListProps {
|
interface SimpleListProps {
|
||||||
items: string[];
|
items: string[];
|
||||||
@@ -141,6 +596,9 @@ interface SimpleListProps {
|
|||||||
bullet?: string;
|
bullet?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Simple inline list for displaying items without selection.
|
||||||
|
*/
|
||||||
export function SimpleList({
|
export function SimpleList({
|
||||||
items,
|
items,
|
||||||
label,
|
label,
|
||||||
|
|||||||
68
src/tui/components/VariableInputField.tsx
Normal file
68
src/tui/components/VariableInputField.tsx
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { Box, Text } from "ink";
|
||||||
|
import TextInput from "ink-text-input";
|
||||||
|
import { formatSatoshis } from "../theme.js";
|
||||||
|
|
||||||
|
interface VariableInputFieldProps {
|
||||||
|
variable: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
type: string;
|
||||||
|
hint?: string;
|
||||||
|
value: string;
|
||||||
|
};
|
||||||
|
index: number;
|
||||||
|
isFocused: boolean;
|
||||||
|
onChange: (index: number, value: string) => void;
|
||||||
|
onSubmit: () => void;
|
||||||
|
borderColor: string;
|
||||||
|
focusColor: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function VariableInputField({
|
||||||
|
variable,
|
||||||
|
index,
|
||||||
|
isFocused,
|
||||||
|
onChange,
|
||||||
|
onSubmit,
|
||||||
|
borderColor,
|
||||||
|
focusColor,
|
||||||
|
}: VariableInputFieldProps): React.ReactElement {
|
||||||
|
return (
|
||||||
|
<Box flexDirection="column" marginBottom={1}>
|
||||||
|
<Text color={focusColor}>{variable.name}</Text>
|
||||||
|
{variable.hint && (
|
||||||
|
<Text color={borderColor} dimColor>
|
||||||
|
({variable.hint})
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
<Box
|
||||||
|
borderStyle="single"
|
||||||
|
borderColor={isFocused ? focusColor : borderColor}
|
||||||
|
paddingX={1}
|
||||||
|
marginTop={1}
|
||||||
|
gap={1}
|
||||||
|
>
|
||||||
|
<TextInput
|
||||||
|
value={variable.value}
|
||||||
|
onChange={(value) => onChange(index, value)}
|
||||||
|
onSubmit={onSubmit}
|
||||||
|
focus={isFocused}
|
||||||
|
placeholder={`Enter ${variable.name}...`}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* TODO: this may need to be conditional. Need to play around with other templates though */}
|
||||||
|
<Text color={borderColor} dimColor>{variable.hint}</Text>
|
||||||
|
|
||||||
|
</Box>
|
||||||
|
{variable.type === 'integer' && variable.hint === 'satoshis' && (
|
||||||
|
<Box>
|
||||||
|
<Text color={borderColor} dimColor>
|
||||||
|
{/* Convert from sats to bch. NOTE: we can't use the formatSatoshis function because it is too verbose and returns too many values in the string*/}
|
||||||
|
{(Number(variable.value) / 100_000_000).toFixed(8)} BCH
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -5,6 +5,14 @@
|
|||||||
export { Screen } from './Screen.js';
|
export { Screen } from './Screen.js';
|
||||||
export { Input, TextDisplay } from './Input.js';
|
export { Input, TextDisplay } from './Input.js';
|
||||||
export { Button, ButtonRow } from './Button.js';
|
export { Button, ButtonRow } from './Button.js';
|
||||||
export { List, SimpleList, type ListItem } from './List.js';
|
export {
|
||||||
|
List,
|
||||||
|
SimpleList,
|
||||||
|
ScrollableList,
|
||||||
|
type ListItem,
|
||||||
|
type ListItemData,
|
||||||
|
type ListGroup,
|
||||||
|
type ScrollableListProps,
|
||||||
|
} from './List.js';
|
||||||
export { InputDialog, ConfirmDialog, MessageDialog } from './Dialog.js';
|
export { InputDialog, ConfirmDialog, MessageDialog } from './Dialog.js';
|
||||||
export { ProgressBar, StepIndicator, Loading, type Step } from './ProgressBar.js';
|
export { ProgressBar, StepIndicator, Loading, type Step } from './ProgressBar.js';
|
||||||
|
|||||||
@@ -4,3 +4,10 @@
|
|||||||
|
|
||||||
export { NavigationProvider, useNavigation } from './useNavigation.js';
|
export { NavigationProvider, useNavigation } from './useNavigation.js';
|
||||||
export { AppProvider, useAppContext, useDialog, useStatus } from './useAppContext.js';
|
export { AppProvider, useAppContext, useDialog, useStatus } from './useAppContext.js';
|
||||||
|
export {
|
||||||
|
useInvitations,
|
||||||
|
useInvitation,
|
||||||
|
useInvitationData,
|
||||||
|
useCreateInvitation,
|
||||||
|
useInvitationIds,
|
||||||
|
} from './useInvitations.js';
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
/**
|
/**
|
||||||
* App context hook for accessing controllers and app-level functions.
|
* App context hook for accessing AppService and app-level functions.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { createContext, useContext, useState, useCallback, type ReactNode } from 'react';
|
import React, { createContext, useContext, useState, useCallback, type ReactNode } from 'react';
|
||||||
import type { WalletController } from '../../controllers/wallet-controller.js';
|
import { AppService } from '../../services/app.js';
|
||||||
import type { InvitationController } from '../../controllers/invitation-controller.js';
|
import type { AppConfig } from '../../app.js';
|
||||||
import type { AppContextType, DialogState } from '../types.js';
|
import type { AppContextType, DialogState } from '../types.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -37,27 +37,50 @@ const StatusContext = createContext<StatusContextType | null>(null);
|
|||||||
*/
|
*/
|
||||||
interface AppProviderProps {
|
interface AppProviderProps {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
walletController: WalletController;
|
config: AppConfig;
|
||||||
invitationController: InvitationController;
|
|
||||||
onExit: () => void;
|
onExit: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* App provider component.
|
* App provider component.
|
||||||
* Provides controllers, dialog management, and app-level functions to children.
|
* Provides AppService, dialog management, and app-level functions to children.
|
||||||
*/
|
*/
|
||||||
export function AppProvider({
|
export function AppProvider({
|
||||||
children,
|
children,
|
||||||
walletController,
|
config,
|
||||||
invitationController,
|
|
||||||
onExit,
|
onExit,
|
||||||
}: AppProviderProps): React.ReactElement {
|
}: AppProviderProps): React.ReactElement {
|
||||||
|
const [appService, setAppService] = useState<AppService | null>(null);
|
||||||
const [dialog, setDialog] = useState<DialogState | null>(null);
|
const [dialog, setDialog] = useState<DialogState | null>(null);
|
||||||
const [status, setStatusState] = useState<string>('Ready');
|
const [status, setStatusState] = useState<string>('Ready');
|
||||||
const [isWalletInitialized, setWalletInitialized] = useState(false);
|
const [isWalletInitialized, setWalletInitialized] = useState(false);
|
||||||
|
|
||||||
// Promise resolver for confirm dialogs
|
/**
|
||||||
const [confirmResolver, setConfirmResolver] = useState<((value: boolean) => void) | null>(null);
|
* Initialize wallet with seed phrase and create AppService.
|
||||||
|
*/
|
||||||
|
const initializeWallet = useCallback(async (seed: string) => {
|
||||||
|
try {
|
||||||
|
// Create the AppService with the seed
|
||||||
|
const service = await AppService.create(seed, {
|
||||||
|
syncServerUrl: config.syncServerUrl,
|
||||||
|
engineConfig: {
|
||||||
|
databasePath: config.databasePath,
|
||||||
|
databaseFilename: config.databaseFilename,
|
||||||
|
},
|
||||||
|
invitationStoragePath: config.invitationStoragePath,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Start the AppService (loads existing invitations)
|
||||||
|
await service.start();
|
||||||
|
|
||||||
|
// Set the service and mark as initialized
|
||||||
|
setAppService(service);
|
||||||
|
setWalletInitialized(true);
|
||||||
|
} catch (error) {
|
||||||
|
// Re-throw the error so the caller can handle it
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}, [config]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Show an error dialog.
|
* Show an error dialog.
|
||||||
@@ -88,7 +111,6 @@ export function AppProvider({
|
|||||||
*/
|
*/
|
||||||
const confirm = useCallback((message: string): Promise<boolean> => {
|
const confirm = useCallback((message: string): Promise<boolean> => {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
setConfirmResolver(() => resolve);
|
|
||||||
setDialog({
|
setDialog({
|
||||||
visible: true,
|
visible: true,
|
||||||
type: 'confirm',
|
type: 'confirm',
|
||||||
@@ -113,15 +135,15 @@ export function AppProvider({
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const appValue: AppContextType = {
|
const appValue: AppContextType = {
|
||||||
walletController,
|
appService,
|
||||||
invitationController,
|
initializeWallet,
|
||||||
|
isWalletInitialized,
|
||||||
|
config,
|
||||||
showError,
|
showError,
|
||||||
showInfo,
|
showInfo,
|
||||||
confirm,
|
confirm,
|
||||||
exit: onExit,
|
exit: onExit,
|
||||||
setStatus,
|
setStatus,
|
||||||
isWalletInitialized,
|
|
||||||
setWalletInitialized,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const dialogValue: DialogContextType = {
|
const dialogValue: DialogContextType = {
|
||||||
|
|||||||
144
src/tui/hooks/useInvitations.tsx
Normal file
144
src/tui/hooks/useInvitations.tsx
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
/**
|
||||||
|
* Performance-optimized invitation hooks.
|
||||||
|
* Uses useSyncExternalStore for fine-grained reactivity.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useSyncExternalStore, useMemo, useCallback } from 'react';
|
||||||
|
import type { Invitation } from '../../services/invitation.js';
|
||||||
|
import type { XOInvitation } from '@xo-cash/types';
|
||||||
|
import { useAppContext } from './useAppContext.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all invitations reactively.
|
||||||
|
* Re-renders when invitations are added or removed.
|
||||||
|
*/
|
||||||
|
export function useInvitations(): Invitation[] {
|
||||||
|
const { appService } = useAppContext();
|
||||||
|
|
||||||
|
const subscribe = useCallback(
|
||||||
|
(callback: () => void) => {
|
||||||
|
if (!appService) {
|
||||||
|
return () => {};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Subscribe to invitation list changes
|
||||||
|
const onAdded = () => callback();
|
||||||
|
const onRemoved = () => callback();
|
||||||
|
|
||||||
|
appService.on('invitation-added', onAdded);
|
||||||
|
appService.on('invitation-removed', onRemoved);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
appService.off('invitation-added', onAdded);
|
||||||
|
appService.off('invitation-removed', onRemoved);
|
||||||
|
};
|
||||||
|
},
|
||||||
|
[appService]
|
||||||
|
);
|
||||||
|
|
||||||
|
const getSnapshot = useCallback(() => {
|
||||||
|
return appService?.invitations ?? [];
|
||||||
|
}, [appService]);
|
||||||
|
|
||||||
|
return useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a single invitation by ID with selective re-rendering.
|
||||||
|
* Only re-renders when the specific invitation is updated.
|
||||||
|
*/
|
||||||
|
export function useInvitation(invitationId: string | null): Invitation | null {
|
||||||
|
const { appService } = useAppContext();
|
||||||
|
|
||||||
|
const subscribe = useCallback(
|
||||||
|
(callback: () => void) => {
|
||||||
|
if (!appService || !invitationId) {
|
||||||
|
return () => {};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the invitation instance
|
||||||
|
const invitation = appService.invitations.find(
|
||||||
|
(inv) => inv.data.invitationIdentifier === invitationId
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!invitation) {
|
||||||
|
return () => {};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Subscribe to this specific invitation's updates
|
||||||
|
const onUpdated = () => callback();
|
||||||
|
const onStatusChanged = () => callback();
|
||||||
|
|
||||||
|
invitation.on('invitation-updated', onUpdated);
|
||||||
|
invitation.on('invitation-status-changed', onStatusChanged);
|
||||||
|
|
||||||
|
// Also subscribe to list changes in case the invitation is removed
|
||||||
|
const onRemoved = () => callback();
|
||||||
|
appService.on('invitation-removed', onRemoved);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
invitation.off('invitation-updated', onUpdated);
|
||||||
|
invitation.off('invitation-status-changed', onStatusChanged);
|
||||||
|
appService.off('invitation-removed', onRemoved);
|
||||||
|
};
|
||||||
|
},
|
||||||
|
[appService, invitationId]
|
||||||
|
);
|
||||||
|
|
||||||
|
const getSnapshot = useCallback(() => {
|
||||||
|
if (!appService || !invitationId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
appService.invitations.find(
|
||||||
|
(inv) => inv.data.invitationIdentifier === invitationId
|
||||||
|
) ?? null
|
||||||
|
);
|
||||||
|
}, [appService, invitationId]);
|
||||||
|
|
||||||
|
return useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get invitation data with memoization.
|
||||||
|
* Returns stable references to prevent unnecessary re-renders.
|
||||||
|
*/
|
||||||
|
export function useInvitationData(invitationId: string | null): XOInvitation | null {
|
||||||
|
const invitation = useInvitation(invitationId);
|
||||||
|
|
||||||
|
return useMemo(() => {
|
||||||
|
return invitation?.data ?? null;
|
||||||
|
}, [invitation?.data.invitationIdentifier, invitation?.data.commits?.length]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to create invitations.
|
||||||
|
* Returns a memoized function to create invitations.
|
||||||
|
*/
|
||||||
|
export function useCreateInvitation() {
|
||||||
|
const { appService } = useAppContext();
|
||||||
|
|
||||||
|
return useCallback(
|
||||||
|
async (invitation: XOInvitation | string): Promise<Invitation> => {
|
||||||
|
if (!appService) {
|
||||||
|
throw new Error('AppService not initialized');
|
||||||
|
}
|
||||||
|
|
||||||
|
return await appService.createInvitation(invitation);
|
||||||
|
},
|
||||||
|
[appService]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to get all invitations with their IDs.
|
||||||
|
* Useful for lists where you only need IDs (prevents re-renders on data changes).
|
||||||
|
*/
|
||||||
|
export function useInvitationIds(): string[] {
|
||||||
|
const invitations = useInvitations();
|
||||||
|
|
||||||
|
return useMemo(() => {
|
||||||
|
return invitations.map((inv) => inv.data.invitationIdentifier);
|
||||||
|
}, [invitations]);
|
||||||
|
}
|
||||||
@@ -1,885 +0,0 @@
|
|||||||
/**
|
|
||||||
* Action Wizard Screen - Step-by-step walkthrough for template actions.
|
|
||||||
*
|
|
||||||
* Guides users through:
|
|
||||||
* - Reviewing action requirements
|
|
||||||
* - Entering variables (e.g., requestedSatoshis)
|
|
||||||
* - Selecting inputs (UTXOs) for funding
|
|
||||||
* - Reviewing outputs and change
|
|
||||||
* - Creating and publishing invitation
|
|
||||||
*/
|
|
||||||
|
|
||||||
import React, { useState, useEffect, useCallback } from 'react';
|
|
||||||
import { Box, Text, useInput } from 'ink';
|
|
||||||
import TextInput from 'ink-text-input';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Isolated Variable Input Component.
|
|
||||||
* This component handles its own input without interference from parent useInput hooks.
|
|
||||||
*/
|
|
||||||
interface VariableInputFieldProps {
|
|
||||||
variable: { id: string; name: string; type: string; hint?: string; value: string };
|
|
||||||
index: number;
|
|
||||||
isFocused: boolean;
|
|
||||||
onChange: (index: number, value: string) => void;
|
|
||||||
onSubmit: () => void;
|
|
||||||
borderColor: string;
|
|
||||||
focusColor: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
function VariableInputField({
|
|
||||||
variable,
|
|
||||||
index,
|
|
||||||
isFocused,
|
|
||||||
onChange,
|
|
||||||
onSubmit,
|
|
||||||
borderColor,
|
|
||||||
focusColor,
|
|
||||||
}: VariableInputFieldProps): React.ReactElement {
|
|
||||||
return (
|
|
||||||
<Box flexDirection="column" marginBottom={1}>
|
|
||||||
<Text color={focusColor}>{variable.name}</Text>
|
|
||||||
{variable.hint && (
|
|
||||||
<Text color={borderColor} dimColor>({variable.hint})</Text>
|
|
||||||
)}
|
|
||||||
<Box
|
|
||||||
borderStyle="single"
|
|
||||||
borderColor={isFocused ? focusColor : borderColor}
|
|
||||||
paddingX={1}
|
|
||||||
marginTop={1}
|
|
||||||
>
|
|
||||||
<TextInput
|
|
||||||
value={variable.value}
|
|
||||||
onChange={value => onChange(index, value)}
|
|
||||||
onSubmit={onSubmit}
|
|
||||||
focus={isFocused}
|
|
||||||
placeholder={`Enter ${variable.name}...`}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
import { StepIndicator, type Step } from '../components/ProgressBar.js';
|
|
||||||
import { Button } from '../components/Button.js';
|
|
||||||
import { useNavigation } from '../hooks/useNavigation.js';
|
|
||||||
import { useAppContext, useStatus } from '../hooks/useAppContext.js';
|
|
||||||
import { colors, logoSmall, formatSatoshis, formatHex } from '../theme.js';
|
|
||||||
import { copyToClipboard } from '../utils/clipboard.js';
|
|
||||||
import type { XOTemplate, XOInvitation } from '@xo-cash/types';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Wizard step types.
|
|
||||||
*/
|
|
||||||
type StepType = 'info' | 'variables' | 'inputs' | 'review' | 'publish';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Wizard step definition.
|
|
||||||
*/
|
|
||||||
interface WizardStep {
|
|
||||||
name: string;
|
|
||||||
type: StepType;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Variable input state.
|
|
||||||
*/
|
|
||||||
interface VariableInput {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
type: string;
|
|
||||||
hint?: string;
|
|
||||||
value: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* UTXO for selection.
|
|
||||||
*/
|
|
||||||
interface SelectableUTXO {
|
|
||||||
outpointTransactionHash: string;
|
|
||||||
outpointIndex: number;
|
|
||||||
valueSatoshis: bigint;
|
|
||||||
lockingBytecode?: string;
|
|
||||||
selected: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Action Wizard Screen Component.
|
|
||||||
*/
|
|
||||||
export function ActionWizardScreen(): React.ReactElement {
|
|
||||||
const { navigate, goBack, data: navData } = useNavigation();
|
|
||||||
const { walletController, invitationController, showError, showInfo } = useAppContext();
|
|
||||||
const { setStatus } = useStatus();
|
|
||||||
|
|
||||||
// Extract navigation data
|
|
||||||
const templateIdentifier = navData.templateIdentifier as string | undefined;
|
|
||||||
const actionIdentifier = navData.actionIdentifier as string | undefined;
|
|
||||||
const roleIdentifier = navData.roleIdentifier as string | undefined;
|
|
||||||
const template = navData.template as XOTemplate | undefined;
|
|
||||||
|
|
||||||
// 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); // Default fee estimate
|
|
||||||
|
|
||||||
// Invitation state
|
|
||||||
const [invitation, setInvitation] = useState<XOInvitation | null>(null);
|
|
||||||
const [invitationId, setInvitationId] = useState<string | null>(null);
|
|
||||||
|
|
||||||
// UI state
|
|
||||||
const [focusedInput, setFocusedInput] = useState(0);
|
|
||||||
const [focusedButton, setFocusedButton] = useState<'back' | 'cancel' | 'next'>('next');
|
|
||||||
const [focusArea, setFocusArea] = useState<'content' | 'buttons'>('content');
|
|
||||||
const [isProcessing, setIsProcessing] = useState(false);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Initialize wizard on mount.
|
|
||||||
*/
|
|
||||||
useEffect(() => {
|
|
||||||
if (!template || !actionIdentifier || !roleIdentifier) {
|
|
||||||
showError('Missing wizard data');
|
|
||||||
goBack();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build steps based on template
|
|
||||||
const action = template.actions?.[actionIdentifier];
|
|
||||||
const role = action?.roles?.[roleIdentifier];
|
|
||||||
const requirements = role?.requirements;
|
|
||||||
|
|
||||||
const wizardSteps: WizardStep[] = [
|
|
||||||
{ name: 'Welcome', type: 'info' },
|
|
||||||
];
|
|
||||||
|
|
||||||
// Add variables step if needed
|
|
||||||
if (requirements?.variables && requirements.variables.length > 0) {
|
|
||||||
wizardSteps.push({ name: 'Variables', type: 'variables' });
|
|
||||||
|
|
||||||
// Initialize variable inputs
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add inputs step if role requires slots (funding inputs)
|
|
||||||
// Slots indicate the role needs to provide transaction inputs/outputs
|
|
||||||
if (requirements?.slots && requirements.slots.min > 0) {
|
|
||||||
wizardSteps.push({ name: 'Select UTXOs', type: 'inputs' });
|
|
||||||
}
|
|
||||||
|
|
||||||
wizardSteps.push({ name: 'Review', type: 'review' });
|
|
||||||
wizardSteps.push({ name: 'Publish', type: 'publish' });
|
|
||||||
|
|
||||||
setSteps(wizardSteps);
|
|
||||||
setStatus(`${actionIdentifier}/${roleIdentifier}`);
|
|
||||||
}, [template, actionIdentifier, roleIdentifier, showError, goBack, setStatus]);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get current step data.
|
|
||||||
*/
|
|
||||||
const currentStepData = steps[currentStep];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Calculate selected amount.
|
|
||||||
*/
|
|
||||||
const selectedAmount = availableUtxos
|
|
||||||
.filter(u => u.selected)
|
|
||||||
.reduce((sum, u) => sum + u.valueSatoshis, 0n);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Calculate change amount.
|
|
||||||
*/
|
|
||||||
const changeAmount = selectedAmount - requiredAmount - fee;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Load available UTXOs for the inputs step.
|
|
||||||
*/
|
|
||||||
const loadAvailableUtxos = useCallback(async () => {
|
|
||||||
if (!invitation || !templateIdentifier) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
setIsProcessing(true);
|
|
||||||
setStatus('Finding suitable UTXOs...');
|
|
||||||
|
|
||||||
// First, get the required amount from variables (e.g., requestedSatoshis)
|
|
||||||
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 suitable resources
|
|
||||||
const resources = await walletController.findSuitableResources(invitation, {
|
|
||||||
templateIdentifier,
|
|
||||||
outputIdentifier: 'receiveOutput', // Common output identifier
|
|
||||||
});
|
|
||||||
|
|
||||||
// Convert to selectable UTXOs
|
|
||||||
const utxos: SelectableUTXO[] = (resources.unspentOutputs || []).map((utxo: any) => ({
|
|
||||||
outpointTransactionHash: utxo.outpointTransactionHash,
|
|
||||||
outpointIndex: utxo.outpointIndex,
|
|
||||||
valueSatoshis: BigInt(utxo.valueSatoshis),
|
|
||||||
lockingBytecode: utxo.lockingBytecode
|
|
||||||
? typeof utxo.lockingBytecode === 'string'
|
|
||||||
? utxo.lockingBytecode
|
|
||||||
: Buffer.from(utxo.lockingBytecode).toString('hex')
|
|
||||||
: undefined,
|
|
||||||
selected: false,
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Auto-select UTXOs to cover required amount + fee
|
|
||||||
let accumulated = 0n;
|
|
||||||
const seenLockingBytecodes = new Set<string>();
|
|
||||||
|
|
||||||
for (const utxo of utxos) {
|
|
||||||
// Ensure lockingBytecode uniqueness
|
|
||||||
if (utxo.lockingBytecode && seenLockingBytecodes.has(utxo.lockingBytecode)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (utxo.lockingBytecode) {
|
|
||||||
seenLockingBytecodes.add(utxo.lockingBytecode);
|
|
||||||
}
|
|
||||||
|
|
||||||
utxo.selected = true;
|
|
||||||
accumulated += utxo.valueSatoshis;
|
|
||||||
|
|
||||||
if (accumulated >= requested + fee) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
setAvailableUtxos(utxos);
|
|
||||||
setStatus('Ready');
|
|
||||||
} catch (error) {
|
|
||||||
showError(`Failed to load UTXOs: ${error instanceof Error ? error.message : String(error)}`);
|
|
||||||
} finally {
|
|
||||||
setIsProcessing(false);
|
|
||||||
}
|
|
||||||
}, [invitation, templateIdentifier, variables, walletController, showError, setStatus]);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Toggle UTXO selection.
|
|
||||||
*/
|
|
||||||
const toggleUtxoSelection = useCallback((index: number) => {
|
|
||||||
setAvailableUtxos(prev => {
|
|
||||||
const updated = [...prev];
|
|
||||||
const utxo = updated[index];
|
|
||||||
if (utxo) {
|
|
||||||
updated[index] = { ...utxo, selected: !utxo.selected };
|
|
||||||
}
|
|
||||||
return updated;
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Navigate to next step.
|
|
||||||
*/
|
|
||||||
const nextStep = useCallback(async () => {
|
|
||||||
if (currentStep >= steps.length - 1) return;
|
|
||||||
|
|
||||||
const stepType = currentStepData?.type;
|
|
||||||
|
|
||||||
// Handle step-specific logic
|
|
||||||
if (stepType === 'variables') {
|
|
||||||
// Validate that all required variables have values
|
|
||||||
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 invitation and add variables
|
|
||||||
await createInvitationWithVariables();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (stepType === 'inputs') {
|
|
||||||
// Add selected inputs and outputs to invitation
|
|
||||||
await addInputsAndOutputs();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (stepType === 'review') {
|
|
||||||
// Publish invitation
|
|
||||||
await publishInvitation();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setCurrentStep(prev => prev + 1);
|
|
||||||
setFocusArea('content');
|
|
||||||
setFocusedInput(0);
|
|
||||||
}, [currentStep, steps.length, currentStepData, variables, showError]);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create invitation and add variables.
|
|
||||||
*/
|
|
||||||
const createInvitationWithVariables = useCallback(async () => {
|
|
||||||
if (!templateIdentifier || !actionIdentifier || !roleIdentifier || !template) return;
|
|
||||||
|
|
||||||
setIsProcessing(true);
|
|
||||||
setStatus('Creating invitation...');
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Create invitation
|
|
||||||
const tracked = await invitationController.createInvitation(
|
|
||||||
templateIdentifier,
|
|
||||||
actionIdentifier,
|
|
||||||
);
|
|
||||||
|
|
||||||
let inv = tracked.invitation;
|
|
||||||
const invId = inv.invitationIdentifier;
|
|
||||||
setInvitationId(invId);
|
|
||||||
|
|
||||||
// Add variables if any
|
|
||||||
if (variables.length > 0) {
|
|
||||||
const variableData = variables.map(v => {
|
|
||||||
// Determine if this is a numeric type that should be BigInt
|
|
||||||
// Template types include: 'integer', 'number', 'satoshis'
|
|
||||||
// Hints include: 'satoshis', 'amount'
|
|
||||||
const isNumeric = ['integer', 'number', 'satoshis'].includes(v.type) ||
|
|
||||||
(v.hint && ['satoshis', 'amount'].includes(v.hint));
|
|
||||||
|
|
||||||
return {
|
|
||||||
variableIdentifier: v.id,
|
|
||||||
roleIdentifier: roleIdentifier,
|
|
||||||
value: isNumeric ? BigInt(v.value || '0') : v.value,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
const updated = await invitationController.addVariables(invId, variableData);
|
|
||||||
inv = updated.invitation;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add template-required outputs for the current role
|
|
||||||
// This is critical - the template defines which outputs the initiator must create
|
|
||||||
const action = template.actions?.[actionIdentifier];
|
|
||||||
const transaction = action?.transaction ? template.transactions?.[action.transaction] : null;
|
|
||||||
|
|
||||||
if (transaction?.outputs && transaction.outputs.length > 0) {
|
|
||||||
setStatus('Adding required outputs...');
|
|
||||||
|
|
||||||
// Add each required output with just its identifier
|
|
||||||
// IMPORTANT: Do NOT pass roleIdentifier here - if roleIdentifier is set,
|
|
||||||
// the engine skips generating the lockingBytecode (see engine.ts appendInvitation)
|
|
||||||
// The engine will automatically generate the locking bytecode based on the template
|
|
||||||
const outputsToAdd = transaction.outputs.map((outputId: string) => ({
|
|
||||||
outputIdentifier: outputId,
|
|
||||||
// Note: roleIdentifier intentionally omitted to trigger lockingBytecode generation
|
|
||||||
}));
|
|
||||||
|
|
||||||
const updated = await invitationController.addOutputs(invId, outputsToAdd);
|
|
||||||
inv = updated.invitation;
|
|
||||||
}
|
|
||||||
|
|
||||||
setInvitation(inv);
|
|
||||||
|
|
||||||
// Check if next step is inputs
|
|
||||||
const nextStepType = steps[currentStep + 1]?.type;
|
|
||||||
if (nextStepType === 'inputs') {
|
|
||||||
setCurrentStep(prev => prev + 1);
|
|
||||||
// Load UTXOs after step change
|
|
||||||
setTimeout(() => loadAvailableUtxos(), 100);
|
|
||||||
} else {
|
|
||||||
setCurrentStep(prev => prev + 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
setStatus('Invitation created');
|
|
||||||
} catch (error) {
|
|
||||||
showError(`Failed to create invitation: ${error instanceof Error ? error.message : String(error)}`);
|
|
||||||
} finally {
|
|
||||||
setIsProcessing(false);
|
|
||||||
}
|
|
||||||
}, [templateIdentifier, actionIdentifier, roleIdentifier, template, variables, invitationController, steps, currentStep, showError, setStatus, loadAvailableUtxos]);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Add selected inputs and change output to invitation.
|
|
||||||
*/
|
|
||||||
const addInputsAndOutputs = useCallback(async () => {
|
|
||||||
if (!invitationId || !invitation) 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) { // Dust threshold
|
|
||||||
showError(`Change amount (${changeAmount}) is below dust threshold (546 sats)`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsProcessing(true);
|
|
||||||
setStatus('Adding inputs and outputs...');
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Add inputs
|
|
||||||
const inputs = selectedUtxos.map(utxo => ({
|
|
||||||
outpointTransactionHash: utxo.outpointTransactionHash,
|
|
||||||
outpointIndex: utxo.outpointIndex,
|
|
||||||
}));
|
|
||||||
|
|
||||||
await invitationController.addInputs(invitationId, inputs);
|
|
||||||
|
|
||||||
// Add change output
|
|
||||||
const outputs = [{
|
|
||||||
valueSatoshis: changeAmount,
|
|
||||||
// The engine will automatically generate the locking bytecode for change
|
|
||||||
}];
|
|
||||||
|
|
||||||
await invitationController.addOutputs(invitationId, outputs);
|
|
||||||
|
|
||||||
// Add transaction metadata
|
|
||||||
// Note: This would be done via appendInvitation but we don't have direct access here
|
|
||||||
// The engine should handle defaults
|
|
||||||
|
|
||||||
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, invitationController, showError, setStatus]);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Publish invitation.
|
|
||||||
*/
|
|
||||||
const publishInvitation = useCallback(async () => {
|
|
||||||
if (!invitationId) return;
|
|
||||||
|
|
||||||
setIsProcessing(true);
|
|
||||||
setStatus('Publishing invitation...');
|
|
||||||
|
|
||||||
try {
|
|
||||||
await invitationController.publishAndSubscribe(invitationId);
|
|
||||||
setCurrentStep(prev => prev + 1);
|
|
||||||
setStatus('Invitation published');
|
|
||||||
} catch (error) {
|
|
||||||
showError(`Failed to publish: ${error instanceof Error ? error.message : String(error)}`);
|
|
||||||
} finally {
|
|
||||||
setIsProcessing(false);
|
|
||||||
}
|
|
||||||
}, [invitationId, invitationController, showError, setStatus]);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Navigate to previous step.
|
|
||||||
*/
|
|
||||||
const previousStep = useCallback(() => {
|
|
||||||
if (currentStep <= 0) {
|
|
||||||
goBack();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setCurrentStep(prev => prev - 1);
|
|
||||||
setFocusArea('content');
|
|
||||||
setFocusedInput(0);
|
|
||||||
}, [currentStep, goBack]);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Cancel wizard.
|
|
||||||
*/
|
|
||||||
const cancel = useCallback(() => {
|
|
||||||
goBack();
|
|
||||||
}, [goBack]);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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]);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Update 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;
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Check if TextInput should have exclusive focus (variables step with content focus)
|
|
||||||
const textInputHasFocus = currentStepData?.type === 'variables' && focusArea === 'content';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handle TextInput submit (Enter key) - moves to next variable or buttons.
|
|
||||||
*/
|
|
||||||
const handleTextInputSubmit = useCallback(() => {
|
|
||||||
if (focusedInput < variables.length - 1) {
|
|
||||||
setFocusedInput(prev => prev + 1);
|
|
||||||
} else {
|
|
||||||
setFocusArea('buttons');
|
|
||||||
setFocusedButton('next');
|
|
||||||
}
|
|
||||||
}, [focusedInput, variables.length]);
|
|
||||||
|
|
||||||
// Keyboard handler - COMPLETELY DISABLED when TextInput has focus
|
|
||||||
// This allows TextInput to receive character input without interference
|
|
||||||
// When TextInput is focused, use Enter to navigate (handled by onSubmit callback)
|
|
||||||
useInput((input, key) => {
|
|
||||||
// Tab to switch between content and buttons
|
|
||||||
if (key.tab) {
|
|
||||||
if (focusArea === 'content') {
|
|
||||||
// Handle tab based on current step type
|
|
||||||
if (currentStepData?.type === 'inputs' && availableUtxos.length > 0) {
|
|
||||||
if (selectedUtxoIndex < availableUtxos.length - 1) {
|
|
||||||
setSelectedUtxoIndex(prev => prev + 1);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
setFocusArea('buttons');
|
|
||||||
setFocusedButton('next');
|
|
||||||
} else {
|
|
||||||
if (focusedButton === 'back') {
|
|
||||||
setFocusedButton('cancel');
|
|
||||||
} else if (focusedButton === 'cancel') {
|
|
||||||
setFocusedButton('next');
|
|
||||||
} else {
|
|
||||||
setFocusArea('content');
|
|
||||||
setFocusedInput(0);
|
|
||||||
setSelectedUtxoIndex(0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Arrow keys for UTXO selection
|
|
||||||
if (focusArea === 'content' && currentStepData?.type === 'inputs') {
|
|
||||||
if (key.upArrow) {
|
|
||||||
setSelectedUtxoIndex(prev => Math.max(0, prev - 1));
|
|
||||||
} else if (key.downArrow) {
|
|
||||||
setSelectedUtxoIndex(prev => Math.min(availableUtxos.length - 1, prev + 1));
|
|
||||||
} else if (key.return || input === ' ') {
|
|
||||||
toggleUtxoSelection(selectedUtxoIndex);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Arrow keys in buttons area
|
|
||||||
if (focusArea === 'buttons') {
|
|
||||||
if (key.leftArrow) {
|
|
||||||
setFocusedButton(prev =>
|
|
||||||
prev === 'next' ? 'cancel' : prev === 'cancel' ? 'back' : 'back'
|
|
||||||
);
|
|
||||||
} else if (key.rightArrow) {
|
|
||||||
setFocusedButton(prev =>
|
|
||||||
prev === 'back' ? 'cancel' : prev === 'cancel' ? 'next' : 'next'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Enter on buttons
|
|
||||||
if (key.return && focusArea === 'buttons') {
|
|
||||||
if (focusedButton === 'back') previousStep();
|
|
||||||
else if (focusedButton === 'cancel') cancel();
|
|
||||||
else if (focusedButton === 'next') nextStep();
|
|
||||||
}
|
|
||||||
|
|
||||||
// 'c' to copy on publish step
|
|
||||||
if (input === 'c' && currentStepData?.type === 'publish' && invitationId) {
|
|
||||||
copyId();
|
|
||||||
}
|
|
||||||
|
|
||||||
// 'a' to select all UTXOs
|
|
||||||
if (input === 'a' && currentStepData?.type === 'inputs') {
|
|
||||||
setAvailableUtxos(prev => prev.map(u => ({ ...u, selected: true })));
|
|
||||||
}
|
|
||||||
|
|
||||||
// 'n' to deselect all UTXOs
|
|
||||||
if (input === 'n' && currentStepData?.type === 'inputs') {
|
|
||||||
setAvailableUtxos(prev => prev.map(u => ({ ...u, selected: false })));
|
|
||||||
}
|
|
||||||
}, { isActive: !textInputHasFocus });
|
|
||||||
|
|
||||||
// Get action details
|
|
||||||
const action = template?.actions?.[actionIdentifier ?? ''];
|
|
||||||
const actionName = action?.name || actionIdentifier || 'Unknown';
|
|
||||||
|
|
||||||
// Render step content
|
|
||||||
const renderStepContent = () => {
|
|
||||||
if (!currentStepData) return null;
|
|
||||||
|
|
||||||
switch (currentStepData.type) {
|
|
||||||
case 'info':
|
|
||||||
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>
|
|
||||||
|
|
||||||
{action?.roles?.[roleIdentifier ?? '']?.requirements && (
|
|
||||||
<Box marginTop={1} flexDirection="column">
|
|
||||||
<Text color={colors.text}>Requirements:</Text>
|
|
||||||
{action.roles[roleIdentifier ?? '']?.requirements?.variables?.map(v => (
|
|
||||||
<Text key={v} color={colors.textMuted}> • Variable: {v}</Text>
|
|
||||||
))}
|
|
||||||
{action.roles[roleIdentifier ?? '']?.requirements?.slots && (
|
|
||||||
<Text color={colors.textMuted}> • Slots: {action.roles[roleIdentifier ?? '']?.requirements?.slots?.min} min (UTXO selection required)</Text>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
|
|
||||||
case 'variables':
|
|
||||||
return (
|
|
||||||
<Box flexDirection="column">
|
|
||||||
<Text color={colors.text} bold>Enter required values:</Text>
|
|
||||||
<Box marginTop={1} flexDirection="column">
|
|
||||||
{variables.map((variable, index) => (
|
|
||||||
<VariableInputField
|
|
||||||
key={variable.id}
|
|
||||||
variable={variable}
|
|
||||||
index={index}
|
|
||||||
isFocused={focusArea === 'content' && focusedInput === index}
|
|
||||||
onChange={updateVariable}
|
|
||||||
onSubmit={handleTextInputSubmit}
|
|
||||||
borderColor={colors.border as string}
|
|
||||||
focusColor={colors.primary as string}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</Box>
|
|
||||||
<Box marginTop={1}>
|
|
||||||
<Text color={colors.textMuted} dimColor>
|
|
||||||
Type your value, then press Enter to continue
|
|
||||||
</Text>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
|
|
||||||
case 'inputs':
|
|
||||||
return (
|
|
||||||
<Box flexDirection="column">
|
|
||||||
<Text color={colors.text} bold>Select UTXOs to fund the transaction:</Text>
|
|
||||||
|
|
||||||
<Box marginTop={1} flexDirection="column">
|
|
||||||
<Text color={colors.textMuted}>
|
|
||||||
Required: {formatSatoshis(requiredAmount)} + {formatSatoshis(fee)} fee
|
|
||||||
</Text>
|
|
||||||
<Text color={selectedAmount >= requiredAmount + fee ? colors.success : colors.warning}>
|
|
||||||
Selected: {formatSatoshis(selectedAmount)}
|
|
||||||
</Text>
|
|
||||||
{selectedAmount > requiredAmount + fee && (
|
|
||||||
<Text color={colors.info}>
|
|
||||||
Change: {formatSatoshis(changeAmount)}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
<Box marginTop={1} flexDirection="column" borderStyle="single" borderColor={colors.border} paddingX={1}>
|
|
||||||
{availableUtxos.length === 0 ? (
|
|
||||||
<Text color={colors.textMuted}>No UTXOs available</Text>
|
|
||||||
) : (
|
|
||||||
availableUtxos.map((utxo, index) => (
|
|
||||||
<Box key={`${utxo.outpointTransactionHash}:${utxo.outpointIndex}`}>
|
|
||||||
<Text
|
|
||||||
color={selectedUtxoIndex === index && focusArea === 'content' ? colors.focus : colors.text}
|
|
||||||
bold={selectedUtxoIndex === index && focusArea === 'content'}
|
|
||||||
>
|
|
||||||
{selectedUtxoIndex === index && focusArea === 'content' ? '▸ ' : ' '}
|
|
||||||
[{utxo.selected ? 'X' : ' '}] {formatSatoshis(utxo.valueSatoshis)} - {formatHex(utxo.outpointTransactionHash, 12)}:{utxo.outpointIndex}
|
|
||||||
</Text>
|
|
||||||
</Box>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
<Box marginTop={1}>
|
|
||||||
<Text color={colors.textMuted} dimColor>
|
|
||||||
Space/Enter: Toggle • a: Select all • n: Deselect all
|
|
||||||
</Text>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
|
|
||||||
case 'review':
|
|
||||||
const selectedUtxos = availableUtxos.filter(u => u.selected);
|
|
||||||
return (
|
|
||||||
<Box flexDirection="column">
|
|
||||||
<Text color={colors.text} bold>Review your invitation:</Text>
|
|
||||||
|
|
||||||
<Box marginTop={1} flexDirection="column">
|
|
||||||
<Text color={colors.textMuted}>Template: {template?.name}</Text>
|
|
||||||
<Text color={colors.textMuted}>Action: {actionName}</Text>
|
|
||||||
<Text color={colors.textMuted}>Role: {roleIdentifier}</Text>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{variables.length > 0 && (
|
|
||||||
<Box marginTop={1} flexDirection="column">
|
|
||||||
<Text color={colors.text}>Variables:</Text>
|
|
||||||
{variables.map(v => (
|
|
||||||
<Text key={v.id} color={colors.textMuted}>
|
|
||||||
{' '}{v.name}: {v.value || '(empty)'}
|
|
||||||
</Text>
|
|
||||||
))}
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{selectedUtxos.length > 0 && (
|
|
||||||
<Box marginTop={1} flexDirection="column">
|
|
||||||
<Text color={colors.text}>Inputs ({selectedUtxos.length}):</Text>
|
|
||||||
{selectedUtxos.slice(0, 3).map(u => (
|
|
||||||
<Text key={`${u.outpointTransactionHash}:${u.outpointIndex}`} color={colors.textMuted}>
|
|
||||||
{' '}{formatSatoshis(u.valueSatoshis)}
|
|
||||||
</Text>
|
|
||||||
))}
|
|
||||||
{selectedUtxos.length > 3 && (
|
|
||||||
<Text color={colors.textMuted}> ...and {selectedUtxos.length - 3} more</Text>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{changeAmount > 0 && (
|
|
||||||
<Box marginTop={1} flexDirection="column">
|
|
||||||
<Text color={colors.text}>Outputs:</Text>
|
|
||||||
<Text color={colors.textMuted}> Change: {formatSatoshis(changeAmount)}</Text>
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Box marginTop={1}>
|
|
||||||
<Text color={colors.warning}>
|
|
||||||
Press Next to create and publish the invitation.
|
|
||||||
</Text>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
|
|
||||||
case 'publish':
|
|
||||||
return (
|
|
||||||
<Box flexDirection="column">
|
|
||||||
<Text color={colors.success} bold>✓ Invitation Created & Published!</Text>
|
|
||||||
<Box marginTop={1} flexDirection="column">
|
|
||||||
<Text color={colors.text}>Invitation ID:</Text>
|
|
||||||
<Box
|
|
||||||
borderStyle="single"
|
|
||||||
borderColor={colors.primary}
|
|
||||||
paddingX={1}
|
|
||||||
marginTop={1}
|
|
||||||
>
|
|
||||||
<Text color={colors.accent}>{invitationId}</Text>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
<Box marginTop={1}>
|
|
||||||
<Text color={colors.textMuted}>
|
|
||||||
Share this ID with the other party to complete the transaction.
|
|
||||||
</Text>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
<Box marginTop={1}>
|
|
||||||
<Text color={colors.warning}>Press 'c' to copy ID to clipboard</Text>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
|
|
||||||
default:
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Convert steps to StepIndicator format
|
|
||||||
const stepIndicatorSteps: Step[] = steps.map(s => ({ label: s.name }));
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box flexDirection="column" flexGrow={1}>
|
|
||||||
{/* Header */}
|
|
||||||
<Box borderStyle="single" borderColor={colors.secondary} paddingX={1} flexDirection="column">
|
|
||||||
<Text color={colors.primary} bold>{logoSmall} - Action Wizard</Text>
|
|
||||||
<Text color={colors.textMuted}>
|
|
||||||
{template?.name} {'>'} {actionName} (as {roleIdentifier})
|
|
||||||
</Text>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{/* Progress indicator */}
|
|
||||||
<Box marginTop={1} paddingX={1}>
|
|
||||||
<StepIndicator steps={stepIndicatorSteps} currentStep={currentStep} />
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{/* Content area */}
|
|
||||||
<Box
|
|
||||||
borderStyle="single"
|
|
||||||
borderColor={focusArea === 'content' ? colors.focus : colors.primary}
|
|
||||||
flexDirection="column"
|
|
||||||
paddingX={1}
|
|
||||||
paddingY={1}
|
|
||||||
marginTop={1}
|
|
||||||
marginX={1}
|
|
||||||
flexGrow={1}
|
|
||||||
>
|
|
||||||
<Text color={colors.primary} bold>
|
|
||||||
{' '}{currentStepData?.name} ({currentStep + 1}/{steps.length}){' '}
|
|
||||||
</Text>
|
|
||||||
<Box marginTop={1}>
|
|
||||||
{isProcessing ? (
|
|
||||||
<Text color={colors.info}>Processing...</Text>
|
|
||||||
) : (
|
|
||||||
renderStepContent()
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{/* Buttons */}
|
|
||||||
<Box marginTop={1} marginX={1} justifyContent="space-between">
|
|
||||||
<Box gap={1}>
|
|
||||||
<Button
|
|
||||||
label="Back"
|
|
||||||
focused={focusArea === 'buttons' && focusedButton === 'back'}
|
|
||||||
disabled={currentStepData?.type === 'publish'}
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
label="Cancel"
|
|
||||||
focused={focusArea === 'buttons' && focusedButton === 'cancel'}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
<Button
|
|
||||||
label={currentStepData?.type === 'publish' ? 'Done' : 'Next'}
|
|
||||||
focused={focusArea === 'buttons' && focusedButton === 'next'}
|
|
||||||
disabled={isProcessing}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{/* Help text */}
|
|
||||||
<Box marginTop={1} marginX={1}>
|
|
||||||
<Text color={colors.textMuted} dimColor>
|
|
||||||
Tab: Navigate • Enter: Select • Esc: Back
|
|
||||||
{currentStepData?.type === 'publish' ? ' • c: Copy ID' : ''}
|
|
||||||
</Text>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -7,8 +7,7 @@
|
|||||||
import React, { useState, useCallback } from 'react';
|
import React, { useState, useCallback } from 'react';
|
||||||
import { Box, Text, useInput } from 'ink';
|
import { Box, Text, useInput } from 'ink';
|
||||||
import TextInput from 'ink-text-input';
|
import TextInput from 'ink-text-input';
|
||||||
import { Screen } from '../components/Screen.js';
|
import { Button } from '../components/Button.js';
|
||||||
import { Button, ButtonRow } from '../components/Button.js';
|
|
||||||
import { useNavigation } from '../hooks/useNavigation.js';
|
import { useNavigation } from '../hooks/useNavigation.js';
|
||||||
import { useAppContext, useStatus } from '../hooks/useAppContext.js';
|
import { useAppContext, useStatus } from '../hooks/useAppContext.js';
|
||||||
import { colors, logo } from '../theme.js';
|
import { colors, logo } from '../theme.js';
|
||||||
@@ -24,7 +23,7 @@ type StatusType = 'idle' | 'loading' | 'error' | 'success';
|
|||||||
*/
|
*/
|
||||||
export function SeedInputScreen(): React.ReactElement {
|
export function SeedInputScreen(): React.ReactElement {
|
||||||
const { navigate } = useNavigation();
|
const { navigate } = useNavigation();
|
||||||
const { walletController, showError, setWalletInitialized } = useAppContext();
|
const { initializeWallet } = useAppContext();
|
||||||
const { setStatus } = useStatus();
|
const { setStatus } = useStatus();
|
||||||
|
|
||||||
// State
|
// State
|
||||||
@@ -66,12 +65,11 @@ export function SeedInputScreen(): React.ReactElement {
|
|||||||
setIsSubmitting(true);
|
setIsSubmitting(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Initialize wallet via controller
|
// Initialize wallet and create AppService
|
||||||
await walletController.initialize(seed);
|
await initializeWallet(seed);
|
||||||
|
|
||||||
showStatus('Wallet initialized successfully!', 'success');
|
showStatus('Wallet initialized successfully!', 'success');
|
||||||
setStatus('Wallet ready');
|
setStatus('Wallet ready');
|
||||||
setWalletInitialized(true);
|
|
||||||
|
|
||||||
// Clear sensitive data before navigating
|
// Clear sensitive data before navigating
|
||||||
setSeedPhrase('');
|
setSeedPhrase('');
|
||||||
@@ -87,7 +85,7 @@ export function SeedInputScreen(): React.ReactElement {
|
|||||||
setStatus('Initialization failed');
|
setStatus('Initialization failed');
|
||||||
setIsSubmitting(false);
|
setIsSubmitting(false);
|
||||||
}
|
}
|
||||||
}, [seedPhrase, walletController, navigate, showStatus, setStatus, setWalletInitialized]);
|
}, [seedPhrase, initializeWallet, navigate, showStatus, setStatus]);
|
||||||
|
|
||||||
// Handle keyboard navigation
|
// Handle keyboard navigation
|
||||||
useInput((input, key) => {
|
useInput((input, key) => {
|
||||||
@@ -117,7 +115,7 @@ export function SeedInputScreen(): React.ReactElement {
|
|||||||
colors.border;
|
colors.border;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box flexDirection="column" alignItems="center" paddingY={1}>
|
<Box flexDirection='column' alignItems='center' paddingY={1}>
|
||||||
{/* Logo */}
|
{/* Logo */}
|
||||||
<Box marginBottom={1}>
|
<Box marginBottom={1}>
|
||||||
<Text color={colors.primary}>{logo}</Text>
|
<Text color={colors.primary}>{logo}</Text>
|
||||||
@@ -131,10 +129,10 @@ export function SeedInputScreen(): React.ReactElement {
|
|||||||
<Box marginY={1} />
|
<Box marginY={1} />
|
||||||
|
|
||||||
{/* Input section */}
|
{/* Input section */}
|
||||||
<Box flexDirection="column" width={64}>
|
<Box flexDirection='column' width={64}>
|
||||||
<Text color={colors.text} bold>Seed Phrase (12 or 24 words):</Text>
|
<Text color={colors.text} bold>Seed Phrase (12 or 24 words):</Text>
|
||||||
<Box
|
<Box
|
||||||
borderStyle="single"
|
borderStyle='single'
|
||||||
borderColor={inputBorderColor}
|
borderColor={inputBorderColor}
|
||||||
paddingX={1}
|
paddingX={1}
|
||||||
marginTop={1}
|
marginTop={1}
|
||||||
@@ -143,7 +141,7 @@ export function SeedInputScreen(): React.ReactElement {
|
|||||||
value={seedPhrase}
|
value={seedPhrase}
|
||||||
onChange={setSeedPhrase}
|
onChange={setSeedPhrase}
|
||||||
onSubmit={handleSubmit}
|
onSubmit={handleSubmit}
|
||||||
placeholder="Enter your seed phrase..."
|
placeholder='Enter your seed phrase...'
|
||||||
focus={focusedElement === 'input' && !isSubmitting}
|
focus={focusedElement === 'input' && !isSubmitting}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
@@ -161,12 +159,12 @@ export function SeedInputScreen(): React.ReactElement {
|
|||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* Submit button */}
|
{/* Submit button */}
|
||||||
<Box justifyContent="center" marginTop={1}>
|
<Box justifyContent='center' marginTop={1}>
|
||||||
<Button
|
<Button
|
||||||
label="Continue"
|
label='Continue'
|
||||||
focused={focusedElement === 'button'}
|
focused={focusedElement === 'button'}
|
||||||
disabled={isSubmitting}
|
disabled={isSubmitting}
|
||||||
shortcut="Enter"
|
shortcut='Enter'
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -6,13 +6,26 @@
|
|||||||
* - Select a template and action to start a new transaction
|
* - Select a template and action to start a new transaction
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useState, useEffect, useCallback } from 'react';
|
import React, { useState, useEffect, useCallback, useMemo } from 'react';
|
||||||
import { Box, Text, useInput } from 'ink';
|
import { Box, Text, useInput } from 'ink';
|
||||||
import { Screen } from '../components/Screen.js';
|
import { ScrollableList, type ListItemData } from '../components/List.js';
|
||||||
import { useNavigation } from '../hooks/useNavigation.js';
|
import { useNavigation } from '../hooks/useNavigation.js';
|
||||||
import { useAppContext, useStatus } from '../hooks/useAppContext.js';
|
import { useAppContext, useStatus } from '../hooks/useAppContext.js';
|
||||||
import { colors, logoSmall } from '../theme.js';
|
import { colors, logoSmall } from '../theme.js';
|
||||||
import type { XOTemplate, XOTemplateStartingActions } from '@xo-cash/types';
|
|
||||||
|
// XO Imports
|
||||||
|
import { generateTemplateIdentifier } from '@xo-cash/engine';
|
||||||
|
import type { XOTemplate } from '@xo-cash/types';
|
||||||
|
|
||||||
|
// Import utility functions
|
||||||
|
import {
|
||||||
|
formatTemplateListItem,
|
||||||
|
formatActionListItem,
|
||||||
|
deduplicateStartingActions,
|
||||||
|
getTemplateRoles,
|
||||||
|
getRolesForAction,
|
||||||
|
type UniqueStartingAction,
|
||||||
|
} from '../../utils/template-utils.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Template item with metadata.
|
* Template item with metadata.
|
||||||
@@ -20,16 +33,26 @@ import type { XOTemplate, XOTemplateStartingActions } from '@xo-cash/types';
|
|||||||
interface TemplateItem {
|
interface TemplateItem {
|
||||||
template: XOTemplate;
|
template: XOTemplate;
|
||||||
templateIdentifier: string;
|
templateIdentifier: string;
|
||||||
startingActions: XOTemplateStartingActions;
|
startingActions: UniqueStartingAction[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Template list item with TemplateItem value.
|
||||||
|
*/
|
||||||
|
type TemplateListItem = ListItemData<TemplateItem>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Action list item with UniqueStartingAction value.
|
||||||
|
*/
|
||||||
|
type ActionListItem = ListItemData<UniqueStartingAction>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Template List Screen Component.
|
* Template List Screen Component.
|
||||||
* Displays templates and their starting actions.
|
* Displays templates and their starting actions.
|
||||||
*/
|
*/
|
||||||
export function TemplateListScreen(): React.ReactElement {
|
export function TemplateListScreen(): React.ReactElement {
|
||||||
const { navigate } = useNavigation();
|
const { navigate } = useNavigation();
|
||||||
const { walletController, showError } = useAppContext();
|
const { appService, showError } = useAppContext();
|
||||||
const { setStatus } = useStatus();
|
const { setStatus } = useStatus();
|
||||||
|
|
||||||
// State
|
// State
|
||||||
@@ -40,21 +63,33 @@ export function TemplateListScreen(): React.ReactElement {
|
|||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Loads templates from the wallet controller.
|
* Loads templates from the engine.
|
||||||
*/
|
*/
|
||||||
const loadTemplates = useCallback(async () => {
|
const loadTemplates = useCallback(async () => {
|
||||||
|
if (!appService) {
|
||||||
|
showError('AppService not initialized');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
setStatus('Loading templates...');
|
setStatus('Loading templates...');
|
||||||
|
|
||||||
const templateList = await walletController.getTemplates();
|
const templateList = await appService.engine.listImportedTemplates();
|
||||||
const { generateTemplateIdentifier } = await import('@xo-cash/engine');
|
|
||||||
|
|
||||||
const loadedTemplates = await Promise.all(
|
const loadedTemplates = await Promise.all(
|
||||||
templateList.map(async (template) => {
|
templateList.map(async (template) => {
|
||||||
const templateIdentifier = generateTemplateIdentifier(template);
|
const templateIdentifier = generateTemplateIdentifier(template);
|
||||||
const startingActions = await walletController.getStartingActions(templateIdentifier);
|
const rawStartingActions = await appService.engine.listStartingActions(templateIdentifier);
|
||||||
return { template, templateIdentifier, startingActions };
|
|
||||||
|
// Use utility function to deduplicate actions
|
||||||
|
const startingActions = deduplicateStartingActions(template, rawStartingActions);
|
||||||
|
|
||||||
|
return {
|
||||||
|
template,
|
||||||
|
templateIdentifier,
|
||||||
|
startingActions,
|
||||||
|
};
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -67,7 +102,7 @@ export function TemplateListScreen(): React.ReactElement {
|
|||||||
showError(`Failed to load templates: ${error instanceof Error ? error.message : String(error)}`);
|
showError(`Failed to load templates: ${error instanceof Error ? error.message : String(error)}`);
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
}, [walletController, setStatus, showError]);
|
}, [appService, setStatus, showError]);
|
||||||
|
|
||||||
// Load templates on mount
|
// Load templates on mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -79,22 +114,66 @@ export function TemplateListScreen(): React.ReactElement {
|
|||||||
const currentActions = currentTemplate?.startingActions ?? [];
|
const currentActions = currentTemplate?.startingActions ?? [];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handles action selection.
|
* Build template list items for ScrollableList.
|
||||||
*/
|
*/
|
||||||
const handleActionSelect = useCallback(() => {
|
const templateListItems = useMemo((): TemplateListItem[] => {
|
||||||
if (!currentTemplate || currentActions.length === 0) return;
|
return templates.map((item, index) => {
|
||||||
|
const formatted = formatTemplateListItem(item.template, index);
|
||||||
|
return {
|
||||||
|
key: item.templateIdentifier,
|
||||||
|
label: formatted.label,
|
||||||
|
description: formatted.description,
|
||||||
|
value: item,
|
||||||
|
hidden: !formatted.isValid,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}, [templates]);
|
||||||
|
|
||||||
const action = currentActions[selectedActionIndex];
|
/**
|
||||||
if (!action) return;
|
* Build action list items for ScrollableList.
|
||||||
|
*/
|
||||||
|
const actionListItems = useMemo((): ActionListItem[] => {
|
||||||
|
return currentActions.map((action, index) => {
|
||||||
|
const formatted = formatActionListItem(
|
||||||
|
action.actionIdentifier,
|
||||||
|
currentTemplate?.template?.actions?.[action.actionIdentifier],
|
||||||
|
action.roleCount,
|
||||||
|
index
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
key: action.actionIdentifier,
|
||||||
|
label: formatted.label,
|
||||||
|
description: formatted.description,
|
||||||
|
value: action,
|
||||||
|
hidden: !formatted.isValid,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}, [currentActions, currentTemplate]);
|
||||||
|
|
||||||
// Navigate to action wizard with selected template and action
|
/**
|
||||||
|
* Handle template selection change.
|
||||||
|
*/
|
||||||
|
const handleTemplateSelect = useCallback((index: number) => {
|
||||||
|
setSelectedTemplateIndex(index);
|
||||||
|
setSelectedActionIndex(0); // Reset action selection when template changes
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles action selection.
|
||||||
|
* Navigates to the Action Wizard where the user will choose their role.
|
||||||
|
*/
|
||||||
|
const handleActionActivate = useCallback((item: ActionListItem, index: number) => {
|
||||||
|
if (!currentTemplate || !item.value) return;
|
||||||
|
|
||||||
|
const action = item.value;
|
||||||
|
|
||||||
|
// Navigate to the Action Wizard — role selection happens there
|
||||||
navigate('wizard', {
|
navigate('wizard', {
|
||||||
templateIdentifier: currentTemplate.templateIdentifier,
|
templateIdentifier: currentTemplate.templateIdentifier,
|
||||||
actionIdentifier: action.action,
|
actionIdentifier: action.actionIdentifier,
|
||||||
roleIdentifier: action.role,
|
|
||||||
template: currentTemplate.template,
|
template: currentTemplate.template,
|
||||||
});
|
});
|
||||||
}, [currentTemplate, currentActions, selectedActionIndex, navigate]);
|
}, [currentTemplate, navigate]);
|
||||||
|
|
||||||
// Handle keyboard navigation
|
// Handle keyboard navigation
|
||||||
useInput((input, key) => {
|
useInput((input, key) => {
|
||||||
@@ -103,30 +182,46 @@ export function TemplateListScreen(): React.ReactElement {
|
|||||||
setFocusedPanel(prev => prev === 'templates' ? 'actions' : 'templates');
|
setFocusedPanel(prev => prev === 'templates' ? 'actions' : 'templates');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Up/Down navigation
|
|
||||||
if (key.upArrow || input === 'k') {
|
|
||||||
if (focusedPanel === 'templates') {
|
|
||||||
setSelectedTemplateIndex(prev => Math.max(0, prev - 1));
|
|
||||||
setSelectedActionIndex(0); // Reset action selection when template changes
|
|
||||||
} else {
|
|
||||||
setSelectedActionIndex(prev => Math.max(0, prev - 1));
|
|
||||||
}
|
|
||||||
} else if (key.downArrow || input === 'j') {
|
|
||||||
if (focusedPanel === 'templates') {
|
|
||||||
setSelectedTemplateIndex(prev => Math.min(templates.length - 1, prev + 1));
|
|
||||||
setSelectedActionIndex(0); // Reset action selection when template changes
|
|
||||||
} else {
|
|
||||||
setSelectedActionIndex(prev => Math.min(currentActions.length - 1, prev + 1));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Enter to select action
|
|
||||||
if (key.return && focusedPanel === 'actions') {
|
|
||||||
handleActionSelect();
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render custom template list item.
|
||||||
|
*/
|
||||||
|
const renderTemplateItem = useCallback((
|
||||||
|
item: TemplateListItem,
|
||||||
|
isSelected: boolean,
|
||||||
|
isFocused: boolean
|
||||||
|
): React.ReactNode => {
|
||||||
|
return (
|
||||||
|
<Text
|
||||||
|
color={isFocused ? colors.focus : colors.text}
|
||||||
|
bold={isSelected}
|
||||||
|
>
|
||||||
|
{isFocused ? '▸ ' : ' '}
|
||||||
|
{item.label}
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render custom action list item.
|
||||||
|
*/
|
||||||
|
const renderActionItem = useCallback((
|
||||||
|
item: ActionListItem,
|
||||||
|
isSelected: boolean,
|
||||||
|
isFocused: boolean
|
||||||
|
): React.ReactNode => {
|
||||||
|
return (
|
||||||
|
<Text
|
||||||
|
color={isFocused ? colors.focus : colors.text}
|
||||||
|
bold={isSelected}
|
||||||
|
>
|
||||||
|
{isFocused ? '▸ ' : ' '}
|
||||||
|
{item.label}
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box flexDirection="column" flexGrow={1}>
|
<Box flexDirection="column" flexGrow={1}>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
@@ -146,24 +241,20 @@ export function TemplateListScreen(): React.ReactElement {
|
|||||||
flexGrow={1}
|
flexGrow={1}
|
||||||
>
|
>
|
||||||
<Text color={colors.primary} bold> Templates </Text>
|
<Text color={colors.primary} bold> Templates </Text>
|
||||||
<Box marginTop={1} flexDirection="column">
|
{isLoading ? (
|
||||||
{isLoading ? (
|
<Box marginTop={1}>
|
||||||
<Text color={colors.textMuted}>Loading...</Text>
|
<Text color={colors.textMuted}>Loading...</Text>
|
||||||
) : templates.length === 0 ? (
|
</Box>
|
||||||
<Text color={colors.textMuted}>No templates imported</Text>
|
) : (
|
||||||
) : (
|
<ScrollableList
|
||||||
templates.map((item, index) => (
|
items={templateListItems}
|
||||||
<Text
|
selectedIndex={selectedTemplateIndex}
|
||||||
key={item.templateIdentifier}
|
onSelect={handleTemplateSelect}
|
||||||
color={index === selectedTemplateIndex ? colors.focus : colors.text}
|
focus={focusedPanel === 'templates'}
|
||||||
bold={index === selectedTemplateIndex}
|
emptyMessage="No templates imported"
|
||||||
>
|
renderItem={renderTemplateItem}
|
||||||
{index === selectedTemplateIndex && focusedPanel === 'templates' ? '▸ ' : ' '}
|
/>
|
||||||
{index + 1}. {item.template.name || 'Unnamed Template'}
|
)}
|
||||||
</Text>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
@@ -177,28 +268,25 @@ export function TemplateListScreen(): React.ReactElement {
|
|||||||
flexGrow={1}
|
flexGrow={1}
|
||||||
>
|
>
|
||||||
<Text color={colors.primary} bold> Starting Actions </Text>
|
<Text color={colors.primary} bold> Starting Actions </Text>
|
||||||
<Box marginTop={1} flexDirection="column">
|
{isLoading ? (
|
||||||
{!currentTemplate ? (
|
<Box marginTop={1}>
|
||||||
|
<Text color={colors.textMuted}>Loading...</Text>
|
||||||
|
</Box>
|
||||||
|
) : !currentTemplate ? (
|
||||||
|
<Box marginTop={1}>
|
||||||
<Text color={colors.textMuted}>Select a template...</Text>
|
<Text color={colors.textMuted}>Select a template...</Text>
|
||||||
) : currentActions.length === 0 ? (
|
</Box>
|
||||||
<Text color={colors.textMuted}>No starting actions available</Text>
|
) : (
|
||||||
) : (
|
<ScrollableList
|
||||||
currentActions.map((action, index) => {
|
items={actionListItems}
|
||||||
const actionDef = currentTemplate.template.actions?.[action.action];
|
selectedIndex={selectedActionIndex}
|
||||||
const name = actionDef?.name || action.action;
|
onSelect={setSelectedActionIndex}
|
||||||
return (
|
onActivate={handleActionActivate}
|
||||||
<Text
|
focus={focusedPanel === 'actions'}
|
||||||
key={`${action.action}-${action.role}`}
|
emptyMessage="No starting actions available"
|
||||||
color={index === selectedActionIndex ? colors.focus : colors.text}
|
renderItem={renderActionItem}
|
||||||
bold={index === selectedActionIndex}
|
/>
|
||||||
>
|
)}
|
||||||
{index === selectedActionIndex && focusedPanel === 'actions' ? '▸ ' : ' '}
|
|
||||||
{index + 1}. {name} (as {action.role})
|
|
||||||
</Text>
|
|
||||||
);
|
|
||||||
})
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
@@ -214,7 +302,9 @@ export function TemplateListScreen(): React.ReactElement {
|
|||||||
width="100%"
|
width="100%"
|
||||||
>
|
>
|
||||||
<Text color={colors.primary} bold> Description </Text>
|
<Text color={colors.primary} bold> Description </Text>
|
||||||
{currentTemplate ? (
|
|
||||||
|
{/* Show template description when templates panel is focused */}
|
||||||
|
{focusedPanel === 'templates' && currentTemplate ? (
|
||||||
<Box marginTop={1} flexDirection="column">
|
<Box marginTop={1} flexDirection="column">
|
||||||
<Text color={colors.text} bold>
|
<Text color={colors.text} bold>
|
||||||
{currentTemplate.template.name || 'Unnamed Template'}
|
{currentTemplate.template.name || 'Unnamed Template'}
|
||||||
@@ -230,17 +320,58 @@ export function TemplateListScreen(): React.ReactElement {
|
|||||||
{currentTemplate.template.roles && (
|
{currentTemplate.template.roles && (
|
||||||
<Box marginTop={1} flexDirection="column">
|
<Box marginTop={1} flexDirection="column">
|
||||||
<Text color={colors.text}>Roles:</Text>
|
<Text color={colors.text}>Roles:</Text>
|
||||||
{Object.entries(currentTemplate.template.roles).map(([roleId, role]) => (
|
{getTemplateRoles(currentTemplate.template).map((role) => (
|
||||||
<Text key={roleId} color={colors.textMuted}>
|
<Text key={role.roleId} color={colors.textMuted}>
|
||||||
{' '}- {role.name || roleId}: {role.description || 'No description'}
|
{' '}- {role.name}: {role.description || 'No description'}
|
||||||
</Text>
|
</Text>
|
||||||
))}
|
))}
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
) : (
|
) : focusedPanel === 'templates' && !currentTemplate ? (
|
||||||
<Text color={colors.textMuted}>Select a template to see details</Text>
|
<Text color={colors.textMuted}>Select a template to see details</Text>
|
||||||
)}
|
) : null}
|
||||||
|
|
||||||
|
{/* Show action description when actions panel is focused */}
|
||||||
|
{focusedPanel === 'actions' && currentTemplate && currentActions.length > 0 ? (
|
||||||
|
<Box marginTop={1} flexDirection="column">
|
||||||
|
{(() => {
|
||||||
|
const action = currentActions[selectedActionIndex];
|
||||||
|
if (!action) return null;
|
||||||
|
|
||||||
|
// Get roles that can start this action using utility function
|
||||||
|
const availableRoles = getRolesForAction(currentTemplate.template, action.actionIdentifier);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Text color={colors.text} bold>
|
||||||
|
{action.name}
|
||||||
|
</Text>
|
||||||
|
<Text color={colors.textMuted}>
|
||||||
|
{action.description || 'No description available'}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{/* List available roles for this action */}
|
||||||
|
{availableRoles.length > 0 && (
|
||||||
|
<Box marginTop={1} flexDirection="column">
|
||||||
|
<Text color={colors.text}>Available Roles:</Text>
|
||||||
|
{availableRoles.map((role) => (
|
||||||
|
<Text key={role.roleId} color={colors.textMuted}>
|
||||||
|
{' '}- {role.name}
|
||||||
|
{role.description ? `: ${role.description}` : ''}
|
||||||
|
</Text>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
</Box>
|
||||||
|
) : focusedPanel === 'actions' && !currentTemplate ? (
|
||||||
|
<Text color={colors.textMuted}>Select a template first</Text>
|
||||||
|
) : focusedPanel === 'actions' && currentActions.length === 0 ? (
|
||||||
|
<Text color={colors.textMuted}>No starting actions available</Text>
|
||||||
|
) : null}
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { Box, Text, useInput } from 'ink';
|
|||||||
import { ConfirmDialog } from '../components/Dialog.js';
|
import { ConfirmDialog } from '../components/Dialog.js';
|
||||||
import { useNavigation } from '../hooks/useNavigation.js';
|
import { useNavigation } from '../hooks/useNavigation.js';
|
||||||
import { useAppContext, useStatus } from '../hooks/useAppContext.js';
|
import { useAppContext, useStatus } from '../hooks/useAppContext.js';
|
||||||
|
import { useInvitation } from '../hooks/useInvitations.js';
|
||||||
import { colors, logoSmall, formatSatoshis, formatHex } from '../theme.js';
|
import { colors, logoSmall, formatSatoshis, formatHex } from '../theme.js';
|
||||||
import { copyToClipboard } from '../utils/clipboard.js';
|
import { copyToClipboard } from '../utils/clipboard.js';
|
||||||
import type { XOInvitation } from '@xo-cash/types';
|
import type { XOInvitation } from '@xo-cash/types';
|
||||||
@@ -32,59 +33,51 @@ const actionItems = [
|
|||||||
*/
|
*/
|
||||||
export function TransactionScreen(): React.ReactElement {
|
export function TransactionScreen(): React.ReactElement {
|
||||||
const { navigate, goBack, data: navData } = useNavigation();
|
const { navigate, goBack, data: navData } = useNavigation();
|
||||||
const { invitationController, showError, showInfo, confirm } = useAppContext();
|
const { showError, showInfo } = useAppContext();
|
||||||
const { setStatus } = useStatus();
|
const { setStatus } = useStatus();
|
||||||
|
|
||||||
// Extract invitation ID from navigation data
|
// Extract invitation ID from navigation data
|
||||||
const invitationId = navData.invitationId as string | undefined;
|
const invitationId = navData.invitationId as string | undefined;
|
||||||
|
|
||||||
|
// Use hook to get invitation reactively
|
||||||
|
const invitationInstance = useInvitation(invitationId ?? null);
|
||||||
|
|
||||||
// State
|
// State
|
||||||
const [invitation, setInvitation] = useState<XOInvitation | null>(null);
|
|
||||||
const [focusedPanel, setFocusedPanel] = useState<'inputs' | 'outputs' | 'actions'>('actions');
|
const [focusedPanel, setFocusedPanel] = useState<'inputs' | 'outputs' | 'actions'>('actions');
|
||||||
const [selectedActionIndex, setSelectedActionIndex] = useState(0);
|
const [selectedActionIndex, setSelectedActionIndex] = useState(0);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [showBroadcastConfirm, setShowBroadcastConfirm] = useState(false);
|
const [showBroadcastConfirm, setShowBroadcastConfirm] = useState(false);
|
||||||
|
|
||||||
/**
|
// Check if invitation exists
|
||||||
* Load invitation data.
|
useEffect(() => {
|
||||||
*/
|
|
||||||
const loadInvitation = useCallback(() => {
|
|
||||||
if (!invitationId) {
|
if (!invitationId) {
|
||||||
showError('No invitation ID provided');
|
showError('No invitation ID provided');
|
||||||
goBack();
|
goBack();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const tracked = invitationController.getInvitation(invitationId);
|
if (invitationId && !invitationInstance) {
|
||||||
if (!tracked) {
|
|
||||||
showError('Invitation not found');
|
showError('Invitation not found');
|
||||||
goBack();
|
goBack();
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
}, [invitationId, invitationInstance, showError, goBack]);
|
||||||
|
|
||||||
setInvitation(tracked.invitation);
|
const invitation = invitationInstance?.data ?? null;
|
||||||
}, [invitationId, invitationController, showError, goBack]);
|
|
||||||
|
|
||||||
// Load on mount
|
|
||||||
useEffect(() => {
|
|
||||||
loadInvitation();
|
|
||||||
}, [loadInvitation]);
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Broadcast transaction.
|
* Broadcast transaction.
|
||||||
*/
|
*/
|
||||||
const broadcastTransaction = useCallback(async () => {
|
const broadcastTransaction = useCallback(async () => {
|
||||||
if (!invitationId) return;
|
if (!invitationInstance) return;
|
||||||
|
|
||||||
setShowBroadcastConfirm(false);
|
setShowBroadcastConfirm(false);
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
setStatus('Broadcasting transaction...');
|
setStatus('Broadcasting transaction...');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const txHash = await invitationController.broadcastTransaction(invitationId);
|
await invitationInstance.broadcast();
|
||||||
showInfo(
|
showInfo(
|
||||||
`Transaction Broadcast Successful!\n\n` +
|
`Transaction Broadcast Successful!\n\n` +
|
||||||
`Transaction Hash:\n${txHash}\n\n` +
|
|
||||||
`The transaction has been submitted to the network.`
|
`The transaction has been submitted to the network.`
|
||||||
);
|
);
|
||||||
navigate('wallet');
|
navigate('wallet');
|
||||||
@@ -94,20 +87,19 @@ export function TransactionScreen(): React.ReactElement {
|
|||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
setStatus('Ready');
|
setStatus('Ready');
|
||||||
}
|
}
|
||||||
}, [invitationId, invitationController, showInfo, showError, navigate, setStatus]);
|
}, [invitationInstance, showInfo, showError, navigate, setStatus]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sign transaction.
|
* Sign transaction.
|
||||||
*/
|
*/
|
||||||
const signTransaction = useCallback(async () => {
|
const signTransaction = useCallback(async () => {
|
||||||
if (!invitationId) return;
|
if (!invitationInstance) return;
|
||||||
|
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
setStatus('Signing transaction...');
|
setStatus('Signing transaction...');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await invitationController.signInvitation(invitationId);
|
await invitationInstance.sign();
|
||||||
loadInvitation();
|
|
||||||
showInfo('Transaction signed successfully!');
|
showInfo('Transaction signed successfully!');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showError(`Failed to sign: ${error instanceof Error ? error.message : String(error)}`);
|
showError(`Failed to sign: ${error instanceof Error ? error.message : String(error)}`);
|
||||||
@@ -115,7 +107,7 @@ export function TransactionScreen(): React.ReactElement {
|
|||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
setStatus('Ready');
|
setStatus('Ready');
|
||||||
}
|
}
|
||||||
}, [invitationId, invitationController, loadInvitation, showInfo, showError, setStatus]);
|
}, [invitationInstance, showInfo, showError, setStatus]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Copy transaction hex.
|
* Copy transaction hex.
|
||||||
@@ -273,24 +265,24 @@ export function TransactionScreen(): React.ReactElement {
|
|||||||
const hasUnresolvedInputs = inputs.length > 0; // Input values are always unknown from commit data
|
const hasUnresolvedInputs = inputs.length > 0; // Input values are always unknown from commit data
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box flexDirection="column" flexGrow={1}>
|
<Box flexDirection='column' flexGrow={1}>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<Box borderStyle="single" borderColor={colors.secondary} paddingX={1}>
|
<Box borderStyle='single' borderColor={colors.secondary} paddingX={1}>
|
||||||
<Text color={colors.primary} bold>{logoSmall} - Transaction Review</Text>
|
<Text color={colors.primary} bold>{logoSmall} - Transaction Review</Text>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* Summary box */}
|
{/* Summary box */}
|
||||||
<Box
|
<Box
|
||||||
borderStyle="single"
|
borderStyle='single'
|
||||||
borderColor={colors.primary}
|
borderColor={colors.primary}
|
||||||
marginTop={1}
|
marginTop={1}
|
||||||
marginX={1}
|
marginX={1}
|
||||||
paddingX={1}
|
paddingX={1}
|
||||||
flexDirection="column"
|
flexDirection='column'
|
||||||
>
|
>
|
||||||
<Text color={colors.primary} bold> Transaction Summary </Text>
|
<Text color={colors.primary} bold> Transaction Summary </Text>
|
||||||
{invitation ? (
|
{invitation ? (
|
||||||
<Box flexDirection="column" marginTop={1}>
|
<Box flexDirection='column' marginTop={1}>
|
||||||
<Text color={colors.text}>Inputs: {inputs.length} | Outputs: {resolvedOutputs.length} | Commits: {commits.length}</Text>
|
<Text color={colors.text}>Inputs: {inputs.length} | Outputs: {resolvedOutputs.length} | Commits: {commits.length}</Text>
|
||||||
{hasUnresolvedInputs && (
|
{hasUnresolvedInputs && (
|
||||||
<Text color={colors.textMuted}>Total In: (requires UTXO lookup)</Text>
|
<Text color={colors.textMuted}>Total In: (requires UTXO lookup)</Text>
|
||||||
@@ -308,22 +300,22 @@ export function TransactionScreen(): React.ReactElement {
|
|||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* Inputs and Outputs */}
|
{/* Inputs and Outputs */}
|
||||||
<Box flexDirection="row" marginTop={1} marginX={1} flexGrow={1}>
|
<Box flexDirection='row' marginTop={1} marginX={1} flexGrow={1}>
|
||||||
{/* Inputs */}
|
{/* Inputs */}
|
||||||
<Box
|
<Box
|
||||||
borderStyle="single"
|
borderStyle='single'
|
||||||
borderColor={focusedPanel === 'inputs' ? colors.focus : colors.border}
|
borderColor={focusedPanel === 'inputs' ? colors.focus : colors.border}
|
||||||
width="50%"
|
width='50%'
|
||||||
flexDirection="column"
|
flexDirection='column'
|
||||||
paddingX={1}
|
paddingX={1}
|
||||||
>
|
>
|
||||||
<Text color={colors.primary} bold> Inputs </Text>
|
<Text color={colors.primary} bold> Inputs </Text>
|
||||||
<Box flexDirection="column" marginTop={1}>
|
<Box flexDirection='column' marginTop={1}>
|
||||||
{inputs.length === 0 ? (
|
{inputs.length === 0 ? (
|
||||||
<Text color={colors.textMuted}>No inputs</Text>
|
<Text color={colors.textMuted}>No inputs</Text>
|
||||||
) : (
|
) : (
|
||||||
inputs.map((input, index) => (
|
inputs.map((input, index) => (
|
||||||
<Box key={`${input.txid}-${input.index}`} flexDirection="column" marginBottom={1}>
|
<Box key={`${input.txid}-${input.index}`} flexDirection='column' marginBottom={1}>
|
||||||
<Text color={colors.text}>
|
<Text color={colors.text}>
|
||||||
{index + 1}. {formatHex(input.txid, 12)}:{input.index}
|
{index + 1}. {formatHex(input.txid, 12)}:{input.index}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -338,20 +330,20 @@ export function TransactionScreen(): React.ReactElement {
|
|||||||
|
|
||||||
{/* Outputs */}
|
{/* Outputs */}
|
||||||
<Box
|
<Box
|
||||||
borderStyle="single"
|
borderStyle='single'
|
||||||
borderColor={focusedPanel === 'outputs' ? colors.focus : colors.border}
|
borderColor={focusedPanel === 'outputs' ? colors.focus : colors.border}
|
||||||
width="50%"
|
width='50%'
|
||||||
flexDirection="column"
|
flexDirection='column'
|
||||||
paddingX={1}
|
paddingX={1}
|
||||||
marginLeft={1}
|
marginLeft={1}
|
||||||
>
|
>
|
||||||
<Text color={colors.primary} bold> Outputs </Text>
|
<Text color={colors.primary} bold> Outputs </Text>
|
||||||
<Box flexDirection="column" marginTop={1}>
|
<Box flexDirection='column' marginTop={1}>
|
||||||
{resolvedOutputs.length === 0 ? (
|
{resolvedOutputs.length === 0 ? (
|
||||||
<Text color={colors.textMuted}>No outputs</Text>
|
<Text color={colors.textMuted}>No outputs</Text>
|
||||||
) : (
|
) : (
|
||||||
resolvedOutputs.map((output, index) => (
|
resolvedOutputs.map((output, index) => (
|
||||||
<Box key={index} flexDirection="column" marginBottom={1}>
|
<Box key={index} flexDirection='column' marginBottom={1}>
|
||||||
<Text color={colors.text}>
|
<Text color={colors.text}>
|
||||||
{index + 1}. {output.value !== undefined ? formatSatoshis(output.value) : '(pending)'}
|
{index + 1}. {output.value !== undefined ? formatSatoshis(output.value) : '(pending)'}
|
||||||
{output.outputIdentifier && (
|
{output.outputIdentifier && (
|
||||||
@@ -371,15 +363,15 @@ export function TransactionScreen(): React.ReactElement {
|
|||||||
|
|
||||||
{/* Actions */}
|
{/* Actions */}
|
||||||
<Box
|
<Box
|
||||||
borderStyle="single"
|
borderStyle='single'
|
||||||
borderColor={focusedPanel === 'actions' ? colors.focus : colors.border}
|
borderColor={focusedPanel === 'actions' ? colors.focus : colors.border}
|
||||||
marginTop={1}
|
marginTop={1}
|
||||||
marginX={1}
|
marginX={1}
|
||||||
paddingX={1}
|
paddingX={1}
|
||||||
flexDirection="column"
|
flexDirection='column'
|
||||||
>
|
>
|
||||||
<Text color={colors.primary} bold> Actions </Text>
|
<Text color={colors.primary} bold> Actions </Text>
|
||||||
<Box flexDirection="column" marginTop={1}>
|
<Box flexDirection='column' marginTop={1}>
|
||||||
{actionItems.map((item, index) => (
|
{actionItems.map((item, index) => (
|
||||||
<Text
|
<Text
|
||||||
key={item.value}
|
key={item.value}
|
||||||
@@ -403,16 +395,16 @@ export function TransactionScreen(): React.ReactElement {
|
|||||||
{/* Broadcast confirmation dialog */}
|
{/* Broadcast confirmation dialog */}
|
||||||
{showBroadcastConfirm && (
|
{showBroadcastConfirm && (
|
||||||
<Box
|
<Box
|
||||||
position="absolute"
|
position='absolute'
|
||||||
flexDirection="column"
|
flexDirection='column'
|
||||||
alignItems="center"
|
alignItems='center'
|
||||||
justifyContent="center"
|
justifyContent='center'
|
||||||
width="100%"
|
width='100%'
|
||||||
height="100%"
|
height='100%'
|
||||||
>
|
>
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
title="Broadcast Transaction"
|
title='Broadcast Transaction'
|
||||||
message="Are you sure you want to broadcast this transaction? This action cannot be undone."
|
message='Are you sure you want to broadcast this transaction? This action cannot be undone.'
|
||||||
onConfirm={broadcastTransaction}
|
onConfirm={broadcastTransaction}
|
||||||
onCancel={() => setShowBroadcastConfirm(false)}
|
onCancel={() => setShowBroadcastConfirm(false)}
|
||||||
isActive={showBroadcastConfirm}
|
isActive={showBroadcastConfirm}
|
||||||
|
|||||||
@@ -1,83 +1,109 @@
|
|||||||
/**
|
/**
|
||||||
* Wallet State Screen - Displays wallet balances and UTXOs.
|
* Wallet State Screen - Displays wallet balances and history.
|
||||||
*
|
*
|
||||||
* Shows:
|
* Shows:
|
||||||
* - Total balance
|
* - Total balance
|
||||||
* - List of unspent outputs
|
* - Wallet history (invitations, reservations)
|
||||||
* - Navigation to other actions
|
* - Navigation to other actions
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useState, useEffect, useCallback } from 'react';
|
import React, { useState, useEffect, useCallback, useMemo } from 'react';
|
||||||
import { Box, Text, useInput } from 'ink';
|
import { Box, Text, useInput } from 'ink';
|
||||||
import SelectInput from 'ink-select-input';
|
import { ScrollableList, type ListItemData } from '../components/List.js';
|
||||||
import { Screen } from '../components/Screen.js';
|
|
||||||
import { List, type ListItem } from '../components/List.js';
|
|
||||||
import { useNavigation } from '../hooks/useNavigation.js';
|
import { useNavigation } from '../hooks/useNavigation.js';
|
||||||
import { useAppContext, useStatus } from '../hooks/useAppContext.js';
|
import { useAppContext, useStatus } from '../hooks/useAppContext.js';
|
||||||
import { colors, formatSatoshis, formatHex, logoSmall } from '../theme.js';
|
import { colors, formatSatoshis, formatHex, logoSmall } from '../theme.js';
|
||||||
|
import type { HistoryItem } from '../../services/history.js';
|
||||||
|
|
||||||
|
// Import utility functions
|
||||||
|
import {
|
||||||
|
formatHistoryListItem,
|
||||||
|
getHistoryItemColorName,
|
||||||
|
formatHistoryDate,
|
||||||
|
type HistoryColorName,
|
||||||
|
} from '../../utils/history-utils.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map history color name to theme color.
|
||||||
|
*/
|
||||||
|
function getHistoryColor(colorName: HistoryColorName): string {
|
||||||
|
switch (colorName) {
|
||||||
|
case 'info':
|
||||||
|
return colors.info as string;
|
||||||
|
case 'warning':
|
||||||
|
return colors.warning as string;
|
||||||
|
case 'success':
|
||||||
|
return colors.success as string;
|
||||||
|
case 'error':
|
||||||
|
return colors.error as string;
|
||||||
|
case 'muted':
|
||||||
|
return colors.textMuted as string;
|
||||||
|
case 'text':
|
||||||
|
default:
|
||||||
|
return colors.text as string;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Menu action items.
|
* Menu action items.
|
||||||
*/
|
*/
|
||||||
const menuItems = [
|
const menuItems: ListItemData<string>[] = [
|
||||||
{ label: 'New Transaction (from template)', value: 'new-tx' },
|
{ key: 'new-tx', label: 'New Transaction (from template)', value: 'new-tx' },
|
||||||
{ label: 'Import Invitation', value: 'import' },
|
{ key: 'import', label: 'Import Invitation', value: 'import' },
|
||||||
{ label: 'View Invitations', value: 'invitations' },
|
{ key: 'invitations', label: 'View Invitations', value: 'invitations' },
|
||||||
{ label: 'Generate New Address', value: 'new-address' },
|
{ key: 'new-address', label: 'Generate New Address', value: 'new-address' },
|
||||||
{ label: 'Refresh', value: 'refresh' },
|
{ key: 'refresh', label: 'Refresh', value: 'refresh' },
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* UTXO display item.
|
* History list item with HistoryItem value.
|
||||||
*/
|
*/
|
||||||
interface UTXOItem {
|
type HistoryListItem = ListItemData<HistoryItem>;
|
||||||
key: string;
|
|
||||||
satoshis: bigint;
|
|
||||||
txid: string;
|
|
||||||
index: number;
|
|
||||||
reserved: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Wallet State Screen Component.
|
* Wallet State Screen Component.
|
||||||
* Displays wallet balance, UTXOs, and action menu.
|
* Displays wallet balance, history, and action menu.
|
||||||
*/
|
*/
|
||||||
export function WalletStateScreen(): React.ReactElement {
|
export function WalletStateScreen(): React.ReactElement {
|
||||||
const { navigate } = useNavigation();
|
const { navigate } = useNavigation();
|
||||||
const { walletController, showError, showInfo } = useAppContext();
|
const { appService, showError, showInfo } = useAppContext();
|
||||||
const { setStatus } = useStatus();
|
const { setStatus } = useStatus();
|
||||||
|
|
||||||
// State
|
// State
|
||||||
const [balance, setBalance] = useState<{ totalSatoshis: bigint; utxoCount: number } | null>(null);
|
const [balance, setBalance] = useState<{ totalSatoshis: bigint; utxoCount: number } | null>(null);
|
||||||
const [utxos, setUtxos] = useState<UTXOItem[]>([]);
|
const [history, setHistory] = useState<HistoryItem[]>([]);
|
||||||
const [focusedPanel, setFocusedPanel] = useState<'menu' | 'utxos'>('menu');
|
const [focusedPanel, setFocusedPanel] = useState<'menu' | 'history'>('menu');
|
||||||
const [selectedUtxoIndex, setSelectedUtxoIndex] = useState(0);
|
const [selectedMenuIndex, setSelectedMenuIndex] = useState(0);
|
||||||
|
const [selectedHistoryIndex, setSelectedHistoryIndex] = useState(0);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Refreshes wallet state.
|
* Refreshes wallet state.
|
||||||
*/
|
*/
|
||||||
const refresh = useCallback(async () => {
|
const refresh = useCallback(async () => {
|
||||||
|
if (!appService) {
|
||||||
|
showError('AppService not initialized');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
setStatus('Loading wallet state...');
|
setStatus('Loading wallet state...');
|
||||||
|
|
||||||
// Get balance
|
// Get UTXOs for balance calculation
|
||||||
const balanceData = await walletController.getBalance();
|
const utxoData = await appService.engine.listUnspentOutputsData();
|
||||||
|
|
||||||
|
// Calculate balance
|
||||||
|
const selectableUtxos = utxoData.filter(utxo => utxo.selectable);
|
||||||
|
const balanceData = selectableUtxos.reduce((acc, utxo) => acc + BigInt(utxo.valueSatoshis), BigInt(0));
|
||||||
setBalance({
|
setBalance({
|
||||||
totalSatoshis: balanceData.totalSatoshis,
|
totalSatoshis: balanceData,
|
||||||
utxoCount: balanceData.utxoCount,
|
utxoCount: selectableUtxos.length,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Get UTXOs
|
// Get wallet history from the history service
|
||||||
const utxoData = await walletController.getUnspentOutputs();
|
const historyData = await appService.history.getHistory();
|
||||||
setUtxos(utxoData.map((utxo) => ({
|
setHistory(historyData);
|
||||||
key: `${utxo.outpointTransactionHash}:${utxo.outpointIndex}`,
|
|
||||||
satoshis: BigInt(utxo.valueSatoshis),
|
|
||||||
txid: utxo.outpointTransactionHash,
|
|
||||||
index: utxo.outpointIndex,
|
|
||||||
reserved: utxo.reserved ?? false,
|
|
||||||
})));
|
|
||||||
|
|
||||||
setStatus('Wallet ready');
|
setStatus('Wallet ready');
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
@@ -85,7 +111,7 @@ export function WalletStateScreen(): React.ReactElement {
|
|||||||
showError(`Failed to load wallet state: ${error instanceof Error ? error.message : String(error)}`);
|
showError(`Failed to load wallet state: ${error instanceof Error ? error.message : String(error)}`);
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
}, [walletController, setStatus, showError]);
|
}, [appService, setStatus, showError]);
|
||||||
|
|
||||||
// Load wallet state on mount
|
// Load wallet state on mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -96,11 +122,16 @@ export function WalletStateScreen(): React.ReactElement {
|
|||||||
* Generates a new receiving address.
|
* Generates a new receiving address.
|
||||||
*/
|
*/
|
||||||
const generateNewAddress = useCallback(async () => {
|
const generateNewAddress = useCallback(async () => {
|
||||||
|
if (!appService) {
|
||||||
|
showError('AppService not initialized');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setStatus('Generating new address...');
|
setStatus('Generating new address...');
|
||||||
|
|
||||||
// Get the default P2PKH template
|
// Get the default P2PKH template
|
||||||
const templates = await walletController.getTemplates();
|
const templates = await appService.engine.listImportedTemplates();
|
||||||
const p2pkhTemplate = templates.find(t => t.name?.includes('P2PKH'));
|
const p2pkhTemplate = templates.find(t => t.name?.includes('P2PKH'));
|
||||||
|
|
||||||
if (!p2pkhTemplate) {
|
if (!p2pkhTemplate) {
|
||||||
@@ -112,7 +143,7 @@ export function WalletStateScreen(): React.ReactElement {
|
|||||||
const { generateTemplateIdentifier } = await import('@xo-cash/engine');
|
const { generateTemplateIdentifier } = await import('@xo-cash/engine');
|
||||||
const templateId = generateTemplateIdentifier(p2pkhTemplate);
|
const templateId = generateTemplateIdentifier(p2pkhTemplate);
|
||||||
|
|
||||||
const lockingBytecode = await walletController.generateLockingBytecode(
|
const lockingBytecode = await appService.engine.generateLockingBytecode(
|
||||||
templateId,
|
templateId,
|
||||||
'receiveOutput',
|
'receiveOutput',
|
||||||
'receiver',
|
'receiver',
|
||||||
@@ -125,13 +156,13 @@ export function WalletStateScreen(): React.ReactElement {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
showError(`Failed to generate address: ${error instanceof Error ? error.message : String(error)}`);
|
showError(`Failed to generate address: ${error instanceof Error ? error.message : String(error)}`);
|
||||||
}
|
}
|
||||||
}, [walletController, setStatus, showInfo, showError, refresh]);
|
}, [appService, setStatus, showInfo, showError, refresh]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handles menu selection.
|
* Handles menu action.
|
||||||
*/
|
*/
|
||||||
const handleMenuSelect = useCallback((item: { value: string }) => {
|
const handleMenuAction = useCallback((action: string) => {
|
||||||
switch (item.value) {
|
switch (action) {
|
||||||
case 'new-tx':
|
case 'new-tx':
|
||||||
navigate('templates');
|
navigate('templates');
|
||||||
break;
|
break;
|
||||||
@@ -150,19 +181,106 @@ export function WalletStateScreen(): React.ReactElement {
|
|||||||
}
|
}
|
||||||
}, [navigate, generateNewAddress, refresh]);
|
}, [navigate, generateNewAddress, refresh]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle menu item activation.
|
||||||
|
*/
|
||||||
|
const handleMenuItemActivate = useCallback((item: ListItemData<string>, index: number) => {
|
||||||
|
if (item.value) {
|
||||||
|
handleMenuAction(item.value);
|
||||||
|
}
|
||||||
|
}, [handleMenuAction]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build history list items for ScrollableList.
|
||||||
|
*/
|
||||||
|
const historyListItems = useMemo((): HistoryListItem[] => {
|
||||||
|
return history.map(item => {
|
||||||
|
const formatted = formatHistoryListItem(item, false);
|
||||||
|
return {
|
||||||
|
key: item.id,
|
||||||
|
label: formatted.label,
|
||||||
|
description: formatted.description,
|
||||||
|
value: item,
|
||||||
|
color: formatted.color,
|
||||||
|
hidden: !formatted.isValid,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}, [history]);
|
||||||
|
|
||||||
// Handle keyboard navigation between panels
|
// Handle keyboard navigation between panels
|
||||||
useInput((input, key) => {
|
useInput((input, key) => {
|
||||||
if (key.tab) {
|
if (key.tab) {
|
||||||
setFocusedPanel(prev => prev === 'menu' ? 'utxos' : 'menu');
|
setFocusedPanel(prev => prev === 'menu' ? 'history' : 'menu');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Convert UTXOs to list items
|
/**
|
||||||
const utxoListItems: ListItem[] = utxos.map((utxo, index) => ({
|
* Render custom history list item.
|
||||||
key: utxo.key,
|
*/
|
||||||
label: `${formatSatoshis(utxo.satoshis)} | ${formatHex(utxo.txid, 16)}:${utxo.index}`,
|
const renderHistoryItem = useCallback((
|
||||||
description: utxo.reserved ? '[Reserved]' : undefined,
|
item: HistoryListItem,
|
||||||
}));
|
isSelected: boolean,
|
||||||
|
isFocused: boolean
|
||||||
|
): React.ReactNode => {
|
||||||
|
const historyItem = item.value;
|
||||||
|
if (!historyItem) return null;
|
||||||
|
|
||||||
|
const colorName = getHistoryItemColorName(historyItem.type, isFocused);
|
||||||
|
const itemColor = isFocused ? colors.focus : getHistoryColor(colorName);
|
||||||
|
const dateStr = formatHistoryDate(historyItem.timestamp);
|
||||||
|
const indicator = isFocused ? '▸ ' : ' ';
|
||||||
|
|
||||||
|
// Format based on type
|
||||||
|
if (historyItem.type === 'invitation_created') {
|
||||||
|
return (
|
||||||
|
<Box flexDirection="row" justifyContent="space-between">
|
||||||
|
<Text color={itemColor}>
|
||||||
|
{indicator}[Invitation] {historyItem.description}
|
||||||
|
</Text>
|
||||||
|
{dateStr && <Text color={colors.textMuted}>{dateStr}</Text>}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
} else if (historyItem.type === 'utxo_reserved') {
|
||||||
|
const sats = historyItem.valueSatoshis ?? 0n;
|
||||||
|
return (
|
||||||
|
<Box flexDirection="row" justifyContent="space-between">
|
||||||
|
<Box>
|
||||||
|
<Text color={itemColor}>
|
||||||
|
{indicator}[Reserved] {formatSatoshis(sats)}
|
||||||
|
</Text>
|
||||||
|
<Text color={colors.textMuted}> {historyItem.description}</Text>
|
||||||
|
</Box>
|
||||||
|
{dateStr && <Text color={colors.textMuted}>{dateStr}</Text>}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
} else if (historyItem.type === 'utxo_received') {
|
||||||
|
const sats = historyItem.valueSatoshis ?? 0n;
|
||||||
|
const reservedTag = historyItem.reserved ? ' [Reserved]' : '';
|
||||||
|
return (
|
||||||
|
<Box flexDirection="row" justifyContent="space-between">
|
||||||
|
<Box flexDirection="row">
|
||||||
|
<Text color={itemColor}>
|
||||||
|
{indicator}{formatSatoshis(sats)}
|
||||||
|
</Text>
|
||||||
|
<Text color={colors.textMuted}>
|
||||||
|
{' '}{historyItem.description}{reservedTag}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
{dateStr && <Text color={colors.textMuted}>{dateStr}</Text>}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback for other types
|
||||||
|
return (
|
||||||
|
<Box flexDirection="row" justifyContent="space-between">
|
||||||
|
<Text color={itemColor}>
|
||||||
|
{indicator}{historyItem.type}: {historyItem.description}
|
||||||
|
</Text>
|
||||||
|
{dateStr && <Text color={colors.textMuted}>{dateStr}</Text>}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box flexDirection="column" flexGrow={1}>
|
<Box flexDirection="column" flexGrow={1}>
|
||||||
@@ -218,59 +336,45 @@ export function WalletStateScreen(): React.ReactElement {
|
|||||||
paddingX={1}
|
paddingX={1}
|
||||||
>
|
>
|
||||||
<Text color={colors.primary} bold> Actions </Text>
|
<Text color={colors.primary} bold> Actions </Text>
|
||||||
<Box marginTop={1}>
|
<ScrollableList
|
||||||
<SelectInput
|
items={menuItems}
|
||||||
items={menuItems}
|
selectedIndex={selectedMenuIndex}
|
||||||
onSelect={handleMenuSelect}
|
onSelect={setSelectedMenuIndex}
|
||||||
isFocused={focusedPanel === 'menu'}
|
onActivate={handleMenuItemActivate}
|
||||||
indicatorComponent={({ isSelected }) => (
|
focus={focusedPanel === 'menu'}
|
||||||
<Text color={isSelected ? colors.focus : colors.text}>
|
emptyMessage="No actions"
|
||||||
{isSelected ? '▸ ' : ' '}
|
/>
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
itemComponent={({ isSelected, label }) => (
|
|
||||||
<Text
|
|
||||||
color={isSelected ? colors.text : colors.textMuted}
|
|
||||||
bold={isSelected}
|
|
||||||
>
|
|
||||||
{label}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* UTXO list */}
|
{/* Wallet History */}
|
||||||
<Box marginTop={1} flexGrow={1}>
|
<Box marginTop={1} flexGrow={1}>
|
||||||
<Box
|
<Box
|
||||||
borderStyle="single"
|
borderStyle="single"
|
||||||
borderColor={focusedPanel === 'utxos' ? colors.focus : colors.border}
|
borderColor={focusedPanel === 'history' ? colors.focus : colors.border}
|
||||||
flexDirection="column"
|
flexDirection="column"
|
||||||
paddingX={1}
|
paddingX={1}
|
||||||
width="100%"
|
width="100%"
|
||||||
|
height={14}
|
||||||
|
overflow="hidden"
|
||||||
>
|
>
|
||||||
<Text color={colors.primary} bold> Unspent Outputs (UTXOs) </Text>
|
<Text color={colors.primary} bold> Wallet History {history.length > 0 ? `(${selectedHistoryIndex + 1}/${history.length})` : ''}</Text>
|
||||||
<Box marginTop={1} flexDirection="column">
|
{isLoading ? (
|
||||||
{isLoading ? (
|
<Box marginTop={1}>
|
||||||
<Text color={colors.textMuted}>Loading...</Text>
|
<Text color={colors.textMuted}>Loading...</Text>
|
||||||
) : utxoListItems.length === 0 ? (
|
</Box>
|
||||||
<Text color={colors.textMuted}>No unspent outputs found</Text>
|
) : (
|
||||||
) : (
|
<ScrollableList
|
||||||
utxoListItems.map((item, index) => (
|
items={historyListItems}
|
||||||
<Box key={item.key}>
|
selectedIndex={selectedHistoryIndex}
|
||||||
<Text color={index === selectedUtxoIndex && focusedPanel === 'utxos' ? colors.focus : colors.text}>
|
onSelect={setSelectedHistoryIndex}
|
||||||
{index === selectedUtxoIndex && focusedPanel === 'utxos' ? '▸ ' : ' '}
|
focus={focusedPanel === 'history'}
|
||||||
{index + 1}. {item.label}
|
maxVisible={10}
|
||||||
</Text>
|
emptyMessage="No history found"
|
||||||
{item.description && (
|
renderItem={renderHistoryItem}
|
||||||
<Text color={colors.warning}> {item.description}</Text>
|
/>
|
||||||
)}
|
)}
|
||||||
</Box>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
|
|||||||
308
src/tui/screens/action-wizard/ActionWizardScreen.tsx
Normal file
308
src/tui/screens/action-wizard/ActionWizardScreen.tsx
Normal file
@@ -0,0 +1,308 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Box, Text, useInput } 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';
|
||||||
|
|
||||||
|
// 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';
|
||||||
|
|
||||||
|
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 }
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── Step router ────────────────────────────────────────────────
|
||||||
|
const renderStep = () => {
|
||||||
|
if (wizard.isProcessing) {
|
||||||
|
return <Text color={colors.info}>Processing...</Text>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
template={wizard.template!}
|
||||||
|
actionIdentifier={wizard.actionIdentifier!}
|
||||||
|
availableRoles={wizard.availableRoles}
|
||||||
|
selectedRoleIndex={wizard.selectedRoleIndex}
|
||||||
|
focusArea={wizard.focusArea}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
case 'variables':
|
||||||
|
return (
|
||||||
|
<VariablesStep
|
||||||
|
variables={wizard.variables}
|
||||||
|
updateVariable={wizard.updateVariable}
|
||||||
|
handleTextInputSubmit={wizard.handleTextInputSubmit}
|
||||||
|
focusArea={wizard.focusArea}
|
||||||
|
focusedInput={wizard.focusedInput}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
case 'inputs':
|
||||||
|
return (
|
||||||
|
<InputsStep
|
||||||
|
availableUtxos={wizard.availableUtxos}
|
||||||
|
selectedUtxoIndex={wizard.selectedUtxoIndex}
|
||||||
|
requiredAmount={wizard.requiredAmount}
|
||||||
|
fee={wizard.fee}
|
||||||
|
selectedAmount={wizard.selectedAmount}
|
||||||
|
changeAmount={wizard.changeAmount}
|
||||||
|
focusArea={wizard.focusArea}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
case 'review':
|
||||||
|
return (
|
||||||
|
<ReviewStep
|
||||||
|
template={wizard.template!}
|
||||||
|
actionName={wizard.actionName}
|
||||||
|
roleIdentifier={wizard.roleIdentifier!}
|
||||||
|
variables={wizard.variables}
|
||||||
|
availableUtxos={wizard.availableUtxos}
|
||||||
|
changeAmount={wizard.changeAmount}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
case 'publish':
|
||||||
|
return <PublishStep invitationId={wizard.invitationId} />;
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Layout ─────────────────────────────────────────────────────
|
||||||
|
const stepIndicatorSteps: Step[] = wizard.steps.map((s) => ({
|
||||||
|
label: s.name,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box flexDirection="column" flexGrow={1}>
|
||||||
|
{/* Header */}
|
||||||
|
<Box
|
||||||
|
borderStyle="single"
|
||||||
|
borderColor={colors.secondary}
|
||||||
|
paddingX={1}
|
||||||
|
flexDirection="column"
|
||||||
|
>
|
||||||
|
<Text color={colors.primary} bold>
|
||||||
|
{logoSmall} - Action Wizard
|
||||||
|
</Text>
|
||||||
|
<Text color={colors.textMuted}>
|
||||||
|
{wizard.template?.name} {">"} {wizard.actionName}
|
||||||
|
{wizard.roleIdentifier ? ` (as ${wizard.roleIdentifier})` : ''}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Progress indicator */}
|
||||||
|
<Box marginTop={1} paddingX={1}>
|
||||||
|
<StepIndicator
|
||||||
|
steps={stepIndicatorSteps}
|
||||||
|
currentStep={wizard.currentStep}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Content area */}
|
||||||
|
<Box
|
||||||
|
borderStyle="single"
|
||||||
|
borderColor={
|
||||||
|
wizard.focusArea === "content" ? colors.focus : colors.primary
|
||||||
|
}
|
||||||
|
flexDirection="column"
|
||||||
|
paddingX={1}
|
||||||
|
paddingY={1}
|
||||||
|
marginTop={1}
|
||||||
|
marginX={1}
|
||||||
|
flexGrow={1}
|
||||||
|
>
|
||||||
|
<Text color={colors.primary} bold>
|
||||||
|
{" "}
|
||||||
|
{wizard.currentStepData?.name} ({wizard.currentStep + 1}/
|
||||||
|
{wizard.steps.length}){" "}
|
||||||
|
</Text>
|
||||||
|
<Box marginTop={1}>{renderStep()}</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Buttons */}
|
||||||
|
<Box marginTop={1} marginX={1} justifyContent="space-between">
|
||||||
|
<Box gap={1}>
|
||||||
|
<Button
|
||||||
|
label="Back"
|
||||||
|
focused={
|
||||||
|
wizard.focusArea === "buttons" &&
|
||||||
|
wizard.focusedButton === "back"
|
||||||
|
}
|
||||||
|
disabled={wizard.currentStepData?.type === "publish"}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
label="Cancel"
|
||||||
|
focused={
|
||||||
|
wizard.focusArea === "buttons" &&
|
||||||
|
wizard.focusedButton === "cancel"
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
<Button
|
||||||
|
label={
|
||||||
|
wizard.currentStepData?.type === "publish" ? "Done" : "Next"
|
||||||
|
}
|
||||||
|
focused={
|
||||||
|
wizard.focusArea === "buttons" &&
|
||||||
|
wizard.focusedButton === "next"
|
||||||
|
}
|
||||||
|
disabled={wizard.isProcessing}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Help text */}
|
||||||
|
<Box marginTop={1} marginX={1}>
|
||||||
|
<Text color={colors.textMuted} dimColor>
|
||||||
|
Tab: Navigate • Enter: Select • Esc: Back
|
||||||
|
{wizard.currentStepData?.type === "publish"
|
||||||
|
? " • c: Copy ID"
|
||||||
|
: ""}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
4
src/tui/screens/action-wizard/index.ts
Normal file
4
src/tui/screens/action-wizard/index.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
export * from './ActionWizardScreen.js';
|
||||||
|
export * from './useActionWizard.js';
|
||||||
|
export * from './types.js';
|
||||||
|
export * from './steps/index.js';
|
||||||
52
src/tui/screens/action-wizard/steps/InfoStep.tsx
Normal file
52
src/tui/screens/action-wizard/steps/InfoStep.tsx
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
92
src/tui/screens/action-wizard/steps/InputsStep.tsx
Normal file
92
src/tui/screens/action-wizard/steps/InputsStep.tsx
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Box, Text } from 'ink';
|
||||||
|
import { colors, formatSatoshis, formatHex } from '../../../theme.js';
|
||||||
|
import type { WizardStepProps } from '../types.js';
|
||||||
|
|
||||||
|
type Props = Pick<
|
||||||
|
WizardStepProps,
|
||||||
|
| 'availableUtxos'
|
||||||
|
| 'selectedUtxoIndex'
|
||||||
|
| 'requiredAmount'
|
||||||
|
| 'fee'
|
||||||
|
| 'selectedAmount'
|
||||||
|
| 'changeAmount'
|
||||||
|
| 'focusArea'
|
||||||
|
>;
|
||||||
|
|
||||||
|
export function InputsStep({
|
||||||
|
availableUtxos,
|
||||||
|
selectedUtxoIndex,
|
||||||
|
requiredAmount,
|
||||||
|
fee,
|
||||||
|
selectedAmount,
|
||||||
|
changeAmount,
|
||||||
|
focusArea,
|
||||||
|
}: Props): React.ReactElement {
|
||||||
|
return (
|
||||||
|
<Box flexDirection='column'>
|
||||||
|
<Text color={colors.text} bold>
|
||||||
|
Select UTXOs to fund the transaction:
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Box marginTop={1} flexDirection='column'>
|
||||||
|
<Text color={colors.textMuted}>
|
||||||
|
Required: {formatSatoshis(requiredAmount)} +{' '}
|
||||||
|
{formatSatoshis(fee)} fee
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
color={
|
||||||
|
selectedAmount >= requiredAmount + fee
|
||||||
|
? colors.success
|
||||||
|
: colors.warning
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Selected: {formatSatoshis(selectedAmount)}
|
||||||
|
</Text>
|
||||||
|
{selectedAmount > requiredAmount + fee && (
|
||||||
|
<Text color={colors.info}>
|
||||||
|
Change: {formatSatoshis(changeAmount)}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box
|
||||||
|
marginTop={1}
|
||||||
|
flexDirection='column'
|
||||||
|
borderStyle='single'
|
||||||
|
borderColor={colors.border}
|
||||||
|
paddingX={1}
|
||||||
|
>
|
||||||
|
{availableUtxos.length === 0 ? (
|
||||||
|
<Text color={colors.textMuted}>No UTXOs available</Text>
|
||||||
|
) : (
|
||||||
|
availableUtxos.map((utxo, index) => {
|
||||||
|
const isCursor =
|
||||||
|
selectedUtxoIndex === index && focusArea === 'content';
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
key={`${utxo.outpointTransactionHash}:${utxo.outpointIndex}`}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
color={isCursor ? colors.focus : colors.text}
|
||||||
|
bold={isCursor}
|
||||||
|
>
|
||||||
|
{isCursor ? '▸ ' : ' '}[{utxo.selected ? 'X' : ' '}]{' '}
|
||||||
|
{formatSatoshis(utxo.valueSatoshis)} -{' '}
|
||||||
|
{formatHex(utxo.outpointTransactionHash, 12)}:
|
||||||
|
{utxo.outpointIndex}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box marginTop={1}>
|
||||||
|
<Text color={colors.textMuted} dimColor>
|
||||||
|
Space/Enter: Toggle • a: Select all • n: Deselect all
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
45
src/tui/screens/action-wizard/steps/PublishStep.tsx
Normal file
45
src/tui/screens/action-wizard/steps/PublishStep.tsx
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Box, Text } from 'ink';
|
||||||
|
import { colors } from '../../../theme.js';
|
||||||
|
|
||||||
|
interface PublishStepProps {
|
||||||
|
invitationId: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PublishStep({
|
||||||
|
invitationId,
|
||||||
|
}: PublishStepProps): React.ReactElement {
|
||||||
|
return (
|
||||||
|
<Box flexDirection='column'>
|
||||||
|
<Text color={colors.success} bold>
|
||||||
|
✓ Invitation Created & Published!
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Box marginTop={1} flexDirection='column'>
|
||||||
|
<Text color={colors.text}>Invitation ID:</Text>
|
||||||
|
<Box
|
||||||
|
borderStyle='single'
|
||||||
|
borderColor={colors.primary}
|
||||||
|
paddingX={1}
|
||||||
|
marginTop={1}
|
||||||
|
>
|
||||||
|
<Text color={colors.accent}>
|
||||||
|
{invitationId ?? '(unknown)'}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box marginTop={1}>
|
||||||
|
<Text color={colors.textMuted}>
|
||||||
|
Share this ID with the other party to complete the transaction.
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box marginTop={1}>
|
||||||
|
<Text color={colors.warning}>
|
||||||
|
Press 'c' to copy ID to clipboard
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
93
src/tui/screens/action-wizard/steps/ReviewStep.tsx
Normal file
93
src/tui/screens/action-wizard/steps/ReviewStep.tsx
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Box, Text } from 'ink';
|
||||||
|
import { colors, formatSatoshis } from '../../../theme.js';
|
||||||
|
import type { VariableInput, SelectableUTXO } from '../types.js';
|
||||||
|
import type { XOTemplate } from '@xo-cash/types';
|
||||||
|
|
||||||
|
interface ReviewStepProps {
|
||||||
|
template: XOTemplate;
|
||||||
|
actionName: string;
|
||||||
|
roleIdentifier: string;
|
||||||
|
variables: VariableInput[];
|
||||||
|
availableUtxos: SelectableUTXO[];
|
||||||
|
changeAmount: bigint;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ReviewStep({
|
||||||
|
template,
|
||||||
|
actionName,
|
||||||
|
roleIdentifier,
|
||||||
|
variables,
|
||||||
|
availableUtxos,
|
||||||
|
changeAmount,
|
||||||
|
}: ReviewStepProps): React.ReactElement {
|
||||||
|
const selectedUtxos = availableUtxos.filter((u) => u.selected);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box flexDirection='column'>
|
||||||
|
<Text color={colors.text} bold>
|
||||||
|
Review your invitation:
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{/* Summary */}
|
||||||
|
<Box marginTop={1} flexDirection='column'>
|
||||||
|
<Text color={colors.textMuted}>Template: {template?.name}</Text>
|
||||||
|
<Text color={colors.textMuted}>Action: {actionName}</Text>
|
||||||
|
<Text color={colors.textMuted}>Role: {roleIdentifier}</Text>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Variables */}
|
||||||
|
{variables.length > 0 && (
|
||||||
|
<Box marginTop={1} flexDirection='column'>
|
||||||
|
<Text color={colors.text}>Variables:</Text>
|
||||||
|
{variables.map((v) => (
|
||||||
|
<Text key={v.id} color={colors.textMuted}>
|
||||||
|
{' '}
|
||||||
|
{v.name}: {v.value || '(empty)'}
|
||||||
|
</Text>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Inputs */}
|
||||||
|
{selectedUtxos.length > 0 && (
|
||||||
|
<Box marginTop={1} flexDirection='column'>
|
||||||
|
<Text color={colors.text}>
|
||||||
|
Inputs ({selectedUtxos.length}):
|
||||||
|
</Text>
|
||||||
|
{selectedUtxos.slice(0, 3).map((u) => (
|
||||||
|
<Text
|
||||||
|
key={`${u.outpointTransactionHash}:${u.outpointIndex}`}
|
||||||
|
color={colors.textMuted}
|
||||||
|
>
|
||||||
|
{' '}
|
||||||
|
{formatSatoshis(u.valueSatoshis)}
|
||||||
|
</Text>
|
||||||
|
))}
|
||||||
|
{selectedUtxos.length > 3 && (
|
||||||
|
<Text color={colors.textMuted}>
|
||||||
|
{' '}...and {selectedUtxos.length - 3} more
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Outputs */}
|
||||||
|
{changeAmount > 0n && (
|
||||||
|
<Box marginTop={1} flexDirection='column'>
|
||||||
|
<Text color={colors.text}>Outputs:</Text>
|
||||||
|
<Text color={colors.textMuted}>
|
||||||
|
{' '}Change: {formatSatoshis(changeAmount)}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Confirmation prompt */}
|
||||||
|
<Box marginTop={1}>
|
||||||
|
<Text color={colors.warning}>
|
||||||
|
Press Next to create and publish the invitation.
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
120
src/tui/screens/action-wizard/steps/RoleSelectStep.tsx
Normal file
120
src/tui/screens/action-wizard/steps/RoleSelectStep.tsx
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
/**
|
||||||
|
* Role Selection Step - Allows the user to choose which role they want
|
||||||
|
* to take for the selected action.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { Box, Text } from 'ink';
|
||||||
|
import { colors } from '../../../theme.js';
|
||||||
|
import type { XOTemplate } from '@xo-cash/types';
|
||||||
|
import type { FocusArea } from '../types.js';
|
||||||
|
|
||||||
|
interface RoleSelectStepProps {
|
||||||
|
/** The loaded template definition. */
|
||||||
|
template: XOTemplate;
|
||||||
|
/** The selected action identifier. */
|
||||||
|
actionIdentifier: string;
|
||||||
|
/** Role identifiers available for this action. */
|
||||||
|
availableRoles: string[];
|
||||||
|
/** The currently focused role index. */
|
||||||
|
selectedRoleIndex: number;
|
||||||
|
/** Whether the content area or button bar is focused. */
|
||||||
|
focusArea: FocusArea;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Displays the available roles for the selected action and
|
||||||
|
* lets the user navigate between them with arrow keys.
|
||||||
|
*/
|
||||||
|
export function RoleSelectStep({
|
||||||
|
template,
|
||||||
|
actionIdentifier,
|
||||||
|
availableRoles,
|
||||||
|
selectedRoleIndex,
|
||||||
|
focusArea,
|
||||||
|
}: RoleSelectStepProps): React.ReactElement {
|
||||||
|
const action = template.actions?.[actionIdentifier];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box flexDirection="column">
|
||||||
|
<Text color={colors.text} bold>
|
||||||
|
Select your role for this action:
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{/* Action info */}
|
||||||
|
{action && (
|
||||||
|
<Box marginTop={1} flexDirection="column">
|
||||||
|
<Text color={colors.textMuted}>
|
||||||
|
{action.description || 'No description available'}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Role list */}
|
||||||
|
<Box
|
||||||
|
marginTop={1}
|
||||||
|
flexDirection="column"
|
||||||
|
borderStyle="single"
|
||||||
|
borderColor={colors.border}
|
||||||
|
paddingX={1}
|
||||||
|
>
|
||||||
|
{availableRoles.length === 0 ? (
|
||||||
|
<Text color={colors.textMuted}>No roles available</Text>
|
||||||
|
) : (
|
||||||
|
availableRoles.map((roleId, index) => {
|
||||||
|
const isCursor =
|
||||||
|
selectedRoleIndex === index && focusArea === 'content';
|
||||||
|
const roleDef = template.roles?.[roleId];
|
||||||
|
const actionRole = action?.roles?.[roleId];
|
||||||
|
const requirements = actionRole?.requirements;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box key={roleId} flexDirection="column" marginY={0}>
|
||||||
|
<Text
|
||||||
|
color={isCursor ? colors.focus : colors.text}
|
||||||
|
bold={isCursor}
|
||||||
|
>
|
||||||
|
{isCursor ? '▸ ' : ' '}
|
||||||
|
{roleDef?.name || roleId}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{/* Show role description indented below the name */}
|
||||||
|
{roleDef?.description && (
|
||||||
|
<Text color={colors.textMuted}>
|
||||||
|
{' '}
|
||||||
|
{roleDef.description}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Show a brief summary of requirements */}
|
||||||
|
{requirements && (
|
||||||
|
<Box flexDirection="row" paddingLeft={4}>
|
||||||
|
{requirements.variables && requirements.variables.length > 0 && (
|
||||||
|
<Text color={colors.textMuted} dimColor>
|
||||||
|
{requirements.variables.length} variable
|
||||||
|
{requirements.variables.length !== 1 ? 's' : ''}
|
||||||
|
{' '}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
{requirements.slots && requirements.slots.min > 0 && (
|
||||||
|
<Text color={colors.textMuted} dimColor>
|
||||||
|
{requirements.slots.min} input slot
|
||||||
|
{requirements.slots.min !== 1 ? 's' : ''}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box marginTop={1}>
|
||||||
|
<Text color={colors.textMuted} dimColor>
|
||||||
|
↑↓: Navigate • Next: Confirm selection
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
49
src/tui/screens/action-wizard/steps/VariablesStep.tsx
Normal file
49
src/tui/screens/action-wizard/steps/VariablesStep.tsx
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
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';
|
||||||
|
|
||||||
|
type Props = Pick<
|
||||||
|
WizardStepProps,
|
||||||
|
| 'variables'
|
||||||
|
| 'updateVariable'
|
||||||
|
| 'handleTextInputSubmit'
|
||||||
|
| 'focusArea'
|
||||||
|
| 'focusedInput'
|
||||||
|
>;
|
||||||
|
|
||||||
|
export function VariablesStep({
|
||||||
|
variables,
|
||||||
|
updateVariable,
|
||||||
|
handleTextInputSubmit,
|
||||||
|
focusArea,
|
||||||
|
focusedInput,
|
||||||
|
}: Props): React.ReactElement {
|
||||||
|
return (
|
||||||
|
<Box flexDirection='column'>
|
||||||
|
<Text color={colors.text} bold>
|
||||||
|
Enter required values:
|
||||||
|
</Text>
|
||||||
|
<Box marginTop={1} flexDirection='column'>
|
||||||
|
{variables.map((variable, index) => (
|
||||||
|
<VariableInputField
|
||||||
|
key={variable.id}
|
||||||
|
variable={variable}
|
||||||
|
index={index}
|
||||||
|
isFocused={focusArea === 'content' && focusedInput === index}
|
||||||
|
onChange={updateVariable}
|
||||||
|
onSubmit={handleTextInputSubmit}
|
||||||
|
borderColor={colors.border as string}
|
||||||
|
focusColor={colors.primary as string}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
<Box marginTop={1}>
|
||||||
|
<Text color={colors.textMuted} dimColor>
|
||||||
|
Type your value, then press Enter to continue
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
6
src/tui/screens/action-wizard/steps/index.ts
Normal file
6
src/tui/screens/action-wizard/steps/index.ts
Normal file
@@ -0,0 +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';
|
||||||
62
src/tui/screens/action-wizard/types.ts
Normal file
62
src/tui/screens/action-wizard/types.ts
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
import type { XOTemplate } from '@xo-cash/types';
|
||||||
|
|
||||||
|
export type StepType = 'info' | 'role-select' | 'variables' | 'inputs' | 'review' | 'publish';
|
||||||
|
|
||||||
|
export interface WizardStep {
|
||||||
|
name: string;
|
||||||
|
type: StepType;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VariableInput {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
type: string;
|
||||||
|
hint?: string;
|
||||||
|
value: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SelectableUTXO {
|
||||||
|
outpointTransactionHash: string;
|
||||||
|
outpointIndex: number;
|
||||||
|
valueSatoshis: bigint;
|
||||||
|
lockingBytecode?: string;
|
||||||
|
selected: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type FocusArea = 'content' | 'buttons';
|
||||||
|
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>;
|
||||||
|
}
|
||||||
680
src/tui/screens/action-wizard/useActionWizard.ts
Normal file
680
src/tui/screens/action-wizard/useActionWizard.ts
Normal file
@@ -0,0 +1,680 @@
|
|||||||
|
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 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;
|
||||||
|
|
||||||
|
// ── 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(() => {
|
||||||
|
if (!template || !actionIdentifier) return [];
|
||||||
|
const starts = template.start ?? [];
|
||||||
|
const roleIds = starts
|
||||||
|
.filter((s) => s.action === actionIdentifier)
|
||||||
|
.map((s) => s.role);
|
||||||
|
// Deduplicate while preserving order
|
||||||
|
return [...new Set(roleIds)];
|
||||||
|
}, [template, actionIdentifier]);
|
||||||
|
|
||||||
|
// ── 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);
|
||||||
|
|
||||||
|
// ── 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';
|
||||||
|
|
||||||
|
// ── Initialization ───────────────────────────────────────────────
|
||||||
|
// Builds the wizard steps dynamically based on the selected role.
|
||||||
|
// Re-runs when roleIdentifier changes to add role-specific steps.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!template || !actionIdentifier) {
|
||||||
|
showError('Missing wizard data');
|
||||||
|
goBack();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const wizardSteps: WizardStep[] = [];
|
||||||
|
|
||||||
|
// Always start with role selection
|
||||||
|
wizardSteps.push({ name: 'Select Role', type: 'role-select' });
|
||||||
|
|
||||||
|
// Add role-specific steps only after role is selected
|
||||||
|
if (roleIdentifier) {
|
||||||
|
const act = template.actions?.[actionIdentifier];
|
||||||
|
const role = act?.roles?.[roleIdentifier];
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add inputs step if role requires slots (funding inputs)
|
||||||
|
if (requirements?.slots && requirements.slots.min > 0) {
|
||||||
|
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(roleIdentifier ? `${actionIdentifier}/${roleIdentifier}` : actionIdentifier);
|
||||||
|
}, [
|
||||||
|
template,
|
||||||
|
actionIdentifier,
|
||||||
|
roleIdentifier,
|
||||||
|
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 (roleIdentifier && currentStep === 0 && steps[0]?.type === 'role-select') {
|
||||||
|
setCurrentStep(1);
|
||||||
|
setFocusArea('content');
|
||||||
|
setFocusedInput(0);
|
||||||
|
}
|
||||||
|
}, [roleIdentifier, 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]);
|
||||||
|
|
||||||
|
// ── 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
|
||||||
|
const unspentOutputs = await invitationInstance.findSuitableResources({
|
||||||
|
templateIdentifier,
|
||||||
|
outputIdentifier: 'receiveOutput',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Map to selectable UTXOs
|
||||||
|
const utxos: SelectableUTXO[] = unspentOutputs.map((utxo: any) => ({
|
||||||
|
outpointTransactionHash: utxo.outpointTransactionHash,
|
||||||
|
outpointIndex: utxo.outpointIndex,
|
||||||
|
valueSatoshis: BigInt(utxo.valueSatoshis),
|
||||||
|
lockingBytecode: utxo.lockingBytecode
|
||||||
|
? typeof utxo.lockingBytecode === 'string'
|
||||||
|
? utxo.lockingBytecode
|
||||||
|
: Buffer.from(utxo.lockingBytecode).toString('hex')
|
||||||
|
: undefined,
|
||||||
|
selected: false,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Auto-select UTXOs greedily until the requirement is met
|
||||||
|
let accumulated = 0n;
|
||||||
|
const seenLockingBytecodes = new Set<string>();
|
||||||
|
|
||||||
|
for (const utxo of utxos) {
|
||||||
|
if (
|
||||||
|
utxo.lockingBytecode &&
|
||||||
|
seenLockingBytecodes.has(utxo.lockingBytecode)
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (utxo.lockingBytecode) {
|
||||||
|
seenLockingBytecodes.add(utxo.lockingBytecode);
|
||||||
|
}
|
||||||
|
|
||||||
|
utxo.selected = true;
|
||||||
|
accumulated += utxo.valueSatoshis;
|
||||||
|
|
||||||
|
if (accumulated >= requested + fee) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setAvailableUtxos(utxos);
|
||||||
|
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 ?? roleIdentifier;
|
||||||
|
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(xoInvitation)
|
||||||
|
|
||||||
|
// 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) {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 = transaction.outputs.map(
|
||||||
|
(output: XOTemplateTransactionOutput) => ({
|
||||||
|
outputIdentifier: output.output,
|
||||||
|
roleIdentifier: roleIdentifier,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
await invitationInstance.addOutputs(outputsToAdd);
|
||||||
|
inv = invitationInstance.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
setInvitation(inv);
|
||||||
|
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,
|
||||||
|
roleIdentifier,
|
||||||
|
template,
|
||||||
|
variables,
|
||||||
|
appService,
|
||||||
|
showError,
|
||||||
|
setStatus,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── 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);
|
||||||
|
|
||||||
|
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,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// ── Publish the invitation ──────────────────────────────────────
|
||||||
|
const publishInvitation = useCallback(async () => {
|
||||||
|
if (!invitationId || !appService) return;
|
||||||
|
|
||||||
|
setIsProcessing(true);
|
||||||
|
setStatus('Publishing invitation...');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const invitationInstance = appService.invitations.find(
|
||||||
|
(inv) => inv.data.invitationIdentifier === invitationId
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!invitationInstance) {
|
||||||
|
throw new Error('Invitation not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Already tracked and synced via SSE from createInvitation
|
||||||
|
setCurrentStep((prev) => prev + 1);
|
||||||
|
setStatus('Invitation published');
|
||||||
|
} catch (error) {
|
||||||
|
showError(
|
||||||
|
`Failed to publish: ${error instanceof Error ? error.message : String(error)}`
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setIsProcessing(false);
|
||||||
|
}
|
||||||
|
}, [invitationId, appService, showError, setStatus]);
|
||||||
|
|
||||||
|
// ── Navigate to the next step ───────────────────────────────────
|
||||||
|
const nextStep = useCallback(async () => {
|
||||||
|
if (currentStep >= steps.length - 1) return;
|
||||||
|
|
||||||
|
const stepType = currentStepData?.type;
|
||||||
|
|
||||||
|
// ── 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;
|
||||||
|
const hasSlots = requirements?.slots && requirements.slots.min > 0;
|
||||||
|
|
||||||
|
// 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') {
|
||||||
|
await addInputsAndOutputs();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Review ──────────────────────────────────────────────────
|
||||||
|
if (stepType === 'review') {
|
||||||
|
await publishInvitation();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Generic advance (e.g. publish → done) ───────────────────
|
||||||
|
setCurrentStep((prev) => prev + 1);
|
||||||
|
setFocusArea('content');
|
||||||
|
setFocusedInput(0);
|
||||||
|
}, [
|
||||||
|
currentStep,
|
||||||
|
steps,
|
||||||
|
currentStepData,
|
||||||
|
availableRoles,
|
||||||
|
selectedRoleIndex,
|
||||||
|
template,
|
||||||
|
actionIdentifier,
|
||||||
|
variables,
|
||||||
|
showError,
|
||||||
|
createInvitationWithVariables,
|
||||||
|
loadAvailableUtxos,
|
||||||
|
addInputsAndOutputs,
|
||||||
|
publishInvitation,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// ── 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,
|
||||||
|
|
||||||
|
// 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>;
|
||||||
@@ -2,9 +2,9 @@
|
|||||||
* Export all screen components.
|
* Export all screen components.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
export * from './action-wizard/index.js';
|
||||||
export { SeedInputScreen } from './SeedInput.js';
|
export { SeedInputScreen } from './SeedInput.js';
|
||||||
export { WalletStateScreen } from './WalletState.js';
|
export { WalletStateScreen } from './WalletState.js';
|
||||||
export { TemplateListScreen } from './TemplateList.js';
|
export { TemplateListScreen } from './TemplateList.js';
|
||||||
export { ActionWizardScreen } from './ActionWizard.js';
|
|
||||||
export { InvitationScreen } from './Invitation.js';
|
export { InvitationScreen } from './Invitation.js';
|
||||||
export { TransactionScreen } from './Transaction.js';
|
export { TransactionScreen } from './Transaction.js';
|
||||||
|
|||||||
@@ -2,8 +2,8 @@
|
|||||||
* Shared types for the CLI TUI.
|
* Shared types for the CLI TUI.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { WalletController } from '../controllers/wallet-controller.js';
|
import type { AppService } from '../services/app.js';
|
||||||
import type { InvitationController } from '../controllers/invitation-controller.js';
|
import type { AppConfig } from '../app.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Screen names for navigation.
|
* Screen names for navigation.
|
||||||
@@ -51,13 +51,17 @@ export interface NavigationContextType {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* App context interface - provides access to controllers and app-level functions.
|
* App context interface - provides access to AppService and app-level functions.
|
||||||
*/
|
*/
|
||||||
export interface AppContextType {
|
export interface AppContextType {
|
||||||
/** Wallet controller for wallet operations */
|
/** AppService instance (null before wallet initialization) */
|
||||||
walletController: WalletController;
|
appService: AppService | null;
|
||||||
/** Invitation controller for invitation operations */
|
/** Initialize wallet with seed phrase and create AppService */
|
||||||
invitationController: InvitationController;
|
initializeWallet: (seed: string) => Promise<void>;
|
||||||
|
/** Whether the wallet is initialized */
|
||||||
|
isWalletInitialized: boolean;
|
||||||
|
/** Application configuration */
|
||||||
|
config: AppConfig;
|
||||||
/** Show an error message dialog */
|
/** Show an error message dialog */
|
||||||
showError: (message: string) => void;
|
showError: (message: string) => void;
|
||||||
/** Show an info message dialog */
|
/** Show an info message dialog */
|
||||||
@@ -68,10 +72,6 @@ export interface AppContextType {
|
|||||||
exit: () => void;
|
exit: () => void;
|
||||||
/** Update status bar message */
|
/** Update status bar message */
|
||||||
setStatus: (message: string) => void;
|
setStatus: (message: string) => void;
|
||||||
/** Whether the wallet is initialized */
|
|
||||||
isWalletInitialized: boolean;
|
|
||||||
/** Set wallet initialized state */
|
|
||||||
setWalletInitialized: (initialized: boolean) => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
152
src/utils/event-emitter.ts
Normal file
152
src/utils/event-emitter.ts
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
// TODO: You'll probably want to use WeakRef's here.
|
||||||
|
|
||||||
|
export type EventMap = Record<string, unknown>;
|
||||||
|
|
||||||
|
type Listener<T> = (detail: T) => void;
|
||||||
|
|
||||||
|
interface ListenerEntry<T> {
|
||||||
|
listener: Listener<T>;
|
||||||
|
wrappedListener: Listener<T>;
|
||||||
|
debounceTime?: number;
|
||||||
|
once?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type OffCallback = () => void;
|
||||||
|
|
||||||
|
export class EventEmitter<T extends EventMap> {
|
||||||
|
private listeners: Map<keyof T, Set<ListenerEntry<T[keyof T]>>> = new Map();
|
||||||
|
|
||||||
|
on<K extends keyof T>(
|
||||||
|
type: K,
|
||||||
|
listener: Listener<T[K]>,
|
||||||
|
debounceMilliseconds?: number,
|
||||||
|
): OffCallback {
|
||||||
|
const wrappedListener =
|
||||||
|
debounceMilliseconds && debounceMilliseconds > 0
|
||||||
|
? this.debounce(listener, debounceMilliseconds)
|
||||||
|
: listener;
|
||||||
|
|
||||||
|
if (!this.listeners.has(type)) {
|
||||||
|
this.listeners.set(type, new Set());
|
||||||
|
}
|
||||||
|
|
||||||
|
const listenerEntry: ListenerEntry<T[K]> = {
|
||||||
|
listener,
|
||||||
|
wrappedListener,
|
||||||
|
debounceTime: debounceMilliseconds,
|
||||||
|
};
|
||||||
|
|
||||||
|
this.listeners.get(type)?.add(listenerEntry as ListenerEntry<T[keyof T]>);
|
||||||
|
|
||||||
|
// Return an "off" callback that can be called to stop listening for events.
|
||||||
|
return () => this.off(type, listener);
|
||||||
|
}
|
||||||
|
|
||||||
|
once<K extends keyof T>(
|
||||||
|
type: K,
|
||||||
|
listener: Listener<T[K]>,
|
||||||
|
debounceMilliseconds?: number,
|
||||||
|
): OffCallback {
|
||||||
|
const wrappedListener: Listener<T[K]> = (detail: T[K]) => {
|
||||||
|
this.off(type, listener);
|
||||||
|
listener(detail);
|
||||||
|
};
|
||||||
|
|
||||||
|
const debouncedListener =
|
||||||
|
debounceMilliseconds && debounceMilliseconds > 0
|
||||||
|
? this.debounce(wrappedListener, debounceMilliseconds)
|
||||||
|
: wrappedListener;
|
||||||
|
|
||||||
|
if (!this.listeners.has(type)) {
|
||||||
|
this.listeners.set(type, new Set());
|
||||||
|
}
|
||||||
|
|
||||||
|
const listenerEntry: ListenerEntry<T[K]> = {
|
||||||
|
listener,
|
||||||
|
wrappedListener: debouncedListener,
|
||||||
|
debounceTime: debounceMilliseconds,
|
||||||
|
once: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
this.listeners.get(type)?.add(listenerEntry as ListenerEntry<T[keyof T]>);
|
||||||
|
|
||||||
|
// Return an "off" callback that can be called to stop listening for events.
|
||||||
|
return () => this.off(type, listener);
|
||||||
|
}
|
||||||
|
|
||||||
|
off<K extends keyof T>(type: K, listener: Listener<T[K]>): void {
|
||||||
|
const listeners = this.listeners.get(type);
|
||||||
|
if (!listeners) return;
|
||||||
|
|
||||||
|
const listenerEntry = Array.from(listeners).find(
|
||||||
|
(entry) =>
|
||||||
|
entry.listener === listener || entry.wrappedListener === listener,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (listenerEntry) {
|
||||||
|
listeners.delete(listenerEntry);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
emit<K extends keyof T>(type: K, payload: T[K]): boolean {
|
||||||
|
const listeners = this.listeners.get(type);
|
||||||
|
if (!listeners) return false;
|
||||||
|
|
||||||
|
listeners.forEach((entry) => {
|
||||||
|
entry.wrappedListener(payload);
|
||||||
|
});
|
||||||
|
|
||||||
|
return listeners.size > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
removeAllListeners(): void {
|
||||||
|
this.listeners.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
async waitFor<K extends keyof T>(
|
||||||
|
type: K,
|
||||||
|
predicate: (payload: T[K]) => boolean,
|
||||||
|
timeoutMs?: number,
|
||||||
|
): Promise<T[K]> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
let timeoutId: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
|
||||||
|
const listener = (payload: T[K]) => {
|
||||||
|
if (predicate(payload)) {
|
||||||
|
// Clean up
|
||||||
|
this.off(type, listener);
|
||||||
|
if (timeoutId !== undefined) {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
}
|
||||||
|
resolve(payload);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Set up timeout if specified
|
||||||
|
if (timeoutMs !== undefined) {
|
||||||
|
timeoutId = setTimeout(() => {
|
||||||
|
this.off(type, listener);
|
||||||
|
reject(new Error(`Timeout waiting for event "${String(type)}"`));
|
||||||
|
}, timeoutMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.on(type, listener);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private debounce<K extends keyof T>(
|
||||||
|
func: Listener<T[K]>,
|
||||||
|
wait: number,
|
||||||
|
): Listener<T[K]> {
|
||||||
|
let timeout: ReturnType<typeof setTimeout>;
|
||||||
|
|
||||||
|
return (detail: T[K]) => {
|
||||||
|
if (timeout !== null) {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
}
|
||||||
|
timeout = setTimeout(() => {
|
||||||
|
func(detail);
|
||||||
|
}, wait);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
259
src/utils/history-utils.ts
Normal file
259
src/utils/history-utils.ts
Normal file
@@ -0,0 +1,259 @@
|
|||||||
|
/**
|
||||||
|
* History utility functions.
|
||||||
|
*
|
||||||
|
* Pure functions for parsing and formatting wallet history data.
|
||||||
|
* These functions have no React dependencies and can be used
|
||||||
|
* in both TUI and CLI contexts.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { HistoryItem, HistoryItemType } from '../services/history.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Color names for history item types.
|
||||||
|
* These are semantic color names that can be mapped to actual colors
|
||||||
|
* by the consuming application (TUI or CLI).
|
||||||
|
*/
|
||||||
|
export type HistoryColorName = 'info' | 'warning' | 'success' | 'error' | 'muted' | 'text';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formatted history list item data.
|
||||||
|
*/
|
||||||
|
export interface FormattedHistoryItem {
|
||||||
|
/** The display label for the history item */
|
||||||
|
label: string;
|
||||||
|
/** Optional secondary description */
|
||||||
|
description?: string;
|
||||||
|
/** The formatted date string */
|
||||||
|
dateStr?: string;
|
||||||
|
/** The semantic color name for this item type */
|
||||||
|
color: HistoryColorName;
|
||||||
|
/** The history item type */
|
||||||
|
type: HistoryItemType;
|
||||||
|
/** Whether the item data is valid */
|
||||||
|
isValid: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the semantic color name for a history item type.
|
||||||
|
*
|
||||||
|
* @param type - The history item type
|
||||||
|
* @param isSelected - Whether the item is currently selected
|
||||||
|
* @returns A semantic color name
|
||||||
|
*/
|
||||||
|
export function getHistoryItemColorName(type: HistoryItemType, isSelected: boolean = false): HistoryColorName {
|
||||||
|
if (isSelected) return 'info'; // Use focus color when selected
|
||||||
|
|
||||||
|
switch (type) {
|
||||||
|
case 'invitation_created':
|
||||||
|
return 'text';
|
||||||
|
case 'utxo_reserved':
|
||||||
|
return 'warning';
|
||||||
|
case 'utxo_received':
|
||||||
|
return 'success';
|
||||||
|
default:
|
||||||
|
return 'text';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format a satoshi value for display.
|
||||||
|
*
|
||||||
|
* @param satoshis - The value in satoshis
|
||||||
|
* @returns Formatted string with BCH amount
|
||||||
|
*/
|
||||||
|
export function formatSatoshisValue(satoshis: bigint | number): string {
|
||||||
|
const value = typeof satoshis === 'bigint' ? satoshis : BigInt(satoshis);
|
||||||
|
const bch = Number(value) / 100_000_000;
|
||||||
|
return `${bch.toFixed(8)} BCH (${value.toLocaleString()} sats)`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format a timestamp for display.
|
||||||
|
*
|
||||||
|
* @param timestamp - Unix timestamp in milliseconds
|
||||||
|
* @returns Formatted date string or undefined
|
||||||
|
*/
|
||||||
|
export function formatHistoryDate(timestamp?: number): string | undefined {
|
||||||
|
if (!timestamp) return undefined;
|
||||||
|
return new Date(timestamp).toLocaleDateString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format a history item for display in a list.
|
||||||
|
*
|
||||||
|
* @param item - The history item to format
|
||||||
|
* @param isSelected - Whether the item is currently selected
|
||||||
|
* @returns Formatted item data for display
|
||||||
|
*/
|
||||||
|
export function formatHistoryListItem(
|
||||||
|
item: HistoryItem | null | undefined,
|
||||||
|
isSelected: boolean = false
|
||||||
|
): FormattedHistoryItem {
|
||||||
|
if (!item) {
|
||||||
|
return {
|
||||||
|
label: '',
|
||||||
|
description: undefined,
|
||||||
|
dateStr: undefined,
|
||||||
|
color: 'muted',
|
||||||
|
type: 'utxo_received',
|
||||||
|
isValid: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const dateStr = formatHistoryDate(item.timestamp);
|
||||||
|
const color = getHistoryItemColorName(item.type, isSelected);
|
||||||
|
|
||||||
|
switch (item.type) {
|
||||||
|
case 'invitation_created':
|
||||||
|
return {
|
||||||
|
label: `[Invitation] ${item.description}`,
|
||||||
|
description: undefined,
|
||||||
|
dateStr,
|
||||||
|
color,
|
||||||
|
type: item.type,
|
||||||
|
isValid: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
case 'utxo_reserved': {
|
||||||
|
const satsStr = item.valueSatoshis !== undefined
|
||||||
|
? formatSatoshisValue(item.valueSatoshis)
|
||||||
|
: 'Unknown amount';
|
||||||
|
return {
|
||||||
|
label: `[Reserved] ${satsStr}`,
|
||||||
|
description: item.description,
|
||||||
|
dateStr,
|
||||||
|
color,
|
||||||
|
type: item.type,
|
||||||
|
isValid: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'utxo_received': {
|
||||||
|
const satsStr = item.valueSatoshis !== undefined
|
||||||
|
? formatSatoshisValue(item.valueSatoshis)
|
||||||
|
: 'Unknown amount';
|
||||||
|
const reservedTag = item.reserved ? ' [Reserved]' : '';
|
||||||
|
return {
|
||||||
|
label: satsStr,
|
||||||
|
description: `${item.description}${reservedTag}`,
|
||||||
|
dateStr,
|
||||||
|
color,
|
||||||
|
type: item.type,
|
||||||
|
isValid: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
return {
|
||||||
|
label: `${item.type}: ${item.description}`,
|
||||||
|
description: undefined,
|
||||||
|
dateStr,
|
||||||
|
color: 'text',
|
||||||
|
type: item.type,
|
||||||
|
isValid: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a type label for display.
|
||||||
|
*
|
||||||
|
* @param type - The history item type
|
||||||
|
* @returns Human-readable type label
|
||||||
|
*/
|
||||||
|
export function getHistoryTypeLabel(type: HistoryItemType): string {
|
||||||
|
switch (type) {
|
||||||
|
case 'invitation_created':
|
||||||
|
return 'Invitation';
|
||||||
|
case 'utxo_reserved':
|
||||||
|
return 'Reserved';
|
||||||
|
case 'utxo_received':
|
||||||
|
return 'Received';
|
||||||
|
default:
|
||||||
|
return type;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate scrolling window indices for a list.
|
||||||
|
*
|
||||||
|
* @param selectedIndex - Currently selected index
|
||||||
|
* @param totalItems - Total number of items
|
||||||
|
* @param maxVisible - Maximum visible items
|
||||||
|
* @returns Start and end indices for the visible window
|
||||||
|
*/
|
||||||
|
export function calculateScrollWindow(
|
||||||
|
selectedIndex: number,
|
||||||
|
totalItems: number,
|
||||||
|
maxVisible: number
|
||||||
|
): { startIndex: number; endIndex: number } {
|
||||||
|
const halfWindow = Math.floor(maxVisible / 2);
|
||||||
|
let startIndex = Math.max(0, selectedIndex - halfWindow);
|
||||||
|
const endIndex = Math.min(totalItems, startIndex + maxVisible);
|
||||||
|
|
||||||
|
// Adjust start if we're near the end
|
||||||
|
if (endIndex - startIndex < maxVisible) {
|
||||||
|
startIndex = Math.max(0, endIndex - maxVisible);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { startIndex, endIndex };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a history item is a UTXO-related event.
|
||||||
|
*
|
||||||
|
* @param item - The history item to check
|
||||||
|
* @returns True if the item is UTXO-related
|
||||||
|
*/
|
||||||
|
export function isUtxoEvent(item: HistoryItem): boolean {
|
||||||
|
return item.type === 'utxo_received' || item.type === 'utxo_reserved';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filter history items by type.
|
||||||
|
*
|
||||||
|
* @param items - Array of history items
|
||||||
|
* @param types - Types to include
|
||||||
|
* @returns Filtered array
|
||||||
|
*/
|
||||||
|
export function filterHistoryByType(
|
||||||
|
items: HistoryItem[],
|
||||||
|
types: HistoryItemType[]
|
||||||
|
): HistoryItem[] {
|
||||||
|
return items.filter(item => types.includes(item.type));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get summary statistics for history items.
|
||||||
|
*
|
||||||
|
* @param items - Array of history items
|
||||||
|
* @returns Summary statistics
|
||||||
|
*/
|
||||||
|
export function getHistorySummary(items: HistoryItem[]): {
|
||||||
|
totalReceived: bigint;
|
||||||
|
totalReserved: bigint;
|
||||||
|
invitationCount: number;
|
||||||
|
utxoCount: number;
|
||||||
|
} {
|
||||||
|
let totalReceived = 0n;
|
||||||
|
let totalReserved = 0n;
|
||||||
|
let invitationCount = 0;
|
||||||
|
let utxoCount = 0;
|
||||||
|
|
||||||
|
for (const item of items) {
|
||||||
|
switch (item.type) {
|
||||||
|
case 'invitation_created':
|
||||||
|
invitationCount++;
|
||||||
|
break;
|
||||||
|
case 'utxo_reserved':
|
||||||
|
totalReserved += item.valueSatoshis ?? 0n;
|
||||||
|
break;
|
||||||
|
case 'utxo_received':
|
||||||
|
totalReceived += item.valueSatoshis ?? 0n;
|
||||||
|
utxoCount++;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { totalReceived, totalReserved, invitationCount, utxoCount };
|
||||||
|
}
|
||||||
264
src/utils/invitation-utils.ts
Normal file
264
src/utils/invitation-utils.ts
Normal file
@@ -0,0 +1,264 @@
|
|||||||
|
/**
|
||||||
|
* Invitation utility functions.
|
||||||
|
*
|
||||||
|
* Pure functions for parsing and formatting invitation data.
|
||||||
|
* These functions have no React dependencies and can be used
|
||||||
|
* in both TUI and CLI contexts.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { Invitation } from '../services/invitation.js';
|
||||||
|
import type { XOTemplate } from '@xo-cash/types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Color names for invitation states.
|
||||||
|
* These are semantic color names that can be mapped to actual colors
|
||||||
|
* by the consuming application (TUI or CLI).
|
||||||
|
*/
|
||||||
|
export type StateColorName = 'info' | 'warning' | 'success' | 'error' | 'muted';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Input data extracted from invitation commits.
|
||||||
|
*/
|
||||||
|
export interface InvitationInput {
|
||||||
|
inputIdentifier?: string;
|
||||||
|
roleIdentifier?: string;
|
||||||
|
entityIdentifier: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Output data extracted from invitation commits.
|
||||||
|
*/
|
||||||
|
export interface InvitationOutput {
|
||||||
|
outputIdentifier?: string;
|
||||||
|
roleIdentifier?: string;
|
||||||
|
valueSatoshis?: bigint;
|
||||||
|
entityIdentifier: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Variable data extracted from invitation commits.
|
||||||
|
*/
|
||||||
|
export interface InvitationVariable {
|
||||||
|
variableIdentifier: string;
|
||||||
|
value: unknown;
|
||||||
|
roleIdentifier?: string;
|
||||||
|
entityIdentifier: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formatted invitation list item data.
|
||||||
|
*/
|
||||||
|
export interface FormattedInvitationItem {
|
||||||
|
/** The display label for the invitation */
|
||||||
|
label: string;
|
||||||
|
/** The current status of the invitation */
|
||||||
|
status: string;
|
||||||
|
/** The semantic color name for the status */
|
||||||
|
statusColor: StateColorName;
|
||||||
|
/** Whether the invitation data is valid */
|
||||||
|
isValid: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the current state/status of an invitation.
|
||||||
|
*
|
||||||
|
* @param invitation - The invitation to get state for
|
||||||
|
* @returns The status string
|
||||||
|
*/
|
||||||
|
export function getInvitationState(invitation: Invitation): string {
|
||||||
|
return invitation.status;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the semantic color name for an invitation state.
|
||||||
|
*
|
||||||
|
* @param state - The invitation state string
|
||||||
|
* @returns A semantic color name
|
||||||
|
*/
|
||||||
|
export function getStateColorName(state: string): StateColorName {
|
||||||
|
switch (state) {
|
||||||
|
case 'created':
|
||||||
|
case 'published':
|
||||||
|
return 'info';
|
||||||
|
case 'pending':
|
||||||
|
return 'warning';
|
||||||
|
case 'ready':
|
||||||
|
case 'signed':
|
||||||
|
case 'broadcast':
|
||||||
|
case 'completed':
|
||||||
|
return 'success';
|
||||||
|
case 'expired':
|
||||||
|
case 'error':
|
||||||
|
return 'error';
|
||||||
|
default:
|
||||||
|
return 'muted';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract all inputs from invitation commits.
|
||||||
|
*
|
||||||
|
* @param invitation - The invitation to extract inputs from
|
||||||
|
* @returns Array of input data
|
||||||
|
*/
|
||||||
|
export function getInvitationInputs(invitation: Invitation): InvitationInput[] {
|
||||||
|
const inputs: InvitationInput[] = [];
|
||||||
|
for (const commit of invitation.data.commits || []) {
|
||||||
|
for (const input of commit.data?.inputs || []) {
|
||||||
|
inputs.push({
|
||||||
|
inputIdentifier: input.inputIdentifier,
|
||||||
|
roleIdentifier: input.roleIdentifier,
|
||||||
|
entityIdentifier: commit.entityIdentifier,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return inputs;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract all outputs from invitation commits.
|
||||||
|
*
|
||||||
|
* @param invitation - The invitation to extract outputs from
|
||||||
|
* @returns Array of output data
|
||||||
|
*/
|
||||||
|
export function getInvitationOutputs(invitation: Invitation): InvitationOutput[] {
|
||||||
|
const outputs: InvitationOutput[] = [];
|
||||||
|
for (const commit of invitation.data.commits || []) {
|
||||||
|
for (const output of commit.data?.outputs || []) {
|
||||||
|
outputs.push({
|
||||||
|
outputIdentifier: output.outputIdentifier,
|
||||||
|
roleIdentifier: output.roleIdentifier,
|
||||||
|
valueSatoshis: output.valueSatoshis,
|
||||||
|
entityIdentifier: commit.entityIdentifier,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return outputs;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract all variables from invitation commits.
|
||||||
|
*
|
||||||
|
* @param invitation - The invitation to extract variables from
|
||||||
|
* @returns Array of variable data
|
||||||
|
*/
|
||||||
|
export function getInvitationVariables(invitation: Invitation): InvitationVariable[] {
|
||||||
|
const variables: InvitationVariable[] = [];
|
||||||
|
for (const commit of invitation.data.commits || []) {
|
||||||
|
for (const variable of commit.data?.variables || []) {
|
||||||
|
variables.push({
|
||||||
|
variableIdentifier: variable.variableIdentifier,
|
||||||
|
value: variable.value,
|
||||||
|
roleIdentifier: variable.roleIdentifier,
|
||||||
|
entityIdentifier: commit.entityIdentifier,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return variables;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the user's role from commits (the role they have accepted).
|
||||||
|
*
|
||||||
|
* @param invitation - The invitation to check
|
||||||
|
* @param userEntityId - The user's entity identifier
|
||||||
|
* @returns The role identifier if found, null otherwise
|
||||||
|
*/
|
||||||
|
export function getUserRole(invitation: Invitation, userEntityId: string | null): string | null {
|
||||||
|
if (!userEntityId) return null;
|
||||||
|
|
||||||
|
for (const commit of invitation.data.commits || []) {
|
||||||
|
if (commit.entityIdentifier === userEntityId) {
|
||||||
|
// Check inputs for role
|
||||||
|
for (const input of commit.data?.inputs || []) {
|
||||||
|
if (input.roleIdentifier) return input.roleIdentifier;
|
||||||
|
}
|
||||||
|
// Check outputs for role
|
||||||
|
for (const output of commit.data?.outputs || []) {
|
||||||
|
if (output.roleIdentifier) return output.roleIdentifier;
|
||||||
|
}
|
||||||
|
// Check variables for role
|
||||||
|
for (const variable of commit.data?.variables || []) {
|
||||||
|
if (variable.roleIdentifier) return variable.roleIdentifier;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format an invitation for display in a list.
|
||||||
|
*
|
||||||
|
* @param invitation - The invitation to format
|
||||||
|
* @param template - Optional template for additional info (name)
|
||||||
|
* @returns Formatted item data for display
|
||||||
|
*/
|
||||||
|
export function formatInvitationListItem(
|
||||||
|
invitation: Invitation,
|
||||||
|
template?: XOTemplate | null
|
||||||
|
): FormattedInvitationItem {
|
||||||
|
// Validate that we have the minimum required data
|
||||||
|
const invitationId = invitation?.data?.invitationIdentifier;
|
||||||
|
const actionId = invitation?.data?.actionIdentifier;
|
||||||
|
|
||||||
|
if (!invitationId || !actionId) {
|
||||||
|
return {
|
||||||
|
label: '',
|
||||||
|
status: 'error',
|
||||||
|
statusColor: 'error',
|
||||||
|
isValid: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const state = getInvitationState(invitation);
|
||||||
|
const templateName = template?.name ?? 'Unknown';
|
||||||
|
const shortId = formatInvitationId(invitationId, 8);
|
||||||
|
|
||||||
|
return {
|
||||||
|
label: `[${state}] ${templateName}-${actionId} (${shortId})`,
|
||||||
|
status: state,
|
||||||
|
statusColor: getStateColorName(state),
|
||||||
|
isValid: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format an invitation ID for display (truncated).
|
||||||
|
*
|
||||||
|
* @param id - The full invitation ID
|
||||||
|
* @param maxLength - Maximum length for display
|
||||||
|
* @returns Truncated ID string
|
||||||
|
*/
|
||||||
|
export function formatInvitationId(id: string, maxLength: number = 16): string {
|
||||||
|
if (id.length <= maxLength) return id;
|
||||||
|
const half = Math.floor((maxLength - 3) / 2);
|
||||||
|
return `${id.slice(0, half)}...${id.slice(-half)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all unique entity identifiers from an invitation's commits.
|
||||||
|
*
|
||||||
|
* @param invitation - The invitation to check
|
||||||
|
* @returns Array of unique entity identifiers
|
||||||
|
*/
|
||||||
|
export function getInvitationParticipants(invitation: Invitation): string[] {
|
||||||
|
const participants = new Set<string>();
|
||||||
|
for (const commit of invitation.data.commits || []) {
|
||||||
|
if (commit.entityIdentifier) {
|
||||||
|
participants.add(commit.entityIdentifier);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Array.from(participants);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a user is a participant in an invitation.
|
||||||
|
*
|
||||||
|
* @param invitation - The invitation to check
|
||||||
|
* @param userEntityId - The user's entity identifier
|
||||||
|
* @returns True if the user has made at least one commit
|
||||||
|
*/
|
||||||
|
export function isUserParticipant(invitation: Invitation, userEntityId: string | null): boolean {
|
||||||
|
if (!userEntityId) return false;
|
||||||
|
return getInvitationParticipants(invitation).includes(userEntityId);
|
||||||
|
}
|
||||||
46
src/utils/logger.ts
Normal file
46
src/utils/logger.ts
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
export class Logger {
|
||||||
|
constructor(
|
||||||
|
private readonly endpoint: string,
|
||||||
|
private readonly token: string,
|
||||||
|
private readonly path: string,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
send(level: 'log' | 'error' | 'warn' | 'info', message: string, ...metadata: unknown[]) {
|
||||||
|
const data = {
|
||||||
|
level,
|
||||||
|
message: `${this.path}: ${message}`,
|
||||||
|
metadata,
|
||||||
|
};
|
||||||
|
|
||||||
|
fetch(`${this.endpoint}`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'x-api-key': this.token,
|
||||||
|
},
|
||||||
|
}).catch(error => {
|
||||||
|
console.error('Failed to send log to logger:', error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
log(message: string, ...metadata: unknown[]) {
|
||||||
|
this.send('log', message, ...metadata);
|
||||||
|
}
|
||||||
|
|
||||||
|
error(message: string, ...metadata: unknown[]) {
|
||||||
|
this.send('error', message, ...metadata);
|
||||||
|
}
|
||||||
|
|
||||||
|
warn(message: string, ...metadata: unknown[]) {
|
||||||
|
this.send('warn', message, ...metadata);
|
||||||
|
}
|
||||||
|
|
||||||
|
info(message: string, ...metadata: unknown[]) {
|
||||||
|
this.send('info', message, ...metadata);
|
||||||
|
}
|
||||||
|
|
||||||
|
child(path: string): Logger {
|
||||||
|
return new Logger(this.endpoint, this.token, `${this.path}.${path}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
99
src/utils/sync-server.ts
Normal file
99
src/utils/sync-server.ts
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
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";
|
||||||
|
|
||||||
|
export type SyncServerEventMap = {
|
||||||
|
'connected': void;
|
||||||
|
'disconnected': void;
|
||||||
|
'error': Error;
|
||||||
|
'message': SSEvent;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SyncServer extends EventEmitter<SyncServerEventMap> {
|
||||||
|
static async from(baseUrl: string, invitationIdentifier: string): Promise<SyncServer> {
|
||||||
|
const server = new SyncServer(baseUrl, invitationIdentifier);
|
||||||
|
await server.connect();
|
||||||
|
return server;
|
||||||
|
}
|
||||||
|
|
||||||
|
private sse: SSESession;
|
||||||
|
|
||||||
|
constructor(private readonly baseUrl: string, private readonly invitationIdentifier: string) {
|
||||||
|
super();
|
||||||
|
|
||||||
|
// Create an SSE Session
|
||||||
|
this.sse = new SSESession(`${baseUrl}/invitations?invitationIdentifier=${invitationIdentifier}`, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
'Accept': 'text/event-stream',
|
||||||
|
},
|
||||||
|
|
||||||
|
// Create our event bubblers
|
||||||
|
onMessage: (event: SSEvent) => this.emit('message', event),
|
||||||
|
onError: (error: unknown) => this.emit('error', error instanceof Error ? error : new Error(String(error))),
|
||||||
|
onDisconnected: () => this.emit('disconnected', undefined),
|
||||||
|
onConnected: () => this.emit('connected', undefined),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Connect to the sync server.
|
||||||
|
*/
|
||||||
|
async connect(): Promise<void> {
|
||||||
|
// Connect to the SSE Session
|
||||||
|
await this.sse.connect();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Disconnect from the sync server.
|
||||||
|
*/
|
||||||
|
async disconnect(): Promise<void> {
|
||||||
|
// Disconnect from the SSE Session
|
||||||
|
this.sse.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the invitation by identifier.
|
||||||
|
* @param identifier - The invitation identifier.
|
||||||
|
* @returns The invitation.
|
||||||
|
*/
|
||||||
|
async getInvitation(identifier: string): Promise<XOInvitation | undefined> {
|
||||||
|
// Send a GET request to the sync server
|
||||||
|
const response = await fetch(`${this.baseUrl}/invitations?invitationIdentifier=${identifier}`);
|
||||||
|
|
||||||
|
if(!response.ok) {
|
||||||
|
throw new Error(`Failed to get invitation: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const invitation = decodeExtendedJson(await response.text()) as XOInvitation | undefined;
|
||||||
|
return invitation;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Publish an invitation.
|
||||||
|
* @param invitation - The invitation to create.
|
||||||
|
* @returns The invitation.
|
||||||
|
*/
|
||||||
|
async publishInvitation(invitation: XOInvitation): Promise<XOInvitation> {
|
||||||
|
// Send a POST request to the sync server
|
||||||
|
const response = await fetch(`${this.baseUrl}/invitations`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: encodeExtendedJson(invitation),
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Throw is there was an issue with the request
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to publish invitation: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read the returned JSON
|
||||||
|
// TODO: This should use zod to verify the response
|
||||||
|
const data = decodeExtendedJson(await response.text()) as XOInvitation;
|
||||||
|
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
}
|
||||||
246
src/utils/template-utils.ts
Normal file
246
src/utils/template-utils.ts
Normal file
@@ -0,0 +1,246 @@
|
|||||||
|
/**
|
||||||
|
* Template utility functions.
|
||||||
|
*
|
||||||
|
* Pure functions for parsing and formatting template data.
|
||||||
|
* These functions have no React dependencies and can be used
|
||||||
|
* in both TUI and CLI contexts.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { XOTemplate, XOTemplateAction } from '@xo-cash/types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formatted template list item data.
|
||||||
|
*/
|
||||||
|
export interface FormattedTemplateItem {
|
||||||
|
/** The display label for the template */
|
||||||
|
label: string;
|
||||||
|
/** The template description */
|
||||||
|
description?: string;
|
||||||
|
/** Whether the template data is valid */
|
||||||
|
isValid: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formatted action list item data.
|
||||||
|
*/
|
||||||
|
export interface FormattedActionItem {
|
||||||
|
/** The display label for the action */
|
||||||
|
label: string;
|
||||||
|
/** The action description */
|
||||||
|
description?: string;
|
||||||
|
/** Number of roles that can start this action */
|
||||||
|
roleCount: number;
|
||||||
|
/** Whether the action data is valid */
|
||||||
|
isValid: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A unique starting action (deduplicated by action identifier).
|
||||||
|
* Multiple roles that can start the same action are counted
|
||||||
|
* but not shown as separate entries.
|
||||||
|
*/
|
||||||
|
export interface UniqueStartingAction {
|
||||||
|
actionIdentifier: string;
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
roleCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Role information from a template.
|
||||||
|
*/
|
||||||
|
export interface TemplateRole {
|
||||||
|
roleId: string;
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format a template for display in a list.
|
||||||
|
*
|
||||||
|
* @param template - The template to format
|
||||||
|
* @param index - Optional index for numbered display
|
||||||
|
* @returns Formatted item data for display
|
||||||
|
*/
|
||||||
|
export function formatTemplateListItem(
|
||||||
|
template: XOTemplate | null | undefined,
|
||||||
|
index?: number
|
||||||
|
): FormattedTemplateItem {
|
||||||
|
if (!template) {
|
||||||
|
return {
|
||||||
|
label: '',
|
||||||
|
description: undefined,
|
||||||
|
isValid: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const name = template.name || 'Unnamed Template';
|
||||||
|
const prefix = index !== undefined ? `${index + 1}. ` : '';
|
||||||
|
|
||||||
|
return {
|
||||||
|
label: `${prefix}${name}`,
|
||||||
|
description: template.description,
|
||||||
|
isValid: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format an action for display in a list.
|
||||||
|
*
|
||||||
|
* @param actionId - The action identifier
|
||||||
|
* @param action - The action definition from the template
|
||||||
|
* @param roleCount - Number of roles that can start this action
|
||||||
|
* @param index - Optional index for numbered display
|
||||||
|
* @returns Formatted item data for display
|
||||||
|
*/
|
||||||
|
export function formatActionListItem(
|
||||||
|
actionId: string,
|
||||||
|
action: XOTemplateAction | null | undefined,
|
||||||
|
roleCount: number = 1,
|
||||||
|
index?: number
|
||||||
|
): FormattedActionItem {
|
||||||
|
if (!actionId) {
|
||||||
|
return {
|
||||||
|
label: '',
|
||||||
|
description: undefined,
|
||||||
|
roleCount: 0,
|
||||||
|
isValid: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const name = action?.name || actionId;
|
||||||
|
const prefix = index !== undefined ? `${index + 1}. ` : '';
|
||||||
|
const roleSuffix = roleCount > 1 ? ` (${roleCount} roles)` : '';
|
||||||
|
|
||||||
|
return {
|
||||||
|
label: `${prefix}${name}${roleSuffix}`,
|
||||||
|
description: action?.description,
|
||||||
|
roleCount,
|
||||||
|
isValid: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deduplicate starting actions from a template.
|
||||||
|
* Multiple roles that can start the same action are counted
|
||||||
|
* but returned as a single entry.
|
||||||
|
*
|
||||||
|
* @param template - The template to process
|
||||||
|
* @param startingActions - Array of { action, role } pairs
|
||||||
|
* @returns Array of unique starting actions with role counts
|
||||||
|
*/
|
||||||
|
export function deduplicateStartingActions(
|
||||||
|
template: XOTemplate,
|
||||||
|
startingActions: Array<{ action: string; role: string }>
|
||||||
|
): UniqueStartingAction[] {
|
||||||
|
const actionMap = new Map<string, UniqueStartingAction>();
|
||||||
|
|
||||||
|
for (const sa of startingActions) {
|
||||||
|
if (actionMap.has(sa.action)) {
|
||||||
|
actionMap.get(sa.action)!.roleCount++;
|
||||||
|
} else {
|
||||||
|
const actionDef = template.actions?.[sa.action];
|
||||||
|
actionMap.set(sa.action, {
|
||||||
|
actionIdentifier: sa.action,
|
||||||
|
name: actionDef?.name || sa.action,
|
||||||
|
description: actionDef?.description,
|
||||||
|
roleCount: 1,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(actionMap.values());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all roles from a template.
|
||||||
|
*
|
||||||
|
* @param template - The template to process
|
||||||
|
* @returns Array of role information
|
||||||
|
*/
|
||||||
|
export function getTemplateRoles(template: XOTemplate): TemplateRole[] {
|
||||||
|
if (!template.roles) return [];
|
||||||
|
|
||||||
|
return Object.entries(template.roles).map(([roleId, role]) => {
|
||||||
|
// Handle case where role might be a string instead of object
|
||||||
|
const roleObj = typeof role === 'object' ? role : null;
|
||||||
|
return {
|
||||||
|
roleId,
|
||||||
|
name: roleObj?.name || roleId,
|
||||||
|
description: roleObj?.description,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get roles that can start a specific action.
|
||||||
|
*
|
||||||
|
* @param template - The template to check
|
||||||
|
* @param actionIdentifier - The action to check
|
||||||
|
* @returns Array of role information for roles that can start this action
|
||||||
|
*/
|
||||||
|
export function getRolesForAction(
|
||||||
|
template: XOTemplate,
|
||||||
|
actionIdentifier: string
|
||||||
|
): TemplateRole[] {
|
||||||
|
const startEntries = (template.start ?? [])
|
||||||
|
.filter((s) => s.action === actionIdentifier);
|
||||||
|
|
||||||
|
return startEntries.map((entry) => {
|
||||||
|
const roleDef = template.roles?.[entry.role];
|
||||||
|
const roleObj = typeof roleDef === 'object' ? roleDef : null;
|
||||||
|
return {
|
||||||
|
roleId: entry.role,
|
||||||
|
name: roleObj?.name || entry.role,
|
||||||
|
description: roleObj?.description,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get template name safely.
|
||||||
|
*
|
||||||
|
* @param template - The template
|
||||||
|
* @returns The template name or a default
|
||||||
|
*/
|
||||||
|
export function getTemplateName(template: XOTemplate | null | undefined): string {
|
||||||
|
return template?.name || 'Unknown Template';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get template description safely.
|
||||||
|
*
|
||||||
|
* @param template - The template
|
||||||
|
* @returns The template description or undefined
|
||||||
|
*/
|
||||||
|
export function getTemplateDescription(template: XOTemplate | null | undefined): string | undefined {
|
||||||
|
return template?.description;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get action name safely.
|
||||||
|
*
|
||||||
|
* @param template - The template containing the action
|
||||||
|
* @param actionId - The action identifier
|
||||||
|
* @returns The action name or the action ID as fallback
|
||||||
|
*/
|
||||||
|
export function getActionName(
|
||||||
|
template: XOTemplate | null | undefined,
|
||||||
|
actionId: string
|
||||||
|
): string {
|
||||||
|
return template?.actions?.[actionId]?.name || actionId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get action description safely.
|
||||||
|
*
|
||||||
|
* @param template - The template containing the action
|
||||||
|
* @param actionId - The action identifier
|
||||||
|
* @returns The action description or undefined
|
||||||
|
*/
|
||||||
|
export function getActionDescription(
|
||||||
|
template: XOTemplate | null | undefined,
|
||||||
|
actionId: string
|
||||||
|
): string | undefined {
|
||||||
|
return template?.actions?.[actionId]?.description;
|
||||||
|
}
|
||||||
376
src/utils/templates.ts
Normal file
376
src/utils/templates.ts
Normal file
@@ -0,0 +1,376 @@
|
|||||||
|
import type { XOTemplate } from "@xo-cash/types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List of helper functions to make templates easy to develop with.
|
||||||
|
*
|
||||||
|
* Most of these are centered around the fact that the templates are very disjointed and sporadic in where the information lies.
|
||||||
|
*
|
||||||
|
* I.e. required variables ** names ** are stored in actions.roles.requirements.variables, but the variable definitions are stored in the template.variables object.
|
||||||
|
* so to make a UI out of that, you first need to iterate over the actions.roles.requirements.variables and then lookup the variable definition in the template.variables object.
|
||||||
|
* this is a pain, so these functions are here to help.
|
||||||
|
*
|
||||||
|
* Simiarly for inputs, outputs, locking scripts, etc. The get referenced in the actions, but then the actual lookup of what is actually is becomes a pain.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deepens a templates object by append the actual definitions for the objects as they are referenced in the template.
|
||||||
|
* NOTE: Whether this is better as part of the template or not can be debated endlessly.
|
||||||
|
* The decision for separating the defintions from where they are used is likely to reduce template size...
|
||||||
|
* This could be fruitless though, as its easily compressible (gzip, msgpack, etc) will yield similar results to the separated approach.
|
||||||
|
*/
|
||||||
|
type ResolutionMode = "single" | "map";
|
||||||
|
|
||||||
|
interface ResolutionRule {
|
||||||
|
/**
|
||||||
|
* Dot-separated path pattern.
|
||||||
|
* - `*` matches any key in an object.
|
||||||
|
* - `[]` iterates items in an array.
|
||||||
|
*/
|
||||||
|
path: string;
|
||||||
|
/** Root-level collection key to resolve references from. */
|
||||||
|
from: string;
|
||||||
|
/**
|
||||||
|
* - `single`: replaces a string reference with its definition.
|
||||||
|
* - `map`: converts a `string[]` into a `Record<string, definition>`.
|
||||||
|
*/
|
||||||
|
mode: ResolutionMode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rules are ordered by dependency so that each phase reads
|
||||||
|
* collections already enriched by earlier phases.
|
||||||
|
*
|
||||||
|
* Dependency graph (leaf → root):
|
||||||
|
* scripts (no deps)
|
||||||
|
* variables / roles / data / constants (no deps)
|
||||||
|
* lockingScripts ← scripts, variables
|
||||||
|
* inputs ← scripts
|
||||||
|
* outputs ← lockingScripts
|
||||||
|
* transactions ← inputs, outputs
|
||||||
|
* actions ← variables, transactions, data, roles
|
||||||
|
* lockingScripts.roles.actions ← actions, roles, variables (2nd pass)
|
||||||
|
* start ← actions, roles
|
||||||
|
*/
|
||||||
|
const RESOLUTION_RULES: ResolutionRule[] = [
|
||||||
|
// ── Phase 1: lockingScripts ← scripts, variables ──────────
|
||||||
|
{
|
||||||
|
path: "lockingScripts.*.lockingScript",
|
||||||
|
from: "scripts",
|
||||||
|
mode: "single",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "lockingScripts.*.unlockingScript",
|
||||||
|
from: "scripts",
|
||||||
|
mode: "single",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "lockingScripts.*.roles.*.state.secrets",
|
||||||
|
from: "variables",
|
||||||
|
mode: "map",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "lockingScripts.*.roles.*.state.variables",
|
||||||
|
from: "variables",
|
||||||
|
mode: "map",
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Phase 2: inputs ← scripts ─────────────────────────────
|
||||||
|
{ path: "inputs.*.unlockingScript", from: "scripts", mode: "single" },
|
||||||
|
|
||||||
|
// ── Phase 3: outputs ← lockingScripts (now enriched) ──────
|
||||||
|
{ path: "outputs.*.lockscript", from: "lockingScripts", mode: "single" },
|
||||||
|
|
||||||
|
// ── Phase 4: transactions ← inputs, outputs ───────────────
|
||||||
|
{ path: "transactions.*.inputs", from: "inputs", mode: "map" },
|
||||||
|
{ path: "transactions.*.outputs", from: "outputs", mode: "map" },
|
||||||
|
|
||||||
|
// ── Phase 5: actions ← variables, transactions, data, roles
|
||||||
|
{
|
||||||
|
path: "actions.*.roles.*.requirements.variables",
|
||||||
|
from: "variables",
|
||||||
|
mode: "map",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "actions.*.roles.*.requirements.secrets",
|
||||||
|
from: "variables",
|
||||||
|
mode: "map",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "actions.*.roles.*.requirements.generate",
|
||||||
|
from: "variables",
|
||||||
|
mode: "map",
|
||||||
|
},
|
||||||
|
{ path: "actions.*.transaction", from: "transactions", mode: "single" },
|
||||||
|
{ path: "actions.*.data", from: "data", mode: "map" },
|
||||||
|
{
|
||||||
|
path: "actions.*.requirements.roles.[].role",
|
||||||
|
from: "roles",
|
||||||
|
mode: "single",
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Phase 6: lockingScripts.roles.actions ← actions (now enriched), roles, variables
|
||||||
|
{
|
||||||
|
path: "lockingScripts.*.roles.*.actions.[].action",
|
||||||
|
from: "actions",
|
||||||
|
mode: "single",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "lockingScripts.*.roles.*.actions.[].role",
|
||||||
|
from: "roles",
|
||||||
|
mode: "single",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "lockingScripts.*.roles.*.actions.[].secrets",
|
||||||
|
from: "variables",
|
||||||
|
mode: "single",
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Phase 7: start ← actions, roles ───────────────────────
|
||||||
|
{ path: "start.[].action", from: "actions", mode: "single" },
|
||||||
|
{ path: "start.[].role", from: "roles", mode: "single" },
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recursively walks `obj` following the path pattern described by `parts`,
|
||||||
|
* and resolves the leaf reference(s) from `root[from]`.
|
||||||
|
*/
|
||||||
|
function applyRule(
|
||||||
|
obj: unknown,
|
||||||
|
root: Record<string, any>,
|
||||||
|
parts: string[],
|
||||||
|
depth: number,
|
||||||
|
from: string,
|
||||||
|
mode: ResolutionMode,
|
||||||
|
): void {
|
||||||
|
if (obj == null || typeof obj !== "object") return;
|
||||||
|
|
||||||
|
const part = parts[depth]!;
|
||||||
|
const isLast = depth === parts.length - 1;
|
||||||
|
|
||||||
|
// ── Leaf: perform the resolution ──────────────────────────
|
||||||
|
if (isLast) {
|
||||||
|
const collection = root[from] as Record<string, unknown> | undefined;
|
||||||
|
const record = obj as Record<string, unknown>;
|
||||||
|
if (!collection || !(part in record)) return;
|
||||||
|
|
||||||
|
const value = record[part];
|
||||||
|
|
||||||
|
if (mode === "single") {
|
||||||
|
if (typeof value === "string" && value in collection) {
|
||||||
|
record[part] = structuredClone(collection[value]);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// "map" – convert string[] → Record<string, definition>
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
const resolved: Record<string, unknown> = {};
|
||||||
|
for (const ref of value) {
|
||||||
|
if (typeof ref === "string" && ref in collection) {
|
||||||
|
resolved[ref] = structuredClone(collection[ref]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
record[part] = resolved;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Intermediate path segments ────────────────────────────
|
||||||
|
if (part === "*") {
|
||||||
|
// Wildcard: iterate every key of the current object
|
||||||
|
for (const key of Object.keys(obj as Record<string, unknown>)) {
|
||||||
|
applyRule(
|
||||||
|
(obj as Record<string, unknown>)[key],
|
||||||
|
root,
|
||||||
|
parts,
|
||||||
|
depth + 1,
|
||||||
|
from,
|
||||||
|
mode,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else if (part === "[]") {
|
||||||
|
// Array wildcard: iterate every item
|
||||||
|
if (Array.isArray(obj)) {
|
||||||
|
for (const item of obj) {
|
||||||
|
applyRule(item, root, parts, depth + 1, from, mode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Exact key: descend
|
||||||
|
const next = (obj as Record<string, unknown>)[part];
|
||||||
|
if (next !== undefined) {
|
||||||
|
applyRule(next, root, parts, depth + 1, from, mode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a deep clone of `template` with every string reference replaced
|
||||||
|
* by the full definition it points to.
|
||||||
|
*
|
||||||
|
* References are resolved in dependency order so that embedded objects
|
||||||
|
* themselves contain resolved (not string) references wherever possible.
|
||||||
|
*
|
||||||
|
* The only place resolution deliberately stops is at the circular edge
|
||||||
|
* `lockingScripts → actions → transactions → outputs → lockingScripts`:
|
||||||
|
* the lockingScript copies embedded inside output→transaction→action chains
|
||||||
|
* will have their script/variable refs resolved but will *not* re-embed
|
||||||
|
* actions (which would cause infinite nesting).
|
||||||
|
*/
|
||||||
|
export function resolveTemplateReferences(
|
||||||
|
template: XOTemplate,
|
||||||
|
): ResolvedXOTemplate {
|
||||||
|
const resolved = structuredClone(template);
|
||||||
|
|
||||||
|
for (const rule of RESOLUTION_RULES) {
|
||||||
|
applyRule(resolved, resolved, rule.path.split("."), 0, rule.from, rule.mode);
|
||||||
|
}
|
||||||
|
|
||||||
|
return resolved as unknown as ResolvedXOTemplate;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Base definition types (inferred from your template) ─────────
|
||||||
|
// Adjust these to match your actual @xo-cash/types definitions.
|
||||||
|
|
||||||
|
interface VariableDefinition {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
type: string;
|
||||||
|
hint?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RoleDefinition {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
icon: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ScriptDefinition {
|
||||||
|
// scripts are raw strings in your template
|
||||||
|
script: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DataDefinition {
|
||||||
|
value: string;
|
||||||
|
type: string;
|
||||||
|
hint?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Resolved sub-types ──────────────────────────────────────────
|
||||||
|
|
||||||
|
interface ResolvedActionRoleRequirements {
|
||||||
|
variables?: Record<string, VariableDefinition>;
|
||||||
|
secrets?: Record<string, VariableDefinition>;
|
||||||
|
generate?: Record<string, VariableDefinition>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ResolvedActionRole {
|
||||||
|
name?: string;
|
||||||
|
description?: string;
|
||||||
|
icon?: string;
|
||||||
|
requirements?: ResolvedActionRoleRequirements;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ResolvedRoleSlot {
|
||||||
|
role: RoleDefinition; // was string
|
||||||
|
slots: { min: number; max: number | undefined };
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ResolvedOutputDefinition {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
icon: string;
|
||||||
|
lockscript: ResolvedLockingScriptDefinition; // was string
|
||||||
|
valueSatoshis?: string | null;
|
||||||
|
token?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ResolvedInputDefinition {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
icon: string;
|
||||||
|
unlockingScript: string; // resolved from scripts (string → string)
|
||||||
|
token?: unknown;
|
||||||
|
omitChangeAmounts?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ResolvedTransactionDefinition {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
icon?: string;
|
||||||
|
roles?: Record<string, unknown>;
|
||||||
|
inputs: Record<string, ResolvedInputDefinition>; // was string[]
|
||||||
|
outputs: Record<string, ResolvedOutputDefinition>; // was string[]
|
||||||
|
version: number;
|
||||||
|
locktime: number;
|
||||||
|
composable?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ResolvedActionDefinition {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
icon: string;
|
||||||
|
roles: Record<string, ResolvedActionRole>;
|
||||||
|
requirements: {
|
||||||
|
roles: ResolvedRoleSlot[];
|
||||||
|
};
|
||||||
|
transaction?: ResolvedTransactionDefinition; // was string
|
||||||
|
data?: Record<string, DataDefinition>; // was string[]
|
||||||
|
condition?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ResolvedLockingScriptRoleAction {
|
||||||
|
action: ResolvedActionDefinition; // was string
|
||||||
|
role: RoleDefinition; // was string
|
||||||
|
secrets: VariableDefinition; // was string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ResolvedLockingScriptRole {
|
||||||
|
state?: {
|
||||||
|
variables: Record<string, VariableDefinition>; // was string[]
|
||||||
|
secrets: Record<string, VariableDefinition>; // was string[]
|
||||||
|
};
|
||||||
|
actions?: ResolvedLockingScriptRoleAction[];
|
||||||
|
selectable?: boolean;
|
||||||
|
privacy?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ResolvedLockingScriptDefinition {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
icon: string;
|
||||||
|
lockingType: string;
|
||||||
|
lockingScript: string; // resolved from scripts (string → string)
|
||||||
|
unlockingScript?: string;
|
||||||
|
roles?: Record<string, ResolvedLockingScriptRole>;
|
||||||
|
actions?: unknown[];
|
||||||
|
state?: unknown[];
|
||||||
|
secrets?: unknown[];
|
||||||
|
balance?: boolean;
|
||||||
|
selectable?: boolean;
|
||||||
|
privacy?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ResolvedStartEntry {
|
||||||
|
action: ResolvedActionDefinition; // was string
|
||||||
|
role: RoleDefinition; // was string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── The full resolved template ──────────────────────────────────
|
||||||
|
|
||||||
|
interface ResolvedXOTemplate
|
||||||
|
extends Omit<
|
||||||
|
XOTemplate,
|
||||||
|
| "actions"
|
||||||
|
| "transactions"
|
||||||
|
| "outputs"
|
||||||
|
| "inputs"
|
||||||
|
| "lockingScripts"
|
||||||
|
| "start"
|
||||||
|
> {
|
||||||
|
start: ResolvedStartEntry[];
|
||||||
|
actions: Record<string, ResolvedActionDefinition>;
|
||||||
|
transactions: Record<string, ResolvedTransactionDefinition>;
|
||||||
|
outputs: Record<string, ResolvedOutputDefinition>;
|
||||||
|
inputs: Record<string, ResolvedInputDefinition>;
|
||||||
|
lockingScripts: Record<string, ResolvedLockingScriptDefinition>;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user