Fix receive and send
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -7,4 +7,6 @@ dist/
|
|||||||
*.db-shm
|
*.db-shm
|
||||||
*.db-wal
|
*.db-wal
|
||||||
*.sqlite
|
*.sqlite
|
||||||
|
*.sqlite-journal
|
||||||
resolvedTemplate.json
|
resolvedTemplate.json
|
||||||
|
mnemonic-*
|
||||||
Binary file not shown.
248
package-lock.json
generated
248
package-lock.json
generated
@@ -10,6 +10,7 @@
|
|||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@bitauth/libauth": "^3.0.0",
|
"@bitauth/libauth": "^3.0.0",
|
||||||
|
"@electrum-cash/protocol": "^2.3.1",
|
||||||
"@xo-cash/engine": "file:../engine",
|
"@xo-cash/engine": "file:../engine",
|
||||||
"@xo-cash/state": "file:../state",
|
"@xo-cash/state": "file:../state",
|
||||||
"@xo-cash/templates": "file:../templates",
|
"@xo-cash/templates": "file:../templates",
|
||||||
@@ -17,15 +18,13 @@
|
|||||||
"better-sqlite3": "^12.6.2",
|
"better-sqlite3": "^12.6.2",
|
||||||
"clipboardy": "^5.1.0",
|
"clipboardy": "^5.1.0",
|
||||||
"ink": "^6.6.0",
|
"ink": "^6.6.0",
|
||||||
"ink-select-input": "^6.0.0",
|
"react": "^19.2.4",
|
||||||
"ink-spinner": "^5.0.0",
|
"zod": "^4.3.6"
|
||||||
"ink-text-input": "^6.0.0",
|
|
||||||
"react": "^19.2.4"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/better-sqlite3": "^7.6.13",
|
"@types/better-sqlite3": "^7.6.13",
|
||||||
"@types/node": "^25.0.10",
|
"@types/node": "^25.0.10",
|
||||||
"@types/react": "^18.3.18",
|
"@types/react": "^19.2.14",
|
||||||
"tsx": "^4.21.0",
|
"tsx": "^4.21.0",
|
||||||
"typescript": "^5.9.3"
|
"typescript": "^5.9.3"
|
||||||
}
|
}
|
||||||
@@ -42,21 +41,28 @@
|
|||||||
"@xo-cash/crypto": "0.0.1",
|
"@xo-cash/crypto": "0.0.1",
|
||||||
"@xo-cash/primitives": "0.0.1",
|
"@xo-cash/primitives": "0.0.1",
|
||||||
"@xo-cash/state": "0.0.1",
|
"@xo-cash/state": "0.0.1",
|
||||||
"@xo-cash/templates": "0.0.1",
|
|
||||||
"@xo-cash/types": "0.0.1",
|
"@xo-cash/types": "0.0.1",
|
||||||
"@xo-cash/utils": "0.0.1",
|
"@xo-cash/utils": "0.0.1",
|
||||||
"eventemitter3": "^5.0.1"
|
"eventemitter3": "^5.0.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@generalprotocols/eslint-config": "^1.0.1",
|
"@chalp/eslint-airbnb": "^1.3.0",
|
||||||
"@types/node": "^20.10.0",
|
"@generalprotocols/cspell-dictionary": "^1.0.1",
|
||||||
"del-cli": "^7.0.0",
|
"@stylistic/eslint-plugin": "^5.7.0",
|
||||||
"eslint": "^8.57.1",
|
"@typescript-eslint/eslint-plugin": "^8.53.1",
|
||||||
"eslint-config-prettier": "^10.1.8",
|
"@typescript-eslint/parser": "^8.53.1",
|
||||||
"eslint-plugin-prettier": "^5.5.4",
|
"@vitest/coverage-v8": "^4.0.17",
|
||||||
|
"@viz-kit/esbuild-analyzer": "^1.0.0",
|
||||||
|
"@xo-cash/eslint-config": "1.0.1",
|
||||||
|
"cspell": "^9.6.0",
|
||||||
|
"eslint": "^9.39.2",
|
||||||
"prettier": "^3.6.2",
|
"prettier": "^3.6.2",
|
||||||
"typedoc": "^0.28.15",
|
"tsdown": "^0.20.0-beta.4",
|
||||||
"typescript": "^5.3.2"
|
"typedoc": "^0.28.16",
|
||||||
|
"typedoc-plugin-coverage": "^4.0.2",
|
||||||
|
"typescript": "^5.3.2",
|
||||||
|
"typescript-eslint": "^8.53.1",
|
||||||
|
"vitest": "^4.0.17"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18.0.0"
|
"node": ">=18.0.0"
|
||||||
@@ -78,6 +84,7 @@
|
|||||||
"@chalp/eslint-airbnb": "^1.3.0",
|
"@chalp/eslint-airbnb": "^1.3.0",
|
||||||
"@generalprotocols/cspell-dictionary": "^1.0.1",
|
"@generalprotocols/cspell-dictionary": "^1.0.1",
|
||||||
"@stylistic/eslint-plugin": "^5.7.0",
|
"@stylistic/eslint-plugin": "^5.7.0",
|
||||||
|
"@types/better-sqlite3": "^7.6.13",
|
||||||
"@types/node": "^20.10.0",
|
"@types/node": "^20.10.0",
|
||||||
"@typescript-eslint/eslint-plugin": "^8.53.1",
|
"@typescript-eslint/eslint-plugin": "^8.53.1",
|
||||||
"@typescript-eslint/parser": "^8.53.1",
|
"@typescript-eslint/parser": "^8.53.1",
|
||||||
@@ -180,6 +187,54 @@
|
|||||||
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
|
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@electrum-cash/debug-logs": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@electrum-cash/debug-logs/-/debug-logs-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-GU/CvRR9lZ0d8gy9CXGW7f//OHCIydBavv9q+JcxjGj8Xr7HwlGqHx+Wzhx9y3YmJrXfExpgClcd++gTjdEmzA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"debug": "^4.3.7"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@electrum-cash/network": {
|
||||||
|
"version": "4.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@electrum-cash/network/-/network-4.2.2.tgz",
|
||||||
|
"integrity": "sha512-v2Wwt2o0VBBALPArx2dJEDvSqewKjiTW5KAd+jEXxxgdSxFygjJIrFcXryKOCv2CDbpE5+lXAogPAjx6FqW/nw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@electrum-cash/debug-logs": "^1.0.0",
|
||||||
|
"@electrum-cash/web-socket": "^1.2.3",
|
||||||
|
"async-mutex": "^0.5.0",
|
||||||
|
"debug": "^4.3.2",
|
||||||
|
"eventemitter3": "^5.0.1",
|
||||||
|
"lossless-json": "^4.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@electrum-cash/protocol": {
|
||||||
|
"version": "2.3.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@electrum-cash/protocol/-/protocol-2.3.1.tgz",
|
||||||
|
"integrity": "sha512-fVahMlWAl4hfbLba8yM5ko/D4Rc0FpyQ20rILzjOyj1R0VymmaJ3SuiiHmvTbe2enZFyjKTV4v2oL+7bs6YNkw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@bitauth/libauth": "^3.0.0",
|
||||||
|
"@electrum-cash/network": "^4.2.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@electrum-cash/web-socket": {
|
||||||
|
"version": "1.2.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@electrum-cash/web-socket/-/web-socket-1.2.3.tgz",
|
||||||
|
"integrity": "sha512-sFWujTt98mvsMvE6gWjG44evkF3PcZhG1JffkkEUAVan+c9X51wkiWr3hkjPeIW0WfNpMTrFkvpGqVYmRj1RCA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@electrum-cash/debug-logs": "^1.0.0",
|
||||||
|
"@monsterbitar/isomorphic-ws": "^5.3.0",
|
||||||
|
"@types/ws": "^8.5.5",
|
||||||
|
"async-mutex": "^0.5.0",
|
||||||
|
"eventemitter3": "^5.0.1",
|
||||||
|
"lossless-json": "^4.0.1",
|
||||||
|
"ws": "^8.13.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@esbuild/aix-ppc64": {
|
"node_modules/@esbuild/aix-ppc64": {
|
||||||
"version": "0.27.2",
|
"version": "0.27.2",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz",
|
||||||
@@ -622,6 +677,15 @@
|
|||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@monsterbitar/isomorphic-ws": {
|
||||||
|
"version": "5.3.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@monsterbitar/isomorphic-ws/-/isomorphic-ws-5.3.1.tgz",
|
||||||
|
"integrity": "sha512-BWfWUffbg3uO4K6Cyokg9ff43lPaXAOZcCnNe1lcjCjUMDVrRAb5qEHG5qeJp3ud2SPYbORaNsls5as6SR3oig==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"ws": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@sec-ant/readable-stream": {
|
"node_modules/@sec-ant/readable-stream": {
|
||||||
"version": "0.4.1",
|
"version": "0.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/@sec-ant/readable-stream/-/readable-stream-0.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/@sec-ant/readable-stream/-/readable-stream-0.4.1.tgz",
|
||||||
@@ -654,30 +718,30 @@
|
|||||||
"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",
|
||||||
"integrity": "sha512-zWW5KPngR/yvakJgGOmZ5vTBemDoSqF3AcV/LrO5u5wTWyEAVVh+IT39G4gtyAkh3CtTZs8aX/yRM82OfzHJRg==",
|
"integrity": "sha512-zWW5KPngR/yvakJgGOmZ5vTBemDoSqF3AcV/LrO5u5wTWyEAVVh+IT39G4gtyAkh3CtTZs8aX/yRM82OfzHJRg==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"undici-types": "~7.16.0"
|
"undici-types": "~7.16.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@types/prop-types": {
|
|
||||||
"version": "15.7.15",
|
|
||||||
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
|
|
||||||
"integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==",
|
|
||||||
"devOptional": true,
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/@types/react": {
|
"node_modules/@types/react": {
|
||||||
"version": "18.3.27",
|
"version": "19.2.14",
|
||||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.27.tgz",
|
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
|
||||||
"integrity": "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==",
|
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/prop-types": "*",
|
|
||||||
"csstype": "^3.2.2"
|
"csstype": "^3.2.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/ws": {
|
||||||
|
"version": "8.18.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
|
||||||
|
"integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/node": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@xo-cash/engine": {
|
"node_modules/@xo-cash/engine": {
|
||||||
"resolved": "../engine",
|
"resolved": "../engine",
|
||||||
"link": true
|
"link": true
|
||||||
@@ -733,6 +797,15 @@
|
|||||||
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/async-mutex": {
|
||||||
|
"version": "0.5.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/async-mutex/-/async-mutex-0.5.0.tgz",
|
||||||
|
"integrity": "sha512-1A94B18jkJ3DYq284ohPxoXbfTA5HsQ7/Mf4DEhcyLx3Bz27Rh59iScbB6EPiP+B+joue6YCxcMXSbFC1tZKwA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "^2.4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/auto-bind": {
|
"node_modules/auto-bind": {
|
||||||
"version": "5.0.1",
|
"version": "5.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/auto-bind/-/auto-bind-5.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/auto-bind/-/auto-bind-5.0.1.tgz",
|
||||||
@@ -868,18 +941,6 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/cli-spinners": {
|
|
||||||
"version": "2.9.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-2.9.2.tgz",
|
|
||||||
"integrity": "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=6"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/sponsors/sindresorhus"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/cli-truncate": {
|
"node_modules/cli-truncate": {
|
||||||
"version": "5.1.1",
|
"version": "5.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-5.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-5.1.1.tgz",
|
||||||
@@ -973,6 +1034,23 @@
|
|||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/debug": {
|
||||||
|
"version": "4.4.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||||
|
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"ms": "^2.1.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"supports-color": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/decompress-response": {
|
"node_modules/decompress-response": {
|
||||||
"version": "6.0.0",
|
"version": "6.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz",
|
||||||
@@ -1094,6 +1172,12 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/eventemitter3": {
|
||||||
|
"version": "5.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz",
|
||||||
|
"integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/execa": {
|
"node_modules/execa": {
|
||||||
"version": "9.6.1",
|
"version": "9.6.1",
|
||||||
"resolved": "https://registry.npmjs.org/execa/-/execa-9.6.1.tgz",
|
"resolved": "https://registry.npmjs.org/execa/-/execa-9.6.1.tgz",
|
||||||
@@ -1318,56 +1402,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/ink-select-input": {
|
|
||||||
"version": "6.2.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/ink-select-input/-/ink-select-input-6.2.0.tgz",
|
|
||||||
"integrity": "sha512-304fZXxkpYxJ9si5lxRCaX01GNlmPBgOZumXXRnPYbHW/iI31cgQynqk2tRypGLOF1cMIwPUzL2LSm6q4I5rQQ==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"figures": "^6.1.0",
|
|
||||||
"to-rotated": "^1.0.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"ink": ">=5.0.0",
|
|
||||||
"react": ">=18.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/ink-spinner": {
|
|
||||||
"version": "5.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/ink-spinner/-/ink-spinner-5.0.0.tgz",
|
|
||||||
"integrity": "sha512-EYEasbEjkqLGyPOUc8hBJZNuC5GvXGMLu0w5gdTNskPc7Izc5vO3tdQEYnzvshucyGCBXc86ig0ujXPMWaQCdA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"cli-spinners": "^2.7.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=14.16"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"ink": ">=4.0.0",
|
|
||||||
"react": ">=18.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/ink-text-input": {
|
|
||||||
"version": "6.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/ink-text-input/-/ink-text-input-6.0.0.tgz",
|
|
||||||
"integrity": "sha512-Fw64n7Yha5deb1rHY137zHTAbSTNelUKuB5Kkk2HACXEtwIHBCf9OH2tP/LQ9fRYTl1F0dZgbW0zPnZk6FA9Lw==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"chalk": "^5.3.0",
|
|
||||||
"type-fest": "^4.18.2"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"ink": ">=5",
|
|
||||||
"react": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/ink/node_modules/signal-exit": {
|
"node_modules/ink/node_modules/signal-exit": {
|
||||||
"version": "3.0.7",
|
"version": "3.0.7",
|
||||||
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz",
|
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz",
|
||||||
@@ -1537,6 +1571,12 @@
|
|||||||
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
|
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/lossless-json": {
|
||||||
|
"version": "4.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/lossless-json/-/lossless-json-4.3.0.tgz",
|
||||||
|
"integrity": "sha512-ToxOC+SsduRmdSuoLZLYAr5zy1Qu7l5XhmPWM3zefCZ5IcrzW/h108qbJUKfOlDlhvhjUK84+8PSVX0kxnit0g==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/mimic-fn": {
|
"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",
|
||||||
@@ -1573,6 +1613,12 @@
|
|||||||
"integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==",
|
"integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/ms": {
|
||||||
|
"version": "2.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||||
|
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/napi-build-utils": {
|
"node_modules/napi-build-utils": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz",
|
||||||
@@ -2067,17 +2113,11 @@
|
|||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/to-rotated": {
|
"node_modules/tslib": {
|
||||||
"version": "1.0.0",
|
"version": "2.8.1",
|
||||||
"resolved": "https://registry.npmjs.org/to-rotated/-/to-rotated-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||||
"integrity": "sha512-KsEID8AfgUy+pxVRLsWp0VzCa69wxzUDZnzGbyIST/bcgcrMvTYoFBX/QORH4YApoD89EDuUovx4BTdpOn319Q==",
|
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||||
"license": "MIT",
|
"license": "0BSD"
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/sponsors/sindresorhus"
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"node_modules/tsx": {
|
"node_modules/tsx": {
|
||||||
"version": "4.21.0",
|
"version": "4.21.0",
|
||||||
@@ -2141,7 +2181,6 @@
|
|||||||
"version": "7.16.0",
|
"version": "7.16.0",
|
||||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz",
|
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz",
|
||||||
"integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==",
|
"integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/unicorn-magic": {
|
"node_modules/unicorn-magic": {
|
||||||
@@ -2253,6 +2292,15 @@
|
|||||||
"resolved": "https://registry.npmjs.org/yoga-layout/-/yoga-layout-3.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/yoga-layout/-/yoga-layout-3.2.1.tgz",
|
||||||
"integrity": "sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ==",
|
"integrity": "sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/zod": {
|
||||||
|
"version": "4.3.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz",
|
||||||
|
"integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/colinhacks"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,7 @@
|
|||||||
"description": "XO Wallet CLI - Terminal User Interface for XO crypto wallet",
|
"description": "XO Wallet CLI - Terminal User Interface for XO crypto wallet",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@bitauth/libauth": "^3.0.0",
|
"@bitauth/libauth": "^3.0.0",
|
||||||
|
"@electrum-cash/protocol": "^2.3.1",
|
||||||
"@xo-cash/engine": "file:../engine",
|
"@xo-cash/engine": "file:../engine",
|
||||||
"@xo-cash/state": "file:../state",
|
"@xo-cash/state": "file:../state",
|
||||||
"@xo-cash/templates": "file:../templates",
|
"@xo-cash/templates": "file:../templates",
|
||||||
@@ -29,15 +30,13 @@
|
|||||||
"better-sqlite3": "^12.6.2",
|
"better-sqlite3": "^12.6.2",
|
||||||
"clipboardy": "^5.1.0",
|
"clipboardy": "^5.1.0",
|
||||||
"ink": "^6.6.0",
|
"ink": "^6.6.0",
|
||||||
"ink-select-input": "^6.0.0",
|
"react": "^19.2.4",
|
||||||
"ink-spinner": "^5.0.0",
|
"zod": "^4.3.6"
|
||||||
"ink-text-input": "^6.0.0",
|
|
||||||
"react": "^19.2.4"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/better-sqlite3": "^7.6.13",
|
"@types/better-sqlite3": "^7.6.13",
|
||||||
"@types/node": "^25.0.10",
|
"@types/node": "^25.0.10",
|
||||||
"@types/react": "^18.3.18",
|
"@types/react": "^19.2.14",
|
||||||
"tsx": "^4.21.0",
|
"tsx": "^4.21.0",
|
||||||
"typescript": "^5.9.3"
|
"typescript": "^5.9.3"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -76,6 +76,8 @@ export class App {
|
|||||||
|
|
||||||
// Wait for the app to exit
|
// Wait for the app to exit
|
||||||
await this.inkInstance.waitUntilExit();
|
await this.inkInstance.waitUntilExit();
|
||||||
|
|
||||||
|
process.exit(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { Invitation } from './invitation.js';
|
|||||||
import { Storage } from './storage.js';
|
import { Storage } from './storage.js';
|
||||||
import { SyncServer } from '../utils/sync-server.js';
|
import { SyncServer } from '../utils/sync-server.js';
|
||||||
import { HistoryService } from './history.js';
|
import { HistoryService } from './history.js';
|
||||||
|
import { ElectrumService } from './electrum.js';
|
||||||
|
|
||||||
import { EventEmitter } from '../utils/event-emitter.js';
|
import { EventEmitter } from '../utils/event-emitter.js';
|
||||||
|
|
||||||
@@ -26,6 +27,8 @@ export interface AppConfig {
|
|||||||
syncServerUrl: string;
|
syncServerUrl: string;
|
||||||
engineConfig: XOEngineOptions;
|
engineConfig: XOEngineOptions;
|
||||||
invitationStoragePath: string;
|
invitationStoragePath: string;
|
||||||
|
electrumHost?: string;
|
||||||
|
electrumApplicationIdentifier?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class AppService extends EventEmitter<AppEventMap> {
|
export class AppService extends EventEmitter<AppEventMap> {
|
||||||
@@ -33,6 +36,7 @@ export class AppService extends EventEmitter<AppEventMap> {
|
|||||||
public storage: Storage;
|
public storage: Storage;
|
||||||
public config: AppConfig;
|
public config: AppConfig;
|
||||||
public history: HistoryService;
|
public history: HistoryService;
|
||||||
|
public electrum: ElectrumService;
|
||||||
|
|
||||||
public invitations: Invitation[] = [];
|
public invitations: Invitation[] = [];
|
||||||
|
|
||||||
@@ -68,15 +72,21 @@ export class AppService extends EventEmitter<AppEventMap> {
|
|||||||
const walletStorage = await storage.child(seedHash.slice(0, 8))
|
const walletStorage = await storage.child(seedHash.slice(0, 8))
|
||||||
|
|
||||||
// Create the app service
|
// Create the app service
|
||||||
return new AppService(engine, walletStorage, config);
|
const electrum = new ElectrumService({
|
||||||
|
host: config.electrumHost,
|
||||||
|
applicationIdentifier: config.electrumApplicationIdentifier,
|
||||||
|
});
|
||||||
|
|
||||||
|
return new AppService(engine, walletStorage, config, electrum);
|
||||||
}
|
}
|
||||||
|
|
||||||
constructor(engine: Engine, storage: Storage, config: AppConfig) {
|
constructor(engine: Engine, storage: Storage, config: AppConfig, electrum: ElectrumService) {
|
||||||
super();
|
super();
|
||||||
|
|
||||||
this.engine = engine;
|
this.engine = engine;
|
||||||
this.storage = storage;
|
this.storage = storage;
|
||||||
this.config = config;
|
this.config = config;
|
||||||
|
this.electrum = electrum;
|
||||||
this.history = new HistoryService(engine, this.invitations);
|
this.history = new HistoryService(engine, this.invitations);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -89,6 +99,7 @@ export class AppService extends EventEmitter<AppEventMap> {
|
|||||||
engine: this.engine,
|
engine: this.engine,
|
||||||
syncServer: invitationSyncServer,
|
syncServer: invitationSyncServer,
|
||||||
storage: invitationStorage,
|
storage: invitationStorage,
|
||||||
|
electrum: this.electrum,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Create the invitation
|
// Create the invitation
|
||||||
|
|||||||
46
src/services/electrum.ts
Normal file
46
src/services/electrum.ts
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import { fetchTransactionBlockHeight, initializeElectrumClient } from '@electrum-cash/protocol';
|
||||||
|
|
||||||
|
export interface ElectrumServiceConfig {
|
||||||
|
host?: string;
|
||||||
|
applicationIdentifier?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Small Electrum adapter used by CLI services.
|
||||||
|
* Keeps connection logic in one place and exposes a tiny API.
|
||||||
|
*/
|
||||||
|
export class ElectrumService {
|
||||||
|
private readonly host: string;
|
||||||
|
private readonly applicationIdentifier: string;
|
||||||
|
private clientPromise?: ReturnType<typeof initializeElectrumClient>;
|
||||||
|
|
||||||
|
constructor(config: ElectrumServiceConfig = {}) {
|
||||||
|
this.host = config.host ?? process.env['ELECTRUM_HOST'] ?? 'bch.imaginary.cash';
|
||||||
|
this.applicationIdentifier = 'xo-cli';
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getClient() {
|
||||||
|
if (!this.clientPromise) {
|
||||||
|
this.clientPromise = initializeElectrumClient(this.applicationIdentifier, this.host);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.clientPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns true when the transaction is known by Electrum
|
||||||
|
* (confirmed or currently in mempool).
|
||||||
|
*/
|
||||||
|
async hasSeenTransaction(transactionHash: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const client = await this.getClient();
|
||||||
|
const height = await fetchTransactionBlockHeight(client, transactionHash);
|
||||||
|
|
||||||
|
// Electrum returns numbers for known transactions
|
||||||
|
// (e.g. >0 confirmed, 0/-1 unconfirmed variants).
|
||||||
|
return typeof height === 'number';
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,247 +1,582 @@
|
|||||||
/**
|
|
||||||
* History Service - Derives wallet history from invitations and UTXOs.
|
|
||||||
*
|
|
||||||
* Provides a unified view of wallet activity including:
|
|
||||||
* - UTXO reservations (from invitation commits that reference our UTXOs as inputs)
|
|
||||||
* - UTXOs we own (with descriptions derived from template outputs)
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { type Engine, compileCashAssemblyString } from '@xo-cash/engine';
|
|
||||||
import type { XOInvitation, XOInvitationVariableValue, XOTemplate } from '@xo-cash/types';
|
|
||||||
import type { UnspentOutputData } from '@xo-cash/state';
|
|
||||||
import type { Invitation } from './invitation.js';
|
|
||||||
import { binToHex } from '@bitauth/libauth';
|
import { binToHex } from '@bitauth/libauth';
|
||||||
|
import { compileCashAssemblyString, type Engine } from '@xo-cash/engine';
|
||||||
|
import type { UnspentOutputData } from '@xo-cash/state';
|
||||||
|
import type { XOInvitation, XOInvitationCommit, XOInvitationVariableValue, XOTemplate } from '@xo-cash/types';
|
||||||
|
import type { Invitation } from './invitation.js';
|
||||||
|
|
||||||
/**
|
export type HistoryEntryKind = 'invitation' | 'utxo';
|
||||||
* Types of history events.
|
|
||||||
*/
|
|
||||||
export type HistoryItemType =
|
|
||||||
| 'utxo_received'
|
|
||||||
| 'utxo_reserved'
|
|
||||||
| 'invitation_created';
|
|
||||||
|
|
||||||
/**
|
export interface HistoryDescriptionParts {
|
||||||
* A single item in the wallet history.
|
template: string;
|
||||||
*/
|
role: string;
|
||||||
export interface HistoryItem {
|
outputIdentifier: string;
|
||||||
/** 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;
|
description: string;
|
||||||
|
valueSatoshis?: number;
|
||||||
|
}
|
||||||
|
|
||||||
/** The value in satoshis (for UTXO-related events). */
|
export interface HistoryUtxoItem {
|
||||||
valueSatoshis?: bigint;
|
kind: 'utxo';
|
||||||
|
id: string;
|
||||||
/** The invitation identifier this event relates to (if applicable). */
|
|
||||||
invitationIdentifier?: string;
|
invitationIdentifier?: string;
|
||||||
|
templateIdentifier: string;
|
||||||
/** The template identifier for reference. */
|
outputIdentifier: string;
|
||||||
templateIdentifier?: string;
|
outpoint: {
|
||||||
|
|
||||||
/** The UTXO outpoint (for UTXO-related events). */
|
|
||||||
outpoint?: {
|
|
||||||
txid: string;
|
txid: string;
|
||||||
index: number;
|
index: number;
|
||||||
};
|
};
|
||||||
|
valueSatoshis?: bigint;
|
||||||
/** Whether this UTXO is reserved. */
|
|
||||||
reserved?: boolean;
|
reserved?: boolean;
|
||||||
|
direction: 'input' | 'output' | 'standalone';
|
||||||
|
description: string;
|
||||||
|
descriptionParts: HistoryDescriptionParts;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HistoryInvitationItem {
|
||||||
|
kind: 'invitation';
|
||||||
|
id: string;
|
||||||
|
createdAtTimestamp: number;
|
||||||
|
templateIdentifier: string;
|
||||||
|
invitationIdentifier: string;
|
||||||
|
roles: string[];
|
||||||
|
description: string;
|
||||||
|
descriptionParts: {
|
||||||
|
template: string;
|
||||||
|
roles: string[];
|
||||||
|
description: string;
|
||||||
|
};
|
||||||
|
inputs: HistoryUtxoItem[];
|
||||||
|
outputs: HistoryUtxoItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export type HistoryItem = HistoryInvitationItem | HistoryUtxoItem;
|
||||||
|
|
||||||
|
interface InvitationContext {
|
||||||
|
invitation: Invitation;
|
||||||
|
template: XOTemplate | null;
|
||||||
|
variables: Record<string, XOInvitationVariableValue>;
|
||||||
|
walletEntityIdentifier?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UtxoOriginContext {
|
||||||
|
invitationIdentifier: string;
|
||||||
|
roleIdentifier?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Service for deriving wallet history from invitations and UTXOs.
|
|
||||||
*
|
|
||||||
* This service takes the engine and invitations array as dependencies
|
|
||||||
* and derives history events from them. Since invitations is passed
|
|
||||||
* by reference, getHistory() always sees the current data.
|
|
||||||
*/
|
|
||||||
export class HistoryService {
|
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(
|
constructor(
|
||||||
private engine: Engine,
|
private engine: Engine,
|
||||||
private invitations: Invitation[]
|
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[]> {
|
async getHistory(): Promise<HistoryItem[]> {
|
||||||
const items: HistoryItem[] = [];
|
|
||||||
|
|
||||||
// 1. Get all our UTXOs
|
|
||||||
const allUtxos = await this.engine.listUnspentOutputsData();
|
const allUtxos = await this.engine.listUnspentOutputsData();
|
||||||
|
const ownOutpoints = new Set<string>();
|
||||||
|
const ownLockingBytecodes = new Set<string>();
|
||||||
|
const invitationByOrigin = new Map<string, UtxoOriginContext>();
|
||||||
|
const outpointValueSatoshis = new Map<string, bigint>();
|
||||||
|
|
||||||
// Create a map for quick UTXO lookup by outpoint
|
|
||||||
const utxoMap = new Map<string, UnspentOutputData>();
|
|
||||||
for (const utxo of allUtxos) {
|
for (const utxo of allUtxos) {
|
||||||
const key = `${utxo.outpointTransactionHash}:${utxo.outpointIndex}`;
|
const outpointKey = this.getOutpointKey(utxo.outpointTransactionHash, utxo.outpointIndex);
|
||||||
utxoMap.set(key, utxo);
|
ownOutpoints.add(outpointKey);
|
||||||
|
ownLockingBytecodes.add(utxo.lockingBytecode);
|
||||||
|
outpointValueSatoshis.set(outpointKey, BigInt(utxo.valueSatoshis));
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Process invitations to find UTXO reservations from commits
|
const contexts = new Map<string, InvitationContext>();
|
||||||
for (const invitation of this.invitations) {
|
for (const invitation of this.invitations) {
|
||||||
const invData = invitation.data;
|
const variables = this.extractInvitationVariables(invitation.data);
|
||||||
|
const template = await this.engine.getTemplate(invitation.data.templateIdentifier) ?? null;
|
||||||
// Add invitation created event
|
const walletEntityIdentifier = this.resolveWalletEntityIdentifier(invitation, ownOutpoints, ownLockingBytecodes);
|
||||||
const template = await this.engine.getTemplate(invData.templateIdentifier);
|
contexts.set(invitation.data.invitationIdentifier, {
|
||||||
const invDescription = template
|
invitation,
|
||||||
? this.deriveInvitationDescription(invData, template)
|
template,
|
||||||
: 'Unknown action';
|
variables,
|
||||||
|
walletEntityIdentifier,
|
||||||
items.push({
|
|
||||||
id: `inv-${invData.invitationIdentifier}`,
|
|
||||||
timestamp: invData.createdAtTimestamp,
|
|
||||||
type: 'invitation_created',
|
|
||||||
description: invDescription,
|
|
||||||
invitationIdentifier: invData.invitationIdentifier,
|
|
||||||
templateIdentifier: invData.templateIdentifier,
|
|
||||||
});
|
});
|
||||||
|
this.indexInvitationOutputsByUtxoOrigin(invitationByOrigin, invitation);
|
||||||
|
}
|
||||||
|
|
||||||
// Check each commit for inputs that reference our UTXOs
|
const usedUtxoIds = new Set<string>();
|
||||||
for (const commit of invData.commits) {
|
const invitationItems: HistoryInvitationItem[] = [];
|
||||||
const commitInputs = commit.data.inputs ?? [];
|
|
||||||
|
|
||||||
for (const input of commitInputs) {
|
for (const context of contexts.values()) {
|
||||||
// Input's outpointTransactionHash could be Uint8Array or string
|
const invitation = context.invitation.data;
|
||||||
const txHash = input.outpointTransactionHash
|
const templateName = context.template?.name ?? 'UnknownTemplate';
|
||||||
? (input.outpointTransactionHash instanceof Uint8Array
|
const invitationOutputs = this.buildWalletOutputItemsForInvitation(
|
||||||
? binToHex(input.outpointTransactionHash)
|
context,
|
||||||
: String(input.outpointTransactionHash))
|
allUtxos,
|
||||||
: undefined;
|
invitationByOrigin,
|
||||||
|
usedUtxoIds,
|
||||||
|
);
|
||||||
|
const roles = this.deriveWalletRolesForInvitation(context, invitationOutputs);
|
||||||
|
const invitationInputs = this.buildWalletInputItemsForInvitation(
|
||||||
|
context,
|
||||||
|
roles[0],
|
||||||
|
invitationOutputs.length > 0,
|
||||||
|
outpointValueSatoshis,
|
||||||
|
);
|
||||||
|
const invitationDescription = this.deriveInvitationDescription(invitation, context.template, context.variables, roles[0]);
|
||||||
|
|
||||||
if (!txHash || input.outpointIndex === undefined) continue;
|
invitationItems.push({
|
||||||
|
kind: 'invitation',
|
||||||
|
id: `inv-${invitation.invitationIdentifier}`,
|
||||||
|
createdAtTimestamp: invitation.createdAtTimestamp,
|
||||||
|
templateIdentifier: invitation.templateIdentifier,
|
||||||
|
invitationIdentifier: invitation.invitationIdentifier,
|
||||||
|
roles,
|
||||||
|
description: invitationDescription,
|
||||||
|
descriptionParts: {
|
||||||
|
template: templateName,
|
||||||
|
roles,
|
||||||
|
description: invitationDescription,
|
||||||
|
},
|
||||||
|
inputs: invitationInputs,
|
||||||
|
outputs: invitationOutputs,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const utxoKey = `${txHash}:${input.outpointIndex}`;
|
invitationItems.sort((a, b) => b.createdAtTimestamp - a.createdAtTimestamp);
|
||||||
const matchingUtxo = utxoMap.get(utxoKey);
|
|
||||||
|
|
||||||
// If this input references one of our UTXOs, it's a reservation event
|
const standaloneUtxos: HistoryUtxoItem[] = [];
|
||||||
if (matchingUtxo) {
|
for (const utxo of allUtxos) {
|
||||||
const utxoTemplate = await this.engine.getTemplate(matchingUtxo.templateIdentifier);
|
const utxoId = this.getUtxoId(utxo);
|
||||||
const utxoDescription = utxoTemplate
|
if (usedUtxoIds.has(utxoId)) continue;
|
||||||
? this.deriveUtxoDescription(matchingUtxo, utxoTemplate)
|
|
||||||
: 'Unknown UTXO';
|
|
||||||
|
|
||||||
items.push({
|
const template = await this.engine.getTemplate(utxo.templateIdentifier) ?? null;
|
||||||
id: `reserved-${commit.commitIdentifier}-${utxoKey}`,
|
const inferredRole = this.inferRoleFromOutputIdentifier(utxo.outputIdentifier);
|
||||||
timestamp: invData.createdAtTimestamp, // Use invitation timestamp as proxy
|
const description = this.deriveUtxoDescription(utxo, template, {}, inferredRole);
|
||||||
type: 'utxo_reserved',
|
standaloneUtxos.push(this.buildUtxoHistoryItem(
|
||||||
description: `Reserved for: ${invDescription}`,
|
utxo,
|
||||||
valueSatoshis: BigInt(matchingUtxo.valueSatoshis),
|
description,
|
||||||
invitationIdentifier: invData.invitationIdentifier,
|
template?.name ?? 'UnknownTemplate',
|
||||||
templateIdentifier: matchingUtxo.templateIdentifier,
|
inferredRole,
|
||||||
outpoint: {
|
'standalone',
|
||||||
txid: txHash,
|
));
|
||||||
index: input.outpointIndex,
|
}
|
||||||
},
|
|
||||||
reserved: true,
|
return [ ...invitationItems, ...standaloneUtxos ];
|
||||||
});
|
}
|
||||||
}
|
|
||||||
|
private buildWalletOutputItemsForInvitation(
|
||||||
|
context: InvitationContext,
|
||||||
|
allUtxos: UnspentOutputData[],
|
||||||
|
invitationByOrigin: Map<string, UtxoOriginContext>,
|
||||||
|
usedUtxoIds: Set<string>
|
||||||
|
): HistoryUtxoItem[] {
|
||||||
|
const invitationId = context.invitation.data.invitationIdentifier;
|
||||||
|
const outputs: HistoryUtxoItem[] = [];
|
||||||
|
|
||||||
|
for (const utxo of allUtxos) {
|
||||||
|
const resolvedInvitationId = this.resolveInvitationIdentifierForUtxo(utxo, invitationByOrigin);
|
||||||
|
if (resolvedInvitationId !== invitationId) continue;
|
||||||
|
|
||||||
|
const role = this.resolveRoleIdentifierForUtxo(utxo, invitationByOrigin)
|
||||||
|
?? this.inferRoleFromOutputIdentifier(utxo.outputIdentifier)
|
||||||
|
?? 'receiver';
|
||||||
|
const description = this.deriveUtxoDescription(utxo, context.template, context.variables, role);
|
||||||
|
outputs.push(this.buildUtxoHistoryItem(utxo, description, context.template?.name ?? 'UnknownTemplate', role, 'output'));
|
||||||
|
usedUtxoIds.add(this.getUtxoId(utxo));
|
||||||
|
}
|
||||||
|
|
||||||
|
return outputs;
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildWalletInputItemsForInvitation(
|
||||||
|
context: InvitationContext,
|
||||||
|
walletRole?: string,
|
||||||
|
hasWalletOutputs: boolean = false,
|
||||||
|
outpointValueSatoshis: Map<string, bigint> = new Map(),
|
||||||
|
): HistoryUtxoItem[] {
|
||||||
|
const invitation = context.invitation.data;
|
||||||
|
const commits = invitation.commits ?? [];
|
||||||
|
const commitsByEntity = context.walletEntityIdentifier
|
||||||
|
? commits.filter((commit) => commit.entityIdentifier === context.walletEntityIdentifier)
|
||||||
|
: [];
|
||||||
|
const commitsByRole = walletRole
|
||||||
|
? commits.filter((commit) => this.deriveCommitRoleIdentifier(commit, invitation, context.template) === walletRole)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
let relevantCommits = commitsByEntity.filter((commit) => (commit.data.inputs?.length ?? 0) > 0);
|
||||||
|
if (relevantCommits.length === 0) {
|
||||||
|
relevantCommits = commitsByRole.filter((commit) => (commit.data.inputs?.length ?? 0) > 0);
|
||||||
|
}
|
||||||
|
if (relevantCommits.length === 0 && walletRole === 'sender') {
|
||||||
|
relevantCommits = commits.filter((commit) => (commit.data.inputs?.length ?? 0) > 0);
|
||||||
|
}
|
||||||
|
// Sender fallback only when no wallet outputs were matched.
|
||||||
|
if (relevantCommits.length === 0 && !hasWalletOutputs) {
|
||||||
|
relevantCommits = commits.filter((commit) => (commit.data.inputs?.length ?? 0) > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
const txDescription = this.deriveTransactionActivityDescription(
|
||||||
|
invitation,
|
||||||
|
context.template,
|
||||||
|
context.variables,
|
||||||
|
walletRole,
|
||||||
|
);
|
||||||
|
|
||||||
|
const inputs: HistoryUtxoItem[] = [];
|
||||||
|
for (const commit of relevantCommits) {
|
||||||
|
for (const input of commit.data.inputs ?? []) {
|
||||||
|
const txHash = input.outpointTransactionHash
|
||||||
|
? (input.outpointTransactionHash instanceof Uint8Array
|
||||||
|
? binToHex(input.outpointTransactionHash)
|
||||||
|
: String(input.outpointTransactionHash))
|
||||||
|
: 'unknown-tx';
|
||||||
|
const inputIndex = input.outpointIndex ?? -1;
|
||||||
|
const inputIdentifier = input.inputIdentifier ?? 'input';
|
||||||
|
const inputDescription = this.deriveInputDescription(inputIdentifier, context.template, context.variables);
|
||||||
|
const templateName = context.template?.name ?? 'UnknownTemplate';
|
||||||
|
const role = walletRole ?? 'sender';
|
||||||
|
const inputValue = this.resolveInputSatoshis(txHash, inputIndex, outpointValueSatoshis, context.variables);
|
||||||
|
|
||||||
|
inputs.push({
|
||||||
|
kind: 'utxo',
|
||||||
|
id: `input-${invitation.invitationIdentifier}-${commit.commitIdentifier}-${txHash}:${inputIndex}-${inputIdentifier}`,
|
||||||
|
invitationIdentifier: invitation.invitationIdentifier,
|
||||||
|
templateIdentifier: invitation.templateIdentifier,
|
||||||
|
outputIdentifier: inputIdentifier,
|
||||||
|
outpoint: {
|
||||||
|
txid: txHash,
|
||||||
|
index: inputIndex,
|
||||||
|
},
|
||||||
|
direction: 'input',
|
||||||
|
valueSatoshis: inputValue,
|
||||||
|
description: `${txDescription} - ${inputDescription}`,
|
||||||
|
descriptionParts: {
|
||||||
|
template: templateName,
|
||||||
|
role,
|
||||||
|
outputIdentifier: inputIdentifier,
|
||||||
|
description: `${txDescription} - ${inputDescription}`,
|
||||||
|
valueSatoshis: inputValue !== undefined ? Number(inputValue) : undefined,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return inputs;
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildUtxoHistoryItem(
|
||||||
|
utxo: UnspentOutputData,
|
||||||
|
description: string,
|
||||||
|
templateName: string,
|
||||||
|
roleIdentifier: string | undefined,
|
||||||
|
direction: HistoryUtxoItem['direction']
|
||||||
|
): HistoryUtxoItem {
|
||||||
|
return {
|
||||||
|
kind: 'utxo',
|
||||||
|
id: this.getUtxoId(utxo),
|
||||||
|
invitationIdentifier: utxo.invitationIdentifier || undefined,
|
||||||
|
templateIdentifier: utxo.templateIdentifier,
|
||||||
|
outputIdentifier: utxo.outputIdentifier,
|
||||||
|
outpoint: {
|
||||||
|
txid: utxo.outpointTransactionHash,
|
||||||
|
index: utxo.outpointIndex,
|
||||||
|
},
|
||||||
|
valueSatoshis: BigInt(utxo.valueSatoshis),
|
||||||
|
reserved: utxo.reserved,
|
||||||
|
direction,
|
||||||
|
description,
|
||||||
|
descriptionParts: {
|
||||||
|
template: templateName,
|
||||||
|
role: roleIdentifier ?? 'unknown',
|
||||||
|
outputIdentifier: utxo.outputIdentifier,
|
||||||
|
description,
|
||||||
|
valueSatoshis: utxo.valueSatoshis,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private deriveWalletRolesForInvitation(
|
||||||
|
context: InvitationContext,
|
||||||
|
outputs: HistoryUtxoItem[]
|
||||||
|
): string[] {
|
||||||
|
const roles = new Set<string>();
|
||||||
|
for (const output of outputs) {
|
||||||
|
const outputRole = output.descriptionParts.role;
|
||||||
|
if (outputRole && outputRole !== 'unknown') {
|
||||||
|
roles.add(outputRole);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (roles.size === 0 && outputs.length > 0) {
|
||||||
|
roles.add('receiver');
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasInputCommit = (context.walletEntityIdentifier
|
||||||
|
? context.invitation.data.commits.filter((c) => c.entityIdentifier === context.walletEntityIdentifier)
|
||||||
|
: context.invitation.data.commits
|
||||||
|
).some((c) => (c.data.inputs?.length ?? 0) > 0);
|
||||||
|
|
||||||
|
if (hasInputCommit) roles.add('sender');
|
||||||
|
if (!hasInputCommit && outputs.length === 0 && context.invitation.data.commits.some((c) => (c.data.inputs?.length ?? 0) > 0)) {
|
||||||
|
roles.add('sender');
|
||||||
|
}
|
||||||
|
if (roles.size === 0) {
|
||||||
|
const inferred = this.extractInvitationRoleIdentifier(context.invitation.data, context.template, context.walletEntityIdentifier);
|
||||||
|
if (inferred) roles.add(inferred);
|
||||||
|
}
|
||||||
|
|
||||||
|
return roles.size > 0 ? Array.from(roles) : [ 'unknown' ];
|
||||||
|
}
|
||||||
|
|
||||||
|
private extractInvitationVariables(invitation: XOInvitation): Record<string, XOInvitationVariableValue> {
|
||||||
|
const committedVariables = invitation.commits.flatMap(c => c.data.variables ?? []);
|
||||||
|
return committedVariables.reduce((acc, variable) => {
|
||||||
|
if (!variable.variableIdentifier) return acc;
|
||||||
|
acc[variable.variableIdentifier] = variable.value;
|
||||||
|
return acc;
|
||||||
|
}, {} as Record<string, XOInvitationVariableValue>);
|
||||||
|
}
|
||||||
|
|
||||||
|
private indexInvitationOutputsByUtxoOrigin(
|
||||||
|
invitationByUtxoOrigin: Map<string, UtxoOriginContext>,
|
||||||
|
invitation: Invitation
|
||||||
|
): void {
|
||||||
|
for (const commit of invitation.data.commits) {
|
||||||
|
for (const output of commit.data.outputs ?? []) {
|
||||||
|
if (!output.outputIdentifier || !output.lockingBytecode) continue;
|
||||||
|
const lockingBytecodeHex = this.toLockingBytecodeHex(output.lockingBytecode);
|
||||||
|
const key = this.getUtxoOriginKey(invitation.data.templateIdentifier, output.outputIdentifier, lockingBytecodeHex);
|
||||||
|
invitationByUtxoOrigin.set(key, {
|
||||||
|
invitationIdentifier: invitation.data.invitationIdentifier,
|
||||||
|
roleIdentifier: output.roleIdentifier,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private resolveInvitationIdentifierForUtxo(
|
||||||
|
utxo: UnspentOutputData,
|
||||||
|
invitationByUtxoOrigin: Map<string, UtxoOriginContext>
|
||||||
|
): string | undefined {
|
||||||
|
if (utxo.invitationIdentifier) return utxo.invitationIdentifier;
|
||||||
|
const originKey = this.getUtxoOriginKey(utxo.templateIdentifier, utxo.outputIdentifier, utxo.lockingBytecode);
|
||||||
|
return invitationByUtxoOrigin.get(originKey)?.invitationIdentifier;
|
||||||
|
}
|
||||||
|
|
||||||
|
private resolveRoleIdentifierForUtxo(
|
||||||
|
utxo: UnspentOutputData,
|
||||||
|
invitationByUtxoOrigin: Map<string, UtxoOriginContext>
|
||||||
|
): string | undefined {
|
||||||
|
const originKey = this.getUtxoOriginKey(utxo.templateIdentifier, utxo.outputIdentifier, utxo.lockingBytecode);
|
||||||
|
return invitationByUtxoOrigin.get(originKey)?.roleIdentifier;
|
||||||
|
}
|
||||||
|
|
||||||
|
private resolveWalletEntityIdentifier(
|
||||||
|
invitation: Invitation,
|
||||||
|
ownUtxoOutpointKeys: Set<string>,
|
||||||
|
ownLockingBytecodes: Set<string>
|
||||||
|
): string | undefined {
|
||||||
|
const scores = new Map<string, number>();
|
||||||
|
const addScore = (entityIdentifier: string, delta: number): void => {
|
||||||
|
scores.set(entityIdentifier, (scores.get(entityIdentifier) ?? 0) + delta);
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const commit of invitation.data.commits) {
|
||||||
|
for (const input of commit.data.inputs ?? []) {
|
||||||
|
const txHash = input.outpointTransactionHash
|
||||||
|
? (input.outpointTransactionHash instanceof Uint8Array
|
||||||
|
? binToHex(input.outpointTransactionHash)
|
||||||
|
: String(input.outpointTransactionHash))
|
||||||
|
: undefined;
|
||||||
|
if (!txHash || input.outpointIndex === undefined) continue;
|
||||||
|
if (ownUtxoOutpointKeys.has(this.getOutpointKey(txHash, input.outpointIndex))) {
|
||||||
|
addScore(commit.entityIdentifier, 3);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const output of commit.data.outputs ?? []) {
|
||||||
|
const lockingBytecodeHex = output.lockingBytecode ? this.toLockingBytecodeHex(output.lockingBytecode) : undefined;
|
||||||
|
if (!lockingBytecodeHex) continue;
|
||||||
|
if (ownLockingBytecodes.has(lockingBytecodeHex)) {
|
||||||
|
addScore(commit.entityIdentifier, 2);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Add all UTXOs as "received" events (without timestamps)
|
let bestEntity: string | undefined;
|
||||||
for (const utxo of allUtxos) {
|
let bestScore = 0;
|
||||||
const template = await this.engine.getTemplate(utxo.templateIdentifier);
|
for (const [ entity, score ] of scores.entries()) {
|
||||||
const description = template
|
if (score > bestScore) {
|
||||||
? this.deriveUtxoDescription(utxo, template)
|
bestScore = score;
|
||||||
: 'Unknown output';
|
bestEntity = entity;
|
||||||
|
|
||||||
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;
|
return bestEntity;
|
||||||
// Only b has timestamp: b comes first
|
|
||||||
if (b.timestamp !== undefined) return 1;
|
|
||||||
// Neither has timestamp: maintain order
|
|
||||||
return 0;
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
private deriveUtxoDescription(
|
||||||
* Derives a human-readable description for a UTXO from its template output definition.
|
utxo: UnspentOutputData,
|
||||||
*
|
template: XOTemplate | null,
|
||||||
* @param utxo - The UTXO data.
|
variables: Record<string, XOInvitationVariableValue>,
|
||||||
* @param template - The template definition.
|
roleIdentifier?: string
|
||||||
* @returns Human-readable description string.
|
): string {
|
||||||
*/
|
const templateName = template?.name ?? 'UnknownTemplate';
|
||||||
private deriveUtxoDescription(utxo: UnspentOutputData, template: XOTemplate): string {
|
const role = roleIdentifier ?? 'unknown';
|
||||||
const outputDef = template.outputs?.[utxo.outputIdentifier];
|
const outputDef = template?.outputs?.[utxo.outputIdentifier];
|
||||||
|
let detail = outputDef?.name ?? utxo.outputIdentifier;
|
||||||
if (!outputDef) {
|
if (outputDef?.description) {
|
||||||
return `[${template.name}] ${utxo.outputIdentifier} output`;
|
try {
|
||||||
|
detail = compileCashAssemblyString(outputDef.description, variables);
|
||||||
|
} catch {
|
||||||
|
detail = this.interpolateSimpleCashAssemblyVariables(outputDef.description, variables);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
return `[${templateName}:${role}] ${detail}`;
|
||||||
// Start with the output name or identifier
|
|
||||||
let description = outputDef.name || utxo.outputIdentifier;
|
|
||||||
|
|
||||||
// If there's a description, parse it and replace variable placeholders
|
|
||||||
if (outputDef.description) {
|
|
||||||
description = compileCashAssemblyString(outputDef.description, {})
|
|
||||||
}
|
|
||||||
|
|
||||||
return description;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
private deriveInvitationDescription(
|
||||||
* Derives a human-readable description from an invitation and its template.
|
invitation: XOInvitation,
|
||||||
* Parses the transaction description and replaces variable placeholders.
|
template: XOTemplate | null,
|
||||||
*
|
variables: Record<string, XOInvitationVariableValue>,
|
||||||
* @param invitation - The invitation data.
|
roleIdentifier?: string
|
||||||
* @param template - The template definition.
|
): string {
|
||||||
* @returns Human-readable description string.
|
if (!template) return invitation.actionIdentifier;
|
||||||
*/
|
|
||||||
private deriveInvitationDescription(invitation: XOInvitation, template: XOTemplate): string {
|
|
||||||
const action = template.actions?.[invitation.actionIdentifier];
|
const action = template.actions?.[invitation.actionIdentifier];
|
||||||
const transactionName = action?.transaction;
|
const transactionName = action?.transaction;
|
||||||
const transaction = transactionName ? template.transactions?.[transactionName] : null;
|
const transaction = transactionName ? template.transactions?.[transactionName] : null;
|
||||||
|
const role = roleIdentifier ?? 'unknown';
|
||||||
|
const baseTemplate = transaction?.description ?? action?.description ?? action?.name ?? invitation.actionIdentifier;
|
||||||
|
let detail = baseTemplate;
|
||||||
|
try {
|
||||||
|
detail = compileCashAssemblyString(baseTemplate, variables);
|
||||||
|
} catch {
|
||||||
|
detail = this.interpolateSimpleCashAssemblyVariables(baseTemplate, variables);
|
||||||
|
}
|
||||||
|
return `[${template.name}:${role}] ${detail}`;
|
||||||
|
}
|
||||||
|
|
||||||
if (!transaction?.description) {
|
private deriveInputDescription(
|
||||||
return action?.name ?? invitation.actionIdentifier;
|
inputIdentifier: string,
|
||||||
|
template: XOTemplate | null,
|
||||||
|
variables: Record<string, XOInvitationVariableValue>
|
||||||
|
): string {
|
||||||
|
if (inputIdentifier === 'input') return 'Funding input';
|
||||||
|
const inputDef = template?.inputs?.[inputIdentifier];
|
||||||
|
if (!inputDef) return inputIdentifier;
|
||||||
|
if (!inputDef.description) return inputDef.name ?? inputIdentifier;
|
||||||
|
try {
|
||||||
|
return compileCashAssemblyString(inputDef.description, variables);
|
||||||
|
} catch {
|
||||||
|
return this.interpolateSimpleCashAssemblyVariables(inputDef.description, variables);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private deriveTransactionActivityDescription(
|
||||||
|
invitation: XOInvitation,
|
||||||
|
template: XOTemplate | null,
|
||||||
|
variables: Record<string, XOInvitationVariableValue>,
|
||||||
|
roleIdentifier?: string
|
||||||
|
): string {
|
||||||
|
if (!template) return invitation.actionIdentifier;
|
||||||
|
const action = template.actions?.[invitation.actionIdentifier];
|
||||||
|
const transactionName = action?.transaction;
|
||||||
|
const transaction = transactionName ? template.transactions?.[transactionName] : null;
|
||||||
|
const roleData = roleIdentifier ? transaction?.roles?.[roleIdentifier] : undefined;
|
||||||
|
const descriptionTemplate = roleData?.description
|
||||||
|
?? transaction?.description
|
||||||
|
?? roleData?.name
|
||||||
|
?? transaction?.name
|
||||||
|
?? action?.name
|
||||||
|
?? invitation.actionIdentifier;
|
||||||
|
try {
|
||||||
|
return compileCashAssemblyString(descriptionTemplate, variables);
|
||||||
|
} catch {
|
||||||
|
return this.interpolateSimpleCashAssemblyVariables(descriptionTemplate, variables);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private deriveCommitRoleIdentifier(
|
||||||
|
commit: XOInvitationCommit,
|
||||||
|
invitation: XOInvitation,
|
||||||
|
template: XOTemplate | null
|
||||||
|
): string | undefined {
|
||||||
|
const explicitRoles = new Set<string>();
|
||||||
|
for (const input of commit.data.inputs ?? []) {
|
||||||
|
if (input.roleIdentifier) explicitRoles.add(input.roleIdentifier);
|
||||||
|
}
|
||||||
|
for (const output of commit.data.outputs ?? []) {
|
||||||
|
if (output.roleIdentifier) explicitRoles.add(output.roleIdentifier);
|
||||||
|
}
|
||||||
|
for (const variable of commit.data.variables ?? []) {
|
||||||
|
if (variable.roleIdentifier) explicitRoles.add(variable.roleIdentifier);
|
||||||
|
}
|
||||||
|
if (explicitRoles.size === 1) return Array.from(explicitRoles)[0];
|
||||||
|
|
||||||
|
const action = template?.actions?.[invitation.actionIdentifier];
|
||||||
|
if ((commit.data.inputs?.length ?? 0) > 0 && action?.roles?.sender) return 'sender';
|
||||||
|
if ((commit.data.variables?.length ?? 0) > 0 && action?.roles?.receiver) return 'receiver';
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
private extractInvitationRoleIdentifier(
|
||||||
|
invitation: XOInvitation,
|
||||||
|
template: XOTemplate | null,
|
||||||
|
walletEntityIdentifier?: string
|
||||||
|
): string | undefined {
|
||||||
|
if (walletEntityIdentifier) {
|
||||||
|
const commits = invitation.commits.filter((commit) => commit.entityIdentifier === walletEntityIdentifier);
|
||||||
|
for (const commit of commits) {
|
||||||
|
const role = this.deriveCommitRoleIdentifier(commit, invitation, template);
|
||||||
|
if (role) return role;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
private inferRoleFromOutputIdentifier(outputIdentifier: string): string | undefined {
|
||||||
|
const normalized = outputIdentifier.toLowerCase();
|
||||||
|
if (normalized.includes('receive') || normalized.includes('request')) return 'receiver';
|
||||||
|
if (normalized.includes('change') || normalized.includes('send')) return 'sender';
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
private resolveInputSatoshis(
|
||||||
|
txHash: string,
|
||||||
|
index: number,
|
||||||
|
outpointValueSatoshis: Map<string, bigint>,
|
||||||
|
variables: Record<string, XOInvitationVariableValue>
|
||||||
|
): bigint | undefined {
|
||||||
|
const outpointKey = this.getOutpointKey(txHash, index);
|
||||||
|
const matchedValue = outpointValueSatoshis.get(outpointKey);
|
||||||
|
if (matchedValue !== undefined) return matchedValue;
|
||||||
|
|
||||||
|
const requestedSatoshis = variables.requestedSatoshis;
|
||||||
|
if (requestedSatoshis !== undefined) {
|
||||||
|
try {
|
||||||
|
return BigInt(String(requestedSatoshis));
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const committedVariables = invitation.commits.flatMap(c => c.data.variables ?? []);
|
return undefined;
|
||||||
const formattedVariables = committedVariables.reduce((acc, v) => {
|
}
|
||||||
acc[v.variableIdentifier ?? ''] = v.value;
|
|
||||||
return acc;
|
|
||||||
}, {} as Record<string, XOInvitationVariableValue>);
|
|
||||||
|
|
||||||
const description = compileCashAssemblyString(transaction.description, formattedVariables);
|
private getUtxoId(utxo: UnspentOutputData): string {
|
||||||
|
return `utxo-${utxo.outpointTransactionHash}:${utxo.outpointIndex}`;
|
||||||
|
}
|
||||||
|
|
||||||
return description;
|
private getOutpointKey(txid: string, index: number): string {
|
||||||
|
return `${txid}:${index}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private getUtxoOriginKey(templateIdentifier: string, outputIdentifier: string, lockingBytecodeHex: string): string {
|
||||||
|
return `${templateIdentifier}:${outputIdentifier}:${lockingBytecodeHex}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private toLockingBytecodeHex(lockingBytecode: string | Uint8Array): string {
|
||||||
|
if (typeof lockingBytecode === 'string') return lockingBytecode;
|
||||||
|
return binToHex(lockingBytecode);
|
||||||
|
}
|
||||||
|
|
||||||
|
private interpolateSimpleCashAssemblyVariables(
|
||||||
|
text: string,
|
||||||
|
variables: Record<string, XOInvitationVariableValue>
|
||||||
|
): string {
|
||||||
|
return text.replace(/\$\(<([^>]+)>\)/g, (match, variableIdentifier: string) => {
|
||||||
|
if (!Object.prototype.hasOwnProperty.call(variables, variableIdentifier)) return match;
|
||||||
|
return String(variables[variableIdentifier]);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
import type { AcceptInvitationParameters, AppendInvitationParameters, Engine, FindSuitableResourcesParameters } from '@xo-cash/engine';
|
import type { AcceptInvitationParameters, AppendInvitationParameters, Engine, FindSuitableResourcesParameters } from '@xo-cash/engine';
|
||||||
import { hasInvitationExpired } from '@xo-cash/engine';
|
import { hasInvitationExpired, mergeInvitationCommits } from '@xo-cash/engine';
|
||||||
import type { XOInvitation, XOInvitationCommit, XOInvitationInput, XOInvitationOutput, XOInvitationVariable, XOInvitationVariableValue } from '@xo-cash/types';
|
import type { XOInvitation, XOInvitationCommit, XOInvitationInput, XOInvitationOutput, XOInvitationVariable, XOInvitationVariableValue } from '@xo-cash/types';
|
||||||
import type { UnspentOutputData } from '@xo-cash/state';
|
import type { UnspentOutputData } from '@xo-cash/state';
|
||||||
|
import { binToHex, encodeTransaction, generateTransaction, hashTransaction, hexToBin } from '@bitauth/libauth';
|
||||||
|
|
||||||
import type { SSEvent } from '../utils/sse-client.js';
|
import type { SSEvent } from '../utils/sse-client.js';
|
||||||
import type { SyncServer } from '../utils/sync-server.js';
|
import type { SyncServer } from '../utils/sync-server.js';
|
||||||
import type { Storage } from './storage.js';
|
import type { Storage } from './storage.js';
|
||||||
|
import type { ElectrumService } from './electrum.js';
|
||||||
|
|
||||||
import { EventEmitter } from '../utils/event-emitter.js'
|
import { EventEmitter } from '../utils/event-emitter.js'
|
||||||
import { decodeExtendedJsonObject } from '../utils/ext-json.js';
|
import { decodeExtendedJsonObject } from '../utils/ext-json.js';
|
||||||
@@ -20,6 +22,7 @@ export type InvitationDependencies = {
|
|||||||
syncServer: SyncServer;
|
syncServer: SyncServer;
|
||||||
storage: Storage;
|
storage: Storage;
|
||||||
engine: Engine;
|
engine: Engine;
|
||||||
|
electrum: ElectrumService;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class Invitation extends EventEmitter<InvitationEventMap> {
|
export class Invitation extends EventEmitter<InvitationEventMap> {
|
||||||
@@ -87,16 +90,7 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
|
|||||||
* TODO: This should be a composite with the sync server (probably. We currently double handle this work, which is stupid)
|
* TODO: This should be a composite with the sync server (probably. We currently double handle this work, which is stupid)
|
||||||
*/
|
*/
|
||||||
private storage: Storage;
|
private storage: Storage;
|
||||||
|
private electrum: ElectrumService;
|
||||||
/**
|
|
||||||
* 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).
|
* The status of the invitation (last emitted word: pending, actionable, signed, ready, complete, expired, unknown).
|
||||||
@@ -116,6 +110,7 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
|
|||||||
this.engine = dependencies.engine;
|
this.engine = dependencies.engine;
|
||||||
this.syncServer = dependencies.syncServer;
|
this.syncServer = dependencies.syncServer;
|
||||||
this.storage = dependencies.storage;
|
this.storage = dependencies.storage;
|
||||||
|
this.electrum = dependencies.electrum;
|
||||||
|
|
||||||
// I cannot express this enough, but the event handler does not need a clean up.
|
// 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
|
// There is this beautiful thing called a "garbage collector". Once this class is removed from scope (removed from the invitations array) all the references
|
||||||
@@ -217,21 +212,14 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
|
|||||||
/**
|
/**
|
||||||
* Internal status computation: returns a single word.
|
* 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
|
* 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
|
* - complete: we have broadcast this invitation
|
||||||
|
* - expired: any commit has expired
|
||||||
* - ready: no missing requirements and we have signed (ready to broadcast)
|
* - ready: no missing requirements and we have signed (ready to broadcast)
|
||||||
* - signed: we have signed but there are still missing parts (waiting for others)
|
* - 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)
|
* - actionable: you can provide data (missing requirements and/or you can sign)
|
||||||
* - unknown: template/action not found or error
|
* - unknown: template/action not found or error
|
||||||
*/
|
*/
|
||||||
private async computeStatusInternal(): Promise<string> {
|
private async computeStatusInternal(): Promise<string> {
|
||||||
if (hasInvitationExpired(this.data)) {
|
|
||||||
return 'expired';
|
|
||||||
}
|
|
||||||
if (this._broadcasted) {
|
|
||||||
return 'complete';
|
|
||||||
}
|
|
||||||
|
|
||||||
let missingReqs;
|
let missingReqs;
|
||||||
try {
|
try {
|
||||||
missingReqs = await this.engine.listMissingRequirements(this.data);
|
missingReqs = await this.engine.listMissingRequirements(this.data);
|
||||||
@@ -245,15 +233,74 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
|
|||||||
(missingReqs.outputs?.length ?? 0) > 0 ||
|
(missingReqs.outputs?.length ?? 0) > 0 ||
|
||||||
(missingReqs.roles !== undefined && Object.keys(missingReqs.roles).length > 0);
|
(missingReqs.roles !== undefined && Object.keys(missingReqs.roles).length > 0);
|
||||||
|
|
||||||
if (!hasMissing && this._weHaveSigned) {
|
const hasSignedCommit = this.hasSignedCommitInInvitation();
|
||||||
|
|
||||||
|
if (!hasMissing) {
|
||||||
|
const transactionHash = await this.deriveTransactionHash();
|
||||||
|
if (transactionHash && await this.electrum.hasSeenTransaction(transactionHash)) {
|
||||||
|
return 'complete';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasInvitationExpired(this.data)) {
|
||||||
|
return 'expired';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasMissing && hasSignedCommit) {
|
||||||
return 'ready';
|
return 'ready';
|
||||||
}
|
}
|
||||||
if (hasMissing && this._weHaveSigned) {
|
if (hasMissing && hasSignedCommit) {
|
||||||
return 'signed';
|
return 'signed';
|
||||||
}
|
}
|
||||||
return 'actionable';
|
return 'actionable';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private hasSignedCommitInInvitation(): boolean {
|
||||||
|
for (const commit of this.data.commits) {
|
||||||
|
for (const input of commit.data.inputs ?? []) {
|
||||||
|
if (!input.mergesWith) continue;
|
||||||
|
if (input.unlockingBytecode === undefined) continue;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the transaction to get the TX hash, this is so we can check its status on the blockchain.
|
||||||
|
* TODO: Remove this. This should be part of the engine. The code is virtually identical to `executeAction` except it doesnt throw if the invitation is expired
|
||||||
|
* @returns txHash or undefined if the transaction could not be built
|
||||||
|
*/
|
||||||
|
private async deriveTransactionHash(): Promise<string | undefined> {
|
||||||
|
try {
|
||||||
|
const template = await this.engine.getTemplate(this.data.templateIdentifier);
|
||||||
|
if (!template) return undefined;
|
||||||
|
|
||||||
|
const mergedCommit = mergeInvitationCommits(this.data, template);
|
||||||
|
if (!mergedCommit) return undefined;
|
||||||
|
|
||||||
|
const transactionResult = generateTransaction({
|
||||||
|
version: mergedCommit.transactionVersion,
|
||||||
|
locktime: mergedCommit.transactionLocktime,
|
||||||
|
// @ts-expect-error merged inputs include additional invitation metadata.
|
||||||
|
inputs: mergedCommit.inputs,
|
||||||
|
// @ts-expect-error merged outputs include additional invitation metadata.
|
||||||
|
outputs: mergedCommit.outputs,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!transactionResult.success) return undefined;
|
||||||
|
|
||||||
|
const transactionHex = binToHex(encodeTransaction(transactionResult.transaction));
|
||||||
|
const rawHash: unknown = hashTransaction(hexToBin(transactionHex));
|
||||||
|
if (typeof rawHash === 'string') return rawHash;
|
||||||
|
if (rawHash instanceof Uint8Array) return binToHex(rawHash);
|
||||||
|
return undefined;
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Update the status of the invitation and emit the new single-word status.
|
* Update the status of the invitation and emit the new single-word status.
|
||||||
*/
|
*/
|
||||||
@@ -291,7 +338,6 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
|
|||||||
await this.storage.set(this.data.invitationIdentifier, signedInvitation);
|
await this.storage.set(this.data.invitationIdentifier, signedInvitation);
|
||||||
|
|
||||||
this.data = signedInvitation;
|
this.data = signedInvitation;
|
||||||
this._weHaveSigned = true;
|
|
||||||
|
|
||||||
// Update the status of the invitation
|
// Update the status of the invitation
|
||||||
await this.updateStatus();
|
await this.updateStatus();
|
||||||
@@ -306,8 +352,6 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
|
|||||||
broadcastTransaction: true,
|
broadcastTransaction: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
this._broadcasted = true;
|
|
||||||
|
|
||||||
// Update the status of the invitation
|
// Update the status of the invitation
|
||||||
await this.updateStatus();
|
await this.updateStatus();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -137,7 +137,11 @@ function MainContent(): React.ReactElement {
|
|||||||
if (dialog?.visible) return;
|
if (dialog?.visible) return;
|
||||||
|
|
||||||
// Quit on 'q' or Ctrl+C
|
// Quit on 'q' or Ctrl+C
|
||||||
if (input === 'q' || (key.ctrl && input === 'c')) {
|
if (
|
||||||
|
// Commenting out 'q'. Its annoying me - It activates in text inputs.
|
||||||
|
// input === 'q'
|
||||||
|
(key.ctrl && input === 'c')
|
||||||
|
) {
|
||||||
appContext.exit();
|
appContext.exit();
|
||||||
exit();
|
exit();
|
||||||
}
|
}
|
||||||
@@ -179,8 +183,8 @@ function MainContent(): React.ReactElement {
|
|||||||
export function App({ config }: AppProps): React.ReactElement {
|
export function App({ config }: AppProps): React.ReactElement {
|
||||||
const { exit } = useApp();
|
const { exit } = useApp();
|
||||||
|
|
||||||
|
// Cleanup will be handled by React when components unmount
|
||||||
const handleExit = () => {
|
const handleExit = () => {
|
||||||
// Cleanup will be handled by React when components unmount
|
|
||||||
exit();
|
exit();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
import React, { useRef, useState } from 'react';
|
import React, { useRef, useState } from 'react';
|
||||||
import { Box, Text, useInput, measureElement } from 'ink';
|
import { Box, Text, useInput, measureElement } from 'ink';
|
||||||
import TextInput from 'ink-text-input';
|
import TextInput from './TextInput.js';
|
||||||
import { colors } from '../theme.js';
|
import { colors } from '../theme.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -29,6 +29,7 @@ export function DialogWrapper({
|
|||||||
borderColor = colors.primary,
|
borderColor = colors.primary,
|
||||||
children,
|
children,
|
||||||
width = 60,
|
width = 60,
|
||||||
|
backgroundColor = colors.bg,
|
||||||
}: DialogWrapperProps): React.ReactElement {
|
}: DialogWrapperProps): React.ReactElement {
|
||||||
const ref = useRef<any>(null);
|
const ref = useRef<any>(null);
|
||||||
const [height, setHeight] = useState<number | null>(null);
|
const [height, setHeight] = useState<number | null>(null);
|
||||||
@@ -51,9 +52,12 @@ export function DialogWrapper({
|
|||||||
flexDirection="column"
|
flexDirection="column"
|
||||||
width={width}
|
width={width}
|
||||||
height={height}
|
height={height}
|
||||||
|
backgroundColor={backgroundColor}
|
||||||
>
|
>
|
||||||
{Array.from({ length: height }).map((_, i) => (
|
{Array.from({ length: height }).map((_, i) => (
|
||||||
<Text key={i}>{' '.repeat(width)}</Text>
|
<Text key={i} backgroundColor={backgroundColor}>
|
||||||
|
{' '.repeat(width)}
|
||||||
|
</Text>
|
||||||
))}
|
))}
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
@@ -67,6 +71,7 @@ export function DialogWrapper({
|
|||||||
paddingX={2}
|
paddingX={2}
|
||||||
paddingY={1}
|
paddingY={1}
|
||||||
width={width}
|
width={width}
|
||||||
|
backgroundColor={backgroundColor}
|
||||||
>
|
>
|
||||||
<Text color={borderColor} bold>
|
<Text color={borderColor} bold>
|
||||||
{title}
|
{title}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Box, Text } from 'ink';
|
import { Box, Text } from 'ink';
|
||||||
import TextInput from 'ink-text-input';
|
import TextInput from './TextInput.js';
|
||||||
import { colors } from '../theme.js';
|
import { colors } from '../theme.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
|
|
||||||
import React, { useState, useMemo, useCallback } 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 TextInput from './TextInput.js';
|
||||||
import { colors } from '../theme.js';
|
import { colors } from '../theme.js';
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|||||||
@@ -116,8 +116,14 @@ interface LoadingProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function Loading({ message = 'Loading...' }: LoadingProps): React.ReactElement {
|
export function Loading({ message = 'Loading...' }: LoadingProps): React.ReactElement {
|
||||||
// Simple spinner using Ink's spinner component
|
|
||||||
const Spinner = require('ink-spinner').default;
|
// Was using ink-spinner, but its not updated for react 19.
|
||||||
|
// Just putting nothing here for now
|
||||||
|
const Spinner = (props: any) => {
|
||||||
|
return (
|
||||||
|
<></>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box>
|
<Box>
|
||||||
|
|||||||
216
src/tui/components/TextInput.tsx
Normal file
216
src/tui/components/TextInput.tsx
Normal file
@@ -0,0 +1,216 @@
|
|||||||
|
import React, {useState, useEffect} from 'react';
|
||||||
|
import {Text, useInput} from 'ink';
|
||||||
|
import chalk from 'chalk';
|
||||||
|
import type {Except} from 'type-fest';
|
||||||
|
|
||||||
|
export type Props = {
|
||||||
|
/**
|
||||||
|
* Text to display when `value` is empty.
|
||||||
|
*/
|
||||||
|
readonly placeholder?: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Listen to user's input. Useful in case there are multiple input components
|
||||||
|
* at the same time and input must be "routed" to a specific component.
|
||||||
|
*/
|
||||||
|
readonly focus?: boolean; // eslint-disable-line react/boolean-prop-naming
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replace all chars and mask the value. Useful for password inputs.
|
||||||
|
*/
|
||||||
|
readonly mask?: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether to show cursor and allow navigation inside text input with arrow keys.
|
||||||
|
*/
|
||||||
|
readonly showCursor?: boolean; // eslint-disable-line react/boolean-prop-naming
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Highlight pasted text
|
||||||
|
*/
|
||||||
|
readonly highlightPastedText?: boolean; // eslint-disable-line react/boolean-prop-naming
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Value to display in a text input.
|
||||||
|
*/
|
||||||
|
readonly value: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Function to call when value updates.
|
||||||
|
*/
|
||||||
|
readonly onChange: (value: string) => void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Function to call when `Enter` is pressed, where first argument is a value of the input.
|
||||||
|
*/
|
||||||
|
readonly onSubmit?: (value: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
function TextInput({
|
||||||
|
value: originalValue,
|
||||||
|
placeholder = '',
|
||||||
|
focus = true,
|
||||||
|
mask,
|
||||||
|
highlightPastedText = false,
|
||||||
|
showCursor = true,
|
||||||
|
onChange,
|
||||||
|
onSubmit,
|
||||||
|
}: Props) {
|
||||||
|
const [state, setState] = useState({
|
||||||
|
cursorOffset: (originalValue || '').length,
|
||||||
|
cursorWidth: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const {cursorOffset, cursorWidth} = state;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setState(previousState => {
|
||||||
|
if (!focus || !showCursor) {
|
||||||
|
return previousState;
|
||||||
|
}
|
||||||
|
|
||||||
|
const newValue = originalValue || '';
|
||||||
|
|
||||||
|
if (previousState.cursorOffset > newValue.length - 1) {
|
||||||
|
return {
|
||||||
|
cursorOffset: newValue.length,
|
||||||
|
cursorWidth: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return previousState;
|
||||||
|
});
|
||||||
|
}, [originalValue, focus, showCursor]);
|
||||||
|
|
||||||
|
const cursorActualWidth = highlightPastedText ? cursorWidth : 0;
|
||||||
|
|
||||||
|
const value = mask ? mask.repeat(originalValue.length) : originalValue;
|
||||||
|
let renderedValue = value;
|
||||||
|
let renderedPlaceholder = placeholder ? chalk.grey(placeholder) : undefined;
|
||||||
|
|
||||||
|
// Fake mouse cursor, because it's too inconvenient to deal with actual cursor and ansi escapes
|
||||||
|
if (showCursor && focus) {
|
||||||
|
renderedPlaceholder =
|
||||||
|
placeholder.length > 0
|
||||||
|
? chalk.inverse(placeholder[0]) + chalk.grey(placeholder.slice(1))
|
||||||
|
: chalk.inverse(' ');
|
||||||
|
|
||||||
|
renderedValue = value.length > 0 ? '' : chalk.inverse(' ');
|
||||||
|
|
||||||
|
let i = 0;
|
||||||
|
|
||||||
|
for (const char of value) {
|
||||||
|
renderedValue +=
|
||||||
|
i >= cursorOffset - cursorActualWidth && i <= cursorOffset
|
||||||
|
? chalk.inverse(char)
|
||||||
|
: char;
|
||||||
|
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value.length > 0 && cursorOffset === value.length) {
|
||||||
|
renderedValue += chalk.inverse(' ');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useInput(
|
||||||
|
(input, key) => {
|
||||||
|
if (
|
||||||
|
key.upArrow ||
|
||||||
|
key.downArrow ||
|
||||||
|
(key.ctrl && input === 'c') ||
|
||||||
|
key.tab ||
|
||||||
|
(key.shift && key.tab)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (key.return) {
|
||||||
|
if (onSubmit) {
|
||||||
|
onSubmit(originalValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let nextCursorOffset = cursorOffset;
|
||||||
|
let nextValue = originalValue;
|
||||||
|
let nextCursorWidth = 0;
|
||||||
|
|
||||||
|
if (key.leftArrow) {
|
||||||
|
if (showCursor) {
|
||||||
|
nextCursorOffset--;
|
||||||
|
}
|
||||||
|
} else if (key.rightArrow) {
|
||||||
|
if (showCursor) {
|
||||||
|
nextCursorOffset++;
|
||||||
|
}
|
||||||
|
} else if (key.backspace || key.delete) {
|
||||||
|
if (cursorOffset > 0) {
|
||||||
|
nextValue =
|
||||||
|
originalValue.slice(0, cursorOffset - 1) +
|
||||||
|
originalValue.slice(cursorOffset, originalValue.length);
|
||||||
|
|
||||||
|
nextCursorOffset--;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
nextValue =
|
||||||
|
originalValue.slice(0, cursorOffset) +
|
||||||
|
input +
|
||||||
|
originalValue.slice(cursorOffset, originalValue.length);
|
||||||
|
|
||||||
|
nextCursorOffset += input.length;
|
||||||
|
|
||||||
|
if (input.length > 1) {
|
||||||
|
nextCursorWidth = input.length;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cursorOffset < 0) {
|
||||||
|
nextCursorOffset = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cursorOffset > originalValue.length) {
|
||||||
|
nextCursorOffset = originalValue.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
setState({
|
||||||
|
cursorOffset: nextCursorOffset,
|
||||||
|
cursorWidth: nextCursorWidth,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (nextValue !== originalValue) {
|
||||||
|
onChange(nextValue);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{isActive: focus},
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Text>
|
||||||
|
{placeholder
|
||||||
|
? value.length > 0
|
||||||
|
? renderedValue
|
||||||
|
: renderedPlaceholder
|
||||||
|
: renderedValue}
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default TextInput;
|
||||||
|
|
||||||
|
type UncontrolledProps = {
|
||||||
|
/**
|
||||||
|
* Initial value.
|
||||||
|
*/
|
||||||
|
readonly initialValue?: string;
|
||||||
|
} & Except<Props, 'value' | 'onChange'>;
|
||||||
|
|
||||||
|
export function UncontrolledTextInput({
|
||||||
|
initialValue = '',
|
||||||
|
...props
|
||||||
|
}: UncontrolledProps) {
|
||||||
|
const [value, setValue] = useState(initialValue);
|
||||||
|
|
||||||
|
return <TextInput {...props} value={value} onChange={setValue} />;
|
||||||
|
}
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { Box, Text } from "ink";
|
import { Box, Text } from "ink";
|
||||||
import TextInput from "ink-text-input";
|
import TextInput from "./TextInput.js";
|
||||||
import { formatSatoshis } from "../theme.js";
|
|
||||||
|
|
||||||
interface VariableInputFieldProps {
|
interface VariableInputFieldProps {
|
||||||
variable: {
|
variable: {
|
||||||
|
|||||||
@@ -6,7 +6,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 '../components/TextInput.js';
|
||||||
import { Button } from '../components/Button.js';
|
import { Button } 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';
|
||||||
|
|||||||
@@ -21,10 +21,7 @@ import type { XOTemplate } from '@xo-cash/types';
|
|||||||
import {
|
import {
|
||||||
formatTemplateListItem,
|
formatTemplateListItem,
|
||||||
formatActionListItem,
|
formatActionListItem,
|
||||||
deduplicateStartingActions,
|
|
||||||
getTemplateRoles,
|
getTemplateRoles,
|
||||||
getRolesForAction,
|
|
||||||
type UniqueStartingAction,
|
|
||||||
} from '../../utils/template-utils.js';
|
} from '../../utils/template-utils.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -33,7 +30,7 @@ import {
|
|||||||
interface TemplateItem {
|
interface TemplateItem {
|
||||||
template: XOTemplate;
|
template: XOTemplate;
|
||||||
templateIdentifier: string;
|
templateIdentifier: string;
|
||||||
startingActions: UniqueStartingAction[];
|
availableActions: TemplateActionItem[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -42,9 +39,17 @@ interface TemplateItem {
|
|||||||
type TemplateListItem = ListItemData<TemplateItem>;
|
type TemplateListItem = ListItemData<TemplateItem>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Action list item with UniqueStartingAction value.
|
* Action list item with available action value.
|
||||||
*/
|
*/
|
||||||
type ActionListItem = ListItemData<UniqueStartingAction>;
|
type ActionListItem = ListItemData<TemplateActionItem>;
|
||||||
|
|
||||||
|
interface TemplateActionItem {
|
||||||
|
actionIdentifier: string;
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
roles: string[];
|
||||||
|
source: 'starting' | 'next' | 'starting+next';
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Template List Screen Component.
|
* Template List Screen Component.
|
||||||
@@ -76,19 +81,90 @@ export function TemplateListScreen(): React.ReactElement {
|
|||||||
setStatus('Loading templates...');
|
setStatus('Loading templates...');
|
||||||
|
|
||||||
const templateList = await appService.engine.listImportedTemplates();
|
const templateList = await appService.engine.listImportedTemplates();
|
||||||
|
const allUtxos = await appService.engine.listUnspentOutputsData();
|
||||||
|
|
||||||
|
const ownedOutputsByTemplate = new Map<string, Set<string>>();
|
||||||
|
for (const utxo of allUtxos) {
|
||||||
|
const existing = ownedOutputsByTemplate.get(utxo.templateIdentifier) ?? new Set<string>();
|
||||||
|
existing.add(utxo.outputIdentifier);
|
||||||
|
ownedOutputsByTemplate.set(utxo.templateIdentifier, existing);
|
||||||
|
}
|
||||||
|
|
||||||
const loadedTemplates = await Promise.all(
|
const loadedTemplates = await Promise.all(
|
||||||
templateList.map(async (template) => {
|
templateList.map(async (template) => {
|
||||||
const templateIdentifier = generateTemplateIdentifier(template);
|
const templateIdentifier = generateTemplateIdentifier(template);
|
||||||
const rawStartingActions = await appService.engine.listStartingActions(templateIdentifier);
|
const rawStartingActions = await appService.engine.listStartingActions(templateIdentifier);
|
||||||
|
const actionMap = new Map<string, TemplateActionItem>();
|
||||||
|
|
||||||
// Use utility function to deduplicate actions
|
for (const startingAction of rawStartingActions) {
|
||||||
const startingActions = deduplicateStartingActions(template, rawStartingActions);
|
const existing = actionMap.get(startingAction.action);
|
||||||
|
if (existing) {
|
||||||
|
if (!existing.roles.includes(startingAction.role)) {
|
||||||
|
existing.roles.push(startingAction.role);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const actionDef = template.actions?.[startingAction.action];
|
||||||
|
actionMap.set(startingAction.action, {
|
||||||
|
actionIdentifier: startingAction.action,
|
||||||
|
name: actionDef?.name || startingAction.action,
|
||||||
|
description: actionDef?.description,
|
||||||
|
roles: [startingAction.role],
|
||||||
|
source: 'starting',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const ownedOutputIdentifiers = ownedOutputsByTemplate.get(templateIdentifier) ?? new Set<string>();
|
||||||
|
for (const outputIdentifier of ownedOutputIdentifiers) {
|
||||||
|
const outputDef = template.outputs?.[outputIdentifier];
|
||||||
|
if (!outputDef || typeof outputDef.lockscript !== 'string') continue;
|
||||||
|
|
||||||
|
const lockingScriptDefinition = (template.lockingScripts as Record<string, unknown> | undefined)?.[outputDef.lockscript] as
|
||||||
|
| { roles?: Record<string, { actions?: Array<{ action?: string; role?: string } | string> }> }
|
||||||
|
| undefined;
|
||||||
|
if (!lockingScriptDefinition?.roles) continue;
|
||||||
|
|
||||||
|
for (const [lockscriptRoleId, lockscriptRoleDef] of Object.entries(lockingScriptDefinition.roles)) {
|
||||||
|
for (const actionSpec of lockscriptRoleDef.actions ?? []) {
|
||||||
|
const actionIdentifier = typeof actionSpec === 'string'
|
||||||
|
? actionSpec
|
||||||
|
: actionSpec.action;
|
||||||
|
if (!actionIdentifier) continue;
|
||||||
|
|
||||||
|
const roleIdentifier = typeof actionSpec === 'string'
|
||||||
|
? lockscriptRoleId
|
||||||
|
: (actionSpec.role ?? lockscriptRoleId);
|
||||||
|
|
||||||
|
const existing = actionMap.get(actionIdentifier);
|
||||||
|
if (existing) {
|
||||||
|
if (!existing.roles.includes(roleIdentifier)) {
|
||||||
|
existing.roles.push(roleIdentifier);
|
||||||
|
}
|
||||||
|
if (existing.source === 'starting') {
|
||||||
|
existing.source = 'starting+next';
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const actionDef = template.actions?.[actionIdentifier];
|
||||||
|
actionMap.set(actionIdentifier, {
|
||||||
|
actionIdentifier,
|
||||||
|
name: actionDef?.name || actionIdentifier,
|
||||||
|
description: actionDef?.description,
|
||||||
|
roles: [roleIdentifier],
|
||||||
|
source: 'next',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const availableActions = Array.from(actionMap.values()).sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
|
||||||
return {
|
return {
|
||||||
template,
|
template,
|
||||||
templateIdentifier,
|
templateIdentifier,
|
||||||
startingActions,
|
availableActions,
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
@@ -111,7 +187,7 @@ export function TemplateListScreen(): React.ReactElement {
|
|||||||
|
|
||||||
// Get current template and its actions
|
// Get current template and its actions
|
||||||
const currentTemplate = templates[selectedTemplateIndex];
|
const currentTemplate = templates[selectedTemplateIndex];
|
||||||
const currentActions = currentTemplate?.startingActions ?? [];
|
const currentActions = currentTemplate?.availableActions ?? [];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build template list items for ScrollableList.
|
* Build template list items for ScrollableList.
|
||||||
@@ -137,12 +213,17 @@ export function TemplateListScreen(): React.ReactElement {
|
|||||||
const formatted = formatActionListItem(
|
const formatted = formatActionListItem(
|
||||||
action.actionIdentifier,
|
action.actionIdentifier,
|
||||||
currentTemplate?.template?.actions?.[action.actionIdentifier],
|
currentTemplate?.template?.actions?.[action.actionIdentifier],
|
||||||
action.roleCount,
|
action.roles.length,
|
||||||
index
|
index
|
||||||
);
|
);
|
||||||
|
const sourceSuffix = action.source === 'next'
|
||||||
|
? ' [next]'
|
||||||
|
: action.source === 'starting+next'
|
||||||
|
? ' [start+next]'
|
||||||
|
: '';
|
||||||
return {
|
return {
|
||||||
key: action.actionIdentifier,
|
key: action.actionIdentifier,
|
||||||
label: formatted.label,
|
label: `${formatted.label}${sourceSuffix}`,
|
||||||
description: formatted.description,
|
description: formatted.description,
|
||||||
value: action,
|
value: action,
|
||||||
hidden: !formatted.isValid,
|
hidden: !formatted.isValid,
|
||||||
@@ -171,6 +252,7 @@ export function TemplateListScreen(): React.ReactElement {
|
|||||||
navigate('wizard', {
|
navigate('wizard', {
|
||||||
templateIdentifier: currentTemplate.templateIdentifier,
|
templateIdentifier: currentTemplate.templateIdentifier,
|
||||||
actionIdentifier: action.actionIdentifier,
|
actionIdentifier: action.actionIdentifier,
|
||||||
|
actionRoles: action.roles,
|
||||||
template: currentTemplate.template,
|
template: currentTemplate.template,
|
||||||
});
|
});
|
||||||
}, [currentTemplate, navigate]);
|
}, [currentTemplate, navigate]);
|
||||||
@@ -267,7 +349,7 @@ export function TemplateListScreen(): React.ReactElement {
|
|||||||
paddingX={1}
|
paddingX={1}
|
||||||
flexGrow={1}
|
flexGrow={1}
|
||||||
>
|
>
|
||||||
<Text color={colors.primary} bold> Starting Actions </Text>
|
<Text color={colors.primary} bold> Available Actions </Text>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<Box marginTop={1}>
|
<Box marginTop={1}>
|
||||||
<Text color={colors.textMuted}>Loading...</Text>
|
<Text color={colors.textMuted}>Loading...</Text>
|
||||||
@@ -283,7 +365,7 @@ export function TemplateListScreen(): React.ReactElement {
|
|||||||
onSelect={setSelectedActionIndex}
|
onSelect={setSelectedActionIndex}
|
||||||
onActivate={handleActionActivate}
|
onActivate={handleActionActivate}
|
||||||
focus={focusedPanel === 'actions'}
|
focus={focusedPanel === 'actions'}
|
||||||
emptyMessage="No starting actions available"
|
emptyMessage="No actions available"
|
||||||
renderItem={renderActionItem}
|
renderItem={renderActionItem}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -339,9 +421,6 @@ export function TemplateListScreen(): React.ReactElement {
|
|||||||
const action = currentActions[selectedActionIndex];
|
const action = currentActions[selectedActionIndex];
|
||||||
if (!action) return null;
|
if (!action) return null;
|
||||||
|
|
||||||
// Get roles that can start this action using utility function
|
|
||||||
const availableRoles = getRolesForAction(currentTemplate.template, action.actionIdentifier);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Text color={colors.text} bold>
|
<Text color={colors.text} bold>
|
||||||
@@ -351,16 +430,24 @@ export function TemplateListScreen(): React.ReactElement {
|
|||||||
{action.description || 'No description available'}
|
{action.description || 'No description available'}
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
{/* List available roles for this action */}
|
{/* List roles available for this action in current context */}
|
||||||
{availableRoles.length > 0 && (
|
{action.roles.length > 0 && (
|
||||||
<Box marginTop={1} flexDirection="column">
|
<Box marginTop={1} flexDirection="column">
|
||||||
<Text color={colors.text}>Available Roles:</Text>
|
<Text color={colors.text}>Available Roles:</Text>
|
||||||
{availableRoles.map((role) => (
|
{action.roles.map((roleId) => {
|
||||||
<Text key={role.roleId} color={colors.textMuted}>
|
const roleDef = currentTemplate.template.roles?.[roleId];
|
||||||
{' '}- {role.name}
|
const roleName = typeof roleDef === 'object' ? roleDef?.name ?? roleId : roleId;
|
||||||
{role.description ? `: ${role.description}` : ''}
|
const roleDescription = typeof roleDef === 'object' ? roleDef?.description : undefined;
|
||||||
</Text>
|
return (
|
||||||
))}
|
<Text key={roleId} color={colors.textMuted}>
|
||||||
|
{' '}- {roleName}
|
||||||
|
{roleDescription ? `: ${roleDescription}` : ''}
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<Text color={colors.textMuted}>
|
||||||
|
{' '}Source: {action.source}
|
||||||
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
@@ -370,7 +457,7 @@ export function TemplateListScreen(): React.ReactElement {
|
|||||||
) : focusedPanel === 'actions' && !currentTemplate ? (
|
) : focusedPanel === 'actions' && !currentTemplate ? (
|
||||||
<Text color={colors.textMuted}>Select a template first</Text>
|
<Text color={colors.textMuted}>Select a template first</Text>
|
||||||
) : focusedPanel === 'actions' && currentActions.length === 0 ? (
|
) : focusedPanel === 'actions' && currentActions.length === 0 ? (
|
||||||
<Text color={colors.textMuted}>No starting actions available</Text>
|
<Text color={colors.textMuted}>No actions available</Text>
|
||||||
) : null}
|
) : null}
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -19,9 +19,10 @@ import { generateTemplateIdentifier } from '@xo-cash/engine';
|
|||||||
|
|
||||||
// Import utility functions
|
// Import utility functions
|
||||||
import {
|
import {
|
||||||
formatHistoryListItem,
|
buildHistoryDisplayRows,
|
||||||
getHistoryItemColorName,
|
getHistoryItemColorName,
|
||||||
formatHistoryDate,
|
formatHistoryDate,
|
||||||
|
type HistoryDisplayRow,
|
||||||
type HistoryColorName,
|
type HistoryColorName,
|
||||||
} from '../../utils/history-utils.js';
|
} from '../../utils/history-utils.js';
|
||||||
|
|
||||||
@@ -58,9 +59,9 @@ const menuItems: ListItemData<string>[] = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* History list item with HistoryItem value.
|
* History list item with display row value.
|
||||||
*/
|
*/
|
||||||
type HistoryListItem = ListItemData<HistoryItem>;
|
type HistoryListItem = ListItemData<HistoryDisplayRow>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Wallet State Screen Component.
|
* Wallet State Screen Component.
|
||||||
@@ -196,15 +197,14 @@ export function WalletStateScreen(): React.ReactElement {
|
|||||||
* Build history list items for ScrollableList.
|
* Build history list items for ScrollableList.
|
||||||
*/
|
*/
|
||||||
const historyListItems = useMemo((): HistoryListItem[] => {
|
const historyListItems = useMemo((): HistoryListItem[] => {
|
||||||
return history.map(item => {
|
return buildHistoryDisplayRows(history).map(row => {
|
||||||
const formatted = formatHistoryListItem(item, false);
|
|
||||||
return {
|
return {
|
||||||
key: item.id,
|
key: row.id,
|
||||||
label: formatted.label,
|
label: row.label,
|
||||||
description: formatted.description,
|
description: row.description,
|
||||||
value: item,
|
value: row,
|
||||||
color: formatted.color,
|
color: getHistoryItemColorName(row, false),
|
||||||
hidden: !formatted.isValid,
|
hidden: false,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}, [history]);
|
}, [history]);
|
||||||
@@ -224,49 +224,63 @@ export function WalletStateScreen(): React.ReactElement {
|
|||||||
isSelected: boolean,
|
isSelected: boolean,
|
||||||
isFocused: boolean
|
isFocused: boolean
|
||||||
): React.ReactNode => {
|
): React.ReactNode => {
|
||||||
const historyItem = item.value;
|
const row = item.value;
|
||||||
if (!historyItem) return null;
|
if (!row) return null;
|
||||||
|
|
||||||
const colorName = getHistoryItemColorName(historyItem.type, isFocused);
|
const colorName = getHistoryItemColorName(row, isFocused);
|
||||||
const itemColor = isFocused ? colors.focus : getHistoryColor(colorName);
|
const itemColor = isFocused ? colors.focus : getHistoryColor(colorName);
|
||||||
const dateStr = formatHistoryDate(historyItem.timestamp);
|
const dateStr = formatHistoryDate(row.timestamp);
|
||||||
const indicator = isFocused ? '▸ ' : ' ';
|
const indicator = isFocused ? '▸ ' : ' ';
|
||||||
|
const groupingPrefix = row.isNested ? ' -> ' : '';
|
||||||
|
|
||||||
// Format based on type
|
if (row.type === 'invitation') {
|
||||||
if (historyItem.type === 'invitation_created') {
|
|
||||||
return (
|
return (
|
||||||
<Box flexDirection="row" justifyContent="space-between">
|
<Box flexDirection="row" justifyContent="space-between">
|
||||||
<Text color={itemColor}>
|
<Text color={itemColor}>
|
||||||
{indicator}[Invitation] {historyItem.description}
|
{indicator}[Invitation] {row.label}
|
||||||
</Text>
|
</Text>
|
||||||
{dateStr && <Text color={colors.textMuted}>{dateStr}</Text>}
|
{dateStr && <Text color={colors.textMuted}>{dateStr}</Text>}
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
} else if (historyItem.type === 'utxo_reserved') {
|
}
|
||||||
const sats = historyItem.valueSatoshis ?? 0n;
|
|
||||||
|
if (row.type === 'invitation_input') {
|
||||||
return (
|
return (
|
||||||
<Box flexDirection="row" justifyContent="space-between">
|
<Box flexDirection="row" justifyContent="space-between">
|
||||||
<Box>
|
<Box>
|
||||||
<Text color={itemColor}>
|
<Text color={itemColor}>
|
||||||
{indicator}[Reserved] {formatSatoshis(sats)}
|
{indicator}{groupingPrefix}[Input] {row.label}
|
||||||
</Text>
|
</Text>
|
||||||
<Text color={colors.textMuted}> {historyItem.description}</Text>
|
{row.description && <Text color={colors.textMuted}> {row.description}</Text>}
|
||||||
</Box>
|
</Box>
|
||||||
{dateStr && <Text color={colors.textMuted}>{dateStr}</Text>}
|
{dateStr && <Text color={colors.textMuted}>{dateStr}</Text>}
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
} else if (historyItem.type === 'utxo_received') {
|
}
|
||||||
const sats = historyItem.valueSatoshis ?? 0n;
|
|
||||||
const reservedTag = historyItem.reserved ? ' [Reserved]' : '';
|
if (row.type === 'invitation_output') {
|
||||||
|
const sats = row.utxo?.valueSatoshis ?? 0n;
|
||||||
return (
|
return (
|
||||||
<Box flexDirection="row" justifyContent="space-between">
|
<Box flexDirection="row" justifyContent="space-between">
|
||||||
<Box flexDirection="row">
|
<Box flexDirection="row">
|
||||||
<Text color={itemColor}>
|
<Text color={itemColor}>
|
||||||
{indicator}{formatSatoshis(sats)}
|
{indicator}{groupingPrefix}[Output] {formatSatoshis(sats)}
|
||||||
</Text>
|
|
||||||
<Text color={colors.textMuted}>
|
|
||||||
{' '}{historyItem.description}{reservedTag}
|
|
||||||
</Text>
|
</Text>
|
||||||
|
{row.description && <Text color={colors.textMuted}> {row.description}</Text>}
|
||||||
|
</Box>
|
||||||
|
{dateStr && <Text color={colors.textMuted}>{dateStr}</Text>}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (row.type === 'utxo') {
|
||||||
|
const sats = row.utxo?.valueSatoshis ?? 0n;
|
||||||
|
const reservedTag = row.utxo?.reserved ? ' [Reserved]' : '';
|
||||||
|
return (
|
||||||
|
<Box flexDirection="row" justifyContent="space-between">
|
||||||
|
<Box flexDirection="row">
|
||||||
|
<Text color={itemColor}>{indicator}{formatSatoshis(sats)}</Text>
|
||||||
|
{row.description && <Text color={colors.textMuted}> {row.description}{reservedTag}</Text>}
|
||||||
</Box>
|
</Box>
|
||||||
{dateStr && <Text color={colors.textMuted}>{dateStr}</Text>}
|
{dateStr && <Text color={colors.textMuted}>{dateStr}</Text>}
|
||||||
</Box>
|
</Box>
|
||||||
@@ -277,7 +291,7 @@ export function WalletStateScreen(): React.ReactElement {
|
|||||||
return (
|
return (
|
||||||
<Box flexDirection="row" justifyContent="space-between">
|
<Box flexDirection="row" justifyContent="space-between">
|
||||||
<Text color={itemColor}>
|
<Text color={itemColor}>
|
||||||
{indicator}{historyItem.type}: {historyItem.description}
|
{indicator}{row.label}
|
||||||
</Text>
|
</Text>
|
||||||
{dateStr && <Text color={colors.textMuted}>{dateStr}</Text>}
|
{dateStr && <Text color={colors.textMuted}>{dateStr}</Text>}
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -205,7 +205,13 @@ export function ActionWizardScreen(): React.ReactElement {
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
case 'publish':
|
case 'publish':
|
||||||
return <PublishStep invitationId={wizard.invitationId} />;
|
return (
|
||||||
|
<PublishStep
|
||||||
|
invitationId={wizard.invitationId}
|
||||||
|
requirementsComplete={wizard.requirementsComplete}
|
||||||
|
hasSignedAndBroadcasted={wizard.hasSignedAndBroadcasted}
|
||||||
|
/>
|
||||||
|
);
|
||||||
default:
|
default:
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -284,7 +290,9 @@ export function ActionWizardScreen(): React.ReactElement {
|
|||||||
</Box>
|
</Box>
|
||||||
<Button
|
<Button
|
||||||
label={
|
label={
|
||||||
wizard.currentStepData?.type === "publish" ? "Done" : "Next"
|
wizard.currentStepData?.type === "publish"
|
||||||
|
? (wizard.canSignAndBroadcast ? "Sign & Broadcast" : "Done")
|
||||||
|
: "Next"
|
||||||
}
|
}
|
||||||
focused={
|
focused={
|
||||||
wizard.focusArea === "buttons" &&
|
wizard.focusArea === "buttons" &&
|
||||||
|
|||||||
@@ -4,15 +4,19 @@ import { colors } from '../../../theme.js';
|
|||||||
|
|
||||||
interface PublishStepProps {
|
interface PublishStepProps {
|
||||||
invitationId: string | null;
|
invitationId: string | null;
|
||||||
|
requirementsComplete: boolean;
|
||||||
|
hasSignedAndBroadcasted: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PublishStep({
|
export function PublishStep({
|
||||||
invitationId,
|
invitationId,
|
||||||
|
requirementsComplete,
|
||||||
|
hasSignedAndBroadcasted,
|
||||||
}: PublishStepProps): React.ReactElement {
|
}: PublishStepProps): React.ReactElement {
|
||||||
return (
|
return (
|
||||||
<Box flexDirection='column'>
|
<Box flexDirection='column'>
|
||||||
<Text color={colors.success} bold>
|
<Text color={colors.success} bold>
|
||||||
✓ Invitation Created & Published!
|
✓ Invitation Ready
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
<Box marginTop={1} flexDirection='column'>
|
<Box marginTop={1} flexDirection='column'>
|
||||||
@@ -30,9 +34,19 @@ export function PublishStep({
|
|||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Box marginTop={1}>
|
<Box marginTop={1}>
|
||||||
<Text color={colors.textMuted}>
|
{hasSignedAndBroadcasted ? (
|
||||||
Share this ID with the other party to complete the transaction.
|
<Text color={colors.success}>
|
||||||
</Text>
|
Transaction signed and broadcasted.
|
||||||
|
</Text>
|
||||||
|
) : requirementsComplete ? (
|
||||||
|
<Text color={colors.textMuted}>
|
||||||
|
Requirements are complete. Use the Sign & Broadcast button to finalize.
|
||||||
|
</Text>
|
||||||
|
) : (
|
||||||
|
<Text color={colors.warning}>
|
||||||
|
Requirements are incomplete. Complete missing requirements before signing.
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Box marginTop={1}>
|
<Box marginTop={1}>
|
||||||
|
|||||||
@@ -4,6 +4,15 @@ import { useAppContext, useStatus } from '../../hooks/useAppContext.js';
|
|||||||
import { formatSatoshis } from '../../theme.js';
|
import { formatSatoshis } from '../../theme.js';
|
||||||
import { copyToClipboard } from '../../utils/clipboard.js';
|
import { copyToClipboard } from '../../utils/clipboard.js';
|
||||||
import type { XOTemplate, XOInvitation, XOTemplateTransactionOutput } from '@xo-cash/types';
|
import type { XOTemplate, XOInvitation, XOTemplateTransactionOutput } from '@xo-cash/types';
|
||||||
|
import {
|
||||||
|
autoSelectGreedyUtxos,
|
||||||
|
getTransactionOutputIdentifier,
|
||||||
|
isInvitationRequirementsComplete,
|
||||||
|
mapUnspentOutputsToSelectable,
|
||||||
|
resolveActionRoles,
|
||||||
|
resolveProvidedLockingBytecodeHex,
|
||||||
|
roleRequiresInputs,
|
||||||
|
} from '../../../utils/invitation-flow.js';
|
||||||
import type {
|
import type {
|
||||||
WizardStep,
|
WizardStep,
|
||||||
VariableInput,
|
VariableInput,
|
||||||
@@ -22,6 +31,7 @@ export function useActionWizard() {
|
|||||||
const templateIdentifier = navData.templateIdentifier as string | undefined;
|
const templateIdentifier = navData.templateIdentifier as string | undefined;
|
||||||
const actionIdentifier = navData.actionIdentifier as string | undefined;
|
const actionIdentifier = navData.actionIdentifier as string | undefined;
|
||||||
const template = navData.template as XOTemplate | undefined;
|
const template = navData.template as XOTemplate | undefined;
|
||||||
|
const actionRolesFromNavigation = navData.actionRoles as string[] | undefined;
|
||||||
|
|
||||||
// ── Role selection state ────────────────────────────────────────
|
// ── Role selection state ────────────────────────────────────────
|
||||||
const [roleIdentifier, setRoleIdentifier] = useState<string | undefined>();
|
const [roleIdentifier, setRoleIdentifier] = useState<string | undefined>();
|
||||||
@@ -32,14 +42,20 @@ export function useActionWizard() {
|
|||||||
* `start` entries filtered to the current action.
|
* `start` entries filtered to the current action.
|
||||||
*/
|
*/
|
||||||
const availableRoles = useMemo(() => {
|
const availableRoles = useMemo(() => {
|
||||||
if (!template || !actionIdentifier) return [];
|
return resolveActionRoles(template, actionIdentifier, actionRolesFromNavigation);
|
||||||
const starts = template.start ?? [];
|
}, [template, actionIdentifier, actionRolesFromNavigation]);
|
||||||
const roleIds = starts
|
|
||||||
.filter((s) => s.action === actionIdentifier)
|
const effectiveRoleForFlow = roleIdentifier ?? (
|
||||||
.map((s) => s.role);
|
availableRoles.length === 1 ? availableRoles[0] : undefined
|
||||||
// Deduplicate while preserving order
|
);
|
||||||
return [...new Set(roleIds)];
|
|
||||||
}, [template, actionIdentifier]);
|
// Keep role state aligned when only one role exists for the selected action.
|
||||||
|
// This preserves existing UI bindings that read roleIdentifier directly.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!roleIdentifier && availableRoles.length === 1) {
|
||||||
|
setRoleIdentifier(availableRoles[0]);
|
||||||
|
}
|
||||||
|
}, [roleIdentifier, availableRoles]);
|
||||||
|
|
||||||
// ── Wizard state ─────────────────────────────────────────────────
|
// ── Wizard state ─────────────────────────────────────────────────
|
||||||
const [steps, setSteps] = useState<WizardStep[]>([]);
|
const [steps, setSteps] = useState<WizardStep[]>([]);
|
||||||
@@ -57,6 +73,8 @@ export function useActionWizard() {
|
|||||||
// ── Invitation ───────────────────────────────────────────────────
|
// ── Invitation ───────────────────────────────────────────────────
|
||||||
const [invitation, setInvitation] = useState<XOInvitation | null>(null);
|
const [invitation, setInvitation] = useState<XOInvitation | null>(null);
|
||||||
const [invitationId, setInvitationId] = useState<string | null>(null);
|
const [invitationId, setInvitationId] = useState<string | null>(null);
|
||||||
|
const [requirementsComplete, setRequirementsComplete] = useState(false);
|
||||||
|
const [hasSignedAndBroadcasted, setHasSignedAndBroadcasted] = useState(false);
|
||||||
|
|
||||||
// ── UI state ─────────────────────────────────────────────────────
|
// ── UI state ─────────────────────────────────────────────────────
|
||||||
const [focusedInput, setFocusedInput] = useState(0);
|
const [focusedInput, setFocusedInput] = useState(0);
|
||||||
@@ -78,9 +96,19 @@ export function useActionWizard() {
|
|||||||
const textInputHasFocus =
|
const textInputHasFocus =
|
||||||
currentStepData?.type === 'variables' && focusArea === 'content';
|
currentStepData?.type === 'variables' && focusArea === 'content';
|
||||||
|
|
||||||
|
// Whether the wizard actually includes an inputs step — this determines if
|
||||||
|
// the creator provided funding and therefore can sign & broadcast locally.
|
||||||
|
const wizardCollectedInputs = steps.some((s) => s.type === 'inputs');
|
||||||
|
|
||||||
|
const canSignAndBroadcast =
|
||||||
|
currentStepData?.type === 'publish'
|
||||||
|
&& wizardCollectedInputs
|
||||||
|
&& requirementsComplete
|
||||||
|
&& !hasSignedAndBroadcasted;
|
||||||
|
|
||||||
// ── Initialization ───────────────────────────────────────────────
|
// ── Initialization ───────────────────────────────────────────────
|
||||||
// Builds the wizard steps dynamically based on the selected role.
|
// Builds the wizard steps dynamically based on the selected role.
|
||||||
// Re-runs when roleIdentifier changes to add role-specific steps.
|
// Re-runs when role selection changes to add role-specific steps.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!template || !actionIdentifier) {
|
if (!template || !actionIdentifier) {
|
||||||
showError('Missing wizard data');
|
showError('Missing wizard data');
|
||||||
@@ -89,14 +117,17 @@ export function useActionWizard() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const wizardSteps: WizardStep[] = [];
|
const wizardSteps: WizardStep[] = [];
|
||||||
|
const shouldShowRoleSelection = availableRoles.length > 1;
|
||||||
|
|
||||||
// Always start with role selection
|
// Only require explicit role selection when the action is actually ambiguous.
|
||||||
wizardSteps.push({ name: 'Select Role', type: 'role-select' });
|
if (shouldShowRoleSelection) {
|
||||||
|
wizardSteps.push({ name: 'Select Role', type: 'role-select' });
|
||||||
|
}
|
||||||
|
|
||||||
// Add role-specific steps only after role is selected
|
// Add role-specific steps only after role is selected
|
||||||
if (roleIdentifier) {
|
if (effectiveRoleForFlow) {
|
||||||
const act = template.actions?.[actionIdentifier];
|
const act = template.actions?.[actionIdentifier];
|
||||||
const role = act?.roles?.[roleIdentifier];
|
const role = act?.roles?.[effectiveRoleForFlow];
|
||||||
const requirements = role?.requirements;
|
const requirements = role?.requirements;
|
||||||
|
|
||||||
// Add variables step if needed
|
// Add variables step if needed
|
||||||
@@ -116,8 +147,23 @@ export function useActionWizard() {
|
|||||||
setVariables(varInputs);
|
setVariables(varInputs);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add inputs step if role requires slots (funding inputs)
|
// Determine whether the creator should provide inputs during this wizard.
|
||||||
if (requirements?.slots && requirements.slots.min > 0) {
|
//
|
||||||
|
// Single-role actions (e.g. "send"): the creator is the sole participant,
|
||||||
|
// so we collect inputs here if the role needs them at all.
|
||||||
|
//
|
||||||
|
// Multi-role actions (e.g. "receive"): the creator is setting up the
|
||||||
|
// invitation for another party to accept. We only collect inputs during
|
||||||
|
// creation if the role EXPLICITLY requires them (slots.min > 0).
|
||||||
|
// Implicit inputs (transaction-level) are assumed to be provided later
|
||||||
|
// by the accepting party.
|
||||||
|
const totalActionRoles = Object.keys(act?.roles ?? {}).length;
|
||||||
|
const isSingleRoleAction = totalActionRoles <= 1;
|
||||||
|
|
||||||
|
const shouldCollectInputs =
|
||||||
|
isSingleRoleAction && roleRequiresInputs(template, actionIdentifier, effectiveRoleForFlow);
|
||||||
|
|
||||||
|
if (shouldCollectInputs) {
|
||||||
wizardSteps.push({ name: 'Select UTXOs', type: 'inputs' });
|
wizardSteps.push({ name: 'Select UTXOs', type: 'inputs' });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -127,11 +173,12 @@ export function useActionWizard() {
|
|||||||
wizardSteps.push({ name: 'Publish', type: 'publish' });
|
wizardSteps.push({ name: 'Publish', type: 'publish' });
|
||||||
|
|
||||||
setSteps(wizardSteps);
|
setSteps(wizardSteps);
|
||||||
setStatus(roleIdentifier ? `${actionIdentifier}/${roleIdentifier}` : actionIdentifier);
|
setStatus(effectiveRoleForFlow ? `${actionIdentifier}/${effectiveRoleForFlow}` : actionIdentifier);
|
||||||
}, [
|
}, [
|
||||||
template,
|
template,
|
||||||
actionIdentifier,
|
actionIdentifier,
|
||||||
roleIdentifier,
|
availableRoles.length,
|
||||||
|
effectiveRoleForFlow,
|
||||||
showError,
|
showError,
|
||||||
goBack,
|
goBack,
|
||||||
setStatus,
|
setStatus,
|
||||||
@@ -141,12 +188,12 @@ export function useActionWizard() {
|
|||||||
// This runs after the main useEffect has rebuilt steps, ensuring
|
// This runs after the main useEffect has rebuilt steps, ensuring
|
||||||
// we advance to the correct step (variables, inputs, or review).
|
// we advance to the correct step (variables, inputs, or review).
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (roleIdentifier && currentStep === 0 && steps[0]?.type === 'role-select') {
|
if (effectiveRoleForFlow && currentStep === 0 && steps[0]?.type === 'role-select') {
|
||||||
setCurrentStep(1);
|
setCurrentStep(1);
|
||||||
setFocusArea('content');
|
setFocusArea('content');
|
||||||
setFocusedInput(0);
|
setFocusedInput(0);
|
||||||
}
|
}
|
||||||
}, [roleIdentifier, currentStep, steps]);
|
}, [effectiveRoleForFlow, currentStep, steps]);
|
||||||
|
|
||||||
// ── Update a single variable value ───────────────────────────────
|
// ── Update a single variable value ───────────────────────────────
|
||||||
const updateVariable = useCallback((index: number, value: string) => {
|
const updateVariable = useCallback((index: number, value: string) => {
|
||||||
@@ -195,6 +242,25 @@ export function useActionWizard() {
|
|||||||
}
|
}
|
||||||
}, [invitationId, showInfo, showError]);
|
}, [invitationId, showInfo, showError]);
|
||||||
|
|
||||||
|
const refreshRequirementState = useCallback(async (identifier: string | null = invitationId) => {
|
||||||
|
if (!identifier || !appService) {
|
||||||
|
setRequirementsComplete(false);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const invitationInstance = appService.invitations.find(
|
||||||
|
(inv) => inv.data.invitationIdentifier === identifier
|
||||||
|
);
|
||||||
|
if (!invitationInstance) {
|
||||||
|
setRequirementsComplete(false);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const complete = await isInvitationRequirementsComplete(invitationInstance);
|
||||||
|
setRequirementsComplete(complete);
|
||||||
|
return complete;
|
||||||
|
}, [appService, invitationId]);
|
||||||
|
|
||||||
// ── Load available UTXOs for the inputs step ────────────────────
|
// ── Load available UTXOs for the inputs step ────────────────────
|
||||||
const loadAvailableUtxos = useCallback(async () => {
|
const loadAvailableUtxos = useCallback(async () => {
|
||||||
if (!invitation || !templateIdentifier || !appService || !invitationId) {
|
if (!invitation || !templateIdentifier || !appService || !invitationId) {
|
||||||
@@ -225,49 +291,19 @@ export function useActionWizard() {
|
|||||||
throw new Error('Invitation not found');
|
throw new Error('Invitation not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Query for suitable resources
|
// Query for suitable resources.
|
||||||
|
// NOTE: Even for single-role actions we still keep the user in the loop for inputs:
|
||||||
|
// we only surface UTXOs the engine/template currently considers "selectable" and let
|
||||||
|
// the user confirm them in the inputs step. If selectable semantics evolve, revisit here.
|
||||||
const unspentOutputs = await invitationInstance.findSuitableResources({
|
const unspentOutputs = await invitationInstance.findSuitableResources({
|
||||||
templateIdentifier,
|
templateIdentifier,
|
||||||
outputIdentifier: 'receiveOutput',
|
outputIdentifier: 'receiveOutput',
|
||||||
});
|
});
|
||||||
|
|
||||||
// Map to selectable UTXOs
|
// Map to selectable UTXOs and pre-select greedily.
|
||||||
const utxos: SelectableUTXO[] = unspentOutputs.map((utxo: any) => ({
|
const mappedUtxos = mapUnspentOutputsToSelectable(unspentOutputs);
|
||||||
outpointTransactionHash: utxo.outpointTransactionHash,
|
const autoSelectedUtxos = autoSelectGreedyUtxos(mappedUtxos, requested + fee);
|
||||||
outpointIndex: utxo.outpointIndex,
|
setAvailableUtxos(autoSelectedUtxos as SelectableUTXO[]);
|
||||||
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');
|
setStatus('Ready');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showError(
|
showError(
|
||||||
@@ -301,7 +337,7 @@ export function useActionWizard() {
|
|||||||
*/
|
*/
|
||||||
const createInvitationWithVariables = useCallback(
|
const createInvitationWithVariables = useCallback(
|
||||||
async (roleId?: string): Promise<boolean> => {
|
async (roleId?: string): Promise<boolean> => {
|
||||||
const effectiveRole = roleId ?? roleIdentifier;
|
const effectiveRole = roleId ?? effectiveRoleForFlow;
|
||||||
|
|
||||||
if (
|
if (
|
||||||
!templateIdentifier ||
|
!templateIdentifier ||
|
||||||
@@ -350,6 +386,14 @@ export function useActionWizard() {
|
|||||||
inv = invitationInstance.data;
|
inv = invitationInstance.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const variableValuesByIdentifier = variables.reduce((acc, variable) => {
|
||||||
|
if (typeof variable.value === 'string' && variable.value.trim().length > 0) {
|
||||||
|
acc[variable.id] = variable.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return acc;
|
||||||
|
}, {} as Record<string, string>);
|
||||||
|
|
||||||
// Add template-required outputs for the current role
|
// Add template-required outputs for the current role
|
||||||
const act = template.actions?.[actionIdentifier];
|
const act = template.actions?.[actionIdentifier];
|
||||||
const transaction = act?.transaction
|
const transaction = act?.transaction
|
||||||
@@ -358,17 +402,26 @@ export function useActionWizard() {
|
|||||||
|
|
||||||
if (transaction?.outputs && transaction.outputs.length > 0) {
|
if (transaction?.outputs && transaction.outputs.length > 0) {
|
||||||
setStatus('Adding required outputs...');
|
setStatus('Adding required outputs...');
|
||||||
|
const outputsToAdd = await Promise.all(transaction.outputs.map(async (output: XOTemplateTransactionOutput) => {
|
||||||
|
const outputIdentifier = getTransactionOutputIdentifier(output);
|
||||||
|
if (!outputIdentifier) {
|
||||||
|
throw new Error('Invalid transaction output definition');
|
||||||
|
}
|
||||||
|
|
||||||
const outputsToAdd = await Promise.all(transaction.outputs.map(
|
const providedLockingBytecodeHex = resolveProvidedLockingBytecodeHex(
|
||||||
async (output: XOTemplateTransactionOutput) => ({
|
template,
|
||||||
// TODO: Fix this. Currently, there is a type mismatch due to branches/versions of the libraries
|
outputIdentifier,
|
||||||
outputIdentifier: output as unknown as string,
|
variableValuesByIdentifier,
|
||||||
// roleIdentifier: roleIdentifier,
|
);
|
||||||
|
|
||||||
// TODO: This feels like an odd requirement? Shouldnt this be handled in the engine?
|
const lockingBytecodeHex = providedLockingBytecodeHex
|
||||||
lockingBytecode: await invitationInstance.generateLockingBytecode(output as unknown as string, roleIdentifier),
|
?? await invitationInstance.generateLockingBytecode(outputIdentifier, effectiveRole);
|
||||||
})
|
|
||||||
));
|
return {
|
||||||
|
outputIdentifier,
|
||||||
|
lockingBytecode: lockingBytecodeHex,
|
||||||
|
};
|
||||||
|
}));
|
||||||
|
|
||||||
// TODO: Clean this up. Suggestions: 1. Convert to bytes above. 2. Have addOuputs accept a hex string. 3. Have addOutputs handling the lockscript generation
|
// TODO: Clean this up. Suggestions: 1. Convert to bytes above. 2. Have addOuputs accept a hex string. 3. Have addOutputs handling the lockscript generation
|
||||||
await invitationInstance.addOutputs(outputsToAdd.map((output) => ({
|
await invitationInstance.addOutputs(outputsToAdd.map((output) => ({
|
||||||
@@ -381,6 +434,7 @@ export function useActionWizard() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
setInvitation(inv);
|
setInvitation(inv);
|
||||||
|
await refreshRequirementState(invId);
|
||||||
setStatus('Invitation created');
|
setStatus('Invitation created');
|
||||||
return true;
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -395,15 +449,51 @@ export function useActionWizard() {
|
|||||||
[
|
[
|
||||||
templateIdentifier,
|
templateIdentifier,
|
||||||
actionIdentifier,
|
actionIdentifier,
|
||||||
roleIdentifier,
|
effectiveRoleForFlow,
|
||||||
template,
|
template,
|
||||||
variables,
|
variables,
|
||||||
appService,
|
appService,
|
||||||
showError,
|
showError,
|
||||||
setStatus,
|
setStatus,
|
||||||
|
refreshRequirementState,
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Ensure invitation exists before entering input/review/publish stages.
|
||||||
|
useEffect(() => {
|
||||||
|
const ensureInvitation = async () => {
|
||||||
|
if (!currentStepData) return;
|
||||||
|
if (currentStepData.type !== 'inputs' && currentStepData.type !== 'review' && currentStepData.type !== 'publish') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (invitationId) {
|
||||||
|
if (currentStepData.type === 'inputs' && availableUtxos.length === 0 && !isProcessing) {
|
||||||
|
await loadAvailableUtxos();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!effectiveRoleForFlow || isProcessing) return;
|
||||||
|
const success = await createInvitationWithVariables(effectiveRoleForFlow);
|
||||||
|
if (!success) return;
|
||||||
|
|
||||||
|
if (currentStepData.type === 'inputs') {
|
||||||
|
await loadAvailableUtxos();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
ensureInvitation().catch(() => {});
|
||||||
|
}, [
|
||||||
|
currentStepData,
|
||||||
|
invitationId,
|
||||||
|
effectiveRoleForFlow,
|
||||||
|
isProcessing,
|
||||||
|
createInvitationWithVariables,
|
||||||
|
loadAvailableUtxos,
|
||||||
|
availableUtxos.length,
|
||||||
|
]);
|
||||||
|
|
||||||
// ── Add selected inputs + change output to the invitation ───────
|
// ── Add selected inputs + change output to the invitation ───────
|
||||||
const addInputsAndOutputs = useCallback(async () => {
|
const addInputsAndOutputs = useCallback(async () => {
|
||||||
if (!invitationId || !invitation || !appService) return;
|
if (!invitationId || !invitation || !appService) return;
|
||||||
@@ -459,6 +549,7 @@ export function useActionWizard() {
|
|||||||
];
|
];
|
||||||
|
|
||||||
await invitationInstance.addOutputs(outputs);
|
await invitationInstance.addOutputs(outputs);
|
||||||
|
await refreshRequirementState(invitationId);
|
||||||
|
|
||||||
setCurrentStep((prev) => prev + 1);
|
setCurrentStep((prev) => prev + 1);
|
||||||
setStatus('Inputs and outputs added');
|
setStatus('Inputs and outputs added');
|
||||||
@@ -480,14 +571,15 @@ export function useActionWizard() {
|
|||||||
appService,
|
appService,
|
||||||
showError,
|
showError,
|
||||||
setStatus,
|
setStatus,
|
||||||
|
refreshRequirementState,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// ── Publish the invitation ──────────────────────────────────────
|
// ── Move to publish step ────────────────────────────────────────
|
||||||
const publishInvitation = useCallback(async () => {
|
const advanceToPublishStep = useCallback(async () => {
|
||||||
if (!invitationId || !appService) return;
|
if (!invitationId || !appService) return;
|
||||||
|
|
||||||
setIsProcessing(true);
|
setIsProcessing(true);
|
||||||
setStatus('Publishing invitation...');
|
setStatus('Preparing publish step...');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const invitationInstance = appService.invitations.find(
|
const invitationInstance = appService.invitations.find(
|
||||||
@@ -498,23 +590,61 @@ export function useActionWizard() {
|
|||||||
throw new Error('Invitation not found');
|
throw new Error('Invitation not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Already tracked and synced via SSE from createInvitation
|
await refreshRequirementState(invitationId);
|
||||||
setCurrentStep((prev) => prev + 1);
|
setCurrentStep((prev) => prev + 1);
|
||||||
setStatus('Invitation published');
|
setStatus('Ready to publish');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showError(
|
showError(
|
||||||
`Failed to publish: ${error instanceof Error ? error.message : String(error)}`
|
`Failed to prepare publish step: ${error instanceof Error ? error.message : String(error)}`
|
||||||
);
|
);
|
||||||
} finally {
|
} finally {
|
||||||
setIsProcessing(false);
|
setIsProcessing(false);
|
||||||
}
|
}
|
||||||
}, [invitationId, appService, showError, setStatus]);
|
}, [invitationId, appService, showError, setStatus, refreshRequirementState]);
|
||||||
|
|
||||||
|
// ── Sign and broadcast from publish step ────────────────────────
|
||||||
|
const signAndBroadcastInvitation = useCallback(async () => {
|
||||||
|
if (!invitationId || !appService) return;
|
||||||
|
|
||||||
|
setIsProcessing(true);
|
||||||
|
setStatus('Signing invitation...');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const invitationInstance = appService.invitations.find(
|
||||||
|
(inv) => inv.data.invitationIdentifier === invitationId
|
||||||
|
);
|
||||||
|
if (!invitationInstance) {
|
||||||
|
throw new Error('Invitation not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const complete = await refreshRequirementState(invitationId);
|
||||||
|
if (!complete) {
|
||||||
|
showError('Invitation requirements are not complete yet.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!wizardCollectedInputs) {
|
||||||
|
showError('This action does not require funding inputs, so it cannot be signed and broadcasted here.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await invitationInstance.sign();
|
||||||
|
setStatus('Broadcasting transaction...');
|
||||||
|
await invitationInstance.broadcast();
|
||||||
|
setHasSignedAndBroadcasted(true);
|
||||||
|
setStatus('Transaction signed and broadcasted');
|
||||||
|
showInfo('Transaction signed and broadcasted.');
|
||||||
|
await refreshRequirementState(invitationId);
|
||||||
|
} catch (error) {
|
||||||
|
showError(`Failed to sign and broadcast: ${error instanceof Error ? error.message : String(error)}`);
|
||||||
|
} finally {
|
||||||
|
setIsProcessing(false);
|
||||||
|
}
|
||||||
|
}, [invitationId, appService, setStatus, showError, showInfo, refreshRequirementState, wizardCollectedInputs]);
|
||||||
|
|
||||||
// ── Navigate to the next step ───────────────────────────────────
|
// ── Navigate to the next step ───────────────────────────────────
|
||||||
const nextStep = useCallback(async () => {
|
const nextStep = useCallback(async () => {
|
||||||
if (currentStep >= steps.length - 1) return;
|
|
||||||
|
|
||||||
const stepType = currentStepData?.type;
|
const stepType = currentStepData?.type;
|
||||||
|
if (currentStep >= steps.length - 1 && stepType !== 'publish') return;
|
||||||
|
|
||||||
// ── Role selection ──────────────────────────────────────────
|
// ── Role selection ──────────────────────────────────────────
|
||||||
if (stepType === 'role-select') {
|
if (stepType === 'role-select') {
|
||||||
@@ -531,7 +661,19 @@ export function useActionWizard() {
|
|||||||
|
|
||||||
const hasVariables =
|
const hasVariables =
|
||||||
requirements?.variables && requirements.variables.length > 0;
|
requirements?.variables && requirements.variables.length > 0;
|
||||||
const hasSlots = requirements?.slots && requirements.slots.min > 0;
|
|
||||||
|
// Mirror the inputs-step inference from the step-building effect:
|
||||||
|
// single-role → any inputs; multi-role → explicit requirements only.
|
||||||
|
const totalActionRoles = Object.keys(act?.roles ?? {}).length;
|
||||||
|
const roleExplicitlyNeedsInputs =
|
||||||
|
(requirements?.slots && requirements.slots.min > 0)
|
||||||
|
|| (act?.requirements?.roles?.find(
|
||||||
|
(r: { role: string; slots?: { min?: number } }) => r.role === selectedRole,
|
||||||
|
)?.slots?.min ?? 0) > 0;
|
||||||
|
|
||||||
|
const hasSlots = totalActionRoles <= 1
|
||||||
|
? roleRequiresInputs(template, actionIdentifier, selectedRole)
|
||||||
|
: roleExplicitlyNeedsInputs;
|
||||||
|
|
||||||
// If there is no variables step, the invitation must be created now
|
// If there is no variables step, the invitation must be created now
|
||||||
// because the variables step would normally handle it.
|
// because the variables step would normally handle it.
|
||||||
@@ -582,17 +724,38 @@ export function useActionWizard() {
|
|||||||
|
|
||||||
// ── Inputs ──────────────────────────────────────────────────
|
// ── Inputs ──────────────────────────────────────────────────
|
||||||
if (stepType === 'inputs') {
|
if (stepType === 'inputs') {
|
||||||
|
if (!invitationId) {
|
||||||
|
const success = await createInvitationWithVariables();
|
||||||
|
if (!success) return;
|
||||||
|
await loadAvailableUtxos();
|
||||||
|
return;
|
||||||
|
}
|
||||||
await addInputsAndOutputs();
|
await addInputsAndOutputs();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Review ──────────────────────────────────────────────────
|
// ── Review ──────────────────────────────────────────────────
|
||||||
if (stepType === 'review') {
|
if (stepType === 'review') {
|
||||||
await publishInvitation();
|
if (!invitationId) {
|
||||||
|
const success = await createInvitationWithVariables();
|
||||||
|
if (!success) return;
|
||||||
|
}
|
||||||
|
await advanceToPublishStep();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Generic advance (e.g. publish → done) ───────────────────
|
// ── Publish ─────────────────────────────────────────────────
|
||||||
|
if (stepType === 'publish') {
|
||||||
|
if (canSignAndBroadcast) {
|
||||||
|
await signAndBroadcastInvitation();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Done should exit the wizard, not advance past the final step.
|
||||||
|
goBack();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Generic advance ─────────────────────────────────────────
|
||||||
setCurrentStep((prev) => prev + 1);
|
setCurrentStep((prev) => prev + 1);
|
||||||
setFocusArea('content');
|
setFocusArea('content');
|
||||||
setFocusedInput(0);
|
setFocusedInput(0);
|
||||||
@@ -600,6 +763,7 @@ export function useActionWizard() {
|
|||||||
currentStep,
|
currentStep,
|
||||||
steps,
|
steps,
|
||||||
currentStepData,
|
currentStepData,
|
||||||
|
canSignAndBroadcast,
|
||||||
availableRoles,
|
availableRoles,
|
||||||
selectedRoleIndex,
|
selectedRoleIndex,
|
||||||
template,
|
template,
|
||||||
@@ -609,7 +773,11 @@ export function useActionWizard() {
|
|||||||
createInvitationWithVariables,
|
createInvitationWithVariables,
|
||||||
loadAvailableUtxos,
|
loadAvailableUtxos,
|
||||||
addInputsAndOutputs,
|
addInputsAndOutputs,
|
||||||
publishInvitation,
|
advanceToPublishStep,
|
||||||
|
requirementsComplete,
|
||||||
|
hasSignedAndBroadcasted,
|
||||||
|
signAndBroadcastInvitation,
|
||||||
|
goBack,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// ── Navigate to the previous step ──────────────────────────────
|
// ── Navigate to the previous step ──────────────────────────────
|
||||||
@@ -667,6 +835,9 @@ export function useActionWizard() {
|
|||||||
// Invitation
|
// Invitation
|
||||||
invitation,
|
invitation,
|
||||||
invitationId,
|
invitationId,
|
||||||
|
requirementsComplete,
|
||||||
|
hasSignedAndBroadcasted,
|
||||||
|
canSignAndBroadcast,
|
||||||
|
|
||||||
// UI focus
|
// UI focus
|
||||||
focusedInput,
|
focusedInput,
|
||||||
|
|||||||
@@ -6,10 +6,11 @@
|
|||||||
* Shows required, selected, and change amounts.
|
* Shows required, selected, and change amounts.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useState, useEffect, useCallback } from 'react';
|
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
||||||
import { Box, Text, useInput } from 'ink';
|
import { Box, Text, useInput } from 'ink';
|
||||||
import { colors, formatSatoshis } from '../../../../theme.js';
|
import { colors, formatSatoshis } from '../../../../theme.js';
|
||||||
import type { InputsSelectStepProps, SelectableUTXO } from '../types.js';
|
import type { InputsSelectStepProps, SelectableUTXO } from '../types.js';
|
||||||
|
import { autoSelectGreedyUtxos, mapUnspentOutputsToSelectable } from '../../../../../utils/invitation-flow.js';
|
||||||
|
|
||||||
/** Default fee estimate in satoshis. */
|
/** Default fee estimate in satoshis. */
|
||||||
const DEFAULT_FEE = 500n;
|
const DEFAULT_FEE = 500n;
|
||||||
@@ -64,34 +65,9 @@ export function InputsSelectStep({
|
|||||||
outputIdentifier: 'receiveOutput',
|
outputIdentifier: 'receiveOutput',
|
||||||
});
|
});
|
||||||
|
|
||||||
// Map to selectable UTXOs
|
const selectable = mapUnspentOutputsToSelectable(unspentOutputs);
|
||||||
const selectable: SelectableUTXO[] = unspentOutputs.map((utxo: any) => ({
|
const autoSelected = autoSelectGreedyUtxos(selectable, required + fee);
|
||||||
outpointTransactionHash: utxo.outpointTransactionHash,
|
setUtxos(autoSelected as SelectableUTXO[]);
|
||||||
outpointIndex: utxo.outpointIndex,
|
|
||||||
valueSatoshis: BigInt(utxo.valueSatoshis),
|
|
||||||
lockingBytecode: utxo.lockingBytecode
|
|
||||||
? typeof utxo.lockingBytecode === 'string'
|
|
||||||
? utxo.lockingBytecode
|
|
||||||
: Buffer.from(utxo.lockingBytecode).toString('hex')
|
|
||||||
: undefined,
|
|
||||||
selected: false,
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Greedy auto-select, skipping duplicate locking bytecodes
|
|
||||||
let accumulated = 0n;
|
|
||||||
const seenBytecodes = new Set<string>();
|
|
||||||
|
|
||||||
for (const utxo of selectable) {
|
|
||||||
if (utxo.lockingBytecode && seenBytecodes.has(utxo.lockingBytecode)) continue;
|
|
||||||
if (utxo.lockingBytecode) seenBytecodes.add(utxo.lockingBytecode);
|
|
||||||
|
|
||||||
utxo.selected = true;
|
|
||||||
accumulated += utxo.valueSatoshis;
|
|
||||||
|
|
||||||
if (accumulated >= required + fee) break;
|
|
||||||
}
|
|
||||||
|
|
||||||
setUtxos(selectable);
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : String(err));
|
setError(err instanceof Error ? err.message : String(err));
|
||||||
} finally {
|
} finally {
|
||||||
@@ -99,9 +75,15 @@ export function InputsSelectStep({
|
|||||||
}
|
}
|
||||||
}, [invitation, computeRequiredAmount, fee]);
|
}, [invitation, computeRequiredAmount, fee]);
|
||||||
|
|
||||||
// Load UTXOs on mount
|
// Load UTXOs once on mount. We use a ref guard to prevent re-firing when
|
||||||
|
// `loadUtxos` identity changes due to parent re-renders — each re-fire
|
||||||
|
// flashes the loading state, causing the visible flicker bug.
|
||||||
|
const hasLoadedRef = useRef(false);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isActive) loadUtxos();
|
if (isActive && !hasLoadedRef.current) {
|
||||||
|
hasLoadedRef.current = true;
|
||||||
|
loadUtxos();
|
||||||
|
}
|
||||||
}, [isActive, loadUtxos]);
|
}, [isActive, loadUtxos]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
* Cross-platform clipboard utility with multiple fallback methods.
|
* Cross-platform clipboard utility with multiple fallback methods.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import clipboardy from 'clipboardy';
|
||||||
import { exec } from 'child_process';
|
import { exec } from 'child_process';
|
||||||
import { promisify } from 'util';
|
import { promisify } from 'util';
|
||||||
|
|
||||||
@@ -50,8 +51,7 @@ export async function copyToClipboard(text: string): Promise<void> {
|
|||||||
|
|
||||||
// Fallback to clipboardy
|
// Fallback to clipboardy
|
||||||
try {
|
try {
|
||||||
const clipboard = await import('clipboardy');
|
clipboardy.writeSync(text);
|
||||||
await clipboard.default.write(text);
|
|
||||||
return;
|
return;
|
||||||
} catch {
|
} catch {
|
||||||
// clipboardy also failed
|
// clipboardy also failed
|
||||||
|
|||||||
170
src/utils/bch-mnemonic-url.ts
Normal file
170
src/utils/bch-mnemonic-url.ts
Normal file
@@ -0,0 +1,170 @@
|
|||||||
|
/*
|
||||||
|
* Handles BCH Mnemonic parsing to/from URL form.
|
||||||
|
* Pulled directly from the old stack package.
|
||||||
|
*/
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
export type BCHMnemonicURLRaw = {
|
||||||
|
entropy: Uint8Array;
|
||||||
|
passphrase?: string;
|
||||||
|
language?: (typeof BCHMnemonicURL.SUPPORTED_LANGUAGES)[number];
|
||||||
|
comment?: string;
|
||||||
|
path?: string;
|
||||||
|
startHeight?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles BCHMnemonic URLs
|
||||||
|
*/
|
||||||
|
export class BCHMnemonicURL {
|
||||||
|
static PROTOCOL = 'bch-mnemonic';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a URL is a valid wallet backup URL
|
||||||
|
*
|
||||||
|
* @param url The URL to check
|
||||||
|
* @returns True if the URL is a valid wallet backup URL, false otherwise
|
||||||
|
*/
|
||||||
|
public static canHandle(urlStr: string): boolean {
|
||||||
|
try {
|
||||||
|
BCHMnemonicURL.fromURL(urlStr);
|
||||||
|
return true;
|
||||||
|
} catch (_error) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a BCHMnemonic from a URL-encoded string
|
||||||
|
* @param urlStr - The URL-encoded mnemonic string
|
||||||
|
* @returns A new BCHMnemonic instance
|
||||||
|
* @throws Error if the URL format is invalid or entropy is invalid
|
||||||
|
*/
|
||||||
|
static fromURL(urlStr: string): BCHMnemonicURL {
|
||||||
|
const url = new URL(urlStr);
|
||||||
|
|
||||||
|
if (url.protocol !== `${BCHMnemonicURL.PROTOCOL}:`) {
|
||||||
|
throw new Error(`Invalid URL protocol: ${url.protocol}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decode the entropy.
|
||||||
|
const entropy = new Uint8Array(Buffer.from(url.pathname, 'base64'));
|
||||||
|
|
||||||
|
// Pick out our encoding keys from the URL
|
||||||
|
const params = BCHMnemonicURL.schema.parse(
|
||||||
|
Object.fromEntries(url.searchParams.entries()),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Create and return the backup with validated parameters
|
||||||
|
return BCHMnemonicURL.fromRaw({
|
||||||
|
entropy,
|
||||||
|
language: params[BCHMnemonicURL.ENCODING_KEYS.language],
|
||||||
|
comment: params[BCHMnemonicURL.ENCODING_KEYS.comment],
|
||||||
|
path: params[BCHMnemonicURL.ENCODING_KEYS.path],
|
||||||
|
startHeight: params[BCHMnemonicURL.ENCODING_KEYS.startHeight],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new WalletBackup from a raw object
|
||||||
|
*
|
||||||
|
* @param raw - The raw object to create the WalletBackup from
|
||||||
|
* @returns The created WalletBackup
|
||||||
|
*/
|
||||||
|
static fromRaw(raw: BCHMnemonicURLRaw): BCHMnemonicURL {
|
||||||
|
// Add entropy validation
|
||||||
|
if (!raw.entropy || raw.entropy.length === 0) {
|
||||||
|
throw new Error('Invalid entropy: must be non-empty');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate entropy length (typically 16, 20, 24, 28, or 32 bytes for BIP39)
|
||||||
|
const validLengths = [16, 20, 24, 28, 32];
|
||||||
|
if (!validLengths.includes(raw.entropy.length)) {
|
||||||
|
throw new Error(`Invalid entropy length: ${raw.entropy.length} bytes`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new BCHMnemonicURL(raw);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(protected raw: BCHMnemonicURLRaw) {}
|
||||||
|
|
||||||
|
toObject() {
|
||||||
|
return this.raw;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encode the backup into a URL encoding
|
||||||
|
*
|
||||||
|
* @param prefix - The prefix to use for the URL encoding
|
||||||
|
* @returns The URL encoding of the backup
|
||||||
|
*/
|
||||||
|
toURL(): string {
|
||||||
|
// Conver the mnemonic words into the entropy used to derive the mnemonic words
|
||||||
|
const entropyBase64 = Buffer.from(this.raw.entropy).toString('base64');
|
||||||
|
|
||||||
|
// Create a new URL object with the prefix and the base64 encoded mnemonic
|
||||||
|
const url = new URL(`${BCHMnemonicURL.PROTOCOL}:${entropyBase64}`);
|
||||||
|
|
||||||
|
// Add the raw values to the url encoded string. Only add the values that are defined.
|
||||||
|
if (this.raw.language !== undefined) {
|
||||||
|
url.searchParams.set(
|
||||||
|
BCHMnemonicURL.ENCODING_KEYS.language,
|
||||||
|
this.raw.language,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.raw.comment !== undefined) {
|
||||||
|
url.searchParams.set(
|
||||||
|
BCHMnemonicURL.ENCODING_KEYS.comment,
|
||||||
|
this.raw.comment,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.raw.path !== undefined) {
|
||||||
|
url.searchParams.set(BCHMnemonicURL.ENCODING_KEYS.path, this.raw.path);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.raw.startHeight !== undefined) {
|
||||||
|
url.searchParams.set(
|
||||||
|
BCHMnemonicURL.ENCODING_KEYS.startHeight,
|
||||||
|
this.raw.startHeight.toString(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return url.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
static ENCODING_KEYS = {
|
||||||
|
language: 'l',
|
||||||
|
passphrase: 'p',
|
||||||
|
comment: 'c',
|
||||||
|
path: 'd',
|
||||||
|
startHeight: 'h',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
static SUPPORTED_LANGUAGES = [
|
||||||
|
'en',
|
||||||
|
'zh-CN',
|
||||||
|
'zh-TW',
|
||||||
|
'ja',
|
||||||
|
'es',
|
||||||
|
'pt',
|
||||||
|
'ko',
|
||||||
|
'fr',
|
||||||
|
'it',
|
||||||
|
'cs',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zod schema for validating URL parameters
|
||||||
|
*/
|
||||||
|
static schema = z.object({
|
||||||
|
[BCHMnemonicURL.ENCODING_KEYS.language]: z
|
||||||
|
.enum(BCHMnemonicURL.SUPPORTED_LANGUAGES)
|
||||||
|
.optional(),
|
||||||
|
[BCHMnemonicURL.ENCODING_KEYS.passphrase]: z.string().optional(),
|
||||||
|
[BCHMnemonicURL.ENCODING_KEYS.comment]: z.string().optional(),
|
||||||
|
[BCHMnemonicURL.ENCODING_KEYS.path]: z.string().optional(),
|
||||||
|
[BCHMnemonicURL.ENCODING_KEYS.startHeight]: z.coerce.number().optional(),
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,259 +1,92 @@
|
|||||||
/**
|
import type { HistoryItem, HistoryInvitationItem, HistoryUtxoItem } from '../services/history.js';
|
||||||
* 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';
|
export type HistoryColorName = 'info' | 'warning' | 'success' | 'error' | 'muted' | 'text';
|
||||||
|
|
||||||
/**
|
export type HistoryRowType = 'invitation' | 'invitation_input' | 'invitation_output' | 'utxo';
|
||||||
* Formatted history list item data.
|
|
||||||
*/
|
export interface HistoryDisplayRow {
|
||||||
export interface FormattedHistoryItem {
|
id: string;
|
||||||
/** The display label for the history item */
|
type: HistoryRowType;
|
||||||
label: string;
|
label: string;
|
||||||
/** Optional secondary description */
|
|
||||||
description?: string;
|
description?: string;
|
||||||
/** The formatted date string */
|
timestamp?: number;
|
||||||
dateStr?: string;
|
isNested: boolean;
|
||||||
/** The semantic color name for this item type */
|
utxo?: HistoryUtxoItem;
|
||||||
color: HistoryColorName;
|
invitation?: HistoryInvitationItem;
|
||||||
/** 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 {
|
export function formatHistoryDate(timestamp?: number): string | undefined {
|
||||||
if (!timestamp) return undefined;
|
if (!timestamp) return undefined;
|
||||||
return new Date(timestamp).toLocaleDateString();
|
return new Date(timestamp).toLocaleDateString();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
export function buildHistoryDisplayRows(items: HistoryItem[]): HistoryDisplayRow[] {
|
||||||
* Format a history item for display in a list.
|
const rows: HistoryDisplayRow[] = [];
|
||||||
*
|
|
||||||
* @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) {
|
for (const item of items) {
|
||||||
switch (item.type) {
|
if (item.kind === 'invitation') {
|
||||||
case 'invitation_created':
|
rows.push({
|
||||||
invitationCount++;
|
id: item.id,
|
||||||
break;
|
type: 'invitation',
|
||||||
case 'utxo_reserved':
|
label: item.description,
|
||||||
totalReserved += item.valueSatoshis ?? 0n;
|
timestamp: item.createdAtTimestamp,
|
||||||
break;
|
isNested: false,
|
||||||
case 'utxo_received':
|
invitation: item,
|
||||||
totalReceived += item.valueSatoshis ?? 0n;
|
});
|
||||||
utxoCount++;
|
|
||||||
break;
|
for (const input of item.inputs) {
|
||||||
|
const satsPrefix = input.valueSatoshis !== undefined ? `${input.valueSatoshis.toLocaleString()} sats ` : '';
|
||||||
|
rows.push({
|
||||||
|
id: `${item.id}-input-${input.id}`,
|
||||||
|
type: 'invitation_input',
|
||||||
|
label: `${satsPrefix}${input.outpoint.txid}:${input.outpoint.index}`,
|
||||||
|
description: input.description,
|
||||||
|
isNested: true,
|
||||||
|
utxo: input,
|
||||||
|
invitation: item,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const output of item.outputs) {
|
||||||
|
rows.push({
|
||||||
|
id: `${item.id}-output-${output.id}`,
|
||||||
|
type: 'invitation_output',
|
||||||
|
label: output.valueSatoshis !== undefined ? `${output.valueSatoshis.toLocaleString()} sats` : 'Output',
|
||||||
|
description: output.description,
|
||||||
|
isNested: true,
|
||||||
|
utxo: output,
|
||||||
|
invitation: item,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
rows.push({
|
||||||
|
id: item.id,
|
||||||
|
type: 'utxo',
|
||||||
|
label: item.valueSatoshis !== undefined ? `${item.valueSatoshis.toLocaleString()} sats` : 'UTXO',
|
||||||
|
description: item.description,
|
||||||
|
isNested: false,
|
||||||
|
utxo: item,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return { totalReceived, totalReserved, invitationCount, utxoCount };
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getHistoryItemColorName(row: HistoryDisplayRow, isSelected: boolean = false): HistoryColorName {
|
||||||
|
if (isSelected) return 'info';
|
||||||
|
switch (row.type) {
|
||||||
|
case 'invitation':
|
||||||
|
return 'text';
|
||||||
|
case 'invitation_input':
|
||||||
|
return 'error';
|
||||||
|
case 'invitation_output':
|
||||||
|
return 'success';
|
||||||
|
case 'utxo':
|
||||||
|
return row.utxo?.reserved ? 'warning' : 'success';
|
||||||
|
default:
|
||||||
|
return 'text';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
152
src/utils/invitation-flow.ts
Normal file
152
src/utils/invitation-flow.ts
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
import type { XOTemplate, XOTemplateTransactionOutput } from '@xo-cash/types';
|
||||||
|
import type { Invitation } from '../services/invitation.js';
|
||||||
|
|
||||||
|
export interface SelectableUtxoLike {
|
||||||
|
outpointTransactionHash: string;
|
||||||
|
outpointIndex: number;
|
||||||
|
valueSatoshis: bigint;
|
||||||
|
lockingBytecode?: string;
|
||||||
|
selected: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const hasMissingRequirements = (missingRequirements: {
|
||||||
|
variables?: string[];
|
||||||
|
inputs?: string[];
|
||||||
|
outputs?: string[];
|
||||||
|
roles?: Record<string, unknown>;
|
||||||
|
}): boolean => {
|
||||||
|
return (
|
||||||
|
(missingRequirements.variables?.length ?? 0) > 0
|
||||||
|
|| (missingRequirements.inputs?.length ?? 0) > 0
|
||||||
|
|| (missingRequirements.outputs?.length ?? 0) > 0
|
||||||
|
|| (missingRequirements.roles !== undefined && Object.keys(missingRequirements.roles).length > 0)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const isInvitationRequirementsComplete = async (invitation: Invitation): Promise<boolean> => {
|
||||||
|
const missingRequirements = await invitation.getMissingRequirements();
|
||||||
|
return !hasMissingRequirements(missingRequirements);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const resolveActionRoles = (
|
||||||
|
template: XOTemplate | undefined,
|
||||||
|
actionIdentifier: string | undefined,
|
||||||
|
rolesFromNavigation?: string[],
|
||||||
|
): string[] => {
|
||||||
|
if (rolesFromNavigation && rolesFromNavigation.length > 0) {
|
||||||
|
return [ ...new Set(rolesFromNavigation) ];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!template || !actionIdentifier) return [];
|
||||||
|
const starts = template.start ?? [];
|
||||||
|
const roleIds = starts
|
||||||
|
.filter((entry) => entry.action === actionIdentifier)
|
||||||
|
.map((entry) => entry.role);
|
||||||
|
|
||||||
|
return [ ...new Set(roleIds) ];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const roleRequiresInputs = (
|
||||||
|
template: XOTemplate | undefined,
|
||||||
|
actionIdentifier: string | undefined,
|
||||||
|
roleIdentifier: string | undefined,
|
||||||
|
): boolean => {
|
||||||
|
if (!template || !actionIdentifier || !roleIdentifier) return false;
|
||||||
|
const action = template.actions?.[actionIdentifier];
|
||||||
|
if (!action) return false;
|
||||||
|
|
||||||
|
const actionRole = action.roles?.[roleIdentifier];
|
||||||
|
const roleSlotsMin = actionRole?.requirements?.slots?.min ?? 0;
|
||||||
|
if (roleSlotsMin > 0) return true;
|
||||||
|
|
||||||
|
// Some templates specify slot/input requirements at action.requirements.roles
|
||||||
|
// instead of role.requirements. Respect those as well.
|
||||||
|
const roleRequirement = action.requirements?.roles?.find((requirement) => requirement.role === roleIdentifier);
|
||||||
|
const actionLevelSlotsMin = roleRequirement?.slots?.min ?? 0;
|
||||||
|
if (actionLevelSlotsMin > 0) return true;
|
||||||
|
|
||||||
|
const transactionIdentifier = action.transaction;
|
||||||
|
const transaction = transactionIdentifier ? template.transactions?.[transactionIdentifier] : undefined;
|
||||||
|
const roleInputs = transaction?.roles?.[roleIdentifier]?.inputs;
|
||||||
|
|
||||||
|
return (roleInputs?.length ?? 0) > 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getTransactionOutputIdentifier = (output: XOTemplateTransactionOutput): string | undefined => {
|
||||||
|
if (typeof output === 'string') return output;
|
||||||
|
if (output && typeof output === 'object' && 'output' in output && typeof output.output === 'string') {
|
||||||
|
return output.output;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const normalizeLockingBytecodeHex = (value: string): string => value.trim().replace(/^0x/i, '');
|
||||||
|
|
||||||
|
export const resolveProvidedLockingBytecodeHex = (
|
||||||
|
template: XOTemplate,
|
||||||
|
outputIdentifier: string,
|
||||||
|
variableValues: Record<string, string>,
|
||||||
|
): string | undefined => {
|
||||||
|
const outputDefinition = template.outputs?.[outputIdentifier];
|
||||||
|
if (!outputDefinition || typeof outputDefinition.lockscript !== 'string') return undefined;
|
||||||
|
|
||||||
|
const lockingScriptDefinition = (template.lockingScripts as Record<string, unknown> | undefined)?.[outputDefinition.lockscript] as
|
||||||
|
| { lockingScript?: string }
|
||||||
|
| undefined;
|
||||||
|
const scriptIdentifier = lockingScriptDefinition?.lockingScript;
|
||||||
|
if (!scriptIdentifier) return undefined;
|
||||||
|
|
||||||
|
const scriptExpression = (template.scripts as Record<string, unknown> | undefined)?.[scriptIdentifier];
|
||||||
|
if (typeof scriptExpression !== 'string') return undefined;
|
||||||
|
|
||||||
|
const directVariableMatch = scriptExpression.match(/^<\s*([A-Za-z0-9_]+)\s*>$/);
|
||||||
|
if (!directVariableMatch) return undefined;
|
||||||
|
|
||||||
|
const variableIdentifier = directVariableMatch[1];
|
||||||
|
if (!variableIdentifier) return undefined;
|
||||||
|
|
||||||
|
const providedValue = variableValues[variableIdentifier];
|
||||||
|
if (!providedValue) return undefined;
|
||||||
|
|
||||||
|
return normalizeLockingBytecodeHex(providedValue);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const mapUnspentOutputsToSelectable = (unspentOutputs: any[]): SelectableUtxoLike[] => {
|
||||||
|
return unspentOutputs.map((utxo: any) => ({
|
||||||
|
outpointTransactionHash: utxo.outpointTransactionHash,
|
||||||
|
outpointIndex: utxo.outpointIndex,
|
||||||
|
valueSatoshis: BigInt(utxo.valueSatoshis),
|
||||||
|
lockingBytecode: utxo.lockingBytecode
|
||||||
|
? typeof utxo.lockingBytecode === 'string'
|
||||||
|
? utxo.lockingBytecode
|
||||||
|
: Buffer.from(utxo.lockingBytecode).toString('hex')
|
||||||
|
: undefined,
|
||||||
|
selected: false,
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
export const autoSelectGreedyUtxos = (
|
||||||
|
utxos: SelectableUtxoLike[],
|
||||||
|
requiredWithFee: bigint,
|
||||||
|
): SelectableUtxoLike[] => {
|
||||||
|
let accumulated = 0n;
|
||||||
|
const seenLockingBytecodes = new Set<string>();
|
||||||
|
|
||||||
|
for (const utxo of utxos) {
|
||||||
|
if (utxo.lockingBytecode && seenLockingBytecodes.has(utxo.lockingBytecode)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (utxo.lockingBytecode) {
|
||||||
|
seenLockingBytecodes.add(utxo.lockingBytecode);
|
||||||
|
}
|
||||||
|
|
||||||
|
utxo.selected = true;
|
||||||
|
accumulated += utxo.valueSatoshis;
|
||||||
|
|
||||||
|
if (accumulated >= requiredWithFee) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return utxos;
|
||||||
|
};
|
||||||
@@ -84,6 +84,7 @@ export function getStateColorName(state: string): StateColorName {
|
|||||||
return 'warning';
|
return 'warning';
|
||||||
case 'ready':
|
case 'ready':
|
||||||
case 'signed':
|
case 'signed':
|
||||||
|
case 'complete':
|
||||||
case 'broadcast':
|
case 'broadcast':
|
||||||
case 'completed':
|
case 'completed':
|
||||||
return 'success';
|
return 'success';
|
||||||
|
|||||||
Reference in New Issue
Block a user