Compare commits
54 Commits
32c42cdc2d
...
kiok-updat
| Author | SHA1 | Date | |
|---|---|---|---|
|
cfcba02bb3
|
|||
|
3ee2d53766
|
|||
|
d089e909f8
|
|||
|
771968dfbb
|
|||
|
d2c37fd957
|
|||
|
bca736dab4
|
|||
|
69adee180a
|
|||
|
c7e1d69e2d
|
|||
|
b30243f674
|
|||
|
5e9c6db412
|
|||
|
5bec49858f
|
|||
|
f1ac89ef91
|
|||
|
17a41cf29a
|
|||
|
0b848989a2
|
|||
|
1776fbbf61
|
|||
|
0acc70b613
|
|||
|
14e74fab6c
|
|||
|
a7f0ed69a2
|
|||
|
2f8dad7d8d
|
|||
|
85746c3306
|
|||
|
def261b568
|
|||
|
3d6518e465
|
|||
| bcc3277cb9 | |||
|
b2ccff5b19
|
|||
| 12b7bde74f | |||
| 42d23fa35e | |||
| b6ee25d1dd | |||
| b4d82b8b1f | |||
| a0d9775015 | |||
| 6c01ac1c1b | |||
| ebe1d8acda | |||
| c2334b2cdd | |||
| dec228063b | |||
| 3c47ee8a4c | |||
| 8d7856f32e | |||
| b8b0a4a1ba | |||
| dedfb69dff | |||
| 2f2e515d72 | |||
| 7ffb5c44b5 | |||
| f978d740fe | |||
| 6196d33b2a | |||
| ccfaf3fd70 | |||
| 531e53d2ae | |||
| b708c8c1f8 | |||
| 53ad7b729e | |||
| e73fb24422 | |||
| b282bbf5d6 | |||
| bd1ae909b5 | |||
| e97054fa34 | |||
| a43a45831c | |||
| 1bbc21c742 | |||
| 9fa87d01b3 | |||
| 7ad17a7c0e | |||
| dbfb2c68d2 |
File diff suppressed because one or more lines are too long
175
package-lock.json
generated
175
package-lock.json
generated
@@ -11,17 +11,20 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@bitauth/libauth": "^3.0.0",
|
"@bitauth/libauth": "^3.0.0",
|
||||||
"@electrum-cash/protocol": "^2.3.1",
|
"@electrum-cash/protocol": "^2.3.1",
|
||||||
"@xo-cash/crypto": "file:../crypto",
|
"@generalprotocols/oracle-client": "^0.0.1-development.11945476152",
|
||||||
|
"@xo-cash/crypto": "^0.0.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",
|
||||||
"@xo-cash/types": "file:../types",
|
"@xo-cash/types": "^0.0.1",
|
||||||
|
"@xo-cash/utils": "file:../utils",
|
||||||
"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",
|
||||||
"prettier": "^3.8.1",
|
"prettier": "^3.8.1",
|
||||||
"qrcode": "^1.5.4",
|
"qrcode": "^1.5.4",
|
||||||
"react": "^19.2.4",
|
"react": "^19.2.4",
|
||||||
|
"tsx": "^4.21.0",
|
||||||
"zod": "^4.3.6"
|
"zod": "^4.3.6"
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
@@ -35,58 +38,26 @@
|
|||||||
"@types/qrcode": "^1.5.6",
|
"@types/qrcode": "^1.5.6",
|
||||||
"@types/react": "^19.2.14",
|
"@types/react": "^19.2.14",
|
||||||
"@vitest/coverage-v8": "^4.1.2",
|
"@vitest/coverage-v8": "^4.1.2",
|
||||||
"tsx": "^4.21.0",
|
|
||||||
"typescript": "^5.9.3",
|
"typescript": "^5.9.3",
|
||||||
"vitest": "^4.1.2"
|
"vitest": "^4.1.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"../crypto": {
|
|
||||||
"name": "@xo-cash/crypto",
|
|
||||||
"version": "0.0.1",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@bitauth/libauth": "^3.1.0-next.8",
|
|
||||||
"@xo-cash/primitives": "0.0.1",
|
|
||||||
"@xo-cash/types": "0.0.1"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@chalp/eslint-airbnb": "^1.3.0",
|
|
||||||
"@generalprotocols/cspell-dictionary": "^1.0.1",
|
|
||||||
"@stylistic/eslint-plugin": "^5.7.0",
|
|
||||||
"@typescript-eslint/eslint-plugin": "^8.53.1",
|
|
||||||
"@typescript-eslint/parser": "^8.53.1",
|
|
||||||
"@vitest/coverage-v8": "^4.0.17",
|
|
||||||
"@viz-kit/esbuild-analyzer": "^1.0.0",
|
|
||||||
"@xo-cash/eslint-config": "1.0.1",
|
|
||||||
"cspell": "^9.6.0",
|
|
||||||
"eslint": "^9.39.2",
|
|
||||||
"prettier": "^3.6.2",
|
|
||||||
"tsdown": "^0.20.0-beta.4",
|
|
||||||
"typedoc": "^0.28.16",
|
|
||||||
"typedoc-plugin-coverage": "^4.0.2",
|
|
||||||
"typescript": "^5.3.2",
|
|
||||||
"typescript-eslint": "^8.53.1",
|
|
||||||
"vitest": "^4.0.17"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"../engine": {
|
"../engine": {
|
||||||
"name": "@xo-cash/engine",
|
"name": "@xo-cash/engine",
|
||||||
"version": "0.0.1",
|
"version": "0.0.1",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@bitauth/libauth": "^3.1.0-next.8",
|
"@bitauth/libauth": "^3.1.0-next.8",
|
||||||
"@electrum-cash/application": "^0.2.3-development.13424909069",
|
"@electrum-cash/application": "^0.2.3-development.13447192992",
|
||||||
"@electrum-cash/network": "^4.2.2",
|
"@electrum-cash/network": "^4.2.2",
|
||||||
"@electrum-cash/protocol": "^2.3.1",
|
"@electrum-cash/protocol": "^2.3.1",
|
||||||
"@electrum-cash/servers": "^3.1.0",
|
"@electrum-cash/servers": "^3.1.0",
|
||||||
"@xo-cash/crypto": "0.0.1",
|
"@xo-cash/crypto": "0.0.1",
|
||||||
"@xo-cash/primitives": "0.0.1",
|
"@xo-cash/primitives": "file:../primitives",
|
||||||
"@xo-cash/state": "0.0.1",
|
"@xo-cash/state": "file:../state",
|
||||||
"@xo-cash/types": "0.0.1",
|
"@xo-cash/templates": "0.0.1",
|
||||||
"@xo-cash/utils": "0.0.1",
|
"@xo-cash/types": "^0.0.1-development.14519184304",
|
||||||
|
"@xo-cash/utils": "^0.0.1-development.14519184505",
|
||||||
"eventemitter3": "^5.0.1"
|
"eventemitter3": "^5.0.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -107,18 +78,15 @@
|
|||||||
"typescript": "^5.3.2",
|
"typescript": "^5.3.2",
|
||||||
"typescript-eslint": "^8.53.1",
|
"typescript-eslint": "^8.53.1",
|
||||||
"vitest": "^4.0.17"
|
"vitest": "^4.0.17"
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18.0.0"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"../state": {
|
"../state": {
|
||||||
"name": "@xo-cash/state",
|
"name": "@xo-cash/state",
|
||||||
"version": "0.0.1",
|
"version": "0.0.2",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@bitauth/libauth": "^3.1.0-next.8",
|
"@bitauth/libauth": "^3.1.0-next.8",
|
||||||
"@xo-cash/types": "0.0.1-development.13730885533",
|
"@xo-cash/types": "0.0.1",
|
||||||
"@xo-cash/utils": "0.0.1",
|
"@xo-cash/utils": "0.0.1",
|
||||||
"better-sqlite3": "^12.5.0",
|
"better-sqlite3": "^12.5.0",
|
||||||
"idb": "^8.0.3",
|
"idb": "^8.0.3",
|
||||||
@@ -151,7 +119,7 @@
|
|||||||
"version": "0.0.1",
|
"version": "0.0.1",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@xo-cash/types": "0.0.1-development.13504604083"
|
"@xo-cash/types": "0.0.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@chalp/eslint-airbnb": "^1.3.0",
|
"@chalp/eslint-airbnb": "^1.3.0",
|
||||||
@@ -173,22 +141,26 @@
|
|||||||
"vitest": "^4.0.17"
|
"vitest": "^4.0.17"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"../types": {
|
"../utils": {
|
||||||
"name": "@xo-cash/types",
|
"name": "@xo-cash/utils",
|
||||||
"version": "0.0.1",
|
"version": "0.0.1",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@bitauth/libauth": "^3.1.0-next.8"
|
"@bitauth/libauth": "^3.1.0-next.8",
|
||||||
|
"@xo-cash/types": "0.0.1",
|
||||||
|
"zod": "^4.3.6"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@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/node": "^25.5.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",
|
||||||
"@vitest/coverage-v8": "^4.0.17",
|
"@vitest/coverage-v8": "^4.0.17",
|
||||||
"@viz-kit/esbuild-analyzer": "^1.0.0",
|
"@viz-kit/esbuild-analyzer": "^1.0.0",
|
||||||
"@xo-cash/eslint-config": "1.0.1",
|
"@xo-cash/eslint-config": "1.0.1",
|
||||||
|
"@xo-cash/templates": "0.0.1",
|
||||||
"cspell": "^9.6.0",
|
"cspell": "^9.6.0",
|
||||||
"eslint": "^9.39.2",
|
"eslint": "^9.39.2",
|
||||||
"prettier": "^3.6.2",
|
"prettier": "^3.6.2",
|
||||||
@@ -330,6 +302,16 @@
|
|||||||
"ws": "^8.13.0"
|
"ws": "^8.13.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@generalprotocols/oracle-client": {
|
||||||
|
"version": "0.0.1-development.11945476152",
|
||||||
|
"resolved": "https://registry.npmjs.org/@generalprotocols/oracle-client/-/oracle-client-0.0.1-development.11945476152.tgz",
|
||||||
|
"integrity": "sha512-1Q43NfacrVfSbatCREzIX7U3DgACBUegNjV977y+pql+Fve03bOyTiUQClevymCi7M3T6mCyMzSEGT8zA6EZtQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@bitauth/libauth": "^3.0.0",
|
||||||
|
"zod": "^4.1.12"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@jridgewell/resolve-uri": {
|
"node_modules/@jridgewell/resolve-uri": {
|
||||||
"version": "3.1.2",
|
"version": "3.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
|
||||||
@@ -960,13 +942,47 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@xo-cash/crypto": {
|
"node_modules/@xo-cash/crypto": {
|
||||||
"resolved": "../crypto",
|
"version": "0.0.1",
|
||||||
"link": true
|
"resolved": "https://registry.npmjs.org/@xo-cash/crypto/-/crypto-0.0.1.tgz",
|
||||||
|
"integrity": "sha512-ZIa9MHAVCBJqo5uxyx/Tx/jTSyyJw1cfYfI48gEHqBIl5wyyxiZDx4eZvVWSr8uKgS5Tm3FXUkKQybvk5QGRIQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@bitauth/libauth": "^3.1.0-next.8",
|
||||||
|
"@xo-cash/primitives": "0.0.1",
|
||||||
|
"@xo-cash/types": "0.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@xo-cash/crypto/node_modules/@bitauth/libauth": {
|
||||||
|
"version": "3.1.0-next.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/@bitauth/libauth/-/libauth-3.1.0-next.8.tgz",
|
||||||
|
"integrity": "sha512-Pm+Ju+YP3JeBLLTiVrBnia2wwE4G17r4XqpvPRMcklElJTe8J6x3JgKRg1by0Xm3ZY6UFxACkEAoSA+x419/zA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@xo-cash/engine": {
|
"node_modules/@xo-cash/engine": {
|
||||||
"resolved": "../engine",
|
"resolved": "../engine",
|
||||||
"link": true
|
"link": true
|
||||||
},
|
},
|
||||||
|
"node_modules/@xo-cash/primitives": {
|
||||||
|
"version": "0.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@xo-cash/primitives/-/primitives-0.0.1.tgz",
|
||||||
|
"integrity": "sha512-medxVK9Sawj7oIDhWvTjTgzwf6BjGao6CXtQYJOUFi6NOO1eclb1PDjEmkG/4NeK3v7LQIN8QS60mTAGyS9FXg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@bitauth/libauth": "^3.1.0-next.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@xo-cash/primitives/node_modules/@bitauth/libauth": {
|
||||||
|
"version": "3.1.0-next.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/@bitauth/libauth/-/libauth-3.1.0-next.8.tgz",
|
||||||
|
"integrity": "sha512-Pm+Ju+YP3JeBLLTiVrBnia2wwE4G17r4XqpvPRMcklElJTe8J6x3JgKRg1by0Xm3ZY6UFxACkEAoSA+x419/zA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@xo-cash/state": {
|
"node_modules/@xo-cash/state": {
|
||||||
"resolved": "../state",
|
"resolved": "../state",
|
||||||
"link": true
|
"link": true
|
||||||
@@ -976,7 +992,25 @@
|
|||||||
"link": true
|
"link": true
|
||||||
},
|
},
|
||||||
"node_modules/@xo-cash/types": {
|
"node_modules/@xo-cash/types": {
|
||||||
"resolved": "../types",
|
"version": "0.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@xo-cash/types/-/types-0.0.1.tgz",
|
||||||
|
"integrity": "sha512-BMwh2Y9+LqnTXYmdA7Nxi1NuK+AcsNWFoFGJVAvuY5TBfsbNIzWppjmrI2fAyj/RlSE3tATMxam+6CJb3RnDIA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@bitauth/libauth": "^3.1.0-next.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@xo-cash/types/node_modules/@bitauth/libauth": {
|
||||||
|
"version": "3.1.0-next.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/@bitauth/libauth/-/libauth-3.1.0-next.8.tgz",
|
||||||
|
"integrity": "sha512-Pm+Ju+YP3JeBLLTiVrBnia2wwE4G17r4XqpvPRMcklElJTe8J6x3JgKRg1by0Xm3ZY6UFxACkEAoSA+x419/zA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@xo-cash/utils": {
|
||||||
|
"resolved": "../utils",
|
||||||
"link": true
|
"link": true
|
||||||
},
|
},
|
||||||
"node_modules/ansi-escapes": {
|
"node_modules/ansi-escapes": {
|
||||||
@@ -1652,7 +1686,6 @@
|
|||||||
"version": "2.3.3",
|
"version": "2.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||||
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
|
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
|
||||||
"dev": true,
|
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
@@ -1704,7 +1737,6 @@
|
|||||||
"version": "4.13.0",
|
"version": "4.13.0",
|
||||||
"resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.0.tgz",
|
"resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.0.tgz",
|
||||||
"integrity": "sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ==",
|
"integrity": "sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"resolve-pkg-maps": "^1.0.0"
|
"resolve-pkg-maps": "^1.0.0"
|
||||||
@@ -2556,7 +2588,6 @@
|
|||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz",
|
||||||
"integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==",
|
"integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1"
|
"url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1"
|
||||||
@@ -2979,7 +3010,6 @@
|
|||||||
"version": "4.21.0",
|
"version": "4.21.0",
|
||||||
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz",
|
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz",
|
||||||
"integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==",
|
"integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"esbuild": "~0.27.0",
|
"esbuild": "~0.27.0",
|
||||||
@@ -3002,7 +3032,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -3019,7 +3048,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -3036,7 +3064,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -3053,7 +3080,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -3070,7 +3096,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -3087,7 +3112,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -3104,7 +3128,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -3121,7 +3144,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -3138,7 +3160,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -3155,7 +3176,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -3172,7 +3192,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"ia32"
|
"ia32"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -3189,7 +3208,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"loong64"
|
"loong64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -3206,7 +3224,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"mips64el"
|
"mips64el"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -3223,7 +3240,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -3240,7 +3256,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -3257,7 +3272,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"s390x"
|
"s390x"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -3274,7 +3288,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -3291,7 +3304,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -3308,7 +3320,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -3325,7 +3336,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -3342,7 +3352,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -3359,7 +3368,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -3376,7 +3384,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -3393,7 +3400,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -3410,7 +3416,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"ia32"
|
"ia32"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -3427,7 +3432,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -3441,7 +3445,6 @@
|
|||||||
"version": "0.27.7",
|
"version": "0.27.7",
|
||||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz",
|
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz",
|
||||||
"integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==",
|
"integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==",
|
||||||
"dev": true,
|
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"bin": {
|
"bin": {
|
||||||
@@ -4259,9 +4262,9 @@
|
|||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
"node_modules/ws": {
|
"node_modules/ws": {
|
||||||
"version": "8.19.0",
|
"version": "8.21.0",
|
||||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz",
|
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz",
|
||||||
"integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==",
|
"integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=10.0.0"
|
"node": ">=10.0.0"
|
||||||
|
|||||||
17
package.json
17
package.json
@@ -12,14 +12,17 @@
|
|||||||
"dev": "SYNC_SERVER_URL=https://sync.xo.harvmaster.com tsx src/index.ts",
|
"dev": "SYNC_SERVER_URL=https://sync.xo.harvmaster.com tsx src/index.ts",
|
||||||
"build": "tsc && npm run build:copy-scripts",
|
"build": "tsc && npm run build:copy-scripts",
|
||||||
"build:copy-scripts": "cp -r src/cli/autocomplete/scripts dist/cli/autocomplete/",
|
"build:copy-scripts": "cp -r src/cli/autocomplete/scripts dist/cli/autocomplete/",
|
||||||
|
"build:unsafe": "tsc --nocheck --noEmitOnError false || true && npm run build:copy-scripts",
|
||||||
"start": "SYNC_SERVER_URL=https://sync.xo.harvmaster.com node dist/index.js",
|
"start": "SYNC_SERVER_URL=https://sync.xo.harvmaster.com node dist/index.js",
|
||||||
"test": "vitest --run --passWithNoTests",
|
"test": "vitest --run --passWithNoTests",
|
||||||
"test:watch": "vitest",
|
"test:watch": "vitest",
|
||||||
"test:coverage": "vitest run --coverage --passWithNoTests",
|
"test:coverage": "vitest run --coverage --passWithNoTests",
|
||||||
"nuke": "tsx scripts/rm-dbs.ts",
|
|
||||||
"nuke:dry": "tsx scripts/rm-dbs.ts --dry",
|
|
||||||
"format": "prettier --write \"**/*.{js,ts,md,json}\" --ignore-path .gitignore",
|
"format": "prettier --write \"**/*.{js,ts,md,json}\" --ignore-path .gitignore",
|
||||||
"format:check": "prettier --check ."
|
"format:check": "prettier --check .",
|
||||||
|
"autocomplete:install": "node dist/cli/index.js completions bash --install",
|
||||||
|
"autocomplete:install:bash": "node dist/cli/index.js completions bash --install",
|
||||||
|
"autocomplete:install:zsh": "node dist/cli/index.js completions zsh --install",
|
||||||
|
"autocomplete:install:fish": "node dist/cli/index.js completions fish --install"
|
||||||
},
|
},
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"crypto",
|
"crypto",
|
||||||
@@ -33,17 +36,20 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@bitauth/libauth": "^3.0.0",
|
"@bitauth/libauth": "^3.0.0",
|
||||||
"@electrum-cash/protocol": "^2.3.1",
|
"@electrum-cash/protocol": "^2.3.1",
|
||||||
"@xo-cash/crypto": "file:../crypto",
|
"@generalprotocols/oracle-client": "^0.0.1-development.11945476152",
|
||||||
|
"@xo-cash/crypto": "^0.0.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",
|
||||||
"@xo-cash/types": "file:../types",
|
"@xo-cash/types": "^0.0.1",
|
||||||
|
"@xo-cash/utils": "file:../utils",
|
||||||
"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",
|
||||||
"prettier": "^3.8.1",
|
"prettier": "^3.8.1",
|
||||||
"qrcode": "^1.5.4",
|
"qrcode": "^1.5.4",
|
||||||
"react": "^19.2.4",
|
"react": "^19.2.4",
|
||||||
|
"tsx": "^4.21.0",
|
||||||
"zod": "^4.3.6"
|
"zod": "^4.3.6"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -52,7 +58,6 @@
|
|||||||
"@types/qrcode": "^1.5.6",
|
"@types/qrcode": "^1.5.6",
|
||||||
"@types/react": "^19.2.14",
|
"@types/react": "^19.2.14",
|
||||||
"@vitest/coverage-v8": "^4.1.2",
|
"@vitest/coverage-v8": "^4.1.2",
|
||||||
"tsx": "^4.21.0",
|
|
||||||
"typescript": "^5.9.3",
|
"typescript": "^5.9.3",
|
||||||
"vitest": "^4.1.2"
|
"vitest": "^4.1.2"
|
||||||
}
|
}
|
||||||
|
|||||||
219
readme.md
Normal file
219
readme.md
Normal file
@@ -0,0 +1,219 @@
|
|||||||
|
# XO-CLI & XO-TUI
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
### Full Installation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Create a new directory since we are going to be pulling in engine too
|
||||||
|
mkdir xo-terminal && cd xo-terminal
|
||||||
|
|
||||||
|
# ----- Start State Setup -----
|
||||||
|
# Clone the State Repo
|
||||||
|
git clone https://gitlab.com/Harvmaster/state.git
|
||||||
|
|
||||||
|
# Move into the state directory
|
||||||
|
cd state
|
||||||
|
|
||||||
|
git checkout cli-test
|
||||||
|
|
||||||
|
# Install the dependencies
|
||||||
|
npm ci
|
||||||
|
|
||||||
|
# Build the state
|
||||||
|
npm run build
|
||||||
|
# ----- End State Setup -----
|
||||||
|
|
||||||
|
# Move back to the top level directory
|
||||||
|
cd ..
|
||||||
|
|
||||||
|
# ----- Start Primitive Setup -----
|
||||||
|
git clone git@gitlab.com:GeneralProtocols/xo/primitives.git
|
||||||
|
|
||||||
|
cd primitives
|
||||||
|
|
||||||
|
git checkout update/syncup-ui-requirements
|
||||||
|
|
||||||
|
npm ci
|
||||||
|
|
||||||
|
npm run build
|
||||||
|
|
||||||
|
# ----- End Primitive Setup -----
|
||||||
|
|
||||||
|
# Move back to the top level directory
|
||||||
|
cd ..
|
||||||
|
|
||||||
|
# ----- Start Utils Setup -----
|
||||||
|
git clone git@gitlab.com:Harvmaster/xo-cash-utils.git utils
|
||||||
|
|
||||||
|
cd utils
|
||||||
|
|
||||||
|
git checkout sse-and-backoff
|
||||||
|
|
||||||
|
npm ci
|
||||||
|
|
||||||
|
npm run build
|
||||||
|
# ----- End Utils Setup
|
||||||
|
|
||||||
|
# Move back to the top level directory
|
||||||
|
cd ..
|
||||||
|
|
||||||
|
# ----- Start Template Setup ----
|
||||||
|
# Clone the Template repo
|
||||||
|
git clone https://gitlab.com/Harvmaster/templates.git
|
||||||
|
|
||||||
|
# Move into themplates directory
|
||||||
|
cd templates
|
||||||
|
|
||||||
|
# Install deps
|
||||||
|
npm ci
|
||||||
|
|
||||||
|
#build the templates
|
||||||
|
npm run build
|
||||||
|
# ----- End Templates Setup ----
|
||||||
|
|
||||||
|
# Move back to the top level directory
|
||||||
|
cd ..
|
||||||
|
|
||||||
|
# ----- Start Engine Setup -----
|
||||||
|
# Clone the Engine Repo (Note, this uses harvey's fork of the engine repo to access the cli-test branch)
|
||||||
|
git clone https://gitlab.com/Harvmaster/engine.git
|
||||||
|
|
||||||
|
# Move into teh engine directory
|
||||||
|
cd engine
|
||||||
|
|
||||||
|
# Checkout the cli-test branch
|
||||||
|
git checkout cli-test-update
|
||||||
|
|
||||||
|
# Install the dependencies
|
||||||
|
npm ci
|
||||||
|
|
||||||
|
# Build the engine
|
||||||
|
npm run build
|
||||||
|
# ----- End Engine Setup -----
|
||||||
|
|
||||||
|
# Move back to the top level directory
|
||||||
|
cd ..
|
||||||
|
|
||||||
|
# ----- Start CLI Setup -----
|
||||||
|
# Clone the CLI Repo
|
||||||
|
git clone https://git.harvmaster.com/Harvmaster/xo-cli.git
|
||||||
|
|
||||||
|
# Move into the cli directory
|
||||||
|
cd xo-cli
|
||||||
|
|
||||||
|
git checkout kiok-update
|
||||||
|
|
||||||
|
# Install the dependencies
|
||||||
|
npm ci
|
||||||
|
|
||||||
|
# Build the cli
|
||||||
|
npm run build
|
||||||
|
# ----- End CLI Setup -----
|
||||||
|
```
|
||||||
|
|
||||||
|
### Run TUI in dev mode
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
### Install globally
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# (From the xo-cli directory)
|
||||||
|
npm install -g .
|
||||||
|
```
|
||||||
|
|
||||||
|
### Install autocomplete completions (From the xo-cli directory)
|
||||||
|
|
||||||
|
These commands add `XO_CONFIG_DIR` to your shell config with a default of
|
||||||
|
`~/.config/xo-cli`. Set it to an absolute path before installing, or edit the
|
||||||
|
generated assignment, to use a different wallet-state directory.
|
||||||
|
|
||||||
|
#### Install for bash
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run autocomplete:install:bash
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Install for zsh
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run autocomplete:install:zsh
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Install for fish
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run autocomplete:install:fish
|
||||||
|
```
|
||||||
|
|
||||||
|
### Run the CLI
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# If globally installed (Not really usable if not globally installed)
|
||||||
|
xo-cli
|
||||||
|
```
|
||||||
|
|
||||||
|
### Run the TUI
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# If globally installed
|
||||||
|
xo-tui
|
||||||
|
|
||||||
|
# If not globally installed
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
## TODO
|
||||||
|
|
||||||
|
### Track invitation sync-server connectivity without blocking the UI
|
||||||
|
|
||||||
|
Each `Invitation` currently owns a `SyncServer` instance for its invitation
|
||||||
|
identifier. The invitation uses that instance to open an SSE connection, fetch
|
||||||
|
remote state, and publish local changes. Publish requests are intentionally
|
||||||
|
fire-and-forget so that invitation actions and the TUI stay responsive when the
|
||||||
|
sync server is slow or unavailable.
|
||||||
|
|
||||||
|
The tradeoff is that failed background requests and SSE connection changes are
|
||||||
|
not represented as application state. `SyncServer` already emits `connected`,
|
||||||
|
`disconnected`, and `error` events, and `Invitation` emits errors from failed
|
||||||
|
publishes, but there is no app-level owner that aggregates those events. The UI
|
||||||
|
therefore cannot reliably tell the user that an invitation may only be updated
|
||||||
|
locally and is not currently syncing with other participants.
|
||||||
|
|
||||||
|
Implement an app-owned `InvitationConnectivityService` (or similarly named
|
||||||
|
invitation watcher) with the following responsibilities:
|
||||||
|
|
||||||
|
- Register an invitation and its `SyncServer` when `AppService` creates or loads
|
||||||
|
it, and unregister it when the invitation is removed or stopped.
|
||||||
|
- Listen for each sync server's `connected`, `disconnected`, and `error` events,
|
||||||
|
plus invitation publish failures.
|
||||||
|
- Track connectivity separately from the invitation's business status
|
||||||
|
(`actionable`, `signed`, `ready`, and so on). Suggested transport states are
|
||||||
|
`connecting`, `online`, `offline`, and `degraded`, with the last error and
|
||||||
|
last successful connection timestamp available for diagnostics.
|
||||||
|
- Expose both per-invitation state and an aggregate app-level state such as
|
||||||
|
"one or more invitations are not syncing".
|
||||||
|
- Emit normalized connectivity-change events that the CLI can log and the TUI
|
||||||
|
can subscribe to without awaiting sync-server requests.
|
||||||
|
|
||||||
|
Keep local persistence and local invitation actions independent from remote
|
||||||
|
sync health. Failed sync attempts should not freeze normal wallet interaction.
|
||||||
|
The service should provide a retry path, or observe retry events from the SSE
|
||||||
|
client, and clear the warning after connectivity recovers. If publish retries
|
||||||
|
are added, make the retry policy explicit and preserve commit idempotency.
|
||||||
|
|
||||||
|
For UI integration, inject a small notification function or subscribe at the
|
||||||
|
app-context layer rather than having invitation instances render UI directly.
|
||||||
|
The first version can show an error dialog when the aggregate state becomes
|
||||||
|
unhealthy. A less intrusive version can expose the same state as a warning icon
|
||||||
|
or message in the TUI status bar and reserve dialogs for prolonged failures or
|
||||||
|
explicit user actions.
|
||||||
|
|
||||||
|
While making this change, consolidate invitation startup ownership. Startup is
|
||||||
|
currently triggered during `Invitation.create()` and again by
|
||||||
|
`AppService.createInvitation()`. The watcher should have one clear lifecycle
|
||||||
|
point so connections, listeners, retries, and cleanup are registered exactly
|
||||||
|
once.
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
import fs from "fs/promises";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Remove all the databases without the use of external tools
|
|
||||||
* TODO: Fix the ts linking issue here. Should just be adding this as a dir in tsconfig.json
|
|
||||||
*/
|
|
||||||
const rmDbs = async (dry = false) => {
|
|
||||||
// First, we need to find all the database base files
|
|
||||||
// These end in either .db.sqlite, .sqlite, .db
|
|
||||||
// Get all the files in the current directory
|
|
||||||
const files = await fs.readdir("./");
|
|
||||||
|
|
||||||
// Filter out the files that end in .db.sqlite, .sqlite, .db
|
|
||||||
const dbFiles = files.filter(
|
|
||||||
(file) =>
|
|
||||||
file.endsWith(".db.sqlite") ||
|
|
||||||
file.endsWith(".sqlite") ||
|
|
||||||
file.endsWith(".db"),
|
|
||||||
);
|
|
||||||
|
|
||||||
// We need to remove all the files
|
|
||||||
await deleteFiles(dbFiles, dry);
|
|
||||||
};
|
|
||||||
|
|
||||||
const deleteFiles = async (files: string[], dry = false) => {
|
|
||||||
if (dry) {
|
|
||||||
console.log("Dry run, would delete:", files);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await Promise.all(files.map((file) => fs.rm(file)));
|
|
||||||
console.log("All databases removed");
|
|
||||||
};
|
|
||||||
|
|
||||||
// Read args
|
|
||||||
const args = process.argv.slice(2);
|
|
||||||
const dry = args.includes("--dry");
|
|
||||||
|
|
||||||
// Delete the files
|
|
||||||
await rmDbs(dry);
|
|
||||||
@@ -1,102 +0,0 @@
|
|||||||
import fs from "node:fs/promises";
|
|
||||||
import path from "node:path";
|
|
||||||
import { pathToFileURL } from "node:url";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* This just convers the <template>.ts file to a <template>.json file.
|
|
||||||
* Im fairly sure there is a util in the engine or engine-packages for this, but I decided to just keep it as simple as possible because I didn't feel like digging around for it.
|
|
||||||
*
|
|
||||||
* Usage:
|
|
||||||
* tsx scripts/template-to-json.ts ../templates/source/p2pkh.ts ./p2pkh.json p2pkhTemplate
|
|
||||||
*/
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Prints usage to stderr and exits with a non-zero code.
|
|
||||||
*/
|
|
||||||
function printUsageAndExit(): never {
|
|
||||||
console.error(
|
|
||||||
[
|
|
||||||
"Usage: tsx scripts/template-to-json.ts <input.ts> <output.json> [exportName]",
|
|
||||||
"",
|
|
||||||
"Loads a TypeScript module, picks one exported value, and writes JSON.stringify to the output path.",
|
|
||||||
"If exportName is omitted: uses default export, or the only non-function export if there is exactly one.",
|
|
||||||
"",
|
|
||||||
"Example:",
|
|
||||||
" tsx scripts/template-to-json.ts ../templates/source/p2pkh.ts ./p2pkh.json p2pkhTemplate",
|
|
||||||
].join("\n"),
|
|
||||||
);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Collects runtime export keys whose values are not functions (typical for data/template objects).
|
|
||||||
*/
|
|
||||||
function listDataExportKeys(mod: Record<string, unknown>): string[] {
|
|
||||||
return Object.keys(mod).filter((key) => {
|
|
||||||
if (key === "__esModule") {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
const value = mod[key];
|
|
||||||
return typeof value !== "function";
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Resolves which export to serialize: explicit name, default, or a single unambiguous data export.
|
|
||||||
*/
|
|
||||||
function resolveExportedValue(
|
|
||||||
mod: Record<string, unknown>,
|
|
||||||
exportName: string | undefined,
|
|
||||||
): unknown {
|
|
||||||
if (exportName !== undefined) {
|
|
||||||
if (!(exportName in mod)) {
|
|
||||||
const keys = listDataExportKeys(mod);
|
|
||||||
console.error(
|
|
||||||
`Export "${exportName}" not found. Available data exports: ${keys.length ? keys.join(", ") : "(none)"}`,
|
|
||||||
);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
return mod[exportName];
|
|
||||||
}
|
|
||||||
|
|
||||||
if ("default" in mod && mod.default !== undefined) {
|
|
||||||
return mod.default;
|
|
||||||
}
|
|
||||||
|
|
||||||
const keys = listDataExportKeys(mod);
|
|
||||||
if (keys.length === 1) {
|
|
||||||
return mod[keys[0]!];
|
|
||||||
}
|
|
||||||
|
|
||||||
if (keys.length === 0) {
|
|
||||||
console.error("No suitable exports found (need default or a non-function export).");
|
|
||||||
} else {
|
|
||||||
console.error(
|
|
||||||
`Multiple data exports found; pass exportName. Candidates: ${keys.join(", ")}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function main(): Promise<void> {
|
|
||||||
const args = process.argv.slice(2);
|
|
||||||
if (args.length < 2) {
|
|
||||||
printUsageAndExit();
|
|
||||||
}
|
|
||||||
|
|
||||||
const [inputRel, outputRel, exportName] = args;
|
|
||||||
const inputPath = path.resolve(process.cwd(), inputRel!);
|
|
||||||
const outputPath = path.resolve(process.cwd(), outputRel!);
|
|
||||||
|
|
||||||
/** Dynamic import needs a file URL so Windows paths and ESM resolution behave. */
|
|
||||||
const fileUrl = pathToFileURL(inputPath).href;
|
|
||||||
const mod = (await import(fileUrl)) as Record<string, unknown>;
|
|
||||||
const value = resolveExportedValue(mod, exportName);
|
|
||||||
|
|
||||||
const json = `${JSON.stringify(value, null, 2)}\n`;
|
|
||||||
await fs.mkdir(path.dirname(outputPath), { recursive: true });
|
|
||||||
await fs.writeFile(outputPath, json, "utf8");
|
|
||||||
console.log(`Wrote ${outputPath}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
await main();
|
|
||||||
@@ -9,13 +9,13 @@ There are two global commands after install:
|
|||||||
|
|
||||||
## Global config directory
|
## Global config directory
|
||||||
|
|
||||||
Wallet state lives under **`~/.config/xo-cli/`** (XDG-style), so you can run commands from any directory:
|
Wallet state lives under **`${XO_CONFIG_DIR:-~/.config/xo-cli}`**, so you can run commands from any directory. Set `XO_CONFIG_DIR` to use a different wallet-state root.
|
||||||
|
|
||||||
| Path | Purpose |
|
| Path | Purpose |
|
||||||
|------|---------|
|
| --------------------------- | ----------------------------------------------------------------------- |
|
||||||
| `~/.config/xo-cli/mnemonics/` | Mnemonic files (`mnemonic-*`) |
|
| `$XO_CONFIG_DIR/mnemonics/` | Mnemonic files (`mnemonic-*`) |
|
||||||
| `~/.config/xo-cli/data/` | Engine DB (`xo-wallet.db`) and invitation storage (`xo-invitations.db`) |
|
| `$XO_CONFIG_DIR/data/` | Engine DB (`xo-wallet.db`) and invitation storage (`xo-invitations.db`) |
|
||||||
| `~/.config/xo-cli/.wallet` | Last-used mnemonic reference (so `-m` can be omitted) |
|
| `$XO_CONFIG_DIR/.wallet` | JSON settings (`default-mnemonic`, `currency`) |
|
||||||
|
|
||||||
**Local to your shell’s current directory:** template JSON paths, invitation JSON you create/import, and any path you pass explicitly (e.g. `-m /abs/path/to/file`).
|
**Local to your shell’s current directory:** template JSON paths, invitation JSON you create/import, and any path you pass explicitly (e.g. `-m /abs/path/to/file`).
|
||||||
|
|
||||||
@@ -39,25 +39,28 @@ npx tsx src/cli/index.ts <command> [options]
|
|||||||
npx tsx src/index.ts # TUI
|
npx tsx src/index.ts # TUI
|
||||||
```
|
```
|
||||||
|
|
||||||
### Environment variables (TUI / `xo-tui`)
|
### Environment variables
|
||||||
|
|
||||||
| Variable | Default |
|
| Variable | Default |
|
||||||
|----------|---------|
|
| ------------------------- | --------------------------------------- |
|
||||||
| `SYNC_SERVER_URL` | `http://localhost:3000` |
|
| `XO_CONFIG_DIR` | `~/.config/xo-cli` |
|
||||||
| `DB_PATH` | `~/.config/xo-cli/data` |
|
| `SYNC_SERVER_URL` | `http://localhost:3000` |
|
||||||
| `DB_FILENAME` | `xo-wallet.db` |
|
| `DB_PATH` | `$XO_CONFIG_DIR/data` |
|
||||||
| `INVITATION_STORAGE_PATH` | `~/.config/xo-cli/data/xo-invitations.db` |
|
| `DB_FILENAME` | `xo-wallet.db` |
|
||||||
|
| `INVITATION_STORAGE_PATH` | `$XO_CONFIG_DIR/data/xo-invitations.db` |
|
||||||
|
|
||||||
|
Use an absolute path for a custom root. Setting `XO_CONFIG_DIR` does not copy state from the default directory.
|
||||||
|
|
||||||
## Getting Started
|
## Getting Started
|
||||||
|
|
||||||
### Wallet Setup
|
### Wallet Setup
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Generate a new mnemonic (saved under ~/.config/xo-cli/mnemonics/)
|
# Generate a new mnemonic (saved under $XO_CONFIG_DIR/mnemonics/)
|
||||||
xo-cli mnemonic create
|
xo-cli mnemonic create
|
||||||
|
|
||||||
# Import an existing mnemonic seed phrase
|
# Import an existing mnemonic seed phrase
|
||||||
xo-cli mnemonic import page pencil stock planet limb cluster assault speak off joke private pioneer
|
xo-cli mnemonic import oven crop same above under tower promote decrease vocal pretty require slow
|
||||||
|
|
||||||
# List mnemonic basenames (use with -m)
|
# List mnemonic basenames (use with -m)
|
||||||
xo-cli mnemonic list
|
xo-cli mnemonic list
|
||||||
@@ -67,13 +70,16 @@ xo-cli mnemonic list
|
|||||||
|
|
||||||
### Wallet Persistence
|
### Wallet Persistence
|
||||||
|
|
||||||
The first time you pass `-m <name>`, that reference is saved to `~/.config/xo-cli/.wallet`. Later runs can omit `-m`.
|
The first time you pass `-m <name>`, that reference is saved as
|
||||||
|
`default-mnemonic` in `$XO_CONFIG_DIR/.wallet`. Later runs can omit `-m`.
|
||||||
|
|
||||||
|
`currency` controls the fiat unit used when showing BCH/sats conversions in the TUI.
|
||||||
|
|
||||||
Mnemonic resolution order:
|
Mnemonic resolution order:
|
||||||
|
|
||||||
1. Absolute path, if the file exists
|
1. Absolute path, if the file exists
|
||||||
2. Path relative to the current working directory
|
2. Path relative to the current working directory
|
||||||
3. `~/.config/xo-cli/mnemonics/<basename>`
|
3. `$XO_CONFIG_DIR/mnemonics/<basename>`
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
xo-cli resource list -m mnemonic-nuclear
|
xo-cli resource list -m mnemonic-nuclear
|
||||||
@@ -82,13 +88,15 @@ xo-cli resource list
|
|||||||
|
|
||||||
## Global Options (`xo-cli`)
|
## Global Options (`xo-cli`)
|
||||||
|
|
||||||
| Flag | Description |
|
| Flag | Description |
|
||||||
|------|-------------|
|
| ------------------------------ | ---------------------------------------------------- |
|
||||||
| `-m`, `--mnemonic-file <file>` | Mnemonic file (basename, cwd-relative, or absolute) |
|
| `-m`, `--mnemonic-file <file>` | Mnemonic file (basename, cwd-relative, or absolute) |
|
||||||
| `-v`, `--verbose` | Verbose output |
|
| `--currency <code>` | Fiat display currency (e.g. `USD`, `AUD`) |
|
||||||
| `-h`, `--help` | Help |
|
| `-o`, `--output <filename>` | Output filename (used by `mnemonic create`/`import`) |
|
||||||
|
| `-v`, `--verbose` | Verbose output |
|
||||||
|
| `-h`, `--help` | Help |
|
||||||
|
|
||||||
Advanced: you can pass `--database-path`, `--database-filename`, and `--invitation-storage-path` to override the defaults under `~/.config/xo-cli/data/` (see `src/cli/index.ts`).
|
Advanced: you can pass `--database-path`, `--database-filename`, and `--invitation-storage-path` to override the defaults under `$XO_CONFIG_DIR/data/` (see `src/cli/index.ts`).
|
||||||
|
|
||||||
## Commands
|
## Commands
|
||||||
|
|
||||||
@@ -98,6 +106,7 @@ Advanced: you can pass `--database-path`, `--database-filename`, and `--invitati
|
|||||||
xo-cli mnemonic create
|
xo-cli mnemonic create
|
||||||
xo-cli mnemonic import <seed words...>
|
xo-cli mnemonic import <seed words...>
|
||||||
xo-cli mnemonic list
|
xo-cli mnemonic list
|
||||||
|
xo-cli mnemonic expose <mnemonic-file>
|
||||||
```
|
```
|
||||||
|
|
||||||
### `template` — Manage Templates
|
### `template` — Manage Templates
|
||||||
@@ -124,6 +133,16 @@ xo-cli resource unreserve <txhash:vout>
|
|||||||
xo-cli resource unreserve-all
|
xo-cli resource unreserve-all
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### `settings` — Manage Persisted Settings
|
||||||
|
|
||||||
|
```bash
|
||||||
|
xo-cli settings show
|
||||||
|
xo-cli settings get currency
|
||||||
|
xo-cli settings get default-mnemonic
|
||||||
|
xo-cli settings set currency AUD
|
||||||
|
xo-cli settings set default-mnemonic mnemonic-nuclear
|
||||||
|
```
|
||||||
|
|
||||||
### `receive` — Generate a Receiving Address
|
### `receive` — Generate a Receiving Address
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -139,20 +158,21 @@ xo-cli invitation sign <invitation-id>
|
|||||||
xo-cli invitation broadcast <invitation-id>
|
xo-cli invitation broadcast <invitation-id>
|
||||||
xo-cli invitation requirements <invitation-id>
|
xo-cli invitation requirements <invitation-id>
|
||||||
xo-cli invitation import <invitation-file>
|
xo-cli invitation import <invitation-file>
|
||||||
|
xo-cli invitation inspect <invitation-file>
|
||||||
xo-cli invitation list
|
xo-cli invitation list
|
||||||
```
|
```
|
||||||
|
|
||||||
**Create / append options:**
|
**Create / append options:**
|
||||||
|
|
||||||
| Flag | Description |
|
| Flag | Description |
|
||||||
|------|-------------|
|
| --------------------------- | ---------------------------------------- |
|
||||||
| `-var-<name> <value>` | Template variable |
|
| `-var-<name> <value>` | Template variable |
|
||||||
| `--add-input <txhash:vout>` | Inputs (comma-separated) |
|
| `--add-input <txhash:vout>` | Inputs (comma-separated) |
|
||||||
| `--add-output <id>` | Override outputs (omit to auto-discover) |
|
| `--add-output <id>` | Override outputs (omit to auto-discover) |
|
||||||
| `--auto-inputs` | Auto-select UTXOs |
|
| `--auto-inputs` | Auto-select UTXOs |
|
||||||
| `-role <role>` | Role for variables / bytecode |
|
| `-role <role>` | Role for variables / bytecode |
|
||||||
| `--sign` | Auto-sign when complete |
|
| `--sign` | Auto-sign when complete |
|
||||||
| `--broadcast` | Auto-broadcast (implies `--sign`) |
|
| `--broadcast` | Auto-broadcast (implies `--sign`) |
|
||||||
|
|
||||||
Invitation JSON files from `create` / `append` are written to the **current working directory**.
|
Invitation JSON files from `create` / `append` are written to the **current working directory**.
|
||||||
|
|
||||||
@@ -184,9 +204,11 @@ eval "$(xo-cli completions zsh)"
|
|||||||
xo-cli completions fish | source
|
xo-cli completions fish | source
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`xo-cli completions <shell> --install` adds a default `XO_CONFIG_DIR` assignment to the shell startup file if one is not already present. Mnemonic aliases are completed directly from `$XO_CONFIG_DIR/mnemonics/`; database-backed suggestions still use `xo-complete`.
|
||||||
|
|
||||||
## File Conventions
|
## File Conventions
|
||||||
|
|
||||||
| Location | Purpose |
|
| Location | Purpose |
|
||||||
|----------|---------|
|
| ---------------- | ------------------------------------------ |
|
||||||
| `~/.config/xo-cli/` | Global wallet state |
|
| `$XO_CONFIG_DIR` | Global wallet state |
|
||||||
| `./` (cwd) | Templates, invitation JSON, explicit paths |
|
| `./` (cwd) | Templates, invitation JSON, explicit paths |
|
||||||
|
|||||||
@@ -7,9 +7,9 @@ import { z } from "zod";
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Converts the CLI args to a key-value object and return the options object along with the other arguments still in the array.\
|
* Converts the CLI args to a key-value object and return the options object along with the other arguments still in the array.\
|
||||||
* eg: `xo-cli mnemonic create page pencil stock planet limb cluster assault speak off joke private pioneer -v -o mnemonic.txt` will return:
|
* eg: `xo-cli mnemonic create oven crop same above under tower promote decrease vocal pretty require slow -v -o mnemonic.txt` will return:
|
||||||
* {
|
* {
|
||||||
* args: ["mnemonic", "create", "page", "pencil", "stock", "planet", "limb", "cluster", "assault", "speak", "off", "joke", "private", "pioneer"],
|
* args: ["mnemonic", "create", "oven", "crop", "same", "above", "under", "tower", "promote", "decrease", "vocal", "pretty", "require", "slow"],
|
||||||
* options: {
|
* options: {
|
||||||
* output: "mnemonic.txt",
|
* output: "mnemonic.txt",
|
||||||
* verbose: "true",
|
* verbose: "true",
|
||||||
@@ -19,24 +19,27 @@ import { z } from "zod";
|
|||||||
* @param args - The CLI args to convert.
|
* @param args - The CLI args to convert.
|
||||||
* @returns The key-value object.
|
* @returns The key-value object.
|
||||||
*/
|
*/
|
||||||
export function convertArgsToObject(args: string[]): { args: string[], options: Record<string, string> } {
|
export function convertArgsToObject(args: string[]): {
|
||||||
|
args: string[];
|
||||||
|
options: Record<string, string>;
|
||||||
|
} {
|
||||||
// Map of single-character short flags to their canonical long names
|
// Map of single-character short flags to their canonical long names
|
||||||
const shortToFull: Record<string, string> = {
|
const shortToFull: Record<string, string> = {
|
||||||
'm': 'mnemonicFile',
|
m: "mnemonicFile",
|
||||||
'o': 'output',
|
o: "output",
|
||||||
'v': 'verbose',
|
v: "verbose",
|
||||||
'h': 'help',
|
h: "help",
|
||||||
};
|
};
|
||||||
|
|
||||||
// Flags that are always boolean and never consume the next argument as a value.
|
// Flags that are always boolean and never consume the next argument as a value.
|
||||||
// Uses the canonical (expanded) names so the check works after short-form resolution.
|
// Uses the canonical (expanded) names so the check works after short-form resolution.
|
||||||
const booleanFlags = new Set<string>([
|
const booleanFlags = new Set<string>([
|
||||||
'verbose',
|
"verbose",
|
||||||
'help',
|
"help",
|
||||||
'autoInputs',
|
"autoInputs",
|
||||||
'sign',
|
"sign",
|
||||||
'broadcast',
|
"broadcast",
|
||||||
'install',
|
"install",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const positionalArgs: string[] = [];
|
const positionalArgs: string[] = [];
|
||||||
@@ -55,7 +58,9 @@ export function convertArgsToObject(args: string[]): { args: string[], options:
|
|||||||
// - Remove the leading `-`s
|
// - Remove the leading `-`s
|
||||||
// - Convert kebab-case to camelCase
|
// - Convert kebab-case to camelCase
|
||||||
// - Expand known short forms to their full names
|
// - Expand known short forms to their full names
|
||||||
let key = arg.replace(/^-+/, "").replace(/-([a-z])/g, (_, letter) => letter.toUpperCase());
|
let key = arg
|
||||||
|
.replace(/^-+/, "")
|
||||||
|
.replace(/-([a-z])/g, (_, letter) => letter.toUpperCase());
|
||||||
key = shortToFull[key] ?? key;
|
key = shortToFull[key] ?? key;
|
||||||
|
|
||||||
// Known boolean flags never take a value
|
// Known boolean flags never take a value
|
||||||
|
|||||||
@@ -23,13 +23,18 @@
|
|||||||
* 1 - Error (no output, fails silently for shell integration)
|
* 1 - Error (no output, fails silently for shell integration)
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
import { existsSync, readdirSync } from "node:fs";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import { createHash } from "node:crypto";
|
import { createHash } from "node:crypto";
|
||||||
|
|
||||||
import { getDataDir, getMnemonicsDir, getWalletConfigPath } from "../../utils/paths.js";
|
import {
|
||||||
|
getDataDir,
|
||||||
|
getMnemonicsDir,
|
||||||
|
getWalletConfigPath,
|
||||||
|
} from "../../utils/paths.js";
|
||||||
import { loadMnemonic } from "../mnemonic.js";
|
import { loadMnemonic } from "../mnemonic.js";
|
||||||
import { Storage } from "../../services/storage.js";
|
import { Storage } from "../../services/storage.js";
|
||||||
|
import { SettingsService } from "../../services/settings.js";
|
||||||
import { COMMAND_TREE } from "./completions.js";
|
import { COMMAND_TREE } from "./completions.js";
|
||||||
|
|
||||||
// Lazy-loaded modules (only loaded when needed for dynamic completions)
|
// Lazy-loaded modules (only loaded when needed for dynamic completions)
|
||||||
@@ -56,7 +61,9 @@ async function getEngineModule() {
|
|||||||
*/
|
*/
|
||||||
function outputCompletions(items: readonly string[], prefix?: string): void {
|
function outputCompletions(items: readonly string[], prefix?: string): void {
|
||||||
const filtered = prefix
|
const filtered = prefix
|
||||||
? items.filter((item) => item.toLowerCase().startsWith(prefix.toLowerCase()))
|
? items.filter((item) =>
|
||||||
|
item.toLowerCase().startsWith(prefix.toLowerCase()),
|
||||||
|
)
|
||||||
: items;
|
: items;
|
||||||
|
|
||||||
for (const item of filtered) {
|
for (const item of filtered) {
|
||||||
@@ -71,7 +78,9 @@ function outputCompletions(items: readonly string[], prefix?: string): void {
|
|||||||
function listMnemonics(prefix?: string): void {
|
function listMnemonics(prefix?: string): void {
|
||||||
try {
|
try {
|
||||||
const mnemonicsDir = getMnemonicsDir();
|
const mnemonicsDir = getMnemonicsDir();
|
||||||
const files = readdirSync(mnemonicsDir).filter((f) => f.startsWith("mnemonic-"));
|
const files = readdirSync(mnemonicsDir).filter((f) =>
|
||||||
|
f.startsWith("mnemonic-"),
|
||||||
|
);
|
||||||
outputCompletions(files, prefix);
|
outputCompletions(files, prefix);
|
||||||
} catch {
|
} catch {
|
||||||
// Silently fail - no completions available
|
// Silently fail - no completions available
|
||||||
@@ -95,12 +104,8 @@ function listSubcommands(command: string, prefix?: string): void {
|
|||||||
*/
|
*/
|
||||||
function getCurrentMnemonic(): string | null {
|
function getCurrentMnemonic(): string | null {
|
||||||
try {
|
try {
|
||||||
const walletConfigPath = getWalletConfigPath();
|
const settings = new SettingsService(getWalletConfigPath());
|
||||||
if (!existsSync(walletConfigPath)) {
|
const mnemonicFile = settings.getDefaultMnemonic();
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const mnemonicFile = readFileSync(walletConfigPath, "utf8").trim();
|
|
||||||
if (!mnemonicFile) {
|
if (!mnemonicFile) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -155,7 +160,13 @@ async function listTemplates(prefix?: string): Promise<void> {
|
|||||||
* Resolves a template by name or ID.
|
* Resolves a template by name or ID.
|
||||||
*/
|
*/
|
||||||
async function resolveTemplate(
|
async function resolveTemplate(
|
||||||
engine: Awaited<ReturnType<Awaited<ReturnType<typeof getOfflineEngineModule>>["tryCreateOfflineEngine"]>>,
|
engine: Awaited<
|
||||||
|
ReturnType<
|
||||||
|
Awaited<
|
||||||
|
ReturnType<typeof getOfflineEngineModule>
|
||||||
|
>["tryCreateOfflineEngine"]
|
||||||
|
>
|
||||||
|
>,
|
||||||
templateQuery: string,
|
templateQuery: string,
|
||||||
) {
|
) {
|
||||||
if (!engine) return null;
|
if (!engine) return null;
|
||||||
@@ -165,7 +176,9 @@ async function resolveTemplate(
|
|||||||
|
|
||||||
// Try exact match on name or ID
|
// Try exact match on name or ID
|
||||||
let template = templates.find(
|
let template = templates.find(
|
||||||
(t) => t.name === templateQuery || generateTemplateIdentifier(t) === templateQuery,
|
(t) =>
|
||||||
|
t.name === templateQuery ||
|
||||||
|
generateTemplateIdentifier(t) === templateQuery,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Try partial match on name
|
// Try partial match on name
|
||||||
@@ -181,7 +194,10 @@ async function resolveTemplate(
|
|||||||
/**
|
/**
|
||||||
* Lists actions for a specific template.
|
* Lists actions for a specific template.
|
||||||
*/
|
*/
|
||||||
async function listActions(templateQuery: string, prefix?: string): Promise<void> {
|
async function listActions(
|
||||||
|
templateQuery: string,
|
||||||
|
prefix?: string,
|
||||||
|
): Promise<void> {
|
||||||
const mnemonic = getCurrentMnemonic();
|
const mnemonic = getCurrentMnemonic();
|
||||||
if (!mnemonic) return;
|
if (!mnemonic) return;
|
||||||
|
|
||||||
@@ -210,7 +226,11 @@ async function listActions(templateQuery: string, prefix?: string): Promise<void
|
|||||||
* Lists fields (actions, transactions, outputs, etc.) for a specific template category.
|
* Lists fields (actions, transactions, outputs, etc.) for a specific template category.
|
||||||
* Used for completing the 3rd argument of `template inspect <category> <template> <field>`.
|
* Used for completing the 3rd argument of `template inspect <category> <template> <field>`.
|
||||||
*/
|
*/
|
||||||
async function listFields(category: string, templateQuery: string, prefix?: string): Promise<void> {
|
async function listFields(
|
||||||
|
category: string,
|
||||||
|
templateQuery: string,
|
||||||
|
prefix?: string,
|
||||||
|
): Promise<void> {
|
||||||
const mnemonic = getCurrentMnemonic();
|
const mnemonic = getCurrentMnemonic();
|
||||||
if (!mnemonic) return;
|
if (!mnemonic) return;
|
||||||
|
|
||||||
@@ -300,7 +320,9 @@ async function listResources(prefix?: string): Promise<void> {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const utxos = await engine.listUnspentOutputsData();
|
const utxos = await engine.listUnspentOutputsData();
|
||||||
const outpoints = utxos.map((u) => `${u.outpointTransactionHash}:${u.outpointIndex}`);
|
const outpoints = utxos.map(
|
||||||
|
(u) => `${u.outpointTransactionHash}:${u.outpointIndex}`,
|
||||||
|
);
|
||||||
outputCompletions(outpoints, prefix);
|
outputCompletions(outpoints, prefix);
|
||||||
} finally {
|
} finally {
|
||||||
await engine.stop();
|
await engine.stop();
|
||||||
|
|||||||
@@ -19,7 +19,7 @@
|
|||||||
* xo-cli completions fish --install
|
* xo-cli completions fish --install
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { existsSync, readFileSync, appendFileSync, writeFileSync } from "node:fs";
|
import { existsSync, readFileSync, appendFileSync, mkdirSync } from "node:fs";
|
||||||
import { dirname, join } from "node:path";
|
import { dirname, join } from "node:path";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
import { homedir } from "node:os";
|
import { homedir } from "node:os";
|
||||||
@@ -30,19 +30,32 @@ import { homedir } from "node:os";
|
|||||||
*
|
*
|
||||||
* IMPORTANT: Keep this in sync with actual switch statements in command handlers:
|
* IMPORTANT: Keep this in sync with actual switch statements in command handlers:
|
||||||
* - mnemonic.ts: create, import, list, expose
|
* - mnemonic.ts: create, import, list, expose
|
||||||
* - template.ts: import, list, inspect, set-default
|
* - template.ts: import, list, inspect, export, set-default
|
||||||
* - invitation.ts: create, append, sign, broadcast, requirements, import, inspect, list
|
* - invitation.ts: create, append, sign, broadcast, requirements, import, export, inspect, list
|
||||||
* - resource.ts: list, unreserve, unreserve-all
|
* - resource.ts: list, unreserve, unreserve-all
|
||||||
|
* - settings.ts: show, get, set
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/** Subcommands for the mnemonic command */
|
/** Subcommands for the mnemonic command */
|
||||||
const MNEMONIC_SUBS = ["create", "import", "list", "expose"];
|
const MNEMONIC_SUBS = ["create", "import", "list", "expose"];
|
||||||
/** Subcommands for the template command */
|
/** Subcommands for the template command */
|
||||||
const TEMPLATE_SUBS = ["import", "list", "inspect", "set-default"];
|
const TEMPLATE_SUBS = ["import", "list", "inspect", "export", "set-default"];
|
||||||
/** Subcommands for the invitation command */
|
/** Subcommands for the invitation command */
|
||||||
const INVITATION_SUBS = ["create", "append", "sign", "broadcast", "requirements", "import", "inspect", "list"];
|
const INVITATION_SUBS = [
|
||||||
|
"create",
|
||||||
|
"append",
|
||||||
|
"sign",
|
||||||
|
"broadcast",
|
||||||
|
"requirements",
|
||||||
|
"import",
|
||||||
|
"export",
|
||||||
|
"inspect",
|
||||||
|
"list",
|
||||||
|
];
|
||||||
/** Subcommands for the resource command */
|
/** Subcommands for the resource command */
|
||||||
const RESOURCE_SUBS = ["list", "unreserve", "unreserve-all"];
|
const RESOURCE_SUBS = ["list", "unreserve", "unreserve-all"];
|
||||||
|
/** Subcommands for the settings command */
|
||||||
|
const SETTINGS_SUBS = ["show", "get", "set"];
|
||||||
/** Subcommands for the completions command */
|
/** Subcommands for the completions command */
|
||||||
const COMPLETIONS_SUBS = ["bash", "zsh", "fish"];
|
const COMPLETIONS_SUBS = ["bash", "zsh", "fish"];
|
||||||
|
|
||||||
@@ -52,12 +65,23 @@ export const COMMAND_TREE = {
|
|||||||
invitation: INVITATION_SUBS,
|
invitation: INVITATION_SUBS,
|
||||||
receive: [],
|
receive: [],
|
||||||
resource: RESOURCE_SUBS,
|
resource: RESOURCE_SUBS,
|
||||||
|
settings: SETTINGS_SUBS,
|
||||||
help: [],
|
help: [],
|
||||||
completions: COMPLETIONS_SUBS,
|
completions: COMPLETIONS_SUBS,
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
/** Global option flags available on every command. */
|
/** Global option flags available on every command. */
|
||||||
const GLOBAL_OPTIONS = ["-h", "--help", "-v", "--verbose", "-m", "--mnemonic-file", "-o", "--output"];
|
const GLOBAL_OPTIONS = [
|
||||||
|
"-h",
|
||||||
|
"--help",
|
||||||
|
"-v",
|
||||||
|
"--verbose",
|
||||||
|
"-m",
|
||||||
|
"--mnemonic-file",
|
||||||
|
"--currency",
|
||||||
|
"-o",
|
||||||
|
"--output",
|
||||||
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Gets the path to the scripts directory containing shell templates.
|
* Gets the path to the scripts directory containing shell templates.
|
||||||
@@ -92,13 +116,22 @@ function loadAndProcessTemplate(templateName: string, binName: string): string {
|
|||||||
content = content.replace(/\{\{OPTIONS\}\}/g, options);
|
content = content.replace(/\{\{OPTIONS\}\}/g, options);
|
||||||
content = content.replace(/\{\{MNEMONIC_SUBS\}\}/g, MNEMONIC_SUBS.join(" "));
|
content = content.replace(/\{\{MNEMONIC_SUBS\}\}/g, MNEMONIC_SUBS.join(" "));
|
||||||
content = content.replace(/\{\{TEMPLATE_SUBS\}\}/g, TEMPLATE_SUBS.join(" "));
|
content = content.replace(/\{\{TEMPLATE_SUBS\}\}/g, TEMPLATE_SUBS.join(" "));
|
||||||
content = content.replace(/\{\{INVITATION_SUBS\}\}/g, INVITATION_SUBS.join(" "));
|
content = content.replace(
|
||||||
|
/\{\{INVITATION_SUBS\}\}/g,
|
||||||
|
INVITATION_SUBS.join(" "),
|
||||||
|
);
|
||||||
content = content.replace(/\{\{RESOURCE_SUBS\}\}/g, RESOURCE_SUBS.join(" "));
|
content = content.replace(/\{\{RESOURCE_SUBS\}\}/g, RESOURCE_SUBS.join(" "));
|
||||||
|
|
||||||
// Fish-specific placeholders
|
// Fish-specific placeholders
|
||||||
if (templateName.endsWith(".fish")) {
|
if (templateName.endsWith(".fish")) {
|
||||||
content = content.replace(/\{\{TOP_LEVEL_COMMANDS\}\}/g, generateFishTopLevelCommands(binName));
|
content = content.replace(
|
||||||
content = content.replace(/\{\{STATIC_SUBCOMMANDS\}\}/g, generateFishStaticSubcommands(binName));
|
/\{\{TOP_LEVEL_COMMANDS\}\}/g,
|
||||||
|
generateFishTopLevelCommands(binName),
|
||||||
|
);
|
||||||
|
content = content.replace(
|
||||||
|
/\{\{STATIC_SUBCOMMANDS\}\}/g,
|
||||||
|
generateFishStaticSubcommands(binName),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return content;
|
return content;
|
||||||
@@ -110,7 +143,9 @@ function loadAndProcessTemplate(templateName: string, binName: string): string {
|
|||||||
function generateFishTopLevelCommands(binName: string): string {
|
function generateFishTopLevelCommands(binName: string): string {
|
||||||
const lines: string[] = [];
|
const lines: string[] = [];
|
||||||
for (const cmd of Object.keys(COMMAND_TREE)) {
|
for (const cmd of Object.keys(COMMAND_TREE)) {
|
||||||
lines.push(`complete -c ${binName} -n "__fish_use_subcommand" -a "${cmd}" -d "${cmd} command"`);
|
lines.push(
|
||||||
|
`complete -c ${binName} -n "__fish_use_subcommand" -a "${cmd}" -d "${cmd} command"`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return lines.join("\n");
|
return lines.join("\n");
|
||||||
}
|
}
|
||||||
@@ -122,7 +157,9 @@ function generateFishStaticSubcommands(binName: string): string {
|
|||||||
const lines: string[] = [];
|
const lines: string[] = [];
|
||||||
for (const [cmd, subs] of Object.entries(COMMAND_TREE)) {
|
for (const [cmd, subs] of Object.entries(COMMAND_TREE)) {
|
||||||
for (const sub of subs) {
|
for (const sub of subs) {
|
||||||
lines.push(`complete -c ${binName} -n "__fish_seen_subcommand_from ${cmd}; and not __fish_seen_subcommand_from ${subs.join(" ")}" -a "${sub}" -d "${cmd} ${sub}"`);
|
lines.push(
|
||||||
|
`complete -c ${binName} -n "__fish_seen_subcommand_from ${cmd}; and not __fish_seen_subcommand_from ${subs.join(" ")}" -a "${sub}" -d "${cmd} ${sub}"`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return lines.join("\n");
|
return lines.join("\n");
|
||||||
@@ -152,7 +189,7 @@ export function generateFishCompletions(binName: string): string {
|
|||||||
return loadAndProcessTemplate("fish.fish", binName);
|
return loadAndProcessTemplate("fish.fish", binName);
|
||||||
}
|
}
|
||||||
|
|
||||||
type ShellType = "bash" | "zsh" | "fish";
|
export type ShellType = "bash" | "zsh" | "fish";
|
||||||
|
|
||||||
const generators: Record<ShellType, (binName: string) => string> = {
|
const generators: Record<ShellType, (binName: string) => string> = {
|
||||||
bash: generateBashCompletions,
|
bash: generateBashCompletions,
|
||||||
@@ -161,47 +198,76 @@ const generators: Record<ShellType, (binName: string) => string> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Shell config file paths and eval commands for each shell type.
|
* Shell config file paths and startup commands for each shell type.
|
||||||
*/
|
*/
|
||||||
const shellConfigs: Record<ShellType, { configFile: string; evalCommand: (binName: string) => string }> = {
|
const shellConfigs: Record<
|
||||||
|
ShellType,
|
||||||
|
{
|
||||||
|
configFile: string;
|
||||||
|
configDirCommand: string;
|
||||||
|
configDirPattern: RegExp;
|
||||||
|
evalCommand: (binName: string) => string;
|
||||||
|
}
|
||||||
|
> = {
|
||||||
bash: {
|
bash: {
|
||||||
configFile: join(homedir(), ".bashrc"),
|
configFile: join(homedir(), ".bashrc"),
|
||||||
|
configDirCommand:
|
||||||
|
'export XO_CONFIG_DIR="${XO_CONFIG_DIR:-$HOME/.config/xo-cli}"',
|
||||||
|
configDirPattern: /^\s*(?:export\s+)?XO_CONFIG_DIR=/m,
|
||||||
evalCommand: (binName) => `eval "$(${binName} completions bash)"`,
|
evalCommand: (binName) => `eval "$(${binName} completions bash)"`,
|
||||||
},
|
},
|
||||||
zsh: {
|
zsh: {
|
||||||
configFile: join(homedir(), ".zshrc"),
|
configFile: join(homedir(), ".zshrc"),
|
||||||
|
configDirCommand:
|
||||||
|
'export XO_CONFIG_DIR="${XO_CONFIG_DIR:-$HOME/.config/xo-cli}"',
|
||||||
|
configDirPattern: /^\s*(?:export\s+)?XO_CONFIG_DIR=/m,
|
||||||
evalCommand: (binName) => `eval "$(${binName} completions zsh)"`,
|
evalCommand: (binName) => `eval "$(${binName} completions zsh)"`,
|
||||||
},
|
},
|
||||||
fish: {
|
fish: {
|
||||||
configFile: join(homedir(), ".config", "fish", "config.fish"),
|
configFile: join(homedir(), ".config", "fish", "config.fish"),
|
||||||
|
configDirCommand:
|
||||||
|
'set -q XO_CONFIG_DIR; or set -gx XO_CONFIG_DIR "$HOME/.config/xo-cli"',
|
||||||
|
configDirPattern: /^\s*set\b[^\n]*\bXO_CONFIG_DIR\b/m,
|
||||||
evalCommand: (binName) => `${binName} completions fish | source`,
|
evalCommand: (binName) => `${binName} completions fish | source`,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Installs completions to the user's shell config file.
|
* Installs completions to the user's shell config file.
|
||||||
* Adds the eval command if not already present.
|
* Adds a default config directory and the eval command if not already present.
|
||||||
* @param shell - The shell type
|
* @param shell - The shell type
|
||||||
* @param binName - The CLI binary name
|
* @param binName - The CLI binary name
|
||||||
* @returns true if installed, false if already present
|
* @returns true if installed, false if already present
|
||||||
*/
|
*/
|
||||||
function installCompletions(shell: ShellType, binName: string): boolean {
|
export function installCompletions(
|
||||||
const config = shellConfigs[shell];
|
shell: ShellType,
|
||||||
|
binName: string,
|
||||||
|
configFile: string = shellConfigs[shell].configFile,
|
||||||
|
): boolean {
|
||||||
|
const config = { ...shellConfigs[shell], configFile };
|
||||||
const evalCommand = config.evalCommand(binName);
|
const evalCommand = config.evalCommand(binName);
|
||||||
|
|
||||||
// Check if config file exists and already has the completion line
|
|
||||||
let existingContent = "";
|
let existingContent = "";
|
||||||
if (existsSync(config.configFile)) {
|
if (existsSync(config.configFile)) {
|
||||||
existingContent = readFileSync(config.configFile, "utf8");
|
existingContent = readFileSync(config.configFile, "utf8");
|
||||||
if (existingContent.includes(evalCommand)) {
|
|
||||||
return false; // Already installed
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Append the completion line
|
const commands: string[] = [];
|
||||||
const newLine = existingContent.endsWith("\n") || existingContent === "" ? "" : "\n";
|
if (!config.configDirPattern.test(existingContent)) {
|
||||||
const completionBlock = `${newLine}\n# ${binName} shell completions\n${evalCommand}\n`;
|
commands.push(config.configDirCommand);
|
||||||
|
}
|
||||||
|
if (!existingContent.includes(evalCommand)) {
|
||||||
|
commands.push(evalCommand);
|
||||||
|
}
|
||||||
|
if (commands.length === 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const newLine =
|
||||||
|
existingContent.endsWith("\n") || existingContent === "" ? "" : "\n";
|
||||||
|
const completionBlock = `${newLine}\n# ${binName} shell completions\n${commands.join("\n")}\n`;
|
||||||
|
|
||||||
|
mkdirSync(dirname(config.configFile), { recursive: true });
|
||||||
appendFileSync(config.configFile, completionBlock);
|
appendFileSync(config.configFile, completionBlock);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -227,14 +293,26 @@ export function handleCompletionsCommand(
|
|||||||
console.error(`Usage: ${binName} completions <${supported}> [--install]`);
|
console.error(`Usage: ${binName} completions <${supported}> [--install]`);
|
||||||
console.error("");
|
console.error("");
|
||||||
console.error("Examples:");
|
console.error("Examples:");
|
||||||
console.error(` eval "$(${binName} completions bash)" # Output to stdout (add to ~/.bashrc)`);
|
console.error(
|
||||||
console.error(` eval "$(${binName} completions zsh)" # Output to stdout (add to ~/.zshrc)`);
|
` eval "$(${binName} completions bash)" # Output to stdout (add to ~/.bashrc)`,
|
||||||
console.error(` ${binName} completions fish | source # Output to stdout (add to fish config)`);
|
);
|
||||||
|
console.error(
|
||||||
|
` eval "$(${binName} completions zsh)" # Output to stdout (add to ~/.zshrc)`,
|
||||||
|
);
|
||||||
|
console.error(
|
||||||
|
` ${binName} completions fish | source # Output to stdout (add to fish config)`,
|
||||||
|
);
|
||||||
console.error("");
|
console.error("");
|
||||||
console.error("Install directly to shell config:");
|
console.error("Install directly to shell config:");
|
||||||
console.error(` ${binName} completions bash --install # Appends to ~/.bashrc`);
|
console.error(
|
||||||
console.error(` ${binName} completions zsh --install # Appends to ~/.zshrc`);
|
` ${binName} completions bash --install # Appends to ~/.bashrc`,
|
||||||
console.error(` ${binName} completions fish --install # Appends to ~/.config/fish/config.fish`);
|
);
|
||||||
|
console.error(
|
||||||
|
` ${binName} completions zsh --install # Appends to ~/.zshrc`,
|
||||||
|
);
|
||||||
|
console.error(
|
||||||
|
` ${binName} completions fish --install # Appends to ~/.config/fish/config.fish`,
|
||||||
|
);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
* and instead constructs the engine directly with an in-memory blockchain provider.
|
* and instead constructs the engine directly with an in-memory blockchain provider.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { BlockchainMonitor, Engine, InMemoryBlockchainProvider } from "@xo-cash/engine";
|
import { BlockchainMonitor, Engine } from "@xo-cash/engine";
|
||||||
import { createStorageAdapter, State, StorageType } from "@xo-cash/state";
|
import { createStorageAdapter, State, StorageType } from "@xo-cash/state";
|
||||||
import { convertMnemonicToSeedBytes } from "@xo-cash/crypto";
|
import { convertMnemonicToSeedBytes } from "@xo-cash/crypto";
|
||||||
import { binToHex, hash256 } from "@bitauth/libauth";
|
import { binToHex, hash256 } from "@bitauth/libauth";
|
||||||
@@ -63,18 +63,21 @@ export async function createOfflineEngine(
|
|||||||
// Create the state instance
|
// Create the state instance
|
||||||
const state = new State(storageAdapter);
|
const state = new State(storageAdapter);
|
||||||
|
|
||||||
// Use in-memory blockchain provider (no network connections)
|
// Create a minimal blockchain monitor (no electrum initialization)
|
||||||
const blockchainProvider = new InMemoryBlockchainProvider();
|
const blockchainMonitor = new BlockchainMonitor(state);
|
||||||
await blockchainProvider.initialize({
|
|
||||||
applicationIdentifier: "xo-cli-completions",
|
|
||||||
electrumOptions: {},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Create a minimal blockchain monitor
|
// Engine constructor is private; bypass for offline read-only completions.
|
||||||
const blockchainMonitor = new BlockchainMonitor(state, blockchainProvider);
|
type EngineConstructor = new (
|
||||||
|
mnemonic: string,
|
||||||
|
state: State,
|
||||||
|
blockchainMonitor: BlockchainMonitor,
|
||||||
|
) => Engine;
|
||||||
|
|
||||||
// Construct engine directly without state sync
|
const engine = new (Engine as unknown as EngineConstructor)(
|
||||||
const engine = new Engine(seed, state, blockchainMonitor, blockchainProvider);
|
seed,
|
||||||
|
state,
|
||||||
|
blockchainMonitor,
|
||||||
|
);
|
||||||
|
|
||||||
return engine;
|
return engine;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,16 @@
|
|||||||
# bash completion for {{BIN_NAME}}
|
# ------------------------------------------------------------------------------
|
||||||
# Add to ~/.bashrc: eval "$({{BIN_NAME}} completions bash)"
|
# Bash completion template for {{BIN_NAME}}
|
||||||
|
# ------------------------------------------------------------------------------
|
||||||
|
# Installation:
|
||||||
|
# eval "$({{BIN_NAME}} completions bash)"
|
||||||
|
#
|
||||||
|
# This file is generated from a template. Placeholders (for example `{{OPTIONS}}`)
|
||||||
|
# are replaced at build/runtime with concrete command data from the CLI.
|
||||||
|
# ------------------------------------------------------------------------------
|
||||||
|
|
||||||
# Find xo-complete in the same directory as xo-cli
|
# Prefer a globally-installed helper, but fall back to a helper co-located with
|
||||||
|
# the CLI binary. This lets completions work in both "installed via PATH" and
|
||||||
|
# "single extracted directory" workflows.
|
||||||
__xo_complete_bin=""
|
__xo_complete_bin=""
|
||||||
if command -v xo-complete &>/dev/null; then
|
if command -v xo-complete &>/dev/null; then
|
||||||
__xo_complete_bin="xo-complete"
|
__xo_complete_bin="xo-complete"
|
||||||
@@ -9,19 +18,44 @@ elif command -v {{BIN_NAME}} &>/dev/null; then
|
|||||||
__xo_complete_bin="$(dirname "$(command -v {{BIN_NAME}})")/xo-complete"
|
__xo_complete_bin="$(dirname "$(command -v {{BIN_NAME}})")/xo-complete"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Wrapper to call xo-complete helper
|
# @description
|
||||||
|
# Calls the dynamic completion helper and suppresses helper stderr so the shell
|
||||||
|
# completion menu stays clean even when the helper is unavailable or errors.
|
||||||
|
# @param "$@" Arguments forwarded to xo-complete.
|
||||||
__xo_complete() {
|
__xo_complete() {
|
||||||
[[ -n "${__xo_complete_bin}" ]] && "${__xo_complete_bin}" "$@" 2>/dev/null
|
[[ -n "${__xo_complete_bin}" ]] && "${__xo_complete_bin}" "$@" 2>/dev/null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# @description
|
||||||
|
# Lists mnemonic aliases directly from the config directory without starting
|
||||||
|
# the dynamic Node helper.
|
||||||
|
__xo_complete_mnemonics() {
|
||||||
|
local config_dir="${XO_CONFIG_DIR:-${HOME}/.config/xo-cli}"
|
||||||
|
local file mnemonic
|
||||||
|
for file in "${config_dir}"/mnemonics/mnemonic-*; do
|
||||||
|
[[ -f "${file}" ]] || continue
|
||||||
|
mnemonic="${file##*/}"
|
||||||
|
[[ "${mnemonic}" == "$1"* ]] && printf '%s\n' "${mnemonic}"
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
# @description
|
||||||
|
# Main completion dispatcher invoked by bash's `complete -F`.
|
||||||
|
# It determines context (command/subcommand/argument position) and then mixes:
|
||||||
|
# - static completions (known command words)
|
||||||
|
# - dynamic completions (resolved by xo-complete)
|
||||||
|
# - filesystem completions (when a subcommand expects file paths)
|
||||||
_{{FUNC_NAME}}_completions() {
|
_{{FUNC_NAME}}_completions() {
|
||||||
local cur prev words cword
|
local cur prev words cword
|
||||||
|
# Populates `cur`, `prev`, `words`, and `cword`.
|
||||||
|
# `_init_completion` is provided by bash-completion.
|
||||||
_init_completion || return
|
_init_completion || return
|
||||||
|
|
||||||
# Handle -m/--mnemonic-file argument (previous word was -m)
|
# If the previous token is `-m/--mnemonic-file`, this argument expects a
|
||||||
|
# mnemonic file alias/path. List mnemonic aliases directly from disk.
|
||||||
if [[ "${prev}" == "-m" || "${prev}" == "--mnemonic-file" ]]; then
|
if [[ "${prev}" == "-m" || "${prev}" == "--mnemonic-file" ]]; then
|
||||||
local mnemonics
|
local mnemonics
|
||||||
mnemonics=$(__xo_complete mnemonics "${cur}")
|
mnemonics=$(__xo_complete_mnemonics "${cur}")
|
||||||
if [[ -n "${mnemonics}" ]]; then
|
if [[ -n "${mnemonics}" ]]; then
|
||||||
while IFS= read -r line; do
|
while IFS= read -r line; do
|
||||||
COMPREPLY+=("$line")
|
COMPREPLY+=("$line")
|
||||||
@@ -30,13 +64,14 @@ _{{FUNC_NAME}}_completions() {
|
|||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# If the current word starts with "-", offer option flags
|
# Option context: show global options when the current token starts with `-`.
|
||||||
if [[ "${cur}" == -* ]]; then
|
if [[ "${cur}" == -* ]]; then
|
||||||
COMPREPLY=($(compgen -W "{{OPTIONS}}" -- "${cur}"))
|
COMPREPLY=($(compgen -W "{{OPTIONS}}" -- "${cur}"))
|
||||||
return 0
|
return 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Find the command and subcommand positions
|
# Parse command/subcommand from non-option tokens before the current cursor.
|
||||||
|
# We track indices so argument-position logic can be computed later.
|
||||||
local cmd="" subcmd="" cmd_idx=0 subcmd_idx=0
|
local cmd="" subcmd="" cmd_idx=0 subcmd_idx=0
|
||||||
for ((i=1; i < cword; i++)); do
|
for ((i=1; i < cword; i++)); do
|
||||||
if [[ "${words[i]}" != -* ]]; then
|
if [[ "${words[i]}" != -* ]]; then
|
||||||
@@ -51,13 +86,13 @@ _{{FUNC_NAME}}_completions() {
|
|||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
|
|
||||||
# No command yet — offer the top-level commands
|
# No command selected yet: complete top-level commands.
|
||||||
if [[ -z "${cmd}" ]]; then
|
if [[ -z "${cmd}" ]]; then
|
||||||
COMPREPLY=($(compgen -W "{{COMMANDS}}" -- "${cur}"))
|
COMPREPLY=($(compgen -W "{{COMMANDS}}" -- "${cur}"))
|
||||||
return 0
|
return 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Handle each command's completion
|
# Command-specific completion rules.
|
||||||
case "${cmd}" in
|
case "${cmd}" in
|
||||||
mnemonic)
|
mnemonic)
|
||||||
if [[ -z "${subcmd}" ]]; then
|
if [[ -z "${subcmd}" ]]; then
|
||||||
@@ -69,7 +104,9 @@ _{{FUNC_NAME}}_completions() {
|
|||||||
if [[ -z "${subcmd}" ]]; then
|
if [[ -z "${subcmd}" ]]; then
|
||||||
COMPREPLY=($(compgen -W "{{TEMPLATE_SUBS}}" -- "${cur}"))
|
COMPREPLY=($(compgen -W "{{TEMPLATE_SUBS}}" -- "${cur}"))
|
||||||
elif [[ "${subcmd}" == "list" || "${subcmd}" == "inspect" ]]; then
|
elif [[ "${subcmd}" == "list" || "${subcmd}" == "inspect" ]]; then
|
||||||
# template list/inspect <category> <template> [field] - category first, then template, then field
|
# template list/inspect <category> <template> [field]
|
||||||
|
# Position is computed relative to the subcommand token:
|
||||||
|
# 1 => category, 2 => template, 3 => field (inspect only)
|
||||||
local pos=$((cword - subcmd_idx))
|
local pos=$((cword - subcmd_idx))
|
||||||
if [[ $pos -eq 1 ]]; then
|
if [[ $pos -eq 1 ]]; then
|
||||||
COMPREPLY=($(compgen -W "action transaction output lockingscript variable" -- "${cur}"))
|
COMPREPLY=($(compgen -W "action transaction output lockingscript variable" -- "${cur}"))
|
||||||
@@ -82,7 +119,7 @@ _{{FUNC_NAME}}_completions() {
|
|||||||
done <<< "${templates}"
|
done <<< "${templates}"
|
||||||
fi
|
fi
|
||||||
elif [[ $pos -eq 3 && "${subcmd}" == "inspect" ]]; then
|
elif [[ $pos -eq 3 && "${subcmd}" == "inspect" ]]; then
|
||||||
# Get the category and template from previous args
|
# Field names depend on both selected category and template.
|
||||||
local category="${words[subcmd_idx + 1]}"
|
local category="${words[subcmd_idx + 1]}"
|
||||||
local template_arg="${words[subcmd_idx + 2]}"
|
local template_arg="${words[subcmd_idx + 2]}"
|
||||||
local fields
|
local fields
|
||||||
@@ -94,7 +131,8 @@ _{{FUNC_NAME}}_completions() {
|
|||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
elif [[ "${subcmd}" == "set-default" ]]; then
|
elif [[ "${subcmd}" == "set-default" ]]; then
|
||||||
# template set-default <template> <output> <role> - template first
|
# template set-default <template> <output> <role>
|
||||||
|
# We only complete the first positional argument (template) here.
|
||||||
local pos=$((cword - subcmd_idx))
|
local pos=$((cword - subcmd_idx))
|
||||||
if [[ $pos -eq 1 ]]; then
|
if [[ $pos -eq 1 ]]; then
|
||||||
local templates
|
local templates
|
||||||
@@ -114,7 +152,8 @@ _{{FUNC_NAME}}_completions() {
|
|||||||
else
|
else
|
||||||
case "${subcmd}" in
|
case "${subcmd}" in
|
||||||
create)
|
create)
|
||||||
# invitation create <template> <action> - offer templates then actions
|
# invitation create <template> <action>
|
||||||
|
# The available actions depend on the selected template.
|
||||||
local pos=$((cword - subcmd_idx))
|
local pos=$((cword - subcmd_idx))
|
||||||
if [[ $pos -eq 1 ]]; then
|
if [[ $pos -eq 1 ]]; then
|
||||||
local templates
|
local templates
|
||||||
@@ -135,8 +174,8 @@ _{{FUNC_NAME}}_completions() {
|
|||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
;;
|
;;
|
||||||
append|sign|broadcast|requirements|inspect)
|
append|sign|broadcast|requirements|export|inspect|delete)
|
||||||
# These take an invitation ID
|
# These subcommands expect an invitation identifier as first arg.
|
||||||
local pos=$((cword - subcmd_idx))
|
local pos=$((cword - subcmd_idx))
|
||||||
if [[ $pos -eq 1 ]]; then
|
if [[ $pos -eq 1 ]]; then
|
||||||
local invitations
|
local invitations
|
||||||
@@ -149,7 +188,7 @@ _{{FUNC_NAME}}_completions() {
|
|||||||
fi
|
fi
|
||||||
;;
|
;;
|
||||||
import)
|
import)
|
||||||
# import takes a file path - use default file completion
|
# File import path: delegate to bash's built-in file completion.
|
||||||
COMPREPLY=($(compgen -f -- "${cur}"))
|
COMPREPLY=($(compgen -f -- "${cur}"))
|
||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
@@ -160,7 +199,8 @@ _{{FUNC_NAME}}_completions() {
|
|||||||
if [[ -z "${subcmd}" ]]; then
|
if [[ -z "${subcmd}" ]]; then
|
||||||
COMPREPLY=($(compgen -W "{{RESOURCE_SUBS}}" -- "${cur}"))
|
COMPREPLY=($(compgen -W "{{RESOURCE_SUBS}}" -- "${cur}"))
|
||||||
elif [[ "${subcmd}" == "unreserve" ]]; then
|
elif [[ "${subcmd}" == "unreserve" ]]; then
|
||||||
# resource unreserve <txhash:vout> - offer resources
|
# resource unreserve <txhash:vout>
|
||||||
|
# Suggest known reserved outpoints from the helper.
|
||||||
local pos=$((cword - subcmd_idx))
|
local pos=$((cword - subcmd_idx))
|
||||||
if [[ $pos -eq 1 ]]; then
|
if [[ $pos -eq 1 ]]; then
|
||||||
local resources
|
local resources
|
||||||
@@ -174,8 +214,20 @@ _{{FUNC_NAME}}_completions() {
|
|||||||
fi
|
fi
|
||||||
;;
|
;;
|
||||||
|
|
||||||
|
settings)
|
||||||
|
if [[ -z "${subcmd}" ]]; then
|
||||||
|
COMPREPLY=($(compgen -W "show get set" -- "${cur}"))
|
||||||
|
elif [[ "${subcmd}" == "get" || "${subcmd}" == "set" ]]; then
|
||||||
|
local pos=$((cword - subcmd_idx))
|
||||||
|
if [[ $pos -eq 1 ]]; then
|
||||||
|
COMPREPLY=($(compgen -W "currency default-mnemonic" -- "${cur}"))
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
|
||||||
receive)
|
receive)
|
||||||
# receive <template> [output] - offer templates
|
# receive <template> [output]
|
||||||
|
# Template is the first positional argument after `receive`.
|
||||||
local pos=$((cword - cmd_idx))
|
local pos=$((cword - cmd_idx))
|
||||||
if [[ $pos -eq 1 ]]; then
|
if [[ $pos -eq 1 ]]; then
|
||||||
local templates
|
local templates
|
||||||
@@ -189,6 +241,7 @@ _{{FUNC_NAME}}_completions() {
|
|||||||
;;
|
;;
|
||||||
|
|
||||||
completions)
|
completions)
|
||||||
|
# Shell target for generating completion scripts.
|
||||||
if [[ -z "${subcmd}" ]]; then
|
if [[ -z "${subcmd}" ]]; then
|
||||||
COMPREPLY=($(compgen -W "bash zsh fish" -- "${cur}"))
|
COMPREPLY=($(compgen -W "bash zsh fish" -- "${cur}"))
|
||||||
fi
|
fi
|
||||||
@@ -196,4 +249,5 @@ _{{FUNC_NAME}}_completions() {
|
|||||||
esac
|
esac
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Register the completion function for the CLI binary.
|
||||||
complete -F _{{FUNC_NAME}}_completions {{BIN_NAME}}
|
complete -F _{{FUNC_NAME}}_completions {{BIN_NAME}}
|
||||||
|
|||||||
@@ -1,11 +1,21 @@
|
|||||||
# fish completion for {{BIN_NAME}}
|
# ------------------------------------------------------------------------------
|
||||||
# Add to fish config: {{BIN_NAME}} completions fish | source
|
# Fish completion template for {{BIN_NAME}}
|
||||||
|
# ------------------------------------------------------------------------------
|
||||||
|
# Installation:
|
||||||
|
# {{BIN_NAME}} completions fish | source
|
||||||
|
#
|
||||||
|
# This file is generated from a template. Placeholders (for example
|
||||||
|
# `{{TOP_LEVEL_COMMANDS}}`) are replaced with concrete completion definitions.
|
||||||
|
# ------------------------------------------------------------------------------
|
||||||
|
|
||||||
# Disable file completions by default
|
# Fish offers file completion by default. Disable that globally first so command
|
||||||
|
# words are preferred, then selectively re-enable `-F` where paths are expected.
|
||||||
complete -c {{BIN_NAME}} -f
|
complete -c {{BIN_NAME}} -f
|
||||||
|
|
||||||
# Helper function to get dynamic completions
|
# @description
|
||||||
# Finds xo-complete in the same directory as {{BIN_NAME}}
|
# Resolves and calls `xo-complete` for dynamic values (templates, invitations,
|
||||||
|
# fields, etc.). We first try PATH, then a helper next to `{{BIN_NAME}}`.
|
||||||
|
# @param $argv Arguments forwarded directly to xo-complete.
|
||||||
function __{{FUNC_NAME}}_complete_dynamic
|
function __{{FUNC_NAME}}_complete_dynamic
|
||||||
set -l xo_complete_bin ""
|
set -l xo_complete_bin ""
|
||||||
if command -q xo-complete
|
if command -q xo-complete
|
||||||
@@ -18,53 +28,86 @@ function __{{FUNC_NAME}}_complete_dynamic
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
# Global options
|
# @description
|
||||||
|
# Lists mnemonic aliases directly from the config directory without starting
|
||||||
|
# the dynamic Node helper.
|
||||||
|
function __{{FUNC_NAME}}_complete_mnemonics
|
||||||
|
set -l config_dir "$XO_CONFIG_DIR"
|
||||||
|
if test -z "$config_dir"
|
||||||
|
set config_dir "$HOME/.config/xo-cli"
|
||||||
|
end
|
||||||
|
for file in $config_dir/mnemonics/mnemonic-*
|
||||||
|
if test -f "$file"
|
||||||
|
string replace -r '.*/' '' "$file"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Global option flags available across top-level command contexts.
|
||||||
complete -c {{BIN_NAME}} -s h -d "Show help"
|
complete -c {{BIN_NAME}} -s h -d "Show help"
|
||||||
complete -c {{BIN_NAME}} -l help -d "Show help"
|
complete -c {{BIN_NAME}} -l help -d "Show help"
|
||||||
complete -c {{BIN_NAME}} -s v -d "Verbose output"
|
complete -c {{BIN_NAME}} -s v -d "Verbose output"
|
||||||
complete -c {{BIN_NAME}} -l verbose -d "Verbose output"
|
complete -c {{BIN_NAME}} -l verbose -d "Verbose output"
|
||||||
complete -c {{BIN_NAME}} -s o -d "Output file"
|
complete -c {{BIN_NAME}} -s o -d "Output file"
|
||||||
complete -c {{BIN_NAME}} -l output -d "Output file"
|
complete -c {{BIN_NAME}} -l output -d "Output file"
|
||||||
|
complete -c {{BIN_NAME}} -l currency -d "Set fiat display currency"
|
||||||
|
|
||||||
# Dynamic mnemonic file completion for -m
|
# Shell-native completion for `-m/--mnemonic-file`.
|
||||||
complete -c {{BIN_NAME}} -s m -l mnemonic-file -xa '(__{{FUNC_NAME}}_complete_dynamic mnemonics)'
|
complete -c {{BIN_NAME}} -s m -l mnemonic-file -xa '(__{{FUNC_NAME}}_complete_mnemonics)'
|
||||||
|
|
||||||
# Top-level commands
|
# Top-level command registrations inserted by template expansion.
|
||||||
{{TOP_LEVEL_COMMANDS}}
|
{{TOP_LEVEL_COMMANDS}}
|
||||||
|
|
||||||
# Static sub-commands
|
# Static subcommand registrations inserted by template expansion.
|
||||||
{{STATIC_SUBCOMMANDS}}
|
{{STATIC_SUBCOMMANDS}}
|
||||||
|
|
||||||
# Dynamic completions
|
# ---------------------------------------------------------------------------
|
||||||
|
# Dynamic completions by command/subcommand.
|
||||||
|
#
|
||||||
|
# Fish condition notes:
|
||||||
|
# - `__fish_seen_subcommand_from <name>` checks whether `<name>` exists in the
|
||||||
|
# current tokenized command line.
|
||||||
|
# - `count (commandline -opc)` returns how many tokens were entered.
|
||||||
|
# We use this to infer positional argument index.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
# invitation create: template names
|
# invitation create <template> <action>
|
||||||
|
# Position 3 => template argument.
|
||||||
complete -c {{BIN_NAME}} -n "__fish_seen_subcommand_from invitation; and __fish_seen_subcommand_from create; and test (count (commandline -opc)) -eq 3" -xa '(__{{FUNC_NAME}}_complete_dynamic templates)'
|
complete -c {{BIN_NAME}} -n "__fish_seen_subcommand_from invitation; and __fish_seen_subcommand_from create; and test (count (commandline -opc)) -eq 3" -xa '(__{{FUNC_NAME}}_complete_dynamic templates)'
|
||||||
|
|
||||||
# invitation create: action names (2nd arg)
|
# invitation create <template> <action>
|
||||||
|
# Position 4 => action argument, filtered by selected template token.
|
||||||
complete -c {{BIN_NAME}} -n "__fish_seen_subcommand_from invitation; and __fish_seen_subcommand_from create; and test (count (commandline -opc)) -eq 4" -xa '(__{{FUNC_NAME}}_complete_dynamic actions (commandline -opc)[4])'
|
complete -c {{BIN_NAME}} -n "__fish_seen_subcommand_from invitation; and __fish_seen_subcommand_from create; and test (count (commandline -opc)) -eq 4" -xa '(__{{FUNC_NAME}}_complete_dynamic actions (commandline -opc)[4])'
|
||||||
|
|
||||||
# invitation append/sign/broadcast/requirements/inspect: invitation IDs
|
# invitation append/sign/broadcast/requirements/inspect <invitation-id>
|
||||||
|
# Position 3 => invitation identifier.
|
||||||
complete -c {{BIN_NAME}} -n "__fish_seen_subcommand_from invitation; and __fish_seen_subcommand_from append; and test (count (commandline -opc)) -eq 3" -xa '(__{{FUNC_NAME}}_complete_dynamic invitations)'
|
complete -c {{BIN_NAME}} -n "__fish_seen_subcommand_from invitation; and __fish_seen_subcommand_from append; and test (count (commandline -opc)) -eq 3" -xa '(__{{FUNC_NAME}}_complete_dynamic invitations)'
|
||||||
complete -c {{BIN_NAME}} -n "__fish_seen_subcommand_from invitation; and __fish_seen_subcommand_from sign; and test (count (commandline -opc)) -eq 3" -xa '(__{{FUNC_NAME}}_complete_dynamic invitations)'
|
complete -c {{BIN_NAME}} -n "__fish_seen_subcommand_from invitation; and __fish_seen_subcommand_from sign; and test (count (commandline -opc)) -eq 3" -xa '(__{{FUNC_NAME}}_complete_dynamic invitations)'
|
||||||
complete -c {{BIN_NAME}} -n "__fish_seen_subcommand_from invitation; and __fish_seen_subcommand_from broadcast; and test (count (commandline -opc)) -eq 3" -xa '(__{{FUNC_NAME}}_complete_dynamic invitations)'
|
complete -c {{BIN_NAME}} -n "__fish_seen_subcommand_from invitation; and __fish_seen_subcommand_from broadcast; and test (count (commandline -opc)) -eq 3" -xa '(__{{FUNC_NAME}}_complete_dynamic invitations)'
|
||||||
complete -c {{BIN_NAME}} -n "__fish_seen_subcommand_from invitation; and __fish_seen_subcommand_from requirements; and test (count (commandline -opc)) -eq 3" -xa '(__{{FUNC_NAME}}_complete_dynamic invitations)'
|
complete -c {{BIN_NAME}} -n "__fish_seen_subcommand_from invitation; and __fish_seen_subcommand_from requirements; and test (count (commandline -opc)) -eq 3" -xa '(__{{FUNC_NAME}}_complete_dynamic invitations)'
|
||||||
|
complete -c {{BIN_NAME}} -n "__fish_seen_subcommand_from invitation; and __fish_seen_subcommand_from export; and test (count (commandline -opc)) -eq 3" -xa '(__{{FUNC_NAME}}_complete_dynamic invitations)'
|
||||||
complete -c {{BIN_NAME}} -n "__fish_seen_subcommand_from invitation; and __fish_seen_subcommand_from inspect; and test (count (commandline -opc)) -eq 3" -xa '(__{{FUNC_NAME}}_complete_dynamic invitations)'
|
complete -c {{BIN_NAME}} -n "__fish_seen_subcommand_from invitation; and __fish_seen_subcommand_from inspect; and test (count (commandline -opc)) -eq 3" -xa '(__{{FUNC_NAME}}_complete_dynamic invitations)'
|
||||||
|
|
||||||
# invitation import: file completion
|
# invitation import <path>
|
||||||
|
# Re-enable default filesystem completion for path argument.
|
||||||
complete -c {{BIN_NAME}} -n "__fish_seen_subcommand_from invitation; and __fish_seen_subcommand_from import" -F
|
complete -c {{BIN_NAME}} -n "__fish_seen_subcommand_from invitation; and __fish_seen_subcommand_from import" -F
|
||||||
|
|
||||||
# template list/inspect: category first (pos 3), then template (pos 4), then field (pos 5 for inspect)
|
# template list/inspect <category> <template> [field]
|
||||||
|
# Position 3 => category, 4 => template, 5 => field (inspect only).
|
||||||
complete -c {{BIN_NAME}} -n "__fish_seen_subcommand_from template; and __fish_seen_subcommand_from list; and test (count (commandline -opc)) -eq 3" -xa 'action transaction output lockingscript variable'
|
complete -c {{BIN_NAME}} -n "__fish_seen_subcommand_from template; and __fish_seen_subcommand_from list; and test (count (commandline -opc)) -eq 3" -xa 'action transaction output lockingscript variable'
|
||||||
complete -c {{BIN_NAME}} -n "__fish_seen_subcommand_from template; and __fish_seen_subcommand_from list; and test (count (commandline -opc)) -eq 4" -xa '(__{{FUNC_NAME}}_complete_dynamic templates)'
|
complete -c {{BIN_NAME}} -n "__fish_seen_subcommand_from template; and __fish_seen_subcommand_from list; and test (count (commandline -opc)) -eq 4" -xa '(__{{FUNC_NAME}}_complete_dynamic templates)'
|
||||||
complete -c {{BIN_NAME}} -n "__fish_seen_subcommand_from template; and __fish_seen_subcommand_from inspect; and test (count (commandline -opc)) -eq 3" -xa 'action transaction output lockingscript variable'
|
complete -c {{BIN_NAME}} -n "__fish_seen_subcommand_from template; and __fish_seen_subcommand_from inspect; and test (count (commandline -opc)) -eq 3" -xa 'action transaction output lockingscript variable'
|
||||||
complete -c {{BIN_NAME}} -n "__fish_seen_subcommand_from template; and __fish_seen_subcommand_from inspect; and test (count (commandline -opc)) -eq 4" -xa '(__{{FUNC_NAME}}_complete_dynamic templates)'
|
complete -c {{BIN_NAME}} -n "__fish_seen_subcommand_from template; and __fish_seen_subcommand_from inspect; and test (count (commandline -opc)) -eq 4" -xa '(__{{FUNC_NAME}}_complete_dynamic templates)'
|
||||||
complete -c {{BIN_NAME}} -n "__fish_seen_subcommand_from template; and __fish_seen_subcommand_from inspect; and test (count (commandline -opc)) -eq 5" -xa '(__{{FUNC_NAME}}_complete_dynamic fields (commandline -opc)[4] (commandline -opc)[5])'
|
complete -c {{BIN_NAME}} -n "__fish_seen_subcommand_from template; and __fish_seen_subcommand_from inspect; and test (count (commandline -opc)) -eq 5" -xa '(__{{FUNC_NAME}}_complete_dynamic fields (commandline -opc)[4] (commandline -opc)[5])'
|
||||||
|
|
||||||
# template set-default: template first
|
# template set-default <template> <output> <role>
|
||||||
|
# Position 3 => template argument.
|
||||||
complete -c {{BIN_NAME}} -n "__fish_seen_subcommand_from template; and __fish_seen_subcommand_from set-default; and test (count (commandline -opc)) -eq 3" -xa '(__{{FUNC_NAME}}_complete_dynamic templates)'
|
complete -c {{BIN_NAME}} -n "__fish_seen_subcommand_from template; and __fish_seen_subcommand_from set-default; and test (count (commandline -opc)) -eq 3" -xa '(__{{FUNC_NAME}}_complete_dynamic templates)'
|
||||||
|
|
||||||
# resource unreserve: UTXO outpoints
|
# resource unreserve <txhash:vout>
|
||||||
|
# Position 3 => outpoint to unreserve.
|
||||||
complete -c {{BIN_NAME}} -n "__fish_seen_subcommand_from resource; and __fish_seen_subcommand_from unreserve; and test (count (commandline -opc)) -eq 3" -xa '(__{{FUNC_NAME}}_complete_dynamic resources)'
|
complete -c {{BIN_NAME}} -n "__fish_seen_subcommand_from resource; and __fish_seen_subcommand_from unreserve; and test (count (commandline -opc)) -eq 3" -xa '(__{{FUNC_NAME}}_complete_dynamic resources)'
|
||||||
|
|
||||||
# receive: template names
|
# receive <template> [output]
|
||||||
|
# Position 2 => template argument.
|
||||||
complete -c {{BIN_NAME}} -n "__fish_seen_subcommand_from receive; and test (count (commandline -opc)) -eq 2" -xa '(__{{FUNC_NAME}}_complete_dynamic templates)'
|
complete -c {{BIN_NAME}} -n "__fish_seen_subcommand_from receive; and test (count (commandline -opc)) -eq 2" -xa '(__{{FUNC_NAME}}_complete_dynamic templates)'
|
||||||
|
|||||||
@@ -1,7 +1,15 @@
|
|||||||
# zsh completion for {{BIN_NAME}}
|
# ------------------------------------------------------------------------------
|
||||||
# Add to ~/.zshrc: eval "$({{BIN_NAME}} completions zsh)"
|
# Zsh completion template for {{BIN_NAME}}
|
||||||
|
# ------------------------------------------------------------------------------
|
||||||
|
# Installation:
|
||||||
|
# eval "$({{BIN_NAME}} completions zsh)"
|
||||||
|
#
|
||||||
|
# This file is generated from a template. Placeholders (for example
|
||||||
|
# `{{MNEMONIC_SUBS}}`) are replaced with concrete command values.
|
||||||
|
# ------------------------------------------------------------------------------
|
||||||
|
|
||||||
# Find xo-complete in the same directory as xo-cli
|
# Prefer a helper on PATH; otherwise fall back to helper next to the CLI binary.
|
||||||
|
# This keeps dynamic completion functional in both installed and portable layouts.
|
||||||
__xo_complete_bin=""
|
__xo_complete_bin=""
|
||||||
if (( $+commands[xo-complete] )); then
|
if (( $+commands[xo-complete] )); then
|
||||||
__xo_complete_bin="xo-complete"
|
__xo_complete_bin="xo-complete"
|
||||||
@@ -9,32 +17,55 @@ elif (( $+commands[{{BIN_NAME}}] )); then
|
|||||||
__xo_complete_bin="${commands[{{BIN_NAME}}]:h}/xo-complete"
|
__xo_complete_bin="${commands[{{BIN_NAME}}]:h}/xo-complete"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Wrapper to call xo-complete helper
|
# @description
|
||||||
|
# Calls the dynamic helper while silencing helper stderr to avoid noisy
|
||||||
|
# completion menus if helper lookup fails.
|
||||||
|
# @param "$@" Arguments forwarded to xo-complete.
|
||||||
__xo_complete() {
|
__xo_complete() {
|
||||||
[[ -n "${__xo_complete_bin}" ]] && "${__xo_complete_bin}" "$@" 2>/dev/null
|
[[ -n "${__xo_complete_bin}" ]] && "${__xo_complete_bin}" "$@" 2>/dev/null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# @description
|
||||||
|
# Lists mnemonic aliases directly from the config directory without starting
|
||||||
|
# the dynamic Node helper.
|
||||||
|
__xo_complete_mnemonics() {
|
||||||
|
local config_dir="${XO_CONFIG_DIR:-${HOME}/.config/xo-cli}"
|
||||||
|
local file mnemonic
|
||||||
|
for file in "${config_dir}"/mnemonics/mnemonic-*(N); do
|
||||||
|
[[ -f "${file}" ]] || continue
|
||||||
|
mnemonic="${file:t}"
|
||||||
|
[[ "${mnemonic}" == "$1"* ]] && print -r -- "${mnemonic}"
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
# @description
|
||||||
|
# Main zsh completion dispatcher registered via `compdef`.
|
||||||
|
# It resolves command context from `$words`/`$CURRENT` and serves:
|
||||||
|
# - static command words via `compadd`
|
||||||
|
# - dynamic values from `xo-complete`
|
||||||
|
# - filesystem completions where file paths are expected
|
||||||
_{{FUNC_NAME}}_completions() {
|
_{{FUNC_NAME}}_completions() {
|
||||||
local -a commands
|
local -a commands
|
||||||
commands=({{COMMANDS}})
|
commands=({{COMMANDS}})
|
||||||
|
|
||||||
# Handle -m/--mnemonic-file argument (previous word was -m)
|
# If previous token is `-m/--mnemonic-file`, complete mnemonic sources.
|
||||||
if [[ "${words[CURRENT-1]}" == "-m" || "${words[CURRENT-1]}" == "--mnemonic-file" ]]; then
|
if [[ "${words[CURRENT-1]}" == "-m" || "${words[CURRENT-1]}" == "--mnemonic-file" ]]; then
|
||||||
local mnemonics
|
local mnemonics
|
||||||
mnemonics=("${(@f)$(__xo_complete mnemonics "${words[CURRENT]}")}")
|
mnemonics=("${(@f)$(__xo_complete_mnemonics "${words[CURRENT]}")}")
|
||||||
if [[ ${#mnemonics[@]} -gt 0 ]]; then
|
if [[ ${#mnemonics[@]} -gt 0 ]]; then
|
||||||
compadd -- "${mnemonics[@]}"
|
compadd -- "${mnemonics[@]}"
|
||||||
return
|
return
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# If typing an option flag, complete options
|
# Option context: if current token starts with `-`, complete known options.
|
||||||
if [[ "${words[${CURRENT}]}" == -* ]]; then
|
if [[ "${words[${CURRENT}]}" == -* ]]; then
|
||||||
compadd -- {{OPTIONS}}
|
compadd -- {{OPTIONS}}
|
||||||
return
|
return
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Find the command and subcommand
|
# Find first and second non-option tokens before the cursor.
|
||||||
|
# `cmd_idx` and `subcmd_idx` are used for positional argument calculations.
|
||||||
local cmd="" subcmd="" cmd_idx=0 subcmd_idx=0
|
local cmd="" subcmd="" cmd_idx=0 subcmd_idx=0
|
||||||
for ((i=2; i < CURRENT; i++)); do
|
for ((i=2; i < CURRENT; i++)); do
|
||||||
if [[ "${words[i]}" != -* ]]; then
|
if [[ "${words[i]}" != -* ]]; then
|
||||||
@@ -49,13 +80,13 @@ _{{FUNC_NAME}}_completions() {
|
|||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
|
|
||||||
# No command yet — offer top-level commands
|
# No command token yet: offer top-level commands.
|
||||||
if [[ -z "${cmd}" ]]; then
|
if [[ -z "${cmd}" ]]; then
|
||||||
compadd -- ${commands[@]}
|
compadd -- ${commands[@]}
|
||||||
return
|
return
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Handle each command's completion
|
# Command-specific completion behavior.
|
||||||
case "${cmd}" in
|
case "${cmd}" in
|
||||||
mnemonic)
|
mnemonic)
|
||||||
if [[ -z "${subcmd}" ]]; then
|
if [[ -z "${subcmd}" ]]; then
|
||||||
@@ -67,7 +98,9 @@ _{{FUNC_NAME}}_completions() {
|
|||||||
if [[ -z "${subcmd}" ]]; then
|
if [[ -z "${subcmd}" ]]; then
|
||||||
compadd -- {{TEMPLATE_SUBS}}
|
compadd -- {{TEMPLATE_SUBS}}
|
||||||
elif [[ "${subcmd}" == "list" || "${subcmd}" == "inspect" ]]; then
|
elif [[ "${subcmd}" == "list" || "${subcmd}" == "inspect" ]]; then
|
||||||
# template list/inspect <category> <template> [field] - category first, then template, then field
|
# template list/inspect <category> <template> [field]
|
||||||
|
# Relative positions from subcommand:
|
||||||
|
# 1 => category, 2 => template, 3 => field (inspect only)
|
||||||
local pos=$((CURRENT - subcmd_idx))
|
local pos=$((CURRENT - subcmd_idx))
|
||||||
if [[ $pos -eq 1 ]]; then
|
if [[ $pos -eq 1 ]]; then
|
||||||
compadd -- action transaction output lockingscript variable
|
compadd -- action transaction output lockingscript variable
|
||||||
@@ -78,7 +111,7 @@ _{{FUNC_NAME}}_completions() {
|
|||||||
compadd -- "${templates[@]}"
|
compadd -- "${templates[@]}"
|
||||||
fi
|
fi
|
||||||
elif [[ $pos -eq 3 && "${subcmd}" == "inspect" ]]; then
|
elif [[ $pos -eq 3 && "${subcmd}" == "inspect" ]]; then
|
||||||
# Get the category and template from previous args
|
# Field suggestions depend on selected category and template.
|
||||||
local category="${words[subcmd_idx + 1]}"
|
local category="${words[subcmd_idx + 1]}"
|
||||||
local template_arg="${words[subcmd_idx + 2]}"
|
local template_arg="${words[subcmd_idx + 2]}"
|
||||||
local fields
|
local fields
|
||||||
@@ -88,7 +121,8 @@ _{{FUNC_NAME}}_completions() {
|
|||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
elif [[ "${subcmd}" == "set-default" ]]; then
|
elif [[ "${subcmd}" == "set-default" ]]; then
|
||||||
# template set-default <template> <output> <role> - template first
|
# template set-default <template> <output> <role>
|
||||||
|
# First positional argument is template name.
|
||||||
local pos=$((CURRENT - subcmd_idx))
|
local pos=$((CURRENT - subcmd_idx))
|
||||||
if [[ $pos -eq 1 ]]; then
|
if [[ $pos -eq 1 ]]; then
|
||||||
local templates
|
local templates
|
||||||
@@ -106,6 +140,8 @@ _{{FUNC_NAME}}_completions() {
|
|||||||
else
|
else
|
||||||
case "${subcmd}" in
|
case "${subcmd}" in
|
||||||
create)
|
create)
|
||||||
|
# invitation create <template> <action>
|
||||||
|
# Action list is template-specific.
|
||||||
local pos=$((CURRENT - subcmd_idx))
|
local pos=$((CURRENT - subcmd_idx))
|
||||||
if [[ $pos -eq 1 ]]; then
|
if [[ $pos -eq 1 ]]; then
|
||||||
local templates
|
local templates
|
||||||
@@ -122,7 +158,8 @@ _{{FUNC_NAME}}_completions() {
|
|||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
;;
|
;;
|
||||||
append|sign|broadcast|requirements|inspect)
|
append|sign|broadcast|requirements|export|inspect)
|
||||||
|
# These subcommands take invitation ID as first argument.
|
||||||
local pos=$((CURRENT - subcmd_idx))
|
local pos=$((CURRENT - subcmd_idx))
|
||||||
if [[ $pos -eq 1 ]]; then
|
if [[ $pos -eq 1 ]]; then
|
||||||
local invitations
|
local invitations
|
||||||
@@ -133,6 +170,7 @@ _{{FUNC_NAME}}_completions() {
|
|||||||
fi
|
fi
|
||||||
;;
|
;;
|
||||||
import)
|
import)
|
||||||
|
# invitation import <path>: delegate to zsh file completion.
|
||||||
_files
|
_files
|
||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
@@ -143,6 +181,7 @@ _{{FUNC_NAME}}_completions() {
|
|||||||
if [[ -z "${subcmd}" ]]; then
|
if [[ -z "${subcmd}" ]]; then
|
||||||
compadd -- {{RESOURCE_SUBS}}
|
compadd -- {{RESOURCE_SUBS}}
|
||||||
elif [[ "${subcmd}" == "unreserve" ]]; then
|
elif [[ "${subcmd}" == "unreserve" ]]; then
|
||||||
|
# resource unreserve <txhash:vout>
|
||||||
local pos=$((CURRENT - subcmd_idx))
|
local pos=$((CURRENT - subcmd_idx))
|
||||||
if [[ $pos -eq 1 ]]; then
|
if [[ $pos -eq 1 ]]; then
|
||||||
local resources
|
local resources
|
||||||
@@ -154,7 +193,19 @@ _{{FUNC_NAME}}_completions() {
|
|||||||
fi
|
fi
|
||||||
;;
|
;;
|
||||||
|
|
||||||
|
settings)
|
||||||
|
if [[ -z "${subcmd}" ]]; then
|
||||||
|
compadd -- show get set
|
||||||
|
elif [[ "${subcmd}" == "get" || "${subcmd}" == "set" ]]; then
|
||||||
|
local pos=$((CURRENT - subcmd_idx))
|
||||||
|
if [[ $pos -eq 1 ]]; then
|
||||||
|
compadd -- currency default-mnemonic
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
|
||||||
receive)
|
receive)
|
||||||
|
# receive <template> [output]
|
||||||
local pos=$((CURRENT - cmd_idx))
|
local pos=$((CURRENT - cmd_idx))
|
||||||
if [[ $pos -eq 1 ]]; then
|
if [[ $pos -eq 1 ]]; then
|
||||||
local templates
|
local templates
|
||||||
@@ -166,6 +217,7 @@ _{{FUNC_NAME}}_completions() {
|
|||||||
;;
|
;;
|
||||||
|
|
||||||
completions)
|
completions)
|
||||||
|
# Shell target for completion generation.
|
||||||
if [[ -z "${subcmd}" ]]; then
|
if [[ -z "${subcmd}" ]]; then
|
||||||
compadd -- bash zsh fish
|
compadd -- bash zsh fish
|
||||||
fi
|
fi
|
||||||
@@ -173,4 +225,5 @@ _{{FUNC_NAME}}_completions() {
|
|||||||
esac
|
esac
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Register completion function for the executable name.
|
||||||
compdef _{{FUNC_NAME}}_completions {{BIN_NAME}}
|
compdef _{{FUNC_NAME}}_completions {{BIN_NAME}}
|
||||||
|
|||||||
@@ -1,44 +0,0 @@
|
|||||||
import util from "node:util";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Text formatting utilities for the CLI.
|
|
||||||
*
|
|
||||||
* Uses ANSI escape codes to format text.
|
|
||||||
*
|
|
||||||
* AI Generated links:
|
|
||||||
* @see https://en.wikipedia.org/wiki/ANSI_escape_code
|
|
||||||
* @see https://en.wikipedia.org/wiki/ANSI_escape_code#SGR_(Select_Graphic_Rendition)_parameters
|
|
||||||
* @see https://en.wikipedia.org/wiki/ANSI_escape_code#Colors
|
|
||||||
* @see https://en.wikipedia.org/wiki/ANSI_escape_code#Formatting
|
|
||||||
* @see https://en.wikipedia.org/wiki/ANSI_escape_code#Cursor_movement
|
|
||||||
* @see https://en.wikipedia.org/wiki/ANSI_escape_code#Screen_manipulation
|
|
||||||
*/
|
|
||||||
|
|
||||||
const BOLD = "\x1b[1m";
|
|
||||||
export const bold = (text: string) => `${BOLD}${text}${RESET}`;
|
|
||||||
|
|
||||||
const DIM = "\x1b[2m";
|
|
||||||
export const dim = (text: string) => `${DIM}${text}${RESET}`;
|
|
||||||
|
|
||||||
const UNDERLINE = "\x1b[4m";
|
|
||||||
export const underline = (text: string) => `${UNDERLINE}${text}${RESET}`;
|
|
||||||
|
|
||||||
const INVERSE = "\x1b[7m";
|
|
||||||
export const inverse = (text: string) => `${INVERSE}${text}${RESET}`;
|
|
||||||
|
|
||||||
const HIDDEN = "\x1b[8m";
|
|
||||||
export const hidden = (text: string) => `${HIDDEN}${text}${RESET}`;
|
|
||||||
|
|
||||||
const STRIKETHROUGH = "\x1b[9m";
|
|
||||||
export const strikethrough = (text: string) => `${STRIKETHROUGH}${text}${RESET}`;
|
|
||||||
|
|
||||||
const RESET = "\x1b[0m";
|
|
||||||
export const reset = (text: string) => `${RESET}${text}${RESET}`;
|
|
||||||
|
|
||||||
export const formatObject = (obj: unknown) => {
|
|
||||||
return util.inspect(obj, {
|
|
||||||
depth: null,
|
|
||||||
colors: true,
|
|
||||||
compact: false
|
|
||||||
});
|
|
||||||
};
|
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
export { handleMnemonicCommand, printMnemonicHelp } from "./mnemonic.js";
|
export { handleMnemonicCommand, printMnemonicHelp } from "./mnemonic.js";
|
||||||
|
export { handleSettingsCommand, printSettingsHelp } from "./settings.js";
|
||||||
export { handleTemplateCommand, printTemplateHelp } from "./template.js";
|
export { handleTemplateCommand, printTemplateHelp } from "./template.js";
|
||||||
export { handleInvitationCommand, printInvitationHelp } from "./invitation.js";
|
export { handleInvitationCommand, printInvitationHelp } from "./invitation.js";
|
||||||
export { handleReceiveCommand, printReceiveHelp } from "./receive.js";
|
export { handleReceiveCommand, printReceiveHelp } from "./receive.js";
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,10 @@
|
|||||||
import { bold, dim } from "../cli-utils.js";
|
import { bold, dim } from "../utils.js";
|
||||||
import { listMnemonicFiles, createMnemonicFile, createMnemonicSeed, loadMnemonic } from "../mnemonic.js";
|
import {
|
||||||
|
listMnemonicFiles,
|
||||||
|
createMnemonicFile,
|
||||||
|
createMnemonicSeed,
|
||||||
|
loadMnemonic,
|
||||||
|
} from "../mnemonic.js";
|
||||||
import type { BaseCommandDependencies, CommandIO } from "./types.js";
|
import type { BaseCommandDependencies, CommandIO } from "./types.js";
|
||||||
import { CommandError } from "./types.js";
|
import { CommandError } from "./types.js";
|
||||||
|
|
||||||
@@ -8,17 +13,20 @@ import { CommandError } from "./types.js";
|
|||||||
*/
|
*/
|
||||||
export const printMnemonicHelp = (io: CommandIO): void => {
|
export const printMnemonicHelp = (io: CommandIO): void => {
|
||||||
io.out(
|
io.out(
|
||||||
`
|
`
|
||||||
${bold("Usage:")} xo-cli mnemonic <sub-command>
|
${bold("Usage:")} xo-cli mnemonic <sub-command>
|
||||||
|
|
||||||
${bold("Sub-commands:")}
|
${bold("Sub-commands:")}
|
||||||
- create <mnemonic-seed> ${dim("Create a new mnemonic file")}
|
- create <mnemonic-seed> ${dim("Create a new mnemonic file")}
|
||||||
- list ${dim("List all mnemonic files")}
|
- list ${dim("List all mnemonic files")}
|
||||||
|
- import <mnemonic-seed> ${dim("Import a mnemonic seed from a file")}
|
||||||
|
- expose <mnemonic-file> ${dim("Expose a mnemonic file")}
|
||||||
|
|
||||||
${bold("Options:")}
|
${bold("Options:")}
|
||||||
-o --output <output-filename> ${dim("Output filename for the mnemonic file")}
|
-o --output <output-filename> ${dim("Output filename for the mnemonic file")}
|
||||||
-h --help ${dim("Show this help message")}
|
-h --help ${dim("Show this help message")}
|
||||||
`);
|
`,
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -33,59 +41,97 @@ export const handleMnemonicCommand = async (
|
|||||||
args: string[],
|
args: string[],
|
||||||
options: Record<string, string>,
|
options: Record<string, string>,
|
||||||
): Promise<{ savedAs?: string; count?: number; mnemonic?: string }> => {
|
): Promise<{ savedAs?: string; count?: number; mnemonic?: string }> => {
|
||||||
|
// Get the sub-command from the arguments
|
||||||
const subCommand = args[0];
|
const subCommand = args[0];
|
||||||
const { mnemonicsDir } = deps.paths;
|
const { mnemonicsDir } = deps.paths;
|
||||||
|
|
||||||
|
// If no sub-command is provided, print the help message and throw an error
|
||||||
if (!subCommand) {
|
if (!subCommand) {
|
||||||
deps.io.verbose("No sub-command provided");
|
deps.io.verbose("No sub-command provided");
|
||||||
printMnemonicHelp(deps.io);
|
printMnemonicHelp(deps.io);
|
||||||
throw new CommandError("mnemonic.subcommand.missing", "No sub-command provided");
|
throw new CommandError(
|
||||||
|
"mnemonic.subcommand.missing",
|
||||||
|
"No sub-command provided",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Handle the sub-command
|
||||||
switch (subCommand) {
|
switch (subCommand) {
|
||||||
case "create": {
|
case "create": {
|
||||||
|
// Create a new mnemonic seed
|
||||||
const mnemonicSeed = createMnemonicSeed();
|
const mnemonicSeed = createMnemonicSeed();
|
||||||
const savedAs = createMnemonicFile(mnemonicsDir, mnemonicSeed, options["output"]);
|
|
||||||
|
|
||||||
|
// Create a new mnemonic file
|
||||||
|
const savedAs = createMnemonicFile(
|
||||||
|
mnemonicsDir,
|
||||||
|
mnemonicSeed,
|
||||||
|
options["output"],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Display the mnemonic file to the user
|
||||||
deps.io.out(`Mnemonic file created: ${savedAs} (${mnemonicSeed})`);
|
deps.io.out(`Mnemonic file created: ${savedAs} (${mnemonicSeed})`);
|
||||||
return { savedAs };
|
return { savedAs };
|
||||||
}
|
}
|
||||||
|
|
||||||
case "import": {
|
case "import": {
|
||||||
|
// Get the mnemonic seed from the arguments
|
||||||
const mnemonicSeed = args.slice(1).join(" ");
|
const mnemonicSeed = args.slice(1).join(" ");
|
||||||
|
|
||||||
|
// If no mnemonic seed is provided, print the help message and throw an error
|
||||||
if (!mnemonicSeed) {
|
if (!mnemonicSeed) {
|
||||||
deps.io.verbose("No mnemonic seed provided");
|
deps.io.verbose("No mnemonic seed provided");
|
||||||
printMnemonicHelp(deps.io);
|
printMnemonicHelp(deps.io);
|
||||||
throw new CommandError("mnemonic.import.seed_missing", "No mnemonic seed provided");
|
throw new CommandError(
|
||||||
|
"mnemonic.import.seed_missing",
|
||||||
|
"No mnemonic seed provided",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Create a new mnemonic file
|
||||||
deps.io.verbose(`Mnemonic seed: ${mnemonicSeed}`);
|
deps.io.verbose(`Mnemonic seed: ${mnemonicSeed}`);
|
||||||
const savedAs = createMnemonicFile(mnemonicsDir, mnemonicSeed, options["output"]);
|
const savedAs = createMnemonicFile(
|
||||||
|
mnemonicsDir,
|
||||||
|
mnemonicSeed,
|
||||||
|
options["output"],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Display the mnemonic file to the user
|
||||||
deps.io.out(`Mnemonic file created: ${savedAs}`);
|
deps.io.out(`Mnemonic file created: ${savedAs}`);
|
||||||
return { savedAs };
|
return { savedAs };
|
||||||
}
|
}
|
||||||
|
|
||||||
case "list": {
|
case "list": {
|
||||||
|
// List all the mnemonic files
|
||||||
const mnemonicFiles = listMnemonicFiles(mnemonicsDir);
|
const mnemonicFiles = listMnemonicFiles(mnemonicsDir);
|
||||||
deps.io.out(mnemonicFiles.join('\n'));
|
deps.io.out(mnemonicFiles.join("\n"));
|
||||||
|
|
||||||
|
// Return the number of mnemonic files
|
||||||
return { count: mnemonicFiles.length };
|
return { count: mnemonicFiles.length };
|
||||||
}
|
}
|
||||||
|
|
||||||
case "expose": {
|
case "expose": {
|
||||||
|
// Get the mnemonic file from the arguments
|
||||||
const mnemonicFile = args[1];
|
const mnemonicFile = args[1];
|
||||||
|
|
||||||
|
// If no mnemonic file is provided, print the help message and throw an error
|
||||||
if (!mnemonicFile) {
|
if (!mnemonicFile) {
|
||||||
deps.io.verbose("No mnemonic file provided");
|
deps.io.verbose("No mnemonic file provided");
|
||||||
printMnemonicHelp(deps.io);
|
printMnemonicHelp(deps.io);
|
||||||
throw new CommandError("mnemonic.expose.file_missing", "No mnemonic file provided");
|
throw new CommandError(
|
||||||
|
"mnemonic.expose.file_missing",
|
||||||
|
"No mnemonic file provided",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Try to load the mnemonic file
|
||||||
try {
|
try {
|
||||||
const mnemonic = loadMnemonic(mnemonicsDir, mnemonicFile);
|
const mnemonic = loadMnemonic(mnemonicsDir, mnemonicFile);
|
||||||
deps.io.out(mnemonic);
|
deps.io.out(mnemonic);
|
||||||
|
|
||||||
|
// Return the mnemonic
|
||||||
return { mnemonic };
|
return { mnemonic };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
// If the mnemonic file is not found, print an error and throw an error
|
||||||
throw new CommandError(
|
throw new CommandError(
|
||||||
"mnemonic.expose.file_not_found",
|
"mnemonic.expose.file_not_found",
|
||||||
`Mnemonic file not found: ${mnemonicFile}`,
|
`Mnemonic file not found: ${mnemonicFile}`,
|
||||||
@@ -94,8 +140,12 @@ export const handleMnemonicCommand = async (
|
|||||||
}
|
}
|
||||||
|
|
||||||
default:
|
default:
|
||||||
|
// If the sub-command is not found, print an error and throw an error
|
||||||
deps.io.err(`Unknown sub-command: ${subCommand}`);
|
deps.io.err(`Unknown sub-command: ${subCommand}`);
|
||||||
printMnemonicHelp(deps.io);
|
printMnemonicHelp(deps.io);
|
||||||
throw new CommandError("mnemonic.subcommand.unknown", `Unknown sub-command: ${subCommand}`);
|
throw new CommandError(
|
||||||
|
"mnemonic.subcommand.unknown",
|
||||||
|
`Unknown sub-command: ${subCommand}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { generateTemplateIdentifier } from "@xo-cash/engine";
|
import { generateTemplateIdentifier } from "@xo-cash/engine";
|
||||||
import { hexToBin, lockingBytecodeToCashAddress } from "@bitauth/libauth";
|
import { hexToBin, lockingBytecodeToCashAddress } from "@bitauth/libauth";
|
||||||
|
|
||||||
import { bold, dim } from "../cli-utils.js";
|
import { bold, dim } from "../utils.js";
|
||||||
import type { CommandDependencies, CommandIO } from "./types.js";
|
import type { CommandDependencies, CommandIO } from "./types.js";
|
||||||
import { CommandError } from "./types.js";
|
import { CommandError } from "./types.js";
|
||||||
|
|
||||||
@@ -12,7 +12,7 @@ import { resolveTemplate } from "../utils.js";
|
|||||||
*/
|
*/
|
||||||
export const printReceiveHelp = (io: CommandIO): void => {
|
export const printReceiveHelp = (io: CommandIO): void => {
|
||||||
io.out(
|
io.out(
|
||||||
`
|
`
|
||||||
${bold("Usage:")} xo-cli receive <template-file> <output-identifier> [role-identifier]
|
${bold("Usage:")} xo-cli receive <template-file> <output-identifier> [role-identifier]
|
||||||
|
|
||||||
${bold("Description:")}
|
${bold("Description:")}
|
||||||
@@ -25,7 +25,8 @@ ${bold("Arguments:")}
|
|||||||
|
|
||||||
${bold("Options:")}
|
${bold("Options:")}
|
||||||
-h --help ${dim("Show this help message")}
|
-h --help ${dim("Show this help message")}
|
||||||
`);
|
`,
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -43,16 +44,24 @@ export const handleReceiveCommand = async (
|
|||||||
args: string[],
|
args: string[],
|
||||||
_options: Record<string, string>,
|
_options: Record<string, string>,
|
||||||
): Promise<{ address: string }> => {
|
): Promise<{ address: string }> => {
|
||||||
|
// Get the template query, output identifier, and role identifier from the arguments
|
||||||
const templateQuery = args[0];
|
const templateQuery = args[0];
|
||||||
const outputIdentifier = args[1];
|
const outputIdentifier = args[1];
|
||||||
const roleIdentifier = args[2];
|
const roleIdentifier = args[2];
|
||||||
|
|
||||||
deps.io.verbose(`Receive args - template: ${templateQuery}, output: ${outputIdentifier}, role: ${roleIdentifier}`);
|
// Log the receive args
|
||||||
|
deps.io.verbose(
|
||||||
|
`Receive args - template: ${templateQuery}, output: ${outputIdentifier}, role: ${roleIdentifier}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// If no template query or output identifier is provided, print the help message and throw an error
|
||||||
if (!templateQuery || !outputIdentifier) {
|
if (!templateQuery || !outputIdentifier) {
|
||||||
deps.io.verbose("Missing required arguments");
|
deps.io.verbose("Missing required arguments");
|
||||||
printReceiveHelp(deps.io);
|
printReceiveHelp(deps.io);
|
||||||
throw new CommandError("receive.arguments.missing", "Missing required arguments");
|
throw new CommandError(
|
||||||
|
"receive.arguments.missing",
|
||||||
|
"Missing required arguments",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resolve and read the template file
|
// Resolve and read the template file
|
||||||
@@ -69,11 +78,17 @@ export const handleReceiveCommand = async (
|
|||||||
deps.io.verbose(`Locking bytecode hex: ${lockingBytecodeHex}`);
|
deps.io.verbose(`Locking bytecode hex: ${lockingBytecodeHex}`);
|
||||||
|
|
||||||
// Convert the locking bytecode to a BCH cash address
|
// Convert the locking bytecode to a BCH cash address
|
||||||
const result = lockingBytecodeToCashAddress({ bytecode: hexToBin(lockingBytecodeHex), prefix: 'bitcoincash' });
|
const result = lockingBytecodeToCashAddress({
|
||||||
|
bytecode: hexToBin(lockingBytecodeHex),
|
||||||
|
prefix: "bitcoincash",
|
||||||
|
});
|
||||||
|
|
||||||
if (typeof result === 'string') {
|
if (typeof result === "string") {
|
||||||
deps.io.err(`Failed to encode address: ${result}`);
|
deps.io.err(`Failed to encode address: ${result}`);
|
||||||
throw new CommandError("receive.address.encode_failed", `Failed to encode address: ${result}`);
|
throw new CommandError(
|
||||||
|
"receive.address.encode_failed",
|
||||||
|
`Failed to encode address: ${result}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
deps.io.out(result.address);
|
deps.io.out(result.address);
|
||||||
|
|||||||
@@ -1,16 +1,22 @@
|
|||||||
import { hexToBin } from "@bitauth/libauth";
|
import { hexToBin } from "@bitauth/libauth";
|
||||||
|
|
||||||
import { bold, dim } from "../cli-utils.js";
|
import { bold, dim } from "../utils.js";
|
||||||
import type { CommandDependencies, CommandIO } from "./types.js";
|
import type { CommandDependencies, CommandIO } from "./types.js";
|
||||||
import type { UnspentOutputData } from "@xo-cash/state";
|
|
||||||
import { CommandError } from "./types.js";
|
import { CommandError } from "./types.js";
|
||||||
|
import type { XOTemplate } from "@xo-cash/types";
|
||||||
|
import { generateTemplateIdentifier } from "@xo-cash/engine";
|
||||||
|
import {
|
||||||
|
buildScriptHashDataMap,
|
||||||
|
enrichUnspentOutput,
|
||||||
|
type UnspentOutputWithMetadata,
|
||||||
|
} from "../../utils/utxo-metadata.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Prints the help message for the resource command.
|
* Prints the help message for the resource command.
|
||||||
*/
|
*/
|
||||||
export const printResourceHelp = (io: CommandIO): void => {
|
export const printResourceHelp = (io: CommandIO): void => {
|
||||||
io.out(
|
io.out(
|
||||||
`
|
`
|
||||||
${bold("Usage:")} xo-cli resource <sub-command>
|
${bold("Usage:")} xo-cli resource <sub-command>
|
||||||
|
|
||||||
${bold("Sub-commands:")}
|
${bold("Sub-commands:")}
|
||||||
@@ -19,24 +25,46 @@ ${bold("Sub-commands:")}
|
|||||||
- list all ${dim("List all resources (reserved + unreserved)")}
|
- list all ${dim("List all resources (reserved + unreserved)")}
|
||||||
- unreserve <txhash:vout> ${dim("Unreserve a specific UTXO")}
|
- unreserve <txhash:vout> ${dim("Unreserve a specific UTXO")}
|
||||||
- unreserve-all ${dim("Unreserve all reserved UTXOs")}
|
- unreserve-all ${dim("Unreserve all reserved UTXOs")}
|
||||||
`);
|
`,
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Formats a single UTXO for display, optionally including reservation info.
|
* Formats a single UTXO for display, optionally including reservation info.
|
||||||
*/
|
*/
|
||||||
function formatResource(resource: UnspentOutputData, showReserved = false): string {
|
function formatResource(
|
||||||
const outpoint = bold(`${resource.outpointTransactionHash}:${resource.outpointIndex}`);
|
resource: UnspentOutputWithMetadata & { template?: XOTemplate },
|
||||||
|
showReserved = false,
|
||||||
|
): string {
|
||||||
|
// Format the template
|
||||||
|
const template = resource.template
|
||||||
|
? dim(`[${generateTemplateIdentifier(resource.template)}]`)
|
||||||
|
: "";
|
||||||
|
|
||||||
|
// Format the outpoint
|
||||||
|
const outpoint = bold(
|
||||||
|
`${resource.outpointTransactionHash}:${resource.outpointIndex}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Format the value
|
||||||
const value = dim(`${resource.valueSatoshis} sats`);
|
const value = dim(`${resource.valueSatoshis} sats`);
|
||||||
const output = dim(resource.outputIdentifier);
|
|
||||||
|
// Format the output
|
||||||
|
const output = resource.outputIdentifier
|
||||||
|
? dim(resource.outputIdentifier)
|
||||||
|
: "";
|
||||||
|
|
||||||
|
// Format the height
|
||||||
const height = dim(`(height ${resource.minedAtHeight})`);
|
const height = dim(`(height ${resource.minedAtHeight})`);
|
||||||
|
|
||||||
|
// If the resource is reserved, format the reservation info
|
||||||
if (showReserved && resource.reservedBy) {
|
if (showReserved && resource.reservedBy) {
|
||||||
const inv = dim(`reserved for ${resource.reservedBy}`);
|
const inv = dim(`reserved for ${resource.reservedBy}`);
|
||||||
return `${outpoint} ${value} ${output} ${height} ${inv}`;
|
return `${template} ${outpoint} ${value} ${output} ${height} ${inv}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
return `${outpoint} ${value} ${output} ${height}`;
|
// Otherwise, format the resource without reservation info
|
||||||
|
return `${template} ${outpoint} ${value} ${output} ${height}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -54,97 +82,187 @@ export const handleResourceCommand = async (
|
|||||||
const subCommand = args[0];
|
const subCommand = args[0];
|
||||||
deps.io.verbose(`Resource sub-command: ${subCommand}`);
|
deps.io.verbose(`Resource sub-command: ${subCommand}`);
|
||||||
|
|
||||||
|
// If no sub-command is provided, print the help message and throw an error
|
||||||
if (!subCommand) {
|
if (!subCommand) {
|
||||||
deps.io.verbose("No sub-command provided");
|
deps.io.verbose("No sub-command provided");
|
||||||
printResourceHelp(deps.io);
|
printResourceHelp(deps.io);
|
||||||
throw new CommandError("resource.subcommand.missing", "No sub-command provided");
|
throw new CommandError(
|
||||||
|
"resource.subcommand.missing",
|
||||||
|
"No sub-command provided",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Handle the sub-command
|
||||||
switch (subCommand) {
|
switch (subCommand) {
|
||||||
case "list": {
|
case "list": {
|
||||||
|
// Get the qualifier from the arguments - This could be "reserved", "all", or omitted (which defaults to "unreserved")
|
||||||
const qualifier = args[1];
|
const qualifier = args[1];
|
||||||
|
|
||||||
|
// List all the unspent outputs data
|
||||||
const allResources = await deps.app.engine.listUnspentOutputsData();
|
const allResources = await deps.app.engine.listUnspentOutputsData();
|
||||||
|
|
||||||
let filtered;
|
let filtered;
|
||||||
|
// If the qualifier is "reserved", return only the reserved resources
|
||||||
if (qualifier === "reserved") {
|
if (qualifier === "reserved") {
|
||||||
filtered = allResources.filter((r) => r.reservedBy);
|
filtered = allResources.filter((r) => r.reservedBy);
|
||||||
} else if (qualifier === "all") {
|
}
|
||||||
|
// If the qualifier is "all", return all the resources
|
||||||
|
else if (qualifier === "all") {
|
||||||
filtered = allResources;
|
filtered = allResources;
|
||||||
} else {
|
}
|
||||||
|
// If the qualifier is not "reserved" or "all", return only the unreserved resources
|
||||||
|
else {
|
||||||
filtered = allResources.filter((r) => !r.reservedBy);
|
filtered = allResources.filter((r) => !r.reservedBy);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If no resources are found, print a message and return 0
|
||||||
if (filtered.length === 0) {
|
if (filtered.length === 0) {
|
||||||
deps.io.out(dim("No resources found."));
|
deps.io.out(dim("No resources found."));
|
||||||
return { count: 0 };
|
return { count: 0 };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const scriptHashDataByScriptHash = await buildScriptHashDataMap(
|
||||||
|
deps.app.engine,
|
||||||
|
);
|
||||||
|
|
||||||
|
const resourcesWithTemplateInformation = await Promise.all(
|
||||||
|
filtered.map(async (resource) => {
|
||||||
|
const enriched = enrichUnspentOutput(
|
||||||
|
resource,
|
||||||
|
scriptHashDataByScriptHash,
|
||||||
|
);
|
||||||
|
const template = enriched.templateIdentifier
|
||||||
|
? await deps.app.engine.getTemplate(enriched.templateIdentifier)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...enriched,
|
||||||
|
template,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Format the resources into a list of strings that we can display to the user
|
||||||
const showReserved = qualifier === "all" || qualifier === "reserved";
|
const showReserved = qualifier === "all" || qualifier === "reserved";
|
||||||
const formattedResources = filtered.map((r) => formatResource(r, showReserved));
|
const formattedResources = resourcesWithTemplateInformation.map((r) =>
|
||||||
|
formatResource(r, showReserved),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Display the resources to the user
|
||||||
deps.io.out(formattedResources.join("\n"));
|
deps.io.out(formattedResources.join("\n"));
|
||||||
deps.io.out(`Total satoshis: ${filtered.reduce((acc, r) => acc + r.valueSatoshis, 0)}`);
|
|
||||||
|
// Display the total satoshis
|
||||||
|
deps.io.out(
|
||||||
|
`Total satoshis: ${filtered.reduce((acc, r) => acc + r.valueSatoshis, 0)}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Display the total resources
|
||||||
deps.io.out(`Total resources: ${filtered.length}`);
|
deps.io.out(`Total resources: ${filtered.length}`);
|
||||||
return { count: filtered.length };
|
return { count: filtered.length };
|
||||||
}
|
}
|
||||||
|
|
||||||
case "unreserve": {
|
case "unreserve": {
|
||||||
|
// Get the outpoint from the arguments
|
||||||
const outpointArg = args[1];
|
const outpointArg = args[1];
|
||||||
|
|
||||||
|
// If no outpoint is provided, print a message and throw an error
|
||||||
if (!outpointArg) {
|
if (!outpointArg) {
|
||||||
deps.io.err("Please provide a UTXO in <txhash>:<vout> format.");
|
deps.io.err("Please provide a UTXO in <txhash>:<vout> format.");
|
||||||
printResourceHelp(deps.io);
|
printResourceHelp(deps.io);
|
||||||
throw new CommandError("resource.unreserve.outpoint_missing", "Please provide a UTXO in <txhash>:<vout> format.");
|
throw new CommandError(
|
||||||
|
"resource.unreserve.outpoint_missing",
|
||||||
|
"Please provide a UTXO in <txhash>:<vout> format.",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Get the separator index
|
||||||
const separatorIndex = outpointArg.lastIndexOf(":");
|
const separatorIndex = outpointArg.lastIndexOf(":");
|
||||||
if (separatorIndex === -1) {
|
if (separatorIndex === -1) {
|
||||||
deps.io.err(`Invalid format "${outpointArg}". Expected <txhash>:<vout>.`);
|
// If the separator index is -1 (not found), print a message and throw an error
|
||||||
throw new CommandError("resource.unreserve.outpoint_invalid", `Invalid format "${outpointArg}". Expected <txhash>:<vout>.`);
|
deps.io.err(
|
||||||
|
`Invalid format "${outpointArg}". Expected <txhash>:<vout>.`,
|
||||||
|
);
|
||||||
|
throw new CommandError(
|
||||||
|
"resource.unreserve.outpoint_invalid",
|
||||||
|
`Invalid format "${outpointArg}". Expected <txhash>:<vout>.`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Get the tx hash and vout
|
||||||
const txHash = outpointArg.substring(0, separatorIndex);
|
const txHash = outpointArg.substring(0, separatorIndex);
|
||||||
const vout = parseInt(outpointArg.substring(separatorIndex + 1), 10);
|
const vout = parseInt(outpointArg.substring(separatorIndex + 1), 10);
|
||||||
|
|
||||||
|
// If the tx hash or vout is not a string or isNaN, print a message and throw an error
|
||||||
if (!txHash || isNaN(vout)) {
|
if (!txHash || isNaN(vout)) {
|
||||||
deps.io.err(`Invalid format "${outpointArg}". Expected <txhash>:<vout>.`);
|
deps.io.err(
|
||||||
throw new CommandError("resource.unreserve.outpoint_invalid", `Invalid format "${outpointArg}". Expected <txhash>:<vout>.`);
|
`Invalid format "${outpointArg}". Expected <txhash>:<vout>.`,
|
||||||
|
);
|
||||||
|
throw new CommandError(
|
||||||
|
"resource.unreserve.outpoint_invalid",
|
||||||
|
`Invalid format "${outpointArg}". Expected <txhash>:<vout>.`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Gather all of our resources
|
||||||
const allResources = await deps.app.engine.listUnspentOutputsData();
|
const allResources = await deps.app.engine.listUnspentOutputsData();
|
||||||
|
|
||||||
|
// Find the target resource
|
||||||
const target = allResources.find(
|
const target = allResources.find(
|
||||||
(r) => r.outpointTransactionHash === txHash && r.outpointIndex === vout,
|
(r) => r.outpointTransactionHash === txHash && r.outpointIndex === vout,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// If the target resource is not found, print a message and throw an error
|
||||||
if (!target) {
|
if (!target) {
|
||||||
deps.io.err(`UTXO not found: ${txHash}:${vout}`);
|
deps.io.err(`UTXO not found: ${txHash}:${vout}`);
|
||||||
throw new CommandError("resource.unreserve.utxo_missing", `UTXO not found: ${txHash}:${vout}`);
|
throw new CommandError(
|
||||||
|
"resource.unreserve.utxo_missing",
|
||||||
|
`UTXO not found: ${txHash}:${vout}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If the target resource is not reserved, print a message and return
|
||||||
if (!target.reservedBy) {
|
if (!target.reservedBy) {
|
||||||
deps.io.out(dim("UTXO is not reserved. Nothing to do."));
|
deps.io.out(dim("UTXO is not reserved. Nothing to do."));
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Unreserve the resources
|
||||||
await deps.app.engine.unreserveResources(
|
await deps.app.engine.unreserveResources(
|
||||||
[{ outpointTransactionHash: hexToBin(txHash), outpointIndex: vout }],
|
[{ outpointTransactionHash: hexToBin(txHash), outpointIndex: vout }],
|
||||||
target.reservedBy ,
|
target.reservedBy,
|
||||||
);
|
);
|
||||||
deps.io.out(`Unreserved ${bold(`${txHash}:${vout}`)} (was reserved for ${target.reservedBy})`);
|
|
||||||
|
deps.io.out(
|
||||||
|
`Unreserved ${bold(`${txHash}:${vout}`)} (was reserved for ${target.reservedBy})`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// TODO: What do I want to return here?
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
|
|
||||||
case "unreserve-all": {
|
case "unreserve-all": {
|
||||||
|
// Unreserve all the resources
|
||||||
const count = await deps.app.unreserveAllResources();
|
const count = await deps.app.unreserveAllResources();
|
||||||
|
|
||||||
|
// If no resources are reserved, print a message and return
|
||||||
if (count === 0) {
|
if (count === 0) {
|
||||||
deps.io.out(dim("No reserved resources to unreserve."));
|
deps.io.out(dim("No reserved resources to unreserve."));
|
||||||
} else {
|
}
|
||||||
|
// If some resources were unreserved, print a message and return the count
|
||||||
|
else {
|
||||||
deps.io.out(`Unreserved ${bold(String(count))} resource(s).`);
|
deps.io.out(`Unreserved ${bold(String(count))} resource(s).`);
|
||||||
}
|
}
|
||||||
|
|
||||||
return { count };
|
return { count };
|
||||||
}
|
}
|
||||||
|
|
||||||
default: {
|
default: {
|
||||||
deps.io.verbose(`Unknown resource sub-command: ${subCommand}`);
|
deps.io.verbose(`Unknown resource sub-command: ${subCommand}`);
|
||||||
printResourceHelp(deps.io);
|
printResourceHelp(deps.io);
|
||||||
throw new CommandError("resource.subcommand.unknown", `Unknown resource sub-command: ${subCommand}`);
|
throw new CommandError(
|
||||||
|
"resource.subcommand.unknown",
|
||||||
|
`Unknown resource sub-command: ${subCommand}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
131
src/cli/commands/settings.ts
Normal file
131
src/cli/commands/settings.ts
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
import { SettingsService } from "../../services/settings.js";
|
||||||
|
import { formatObject } from "../utils.js";
|
||||||
|
import type { BaseCommandDependencies, CommandIO } from "./types.js";
|
||||||
|
import { CommandError } from "./types.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prints help text for the settings command.
|
||||||
|
*/
|
||||||
|
export const printSettingsHelp = (io: CommandIO): void => {
|
||||||
|
io.out(`Settings Command Help:
|
||||||
|
Commands:
|
||||||
|
settings show
|
||||||
|
settings get <currency|default-mnemonic>
|
||||||
|
settings set <currency|default-mnemonic> <value>
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
xo-cli settings show
|
||||||
|
xo-cli settings get currency
|
||||||
|
xo-cli settings set currency AUD
|
||||||
|
xo-cli settings set default-mnemonic mnemonic-main`);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Supported settings keys exposed on the CLI.
|
||||||
|
*/
|
||||||
|
type SettingsKey = "currency" | "default-mnemonic";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalizes user input to one of the supported settings keys.
|
||||||
|
*/
|
||||||
|
function parseSettingsKey(input: string | undefined): SettingsKey | null {
|
||||||
|
if (!input) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalized = input.trim().toLowerCase();
|
||||||
|
if (normalized === "currency") {
|
||||||
|
return "currency";
|
||||||
|
}
|
||||||
|
if (normalized === "default-mnemonic" || normalized === "defaultMnemonic") {
|
||||||
|
return "default-mnemonic";
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles `xo-cli settings` commands.
|
||||||
|
*
|
||||||
|
* This command intentionally does not require wallet initialization so users can
|
||||||
|
* configure currency/default mnemonic without passing `-m`.
|
||||||
|
*/
|
||||||
|
export const handleSettingsCommand = async (
|
||||||
|
deps: BaseCommandDependencies,
|
||||||
|
args: string[],
|
||||||
|
options: Record<string, string>,
|
||||||
|
): Promise<Record<string, unknown>> => {
|
||||||
|
const settings = new SettingsService(deps.paths.walletConfigPath);
|
||||||
|
|
||||||
|
// settings show (default if no subcommand)
|
||||||
|
const subCommand = args[0] ?? "show";
|
||||||
|
if (subCommand === "help" || options["help"] === "true") {
|
||||||
|
printSettingsHelp(deps.io);
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (subCommand) {
|
||||||
|
case "show": {
|
||||||
|
const snapshot = settings.getSettings();
|
||||||
|
deps.io.out(formatObject(snapshot));
|
||||||
|
return snapshot;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "get": {
|
||||||
|
const key = parseSettingsKey(args[1]);
|
||||||
|
if (!key) {
|
||||||
|
printSettingsHelp(deps.io);
|
||||||
|
throw new CommandError(
|
||||||
|
"settings.get.key_missing",
|
||||||
|
'Missing or invalid key. Expected "currency" or "default-mnemonic".',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const value =
|
||||||
|
key === "currency"
|
||||||
|
? settings.getCurrency()
|
||||||
|
: (settings.getDefaultMnemonic() ?? "");
|
||||||
|
deps.io.out(value);
|
||||||
|
return { key, value };
|
||||||
|
}
|
||||||
|
|
||||||
|
case "set": {
|
||||||
|
const key = parseSettingsKey(args[1]);
|
||||||
|
if (!key) {
|
||||||
|
printSettingsHelp(deps.io);
|
||||||
|
throw new CommandError(
|
||||||
|
"settings.set.key_missing",
|
||||||
|
'Missing or invalid key. Expected "currency" or "default-mnemonic".',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawValue = args.slice(2).join(" ").trim();
|
||||||
|
if (!rawValue) {
|
||||||
|
printSettingsHelp(deps.io);
|
||||||
|
throw new CommandError(
|
||||||
|
"settings.set.value_missing",
|
||||||
|
"Missing value for settings set command.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (key === "currency") {
|
||||||
|
settings.setCurrency(rawValue);
|
||||||
|
const currency = settings.getCurrency();
|
||||||
|
deps.io.out(`Updated currency: ${currency}`);
|
||||||
|
return { key, value: currency };
|
||||||
|
}
|
||||||
|
|
||||||
|
settings.setDefaultMnemonic(rawValue);
|
||||||
|
const defaultMnemonic = settings.getDefaultMnemonic() ?? "";
|
||||||
|
deps.io.out(`Updated default-mnemonic: ${defaultMnemonic}`);
|
||||||
|
return { key, value: defaultMnemonic };
|
||||||
|
}
|
||||||
|
|
||||||
|
default: {
|
||||||
|
printSettingsHelp(deps.io);
|
||||||
|
throw new CommandError(
|
||||||
|
"settings.subcommand.unknown",
|
||||||
|
`Unknown settings command: ${subCommand}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -1,9 +1,13 @@
|
|||||||
import { existsSync, readFileSync } from "fs";
|
import { existsSync, writeFileSync } from "fs";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
import { generateTemplateIdentifier } from "@xo-cash/engine";
|
import { generateTemplateIdentifier } from "@xo-cash/engine";
|
||||||
import type { XOTemplate } from "@xo-cash/types";
|
import type { XOTemplate } from "@xo-cash/types";
|
||||||
|
|
||||||
import { bold, dim, formatObject } from "../cli-utils.js";
|
import { bold, dim, formatObject } from "../utils.js";
|
||||||
|
import {
|
||||||
|
loadTemplateFromFile,
|
||||||
|
TemplateLoadError,
|
||||||
|
} from "../../utils/load-template-from-file.js";
|
||||||
import { resolveTemplateReferences } from "../../utils/templates.js";
|
import { resolveTemplateReferences } from "../../utils/templates.js";
|
||||||
import type { CommandDependencies, CommandIO } from "./types.js";
|
import type { CommandDependencies, CommandIO } from "./types.js";
|
||||||
import { CommandError } from "./types.js";
|
import { CommandError } from "./types.js";
|
||||||
@@ -14,16 +18,21 @@ import { resolveTemplate } from "../utils.js";
|
|||||||
*/
|
*/
|
||||||
export const printTemplateHelp = (io: CommandIO): void => {
|
export const printTemplateHelp = (io: CommandIO): void => {
|
||||||
io.out(
|
io.out(
|
||||||
`
|
`
|
||||||
${bold("Usage:")} xo-cli template <sub-command>
|
${bold("Usage:")} xo-cli template <sub-command>
|
||||||
|
|
||||||
${bold("Sub-commands:")}
|
${bold("Sub-commands:")}
|
||||||
- import <template-file> ${dim("Import a template from a file")}
|
- import <template-file> ${dim("Import a template from a JSON, JS, or TS file")}
|
||||||
- list ${dim("List all templates")}
|
- list ${dim("List all templates")}
|
||||||
- list <category> <identifier> ${dim("List all options of the field type in a template")}
|
- list <category> <identifier> ${dim("List all options of the field type in a template")}
|
||||||
- inspect <category> <identifier> <field> ${dim("Inspect a field in a template")}
|
- inspect <category> <identifier> <field> ${dim("Inspect a field in a template")}
|
||||||
- set-default <template-file> <output-identifier> <role-identifier> ${dim("Set the default template")}
|
- set-default <template-file> <output-identifier> <role-identifier> ${dim("Set the default template")}
|
||||||
`);
|
- export <template-identifier> [output-file] ${dim("Export a template to stdout or a file")}
|
||||||
|
|
||||||
|
${bold("Options:")}
|
||||||
|
-o --output <output-filename> ${dim("Output filename for the exported template")}
|
||||||
|
`,
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -36,77 +45,156 @@ export const handleTemplateListCommand = async (
|
|||||||
deps: CommandDependencies,
|
deps: CommandDependencies,
|
||||||
args: string[],
|
args: string[],
|
||||||
): Promise<{ count?: number }> => {
|
): Promise<{ count?: number }> => {
|
||||||
|
// Get the template category from the arguments - This could be "action", "transaction", "output", "lockingscript", or "variable"
|
||||||
const templateCategory = args[0];
|
const templateCategory = args[0];
|
||||||
deps.io.verbose(`Template list category: ${templateCategory}`);
|
deps.io.verbose(`Template list category: ${templateCategory}`);
|
||||||
|
|
||||||
|
// If no template category is provided, list all the imported templates
|
||||||
if (!templateCategory) {
|
if (!templateCategory) {
|
||||||
|
// List all the imported templates
|
||||||
const templates = await deps.app.engine.listImportedTemplates();
|
const templates = await deps.app.engine.listImportedTemplates();
|
||||||
const formattedTemplates = templates.map((template: XOTemplate) => `${bold(generateTemplateIdentifier(template))} - ${dim(template.name)} ${dim(template.description)}`);
|
|
||||||
deps.io.out(formattedTemplates.join('\n'));
|
// Format the templates into a list of strings that we can display to the user
|
||||||
|
const formattedTemplates = templates.map(
|
||||||
|
(template: XOTemplate) =>
|
||||||
|
`${bold(generateTemplateIdentifier(template))} - ${dim(template.name)} ${dim(template.description)}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Display the templates to the user
|
||||||
|
deps.io.out(formattedTemplates.join("\n"));
|
||||||
|
|
||||||
|
// Return the number of templates
|
||||||
return { count: templates.length };
|
return { count: templates.length };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Get the template identifier from the arguments
|
||||||
const templateIdentifier = args[1];
|
const templateIdentifier = args[1];
|
||||||
deps.io.verbose(`Template identifier: ${templateIdentifier}`);
|
deps.io.verbose(`Template identifier: ${templateIdentifier}`);
|
||||||
|
|
||||||
|
// If no template identifier is provided, print a message and throw an error
|
||||||
if (!templateIdentifier) {
|
if (!templateIdentifier) {
|
||||||
deps.io.err("No template identifier provided");
|
deps.io.err("No template identifier provided");
|
||||||
throw new CommandError("template.list.identifier_missing", "No template identifier provided");
|
throw new CommandError(
|
||||||
|
"template.list.identifier_missing",
|
||||||
|
"No template identifier provided",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Get the raw template from the engine
|
||||||
const rawTemplate = await deps.app.engine.getTemplate(templateIdentifier);
|
const rawTemplate = await deps.app.engine.getTemplate(templateIdentifier);
|
||||||
|
|
||||||
|
// If the raw template is not found, print a message and throw an error
|
||||||
if (!rawTemplate) {
|
if (!rawTemplate) {
|
||||||
deps.io.err(`No template found: ${templateIdentifier}`);
|
deps.io.err(`No template found: ${templateIdentifier}`);
|
||||||
throw new CommandError("template.list.not_found", `No template found: ${templateIdentifier}`);
|
throw new CommandError(
|
||||||
|
"template.list.not_found",
|
||||||
|
`No template found: ${templateIdentifier}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Resolve the template deeply - Deeply nested objects instead of shallow objects referencing keys at the top level.
|
||||||
|
// Reduces the load of having to call multiple lookups just to get some resolved value like the outputIdentifer that comes from calling an action.
|
||||||
const template = await resolveTemplateReferences(rawTemplate);
|
const template = await resolveTemplateReferences(rawTemplate);
|
||||||
deps.io.verbose(`Template: ${formatObject(template)}`);
|
deps.io.verbose(`Template: ${formatObject(template)}`);
|
||||||
|
|
||||||
|
// Handle the template category
|
||||||
switch (templateCategory) {
|
switch (templateCategory) {
|
||||||
case "action": {
|
case "action": {
|
||||||
|
// Get the actions from the template
|
||||||
const actions = template.actions;
|
const actions = template.actions;
|
||||||
const formattedActions = Object.entries(actions).map(([actionIdentifier, action]) => `${bold(actionIdentifier)} ${dim(action.name)} ${dim(action.description)}`);
|
|
||||||
deps.io.out(formattedActions.join('\n'));
|
// Format the actions into a list of strings that we can display to the user
|
||||||
|
const formattedActions = Object.entries(actions).map(
|
||||||
|
([actionIdentifier, action]) =>
|
||||||
|
`${bold(actionIdentifier)} ${dim(action.name)} ${dim(action.description)}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Display the actions to the user
|
||||||
|
deps.io.out(formattedActions.join("\n"));
|
||||||
|
|
||||||
|
// Return the number of actions
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
case "transaction": {
|
case "transaction": {
|
||||||
|
// Get the transactions from the template
|
||||||
const transactions = template.transactions;
|
const transactions = template.transactions;
|
||||||
const formattedTransactions = Object.entries(transactions).map(([transactionIdentifier, transaction]) => `${bold(transactionIdentifier)} ${dim(transaction.name)} ${dim(transaction.description)}`);
|
|
||||||
deps.io.out(formattedTransactions.join('\n'));
|
// Format the transactions into a list of strings that we can display to the user
|
||||||
|
const formattedTransactions = Object.entries(transactions).map(
|
||||||
|
([transactionIdentifier, transaction]) =>
|
||||||
|
`${bold(transactionIdentifier)} ${dim(transaction.name)} ${dim(transaction.description)}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Display the transactions to the user
|
||||||
|
deps.io.out(formattedTransactions.join("\n"));
|
||||||
|
|
||||||
|
// Return the number of transactions
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
case "output": {
|
case "output": {
|
||||||
|
// Get the outputs from the template
|
||||||
const outputs = template.outputs;
|
const outputs = template.outputs;
|
||||||
const formattedOutputs = Object.entries(outputs).map(([outputIdentifier, output]) => `${bold(outputIdentifier)} ${dim(output.name)} ${dim(output.description)}`);
|
|
||||||
deps.io.out(formattedOutputs.join('\n'));
|
// Format the outputs into a list of strings that we can display to the user
|
||||||
|
const formattedOutputs = Object.entries(outputs).map(
|
||||||
|
([outputIdentifier, output]) =>
|
||||||
|
`${bold(outputIdentifier)} ${dim(output.name)} ${dim(output.description)}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Display the outputs to the user
|
||||||
|
deps.io.out(formattedOutputs.join("\n"));
|
||||||
|
|
||||||
|
// Return the number of outputs
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
case "lockingscript": {
|
case "lockingscript": {
|
||||||
|
// Get the lockingscripts from the template
|
||||||
const lockingscripts = template.lockingScripts;
|
const lockingscripts = template.lockingScripts;
|
||||||
const formattedLockingscripts = Object.entries(lockingscripts).map(([lockingScriptIdentifier, lockingScript]) => `${bold(lockingScriptIdentifier)} ${dim(lockingScript.name)} ${dim(lockingScript.description)}`);
|
|
||||||
deps.io.out(formattedLockingscripts.join('\n'));
|
// Format the lockingscripts into a list of strings that we can display to the user
|
||||||
|
const formattedLockingscripts = Object.entries(lockingscripts).map(
|
||||||
|
([lockingScriptIdentifier, lockingScript]) =>
|
||||||
|
`${bold(lockingScriptIdentifier)} ${dim(lockingScript.name)} ${dim(lockingScript.description)}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Display the lockingscripts to the user
|
||||||
|
deps.io.out(formattedLockingscripts.join("\n"));
|
||||||
|
|
||||||
|
// Return the number of lockingscripts
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
case "variable": {
|
case "variable": {
|
||||||
|
// Get the variables from the template
|
||||||
const variables = template.variables || {};
|
const variables = template.variables || {};
|
||||||
const formattedVariables = Object.entries(variables).map(([variableIdentifier, variable]) => `${bold(variableIdentifier)} ${dim(variable.name)} ${dim(variable.description)}`);
|
|
||||||
deps.io.out(formattedVariables.join('\n'));
|
// Format the variables into a list of strings that we can display to the user
|
||||||
|
const formattedVariables = Object.entries(variables).map(
|
||||||
|
([variableIdentifier, variable]) =>
|
||||||
|
`${bold(variableIdentifier)} ${dim(variable.name)} ${dim(variable.description)}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Display the variables to the user
|
||||||
|
deps.io.out(formattedVariables.join("\n"));
|
||||||
|
|
||||||
|
// Return the number of variables
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
default: {
|
default: {
|
||||||
deps.io.verbose(`Unknown template category: ${templateCategory}`);
|
deps.io.verbose(`Unknown template category: ${templateCategory}`);
|
||||||
throw new CommandError("template.list.category_unknown", `Unknown template category: ${templateCategory}`);
|
throw new CommandError(
|
||||||
|
"template.list.category_unknown",
|
||||||
|
`Unknown template category: ${templateCategory}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Prints the help message for the template inspect command
|
* Prints the help message for the template inspect command
|
||||||
*/
|
*/
|
||||||
export const printTemplateInspectHelp = (io: CommandIO): void => {
|
export const printTemplateInspectHelp = (io: CommandIO): void => {
|
||||||
io.out(
|
io.out(
|
||||||
`
|
`
|
||||||
${bold("Usage:")} xo-cli template inspect <category> <identifier> <field>
|
${bold("Usage:")} xo-cli template inspect <category> <identifier> <field>
|
||||||
|
|
||||||
${bold("Arguments:")}
|
${bold("Arguments:")}
|
||||||
@@ -120,7 +208,8 @@ ${bold("Categories:")}
|
|||||||
- output <output-identifier> ${dim("Inspect an output")}
|
- output <output-identifier> ${dim("Inspect an output")}
|
||||||
- lockingscript <lockingscript-identifier> ${dim("Inspect a lockingscript")}
|
- lockingscript <lockingscript-identifier> ${dim("Inspect a lockingscript")}
|
||||||
- variable <variable-identifier> ${dim("Inspect a variable")}
|
- variable <variable-identifier> ${dim("Inspect a variable")}
|
||||||
`);
|
`,
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -133,76 +222,194 @@ export const handleTemplateInspectCommand = async (
|
|||||||
deps: CommandDependencies,
|
deps: CommandDependencies,
|
||||||
args: string[],
|
args: string[],
|
||||||
): Promise<Record<string, never>> => {
|
): Promise<Record<string, never>> => {
|
||||||
|
// Get the template category, identifier, and field from the arguments
|
||||||
const templateCategory = args[0];
|
const templateCategory = args[0];
|
||||||
const templateQuery = args[1];
|
const templateQuery = args[1];
|
||||||
const templateField = args[2];
|
const templateField = args[2];
|
||||||
|
|
||||||
deps.io.verbose(`Template inspect args - category: ${templateCategory}, identifier: ${templateQuery}, field: ${templateField}`);
|
deps.io.verbose(
|
||||||
|
`Template inspect args - category: ${templateCategory}, identifier: ${templateQuery}, field: ${templateField}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// If no template category, identifier, or field is provided, print a message and throw an error
|
||||||
if (!templateCategory || !templateQuery || !templateField) {
|
if (!templateCategory || !templateQuery || !templateField) {
|
||||||
deps.io.err("No template category, identifier, or field provided");
|
deps.io.err("No template category, identifier, or field provided");
|
||||||
printTemplateInspectHelp(deps.io);
|
printTemplateInspectHelp(deps.io);
|
||||||
throw new CommandError("template.inspect.arguments_missing", "No template category, identifier, or field provided");
|
throw new CommandError(
|
||||||
|
"template.inspect.arguments_missing",
|
||||||
|
"No template category, identifier, or field provided",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Resolve the template
|
||||||
const originalTemplate = await resolveTemplate(deps, templateQuery);
|
const originalTemplate = await resolveTemplate(deps, templateQuery);
|
||||||
deps.io.verbose(`Original Template: ${formatObject(originalTemplate)}`);
|
deps.io.verbose(`Original Template: ${formatObject(originalTemplate)}`);
|
||||||
|
|
||||||
|
// Resolve the template references
|
||||||
const template = await resolveTemplateReferences(originalTemplate);
|
const template = await resolveTemplateReferences(originalTemplate);
|
||||||
deps.io.verbose(`Extended Template: ${formatObject(template)}`);
|
deps.io.verbose(`Extended Template: ${formatObject(template)}`);
|
||||||
|
|
||||||
|
// Handle the template category
|
||||||
switch (templateCategory) {
|
switch (templateCategory) {
|
||||||
case "action": {
|
case "action": {
|
||||||
|
// Get the action from the template
|
||||||
const action = template.actions[templateField];
|
const action = template.actions[templateField];
|
||||||
|
|
||||||
|
// If the action is not found, print a message and throw an error
|
||||||
if (!action) {
|
if (!action) {
|
||||||
deps.io.err(`No action found: ${templateField}`);
|
deps.io.err(`No action found: ${templateField}`);
|
||||||
throw new CommandError("template.inspect.action_missing", `No action found: ${templateField}`);
|
throw new CommandError(
|
||||||
|
"template.inspect.action_missing",
|
||||||
|
`No action found: ${templateField}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Display the action to the user
|
||||||
deps.io.out(formatObject(action));
|
deps.io.out(formatObject(action));
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
case "transaction": {
|
case "transaction": {
|
||||||
|
// Get the transaction from the template
|
||||||
const transaction = template.transactions?.[templateField];
|
const transaction = template.transactions?.[templateField];
|
||||||
|
|
||||||
|
// If the transaction is not found, print a message and throw an error
|
||||||
if (!transaction) {
|
if (!transaction) {
|
||||||
deps.io.err(`No transaction found: ${templateField}`);
|
deps.io.err(`No transaction found: ${templateField}`);
|
||||||
throw new CommandError("template.inspect.transaction_missing", `No transaction found: ${templateField}`);
|
throw new CommandError(
|
||||||
|
"template.inspect.transaction_missing",
|
||||||
|
`No transaction found: ${templateField}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Display the transaction to the user
|
||||||
deps.io.out(formatObject(transaction));
|
deps.io.out(formatObject(transaction));
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
case "output": {
|
case "output": {
|
||||||
|
// Get the output from the template
|
||||||
const output = template.outputs[templateField];
|
const output = template.outputs[templateField];
|
||||||
|
|
||||||
|
// If the output is not found, print a message and throw an error
|
||||||
if (!output) {
|
if (!output) {
|
||||||
deps.io.err(`No output found: ${templateField}`);
|
deps.io.err(`No output found: ${templateField}`);
|
||||||
throw new CommandError("template.inspect.output_missing", `No output found: ${templateField}`);
|
throw new CommandError(
|
||||||
|
"template.inspect.output_missing",
|
||||||
|
`No output found: ${templateField}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Display the output to the user
|
||||||
deps.io.out(formatObject(output));
|
deps.io.out(formatObject(output));
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
case "lockingscript": {
|
case "lockingscript": {
|
||||||
|
// Get the lockingscript from the template
|
||||||
const lockingscript = template.lockingScripts[templateField];
|
const lockingscript = template.lockingScripts[templateField];
|
||||||
|
|
||||||
|
// If the lockingscript is not found, print a message and throw an error
|
||||||
if (!lockingscript) {
|
if (!lockingscript) {
|
||||||
deps.io.err(`No lockingscript found: ${templateField}`);
|
deps.io.err(`No lockingscript found: ${templateField}`);
|
||||||
throw new CommandError("template.inspect.lockingscript_missing", `No lockingscript found: ${templateField}`);
|
throw new CommandError(
|
||||||
|
"template.inspect.lockingscript_missing",
|
||||||
|
`No lockingscript found: ${templateField}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Display the lockingscript to the user
|
||||||
deps.io.out(formatObject(lockingscript));
|
deps.io.out(formatObject(lockingscript));
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
case "variable": {
|
case "variable": {
|
||||||
|
// Get the variable from the template
|
||||||
const variable = template.variables?.[templateField];
|
const variable = template.variables?.[templateField];
|
||||||
|
|
||||||
|
// If the variable is not found, print a message and throw an error
|
||||||
if (!variable) {
|
if (!variable) {
|
||||||
deps.io.err(`No variable found: ${templateField}`);
|
deps.io.err(`No variable found: ${templateField}`);
|
||||||
throw new CommandError("template.inspect.variable_missing", `No variable found: ${templateField}`);
|
throw new CommandError(
|
||||||
|
"template.inspect.variable_missing",
|
||||||
|
`No variable found: ${templateField}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Display the variable to the user
|
||||||
deps.io.out(formatObject(variable));
|
deps.io.out(formatObject(variable));
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
default: {
|
default: {
|
||||||
deps.io.verbose(`Unknown template category: ${templateCategory}`);
|
deps.io.verbose(`Unknown template category: ${templateCategory}`);
|
||||||
throw new CommandError("template.inspect.category_unknown", `Unknown template category: ${templateCategory}`);
|
throw new CommandError(
|
||||||
|
"template.inspect.category_unknown",
|
||||||
|
`Unknown template category: ${templateCategory}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles the template export command.
|
||||||
|
* Throws CommandError on failure, returns result data on success.
|
||||||
|
* @param deps - The command dependencies.
|
||||||
|
* @param args - Positional args after "export", e.g. ["template-id"] or ["template-id", "template.json"].
|
||||||
|
* @param options - Parsed option flags.
|
||||||
|
*/
|
||||||
|
export const handleTemplateExportCommand = async (
|
||||||
|
deps: CommandDependencies,
|
||||||
|
args: string[],
|
||||||
|
options: Record<string, string>,
|
||||||
|
): Promise<{ outputFile?: string }> => {
|
||||||
|
// Get the template identifier from the arguments
|
||||||
|
const templateIdentifier = args[0];
|
||||||
|
|
||||||
|
// If no template identifier is provided, print a message and throw an error
|
||||||
|
if (!templateIdentifier) {
|
||||||
|
deps.io.err("No template identifier provided");
|
||||||
|
printTemplateHelp(deps.io);
|
||||||
|
throw new CommandError(
|
||||||
|
"template.export.identifier_missing",
|
||||||
|
"No template identifier provided",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the raw template from the engine.
|
||||||
|
// Do not resolve references or pretty-print the template.
|
||||||
|
const rawTemplate = await deps.app.engine.getTemplate(templateIdentifier);
|
||||||
|
|
||||||
|
// If the raw template is not found, print a message and throw an error
|
||||||
|
if (!rawTemplate) {
|
||||||
|
deps.io.err(`No template found: ${templateIdentifier}`);
|
||||||
|
throw new CommandError(
|
||||||
|
"template.export.not_found",
|
||||||
|
`No template found: ${templateIdentifier}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Serialize the template without indentation to preserve the engine output shape.
|
||||||
|
const serializedTemplate = JSON.stringify(rawTemplate);
|
||||||
|
|
||||||
|
// Resolve output file from --output (or -o), then fallback to optional positional output file
|
||||||
|
const outputFile = options["output"] ?? args[1];
|
||||||
|
|
||||||
|
// If no output file is provided, print the template to stdout
|
||||||
|
if (!outputFile) {
|
||||||
|
deps.io.out(serializedTemplate);
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve output file path and write the template to disk
|
||||||
|
const outputPath = path.resolve(process.cwd(), outputFile);
|
||||||
|
try {
|
||||||
|
writeFileSync(outputPath, serializedTemplate);
|
||||||
|
} catch (error) {
|
||||||
|
throw new CommandError(
|
||||||
|
"template.export.write_failed",
|
||||||
|
`Failed to export template to file: ${outputPath} (${error instanceof Error ? error.message : "unknown error"})`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
deps.io.out(`Template exported to: ${outputPath}`);
|
||||||
|
return { outputFile: outputPath };
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handles the template command.
|
* Handles the template command.
|
||||||
@@ -214,65 +421,130 @@ export const handleTemplateInspectCommand = async (
|
|||||||
export const handleTemplateCommand = async (
|
export const handleTemplateCommand = async (
|
||||||
deps: CommandDependencies,
|
deps: CommandDependencies,
|
||||||
args: string[],
|
args: string[],
|
||||||
_options: Record<string, string>,
|
options: Record<string, string>,
|
||||||
): Promise<{ templateFile?: string; count?: number }> => {
|
): Promise<{ templateFile?: string; count?: number; outputFile?: string }> => {
|
||||||
|
// Get the sub-command from the arguments
|
||||||
const subCommand = args[0];
|
const subCommand = args[0];
|
||||||
|
|
||||||
|
// If no sub-command is provided, print a message and throw an error
|
||||||
if (!subCommand) {
|
if (!subCommand) {
|
||||||
deps.io.verbose("No sub-command provided");
|
deps.io.verbose("No sub-command provided");
|
||||||
printTemplateHelp(deps.io);
|
printTemplateHelp(deps.io);
|
||||||
throw new CommandError("template.subcommand.missing", "No sub-command provided");
|
throw new CommandError(
|
||||||
|
"template.subcommand.missing",
|
||||||
|
"No sub-command provided",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Handle the sub-command
|
||||||
switch (subCommand) {
|
switch (subCommand) {
|
||||||
case "import": {
|
case "import": {
|
||||||
|
// Get the template file from the arguments
|
||||||
const templateFile = args[1];
|
const templateFile = args[1];
|
||||||
|
|
||||||
|
// If no template file is provided, print a message and throw an error
|
||||||
deps.io.verbose(`Template file: ${templateFile}`);
|
deps.io.verbose(`Template file: ${templateFile}`);
|
||||||
|
|
||||||
if (!templateFile) {
|
if (!templateFile) {
|
||||||
deps.io.verbose("No template file provided");
|
deps.io.verbose("No template file provided");
|
||||||
printTemplateHelp(deps.io);
|
printTemplateHelp(deps.io);
|
||||||
throw new CommandError("template.import.file_missing", "No template file provided");
|
throw new CommandError(
|
||||||
|
"template.import.file_missing",
|
||||||
|
"No template file provided",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Resolve the template path
|
||||||
const templatePath = path.resolve(`${process.cwd()}/${templateFile}`);
|
const templatePath = path.resolve(`${process.cwd()}/${templateFile}`);
|
||||||
deps.io.verbose(`Template path: ${templatePath}`);
|
deps.io.verbose(`Template path: ${templatePath}`);
|
||||||
|
|
||||||
|
// If the template file does not exist, print a message and throw an error
|
||||||
if (!existsSync(templatePath)) {
|
if (!existsSync(templatePath)) {
|
||||||
deps.io.err(`Template file does not exist: ${templatePath}`);
|
deps.io.err(`Template file does not exist: ${templatePath}`);
|
||||||
printTemplateHelp(deps.io);
|
printTemplateHelp(deps.io);
|
||||||
throw new CommandError("template.import.file_not_found", `Template file does not exist: ${templatePath}`);
|
throw new CommandError(
|
||||||
|
"template.import.file_not_found",
|
||||||
|
`Template file does not exist: ${templatePath}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const template = await readFileSync(templatePath, "utf8");
|
// Read and load the template file (JSON directly, TS/JS via child process).
|
||||||
|
let templateContents: string;
|
||||||
|
try {
|
||||||
|
templateContents = await loadTemplateFromFile(templatePath);
|
||||||
|
} catch (error) {
|
||||||
|
const message =
|
||||||
|
error instanceof TemplateLoadError
|
||||||
|
? error.message
|
||||||
|
: error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: String(error);
|
||||||
|
deps.io.err(message);
|
||||||
|
printTemplateHelp(deps.io);
|
||||||
|
throw new CommandError("template.import.load_failed", message);
|
||||||
|
}
|
||||||
|
|
||||||
deps.io.verbose(`Importing template: ${templateFile}`);
|
deps.io.verbose(`Importing template: ${templateFile}`);
|
||||||
await deps.app.engine.importTemplate(template);
|
|
||||||
|
// Import the template
|
||||||
|
await deps.app.engine.importTemplate(templateContents);
|
||||||
deps.io.verbose(`Template imported: ${templateFile}`);
|
deps.io.verbose(`Template imported: ${templateFile}`);
|
||||||
|
|
||||||
|
// Return the template file
|
||||||
return { templateFile };
|
return { templateFile };
|
||||||
}
|
}
|
||||||
case "list": {
|
case "list": {
|
||||||
|
// Handle the template list command, We offload here as it has lots of arguments and is quite long
|
||||||
return handleTemplateListCommand(deps, args.slice(1));
|
return handleTemplateListCommand(deps, args.slice(1));
|
||||||
}
|
}
|
||||||
case "inspect": {
|
case "inspect": {
|
||||||
|
// Handle the template inspect command, We offload here as it has lots of arguments and is quite long
|
||||||
return handleTemplateInspectCommand(deps, args.slice(1));
|
return handleTemplateInspectCommand(deps, args.slice(1));
|
||||||
}
|
}
|
||||||
|
case "export": {
|
||||||
|
// Handle the template export command
|
||||||
|
return handleTemplateExportCommand(deps, args.slice(1), options);
|
||||||
|
}
|
||||||
case "set-default": {
|
case "set-default": {
|
||||||
|
// Get the template file, output identifier, and role identifier from the arguments
|
||||||
const templateFile = args[1];
|
const templateFile = args[1];
|
||||||
const outputIdentifier = args[2];
|
const outputIdentifier = args[2];
|
||||||
const roleIdentifier = args[3];
|
const roleIdentifier = args[3];
|
||||||
|
|
||||||
|
// If no template file, output identifier, or role identifier is provided, print a message and throw an error
|
||||||
if (!templateFile || !outputIdentifier || !roleIdentifier) {
|
if (!templateFile || !outputIdentifier || !roleIdentifier) {
|
||||||
deps.io.verbose("No template file, output identifier, or role identifier provided");
|
deps.io.verbose(
|
||||||
|
"No template file, output identifier, or role identifier provided",
|
||||||
|
);
|
||||||
printTemplateHelp(deps.io);
|
printTemplateHelp(deps.io);
|
||||||
throw new CommandError("template.default.arguments_missing", "No template file, output identifier, or role identifier provided");
|
throw new CommandError(
|
||||||
|
"template.default.arguments_missing",
|
||||||
|
"No template file, output identifier, or role identifier provided",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
deps.io.verbose(`Template file: ${templateFile}, output identifier: ${outputIdentifier}, role identifier: ${roleIdentifier}`);
|
|
||||||
await deps.app.engine.setDefaultLockingParameters(templateFile, outputIdentifier, roleIdentifier);
|
// Set the default locking parameters
|
||||||
|
deps.io.verbose(
|
||||||
|
`Template file: ${templateFile}, output identifier: ${outputIdentifier}, role identifier: ${roleIdentifier}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Set the default locking parameters
|
||||||
|
await deps.app.engine.setDefaultLockingParameters(
|
||||||
|
templateFile,
|
||||||
|
outputIdentifier,
|
||||||
|
roleIdentifier,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Return an empty object
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
|
// If the sub-command is not found, print a message and throw an error
|
||||||
deps.io.verbose(`Unknown template sub-command: ${subCommand}`);
|
deps.io.verbose(`Unknown template sub-command: ${subCommand}`);
|
||||||
printTemplateHelp(deps.io);
|
printTemplateHelp(deps.io);
|
||||||
throw new CommandError("template.subcommand.unknown", `Unknown template sub-command: ${subCommand}`);
|
throw new CommandError(
|
||||||
|
"template.subcommand.unknown",
|
||||||
|
`Unknown template sub-command: ${subCommand}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
105
src/cli/index.ts
105
src/cli/index.ts
@@ -35,14 +35,18 @@
|
|||||||
* -m --mnemonic-file <mnemonic-file>
|
* -m --mnemonic-file <mnemonic-file>
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { existsSync, readFileSync, writeFileSync } from "fs";
|
|
||||||
import { join } from "path";
|
import { join } from "path";
|
||||||
|
|
||||||
import { AppService } from "../services/app.js";
|
import { AppService } from "../services/app.js";
|
||||||
|
import { SettingsService } from "../services/settings.js";
|
||||||
import { convertArgsToObject } from "./arguments.js";
|
import { convertArgsToObject } from "./arguments.js";
|
||||||
import { bold, dim, formatObject } from "./cli-utils.js";
|
import { bold, dim, formatObject } from "./utils.js";
|
||||||
import { listGlobalMnemonicFiles, loadMnemonic } from "./mnemonic.js";
|
import { listGlobalMnemonicFiles, loadMnemonic } from "./mnemonic.js";
|
||||||
import { getDataDir, getMnemonicsDir, getWalletConfigPath } from "../utils/paths.js";
|
import {
|
||||||
|
getDataDir,
|
||||||
|
getMnemonicsDir,
|
||||||
|
getWalletConfigPath,
|
||||||
|
} from "../utils/paths.js";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
type CommandDependencies,
|
type CommandDependencies,
|
||||||
@@ -50,6 +54,7 @@ import {
|
|||||||
type CommandPaths,
|
type CommandPaths,
|
||||||
CommandError,
|
CommandError,
|
||||||
handleMnemonicCommand,
|
handleMnemonicCommand,
|
||||||
|
handleSettingsCommand,
|
||||||
handleTemplateCommand,
|
handleTemplateCommand,
|
||||||
handleInvitationCommand,
|
handleInvitationCommand,
|
||||||
handleReceiveCommand,
|
handleReceiveCommand,
|
||||||
@@ -111,6 +116,7 @@ async function main(): Promise<void> {
|
|||||||
walletConfigPath: getWalletConfigPath(),
|
walletConfigPath: getWalletConfigPath(),
|
||||||
workingDir: process.cwd(),
|
workingDir: process.cwd(),
|
||||||
};
|
};
|
||||||
|
const settings = new SettingsService(paths.walletConfigPath);
|
||||||
|
|
||||||
// Early handling for completions command
|
// Early handling for completions command
|
||||||
if (command === "completions") {
|
if (command === "completions") {
|
||||||
@@ -130,36 +136,65 @@ async function main(): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resolve mnemonic file: explicit flag > persisted config > error.
|
if (command === "settings") {
|
||||||
|
try {
|
||||||
|
await handleSettingsCommand({ io, paths }, subArgs, options);
|
||||||
|
process.exit(0);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof CommandError) {
|
||||||
|
process.exit(error.code);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve mnemonic file: explicit flag > persisted settings > error.
|
||||||
let mnemonicFile = options["mnemonicFile"];
|
let mnemonicFile = options["mnemonicFile"];
|
||||||
if (!mnemonicFile && existsSync(paths.walletConfigPath)) {
|
let didUsePersistedMnemonic = false;
|
||||||
mnemonicFile = readFileSync(paths.walletConfigPath, "utf8").trim();
|
if (!mnemonicFile) {
|
||||||
|
mnemonicFile = settings.getDefaultMnemonic();
|
||||||
|
didUsePersistedMnemonic = Boolean(mnemonicFile);
|
||||||
|
}
|
||||||
|
if (didUsePersistedMnemonic && mnemonicFile) {
|
||||||
io.verbose(`Using persisted wallet: ${mnemonicFile}`);
|
io.verbose(`Using persisted wallet: ${mnemonicFile}`);
|
||||||
}
|
}
|
||||||
if (!mnemonicFile) {
|
if (!mnemonicFile) {
|
||||||
io.err("No mnemonic file provided");
|
io.err("No mnemonic file provided");
|
||||||
io.out(`You can create a mnemonic file with the following command: xo-cli mnemonic create <mnemonic-seed> or use one of the following files: \n${listGlobalMnemonicFiles().join("\n")}`);
|
io.out(
|
||||||
io.out(`\nTip: pass -m <file> once and it will be remembered in ${paths.walletConfigPath}`);
|
`You can create a mnemonic file with the following command: xo-cli mnemonic create <mnemonic-seed> or use one of the following files: \n${listGlobalMnemonicFiles().join("\n")}`,
|
||||||
|
);
|
||||||
|
io.out(
|
||||||
|
`\nTip: pass -m <file> once and it will be remembered in ${paths.walletConfigPath}`,
|
||||||
|
);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Persist the choice so subsequent commands can omit -m.
|
// Persist the choice so subsequent commands can omit -m.
|
||||||
writeFileSync(paths.walletConfigPath, mnemonicFile);
|
settings.setDefaultMnemonic(mnemonicFile);
|
||||||
|
if (options["currency"]) {
|
||||||
|
settings.setCurrency(options["currency"]);
|
||||||
|
io.verbose(`Using configured currency: ${settings.getCurrency()}`);
|
||||||
|
}
|
||||||
|
|
||||||
const mnemonic = loadMnemonic(paths.mnemonicsDir, mnemonicFile);
|
const mnemonic = loadMnemonic(paths.mnemonicsDir, mnemonicFile);
|
||||||
io.verbose(`Loaded mnemonic from file: ${mnemonicFile} ${mnemonic}`);
|
io.verbose(`Loaded mnemonic from file: ${mnemonicFile} ${mnemonic}`);
|
||||||
|
|
||||||
// Create an App instance
|
// Create an App instance
|
||||||
io.verbose("Creating app instance...");
|
io.verbose("Creating app instance...");
|
||||||
const app = await AppService.create(mnemonic, {
|
const app = await AppService.create(
|
||||||
syncServerUrl: options["syncServerUrl"] ?? "http://localhost:3000",
|
mnemonic,
|
||||||
engineConfig: {
|
{
|
||||||
databasePath: options["databasePath"] ?? paths.dataDir,
|
syncServerUrl: options["syncServerUrl"] ?? "http://localhost:3000",
|
||||||
databaseFilename: options["databaseFilename"] ?? "xo-wallet.db",
|
engineConfig: {
|
||||||
|
databasePath: options["databasePath"] ?? paths.dataDir,
|
||||||
|
databaseFilename: options["databaseFilename"] ?? "xo-wallet.db",
|
||||||
|
},
|
||||||
|
invitationStoragePath:
|
||||||
|
options["invitationStoragePath"] ??
|
||||||
|
join(paths.dataDir, "xo-invitations.db"),
|
||||||
},
|
},
|
||||||
invitationStoragePath:
|
settings,
|
||||||
options["invitationStoragePath"] ?? join(paths.dataDir, "xo-invitations.db"),
|
);
|
||||||
});
|
|
||||||
io.verbose("App instance created");
|
io.verbose("App instance created");
|
||||||
|
|
||||||
// Start the app
|
// Start the app
|
||||||
@@ -179,23 +214,42 @@ async function main(): Promise<void> {
|
|||||||
let result: unknown;
|
let result: unknown;
|
||||||
switch (command) {
|
switch (command) {
|
||||||
case "template":
|
case "template":
|
||||||
result = await handleTemplateCommand(commandDependencies, subArgs, options);
|
result = await handleTemplateCommand(
|
||||||
|
commandDependencies,
|
||||||
|
subArgs,
|
||||||
|
options,
|
||||||
|
);
|
||||||
break;
|
break;
|
||||||
case "invitation":
|
case "invitation":
|
||||||
result = await handleInvitationCommand(commandDependencies, subArgs, options);
|
result = await handleInvitationCommand(
|
||||||
|
commandDependencies,
|
||||||
|
subArgs,
|
||||||
|
options,
|
||||||
|
);
|
||||||
break;
|
break;
|
||||||
case "receive":
|
case "receive":
|
||||||
result = await handleReceiveCommand(commandDependencies, subArgs, options);
|
result = await handleReceiveCommand(
|
||||||
|
commandDependencies,
|
||||||
|
subArgs,
|
||||||
|
options,
|
||||||
|
);
|
||||||
break;
|
break;
|
||||||
case "resource":
|
case "resource":
|
||||||
result = await handleResourceCommand(commandDependencies, subArgs, options);
|
result = await handleResourceCommand(
|
||||||
|
commandDependencies,
|
||||||
|
subArgs,
|
||||||
|
options,
|
||||||
|
);
|
||||||
break;
|
break;
|
||||||
case "help":
|
case "help":
|
||||||
result = await handleHelpCommand(commandDependencies, subArgs, options);
|
result = await handleHelpCommand(commandDependencies, subArgs, options);
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
io.err(`Unknown command: ${command}`);
|
io.err(`Unknown command: ${command}`);
|
||||||
throw new CommandError("cli.command.unknown", `Unknown command: ${command}`);
|
throw new CommandError(
|
||||||
|
"cli.command.unknown",
|
||||||
|
`Unknown command: ${command}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// console.log(result);
|
// console.log(result);
|
||||||
@@ -217,7 +271,7 @@ const handleHelpCommand = async (
|
|||||||
_options: Record<string, string>,
|
_options: Record<string, string>,
|
||||||
): Promise<Record<string, never>> => {
|
): Promise<Record<string, never>> => {
|
||||||
deps.io.out(
|
deps.io.out(
|
||||||
`${bold("XO-CLI Help:")}
|
`${bold("XO-CLI Help:")}
|
||||||
|
|
||||||
${bold("Usage:")} xo-cli <command> [options]
|
${bold("Usage:")} xo-cli <command> [options]
|
||||||
|
|
||||||
@@ -227,12 +281,15 @@ Commands:
|
|||||||
invitation ${dim("Manage invitations")}
|
invitation ${dim("Manage invitations")}
|
||||||
receive ${dim("Generate a single-use receiving address")}
|
receive ${dim("Generate a single-use receiving address")}
|
||||||
resource ${dim("Manage resources")}
|
resource ${dim("Manage resources")}
|
||||||
|
settings ${dim("Manage persisted wallet settings")}
|
||||||
completions ${dim("Generate shell completion scripts (bash, zsh, fish)")}
|
completions ${dim("Generate shell completion scripts (bash, zsh, fish)")}
|
||||||
|
help ${dim("Show this help message")}
|
||||||
|
|
||||||
Options:
|
Options:
|
||||||
-h, --help ${dim("Show this help message")}
|
-h, --help ${dim("Show this help message")}
|
||||||
-m, --mnemonic-file <mnemonic-file> ${dim("Use a specific mnemonic file")}
|
-m, --mnemonic-file <mnemonic-file> ${dim("Use a specific mnemonic file")}
|
||||||
-v, --verbose ${dim("Show verbose output")}`
|
--currency <currency-code> ${dim("Set fiat display currency (e.g. USD, AUD)")}
|
||||||
|
-v, --verbose ${dim("Show verbose output")}`,
|
||||||
);
|
);
|
||||||
return {};
|
return {};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -28,9 +28,11 @@ export const createMnemonicFile = (
|
|||||||
|
|
||||||
let fileName = outputFilename;
|
let fileName = outputFilename;
|
||||||
if (!fileName) {
|
if (!fileName) {
|
||||||
const firstWord = mnemonic.split(' ')[0]?.toLowerCase();
|
const firstWord = mnemonic.split(" ")[0]?.toLowerCase();
|
||||||
if (!firstWord) {
|
if (!firstWord) {
|
||||||
throw new Error("Failed to create mnemonic file: Unable to extract first word from the mnemonic");
|
throw new Error(
|
||||||
|
"Failed to create mnemonic file: Unable to extract first word from the mnemonic",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
fileName = `mnemonic-${firstWord}`;
|
fileName = `mnemonic-${firstWord}`;
|
||||||
}
|
}
|
||||||
@@ -55,20 +57,24 @@ export const resolveMnemonicFilePath = (
|
|||||||
mnemonicsDir: string,
|
mnemonicsDir: string,
|
||||||
mnemonicRef: string,
|
mnemonicRef: string,
|
||||||
): string => {
|
): string => {
|
||||||
|
// Try to resolve the mnemonic file as an absolute path
|
||||||
if (isAbsolute(mnemonicRef) && existsSync(mnemonicRef)) {
|
if (isAbsolute(mnemonicRef) && existsSync(mnemonicRef)) {
|
||||||
return mnemonicRef;
|
return mnemonicRef;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Try to resolve the mnemonic file relative to the current working directory
|
||||||
const relativeToCwd = resolve(process.cwd(), mnemonicRef);
|
const relativeToCwd = resolve(process.cwd(), mnemonicRef);
|
||||||
if (existsSync(relativeToCwd)) {
|
if (existsSync(relativeToCwd)) {
|
||||||
return relativeToCwd;
|
return relativeToCwd;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Try to resolve the mnemonic file in the mnemonics directory
|
||||||
const inMnemonics = join(mnemonicsDir, basename(mnemonicRef));
|
const inMnemonics = join(mnemonicsDir, basename(mnemonicRef));
|
||||||
if (existsSync(inMnemonics)) {
|
if (existsSync(inMnemonics)) {
|
||||||
return inMnemonics;
|
return inMnemonics;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If the mnemonic file is not found, throw an error
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Mnemonic file not found: ${mnemonicRef}. Run "xo-cli mnemonic list" to see available files.`,
|
`Mnemonic file not found: ${mnemonicRef}. Run "xo-cli mnemonic list" to see available files.`,
|
||||||
);
|
);
|
||||||
@@ -80,17 +86,30 @@ export const resolveMnemonicFilePath = (
|
|||||||
* @param mnemonicFile - The filename of the mnemonic file
|
* @param mnemonicFile - The filename of the mnemonic file
|
||||||
* @returns The mnemonic seed
|
* @returns The mnemonic seed
|
||||||
*/
|
*/
|
||||||
export const loadMnemonic = (mnemonicsDir: string, mnemonicFile: string): string => {
|
export const loadMnemonic = (
|
||||||
|
mnemonicsDir: string,
|
||||||
|
mnemonicFile: string,
|
||||||
|
): string => {
|
||||||
|
// Resolve the mnemonic file path
|
||||||
const resolvedPath = resolveMnemonicFilePath(mnemonicsDir, mnemonicFile);
|
const resolvedPath = resolveMnemonicFilePath(mnemonicsDir, mnemonicFile);
|
||||||
const mnemonicUrl = BCHMnemonicURL.fromURL(readFileSync(resolvedPath, "utf8"));
|
|
||||||
|
// Read the mnemonic file
|
||||||
|
const mnemonicUrl = BCHMnemonicURL.fromURL(
|
||||||
|
readFileSync(resolvedPath, "utf8"),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get the entropy from the mnemonic url
|
||||||
const { entropy } = mnemonicUrl.toObject();
|
const { entropy } = mnemonicUrl.toObject();
|
||||||
|
|
||||||
|
// Encode the entropy to a mnemonic
|
||||||
const mnemonic = encodeBip39Mnemonic(entropy);
|
const mnemonic = encodeBip39Mnemonic(entropy);
|
||||||
|
|
||||||
|
// If the mnemonic is not a string, throw an error
|
||||||
if (typeof mnemonic === "string") {
|
if (typeof mnemonic === "string") {
|
||||||
throw new Error(`Failed to convert entropy to mnemonic: ${mnemonic}`);
|
throw new Error(`Failed to convert entropy to mnemonic: ${mnemonic}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Return the mnemonic phrase
|
||||||
return mnemonic.phrase;
|
return mnemonic.phrase;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -100,9 +119,12 @@ export const loadMnemonic = (mnemonicsDir: string, mnemonicFile: string): string
|
|||||||
* @returns Basenames suitable for `-m <name>`
|
* @returns Basenames suitable for `-m <name>`
|
||||||
*/
|
*/
|
||||||
export const listMnemonicFiles = (mnemonicsDir: string): string[] => {
|
export const listMnemonicFiles = (mnemonicsDir: string): string[] => {
|
||||||
|
// List the mnemonic files in the given directory
|
||||||
const filenames = readdirSync(mnemonicsDir).filter((f: string) =>
|
const filenames = readdirSync(mnemonicsDir).filter((f: string) =>
|
||||||
f.startsWith("mnemonic-"),
|
f.startsWith("mnemonic-"),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Return the mnemonic files
|
||||||
return filenames;
|
return filenames;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -112,5 +134,6 @@ export const listMnemonicFiles = (mnemonicsDir: string): string[] => {
|
|||||||
* @returns Basenames suitable for `-m <name>`
|
* @returns Basenames suitable for `-m <name>`
|
||||||
*/
|
*/
|
||||||
export const listGlobalMnemonicFiles = (): string[] => {
|
export const listGlobalMnemonicFiles = (): string[] => {
|
||||||
|
// List the mnemonic files in the global mnemonics directory
|
||||||
return listMnemonicFiles(getGlobalMnemonicsDir());
|
return listMnemonicFiles(getGlobalMnemonicsDir());
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
|
import util from "node:util";
|
||||||
|
|
||||||
import type { XOTemplate } from "@xo-cash/types";
|
import type { XOTemplate } from "@xo-cash/types";
|
||||||
|
import { generateTemplateIdentifier } from "@xo-cash/engine";
|
||||||
|
|
||||||
import type { CommandDependencies } from "./commands/types.js";
|
import type { CommandDependencies } from "./commands/types.js";
|
||||||
import { CommandError } from "./commands/types.js";
|
import { CommandError } from "./commands/types.js";
|
||||||
import { generateTemplateIdentifier } from "@xo-cash/engine";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Iterate through the templates, trying to match the id or the name with the given input.
|
* Iterate through the templates, trying to match the id or the name with the given input.
|
||||||
@@ -16,33 +19,96 @@ import { generateTemplateIdentifier } from "@xo-cash/engine";
|
|||||||
* @throws CommandError if no template is found.
|
* @throws CommandError if no template is found.
|
||||||
* @throws CommandError if multiple templates are found.
|
* @throws CommandError if multiple templates are found.
|
||||||
*/
|
*/
|
||||||
export const resolveTemplate = async (deps: CommandDependencies, query: string): Promise<XOTemplate> => {
|
export const resolveTemplate = async (
|
||||||
|
deps: CommandDependencies,
|
||||||
|
query: string,
|
||||||
|
): Promise<XOTemplate> => {
|
||||||
|
// Gather all of our imported templates
|
||||||
const templates = await deps.app.engine.listImportedTemplates();
|
const templates = await deps.app.engine.listImportedTemplates();
|
||||||
|
|
||||||
|
// Create a set to store the matches
|
||||||
const matches = new Set<XOTemplate>();
|
const matches = new Set<XOTemplate>();
|
||||||
|
|
||||||
|
// Iterate through the templates and check if the identifier matches the query
|
||||||
for (const template of templates) {
|
for (const template of templates) {
|
||||||
if (generateTemplateIdentifier(template) === query) {
|
if (generateTemplateIdentifier(template) === query) {
|
||||||
|
// Return early if we got a match since identifiers are always unique by content
|
||||||
return template;
|
return template;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Iterate through the templates and check if the name matches the query
|
||||||
for (const template of templates) {
|
for (const template of templates) {
|
||||||
if (template.name === query) {
|
if (template.name === query) {
|
||||||
matches.add(template);
|
matches.add(template);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If there are multiple matches, throw an error
|
||||||
if (matches.size > 1) {
|
if (matches.size > 1) {
|
||||||
throw new CommandError(
|
throw new CommandError(
|
||||||
"template.resolve.multiple_matches",
|
"template.resolve.multiple_matches",
|
||||||
`Multiple templates found for "${query}": ${Array.from(matches).map(template => `${template.name} (${generateTemplateIdentifier(template)})`).join(", ")}`,
|
`Multiple templates found for "${query}": ${Array.from(matches)
|
||||||
|
.map(
|
||||||
|
(template) =>
|
||||||
|
`${template.name} (${generateTemplateIdentifier(template)})`,
|
||||||
|
)
|
||||||
|
.join(", ")}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If there is one match, return the match
|
||||||
if (matches.size === 1) {
|
if (matches.size === 1) {
|
||||||
return matches.values().next().value!;
|
return matches.values().next().value!;
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new CommandError("template.resolve.not_found", `Template not found: ${query}`);
|
// If there are no matches, throw an error
|
||||||
}
|
throw new CommandError(
|
||||||
|
"template.resolve.not_found",
|
||||||
|
`Template not found: ${query}`,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Text formatting utilities for the CLI.
|
||||||
|
*
|
||||||
|
* Uses ANSI escape codes to format text.
|
||||||
|
*
|
||||||
|
* AI Generated links:
|
||||||
|
* @see https://en.wikipedia.org/wiki/ANSI_escape_code
|
||||||
|
* @see https://en.wikipedia.org/wiki/ANSI_escape_code#SGR_(Select_Graphic_Rendition)_parameters
|
||||||
|
* @see https://en.wikipedia.org/wiki/ANSI_escape_code#Colors
|
||||||
|
* @see https://en.wikipedia.org/wiki/ANSI_escape_code#Formatting
|
||||||
|
* @see https://en.wikipedia.org/wiki/ANSI_escape_code#Cursor_movement
|
||||||
|
* @see https://en.wikipedia.org/wiki/ANSI_escape_code#Screen_manipulation
|
||||||
|
*/
|
||||||
|
|
||||||
|
const BOLD = "\x1b[1m";
|
||||||
|
export const bold = (text: string) => `${BOLD}${text}${RESET}`;
|
||||||
|
|
||||||
|
const DIM = "\x1b[2m";
|
||||||
|
export const dim = (text: string) => `${DIM}${text}${RESET}`;
|
||||||
|
|
||||||
|
const UNDERLINE = "\x1b[4m";
|
||||||
|
export const underline = (text: string) => `${UNDERLINE}${text}${RESET}`;
|
||||||
|
|
||||||
|
const INVERSE = "\x1b[7m";
|
||||||
|
export const inverse = (text: string) => `${INVERSE}${text}${RESET}`;
|
||||||
|
|
||||||
|
const HIDDEN = "\x1b[8m";
|
||||||
|
export const hidden = (text: string) => `${HIDDEN}${text}${RESET}`;
|
||||||
|
|
||||||
|
const STRIKETHROUGH = "\x1b[9m";
|
||||||
|
export const strikethrough = (text: string) =>
|
||||||
|
`${STRIKETHROUGH}${text}${RESET}`;
|
||||||
|
|
||||||
|
const RESET = "\x1b[0m";
|
||||||
|
export const reset = (text: string) => `${RESET}${text}${RESET}`;
|
||||||
|
|
||||||
|
export const formatObject = (obj: unknown) => {
|
||||||
|
return util.inspect(obj, {
|
||||||
|
depth: null,
|
||||||
|
colors: true,
|
||||||
|
compact: false,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|||||||
@@ -11,13 +11,19 @@ import { BaseStorage, 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 { type BlockchainService, ElectrumService } from "./electrum.js";
|
import { type BlockchainService, ElectrumService } from "./electrum.js";
|
||||||
|
import { RatesService } from "./rates.js";
|
||||||
|
import { SettingsService } from "./settings.js";
|
||||||
|
|
||||||
import { EventEmitter } from "../utils/event-emitter.js";
|
import { EventEmitter } from "../utils/event-emitter.js";
|
||||||
|
|
||||||
// TODO: Remove this. Exists to hash the seed for database namespace.
|
// TODO: Remove this. Exists to hash the seed for database namespace.
|
||||||
import { createHash } from "crypto";
|
import { createHash } from "crypto";
|
||||||
import { p2pkhTemplate } from "@xo-cash/templates";
|
|
||||||
import { hexToBin } from "@bitauth/libauth";
|
import { hexToBin } from "@bitauth/libauth";
|
||||||
|
import { parseTemplate } from "@xo-cash/engine";
|
||||||
|
|
||||||
|
import { p2pkhTemplate } from "@xo-cash/templates";
|
||||||
|
import { vendingMachineTemplate } from "../templates/vending-machine.js";
|
||||||
|
import { wrapBCHTemplate } from "../templates/wrap-template.js";
|
||||||
|
|
||||||
export type AppEventMap = {
|
export type AppEventMap = {
|
||||||
"invitation-added": Invitation;
|
"invitation-added": Invitation;
|
||||||
@@ -46,17 +52,30 @@ export class AppService extends EventEmitter<AppEventMap> {
|
|||||||
public config: AppConfig;
|
public config: AppConfig;
|
||||||
public history: HistoryService;
|
public history: HistoryService;
|
||||||
public electrum: BlockchainService;
|
public electrum: BlockchainService;
|
||||||
|
public rates: RatesService;
|
||||||
|
public settings: SettingsService;
|
||||||
|
|
||||||
public invitations: Invitation[] = [];
|
public invitations: Invitation[] = [];
|
||||||
|
/**
|
||||||
|
* Incremented whenever the invitation list or any invitation's data/status changes.
|
||||||
|
* Used by TUI hooks so useSyncExternalStore snapshots change on in-place mutations.
|
||||||
|
*/
|
||||||
|
public invitationsRevision = 0;
|
||||||
|
private invitationRevisions = new Map<string, number>();
|
||||||
private invitationEventCleanup = new Map<
|
private invitationEventCleanup = new Map<
|
||||||
string,
|
string,
|
||||||
{
|
{
|
||||||
onUpdated: (invitation: XOInvitation) => void;
|
onUpdated: (invitation: XOInvitation) => void;
|
||||||
onStatusChanged: (status: string) => void;
|
onStatusChanged: (status: string) => void;
|
||||||
|
onRemoved: () => void;
|
||||||
}
|
}
|
||||||
>();
|
>();
|
||||||
|
|
||||||
static async create(seed: string, config: AppConfig): Promise<AppService> {
|
static async create(
|
||||||
|
seed: string,
|
||||||
|
config: AppConfig,
|
||||||
|
settings: SettingsService = new SettingsService(),
|
||||||
|
): Promise<AppService> {
|
||||||
// Because of a bug that lets wallets read the unspents of other wallets, we are going to manually namespace the storage paths for the app.
|
// Because of a bug that lets wallets read the unspents of other wallets, we are going to manually namespace the storage paths for the app.
|
||||||
// We are going to do this by computing a hash of the seed and prefixing the storage paths with it.
|
// We are going to do this by computing a hash of the seed and prefixing the storage paths with it.
|
||||||
const seedHash = createHash("sha256").update(seed).digest("hex");
|
const seedHash = createHash("sha256").update(seed).digest("hex");
|
||||||
@@ -72,16 +91,32 @@ export class AppService extends EventEmitter<AppEventMap> {
|
|||||||
|
|
||||||
// TODO: We *technically* dont want this here, but we also need some initial templates for the wallet, so im doing it here
|
// TODO: We *technically* dont want this here, but we also need some initial templates for the wallet, so im doing it here
|
||||||
// Import the default P2PKH template
|
// Import the default P2PKH template
|
||||||
const { templateIdentifier } = await engine.importTemplate(p2pkhTemplate);
|
await engine.importTemplate(p2pkhTemplate);
|
||||||
|
await engine.importTemplate(vendingMachineTemplate);
|
||||||
|
await engine.importTemplate(wrapBCHTemplate);
|
||||||
|
|
||||||
engine.subscribeToLockingBytecodesForTemplate(templateIdentifier).catch(err => console.error(`Error subscribing to locking bytecodes for template ${templateIdentifier}: ${err}`));
|
// Update all the unspents for every template, and subscribe to the locking bytecodes for changes
|
||||||
engine.updateUnspentOutputsForTemplate(templateIdentifier).catch(err => console.error(`Error updating unspent outputs for template ${templateIdentifier}: ${err}`));
|
// TODO: Remove the above lines that do the same thing. Minimising changes for BLISS.
|
||||||
|
const updateTemplates = async () => {
|
||||||
|
const templates = await engine.listImportedTemplates();
|
||||||
|
|
||||||
|
templates.forEach(async (template) => {
|
||||||
|
engine.updateUnspentOutputsForTemplate(
|
||||||
|
generateTemplateIdentifier(template),
|
||||||
|
);
|
||||||
|
engine.subscribeToScriptHashForTemplate(
|
||||||
|
generateTemplateIdentifier(template),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
updateTemplates();
|
||||||
|
|
||||||
// Set default locking parameters for P2PKH
|
// Set default locking parameters for P2PKH
|
||||||
// To my knowledge, this doesnt generate any lockscript, so discovery of funds will not work automatically.
|
// To my knowledge, this doesnt generate any lockscript, so discovery of funds will not work automatically.
|
||||||
// TODO: Add discovery for funds in the first index? Or until we return 0 TXs?
|
// TODO: Add discovery for funds in the first index? Or until we return 0 TXs?
|
||||||
await engine.setDefaultLockingParameters(
|
await engine.setDefaultLockingParameters(
|
||||||
generateTemplateIdentifier(p2pkhTemplate),
|
generateTemplateIdentifier(parseTemplate(p2pkhTemplate)),
|
||||||
"receiveOutput",
|
"receiveOutput",
|
||||||
"receiver",
|
"receiver",
|
||||||
);
|
);
|
||||||
@@ -95,8 +130,16 @@ export class AppService extends EventEmitter<AppEventMap> {
|
|||||||
host: config.electrumHost,
|
host: config.electrumHost,
|
||||||
applicationIdentifier: config.electrumApplicationIdentifier,
|
applicationIdentifier: config.electrumApplicationIdentifier,
|
||||||
});
|
});
|
||||||
|
const rates = await RatesService.create(settings);
|
||||||
|
|
||||||
return new AppService(engine, walletStorage, config, electrum);
|
return new AppService(
|
||||||
|
engine,
|
||||||
|
walletStorage,
|
||||||
|
config,
|
||||||
|
electrum,
|
||||||
|
rates,
|
||||||
|
settings,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@@ -104,6 +147,8 @@ export class AppService extends EventEmitter<AppEventMap> {
|
|||||||
storage: BaseStorage,
|
storage: BaseStorage,
|
||||||
config: AppConfig,
|
config: AppConfig,
|
||||||
electrum: BlockchainService,
|
electrum: BlockchainService,
|
||||||
|
rates: RatesService,
|
||||||
|
settings: SettingsService,
|
||||||
) {
|
) {
|
||||||
super();
|
super();
|
||||||
|
|
||||||
@@ -111,6 +156,8 @@ export class AppService extends EventEmitter<AppEventMap> {
|
|||||||
this.storage = storage;
|
this.storage = storage;
|
||||||
this.config = config;
|
this.config = config;
|
||||||
this.electrum = electrum;
|
this.electrum = electrum;
|
||||||
|
this.rates = rates;
|
||||||
|
this.settings = settings;
|
||||||
this.history = new HistoryService(engine, this.invitations);
|
this.history = new HistoryService(engine, this.invitations);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -136,8 +183,9 @@ export class AppService extends EventEmitter<AppEventMap> {
|
|||||||
// Create the invitation
|
// Create the invitation
|
||||||
const invitationInstance = await Invitation.create(invitation, deps);
|
const invitationInstance = await Invitation.create(invitation, deps);
|
||||||
|
|
||||||
// Add the invitation to the invitations array
|
// Attach listeners before SSE connects so updates are not missed.
|
||||||
await this.addInvitation(invitationInstance);
|
await this.addInvitation(invitationInstance);
|
||||||
|
invitationInstance.start();
|
||||||
|
|
||||||
return invitationInstance;
|
return invitationInstance;
|
||||||
}
|
}
|
||||||
@@ -147,6 +195,7 @@ export class AppService extends EventEmitter<AppEventMap> {
|
|||||||
|
|
||||||
// Add the invitation to the invitations array
|
// Add the invitation to the invitations array
|
||||||
this.invitations.push(invitation);
|
this.invitations.push(invitation);
|
||||||
|
this.bumpInvitationRevision(invitation.data.invitationIdentifier);
|
||||||
|
|
||||||
// Emit the invitation-added event
|
// Emit the invitation-added event
|
||||||
this.emit("invitation-added", invitation);
|
this.emit("invitation-added", invitation);
|
||||||
@@ -165,6 +214,7 @@ export class AppService extends EventEmitter<AppEventMap> {
|
|||||||
if (invitationIndex >= 0) {
|
if (invitationIndex >= 0) {
|
||||||
this.invitations.splice(invitationIndex, 1);
|
this.invitations.splice(invitationIndex, 1);
|
||||||
}
|
}
|
||||||
|
this.bumpInvitationRevision(invitationIdentifier);
|
||||||
|
|
||||||
// Emit the invitation-removed event
|
// Emit the invitation-removed event
|
||||||
this.emit("invitation-removed", invitation);
|
this.emit("invitation-removed", invitation);
|
||||||
@@ -179,27 +229,53 @@ export class AppService extends EventEmitter<AppEventMap> {
|
|||||||
if (this.invitationEventCleanup.has(invitationIdentifier)) return;
|
if (this.invitationEventCleanup.has(invitationIdentifier)) return;
|
||||||
|
|
||||||
const onUpdated = () => {
|
const onUpdated = () => {
|
||||||
|
this.bumpInvitationRevision(invitationIdentifier);
|
||||||
this.emit("wallet-state-changed", {
|
this.emit("wallet-state-changed", {
|
||||||
reason: "invitation-updated",
|
reason: "invitation-updated",
|
||||||
invitationIdentifier,
|
invitationIdentifier,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
const onStatusChanged = () => {
|
const onStatusChanged = () => {
|
||||||
|
this.bumpInvitationRevision(invitationIdentifier);
|
||||||
this.emit("wallet-state-changed", {
|
this.emit("wallet-state-changed", {
|
||||||
reason: "invitation-status-changed",
|
reason: "invitation-status-changed",
|
||||||
invitationIdentifier,
|
invitationIdentifier,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
const onRemoved = () => {
|
||||||
|
this.detachInvitationListeners(invitationIdentifier);
|
||||||
|
this.invitations.splice(this.invitations.indexOf(invitation), 1);
|
||||||
|
this.bumpInvitationRevision(invitationIdentifier);
|
||||||
|
this.emit("invitation-removed", invitation);
|
||||||
|
this.emit("wallet-state-changed", {
|
||||||
|
reason: "invitation-removed",
|
||||||
|
invitationIdentifier: invitationIdentifier,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
invitation.on("invitation-updated", onUpdated);
|
invitation.on("invitation-updated", onUpdated);
|
||||||
invitation.on("invitation-status-changed", onStatusChanged);
|
invitation.on("invitation-status-changed", onStatusChanged);
|
||||||
|
invitation.on("invitation-removed", onRemoved);
|
||||||
|
|
||||||
this.invitationEventCleanup.set(invitationIdentifier, {
|
this.invitationEventCleanup.set(invitationIdentifier, {
|
||||||
onUpdated,
|
onUpdated,
|
||||||
onStatusChanged,
|
onStatusChanged,
|
||||||
|
onRemoved,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getInvitationRevision(invitationIdentifier: string): number {
|
||||||
|
return this.invitationRevisions.get(invitationIdentifier) ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private bumpInvitationRevision(invitationIdentifier: string): void {
|
||||||
|
this.invitationsRevision += 1;
|
||||||
|
this.invitationRevisions.set(
|
||||||
|
invitationIdentifier,
|
||||||
|
this.getInvitationRevision(invitationIdentifier) + 1,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
private detachInvitationListeners(invitationIdentifier: string): void {
|
private detachInvitationListeners(invitationIdentifier: string): void {
|
||||||
const trackedInvitation = this.invitations.find(
|
const trackedInvitation = this.invitations.find(
|
||||||
(candidate) =>
|
(candidate) =>
|
||||||
@@ -209,10 +285,7 @@ export class AppService extends EventEmitter<AppEventMap> {
|
|||||||
if (!trackedInvitation || !cleanup) return;
|
if (!trackedInvitation || !cleanup) return;
|
||||||
|
|
||||||
trackedInvitation.off("invitation-updated", cleanup.onUpdated);
|
trackedInvitation.off("invitation-updated", cleanup.onUpdated);
|
||||||
trackedInvitation.off(
|
trackedInvitation.off("invitation-status-changed", cleanup.onStatusChanged);
|
||||||
"invitation-status-changed",
|
|
||||||
cleanup.onStatusChanged,
|
|
||||||
);
|
|
||||||
this.invitationEventCleanup.delete(invitationIdentifier);
|
this.invitationEventCleanup.delete(invitationIdentifier);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -248,6 +321,11 @@ export class AppService extends EventEmitter<AppEventMap> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async start(): Promise<void> {
|
async start(): Promise<void> {
|
||||||
|
// Start rates in the background so BCH -> fiat conversions become reactive in the TUI.
|
||||||
|
this.rates
|
||||||
|
.start()
|
||||||
|
.catch((err) => console.error("Error starting rates service:", err));
|
||||||
|
|
||||||
// Get the invitations db
|
// Get the invitations db
|
||||||
const invitationsDb = this.storage.child("invitations");
|
const invitationsDb = this.storage.child("invitations");
|
||||||
|
|
||||||
@@ -259,7 +337,9 @@ export class AppService extends EventEmitter<AppEventMap> {
|
|||||||
|
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
invitations.map(async ({ key }) => {
|
invitations.map(async ({ key }) => {
|
||||||
await this.createInvitation(key).catch(err => console.error(`Error creating invitation ${key}: ${err}`));
|
await this.createInvitation(key).catch((err) =>
|
||||||
|
console.error(`Error creating invitation ${key}: ${err}`),
|
||||||
|
);
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,10 +1,15 @@
|
|||||||
import type {
|
import type {
|
||||||
AcceptInvitationParameters,
|
InvitationParameters,
|
||||||
AppendInvitationParameters,
|
|
||||||
Engine,
|
Engine,
|
||||||
FindSuitableResourcesParameters,
|
GetSpendableResourcesParameters,
|
||||||
|
} from "@xo-cash/engine";
|
||||||
|
import {
|
||||||
|
generateTemplateIdentifier,
|
||||||
|
hasInvitationExpired,
|
||||||
|
mergeInvitationCommits,
|
||||||
|
serializeInvitation,
|
||||||
|
deserializeInvitation,
|
||||||
} from "@xo-cash/engine";
|
} from "@xo-cash/engine";
|
||||||
import { hasInvitationExpired, mergeInvitationCommits } from "@xo-cash/engine";
|
|
||||||
import type {
|
import type {
|
||||||
XOInvitation,
|
XOInvitation,
|
||||||
XOInvitationCommit,
|
XOInvitationCommit,
|
||||||
@@ -12,6 +17,7 @@ import type {
|
|||||||
XOInvitationOutput,
|
XOInvitationOutput,
|
||||||
XOInvitationVariable,
|
XOInvitationVariable,
|
||||||
XOInvitationVariableValue,
|
XOInvitationVariableValue,
|
||||||
|
XOTemplate,
|
||||||
} from "@xo-cash/types";
|
} from "@xo-cash/types";
|
||||||
import type { UnspentOutputData } from "@xo-cash/state";
|
import type { UnspentOutputData } from "@xo-cash/state";
|
||||||
import {
|
import {
|
||||||
@@ -29,12 +35,19 @@ import type { BlockchainService } 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";
|
||||||
|
import {
|
||||||
|
resolveCommitReferences,
|
||||||
|
type ResolvedInvitationData,
|
||||||
|
} from "../utils/resolve-invitation-data.js";
|
||||||
import { compileCashAssemblyString } from "@xo-cash/engine";
|
import { compileCashAssemblyString } from "@xo-cash/engine";
|
||||||
|
|
||||||
|
export type { ResolvedInvitationData } from "../utils/resolve-invitation-data.js";
|
||||||
|
|
||||||
export type InvitationEventMap = {
|
export type InvitationEventMap = {
|
||||||
"invitation-updated": XOInvitation;
|
"invitation-updated": XOInvitation;
|
||||||
"invitation-status-changed": string;
|
"invitation-status-changed": string;
|
||||||
"error": Error;
|
"invitation-removed": void;
|
||||||
|
error: Error;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type InvitationDependencies = {
|
export type InvitationDependencies = {
|
||||||
@@ -44,6 +57,13 @@ export type InvitationDependencies = {
|
|||||||
electrum: BlockchainService;
|
electrum: BlockchainService;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function stripLocalInvitationMetadata(invitation: XOInvitation): XOInvitation {
|
||||||
|
const { entityIdentifier: _entityIdentifier, ...sharedInvitation } =
|
||||||
|
invitation as XOInvitation & { entityIdentifier?: string };
|
||||||
|
|
||||||
|
return sharedInvitation;
|
||||||
|
}
|
||||||
|
|
||||||
export class Invitation extends EventEmitter<InvitationEventMap> {
|
export class Invitation extends EventEmitter<InvitationEventMap> {
|
||||||
/**
|
/**
|
||||||
* Create an invitation and start the SSE Session required for it.
|
* Create an invitation and start the SSE Session required for it.
|
||||||
@@ -85,15 +105,39 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
|
|||||||
throw new Error(`Template not found: ${invitation.templateIdentifier}`);
|
throw new Error(`Template not found: ${invitation.templateIdentifier}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create the invitation
|
// engine invitation (I have no idea if this is required)
|
||||||
const invitationInstance = new Invitation(invitation, dependencies);
|
const engineInvitation = await dependencies.engine.importInvitation(
|
||||||
|
serializeInvitation(invitation),
|
||||||
|
);
|
||||||
|
|
||||||
// Start the invitation and its tracking
|
// Create the invitation
|
||||||
await invitationInstance.start();
|
const invitationInstance = new Invitation(
|
||||||
|
engineInvitation,
|
||||||
|
dependencies,
|
||||||
|
template,
|
||||||
|
);
|
||||||
|
|
||||||
return invitationInstance;
|
return invitationInstance;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Flattened, template-enriched view of {@link Invitation.data}.
|
||||||
|
* Updated automatically whenever invitation data changes.
|
||||||
|
*/
|
||||||
|
public resolvedData: ResolvedInvitationData = {
|
||||||
|
invitationIdentifier: "",
|
||||||
|
templateIdentifier: "",
|
||||||
|
actionIdentifier: "",
|
||||||
|
variables: [],
|
||||||
|
inputs: [],
|
||||||
|
outputs: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The template used to enrich {@link resolvedData}.
|
||||||
|
*/
|
||||||
|
private template: XOTemplate;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The invitation data.
|
* The invitation data.
|
||||||
*/
|
*/
|
||||||
@@ -121,6 +165,7 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
|
|||||||
*/
|
*/
|
||||||
private storage: BaseStorage;
|
private storage: BaseStorage;
|
||||||
private electrum: BlockchainService;
|
private electrum: BlockchainService;
|
||||||
|
private sseUpdateQueue: Promise<void> = Promise.resolve();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 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).
|
||||||
@@ -130,23 +175,55 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
|
|||||||
/**
|
/**
|
||||||
* Create an invitation and start the SSE Session required for it.
|
* Create an invitation and start the SSE Session required for it.
|
||||||
*/
|
*/
|
||||||
constructor(invitation: XOInvitation, dependencies: InvitationDependencies) {
|
constructor(
|
||||||
|
invitation: XOInvitation,
|
||||||
|
dependencies: InvitationDependencies,
|
||||||
|
template: XOTemplate,
|
||||||
|
) {
|
||||||
super();
|
super();
|
||||||
|
|
||||||
this.data = invitation;
|
this.template = template;
|
||||||
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;
|
this.electrum = dependencies.electrum;
|
||||||
|
this.updateInvitationData(invitation);
|
||||||
|
|
||||||
// Create a listerner for the messages from the SSE Session (sync server)
|
// Apply SSE updates serially so each engine update sees the latest history.
|
||||||
this.syncServer.on("message", this.handleSSEMessage.bind(this));
|
this.syncServer.on("message", (event) => {
|
||||||
|
this.enqueueSyncUpdate(() => this.handleSSEMessage(event)).catch(
|
||||||
|
(error) => {
|
||||||
|
this.emit(
|
||||||
|
"error",
|
||||||
|
error instanceof Error ? error : new Error(String(error)),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates raw invitation data and recomputes {@link resolvedData}.
|
||||||
|
*/
|
||||||
|
private updateInvitationData(invitation: XOInvitation): void {
|
||||||
|
this.data = invitation;
|
||||||
|
this.resolvedData = resolveCommitReferences(invitation, this.template);
|
||||||
|
}
|
||||||
|
|
||||||
|
private enqueueSyncUpdate(update: () => Promise<void>): Promise<void> {
|
||||||
|
const queuedUpdate = this.sseUpdateQueue.then(update);
|
||||||
|
this.sseUpdateQueue = queuedUpdate.catch(() => {});
|
||||||
|
return queuedUpdate;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Start the invitation - Connect sync server and download latest invitation data.
|
* Start the invitation - Connect sync server and download latest invitation data.
|
||||||
*/
|
*/
|
||||||
async start(): Promise<void> {
|
async start(): Promise<void> {
|
||||||
|
// Persist immediately so imports survive sync-server outages and appear in the TUI
|
||||||
|
// after a CLI import or app restart.
|
||||||
|
await this.storage.set(this.data.invitationIdentifier, this.data);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Connect to the sync server and get the invitation (in parallel)
|
// Connect to the sync server and get the invitation (in parallel)
|
||||||
const [_, invitation] = await Promise.all([
|
const [_, invitation] = await Promise.all([
|
||||||
@@ -154,20 +231,34 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
|
|||||||
this.syncServer.getInvitation(this.data.invitationIdentifier),
|
this.syncServer.getInvitation(this.data.invitationIdentifier),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// There is a chance we get SSE messages before the invitation is returned, so we want to combine any commits
|
await this.enqueueSyncUpdate(async () => {
|
||||||
const sseCommits = this.data.commits;
|
// SSE messages can arrive before the GET request completes.
|
||||||
|
const combinedCommits = this.mergeCommits(
|
||||||
|
this.data.commits,
|
||||||
|
invitation?.commits ?? [],
|
||||||
|
);
|
||||||
|
|
||||||
// Merge the commits
|
try {
|
||||||
const combinedCommits = this.mergeCommits(
|
// Prefer keeping the engine's local invitation state in sync.
|
||||||
sseCommits,
|
this.updateInvitationData(
|
||||||
invitation?.commits ?? [],
|
stripLocalInvitationMetadata(
|
||||||
);
|
await this.engine.updateInvitation({
|
||||||
|
...this.data,
|
||||||
|
...invitation,
|
||||||
|
commits: combinedCommits,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
this.emit(
|
||||||
|
"error",
|
||||||
|
error instanceof Error ? error : new Error(String(error)),
|
||||||
|
);
|
||||||
|
this.updateInvitationData({ ...this.data, commits: combinedCommits });
|
||||||
|
}
|
||||||
|
|
||||||
// Set the invitation data with the combined commits
|
await this.storage.set(this.data.invitationIdentifier, this.data);
|
||||||
this.data = { ...this.data, ...invitation, commits: combinedCommits };
|
});
|
||||||
|
|
||||||
// Store the invitation in the storage
|
|
||||||
await this.storage.set(this.data.invitationIdentifier, this.data);
|
|
||||||
|
|
||||||
// Publish the invitation to the sync server
|
// Publish the invitation to the sync server
|
||||||
this.publishInvitation(this.data);
|
this.publishInvitation(this.data);
|
||||||
@@ -175,8 +266,6 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
|
|||||||
// Compute and emit initial status
|
// Compute and emit initial status
|
||||||
await this.updateStatus();
|
await this.updateStatus();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// console.error(`Error starting invitation, could not connect to sync server or get invitation`, err);
|
|
||||||
// Emit the error event. We might want to throw? but we need a better way of handling errors in the invitation system because we need the invitation to successfully initialize.
|
|
||||||
this.emit("error", err instanceof Error ? err : new Error(String(err)));
|
this.emit("error", err instanceof Error ? err : new Error(String(err)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -186,42 +275,101 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
|
|||||||
*
|
*
|
||||||
* TODO: Invitation should sync up the initial data (top level) then everything after that should be the commits. This makes it easier to merge as we go instead of just having to overwrite the entire invitation.
|
* TODO: Invitation should sync up the initial data (top level) then everything after that should be the commits. This makes it easier to merge as we go instead of just having to overwrite the entire invitation.
|
||||||
*/
|
*/
|
||||||
private handleSSEMessage(event: SSEvent): void {
|
private async handleSSEMessage(event: SSEvent): Promise<void> {
|
||||||
const data = JSON.parse(event.data) as { topic?: string; data?: unknown };
|
const invitation = this.parseInvitationFromSSEMessage(event);
|
||||||
if (data.topic === "invitation-updated") {
|
if (
|
||||||
const invitation = decodeExtendedJsonObject(data.data) as XOInvitation;
|
!invitation ||
|
||||||
|
invitation.invitationIdentifier !== this.data.invitationIdentifier
|
||||||
if (invitation.invitationIdentifier !== this.data.invitationIdentifier) {
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
|
||||||
|
|
||||||
// Filter out commits that already exist (probably a faster way to do this. This is n^2)
|
|
||||||
const newCommits = this.mergeCommits(
|
|
||||||
this.data.commits,
|
|
||||||
invitation.commits,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Set the new commits
|
|
||||||
this.data = { ...this.data, commits: newCommits };
|
|
||||||
|
|
||||||
// Calculate the new status of the invitation (fire-and-forget; handler is sync)
|
|
||||||
this.updateStatus().catch(() => {});
|
|
||||||
|
|
||||||
// Emit the updated event
|
|
||||||
this.emit("invitation-updated", this.data);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Filter out commits that already exist
|
||||||
|
const newCommits = this.mergeCommits(this.data.commits, invitation.commits);
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.updateInvitationData(
|
||||||
|
stripLocalInvitationMetadata(
|
||||||
|
await this.engine.updateInvitation({
|
||||||
|
...this.data,
|
||||||
|
...invitation,
|
||||||
|
commits: newCommits,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
this.emit(
|
||||||
|
"error",
|
||||||
|
error instanceof Error ? error : new Error(String(error)),
|
||||||
|
);
|
||||||
|
this.updateInvitationData({ ...this.data, commits: newCommits });
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.storage.set(this.data.invitationIdentifier, this.data);
|
||||||
|
await this.updateStatus();
|
||||||
|
this.emit("invitation-updated", this.data);
|
||||||
|
}
|
||||||
|
|
||||||
|
private parseInvitationFromSSEMessage(event: SSEvent): XOInvitation | null {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(event.data) as unknown;
|
||||||
|
const payload =
|
||||||
|
event.event === "invitation-updated"
|
||||||
|
? this.unwrapInvitationUpdatedPayload(parsed)
|
||||||
|
: this.unwrapLegacyInvitationUpdatedPayload(parsed);
|
||||||
|
|
||||||
|
if (!payload) return null;
|
||||||
|
|
||||||
|
const decoded = decodeExtendedJsonObject(payload) as XOInvitation;
|
||||||
|
return stripLocalInvitationMetadata(
|
||||||
|
deserializeInvitation(serializeInvitation(decoded)),
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private unwrapInvitationUpdatedPayload(payload: unknown): unknown | null {
|
||||||
|
if (
|
||||||
|
payload &&
|
||||||
|
typeof payload === "object" &&
|
||||||
|
"topic" in payload &&
|
||||||
|
"data" in payload
|
||||||
|
) {
|
||||||
|
return this.unwrapLegacyInvitationUpdatedPayload(payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
private unwrapLegacyInvitationUpdatedPayload(
|
||||||
|
payload: unknown,
|
||||||
|
): unknown | null {
|
||||||
|
if (
|
||||||
|
payload &&
|
||||||
|
typeof payload === "object" &&
|
||||||
|
"topic" in payload &&
|
||||||
|
"data" in payload &&
|
||||||
|
payload.topic === "invitation-updated"
|
||||||
|
) {
|
||||||
|
return payload.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Publish the invitation to the sync server
|
* Publish the invitation to the sync server
|
||||||
*/
|
*/
|
||||||
private async publishInvitation(invitation: XOInvitation = this.data): Promise<void> {
|
private async publishInvitation(
|
||||||
try {
|
invitation: XOInvitation = this.data,
|
||||||
await this.syncServer.publishInvitation(invitation);
|
): Promise<void> {
|
||||||
} catch (err) {
|
this.syncServer.publishInvitation(invitation).catch((error) => {
|
||||||
// Emit the error event. We might want to throw? but we need a better way of handling errors in the invitation system because we need the invitation to successfully initialize.
|
this.emit(
|
||||||
this.emit("error", err instanceof Error ? err : new Error(String(err)));
|
"error",
|
||||||
}
|
error instanceof Error ? error : new Error(String(error)),
|
||||||
|
);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -274,7 +422,10 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
|
|||||||
private async computeStatusInternal(): Promise<string> {
|
private async computeStatusInternal(): Promise<string> {
|
||||||
let missingReqs;
|
let missingReqs;
|
||||||
try {
|
try {
|
||||||
missingReqs = await this.engine.listMissingRequirements(this.data);
|
const missingRequirements = await this.engine.listMissingRequirements(
|
||||||
|
this.data.invitationIdentifier,
|
||||||
|
);
|
||||||
|
missingReqs = missingRequirements.templateRequirements;
|
||||||
} catch {
|
} catch {
|
||||||
return "unknown";
|
return "unknown";
|
||||||
}
|
}
|
||||||
@@ -365,31 +516,61 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
|
|||||||
* 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.
|
||||||
*/
|
*/
|
||||||
private async updateStatus(): Promise<void> {
|
private async updateStatus(): Promise<void> {
|
||||||
const status = await this.computeStatus();
|
this.computeStatus()
|
||||||
this.status = status;
|
.then((status) => {
|
||||||
this.emit("invitation-status-changed", status);
|
this.status = status;
|
||||||
|
this.emit("invitation-status-changed", status);
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
this.status = `error (${error instanceof Error ? error.message : String(error)})`;
|
||||||
|
this.emit(
|
||||||
|
"error",
|
||||||
|
error instanceof Error ? error : new Error(String(error)),
|
||||||
|
);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Accept the invitation
|
* Accept the invitation
|
||||||
*/
|
*/
|
||||||
async accept(acceptParams?: AcceptInvitationParameters): Promise<void> {
|
async accept(acceptParams?: InvitationParameters): Promise<void> {
|
||||||
// Accept the invitation
|
// Accept the invitation
|
||||||
this.data = await this.engine.acceptInvitation(this.data, acceptParams);
|
this.updateInvitationData(
|
||||||
|
await this.engine.acceptInvitation(this.data, acceptParams),
|
||||||
|
);
|
||||||
|
|
||||||
// Sync the invitation to the sync server
|
// Sync the invitation to the sync server
|
||||||
this.publishInvitation(this.data);
|
await this.publishInvitation(this.data);
|
||||||
|
|
||||||
|
// Store the accepted invitation and notify reactive consumers.
|
||||||
|
await this.storage.set(this.data.invitationIdentifier, this.data);
|
||||||
|
this.emit("invitation-updated", this.data);
|
||||||
|
|
||||||
// Update the status of the invitation
|
// Update the status of the invitation
|
||||||
await this.updateStatus();
|
await this.updateStatus();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Accept the invitation once for this engine entity so future appends have a root commit.
|
||||||
|
*/
|
||||||
|
async ensureAccepted(): Promise<void> {
|
||||||
|
const ownCommits = await this.engine.findOwnCommits(
|
||||||
|
this.data.invitationIdentifier,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (ownCommits.length === 0) {
|
||||||
|
await this.accept();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sign the invitation
|
* Sign the invitation
|
||||||
*/
|
*/
|
||||||
async sign(): Promise<void> {
|
async sign(): Promise<void> {
|
||||||
// Sign the invitation
|
// Sign the invitation
|
||||||
const signedInvitation = await this.engine.signInvitation(this.data);
|
const signedInvitation = await this.engine.signInvitation(
|
||||||
|
this.data.invitationIdentifier,
|
||||||
|
);
|
||||||
|
|
||||||
// Publish the signed invitation to the sync server
|
// Publish the signed invitation to the sync server
|
||||||
this.publishInvitation(signedInvitation);
|
this.publishInvitation(signedInvitation);
|
||||||
@@ -397,7 +578,7 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
|
|||||||
// Store the signed invitation in the storage
|
// Store the signed invitation in the storage
|
||||||
await this.storage.set(this.data.invitationIdentifier, signedInvitation);
|
await this.storage.set(this.data.invitationIdentifier, signedInvitation);
|
||||||
|
|
||||||
this.data = signedInvitation;
|
this.updateInvitationData(signedInvitation);
|
||||||
|
|
||||||
// Update the status of the invitation
|
// Update the status of the invitation
|
||||||
await this.updateStatus();
|
await this.updateStatus();
|
||||||
@@ -408,9 +589,12 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
|
|||||||
* @returns The transaction hash returned by the network after broadcast.
|
* @returns The transaction hash returned by the network after broadcast.
|
||||||
*/
|
*/
|
||||||
async broadcast(): Promise<string> {
|
async broadcast(): Promise<string> {
|
||||||
const txHash = await this.engine.executeAction(this.data, {
|
const txHash = await this.engine.executeAction(
|
||||||
broadcastTransaction: true,
|
this.data.invitationIdentifier,
|
||||||
});
|
{
|
||||||
|
broadcastTransaction: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
await this.updateStatus();
|
await this.updateStatus();
|
||||||
|
|
||||||
@@ -424,9 +608,16 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
|
|||||||
/**
|
/**
|
||||||
* Append a commit to the invitation
|
* Append a commit to the invitation
|
||||||
*/
|
*/
|
||||||
async append(data: AppendInvitationParameters): Promise<void> {
|
async append(data: InvitationParameters): Promise<void> {
|
||||||
|
await this.ensureAccepted();
|
||||||
|
|
||||||
// Append the commit to the invitation
|
// Append the commit to the invitation
|
||||||
this.data = await this.engine.appendInvitation(this.data, data);
|
this.updateInvitationData(
|
||||||
|
await this.engine.appendInvitation(
|
||||||
|
this.data.invitationIdentifier,
|
||||||
|
data,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
// Sync the invitation to the sync server
|
// Sync the invitation to the sync server
|
||||||
await this.publishInvitation(this.data);
|
await this.publishInvitation(this.data);
|
||||||
@@ -481,12 +672,53 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async findSuitableResources(
|
async findSuitableResources(
|
||||||
options: Partial<FindSuitableResourcesParameters> = {},
|
options: Partial<GetSpendableResourcesParameters> = {},
|
||||||
): Promise<UnspentOutputData[]> {
|
): Promise<UnspentOutputData[]> {
|
||||||
// Find the suitable resources
|
const templateIdentifier =
|
||||||
const { unspentOutputs } = await this.engine.findSuitableResources(
|
options.templateIdentifier ?? this.data.templateIdentifier;
|
||||||
this.data,
|
const template = await this.engine.getTemplate(templateIdentifier);
|
||||||
options,
|
const fallbackOutputIdentifier = Object.keys(template?.outputs ?? {})[0];
|
||||||
|
if (!fallbackOutputIdentifier && !options.outputIdentifier) {
|
||||||
|
throw new Error(
|
||||||
|
`No output identifiers found for template: ${templateIdentifier}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// const resolvedOptions: GetSpendableResourcesParameters = {
|
||||||
|
// templateIdentifier,
|
||||||
|
// outputIdentifier: options.outputIdentifier ?? fallbackOutputIdentifier ?? "",
|
||||||
|
// };
|
||||||
|
|
||||||
|
// There are disagreements around whether all spendables should be returned from getSpendableResources.
|
||||||
|
// I had a fix merged in, but it got overwritten. So, im just going to get all of them manually and go around
|
||||||
|
// The engine's expectations.
|
||||||
|
// To do this, we are going to grab all out templates
|
||||||
|
const templates = await this.engine.listImportedTemplates();
|
||||||
|
|
||||||
|
// For each template, we need to create a 2d array of all the outputs
|
||||||
|
const outputs = templates.map((template) => {
|
||||||
|
return Object.keys(template.outputs).map((output) => {
|
||||||
|
const templateIdentifier = generateTemplateIdentifier(template);
|
||||||
|
|
||||||
|
return {
|
||||||
|
templateIdentifier,
|
||||||
|
outputIdentifier: output,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// then, for each output, we need to get the spendable resources
|
||||||
|
const spendableResources = await Promise.all(
|
||||||
|
outputs.flat().map((output) => {
|
||||||
|
return this.engine.getSpendableResources(this.data, {
|
||||||
|
templateIdentifier: output.templateIdentifier,
|
||||||
|
outputIdentifier: output.outputIdentifier,
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const unspentOutputs = spendableResources.flatMap(
|
||||||
|
(resource) => resource.unspentOutputs,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Update the status of the invitation
|
// Update the status of the invitation
|
||||||
@@ -504,7 +736,7 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
|
|||||||
* Get the missing requirements for the invitation
|
* Get the missing requirements for the invitation
|
||||||
*/
|
*/
|
||||||
async getMissingRequirements() {
|
async getMissingRequirements() {
|
||||||
return this.engine.listMissingRequirements(this.data);
|
return this.engine.listMissingRequirements(this.data.invitationIdentifier);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -566,8 +798,8 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const valueSatoshisIdentifier = output.valueSatoshis;
|
const valueSatoshisExpression = output.valueSatoshis;
|
||||||
if (!valueSatoshisIdentifier) {
|
if (!valueSatoshisExpression) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Value satoshis identifier not found: ${outputIdentifier} in template: ${this.data.templateIdentifier}`,
|
`Value satoshis identifier not found: ${outputIdentifier} in template: ${this.data.templateIdentifier}`,
|
||||||
);
|
);
|
||||||
@@ -581,17 +813,19 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
|
|||||||
// Create a dictionary of the variables
|
// Create a dictionary of the variables
|
||||||
const formattedVariables = variables.reduce(
|
const formattedVariables = variables.reduce(
|
||||||
(acc, v) => {
|
(acc, v) => {
|
||||||
acc[v.variableIdentifier ?? ""] = v.value;
|
const { variableIdentifier, value } = v;
|
||||||
|
acc[variableIdentifier ?? ""] = value;
|
||||||
return acc;
|
return acc;
|
||||||
},
|
},
|
||||||
{} as Record<string, XOInvitationVariableValue>,
|
{} as Record<string, XOInvitationVariableValue>,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Compile the CashAssembly expression to get the value satoshis (It handles the variable replacement for us)
|
// Compile the CashAssembly expression to get the value satoshis (It handles the variable replacement for us)
|
||||||
const valueSatoshis = await compileCashAssemblyString(
|
const valueSatoshis = compileCashAssemblyString({
|
||||||
String(valueSatoshisIdentifier),
|
cashAssemblyText: String(valueSatoshisExpression),
|
||||||
formattedVariables,
|
variables: formattedVariables,
|
||||||
);
|
evaluationDecodeMode: "bigint",
|
||||||
|
});
|
||||||
|
|
||||||
// Return the value satoshis as a bigint
|
// Return the value satoshis as a bigint
|
||||||
// TODO: Check this of a vulnerability or crash - I assume there might be one if someone made the `valueSatoshis` a malicious expression
|
// TODO: Check this of a vulnerability or crash - I assume there might be one if someone made the `valueSatoshis` a malicious expression
|
||||||
@@ -646,12 +880,31 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
|
|||||||
// Iterate through the outputs and sum the valueSatoshis
|
// Iterate through the outputs and sum the valueSatoshis
|
||||||
for (const output of outputs) {
|
for (const output of outputs) {
|
||||||
if (typeof output === "string") {
|
if (typeof output === "string") {
|
||||||
totalSats += await this.getSatsOut(output);
|
const sats = await this.getSatsOut(output);
|
||||||
|
totalSats += sats;
|
||||||
} else {
|
} else {
|
||||||
totalSats += await this.getSatsOut(output.output);
|
const sats = await this.getSatsOut(output.output);
|
||||||
|
totalSats += sats;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return totalSats;
|
return totalSats;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Removes the invitation from the Local SQLite db as well as the Engine's internal DB
|
||||||
|
* NOTE: This uses methods that are marked "DANGEROUSLY" inside the engine and behaviour may change
|
||||||
|
*/
|
||||||
|
public async delete() {
|
||||||
|
// Remove the invitation from our local db
|
||||||
|
this.storage.remove(this.data.invitationIdentifier);
|
||||||
|
|
||||||
|
// Remove the invitation from the engine's internal db
|
||||||
|
await this.engine.DANGEROUS_deleteStoredInvitation(this.data.invitationIdentifier);
|
||||||
|
|
||||||
|
this.emit("invitation-removed", this.data.invitationIdentifier);
|
||||||
|
|
||||||
|
// Update the status of the invitation
|
||||||
|
await this.updateStatus();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
225
src/services/rates.ts
Normal file
225
src/services/rates.ts
Normal file
@@ -0,0 +1,225 @@
|
|||||||
|
import { OracleClient } from "@generalprotocols/oracle-client";
|
||||||
|
import { EventEmitter } from "../utils/event-emitter.js";
|
||||||
|
import { type RatesEventMap } from "../utils/rates/base-rates.js";
|
||||||
|
import { RatesOracle } from "../utils/rates/rates-oracles.js";
|
||||||
|
import { SettingsService } from "./settings.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Event map emitted by {@link RatesService}.
|
||||||
|
*/
|
||||||
|
export type RatesServiceEventMap = {
|
||||||
|
"rate-updated": {
|
||||||
|
numeratorUnitCode: string;
|
||||||
|
denominatorUnitCode: string;
|
||||||
|
price: number;
|
||||||
|
pair: string;
|
||||||
|
updatedAt: number;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* In-memory representation of a market rate.
|
||||||
|
*/
|
||||||
|
type CachedRate = {
|
||||||
|
price: number;
|
||||||
|
updatedAt: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Minimal adapter contract that RatesService depends on.
|
||||||
|
*
|
||||||
|
* Using a small interface keeps the service decoupled and avoids inheriting
|
||||||
|
* implementation-specific type constraints from concrete adapters.
|
||||||
|
*/
|
||||||
|
export interface RatesAdapter {
|
||||||
|
start(): Promise<void>;
|
||||||
|
stop(): Promise<void>;
|
||||||
|
listPairs(): Promise<Set<string>>;
|
||||||
|
formatCurrency(amount: number, targetCurrency: string): string;
|
||||||
|
on(
|
||||||
|
type: "rateUpdated",
|
||||||
|
listener: (detail: RatesEventMap["rateUpdated"]) => void,
|
||||||
|
): () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Orchestrates the rates adapter lifecycle and provides BCH -> fiat helpers
|
||||||
|
* for the TUI.
|
||||||
|
*
|
||||||
|
* This service keeps a small in-memory snapshot of the latest prices and emits
|
||||||
|
* a normalized event whenever a pair changes. React components can subscribe
|
||||||
|
* through `useSyncExternalStore` for clean and predictable reactivity.
|
||||||
|
*/
|
||||||
|
export class RatesService extends EventEmitter<RatesServiceEventMap> {
|
||||||
|
private readonly adapter: RatesAdapter;
|
||||||
|
private readonly settings: SettingsService;
|
||||||
|
private readonly ratesByPair = new Map<string, CachedRate>();
|
||||||
|
private unsubscribeFromAdapter: (() => void) | null = null;
|
||||||
|
private started = false;
|
||||||
|
|
||||||
|
constructor(adapter: RatesAdapter, settings: SettingsService) {
|
||||||
|
super();
|
||||||
|
this.adapter = adapter;
|
||||||
|
this.settings = settings;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a rates service.
|
||||||
|
*
|
||||||
|
* If no adapter is passed, this defaults to the Oracle-backed adapter.
|
||||||
|
*/
|
||||||
|
public static async create(
|
||||||
|
settings: SettingsService,
|
||||||
|
adapter?: RatesAdapter,
|
||||||
|
): Promise<RatesService> {
|
||||||
|
if (adapter) {
|
||||||
|
return new RatesService(adapter, settings);
|
||||||
|
}
|
||||||
|
|
||||||
|
const oracleClient = new OracleClient();
|
||||||
|
oracleClient.start();
|
||||||
|
|
||||||
|
const ratesOracle = new RatesOracle(oracleClient, settings);
|
||||||
|
ratesOracle.start();
|
||||||
|
|
||||||
|
return new RatesService(ratesOracle, settings);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Starts the underlying adapter and begins collecting live updates.
|
||||||
|
*/
|
||||||
|
public async start(): Promise<void> {
|
||||||
|
if (this.started) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.started = true;
|
||||||
|
|
||||||
|
this.unsubscribeFromAdapter = this.adapter.on("rateUpdated", (event) => {
|
||||||
|
this.handleRateUpdated(event);
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.adapter.start();
|
||||||
|
} catch (error) {
|
||||||
|
this.unsubscribeFromAdapter?.();
|
||||||
|
this.unsubscribeFromAdapter = null;
|
||||||
|
this.started = false;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stops live rate collection.
|
||||||
|
*/
|
||||||
|
public async stop(): Promise<void> {
|
||||||
|
if (!this.started) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.started = false;
|
||||||
|
|
||||||
|
this.unsubscribeFromAdapter?.();
|
||||||
|
this.unsubscribeFromAdapter = null;
|
||||||
|
|
||||||
|
await this.adapter.stop();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the latest price for a pair in NUMERATOR/DENOMINATOR form.
|
||||||
|
*
|
||||||
|
* Example: `getRate("USD", "BCH")`.
|
||||||
|
*/
|
||||||
|
public getRate(
|
||||||
|
numeratorUnitCode: string,
|
||||||
|
denominatorUnitCode: string,
|
||||||
|
): number | null {
|
||||||
|
const pair = this.getPairKey(numeratorUnitCode, denominatorUnitCode);
|
||||||
|
return this.ratesByPair.get(pair)?.price ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts satoshis to fiat using the latest BCH/fiat rate.
|
||||||
|
*
|
||||||
|
* Example: `convertBchToFiat(1234n, "USD")`.
|
||||||
|
*/
|
||||||
|
public convertBchToFiat(
|
||||||
|
satoshis: bigint,
|
||||||
|
targetCurrency: string = "USD",
|
||||||
|
): number | null {
|
||||||
|
const rate = this.getRate(targetCurrency, "BCH");
|
||||||
|
if (rate === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const amountInBch = Number(satoshis) / 100_000_000;
|
||||||
|
return amountInBch * rate;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formats a BCH -> fiat converted amount using the adapter formatter.
|
||||||
|
*/
|
||||||
|
public formatBchToFiat(
|
||||||
|
satoshis: bigint,
|
||||||
|
targetCurrency: string = "USD",
|
||||||
|
): string | null {
|
||||||
|
const normalizedCurrency = targetCurrency.toUpperCase();
|
||||||
|
const amount = this.convertBchToFiat(satoshis, normalizedCurrency);
|
||||||
|
if (amount === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return this.adapter.formatCurrency(amount, normalizedCurrency);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formats an arbitrary fiat amount in a currency-aware way.
|
||||||
|
*/
|
||||||
|
public formatCurrency(amount: number, currencyCode: string): string {
|
||||||
|
return this.adapter.formatCurrency(amount, currencyCode.toUpperCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lists available market pairs in NUMERATOR/DENOMINATOR format.
|
||||||
|
*/
|
||||||
|
public async listPairs(): Promise<Set<string>> {
|
||||||
|
return this.adapter.listPairs();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the fiat currency currently configured in settings.
|
||||||
|
*/
|
||||||
|
public getConfiguredCurrency(): string {
|
||||||
|
return this.settings.getCurrency();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles normalized updates from the underlying adapter.
|
||||||
|
*/
|
||||||
|
private handleRateUpdated(event: RatesEventMap["rateUpdated"]): void {
|
||||||
|
const numeratorUnitCode = event.numeratorUnitCode.toUpperCase();
|
||||||
|
const denominatorUnitCode = event.denominatorUnitCode.toUpperCase();
|
||||||
|
const pair = this.getPairKey(numeratorUnitCode, denominatorUnitCode);
|
||||||
|
const updatedAt = Date.now();
|
||||||
|
|
||||||
|
this.ratesByPair.set(pair, {
|
||||||
|
price: event.price,
|
||||||
|
updatedAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.emit("rate-updated", {
|
||||||
|
numeratorUnitCode,
|
||||||
|
denominatorUnitCode,
|
||||||
|
price: event.price,
|
||||||
|
pair,
|
||||||
|
updatedAt,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a stable key for pair lookups.
|
||||||
|
*/
|
||||||
|
private getPairKey(
|
||||||
|
numeratorUnitCode: string,
|
||||||
|
denominatorUnitCode: string,
|
||||||
|
): string {
|
||||||
|
return `${numeratorUnitCode.toUpperCase()}/${denominatorUnitCode.toUpperCase()}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
196
src/services/settings.ts
Normal file
196
src/services/settings.ts
Normal file
@@ -0,0 +1,196 @@
|
|||||||
|
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
||||||
|
|
||||||
|
import { EventEmitter } from "../utils/event-emitter.js";
|
||||||
|
import { getSettingsPath } from "../utils/paths.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Supported persisted settings keys.
|
||||||
|
*/
|
||||||
|
export type SettingsData = {
|
||||||
|
"default-mnemonic"?: string;
|
||||||
|
currency: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Event payloads emitted by {@link SettingsService}.
|
||||||
|
*/
|
||||||
|
export type SettingsServiceEventMap = {
|
||||||
|
"settings-updated": {
|
||||||
|
key: keyof SettingsData;
|
||||||
|
value: string | undefined;
|
||||||
|
settings: SettingsData;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runtime defaults for settings that should always exist in memory.
|
||||||
|
*/
|
||||||
|
const DEFAULT_SETTINGS: SettingsData = {
|
||||||
|
currency: "USD",
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles loading, migrating, and persisting wallet settings.
|
||||||
|
*
|
||||||
|
* The backing file is `<XO_CONFIG_DIR>/.wallet`. Historically it stored a raw
|
||||||
|
* mnemonic reference string. This service migrates that legacy format to JSON:
|
||||||
|
* `{ "default-mnemonic": "<value>", "currency": "USD" }`.
|
||||||
|
*/
|
||||||
|
export class SettingsService extends EventEmitter<SettingsServiceEventMap> {
|
||||||
|
private readonly settingsPath: string;
|
||||||
|
private settings: SettingsData;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a new settings service instance.
|
||||||
|
*
|
||||||
|
* @param settingsPath - Optional custom settings file path (useful for tests)
|
||||||
|
*/
|
||||||
|
constructor(settingsPath: string = getSettingsPath()) {
|
||||||
|
super();
|
||||||
|
this.settingsPath = settingsPath;
|
||||||
|
this.settings = this.loadSettings();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the current settings snapshot.
|
||||||
|
*/
|
||||||
|
public getSettings(): SettingsData {
|
||||||
|
return { ...this.settings };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the currently selected default mnemonic reference.
|
||||||
|
*/
|
||||||
|
public getDefaultMnemonic(): string | undefined {
|
||||||
|
return this.settings["default-mnemonic"];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates the default mnemonic reference and persists it to disk.
|
||||||
|
*/
|
||||||
|
public setDefaultMnemonic(mnemonicRef: string): void {
|
||||||
|
const normalizedMnemonicRef = mnemonicRef.trim();
|
||||||
|
if (normalizedMnemonicRef.length === 0) {
|
||||||
|
throw new Error("default-mnemonic cannot be empty");
|
||||||
|
}
|
||||||
|
|
||||||
|
this.settings["default-mnemonic"] = normalizedMnemonicRef;
|
||||||
|
this.persistSettings();
|
||||||
|
|
||||||
|
this.emit("settings-updated", {
|
||||||
|
key: "default-mnemonic",
|
||||||
|
value: normalizedMnemonicRef,
|
||||||
|
settings: this.getSettings(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the selected fiat currency code (ISO-like uppercase).
|
||||||
|
*/
|
||||||
|
public getCurrency(): string {
|
||||||
|
return this.settings.currency;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates the selected fiat currency and persists it to disk.
|
||||||
|
*/
|
||||||
|
public setCurrency(currencyCode: string): void {
|
||||||
|
const normalizedCurrency = this.normalizeCurrency(currencyCode);
|
||||||
|
if (this.settings.currency === normalizedCurrency) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.settings.currency = normalizedCurrency;
|
||||||
|
this.persistSettings();
|
||||||
|
|
||||||
|
this.emit("settings-updated", {
|
||||||
|
key: "currency",
|
||||||
|
value: normalizedCurrency,
|
||||||
|
settings: this.getSettings(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads and normalizes the settings file from disk.
|
||||||
|
*
|
||||||
|
* If the file contains the old legacy format (raw mnemonic string), the
|
||||||
|
* migrated JSON shape is written back immediately.
|
||||||
|
*/
|
||||||
|
private loadSettings(): SettingsData {
|
||||||
|
if (!existsSync(this.settingsPath)) {
|
||||||
|
return { ...DEFAULT_SETTINGS };
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawContents = readFileSync(this.settingsPath, "utf8").trim();
|
||||||
|
if (rawContents.length === 0) {
|
||||||
|
return { ...DEFAULT_SETTINGS };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(rawContents);
|
||||||
|
const normalized = this.normalizeSettings(parsed);
|
||||||
|
return normalized;
|
||||||
|
} catch {
|
||||||
|
const migrated = this.normalizeSettings({
|
||||||
|
"default-mnemonic": rawContents,
|
||||||
|
});
|
||||||
|
this.persistSettings(migrated);
|
||||||
|
return migrated;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Writes the given settings object to disk as pretty JSON.
|
||||||
|
*
|
||||||
|
* @param nextSettings - Optional explicit value, defaults to in-memory state
|
||||||
|
*/
|
||||||
|
private persistSettings(nextSettings?: SettingsData): void {
|
||||||
|
if (nextSettings) {
|
||||||
|
this.settings = nextSettings;
|
||||||
|
}
|
||||||
|
|
||||||
|
writeFileSync(
|
||||||
|
this.settingsPath,
|
||||||
|
`${JSON.stringify(this.settings, null, 2)}\n`,
|
||||||
|
"utf8",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Coerces unknown input into a safe settings object.
|
||||||
|
*/
|
||||||
|
private normalizeSettings(input: unknown): SettingsData {
|
||||||
|
const normalized: SettingsData = {
|
||||||
|
...DEFAULT_SETTINGS,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!input || typeof input !== "object") {
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
const maybeMnemonic = (input as Record<string, unknown>)[
|
||||||
|
"default-mnemonic"
|
||||||
|
];
|
||||||
|
if (typeof maybeMnemonic === "string" && maybeMnemonic.trim().length > 0) {
|
||||||
|
normalized["default-mnemonic"] = maybeMnemonic.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
const maybeCurrency = (input as Record<string, unknown>).currency;
|
||||||
|
if (typeof maybeCurrency === "string" && maybeCurrency.trim().length > 0) {
|
||||||
|
normalized.currency = this.normalizeCurrency(maybeCurrency);
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensures currency values stay uppercase and non-empty.
|
||||||
|
*/
|
||||||
|
private normalizeCurrency(currencyCode: string): string {
|
||||||
|
const normalizedCurrency = currencyCode.trim().toUpperCase();
|
||||||
|
if (normalizedCurrency.length === 0) {
|
||||||
|
throw new Error("currency cannot be empty");
|
||||||
|
}
|
||||||
|
return normalizedCurrency;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,12 @@
|
|||||||
import Database from "better-sqlite3";
|
import Database from "better-sqlite3";
|
||||||
import { decodeExtendedJson, encodeExtendedJson } from "../utils/ext-json.js";
|
import { deserializeInvitation, serializeInvitation } from "@xo-cash/engine";
|
||||||
|
import type { XOInvitation } from "@xo-cash/types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This is not an actual storage adapter that we want to make use of. This storage adapter is a stop-gap while the engine is under development.
|
||||||
|
* At the time of writing the storage adapter, the engine provided no way to read data about your currenty invitations, so that is where this is coming in.
|
||||||
|
* Its providing a Developer facing way to store/read the invitation data and then we can just import them into the engine whenever we want to interact with an invitation.
|
||||||
|
*/
|
||||||
export abstract class BaseStorage {
|
export abstract class BaseStorage {
|
||||||
abstract all(): Promise<{ key: string; value: any }[]>;
|
abstract all(): Promise<{ key: string; value: any }[]>;
|
||||||
abstract set(key: string, value: any): Promise<void>;
|
abstract set(key: string, value: any): Promise<void>;
|
||||||
@@ -10,6 +16,9 @@ export abstract class BaseStorage {
|
|||||||
abstract child(key: string): BaseStorage;
|
abstract child(key: string): BaseStorage;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SQLite Database Storage Adapter.
|
||||||
|
*/
|
||||||
export class Storage extends BaseStorage {
|
export class Storage extends BaseStorage {
|
||||||
static async create(dbPath: string): Promise<Storage> {
|
static async create(dbPath: string): Promise<Storage> {
|
||||||
// Create the database
|
// Create the database
|
||||||
@@ -48,9 +57,8 @@ export class Storage extends BaseStorage {
|
|||||||
return fullKey.startsWith(prefix) ? fullKey.slice(prefix.length) : fullKey;
|
return fullKey.startsWith(prefix) ? fullKey.slice(prefix.length) : fullKey;
|
||||||
}
|
}
|
||||||
|
|
||||||
async set(key: string, value: any): Promise<void> {
|
async set(key: string, value: XOInvitation): Promise<void> {
|
||||||
// Encode the extended json object
|
const encodedValue = serializeInvitation(value);
|
||||||
const encodedValue = encodeExtendedJson(value);
|
|
||||||
|
|
||||||
// Insert or replace the value into the database with full key (including basePath)
|
// Insert or replace the value into the database with full key (including basePath)
|
||||||
const fullKey = this.getFullKey(key);
|
const fullKey = this.getFullKey(key);
|
||||||
@@ -85,10 +93,10 @@ export class Storage extends BaseStorage {
|
|||||||
return !strippedKey.includes(".");
|
return !strippedKey.includes(".");
|
||||||
});
|
});
|
||||||
|
|
||||||
// Decode the extended json objects and strip basePath from keys
|
// Deserialize invitations and strip basePath from keys
|
||||||
return filteredRows.map((row) => ({
|
return filteredRows.map((row) => ({
|
||||||
key: this.stripBasePath(row.key),
|
key: this.stripBasePath(row.key),
|
||||||
value: decodeExtendedJson(row.value),
|
value: deserializeInvitation(row.value),
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -103,7 +111,7 @@ export class Storage extends BaseStorage {
|
|||||||
if (!row) return null;
|
if (!row) return null;
|
||||||
|
|
||||||
// Decode the extended json object
|
// Decode the extended json object
|
||||||
return decodeExtendedJson(row.value);
|
return deserializeInvitation(row.value);
|
||||||
}
|
}
|
||||||
|
|
||||||
async remove(key: string): Promise<void> {
|
async remove(key: string): Promise<void> {
|
||||||
@@ -134,6 +142,9 @@ export class Storage extends BaseStorage {
|
|||||||
*
|
*
|
||||||
* This adapter is useful for tests and short-lived sessions where persisted
|
* This adapter is useful for tests and short-lived sessions where persisted
|
||||||
* SQLite state is not needed.
|
* SQLite state is not needed.
|
||||||
|
*
|
||||||
|
* TODO: Move this somewhere else. There is no reason for this to be in the main codebase. We should put this stricly in the tests beacuse that were its actually being used.
|
||||||
|
* Ideally, we would provide these kind of generic fills as part of our packages somewhere, but these interfaces dont fit our current design.
|
||||||
*/
|
*/
|
||||||
export class InMemoryStorage extends BaseStorage {
|
export class InMemoryStorage extends BaseStorage {
|
||||||
static async create(): Promise<InMemoryStorage> {
|
static async create(): Promise<InMemoryStorage> {
|
||||||
@@ -163,9 +174,9 @@ export class InMemoryStorage extends BaseStorage {
|
|||||||
return fullKey.startsWith(prefix) ? fullKey.slice(prefix.length) : fullKey;
|
return fullKey.startsWith(prefix) ? fullKey.slice(prefix.length) : fullKey;
|
||||||
}
|
}
|
||||||
|
|
||||||
async set(key: string, value: any): Promise<void> {
|
async set(key: string, value: XOInvitation): Promise<void> {
|
||||||
const fullKey = this.getFullKey(key);
|
const fullKey = this.getFullKey(key);
|
||||||
const encodedValue = encodeExtendedJson(value);
|
const encodedValue = serializeInvitation(value);
|
||||||
this.store.set(fullKey, encodedValue);
|
this.store.set(fullKey, encodedValue);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -188,7 +199,7 @@ export class InMemoryStorage extends BaseStorage {
|
|||||||
|
|
||||||
return filteredRows.map((row) => ({
|
return filteredRows.map((row) => ({
|
||||||
key: this.stripBasePath(row.key),
|
key: this.stripBasePath(row.key),
|
||||||
value: decodeExtendedJson(row.value),
|
value: deserializeInvitation(row.value),
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -197,7 +208,7 @@ export class InMemoryStorage extends BaseStorage {
|
|||||||
const encodedValue = this.store.get(fullKey);
|
const encodedValue = this.store.get(fullKey);
|
||||||
if (encodedValue === undefined) return null;
|
if (encodedValue === undefined) return null;
|
||||||
|
|
||||||
return decodeExtendedJson(encodedValue);
|
return deserializeInvitation(encodedValue);
|
||||||
}
|
}
|
||||||
|
|
||||||
async remove(key: string): Promise<void> {
|
async remove(key: string): Promise<void> {
|
||||||
|
|||||||
283
src/templates/vending-machine.ts
Normal file
283
src/templates/vending-machine.ts
Normal file
@@ -0,0 +1,283 @@
|
|||||||
|
import type { XOTemplate } from "@xo-cash/types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vending machine payment template.
|
||||||
|
*
|
||||||
|
* Merchant creates a purchaseItems invitation with receipt variables;
|
||||||
|
* customer funds and signs the composable transaction.
|
||||||
|
*/
|
||||||
|
export const vendingMachineTemplate: XOTemplate = {
|
||||||
|
$schema: "https://libauth.org/schemas/wallet-template-v0.schema.json",
|
||||||
|
name: "Vending Machine",
|
||||||
|
description:
|
||||||
|
"Purchase items from a vending machine with an itemized receipt.",
|
||||||
|
icon: "wallet",
|
||||||
|
version: "1",
|
||||||
|
supported: ["BCH_2023_05", "BCH_2024_05", "BCH_2025_05", "BCH_2026_05"],
|
||||||
|
|
||||||
|
defaults: {
|
||||||
|
change: {
|
||||||
|
output: "changeOutput",
|
||||||
|
role: "merchant",
|
||||||
|
generate: ["merchantKey"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
roles: {
|
||||||
|
merchant: {
|
||||||
|
name: "Merchant",
|
||||||
|
description: "The vending machine operator receiving payment.",
|
||||||
|
icon: "owner",
|
||||||
|
},
|
||||||
|
customer: {
|
||||||
|
name: "Customer",
|
||||||
|
description: "The customer paying for items.",
|
||||||
|
icon: "sender",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
start: [
|
||||||
|
{
|
||||||
|
action: "purchaseItems",
|
||||||
|
role: "merchant",
|
||||||
|
generate: ["merchantKey"],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|
||||||
|
actions: {
|
||||||
|
purchaseItems: {
|
||||||
|
name: "Purchase Items",
|
||||||
|
description: "Purchase: $(<receiptSummary>) for $(<totalSatoshis>) sats",
|
||||||
|
icon: "request",
|
||||||
|
|
||||||
|
roles: {
|
||||||
|
merchant: {
|
||||||
|
name: "Sell Items",
|
||||||
|
description: "Receive payment for $(<receiptSummary>)",
|
||||||
|
icon: "request",
|
||||||
|
requirements: {
|
||||||
|
secrets: ["merchantKey"],
|
||||||
|
variables: [
|
||||||
|
"totalSatoshis",
|
||||||
|
"orderId",
|
||||||
|
"merchantName",
|
||||||
|
"receiptSummary",
|
||||||
|
"lineItemsJson",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
customer: {
|
||||||
|
name: "Pay",
|
||||||
|
description: "Pay $(<totalSatoshis>) sats for $(<receiptSummary>)",
|
||||||
|
icon: "send",
|
||||||
|
requirements: {},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
requirements: {
|
||||||
|
participants: [
|
||||||
|
{ role: "merchant", slots: { min: 1, max: 1 } },
|
||||||
|
{ role: "customer", slots: { min: 1 } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
transaction: "purchaseItemsTransaction",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
transactions: {
|
||||||
|
purchaseItemsTransaction: {
|
||||||
|
name: "Vending Purchase",
|
||||||
|
description: "Order $(<orderId>): $(<receiptSummary>)",
|
||||||
|
icon: "request",
|
||||||
|
|
||||||
|
roles: {
|
||||||
|
merchant: {
|
||||||
|
name: "Received Payment",
|
||||||
|
description:
|
||||||
|
"Received $(<totalSatoshis>) sats from $(<merchantName>) sale",
|
||||||
|
icon: "receive",
|
||||||
|
},
|
||||||
|
customer: {
|
||||||
|
name: "Sent Payment",
|
||||||
|
description: "Paid $(<totalSatoshis>) sats for $(<receiptSummary>)",
|
||||||
|
icon: "send",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
inputs: [],
|
||||||
|
outputs: [{ output: "purchaseOutput" }],
|
||||||
|
version: 2,
|
||||||
|
locktime: 0,
|
||||||
|
composable: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
/** No custom input templates — customer UTXOs are selected at funding time. */
|
||||||
|
inputs: {},
|
||||||
|
|
||||||
|
outputs: {
|
||||||
|
changeOutput: {
|
||||||
|
name: "Change",
|
||||||
|
description: "Funds returned as change.",
|
||||||
|
icon: "receive",
|
||||||
|
lockingScript: "merchantReceivingLockingScript",
|
||||||
|
},
|
||||||
|
purchaseOutput: {
|
||||||
|
name: "Purchase Payment",
|
||||||
|
description: "$(<totalSatoshis>) sats to $(<merchantName>)",
|
||||||
|
icon: "request",
|
||||||
|
|
||||||
|
roles: {
|
||||||
|
merchant: {
|
||||||
|
name: "Payment Received",
|
||||||
|
description:
|
||||||
|
"Received $(<totalSatoshis>) sats for $(<receiptSummary>)",
|
||||||
|
},
|
||||||
|
customer: {
|
||||||
|
name: "Payment Sent",
|
||||||
|
description: "Sent $(<totalSatoshis>) sats for $(<receiptSummary>)",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
lockingScript: "merchantReceivingLockingScript",
|
||||||
|
valueSatoshis: "$(<totalSatoshis>)",
|
||||||
|
token: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
lockingScripts: {
|
||||||
|
merchantReceivingLockingScript: {
|
||||||
|
name: "Merchant Receive",
|
||||||
|
description: "Funds received by the vending machine merchant.",
|
||||||
|
icon: "address",
|
||||||
|
lockingType: "p2pkh",
|
||||||
|
lockingBytecode: "lockMerchantP2PKH",
|
||||||
|
unlockingBytecode: "unlockMerchantP2PKH",
|
||||||
|
actions: [],
|
||||||
|
state: { variables: [], secrets: [] },
|
||||||
|
balance: {},
|
||||||
|
roles: {
|
||||||
|
merchant: {
|
||||||
|
state: {
|
||||||
|
variables: [],
|
||||||
|
secrets: ["merchantKey"],
|
||||||
|
},
|
||||||
|
actions: [],
|
||||||
|
balance: {
|
||||||
|
satoshis: true,
|
||||||
|
fungibleTokens: true,
|
||||||
|
nonfungibleTokens: true,
|
||||||
|
},
|
||||||
|
selectable: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
scripts: {
|
||||||
|
lockMerchantP2PKH:
|
||||||
|
"OP_DUP OP_HASH160 <$(<merchantKey.public_key> OP_HASH160)> OP_EQUALVERIFY OP_CHECKSIG",
|
||||||
|
unlockMerchantP2PKH:
|
||||||
|
"<merchantKey.schnorr_signature.all_outputs> <merchantKey.public_key>",
|
||||||
|
},
|
||||||
|
|
||||||
|
constants: {
|
||||||
|
dustLimit: {
|
||||||
|
name: "Dust Limit",
|
||||||
|
description: "Minimum satoshis for P2PKH outputs.",
|
||||||
|
type: "integer",
|
||||||
|
value: 546,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
variables: {
|
||||||
|
merchantKey: {
|
||||||
|
name: "Merchant Private Key",
|
||||||
|
description: "Private key for the vending machine merchant wallet.",
|
||||||
|
type: "bytes",
|
||||||
|
hint: "private_key",
|
||||||
|
},
|
||||||
|
totalSatoshis: {
|
||||||
|
name: "Total Price",
|
||||||
|
description: "Total purchase price in satoshis",
|
||||||
|
type: "integer",
|
||||||
|
hint: "satoshis",
|
||||||
|
},
|
||||||
|
orderId: {
|
||||||
|
name: "Order ID",
|
||||||
|
description: "Unique order identifier",
|
||||||
|
type: "string",
|
||||||
|
},
|
||||||
|
merchantName: {
|
||||||
|
name: "Merchant Name",
|
||||||
|
description: "Display name of the vending machine",
|
||||||
|
type: "string",
|
||||||
|
},
|
||||||
|
receiptSummary: {
|
||||||
|
name: "Receipt Summary",
|
||||||
|
description: "Human-readable list of purchased items",
|
||||||
|
type: "string",
|
||||||
|
},
|
||||||
|
lineItemsJson: {
|
||||||
|
name: "Line Items",
|
||||||
|
description: "JSON-encoded line items for the purchase",
|
||||||
|
type: "string",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
icons: [
|
||||||
|
{ name: "wallet", hash: "0000000000000000000000" },
|
||||||
|
{ name: "owner", hash: "0000000000000000000000" },
|
||||||
|
{ name: "sender", hash: "0000000000000000000000" },
|
||||||
|
{ name: "request", hash: "0000000000000000000000" },
|
||||||
|
{ name: "receive", hash: "0000000000000000000000" },
|
||||||
|
{ name: "send", hash: "0000000000000000000000" },
|
||||||
|
],
|
||||||
|
|
||||||
|
scenarios: [
|
||||||
|
{
|
||||||
|
name: "purchase items happy path",
|
||||||
|
description: "Merchant requests payment for vending machine items.",
|
||||||
|
action: "purchaseItems",
|
||||||
|
roles: [
|
||||||
|
{
|
||||||
|
role: "merchant",
|
||||||
|
values: {
|
||||||
|
generated: {
|
||||||
|
merchantKey:
|
||||||
|
"KyRQa5pEXuzVcDwnXRLpYAascjchQW5DoxVRMbj4DTxS83573mz8",
|
||||||
|
},
|
||||||
|
variables: {
|
||||||
|
totalSatoshis: 3500,
|
||||||
|
orderId: "order-demo-1",
|
||||||
|
merchantName: "XO Snack Machine",
|
||||||
|
receiptSummary: "2× Cola, 1× Chips",
|
||||||
|
lineItemsJson:
|
||||||
|
'[{"name":"Cola","qty":2},{"name":"Chips","qty":1}]',
|
||||||
|
},
|
||||||
|
secrets: {},
|
||||||
|
inputs: [],
|
||||||
|
outputs: [
|
||||||
|
{
|
||||||
|
lockingBytecode:
|
||||||
|
"76a91475c715ecb74178fe87933e57e947e5e92d904b8188ac",
|
||||||
|
valueSatoshis: 3500,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: "customer",
|
||||||
|
values: {
|
||||||
|
generated: {},
|
||||||
|
variables: {},
|
||||||
|
secrets: {},
|
||||||
|
inputs: [],
|
||||||
|
outputs: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
270
src/templates/wrap-template.ts
Normal file
270
src/templates/wrap-template.ts
Normal file
@@ -0,0 +1,270 @@
|
|||||||
|
import type { XOTemplate } from "@xo-cash/types";
|
||||||
|
|
||||||
|
export const wrapBCHTemplate: XOTemplate = {
|
||||||
|
$schema: "https://libauth.org/schemas/wallet-template-v0.schema.json",
|
||||||
|
|
||||||
|
name: "Wrapped BCH",
|
||||||
|
description: "Convert between BCH and wBCH tokens.",
|
||||||
|
icon: "wrap",
|
||||||
|
|
||||||
|
version: "1",
|
||||||
|
supported: ["BCH_2023_05", "BCH_2024_05", "BCH_2025_05", "BCH_2026_05"],
|
||||||
|
|
||||||
|
roles: {
|
||||||
|
user: {
|
||||||
|
name: "User",
|
||||||
|
description: "The person wrapping or unwrapping BCH.",
|
||||||
|
icon: "user",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
start: [
|
||||||
|
{
|
||||||
|
action: "wrap",
|
||||||
|
role: "user",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
action: "unwrap",
|
||||||
|
role: "user",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|
||||||
|
actions: {
|
||||||
|
wrap: {
|
||||||
|
name: "Wrap BCH",
|
||||||
|
description: "Convert BCH into wBCH tokens.",
|
||||||
|
icon: "wrap",
|
||||||
|
|
||||||
|
roles: {
|
||||||
|
user: {
|
||||||
|
requirements: {
|
||||||
|
variables: ["amountToWrap", "recipientLockingScript"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
requirements: {
|
||||||
|
participants: [{ role: "user", slots: { min: 1, max: 1 } }],
|
||||||
|
},
|
||||||
|
|
||||||
|
transaction: "wrapTransaction",
|
||||||
|
},
|
||||||
|
|
||||||
|
unwrap: {
|
||||||
|
name: "Unwrap wBCH",
|
||||||
|
description: "Convert wBCH tokens back into BCH.",
|
||||||
|
icon: "unwrap",
|
||||||
|
|
||||||
|
roles: {
|
||||||
|
user: {
|
||||||
|
requirements: {
|
||||||
|
variables: ["amountToUnwrap", "recipientLockingScript"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
requirements: {
|
||||||
|
participants: [{ role: "user", slots: { min: 1, max: 1 } }],
|
||||||
|
},
|
||||||
|
|
||||||
|
transaction: "unwrapTransaction",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
transactions: {
|
||||||
|
wrapTransaction: {
|
||||||
|
name: "Wrapped BCH",
|
||||||
|
description:
|
||||||
|
"Wrapped $(<amountToWrap> <satoshisPerBCH> OP_DIV).$(<amountToWrap> <satoshisPerBCH> OP_MOD) BCH into wBCH tokens.",
|
||||||
|
icon: "wrap",
|
||||||
|
|
||||||
|
inputs: [{ input: "covenantInput", inputIndex: 0 }],
|
||||||
|
outputs: [
|
||||||
|
{ output: "covenantOutput", outputIndex: 0 },
|
||||||
|
{ output: "wrappedTokensOutput", outputIndex: undefined },
|
||||||
|
],
|
||||||
|
|
||||||
|
version: 2,
|
||||||
|
locktime: 0,
|
||||||
|
composable: true,
|
||||||
|
},
|
||||||
|
|
||||||
|
unwrapTransaction: {
|
||||||
|
name: "Unwrapped wBCH",
|
||||||
|
description:
|
||||||
|
"Unwrapped $(<amountToUnwrap> <satoshisPerBCH> OP_DIV).$(<amountToUnwrap> <satoshisPerBCH> OP_MOD) wBCH tokens back into BCH.",
|
||||||
|
icon: "unwrap",
|
||||||
|
|
||||||
|
inputs: [{ input: "covenantInput", inputIndex: 0 }],
|
||||||
|
outputs: [
|
||||||
|
{ output: "covenantOutput", outputIndex: 0 },
|
||||||
|
{ output: "unwrappedSatoshisOutput", outputIndex: undefined },
|
||||||
|
],
|
||||||
|
|
||||||
|
version: 2,
|
||||||
|
locktime: 0,
|
||||||
|
composable: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
outputs: {
|
||||||
|
covenantOutput: {
|
||||||
|
name: "wBCH Covenant",
|
||||||
|
description: "Holds BCH and wBCH tokens that can be freely converted.",
|
||||||
|
icon: "contract",
|
||||||
|
|
||||||
|
lockingScript: "wrapBCHLockingScript",
|
||||||
|
},
|
||||||
|
|
||||||
|
wrappedTokensOutput: {
|
||||||
|
name: "Wrapped wBCH",
|
||||||
|
description:
|
||||||
|
"Wrapped $(<amountToWrap> <satoshisPerBCH> OP_DIV).$(<amountToWrap> <satoshisPerBCH> OP_MOD) wBCH tokens.",
|
||||||
|
icon: "receive",
|
||||||
|
|
||||||
|
valueSatoshis: "$(<amountToWrap>)",
|
||||||
|
token: {
|
||||||
|
category: "$(<wbchTokenCategory>)",
|
||||||
|
amount: "$(<amountToWrap>)",
|
||||||
|
nft: null,
|
||||||
|
},
|
||||||
|
|
||||||
|
roles: {
|
||||||
|
user: {
|
||||||
|
balance: {
|
||||||
|
satoshis: true,
|
||||||
|
fungibleTokens: true,
|
||||||
|
nonfungibleTokens: true,
|
||||||
|
},
|
||||||
|
selectable: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
lockingScript: "$(<recipientLockingScript>)",
|
||||||
|
},
|
||||||
|
|
||||||
|
unwrappedSatoshisOutput: {
|
||||||
|
name: "Unwrapped BCH",
|
||||||
|
description:
|
||||||
|
"Unwrapped $(<amountToUnwrap> <satoshisPerBCH> OP_DIV).$(<amountToUnwrap> <satoshisPerBCH> OP_MOD) BCH.",
|
||||||
|
icon: "receive",
|
||||||
|
|
||||||
|
valueSatoshis: "$(<amountToUnwrap>)",
|
||||||
|
token: null,
|
||||||
|
|
||||||
|
roles: {
|
||||||
|
user: {
|
||||||
|
balance: {
|
||||||
|
satoshis: true,
|
||||||
|
fungibleTokens: true,
|
||||||
|
nonfungibleTokens: true,
|
||||||
|
},
|
||||||
|
selectable: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
lockingScript: "$(<recipientLockingScript>)",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
inputs: {
|
||||||
|
covenantInput: {
|
||||||
|
name: "wBCH Covenant",
|
||||||
|
description: "The covenant being updated.",
|
||||||
|
icon: "contract",
|
||||||
|
|
||||||
|
unlockingScript: "unlockCovenant",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
lockingScripts: {
|
||||||
|
wrapBCHLockingScript: {
|
||||||
|
name: "wBCH Covenant",
|
||||||
|
description: "Holds BCH and wBCH tokens that can be freely converted.",
|
||||||
|
icon: "contract",
|
||||||
|
|
||||||
|
lockingType: "p2sh",
|
||||||
|
lockingBytecode: "wrapBCHLockingBytecode",
|
||||||
|
|
||||||
|
actions: [
|
||||||
|
{ action: "wrap", role: "user" },
|
||||||
|
{ action: "unwrap", role: "user" },
|
||||||
|
],
|
||||||
|
|
||||||
|
state: {
|
||||||
|
variables: [],
|
||||||
|
secrets: [],
|
||||||
|
},
|
||||||
|
balance: {
|
||||||
|
satoshis: 0n,
|
||||||
|
fungibleTokens: 0n,
|
||||||
|
},
|
||||||
|
selectable: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
scripts: {
|
||||||
|
enforceCovenantPersists:
|
||||||
|
"OP_INPUTINDEX OP_DUP OP_OUTPUTBYTECODE OP_SWAP OP_UTXOBYTECODE OP_EQUAL OP_VERIFY",
|
||||||
|
enforceTokenCategoryPreserved:
|
||||||
|
"OP_INPUTINDEX OP_DUP OP_OUTPUTTOKENCATEGORY OP_SWAP OP_UTXOTOKENCATEGORY OP_EQUAL OP_VERIFY",
|
||||||
|
enforceValueTokenSumConserved:
|
||||||
|
"OP_INPUTINDEX OP_UTXOVALUE OP_INPUTINDEX OP_UTXOTOKENAMOUNT OP_ADD OP_INPUTINDEX OP_OUTPUTVALUE OP_INPUTINDEX OP_OUTPUTTOKENAMOUNT OP_ADD OP_EQUAL OP_VERIFY",
|
||||||
|
|
||||||
|
// Direct script references — introspection opcodes must not use $(...) evaluations
|
||||||
|
// because those are evaluated at compile time without transaction context.
|
||||||
|
wrapBCHLockingBytecode:
|
||||||
|
"enforceCovenantPersists enforceTokenCategoryPreserved enforceValueTokenSumConserved",
|
||||||
|
unlockCovenant: "",
|
||||||
|
},
|
||||||
|
|
||||||
|
constants: {
|
||||||
|
wbchTokenCategory: {
|
||||||
|
name: "wBCH Token Category",
|
||||||
|
description: "The official token category for Wrapped BCH.",
|
||||||
|
type: "bytes",
|
||||||
|
value: "ff4d6e4b90aa8158d39c5dc874fd9411af1ac3b5ed6f354755e8362a0d02c6b3",
|
||||||
|
},
|
||||||
|
satoshisPerBCH: {
|
||||||
|
name: "Satoshis per BCH",
|
||||||
|
description: "Used to display amounts in BCH with decimals.",
|
||||||
|
type: "integer",
|
||||||
|
value: 100000000,
|
||||||
|
},
|
||||||
|
tokenDust: {
|
||||||
|
name: "Token Dust Limit",
|
||||||
|
description: "Minimal satoshis required for a token-bearing output.",
|
||||||
|
type: "integer",
|
||||||
|
value: 1000,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
variables: {
|
||||||
|
amountToWrap: {
|
||||||
|
name: "Amount to Wrap",
|
||||||
|
description: "How much BCH to convert to wBCH (in satoshis).",
|
||||||
|
type: "integer",
|
||||||
|
hint: "satoshis",
|
||||||
|
},
|
||||||
|
amountToUnwrap: {
|
||||||
|
name: "Amount to Unwrap",
|
||||||
|
description: "How much wBCH to convert back to BCH (in satoshis).",
|
||||||
|
type: "integer",
|
||||||
|
hint: "satoshis",
|
||||||
|
},
|
||||||
|
recipientLockingScript: {
|
||||||
|
name: "Destination",
|
||||||
|
description: "Where to receive your BCH or wBCH tokens.",
|
||||||
|
type: "bytes",
|
||||||
|
hint: "lockingScript",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
icons: [
|
||||||
|
{ name: "wrap", hash: "0000000000000000000000" },
|
||||||
|
{ name: "unwrap", hash: "0000000000000000000000" },
|
||||||
|
{ name: "user", hash: "0000000000000000000000" },
|
||||||
|
{ name: "contract", hash: "0000000000000000000000" },
|
||||||
|
{ name: "receive", hash: "0000000000000000000000" },
|
||||||
|
],
|
||||||
|
};
|
||||||
@@ -17,7 +17,6 @@ import { WalletStateScreen } from './screens/WalletState.js';
|
|||||||
import { TemplateListScreen } from './screens/TemplateList.js';
|
import { TemplateListScreen } from './screens/TemplateList.js';
|
||||||
import { ActionWizardScreen } from './screens/action-wizard/ActionWizardScreen.js';
|
import { ActionWizardScreen } from './screens/action-wizard/ActionWizardScreen.js';
|
||||||
import { InvitationScreen } from './screens/invitations/InvitationScreen.js';
|
import { InvitationScreen } from './screens/invitations/InvitationScreen.js';
|
||||||
import { TransactionScreen } from './screens/Transaction.js';
|
|
||||||
|
|
||||||
import { MessageDialog } from './components/Dialog.js';
|
import { MessageDialog } from './components/Dialog.js';
|
||||||
|
|
||||||
@@ -45,8 +44,6 @@ function Router(): React.ReactElement {
|
|||||||
return <ActionWizardScreen />;
|
return <ActionWizardScreen />;
|
||||||
case 'invitations':
|
case 'invitations':
|
||||||
return <InvitationScreen />;
|
return <InvitationScreen />;
|
||||||
case 'transaction':
|
|
||||||
return <TransactionScreen />;
|
|
||||||
default:
|
default:
|
||||||
return <Text color={colors.error}>Unknown screen: {screen}</Text>;
|
return <Text color={colors.error}>Unknown screen: {screen}</Text>;
|
||||||
}
|
}
|
||||||
|
|||||||
188
src/tui/components/CurrencySelectionDialog.tsx
Normal file
188
src/tui/components/CurrencySelectionDialog.tsx
Normal file
@@ -0,0 +1,188 @@
|
|||||||
|
import React, { useEffect, useId, useMemo, useState } from "react";
|
||||||
|
import { Box, Text } from "ink";
|
||||||
|
|
||||||
|
import { ScrollableList, type ListItemData } from "./List.js";
|
||||||
|
import TextInput from "./TextInput.js";
|
||||||
|
import { DialogWrapper } from "./Dialog.js";
|
||||||
|
import { useInputLayer, useLayeredInput } from "../hooks/useInputLayer.js";
|
||||||
|
import { colors } from "../theme.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Props for the currency selection dialog.
|
||||||
|
*/
|
||||||
|
interface CurrencySelectionDialogProps {
|
||||||
|
/** Current wallet currency from persisted settings. */
|
||||||
|
currentCurrency: string;
|
||||||
|
/** Available fiat numerator symbols that can be paired with BCH. */
|
||||||
|
currencies: string[];
|
||||||
|
/** True while the dialog is loading available pairs. */
|
||||||
|
isLoading: boolean;
|
||||||
|
/** Optional loading/error message for pair discovery. */
|
||||||
|
errorMessage: string | null;
|
||||||
|
/** Called when the user chooses a currency and confirms. */
|
||||||
|
onSelectCurrency: (currencyCode: string) => void;
|
||||||
|
/** Called when the dialog should close without applying changes. */
|
||||||
|
onCancel: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Currency picker dialog.
|
||||||
|
*
|
||||||
|
* UX requirements:
|
||||||
|
* - Arrow keys move the highlighted item.
|
||||||
|
* - Typing immediately filters results.
|
||||||
|
* - Enter applies current selection.
|
||||||
|
* - Escape closes without saving.
|
||||||
|
*/
|
||||||
|
export function CurrencySelectionDialog({
|
||||||
|
currentCurrency,
|
||||||
|
currencies,
|
||||||
|
isLoading,
|
||||||
|
errorMessage,
|
||||||
|
onSelectCurrency,
|
||||||
|
onCancel,
|
||||||
|
}: CurrencySelectionDialogProps): React.ReactElement {
|
||||||
|
const layerId = useId();
|
||||||
|
const [filterText, setFilterText] = useState("");
|
||||||
|
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||||
|
|
||||||
|
// Mount this as a capturing input layer so background screens stop handling keys.
|
||||||
|
useInputLayer(layerId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Applies the currently selected filtered result.
|
||||||
|
*/
|
||||||
|
const applySelection = (): void => {
|
||||||
|
const selectedCurrency = filteredCurrencies[selectedIndex];
|
||||||
|
if (!selectedCurrency) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onSelectCurrency(selectedCurrency);
|
||||||
|
};
|
||||||
|
|
||||||
|
useLayeredInput(layerId, (_input, key) => {
|
||||||
|
if (key.escape) {
|
||||||
|
onCancel();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (key.upArrow) {
|
||||||
|
setSelectedIndex((prev) =>
|
||||||
|
prev <= 0 ? Math.max(filteredCurrencies.length - 1, 0) : prev - 1,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (key.downArrow) {
|
||||||
|
setSelectedIndex((prev) =>
|
||||||
|
filteredCurrencies.length === 0
|
||||||
|
? 0
|
||||||
|
: prev >= filteredCurrencies.length - 1
|
||||||
|
? 0
|
||||||
|
: prev + 1,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filter currencies as the user types.
|
||||||
|
*/
|
||||||
|
const filteredCurrencies = useMemo(() => {
|
||||||
|
const normalizedFilter = filterText.trim().toUpperCase();
|
||||||
|
if (!normalizedFilter) {
|
||||||
|
return currencies;
|
||||||
|
}
|
||||||
|
return currencies.filter((currencyCode) =>
|
||||||
|
currencyCode.toUpperCase().includes(normalizedFilter),
|
||||||
|
);
|
||||||
|
}, [currencies, filterText]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keep selected index valid whenever filtering shrinks the result set.
|
||||||
|
*/
|
||||||
|
useEffect(() => {
|
||||||
|
if (filteredCurrencies.length === 0) {
|
||||||
|
setSelectedIndex(0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedIndex >= filteredCurrencies.length) {
|
||||||
|
setSelectedIndex(filteredCurrencies.length - 1);
|
||||||
|
}
|
||||||
|
}, [filteredCurrencies, selectedIndex]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* When the dialog opens or the list updates, default to current currency.
|
||||||
|
*/
|
||||||
|
useEffect(() => {
|
||||||
|
if (filterText.trim().length > 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentIndex = filteredCurrencies.findIndex(
|
||||||
|
(currencyCode) => currencyCode.toUpperCase() === currentCurrency.toUpperCase(),
|
||||||
|
);
|
||||||
|
if (currentIndex >= 0) {
|
||||||
|
setSelectedIndex(currentIndex);
|
||||||
|
}
|
||||||
|
}, [filteredCurrencies, currentCurrency, filterText]);
|
||||||
|
|
||||||
|
const listItems: ListItemData<string>[] = filteredCurrencies.map(
|
||||||
|
(currencyCode) => ({
|
||||||
|
key: currencyCode,
|
||||||
|
label: currencyCode,
|
||||||
|
description:
|
||||||
|
currencyCode.toUpperCase() === currentCurrency.toUpperCase()
|
||||||
|
? "(current)"
|
||||||
|
: undefined,
|
||||||
|
value: currencyCode,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DialogWrapper title="Select Fiat Currency" borderColor={colors.info} width={64}>
|
||||||
|
<Text color={colors.textMuted}>
|
||||||
|
Available BCH quote pairs are loaded from the live rates adapter.
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Box marginTop={1}>
|
||||||
|
<Text color={colors.primary}>Filter:</Text>
|
||||||
|
</Box>
|
||||||
|
<Box borderStyle="single" borderColor={colors.focus} paddingX={1}>
|
||||||
|
<TextInput
|
||||||
|
value={filterText}
|
||||||
|
onChange={setFilterText}
|
||||||
|
onSubmit={() => applySelection()}
|
||||||
|
placeholder="Type currency code (e.g. USD, AUD)..."
|
||||||
|
focus
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box marginTop={1} flexDirection="column">
|
||||||
|
{isLoading ? (
|
||||||
|
<Text color={colors.textMuted}>Loading available pairs...</Text>
|
||||||
|
) : errorMessage ? (
|
||||||
|
<Text color={colors.error}>{errorMessage}</Text>
|
||||||
|
) : (
|
||||||
|
<ScrollableList
|
||||||
|
items={listItems}
|
||||||
|
selectedIndex={selectedIndex}
|
||||||
|
onSelect={setSelectedIndex}
|
||||||
|
onActivate={() => applySelection()}
|
||||||
|
focus={false}
|
||||||
|
maxVisible={8}
|
||||||
|
emptyMessage="No BCH quote pairs match this filter."
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box marginTop={1}>
|
||||||
|
<Text color={colors.textMuted}>
|
||||||
|
Type to filter • ↑↓ navigate • Enter apply • Esc cancel
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
</DialogWrapper>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,11 +2,17 @@
|
|||||||
* Dialog components for modals, confirmations, and input dialogs.
|
* Dialog components for modals, confirmations, and input dialogs.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useId, useRef, useState } from 'react';
|
import React, { useId, useMemo, useRef, useState } from 'react';
|
||||||
import { Box, Text, measureElement } from 'ink';
|
import { Box, Text, measureElement, useStdout } from 'ink';
|
||||||
import TextInput from './TextInput.js';
|
import TextInput from './TextInput.js';
|
||||||
import { colors } from '../theme.js';
|
import { colors } from '../theme.js';
|
||||||
import { useInputLayer, useLayeredInput } from '../hooks/useInputLayer.js';
|
import { useInputLayer, useLayeredInput } from '../hooks/useInputLayer.js';
|
||||||
|
import {
|
||||||
|
formatDialogMessageLines,
|
||||||
|
getMessageContentWidth,
|
||||||
|
getMessageDialogWidth,
|
||||||
|
MAX_MESSAGE_DIALOG_LINES,
|
||||||
|
} from '../utils/format-dialog-message.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Base dialog wrapper props.
|
* Base dialog wrapper props.
|
||||||
@@ -261,6 +267,23 @@ export function MessageDialog({
|
|||||||
isActive = true,
|
isActive = true,
|
||||||
}: MessageDialogProps): React.ReactElement {
|
}: MessageDialogProps): React.ReactElement {
|
||||||
const layerId = useId();
|
const layerId = useId();
|
||||||
|
const { stdout } = useStdout();
|
||||||
|
const dialogWidth = getMessageDialogWidth(stdout.columns ?? 80);
|
||||||
|
const contentWidth = getMessageContentWidth(dialogWidth);
|
||||||
|
|
||||||
|
const messageLines = useMemo(() => {
|
||||||
|
const formattedLines = formatDialogMessageLines(message, contentWidth);
|
||||||
|
|
||||||
|
if (formattedLines.length <= MAX_MESSAGE_DIALOG_LINES) {
|
||||||
|
return formattedLines;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hiddenLineCount = formattedLines.length - MAX_MESSAGE_DIALOG_LINES;
|
||||||
|
return [
|
||||||
|
...formattedLines.slice(0, MAX_MESSAGE_DIALOG_LINES),
|
||||||
|
`... and ${hiddenLineCount} more line(s)`,
|
||||||
|
];
|
||||||
|
}, [contentWidth, message]);
|
||||||
|
|
||||||
// Auto-capture input when this dialog is mounted.
|
// Auto-capture input when this dialog is mounted.
|
||||||
useInputLayer(layerId);
|
useInputLayer(layerId);
|
||||||
@@ -269,7 +292,7 @@ export function MessageDialog({
|
|||||||
if (key.return || key.escape) {
|
if (key.return || key.escape) {
|
||||||
onClose();
|
onClose();
|
||||||
}
|
}
|
||||||
});
|
}, { isActive });
|
||||||
|
|
||||||
const borderColor = type === 'error' ? colors.error :
|
const borderColor = type === 'error' ? colors.error :
|
||||||
type === 'success' ? colors.success :
|
type === 'success' ? colors.success :
|
||||||
@@ -280,8 +303,16 @@ export function MessageDialog({
|
|||||||
'ℹ';
|
'ℹ';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DialogWrapper title={`${icon} ${title}`} borderColor={borderColor}>
|
<DialogWrapper
|
||||||
<Text wrap="wrap">{message}</Text>
|
title={`${icon} ${title}`}
|
||||||
|
borderColor={borderColor}
|
||||||
|
width={dialogWidth}
|
||||||
|
>
|
||||||
|
<Box flexDirection="column">
|
||||||
|
{messageLines.map((line, index) => (
|
||||||
|
<Text key={`${index}-${line.slice(0, 24)}`}>{line}</Text>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
<Box marginTop={1}>
|
<Box marginTop={1}>
|
||||||
<Text color={colors.textMuted}>Press Enter or Esc to close</Text>
|
<Text color={colors.textMuted}>Press Enter or Esc to close</Text>
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
273
src/tui/components/FilePicker.tsx
Normal file
273
src/tui/components/FilePicker.tsx
Normal file
@@ -0,0 +1,273 @@
|
|||||||
|
/**
|
||||||
|
* Terminal file picker for browsing directories and selecting files.
|
||||||
|
*
|
||||||
|
* This component does not include a dialog wrapper — consumers wrap it in
|
||||||
|
* {@link DialogWrapper} when needed. When used inside a dialog overlay, pass
|
||||||
|
* `layerId` so keyboard input is routed through the input-layer stack instead
|
||||||
|
* of conflicting with background {@link ScrollableList} handlers.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
import { Box, Text } from "ink";
|
||||||
|
|
||||||
|
import { ScrollableList, type ListItemData } from "./List.js";
|
||||||
|
import { useLayeredInput } from "../hooks/useInputLayer.js";
|
||||||
|
import { colors } from "../theme.js";
|
||||||
|
import {
|
||||||
|
listDirectoryEntries,
|
||||||
|
type DirectoryEntry,
|
||||||
|
} from "../utils/list-directory-entries.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Props for {@link FilePicker}.
|
||||||
|
*/
|
||||||
|
export interface FilePickerProps {
|
||||||
|
/** Starting directory. Defaults to `process.cwd()`. */
|
||||||
|
initialDirectory?: string;
|
||||||
|
/**
|
||||||
|
* Allowed file extensions without a leading dot (e.g. `['json']`).
|
||||||
|
* Omit to show all files. Directories are always shown.
|
||||||
|
*/
|
||||||
|
extensions?: string[];
|
||||||
|
/**
|
||||||
|
* Input-layer id for dialog use. When set, this component handles ↑↓/Enter
|
||||||
|
* via {@link useLayeredInput} and disables {@link ScrollableList} focus.
|
||||||
|
*/
|
||||||
|
layerId?: string;
|
||||||
|
/** Whether the list receives keyboard focus when `layerId` is not set. */
|
||||||
|
focus?: boolean;
|
||||||
|
/** Maximum visible rows in the scroll window. */
|
||||||
|
maxVisible?: number;
|
||||||
|
/** Called when the user confirms a file with Enter. */
|
||||||
|
onSelectFile: (absolutePath: string) => void;
|
||||||
|
/** Optional callback whenever the browsed directory changes. */
|
||||||
|
onDirectoryChange?: (absolutePath: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Truncates a long path for display, keeping the end visible.
|
||||||
|
*/
|
||||||
|
function formatDirectoryPath(directoryPath: string, maxLength = 56): string {
|
||||||
|
if (directoryPath.length <= maxLength) {
|
||||||
|
return directoryPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `...${directoryPath.slice(-(maxLength - 3))}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds list row metadata for a directory entry.
|
||||||
|
*/
|
||||||
|
function toListItem(entry: DirectoryEntry): ListItemData<DirectoryEntry> {
|
||||||
|
if (entry.kind === "parent") {
|
||||||
|
return {
|
||||||
|
key: "__parent__",
|
||||||
|
label: "..",
|
||||||
|
description: "Parent directory",
|
||||||
|
value: entry,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entry.kind === "directory") {
|
||||||
|
return {
|
||||||
|
key: `dir:${entry.absolutePath}`,
|
||||||
|
label: entry.name,
|
||||||
|
description: "Directory",
|
||||||
|
value: entry,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
key: `file:${entry.absolutePath}`,
|
||||||
|
label: entry.name,
|
||||||
|
value: entry,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generic terminal file picker with optional extension filtering.
|
||||||
|
*/
|
||||||
|
export function FilePicker({
|
||||||
|
initialDirectory = process.cwd(),
|
||||||
|
extensions,
|
||||||
|
layerId,
|
||||||
|
focus = true,
|
||||||
|
maxVisible = 10,
|
||||||
|
onSelectFile,
|
||||||
|
onDirectoryChange,
|
||||||
|
}: FilePickerProps): React.ReactElement {
|
||||||
|
const [currentDirectory, setCurrentDirectory] = useState(() =>
|
||||||
|
initialDirectory,
|
||||||
|
);
|
||||||
|
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||||
|
const [loadError, setLoadError] = useState<string | undefined>();
|
||||||
|
|
||||||
|
const { entries, error } = useMemo(
|
||||||
|
() => listDirectoryEntries(currentDirectory, { extensions }),
|
||||||
|
[currentDirectory, extensions],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setLoadError(error);
|
||||||
|
}, [error]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setSelectedIndex(0);
|
||||||
|
}, [currentDirectory, extensions]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (entries.length === 0) {
|
||||||
|
setSelectedIndex(0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedIndex >= entries.length) {
|
||||||
|
setSelectedIndex(entries.length - 1);
|
||||||
|
}
|
||||||
|
}, [entries, selectedIndex]);
|
||||||
|
|
||||||
|
const listItems = useMemo(
|
||||||
|
(): ListItemData<DirectoryEntry>[] => entries.map(toListItem),
|
||||||
|
[entries],
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Moves selection to the previous visible row, wrapping at the top.
|
||||||
|
*/
|
||||||
|
const selectPrevious = useCallback((): void => {
|
||||||
|
setSelectedIndex((previous) =>
|
||||||
|
previous <= 0 ? Math.max(entries.length - 1, 0) : previous - 1,
|
||||||
|
);
|
||||||
|
}, [entries.length]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Moves selection to the next visible row, wrapping at the bottom.
|
||||||
|
*/
|
||||||
|
const selectNext = useCallback((): void => {
|
||||||
|
setSelectedIndex((previous) =>
|
||||||
|
entries.length === 0
|
||||||
|
? 0
|
||||||
|
: previous >= entries.length - 1
|
||||||
|
? 0
|
||||||
|
: previous + 1,
|
||||||
|
);
|
||||||
|
}, [entries.length]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Applies the current row: navigate for parent/directory, select for files.
|
||||||
|
*/
|
||||||
|
const activateSelectedEntry = useCallback((): void => {
|
||||||
|
const entry = entries[selectedIndex];
|
||||||
|
if (!entry) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entry.kind === "parent" || entry.kind === "directory") {
|
||||||
|
setCurrentDirectory(entry.absolutePath);
|
||||||
|
onDirectoryChange?.(entry.absolutePath);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
onSelectFile(entry.absolutePath);
|
||||||
|
}, [entries, onDirectoryChange, onSelectFile, selectedIndex]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dialog overlays must pass `layerId` because ScrollableList uses raw ink
|
||||||
|
* `useInput`, which does not respect the input capture stack.
|
||||||
|
*/
|
||||||
|
useLayeredInput(
|
||||||
|
layerId ?? "file-picker-standalone",
|
||||||
|
(_input, key) => {
|
||||||
|
if (!layerId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (key.upArrow) {
|
||||||
|
selectPrevious();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (key.downArrow) {
|
||||||
|
selectNext();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (key.return) {
|
||||||
|
activateSelectedEntry();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ isActive: Boolean(layerId) },
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderItem = useCallback(
|
||||||
|
(
|
||||||
|
item: ListItemData<DirectoryEntry>,
|
||||||
|
isSelected: boolean,
|
||||||
|
isFocused: boolean,
|
||||||
|
): React.ReactNode => {
|
||||||
|
const entry = item.value;
|
||||||
|
/**
|
||||||
|
* Inside dialogs, ScrollableList focus is disabled (input comes from layerId).
|
||||||
|
* Treat the selected row as highlighted so it matches other focused lists.
|
||||||
|
*/
|
||||||
|
const isHighlighted = layerId ? isSelected : isFocused;
|
||||||
|
const textColor = isHighlighted ? colors.focus : colors.text;
|
||||||
|
const indicator = isHighlighted ? "▸ " : " ";
|
||||||
|
|
||||||
|
if (entry?.kind === "parent") {
|
||||||
|
return (
|
||||||
|
<Text color={textColor} bold={isSelected}>
|
||||||
|
{indicator}
|
||||||
|
⬆ ..
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entry?.kind === "directory") {
|
||||||
|
return (
|
||||||
|
<Text color={textColor} bold={isSelected}>
|
||||||
|
{indicator}
|
||||||
|
📁 {item.label}/
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Text color={textColor} bold={isSelected}>
|
||||||
|
{indicator}
|
||||||
|
{item.label}
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[layerId],
|
||||||
|
);
|
||||||
|
|
||||||
|
const listFocus = layerId ? false : focus;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box flexDirection="column">
|
||||||
|
<Text color={colors.textMuted}>
|
||||||
|
Directory: {formatDirectoryPath(currentDirectory)}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{loadError ? (
|
||||||
|
<Box marginTop={1}>
|
||||||
|
<Text color={colors.error}>{loadError}</Text>
|
||||||
|
</Box>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<Box marginTop={1} flexDirection="column">
|
||||||
|
<ScrollableList
|
||||||
|
items={listItems}
|
||||||
|
selectedIndex={selectedIndex}
|
||||||
|
onSelect={setSelectedIndex}
|
||||||
|
onActivate={() => activateSelectedEntry()}
|
||||||
|
focus={listFocus}
|
||||||
|
maxVisible={maxVisible}
|
||||||
|
emptyMessage="No matching files or folders"
|
||||||
|
renderItem={renderItem}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -47,6 +47,8 @@ interface QRCodeProps {
|
|||||||
dialogTitle?: string;
|
dialogTitle?: string;
|
||||||
/** Whether to display the raw encoded value as copyable text above the QR code. */
|
/** Whether to display the raw encoded value as copyable text above the QR code. */
|
||||||
showValue?: boolean;
|
showValue?: boolean;
|
||||||
|
/** Optional subtitle to display below the QR code. */
|
||||||
|
subtitle?: React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -155,6 +157,7 @@ export function QRCode({
|
|||||||
dialog = false,
|
dialog = false,
|
||||||
dialogTitle = 'QR Code',
|
dialogTitle = 'QR Code',
|
||||||
showValue = false,
|
showValue = false,
|
||||||
|
subtitle = null,
|
||||||
}: QRCodeProps): React.ReactElement {
|
}: QRCodeProps): React.ReactElement {
|
||||||
const { rows, moduleCount } = useMemo(() => {
|
const { rows, moduleCount } = useMemo(() => {
|
||||||
const matrix = generateMatrix(value);
|
const matrix = generateMatrix(value);
|
||||||
@@ -190,6 +193,7 @@ export function QRCode({
|
|||||||
return (
|
return (
|
||||||
<DialogWrapper title={dialogTitle} borderColor={colors.primary} width={dialogWidth}>
|
<DialogWrapper title={dialogTitle} borderColor={colors.primary} width={dialogWidth}>
|
||||||
{qrContent}
|
{qrContent}
|
||||||
|
{subtitle}
|
||||||
</DialogWrapper>
|
</DialogWrapper>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import React from "react";
|
import React, { useMemo } from "react";
|
||||||
import { Box, Text } from "ink";
|
import { Box, Text } from "ink";
|
||||||
import TextInput from "./TextInput.js";
|
import TextInput from "./TextInput.js";
|
||||||
|
import { useSatoshisConversion } from "../hooks/useSatoshisConversion.js";
|
||||||
|
|
||||||
interface VariableInputFieldProps {
|
interface VariableInputFieldProps {
|
||||||
variable: {
|
variable: {
|
||||||
@@ -18,6 +19,45 @@ interface VariableInputFieldProps {
|
|||||||
focusColor: string;
|
focusColor: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const SATOSHIS_PER_BCH = 100_000_000n;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns true when the variable is an integer satoshis field.
|
||||||
|
*/
|
||||||
|
function isSatoshisVariable(variable: VariableInputFieldProps["variable"]): boolean {
|
||||||
|
return (
|
||||||
|
variable.type === "integer" &&
|
||||||
|
variable.hint?.toLowerCase().includes("satoshi") === true
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a strict integer string into bigint.
|
||||||
|
*/
|
||||||
|
function parseSatoshis(value: string): bigint | null {
|
||||||
|
const trimmed = value.trim();
|
||||||
|
if (!/^[-]?\d+$/.test(trimmed)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return BigInt(trimmed);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format satoshis as BCH with fixed 8 decimals, preserving bigint precision.
|
||||||
|
*/
|
||||||
|
function formatBchFromSatoshis(satoshis: bigint): string {
|
||||||
|
const sign = satoshis < 0n ? "-" : "";
|
||||||
|
const absolute = satoshis < 0n ? satoshis * -1n : satoshis;
|
||||||
|
const whole = absolute / SATOSHIS_PER_BCH;
|
||||||
|
const fractional = absolute % SATOSHIS_PER_BCH;
|
||||||
|
return `${sign}${whole.toString()}.${fractional.toString().padStart(8, "0")} BCH`;
|
||||||
|
}
|
||||||
|
|
||||||
export function VariableInputField({
|
export function VariableInputField({
|
||||||
variable,
|
variable,
|
||||||
index,
|
index,
|
||||||
@@ -27,6 +67,26 @@ export function VariableInputField({
|
|||||||
borderColor,
|
borderColor,
|
||||||
focusColor,
|
focusColor,
|
||||||
}: VariableInputFieldProps): React.ReactElement {
|
}: VariableInputFieldProps): React.ReactElement {
|
||||||
|
const { currencyCode, formattedFiatPerBchRate, formatSatoshisToFiat } =
|
||||||
|
useSatoshisConversion();
|
||||||
|
const satoshisValue = useMemo(
|
||||||
|
() => parseSatoshis(variable.value),
|
||||||
|
[variable.value],
|
||||||
|
);
|
||||||
|
const formattedBch = useMemo(() => {
|
||||||
|
if (satoshisValue === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return formatBchFromSatoshis(satoshisValue);
|
||||||
|
}, [satoshisValue]);
|
||||||
|
const formattedFiat = useMemo(() => {
|
||||||
|
if (satoshisValue === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return formatSatoshisToFiat(satoshisValue);
|
||||||
|
}, [satoshisValue, formatSatoshisToFiat]);
|
||||||
|
const shouldShowSatoshisConversion = isSatoshisVariable(variable);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box flexDirection="column" marginBottom={1}>
|
<Box flexDirection="column" marginBottom={1}>
|
||||||
<Text color={focusColor}>{variable.name}</Text>
|
<Text color={focusColor}>{variable.name}</Text>
|
||||||
@@ -54,12 +114,29 @@ export function VariableInputField({
|
|||||||
<Text color={borderColor} dimColor>{variable.hint}</Text>
|
<Text color={borderColor} dimColor>{variable.hint}</Text>
|
||||||
|
|
||||||
</Box>
|
</Box>
|
||||||
{variable.type === 'integer' && variable.hint === 'satoshis' && (
|
{shouldShowSatoshisConversion && (
|
||||||
<Box>
|
<Box flexDirection="column">
|
||||||
<Text color={borderColor} dimColor>
|
{formattedBch ? (
|
||||||
{/* Convert from sats to bch. NOTE: we can't use the formatSatoshis function because it is too verbose and returns too many values in the string*/}
|
<>
|
||||||
{(Number(variable.value) / 100_000_000).toFixed(8)} BCH
|
<Text color={borderColor} dimColor>
|
||||||
</Text>
|
{formattedBch}
|
||||||
|
</Text>
|
||||||
|
<Text color={borderColor} dimColor>
|
||||||
|
{formattedFiat
|
||||||
|
? `Approx. ${currencyCode}: ${formattedFiat}`
|
||||||
|
: `Approx. ${currencyCode}: waiting for live rate...`}
|
||||||
|
</Text>
|
||||||
|
{formattedFiatPerBchRate && (
|
||||||
|
<Text color={borderColor} dimColor>
|
||||||
|
1 BCH = {formattedFiatPerBchRate}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Text color={borderColor} dimColor>
|
||||||
|
Enter a whole satoshi amount to preview BCH/{currencyCode} conversion.
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -23,3 +23,5 @@ export {
|
|||||||
useBlockableInput,
|
useBlockableInput,
|
||||||
useIsInputCaptured,
|
useIsInputCaptured,
|
||||||
} from "./useInputLayer.js";
|
} from "./useInputLayer.js";
|
||||||
|
export { useRate, useBchToFiatRate } from "./useRates.js";
|
||||||
|
export { useSatoshisConversion } from "./useSatoshisConversion.js";
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ export function AppProvider({
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Start the AppService (loads existing invitations)
|
// Start the AppService (loads existing invitations)
|
||||||
await service.start();
|
service.start();
|
||||||
|
|
||||||
// Set the service and mark as initialized
|
// Set the service and mark as initialized
|
||||||
setAppService(service);
|
setAppService(service);
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import { useAppContext } from './useAppContext.js';
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Get all invitations reactively.
|
* Get all invitations reactively.
|
||||||
* Re-renders when invitations are added or removed.
|
* Re-renders when invitations are added, removed, or updated.
|
||||||
*/
|
*/
|
||||||
export function useInvitations(): Invitation[] {
|
export function useInvitations(): Invitation[] {
|
||||||
const { appService } = useAppContext();
|
const { appService } = useAppContext();
|
||||||
@@ -21,26 +21,22 @@ export function useInvitations(): Invitation[] {
|
|||||||
return () => {};
|
return () => {};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Subscribe to invitation list changes
|
appService.on('wallet-state-changed', callback);
|
||||||
const onAdded = () => callback();
|
|
||||||
const onRemoved = () => callback();
|
|
||||||
|
|
||||||
appService.on('invitation-added', onAdded);
|
|
||||||
appService.on('invitation-removed', onRemoved);
|
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
appService.off('invitation-added', onAdded);
|
appService.off('wallet-state-changed', callback);
|
||||||
appService.off('invitation-removed', onRemoved);
|
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
[appService]
|
[appService]
|
||||||
);
|
);
|
||||||
|
|
||||||
const getSnapshot = useCallback(() => {
|
const getSnapshot = useCallback(() => {
|
||||||
return appService?.invitations ?? [];
|
return appService?.invitationsRevision ?? 0;
|
||||||
}, [appService]);
|
}, [appService]);
|
||||||
|
|
||||||
return useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
|
const revision = useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
|
||||||
|
|
||||||
|
return useMemo(() => [...(appService?.invitations ?? [])], [appService, revision]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -56,48 +52,41 @@ export function useInvitation(invitationId: string | null): Invitation | null {
|
|||||||
return () => {};
|
return () => {};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find the invitation instance
|
const onWalletStateChanged = ({
|
||||||
const invitation = appService.invitations.find(
|
invitationIdentifier,
|
||||||
(inv) => inv.data.invitationIdentifier === invitationId
|
}: {
|
||||||
);
|
invitationIdentifier: string;
|
||||||
|
}) => {
|
||||||
if (!invitation) {
|
if (invitationIdentifier === invitationId) {
|
||||||
return () => {};
|
callback();
|
||||||
}
|
}
|
||||||
|
};
|
||||||
// Subscribe to this specific invitation's updates
|
appService.on('wallet-state-changed', onWalletStateChanged);
|
||||||
const onUpdated = () => callback();
|
|
||||||
const onStatusChanged = () => callback();
|
|
||||||
|
|
||||||
invitation.on('invitation-updated', onUpdated);
|
|
||||||
invitation.on('invitation-status-changed', onStatusChanged);
|
|
||||||
|
|
||||||
// Also subscribe to list changes in case the invitation is removed
|
|
||||||
const onRemoved = () => callback();
|
|
||||||
appService.on('invitation-removed', onRemoved);
|
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
invitation.off('invitation-updated', onUpdated);
|
appService.off('wallet-state-changed', onWalletStateChanged);
|
||||||
invitation.off('invitation-status-changed', onStatusChanged);
|
|
||||||
appService.off('invitation-removed', onRemoved);
|
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
[appService, invitationId]
|
[appService, invitationId]
|
||||||
);
|
);
|
||||||
|
|
||||||
const getSnapshot = useCallback(() => {
|
const getSnapshot = useCallback(() => {
|
||||||
if (!appService || !invitationId) {
|
return appService && invitationId
|
||||||
return null;
|
? appService.getInvitationRevision(invitationId)
|
||||||
}
|
: 0;
|
||||||
|
|
||||||
return (
|
|
||||||
appService.invitations.find(
|
|
||||||
(inv) => inv.data.invitationIdentifier === invitationId
|
|
||||||
) ?? null
|
|
||||||
);
|
|
||||||
}, [appService, invitationId]);
|
}, [appService, invitationId]);
|
||||||
|
|
||||||
return useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
|
useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
|
||||||
|
|
||||||
|
if (!appService || !invitationId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
appService.invitations.find(
|
||||||
|
(inv) => inv.data.invitationIdentifier === invitationId
|
||||||
|
) ?? null
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -109,7 +98,7 @@ export function useInvitationData(invitationId: string | null): XOInvitation | n
|
|||||||
|
|
||||||
return useMemo(() => {
|
return useMemo(() => {
|
||||||
return invitation?.data ?? null;
|
return invitation?.data ?? null;
|
||||||
}, [invitation?.data.invitationIdentifier, invitation?.data.commits?.length]);
|
}, [invitation?.data]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
68
src/tui/hooks/useRates.tsx
Normal file
68
src/tui/hooks/useRates.tsx
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
import { useCallback, useMemo, useSyncExternalStore } from 'react';
|
||||||
|
import type { RatesServiceEventMap } from '../../services/rates.js';
|
||||||
|
import { useAppContext } from './useAppContext.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reactive hook for a single market pair.
|
||||||
|
*
|
||||||
|
* Pair format is NUMERATOR / DENOMINATOR, e.g. USD / BCH.
|
||||||
|
*/
|
||||||
|
export function useRate(
|
||||||
|
numeratorUnitCode: string,
|
||||||
|
denominatorUnitCode: string,
|
||||||
|
): number | null {
|
||||||
|
const { appService } = useAppContext();
|
||||||
|
|
||||||
|
const normalizedNumerator = useMemo(
|
||||||
|
() => numeratorUnitCode.toUpperCase(),
|
||||||
|
[numeratorUnitCode],
|
||||||
|
);
|
||||||
|
|
||||||
|
const normalizedDenominator = useMemo(
|
||||||
|
() => denominatorUnitCode.toUpperCase(),
|
||||||
|
[denominatorUnitCode],
|
||||||
|
);
|
||||||
|
|
||||||
|
const subscribe = useCallback(
|
||||||
|
(callback: () => void) => {
|
||||||
|
if (!appService) {
|
||||||
|
return () => {};
|
||||||
|
}
|
||||||
|
|
||||||
|
const onRateUpdated = (event: RatesServiceEventMap['rate-updated']) => {
|
||||||
|
if (
|
||||||
|
event.numeratorUnitCode === normalizedNumerator &&
|
||||||
|
event.denominatorUnitCode === normalizedDenominator
|
||||||
|
) {
|
||||||
|
callback();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const unsubscribe = appService.rates.on('rate-updated', onRateUpdated);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
unsubscribe();
|
||||||
|
};
|
||||||
|
},
|
||||||
|
[appService, normalizedNumerator, normalizedDenominator],
|
||||||
|
);
|
||||||
|
|
||||||
|
const getSnapshot = useCallback(() => {
|
||||||
|
if (!appService) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return appService.rates.getRate(normalizedNumerator, normalizedDenominator);
|
||||||
|
}, [appService, normalizedNumerator, normalizedDenominator]);
|
||||||
|
|
||||||
|
return useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convenience hook for BCH -> fiat market rates.
|
||||||
|
*/
|
||||||
|
export function useBchToFiatRate(
|
||||||
|
targetCurrency: string = 'USD',
|
||||||
|
): number | null {
|
||||||
|
return useRate(targetCurrency, 'BCH');
|
||||||
|
}
|
||||||
73
src/tui/hooks/useSatoshisConversion.tsx
Normal file
73
src/tui/hooks/useSatoshisConversion.tsx
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
import { useCallback, useMemo, useSyncExternalStore } from 'react';
|
||||||
|
import { useAppContext } from './useAppContext.js';
|
||||||
|
import { useBchToFiatRate } from './useRates.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reactive BCH satoshis -> fiat conversion helpers for TUI screens.
|
||||||
|
*
|
||||||
|
* This hook subscribes to rate updates through `useBchToFiatRate`, so any
|
||||||
|
* component using it will re-render automatically when the selected pair
|
||||||
|
* receives a new quote.
|
||||||
|
*/
|
||||||
|
export function useSatoshisConversion(targetCurrency?: string) {
|
||||||
|
const { appService } = useAppContext();
|
||||||
|
const subscribeToCurrency = useCallback(
|
||||||
|
(callback: () => void) => {
|
||||||
|
if (!appService || targetCurrency) {
|
||||||
|
return () => {};
|
||||||
|
}
|
||||||
|
|
||||||
|
return appService.settings.on('settings-updated', (event) => {
|
||||||
|
if (event.key === 'currency') {
|
||||||
|
callback();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[appService, targetCurrency],
|
||||||
|
);
|
||||||
|
|
||||||
|
const getCurrencySnapshot = useCallback(() => {
|
||||||
|
if (targetCurrency) {
|
||||||
|
return targetCurrency.toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!appService) {
|
||||||
|
return 'USD';
|
||||||
|
}
|
||||||
|
|
||||||
|
return appService.settings.getCurrency();
|
||||||
|
}, [appService, targetCurrency]);
|
||||||
|
|
||||||
|
const currencyCode = useSyncExternalStore(
|
||||||
|
subscribeToCurrency,
|
||||||
|
getCurrencySnapshot,
|
||||||
|
getCurrencySnapshot,
|
||||||
|
);
|
||||||
|
const fiatPerBchRate = useBchToFiatRate(currencyCode);
|
||||||
|
|
||||||
|
const formattedFiatPerBchRate = useMemo(() => {
|
||||||
|
if (!appService || fiatPerBchRate === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return appService.rates.formatCurrency(fiatPerBchRate, currencyCode);
|
||||||
|
}, [appService, fiatPerBchRate, currencyCode]);
|
||||||
|
|
||||||
|
const formatSatoshisToFiat = useCallback(
|
||||||
|
(satoshis: bigint): string | null => {
|
||||||
|
if (!appService || fiatPerBchRate === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return appService.rates.formatBchToFiat(satoshis, currencyCode);
|
||||||
|
},
|
||||||
|
[appService, fiatPerBchRate, currencyCode],
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
currencyCode,
|
||||||
|
fiatPerBchRate,
|
||||||
|
formattedFiatPerBchRate,
|
||||||
|
formatSatoshisToFiat,
|
||||||
|
} as const;
|
||||||
|
}
|
||||||
@@ -19,7 +19,7 @@ import path from 'path';
|
|||||||
import { createMnemonicFile } from '../../cli/mnemonic.js';
|
import { createMnemonicFile } from '../../cli/mnemonic.js';
|
||||||
import { getMnemonicsDir } from '../../utils/paths.js';
|
import { getMnemonicsDir } from '../../utils/paths.js';
|
||||||
import { BCHMnemonicURL } from '../../utils/bch-mnemonic-url.js';
|
import { BCHMnemonicURL } from '../../utils/bch-mnemonic-url.js';
|
||||||
import { encodeBip39Mnemonic } from '@bitauth/libauth';
|
import { encodeBip39Mnemonic, generateBip39Mnemonic } from '@bitauth/libauth';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Status message type.
|
* Status message type.
|
||||||
@@ -41,10 +41,10 @@ interface MnemonicFileEntry {
|
|||||||
* Focus sections the user can tab between.
|
* Focus sections the user can tab between.
|
||||||
* When saved wallets exist the file list is shown first.
|
* When saved wallets exist the file list is shown first.
|
||||||
*/
|
*/
|
||||||
type FocusSection = 'files' | 'input' | 'saveCheckbox' | 'button';
|
type FocusSection = 'files' | 'input' | 'saveCheckbox' | 'generateRandomSeed' | 'button';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Reads mnemonic-* files from ~/.config/xo-cli/mnemonics/ (same as xo-cli),
|
* Reads mnemonic-* files from the configured mnemonics directory (same as xo-cli),
|
||||||
* then from cwd for legacy installs. Parses each as a BCHMnemonicURL.
|
* then from cwd for legacy installs. Parses each as a BCHMnemonicURL.
|
||||||
*/
|
*/
|
||||||
function loadMnemonicFiles(): MnemonicFileEntry[] {
|
function loadMnemonicFiles(): MnemonicFileEntry[] {
|
||||||
@@ -101,7 +101,7 @@ export function SeedInputScreen(): React.ReactElement {
|
|||||||
const [mnemonicFiles, setMnemonicFiles] = useState<MnemonicFileEntry[]>([]);
|
const [mnemonicFiles, setMnemonicFiles] = useState<MnemonicFileEntry[]>([]);
|
||||||
const [selectedFileIndex, setSelectedFileIndex] = useState(0);
|
const [selectedFileIndex, setSelectedFileIndex] = useState(0);
|
||||||
|
|
||||||
/** When set, manual seed is written to ~/.config/xo-cli/mnemonics/ after a successful unlock. */
|
/** When set, manual seed is written to the configured mnemonics directory after a successful unlock. */
|
||||||
const [saveMnemonicChecked, setSaveMnemonicChecked] = useState(false);
|
const [saveMnemonicChecked, setSaveMnemonicChecked] = useState(false);
|
||||||
|
|
||||||
// Focus: when saved wallets exist default to the file list, otherwise the input.
|
// Focus: when saved wallets exist default to the file list, otherwise the input.
|
||||||
@@ -117,8 +117,8 @@ export function SeedInputScreen(): React.ReactElement {
|
|||||||
* The ordered list of focusable sections (files section only when entries exist).
|
* The ordered list of focusable sections (files section only when entries exist).
|
||||||
*/
|
*/
|
||||||
const focusSections: FocusSection[] = mnemonicFiles.length > 0
|
const focusSections: FocusSection[] = mnemonicFiles.length > 0
|
||||||
? ['files', 'input', 'saveCheckbox', 'button']
|
? ['files', 'input', 'generateRandomSeed', 'saveCheckbox', 'button']
|
||||||
: ['input', 'saveCheckbox', 'button'];
|
: ['input', 'generateRandomSeed', 'saveCheckbox', 'button'];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Shows a status message with the given type.
|
* Shows a status message with the given type.
|
||||||
@@ -158,9 +158,7 @@ export function SeedInputScreen(): React.ReactElement {
|
|||||||
setSeedPhrase('');
|
setSeedPhrase('');
|
||||||
setSaveMnemonicChecked(false);
|
setSaveMnemonicChecked(false);
|
||||||
|
|
||||||
setTimeout(() => {
|
navigate('wallet');
|
||||||
navigate('wallet');
|
|
||||||
}, 500);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message =
|
const message =
|
||||||
error instanceof Error ? error.message : 'Failed to initialize wallet';
|
error instanceof Error ? error.message : 'Failed to initialize wallet';
|
||||||
@@ -202,7 +200,7 @@ export function SeedInputScreen(): React.ReactElement {
|
|||||||
}, [mnemonicFiles, doInitialize]);
|
}, [mnemonicFiles, doInitialize]);
|
||||||
|
|
||||||
// Keyboard navigation
|
// Keyboard navigation
|
||||||
useBlockableInput((_input, key) => {
|
useBlockableInput((input, key) => {
|
||||||
if (isSubmitting) return;
|
if (isSubmitting) return;
|
||||||
|
|
||||||
// Tab / Shift-Tab to cycle focus sections
|
// Tab / Shift-Tab to cycle focus sections
|
||||||
@@ -219,7 +217,7 @@ export function SeedInputScreen(): React.ReactElement {
|
|||||||
|
|
||||||
// Space or Enter toggles "save mnemonic" when that row is focused
|
// Space or Enter toggles "save mnemonic" when that row is focused
|
||||||
if (focusedSection === 'saveCheckbox') {
|
if (focusedSection === 'saveCheckbox') {
|
||||||
if (_input === ' ' || key.return) {
|
if (input === ' ' || key.return) {
|
||||||
setSaveMnemonicChecked((v) => !v);
|
setSaveMnemonicChecked((v) => !v);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -241,6 +239,18 @@ export function SeedInputScreen(): React.ReactElement {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Ctrl-R generates a random seed phrase and fills it in the input
|
||||||
|
if (key.ctrl && input === 'r') {
|
||||||
|
setSeedPhrase(generateBip39Mnemonic());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If pressing enter while the generate random seed section is focused, generate a random seed and fill it in the input
|
||||||
|
if (key.return && focusedSection === 'generateRandomSeed') {
|
||||||
|
setSeedPhrase(generateBip39Mnemonic());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Enter on button submits manual seed
|
// Enter on button submits manual seed
|
||||||
if (key.return && focusedSection === 'button') {
|
if (key.return && focusedSection === 'button') {
|
||||||
handleSubmit();
|
handleSubmit();
|
||||||
@@ -358,6 +368,19 @@ export function SeedInputScreen(): React.ReactElement {
|
|||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
|
{/* Generate random seed phrase and fill in the input */}
|
||||||
|
<Box marginTop={1}>
|
||||||
|
<Box
|
||||||
|
paddingX={1}
|
||||||
|
paddingY={0}
|
||||||
|
backgroundColor={focusedSection === 'generateRandomSeed' ? colors.focus : colors.bgSelected}
|
||||||
|
>
|
||||||
|
<Text color={focusedSection === 'generateRandomSeed' ? colors.bg : colors.text} bold>Generate Random Seed</Text>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Text color={colors.textMuted}> (Ctrl-R)</Text>
|
||||||
|
</Box>
|
||||||
|
|
||||||
{/* Save mnemonic checkbox (manual entry only; applies on Continue) */}
|
{/* Save mnemonic checkbox (manual entry only; applies on Continue) */}
|
||||||
<Box
|
<Box
|
||||||
marginTop={1}
|
marginTop={1}
|
||||||
@@ -374,7 +397,7 @@ export function SeedInputScreen(): React.ReactElement {
|
|||||||
{saveMnemonicChecked ? '[x] ' : '[ ] '}
|
{saveMnemonicChecked ? '[x] ' : '[ ] '}
|
||||||
</Text>
|
</Text>
|
||||||
<Text color={colors.text}>Save this mnemonic</Text>
|
<Text color={colors.text}>Save this mnemonic</Text>
|
||||||
<Text color={colors.textMuted}> (~/.config/xo-cli/mnemonics/)</Text>
|
<Text color={colors.textMuted}> ({getMnemonicsDir()}/)</Text>
|
||||||
</Box>
|
</Box>
|
||||||
{focusedSection === 'saveCheckbox' && (
|
{focusedSection === 'saveCheckbox' && (
|
||||||
<Box marginTop={0} paddingX={1}>
|
<Box marginTop={0} paddingX={1}>
|
||||||
|
|||||||
@@ -8,10 +8,12 @@
|
|||||||
|
|
||||||
import React, { useState, useEffect, useCallback, useMemo } from 'react';
|
import React, { useState, useEffect, useCallback, useMemo } from 'react';
|
||||||
import { Box, Text } from 'ink';
|
import { Box, Text } from 'ink';
|
||||||
|
import path from 'node:path';
|
||||||
import { ScrollableList, type ListItemData } from '../components/List.js';
|
import { ScrollableList, type ListItemData } from '../components/List.js';
|
||||||
|
import { FilePicker } from '../components/FilePicker.js';
|
||||||
import { useNavigation } from '../hooks/useNavigation.js';
|
import { useNavigation } from '../hooks/useNavigation.js';
|
||||||
import { useAppContext, useStatus } from '../hooks/useAppContext.js';
|
import { useAppContext, useStatus } from '../hooks/useAppContext.js';
|
||||||
import { useBlockableInput } from '../hooks/useInputLayer.js';
|
import { useBlockableInput, useInputLayer, useIsInputCaptured, useLayeredInput } from '../hooks/useInputLayer.js';
|
||||||
import { colors, logoSmall } from '../theme.js';
|
import { colors, logoSmall } from '../theme.js';
|
||||||
|
|
||||||
// XO Imports
|
// XO Imports
|
||||||
@@ -24,6 +26,9 @@ import {
|
|||||||
formatActionListItem,
|
formatActionListItem,
|
||||||
getTemplateRoles,
|
getTemplateRoles,
|
||||||
} from '../../utils/template-utils.js';
|
} from '../../utils/template-utils.js';
|
||||||
|
import { buildScriptHashDataMap } from '../../utils/utxo-metadata.js';
|
||||||
|
import { loadTemplateFromFile } from '../../utils/load-template-from-file.js';
|
||||||
|
import { ConfirmDialog, DialogWrapper } from '../components/Dialog.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Template item with metadata.
|
* Template item with metadata.
|
||||||
@@ -52,6 +57,55 @@ interface TemplateActionItem {
|
|||||||
source: 'starting' | 'next' | 'starting+next';
|
source: 'starting' | 'next' | 'starting+next';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** List item key for the synthetic import row. */
|
||||||
|
const IMPORT_TEMPLATE_KEY = 'import-template';
|
||||||
|
|
||||||
|
/** Input layer id shared by the import dialog and its file picker. */
|
||||||
|
const IMPORT_TEMPLATE_DIALOG_LAYER_ID = 'import-template-dialog';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Import template dialog overlay.
|
||||||
|
* Captures keyboard input and wraps the generic {@link FilePicker}.
|
||||||
|
*/
|
||||||
|
function ImportTemplateDialogOverlay({
|
||||||
|
onClose,
|
||||||
|
onSelectFile,
|
||||||
|
}: {
|
||||||
|
onClose: () => void;
|
||||||
|
onSelectFile: (filePath: string) => void;
|
||||||
|
}): React.ReactElement {
|
||||||
|
useInputLayer(IMPORT_TEMPLATE_DIALOG_LAYER_ID);
|
||||||
|
|
||||||
|
useLayeredInput(IMPORT_TEMPLATE_DIALOG_LAYER_ID, (_input, key) => {
|
||||||
|
if (key.escape) {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DialogWrapper title="Import Template" borderColor={colors.primary} width={72}>
|
||||||
|
<Text color={colors.textMuted}>
|
||||||
|
Select a JSON, JavaScript, or TypeScript template file from disk.
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Box marginTop={1}>
|
||||||
|
<FilePicker
|
||||||
|
layerId={IMPORT_TEMPLATE_DIALOG_LAYER_ID}
|
||||||
|
extensions={['json', 'js', 'mjs', 'cjs', 'ts', 'mts', 'cts']}
|
||||||
|
onSelectFile={onSelectFile}
|
||||||
|
maxVisible={8}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box marginTop={1}>
|
||||||
|
<Text color={colors.textMuted}>
|
||||||
|
↑↓ navigate • Enter open/select • Esc cancel
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
</DialogWrapper>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Template List Screen Component.
|
* Template List Screen Component.
|
||||||
* Displays templates and their starting actions.
|
* Displays templates and their starting actions.
|
||||||
@@ -67,6 +121,10 @@ export function TemplateListScreen(): React.ReactElement {
|
|||||||
const [selectedActionIndex, setSelectedActionIndex] = useState(0);
|
const [selectedActionIndex, setSelectedActionIndex] = useState(0);
|
||||||
const [focusedPanel, setFocusedPanel] = useState<'templates' | 'actions'>('templates');
|
const [focusedPanel, setFocusedPanel] = useState<'templates' | 'actions'>('templates');
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [isImportDialogOpen, setIsImportDialogOpen] = useState(false);
|
||||||
|
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
|
||||||
|
const [templateToDelete, setTemplateToDelete] = useState<TemplateItem | null>(null);
|
||||||
|
const isCaptured = useIsInputCaptured();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Loads templates from the engine.
|
* Loads templates from the engine.
|
||||||
@@ -83,12 +141,21 @@ export function TemplateListScreen(): React.ReactElement {
|
|||||||
|
|
||||||
const templateList = await appService.engine.listImportedTemplates();
|
const templateList = await appService.engine.listImportedTemplates();
|
||||||
const allUtxos = await appService.engine.listUnspentOutputsData();
|
const allUtxos = await appService.engine.listUnspentOutputsData();
|
||||||
|
const scriptHashDataByScriptHash =
|
||||||
|
await buildScriptHashDataMap(appService.engine);
|
||||||
|
|
||||||
const ownedOutputsByTemplate = new Map<string, Set<string>>();
|
const ownedOutputsByTemplate = new Map<string, Set<string>>();
|
||||||
for (const utxo of allUtxos) {
|
for (const utxo of allUtxos) {
|
||||||
const existing = ownedOutputsByTemplate.get(utxo.templateIdentifier) ?? new Set<string>();
|
const scriptRow = scriptHashDataByScriptHash.get(utxo.scriptHash);
|
||||||
existing.add(utxo.outputIdentifier);
|
if (scriptRow === undefined) {
|
||||||
ownedOutputsByTemplate.set(utxo.templateIdentifier, existing);
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing =
|
||||||
|
ownedOutputsByTemplate.get(scriptRow.templateIdentifier) ??
|
||||||
|
new Set<string>();
|
||||||
|
existing.add(scriptRow.outputIdentifier);
|
||||||
|
ownedOutputsByTemplate.set(scriptRow.templateIdentifier, existing);
|
||||||
}
|
}
|
||||||
|
|
||||||
const loadedTemplates = await Promise.all(
|
const loadedTemplates = await Promise.all(
|
||||||
@@ -100,8 +167,8 @@ export function TemplateListScreen(): React.ReactElement {
|
|||||||
for (const startingAction of rawStartingActions) {
|
for (const startingAction of rawStartingActions) {
|
||||||
const existing = actionMap.get(startingAction.action);
|
const existing = actionMap.get(startingAction.action);
|
||||||
if (existing) {
|
if (existing) {
|
||||||
if (!existing.roles.includes(startingAction.role)) {
|
if (!existing.roles.includes(startingAction.role ?? '')) {
|
||||||
existing.roles.push(startingAction.role);
|
existing.roles.push(startingAction.role ?? '');
|
||||||
}
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -111,7 +178,7 @@ export function TemplateListScreen(): React.ReactElement {
|
|||||||
actionIdentifier: startingAction.action,
|
actionIdentifier: startingAction.action,
|
||||||
name: actionDef?.name || startingAction.action,
|
name: actionDef?.name || startingAction.action,
|
||||||
description: actionDef?.description,
|
description: actionDef?.description,
|
||||||
roles: [startingAction.role],
|
roles: [startingAction.role ?? ''],
|
||||||
source: 'starting',
|
source: 'starting',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -119,9 +186,9 @@ export function TemplateListScreen(): React.ReactElement {
|
|||||||
const ownedOutputIdentifiers = ownedOutputsByTemplate.get(templateIdentifier) ?? new Set<string>();
|
const ownedOutputIdentifiers = ownedOutputsByTemplate.get(templateIdentifier) ?? new Set<string>();
|
||||||
for (const outputIdentifier of ownedOutputIdentifiers) {
|
for (const outputIdentifier of ownedOutputIdentifiers) {
|
||||||
const outputDef = template.outputs?.[outputIdentifier];
|
const outputDef = template.outputs?.[outputIdentifier];
|
||||||
if (!outputDef || typeof outputDef.lockscript !== 'string') continue;
|
if (!outputDef || typeof outputDef.lockingScript !== 'string') continue;
|
||||||
|
|
||||||
const lockingScriptDefinition = (template.lockingScripts as Record<string, unknown> | undefined)?.[outputDef.lockscript] as
|
const lockingScriptDefinition = (template.lockingScripts as Record<string, unknown> | undefined)?.[outputDef.lockingScript] as
|
||||||
| { roles?: Record<string, { actions?: Array<{ action?: string; role?: string } | string> }> }
|
| { roles?: Record<string, { actions?: Array<{ action?: string; role?: string } | string> }> }
|
||||||
| undefined;
|
| undefined;
|
||||||
if (!lockingScriptDefinition?.roles) continue;
|
if (!lockingScriptDefinition?.roles) continue;
|
||||||
@@ -186,15 +253,23 @@ export function TemplateListScreen(): React.ReactElement {
|
|||||||
loadTemplates();
|
loadTemplates();
|
||||||
}, [loadTemplates]);
|
}, [loadTemplates]);
|
||||||
|
|
||||||
// Get current template and its actions
|
|
||||||
const currentTemplate = templates[selectedTemplateIndex];
|
|
||||||
const currentActions = currentTemplate?.availableActions ?? [];
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build template list items for ScrollableList.
|
* Build template list items for ScrollableList.
|
||||||
*/
|
*/
|
||||||
const templateListItems = useMemo((): TemplateListItem[] => {
|
const templateListItems = useMemo((): TemplateListItem[] => {
|
||||||
return templates.map((item, index) => {
|
const importTemplateItem: TemplateListItem = {
|
||||||
|
key: IMPORT_TEMPLATE_KEY,
|
||||||
|
label: 'Import Template',
|
||||||
|
description: 'Import a template from a file',
|
||||||
|
value: {
|
||||||
|
templateIdentifier: IMPORT_TEMPLATE_KEY,
|
||||||
|
template: {} as XOTemplate,
|
||||||
|
availableActions: [],
|
||||||
|
},
|
||||||
|
hidden: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
return [...templates.map((item, index) => {
|
||||||
const formatted = formatTemplateListItem(item.template, index);
|
const formatted = formatTemplateListItem(item.template, index);
|
||||||
return {
|
return {
|
||||||
key: item.templateIdentifier,
|
key: item.templateIdentifier,
|
||||||
@@ -203,9 +278,16 @@ export function TemplateListScreen(): React.ReactElement {
|
|||||||
value: item,
|
value: item,
|
||||||
hidden: !formatted.isValid,
|
hidden: !formatted.isValid,
|
||||||
};
|
};
|
||||||
});
|
}), importTemplateItem];
|
||||||
}, [templates]);
|
}, [templates]);
|
||||||
|
|
||||||
|
const selectedTemplateListItem = templateListItems[selectedTemplateIndex];
|
||||||
|
const isImportRowSelected = selectedTemplateListItem?.key === IMPORT_TEMPLATE_KEY;
|
||||||
|
const currentTemplate = isImportRowSelected
|
||||||
|
? undefined
|
||||||
|
: selectedTemplateListItem?.value;
|
||||||
|
const currentActions = currentTemplate?.availableActions ?? [];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build action list items for ScrollableList.
|
* Build action list items for ScrollableList.
|
||||||
*/
|
*/
|
||||||
@@ -217,14 +299,10 @@ export function TemplateListScreen(): React.ReactElement {
|
|||||||
action.roles.length,
|
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}${sourceSuffix}`,
|
label: `${formatted.label}`,
|
||||||
description: formatted.description,
|
description: formatted.description,
|
||||||
value: action,
|
value: action,
|
||||||
hidden: !formatted.isValid,
|
hidden: !formatted.isValid,
|
||||||
@@ -240,6 +318,86 @@ export function TemplateListScreen(): React.ReactElement {
|
|||||||
setSelectedActionIndex(0); // Reset action selection when template changes
|
setSelectedActionIndex(0); // Reset action selection when template changes
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Opens the import file picker.
|
||||||
|
*/
|
||||||
|
const openImportDialog = useCallback(() => {
|
||||||
|
setIsImportDialogOpen(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Opens the import file picker when the synthetic import row is activated.
|
||||||
|
*/
|
||||||
|
const handleTemplateActivate = useCallback((item: TemplateListItem) => {
|
||||||
|
if (item.key === IMPORT_TEMPLATE_KEY) {
|
||||||
|
openImportDialog();
|
||||||
|
}
|
||||||
|
}, [openImportDialog]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Opens delete confirmation for the currently selected template.
|
||||||
|
*/
|
||||||
|
const openDeleteDialog = useCallback(() => {
|
||||||
|
if (!currentTemplate) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setTemplateToDelete(currentTemplate);
|
||||||
|
setIsDeleteDialogOpen(true);
|
||||||
|
}, [currentTemplate]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deletes the confirmed template from local storage.
|
||||||
|
*/
|
||||||
|
const handleConfirmDelete = useCallback(async () => {
|
||||||
|
if (!appService || !templateToDelete) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const deletedName =
|
||||||
|
templateToDelete.template.name || templateToDelete.templateIdentifier;
|
||||||
|
|
||||||
|
try {
|
||||||
|
setStatus('Deleting template...');
|
||||||
|
await appService.engine.DANGEROUS_deleteImportedTemplate(
|
||||||
|
templateToDelete.templateIdentifier,
|
||||||
|
);
|
||||||
|
setIsDeleteDialogOpen(false);
|
||||||
|
setTemplateToDelete(null);
|
||||||
|
await loadTemplates();
|
||||||
|
setStatus(`Deleted ${deletedName}`);
|
||||||
|
} catch (error) {
|
||||||
|
setIsDeleteDialogOpen(false);
|
||||||
|
setTemplateToDelete(null);
|
||||||
|
showError(
|
||||||
|
`Failed to delete template: ${error instanceof Error ? error.message : String(error)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}, [appService, loadTemplates, setStatus, showError, templateToDelete]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loads the selected template file and imports it through the engine.
|
||||||
|
*/
|
||||||
|
const handleImportFile = useCallback(async (filePath: string) => {
|
||||||
|
if (!appService) {
|
||||||
|
showError('AppService not initialized');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setStatus('Importing template...');
|
||||||
|
const content = await loadTemplateFromFile(filePath);
|
||||||
|
await appService.engine.importTemplate(content);
|
||||||
|
await loadTemplates();
|
||||||
|
setIsImportDialogOpen(false);
|
||||||
|
setStatus(`Imported ${path.basename(filePath)}`);
|
||||||
|
} catch (error) {
|
||||||
|
showError(
|
||||||
|
`Failed to import template: ${error instanceof Error ? error.message : String(error)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}, [appService, loadTemplates, setStatus, showError]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handles action selection.
|
* Handles action selection.
|
||||||
* Navigates to the Action Wizard where the user will choose their role.
|
* Navigates to the Action Wizard where the user will choose their role.
|
||||||
@@ -258,12 +416,25 @@ export function TemplateListScreen(): React.ReactElement {
|
|||||||
});
|
});
|
||||||
}, [currentTemplate, navigate]);
|
}, [currentTemplate, navigate]);
|
||||||
|
|
||||||
// Handle keyboard navigation
|
// Handle keyboard navigation and template shortcuts
|
||||||
useBlockableInput((_input, key) => {
|
useBlockableInput((input, key) => {
|
||||||
if (key.tab) {
|
if (key.tab) {
|
||||||
setFocusedPanel(prev => prev === 'templates' ? 'actions' : 'templates');
|
setFocusedPanel(prev => prev === 'templates' ? 'actions' : 'templates');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isLoading || focusedPanel !== 'templates') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input === 'a' || input === 'A') {
|
||||||
|
openImportDialog();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((input === 'd' || input === 'D') && currentTemplate) {
|
||||||
|
openDeleteDialog();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -332,7 +503,8 @@ export function TemplateListScreen(): React.ReactElement {
|
|||||||
items={templateListItems}
|
items={templateListItems}
|
||||||
selectedIndex={selectedTemplateIndex}
|
selectedIndex={selectedTemplateIndex}
|
||||||
onSelect={handleTemplateSelect}
|
onSelect={handleTemplateSelect}
|
||||||
focus={focusedPanel === 'templates'}
|
onActivate={handleTemplateActivate}
|
||||||
|
focus={focusedPanel === 'templates' && !isCaptured}
|
||||||
emptyMessage="No templates imported"
|
emptyMessage="No templates imported"
|
||||||
renderItem={renderTemplateItem}
|
renderItem={renderTemplateItem}
|
||||||
/>
|
/>
|
||||||
@@ -354,6 +526,12 @@ export function TemplateListScreen(): React.ReactElement {
|
|||||||
<Box marginTop={1}>
|
<Box marginTop={1}>
|
||||||
<Text color={colors.textMuted}>Loading...</Text>
|
<Text color={colors.textMuted}>Loading...</Text>
|
||||||
</Box>
|
</Box>
|
||||||
|
) : isImportRowSelected ? (
|
||||||
|
<Box marginTop={1}>
|
||||||
|
<Text color={colors.textMuted}>
|
||||||
|
Import a template to see available actions
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
) : !currentTemplate ? (
|
) : !currentTemplate ? (
|
||||||
<Box marginTop={1}>
|
<Box marginTop={1}>
|
||||||
<Text color={colors.textMuted}>Select a template...</Text>
|
<Text color={colors.textMuted}>Select a template...</Text>
|
||||||
@@ -364,7 +542,7 @@ export function TemplateListScreen(): React.ReactElement {
|
|||||||
selectedIndex={selectedActionIndex}
|
selectedIndex={selectedActionIndex}
|
||||||
onSelect={setSelectedActionIndex}
|
onSelect={setSelectedActionIndex}
|
||||||
onActivate={handleActionActivate}
|
onActivate={handleActionActivate}
|
||||||
focus={focusedPanel === 'actions'}
|
focus={focusedPanel === 'actions' && !isCaptured}
|
||||||
emptyMessage="No actions available"
|
emptyMessage="No actions available"
|
||||||
renderItem={renderActionItem}
|
renderItem={renderActionItem}
|
||||||
/>
|
/>
|
||||||
@@ -386,7 +564,15 @@ export function TemplateListScreen(): React.ReactElement {
|
|||||||
<Text color={colors.primary} bold> Description </Text>
|
<Text color={colors.primary} bold> Description </Text>
|
||||||
|
|
||||||
{/* Show template description when templates panel is focused */}
|
{/* Show template description when templates panel is focused */}
|
||||||
{focusedPanel === 'templates' && currentTemplate ? (
|
{focusedPanel === 'templates' && isImportRowSelected ? (
|
||||||
|
<Box marginTop={1} flexDirection="column">
|
||||||
|
<Text color={colors.text} bold>Import Template</Text>
|
||||||
|
<Text color={colors.textMuted}>
|
||||||
|
Import a template file (JSON, JavaScript, or TypeScript) from the directory where the TUI was launched.
|
||||||
|
Press Enter or a to open the file picker.
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
) : focusedPanel === 'templates' && currentTemplate ? (
|
||||||
<Box marginTop={1} flexDirection="column">
|
<Box marginTop={1} flexDirection="column">
|
||||||
<Text color={colors.text} bold>
|
<Text color={colors.text} bold>
|
||||||
{currentTemplate.template.name || 'Unnamed Template'}
|
{currentTemplate.template.name || 'Unnamed Template'}
|
||||||
@@ -465,9 +651,57 @@ export function TemplateListScreen(): React.ReactElement {
|
|||||||
{/* Help text */}
|
{/* Help text */}
|
||||||
<Box marginTop={1}>
|
<Box marginTop={1}>
|
||||||
<Text color={colors.textMuted} dimColor>
|
<Text color={colors.textMuted} dimColor>
|
||||||
Tab: Switch list • Enter: Select action • ↑↓: Navigate • Esc: Back
|
{focusedPanel === 'templates' && isImportRowSelected
|
||||||
|
? 'Tab: Switch list • a/Enter: Import • ↑↓: Navigate • Esc: Back'
|
||||||
|
: focusedPanel === 'templates' && currentTemplate
|
||||||
|
? 'Tab: Switch list • a: Import • d: Delete • ↑↓: Navigate • Esc: Back'
|
||||||
|
: 'Tab: Switch list • Enter: Select action • ↑↓: Navigate • Esc: Back'}
|
||||||
</Text>
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
|
{/* Import template dialog overlay */}
|
||||||
|
{isImportDialogOpen && (
|
||||||
|
<Box
|
||||||
|
position="absolute"
|
||||||
|
flexDirection="column"
|
||||||
|
alignItems="center"
|
||||||
|
justifyContent="center"
|
||||||
|
width="100%"
|
||||||
|
height="100%"
|
||||||
|
>
|
||||||
|
<ImportTemplateDialogOverlay
|
||||||
|
onClose={() => setIsImportDialogOpen(false)}
|
||||||
|
onSelectFile={handleImportFile}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Delete template confirmation dialog */}
|
||||||
|
{isDeleteDialogOpen && templateToDelete && (
|
||||||
|
<Box
|
||||||
|
position="absolute"
|
||||||
|
flexDirection="column"
|
||||||
|
alignItems="center"
|
||||||
|
justifyContent="center"
|
||||||
|
width="100%"
|
||||||
|
height="100%"
|
||||||
|
>
|
||||||
|
<ConfirmDialog
|
||||||
|
title="Delete Template"
|
||||||
|
message={
|
||||||
|
`Delete "${templateToDelete.template.name || templateToDelete.templateIdentifier}"?\n\n` +
|
||||||
|
'This removes the template from local storage. Invitations that use it may become unusable.'
|
||||||
|
}
|
||||||
|
confirmLabel="Delete"
|
||||||
|
cancelLabel="Cancel"
|
||||||
|
onConfirm={handleConfirmDelete}
|
||||||
|
onCancel={() => {
|
||||||
|
setIsDeleteDialogOpen(false);
|
||||||
|
setTemplateToDelete(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,413 +0,0 @@
|
|||||||
/**
|
|
||||||
* Transaction Screen - Reviews and broadcasts transactions.
|
|
||||||
*
|
|
||||||
* Provides:
|
|
||||||
* - Transaction details review
|
|
||||||
* - Input/output inspection
|
|
||||||
* - Fee calculation display
|
|
||||||
* - Broadcast confirmation
|
|
||||||
*/
|
|
||||||
|
|
||||||
import React, { useState, useEffect, useCallback } from 'react';
|
|
||||||
import { Box, Text } from 'ink';
|
|
||||||
import { ConfirmDialog } from '../components/Dialog.js';
|
|
||||||
import { useNavigation } from '../hooks/useNavigation.js';
|
|
||||||
import { useAppContext, useStatus } from '../hooks/useAppContext.js';
|
|
||||||
import { useBlockableInput } from '../hooks/useInputLayer.js';
|
|
||||||
import { useInvitation } from '../hooks/useInvitations.js';
|
|
||||||
import { colors, logoSmall, formatSatoshis, formatHex } from '../theme.js';
|
|
||||||
import { copyToClipboard } from '../utils/clipboard.js';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Action menu items.
|
|
||||||
*/
|
|
||||||
const actionItems = [
|
|
||||||
{ label: 'Broadcast Transaction', value: 'broadcast' },
|
|
||||||
{ label: 'Sign Transaction', value: 'sign' },
|
|
||||||
{ label: 'Copy Transaction Hex', value: 'copy' },
|
|
||||||
{ label: 'Back to Invitation', value: 'back' },
|
|
||||||
];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Transaction Screen Component.
|
|
||||||
*/
|
|
||||||
export function TransactionScreen(): React.ReactElement {
|
|
||||||
const { navigate, goBack, data: navData } = useNavigation();
|
|
||||||
const { showError, showInfo } = useAppContext();
|
|
||||||
const { setStatus } = useStatus();
|
|
||||||
|
|
||||||
// Extract invitation ID from navigation data
|
|
||||||
const invitationId = navData.invitationId as string | undefined;
|
|
||||||
|
|
||||||
// Use hook to get invitation reactively
|
|
||||||
const invitationInstance = useInvitation(invitationId ?? null);
|
|
||||||
|
|
||||||
// State
|
|
||||||
const [focusedPanel, setFocusedPanel] = useState<'inputs' | 'outputs' | 'actions'>('actions');
|
|
||||||
const [selectedActionIndex, setSelectedActionIndex] = useState(0);
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
|
||||||
const [showBroadcastConfirm, setShowBroadcastConfirm] = useState(false);
|
|
||||||
|
|
||||||
// Check if invitation exists
|
|
||||||
useEffect(() => {
|
|
||||||
if (!invitationId) {
|
|
||||||
showError('No invitation ID provided');
|
|
||||||
goBack();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (invitationId && !invitationInstance) {
|
|
||||||
showError('Invitation not found');
|
|
||||||
goBack();
|
|
||||||
}
|
|
||||||
}, [invitationId, invitationInstance, showError, goBack]);
|
|
||||||
|
|
||||||
const invitation = invitationInstance?.data ?? null;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Broadcast transaction.
|
|
||||||
*/
|
|
||||||
const broadcastTransaction = useCallback(async () => {
|
|
||||||
if (!invitationInstance) return;
|
|
||||||
|
|
||||||
setShowBroadcastConfirm(false);
|
|
||||||
setIsLoading(true);
|
|
||||||
setStatus('Broadcasting transaction...');
|
|
||||||
|
|
||||||
try {
|
|
||||||
await invitationInstance.broadcast();
|
|
||||||
showInfo(
|
|
||||||
`Transaction Broadcast Successful!\n\n` +
|
|
||||||
`The transaction has been submitted to the network.`
|
|
||||||
);
|
|
||||||
navigate('wallet');
|
|
||||||
} catch (error) {
|
|
||||||
showError(`Failed to broadcast: ${error instanceof Error ? error.message : String(error)}`);
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
setStatus('Ready');
|
|
||||||
}
|
|
||||||
}, [invitationInstance, showInfo, showError, navigate, setStatus]);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Sign transaction.
|
|
||||||
*/
|
|
||||||
const signTransaction = useCallback(async () => {
|
|
||||||
if (!invitationInstance) return;
|
|
||||||
|
|
||||||
setIsLoading(true);
|
|
||||||
setStatus('Signing transaction...');
|
|
||||||
|
|
||||||
try {
|
|
||||||
await invitationInstance.sign();
|
|
||||||
showInfo('Transaction signed successfully!');
|
|
||||||
} catch (error) {
|
|
||||||
showError(`Failed to sign: ${error instanceof Error ? error.message : String(error)}`);
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
setStatus('Ready');
|
|
||||||
}
|
|
||||||
}, [invitationInstance, showInfo, showError, setStatus]);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Copy transaction hex.
|
|
||||||
*/
|
|
||||||
const copyTransactionHex = useCallback(async () => {
|
|
||||||
if (!invitation) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
await copyToClipboard(invitation.invitationIdentifier);
|
|
||||||
showInfo(
|
|
||||||
`Copied Invitation ID!\n\n` +
|
|
||||||
`ID: ${invitation.invitationIdentifier}\n` +
|
|
||||||
`Commits: ${invitation.commits.length}`
|
|
||||||
);
|
|
||||||
} catch (error) {
|
|
||||||
showError(`Failed to copy: ${error instanceof Error ? error.message : String(error)}`);
|
|
||||||
}
|
|
||||||
}, [invitation, showInfo, showError]);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handle action selection.
|
|
||||||
*/
|
|
||||||
const handleAction = useCallback((action: string) => {
|
|
||||||
switch (action) {
|
|
||||||
case 'broadcast':
|
|
||||||
setShowBroadcastConfirm(true);
|
|
||||||
break;
|
|
||||||
case 'sign':
|
|
||||||
signTransaction();
|
|
||||||
break;
|
|
||||||
case 'copy':
|
|
||||||
copyTransactionHex();
|
|
||||||
break;
|
|
||||||
case 'back':
|
|
||||||
goBack();
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}, [signTransaction, copyTransactionHex, goBack]);
|
|
||||||
|
|
||||||
// Handle keyboard navigation — automatically blocked when the confirm dialog is open.
|
|
||||||
useBlockableInput((input, key) => {
|
|
||||||
// Tab to switch panels
|
|
||||||
if (key.tab) {
|
|
||||||
setFocusedPanel(prev => {
|
|
||||||
if (prev === 'inputs') return 'outputs';
|
|
||||||
if (prev === 'outputs') return 'actions';
|
|
||||||
return 'inputs';
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Up/Down in actions
|
|
||||||
if (focusedPanel === 'actions') {
|
|
||||||
if (key.upArrow || input === 'k') {
|
|
||||||
setSelectedActionIndex(prev => Math.max(0, prev - 1));
|
|
||||||
} else if (key.downArrow || input === 'j') {
|
|
||||||
setSelectedActionIndex(prev => Math.min(actionItems.length - 1, prev + 1));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Enter to select
|
|
||||||
if (key.return && focusedPanel === 'actions') {
|
|
||||||
const action = actionItems[selectedActionIndex];
|
|
||||||
if (action) {
|
|
||||||
handleAction(action.value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Extract transaction data from invitation
|
|
||||||
const commits = invitation?.commits ?? [];
|
|
||||||
const inputs: Array<{ txid: string; index: number; value?: bigint; inputIdentifier?: string }> = [];
|
|
||||||
const outputs: Array<{ value?: bigint; lockingBytecode: string; outputIdentifier?: string; isTemplate: boolean }> = [];
|
|
||||||
const variables: Array<{ id: string; value: string }> = [];
|
|
||||||
|
|
||||||
// Parse commits for inputs, outputs, and variables
|
|
||||||
for (const commit of commits) {
|
|
||||||
// Extract variables (to help understand output values)
|
|
||||||
if (commit.data?.variables) {
|
|
||||||
for (const variable of commit.data.variables) {
|
|
||||||
variables.push({
|
|
||||||
id: variable.variableIdentifier,
|
|
||||||
value: String(variable.value),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (commit.data?.inputs) {
|
|
||||||
for (const input of commit.data.inputs) {
|
|
||||||
// Convert Uint8Array to hex string if needed
|
|
||||||
const txidHex = input.outpointTransactionHash
|
|
||||||
? typeof input.outpointTransactionHash === 'string'
|
|
||||||
? input.outpointTransactionHash
|
|
||||||
: Buffer.from(input.outpointTransactionHash).toString('hex')
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
// Skip inputs that are just placeholders (no txid)
|
|
||||||
if (txidHex) {
|
|
||||||
inputs.push({
|
|
||||||
txid: txidHex,
|
|
||||||
index: input.outpointIndex ?? 0,
|
|
||||||
value: undefined, // Will be looked up from UTXO data
|
|
||||||
inputIdentifier: (input as any).inputIdentifier,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (commit.data?.outputs) {
|
|
||||||
for (const output of commit.data.outputs) {
|
|
||||||
// Convert Uint8Array to hex string if needed
|
|
||||||
const lockingBytecodeHex = output.lockingBytecode
|
|
||||||
? typeof output.lockingBytecode === 'string'
|
|
||||||
? output.lockingBytecode
|
|
||||||
: Buffer.from(output.lockingBytecode).toString('hex')
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
// Check if this is a template-defined output (has outputIdentifier but no direct value)
|
|
||||||
const isTemplateOutput = !!(output as any).outputIdentifier && !output.valueSatoshis;
|
|
||||||
|
|
||||||
outputs.push({
|
|
||||||
value: output.valueSatoshis,
|
|
||||||
lockingBytecode: lockingBytecodeHex ?? '(pending)',
|
|
||||||
outputIdentifier: (output as any).outputIdentifier,
|
|
||||||
isTemplate: isTemplateOutput,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Try to resolve template output values from variables
|
|
||||||
const resolvedOutputs = outputs.map(output => {
|
|
||||||
if (output.isTemplate && output.outputIdentifier) {
|
|
||||||
// Look for a matching variable (e.g., requestSatoshisOutput -> requestedSatoshis)
|
|
||||||
const satoshiVar = variables.find(v =>
|
|
||||||
v.id.toLowerCase().includes('satoshi') ||
|
|
||||||
v.id.toLowerCase().includes('amount')
|
|
||||||
);
|
|
||||||
if (satoshiVar) {
|
|
||||||
return {
|
|
||||||
...output,
|
|
||||||
value: BigInt(satoshiVar.value),
|
|
||||||
resolvedFrom: satoshiVar.id,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return output;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Calculate totals (only for resolved values)
|
|
||||||
const totalOut = resolvedOutputs.reduce((sum, o) => sum + (o.value ?? 0n), 0n);
|
|
||||||
// Note: We can't calculate totalIn without UTXO lookup, so fee is unknown
|
|
||||||
const hasUnresolvedOutputs = resolvedOutputs.some(o => o.value === undefined);
|
|
||||||
const hasUnresolvedInputs = inputs.length > 0; // Input values are always unknown from commit data
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box flexDirection='column' flexGrow={1}>
|
|
||||||
{/* Header */}
|
|
||||||
<Box borderStyle='single' borderColor={colors.secondary} paddingX={1}>
|
|
||||||
<Text color={colors.primary} bold>{logoSmall} - Transaction Review</Text>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{/* Summary box */}
|
|
||||||
<Box
|
|
||||||
borderStyle='single'
|
|
||||||
borderColor={colors.primary}
|
|
||||||
marginTop={1}
|
|
||||||
marginX={1}
|
|
||||||
paddingX={1}
|
|
||||||
flexDirection='column'
|
|
||||||
>
|
|
||||||
<Text color={colors.primary} bold> Transaction Summary </Text>
|
|
||||||
{invitation ? (
|
|
||||||
<Box flexDirection='column' marginTop={1}>
|
|
||||||
<Text color={colors.text}>Inputs: {inputs.length} | Outputs: {resolvedOutputs.length} | Commits: {commits.length}</Text>
|
|
||||||
{hasUnresolvedInputs && (
|
|
||||||
<Text color={colors.textMuted}>Total In: (requires UTXO lookup)</Text>
|
|
||||||
)}
|
|
||||||
<Text color={colors.warning}>Total Out: {formatSatoshis(totalOut)}{hasUnresolvedOutputs ? ' (partial)' : ''}</Text>
|
|
||||||
{hasUnresolvedInputs ? (
|
|
||||||
<Text color={colors.textMuted}>Fee: (calculated at broadcast)</Text>
|
|
||||||
) : (
|
|
||||||
<Text color={colors.info}>Fee: {formatSatoshis(0n)}</Text>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
) : (
|
|
||||||
<Text color={colors.textMuted}>Loading...</Text>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{/* Inputs and Outputs */}
|
|
||||||
<Box flexDirection='row' marginTop={1} marginX={1} flexGrow={1}>
|
|
||||||
{/* Inputs */}
|
|
||||||
<Box
|
|
||||||
borderStyle='single'
|
|
||||||
borderColor={focusedPanel === 'inputs' ? colors.focus : colors.border}
|
|
||||||
width='50%'
|
|
||||||
flexDirection='column'
|
|
||||||
paddingX={1}
|
|
||||||
>
|
|
||||||
<Text color={colors.primary} bold> Inputs </Text>
|
|
||||||
<Box flexDirection='column' marginTop={1}>
|
|
||||||
{inputs.length === 0 ? (
|
|
||||||
<Text color={colors.textMuted}>No inputs</Text>
|
|
||||||
) : (
|
|
||||||
inputs.map((input, index) => (
|
|
||||||
<Box key={`${input.txid}-${input.index}`} flexDirection='column' marginBottom={1}>
|
|
||||||
<Text color={colors.text}>
|
|
||||||
{index + 1}. {formatHex(input.txid, 12)}:{input.index}
|
|
||||||
</Text>
|
|
||||||
{input.value !== undefined && (
|
|
||||||
<Text color={colors.textMuted}> {formatSatoshis(input.value)}</Text>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{/* Outputs */}
|
|
||||||
<Box
|
|
||||||
borderStyle='single'
|
|
||||||
borderColor={focusedPanel === 'outputs' ? colors.focus : colors.border}
|
|
||||||
width='50%'
|
|
||||||
flexDirection='column'
|
|
||||||
paddingX={1}
|
|
||||||
marginLeft={1}
|
|
||||||
>
|
|
||||||
<Text color={colors.primary} bold> Outputs </Text>
|
|
||||||
<Box flexDirection='column' marginTop={1}>
|
|
||||||
{resolvedOutputs.length === 0 ? (
|
|
||||||
<Text color={colors.textMuted}>No outputs</Text>
|
|
||||||
) : (
|
|
||||||
resolvedOutputs.map((output, index) => (
|
|
||||||
<Box key={index} flexDirection='column' marginBottom={1}>
|
|
||||||
<Text color={colors.text}>
|
|
||||||
{index + 1}. {output.value !== undefined ? formatSatoshis(output.value) : '(pending)'}
|
|
||||||
{output.outputIdentifier && (
|
|
||||||
<Text color={colors.info}> [{output.outputIdentifier}]</Text>
|
|
||||||
)}
|
|
||||||
</Text>
|
|
||||||
<Text color={colors.textMuted}> {output.lockingBytecode !== '(pending)' ? formatHex(output.lockingBytecode, 20) : '(pending)'}</Text>
|
|
||||||
{(output as any).resolvedFrom && (
|
|
||||||
<Text color={colors.textMuted} dimColor> (from ${(output as any).resolvedFrom})</Text>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{/* Actions */}
|
|
||||||
<Box
|
|
||||||
borderStyle='single'
|
|
||||||
borderColor={focusedPanel === 'actions' ? colors.focus : colors.border}
|
|
||||||
marginTop={1}
|
|
||||||
marginX={1}
|
|
||||||
paddingX={1}
|
|
||||||
flexDirection='column'
|
|
||||||
>
|
|
||||||
<Text color={colors.primary} bold> Actions </Text>
|
|
||||||
<Box flexDirection='column' marginTop={1}>
|
|
||||||
{actionItems.map((item, index) => (
|
|
||||||
<Text
|
|
||||||
key={item.value}
|
|
||||||
color={index === selectedActionIndex && focusedPanel === 'actions' ? colors.focus : colors.text}
|
|
||||||
bold={index === selectedActionIndex && focusedPanel === 'actions'}
|
|
||||||
>
|
|
||||||
{index === selectedActionIndex && focusedPanel === 'actions' ? '▸ ' : ' '}
|
|
||||||
{item.label}
|
|
||||||
</Text>
|
|
||||||
))}
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{/* Help text */}
|
|
||||||
<Box marginTop={1} marginX={1}>
|
|
||||||
<Text color={colors.textMuted} dimColor>
|
|
||||||
Tab: Switch focus • Enter: Select • Esc: Back
|
|
||||||
</Text>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{/* Broadcast confirmation dialog */}
|
|
||||||
{showBroadcastConfirm && (
|
|
||||||
<Box
|
|
||||||
position='absolute'
|
|
||||||
flexDirection='column'
|
|
||||||
alignItems='center'
|
|
||||||
justifyContent='center'
|
|
||||||
width='100%'
|
|
||||||
height='100%'
|
|
||||||
>
|
|
||||||
<ConfirmDialog
|
|
||||||
title='Broadcast Transaction'
|
|
||||||
message='Are you sure you want to broadcast this transaction? This action cannot be undone.'
|
|
||||||
onConfirm={broadcastTransaction}
|
|
||||||
onCancel={() => setShowBroadcastConfirm(false)}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -11,8 +11,10 @@ import React, { useState, useEffect, useCallback, useMemo } from 'react';
|
|||||||
import { Box, Text } from 'ink';
|
import { Box, Text } from 'ink';
|
||||||
import { ScrollableList, type ListItemData } from '../components/List.js';
|
import { ScrollableList, type ListItemData } from '../components/List.js';
|
||||||
import { QRCode } from '../components/QRCode.js';
|
import { QRCode } from '../components/QRCode.js';
|
||||||
|
import { CurrencySelectionDialog } from '../components/CurrencySelectionDialog.js';
|
||||||
import { useNavigation } from '../hooks/useNavigation.js';
|
import { useNavigation } from '../hooks/useNavigation.js';
|
||||||
import { useAppContext, useStatus } from '../hooks/useAppContext.js';
|
import { useAppContext, useStatus } from '../hooks/useAppContext.js';
|
||||||
|
import { useSatoshisConversion } from '../hooks/useSatoshisConversion.js';
|
||||||
import { useInputLayer, useLayeredInput, useBlockableInput, useIsInputCaptured } from '../hooks/useInputLayer.js';
|
import { useInputLayer, useLayeredInput, useBlockableInput, useIsInputCaptured } from '../hooks/useInputLayer.js';
|
||||||
import { colors, formatSatoshis, formatHex, logoSmall } from '../theme.js';
|
import { colors, formatSatoshis, formatHex, logoSmall } from '../theme.js';
|
||||||
import type { HistoryItem } from '../../services/history.js';
|
import type { HistoryItem } from '../../services/history.js';
|
||||||
@@ -28,6 +30,7 @@ import {
|
|||||||
type HistoryDisplayRow,
|
type HistoryDisplayRow,
|
||||||
type HistoryColorName,
|
type HistoryColorName,
|
||||||
} from '../../utils/history-utils.js';
|
} from '../../utils/history-utils.js';
|
||||||
|
import { copyToClipboard } from '../utils/clipboard.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Map history color name to theme color.
|
* Map history color name to theme color.
|
||||||
@@ -58,6 +61,7 @@ const menuItems: ListItemData<string>[] = [
|
|||||||
{ key: 'import', label: 'Import Invitation', value: 'import' },
|
{ key: 'import', label: 'Import Invitation', value: 'import' },
|
||||||
{ key: 'invitations', label: 'View Invitations', value: 'invitations' },
|
{ key: 'invitations', label: 'View Invitations', value: 'invitations' },
|
||||||
{ key: 'new-address', label: 'Generate New Address', value: 'new-address' },
|
{ key: 'new-address', label: 'Generate New Address', value: 'new-address' },
|
||||||
|
{ key: 'set-currency', label: 'Set Fiat Currency', value: 'set-currency' },
|
||||||
{ key: 'unreserve-all', label: 'Unreserve All Resources', value: 'unreserve-all' },
|
{ key: 'unreserve-all', label: 'Unreserve All Resources', value: 'unreserve-all' },
|
||||||
{ key: 'refresh', label: 'Refresh', value: 'refresh' },
|
{ key: 'refresh', label: 'Refresh', value: 'refresh' },
|
||||||
];
|
];
|
||||||
@@ -74,7 +78,10 @@ type HistoryListItem = ListItemData<HistoryDisplayRow>;
|
|||||||
function QRDialogOverlay({ address, onClose }: { address: string; onClose: () => void }): React.ReactElement {
|
function QRDialogOverlay({ address, onClose }: { address: string; onClose: () => void }): React.ReactElement {
|
||||||
useInputLayer('qr-dialog');
|
useInputLayer('qr-dialog');
|
||||||
|
|
||||||
useLayeredInput('qr-dialog', (_input, key) => {
|
useLayeredInput('qr-dialog', (input, key) => {
|
||||||
|
if (input === 'c' || input === 'C') {
|
||||||
|
copyToClipboard(address);
|
||||||
|
}
|
||||||
if (key.escape || key.return) {
|
if (key.escape || key.return) {
|
||||||
onClose();
|
onClose();
|
||||||
}
|
}
|
||||||
@@ -92,10 +99,13 @@ function QRDialogOverlay({ address, onClose }: { address: string; onClose: () =>
|
|||||||
dialog
|
dialog
|
||||||
dialogTitle="Receive Address"
|
dialogTitle="Receive Address"
|
||||||
showValue
|
showValue
|
||||||
|
subtitle={
|
||||||
|
<Box flexDirection="column" justifyContent="center" marginTop={1}>
|
||||||
|
<Text color={colors.textMuted}>Press C to copy to clipboard</Text>
|
||||||
|
<Text color={colors.textMuted}>Press Enter or Esc to close</Text>
|
||||||
|
</Box>
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
<Box justifyContent="center" marginTop={1}>
|
|
||||||
<Text color={colors.textMuted}>Press Enter or Esc to close</Text>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -108,6 +118,12 @@ export function WalletStateScreen(): React.ReactElement {
|
|||||||
const { navigate } = useNavigation();
|
const { navigate } = useNavigation();
|
||||||
const { appService, showError, showInfo } = useAppContext();
|
const { appService, showError, showInfo } = useAppContext();
|
||||||
const { setStatus } = useStatus();
|
const { setStatus } = useStatus();
|
||||||
|
const {
|
||||||
|
currencyCode,
|
||||||
|
fiatPerBchRate,
|
||||||
|
formattedFiatPerBchRate,
|
||||||
|
formatSatoshisToFiat,
|
||||||
|
} = useSatoshisConversion();
|
||||||
|
|
||||||
// State
|
// State
|
||||||
const [balance, setBalance] = useState<{ totalSatoshis: bigint; utxoCount: number } | null>(null);
|
const [balance, setBalance] = useState<{ totalSatoshis: bigint; utxoCount: number } | null>(null);
|
||||||
@@ -119,6 +135,14 @@ export function WalletStateScreen(): React.ReactElement {
|
|||||||
|
|
||||||
/** Cash address to display in the QR code dialog (null when dialog is hidden). */
|
/** Cash address to display in the QR code dialog (null when dialog is hidden). */
|
||||||
const [qrAddress, setQrAddress] = useState<string | null>(null);
|
const [qrAddress, setQrAddress] = useState<string | null>(null);
|
||||||
|
/** Whether the fiat currency selection dialog is open. */
|
||||||
|
const [isCurrencyDialogOpen, setCurrencyDialogOpen] = useState(false);
|
||||||
|
/** Loading state for rates pair discovery. */
|
||||||
|
const [isLoadingCurrencyPairs, setLoadingCurrencyPairs] = useState(false);
|
||||||
|
/** Optional error message shown in the currency dialog. */
|
||||||
|
const [currencyPairsError, setCurrencyPairsError] = useState<string | null>(null);
|
||||||
|
/** Available fiat currencies derived from rates pairs in X/BCH format. */
|
||||||
|
const [availableCurrencies, setAvailableCurrencies] = useState<string[]>([]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Refreshes wallet state.
|
* Refreshes wallet state.
|
||||||
@@ -246,6 +270,89 @@ export function WalletStateScreen(): React.ReactElement {
|
|||||||
}
|
}
|
||||||
}, [appService, setStatus, showError, showInfo, refresh]);
|
}, [appService, setStatus, showError, showInfo, refresh]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loads all available rates pairs, then extracts fiat numerator symbols from
|
||||||
|
* pairs shaped like X/BCH.
|
||||||
|
*
|
||||||
|
* We retry briefly because rates startup is asynchronous and metadata can take
|
||||||
|
* a moment to hydrate right after wallet initialization.
|
||||||
|
*/
|
||||||
|
const loadAvailableCurrencies = useCallback(async (): Promise<void> => {
|
||||||
|
if (!appService) {
|
||||||
|
setCurrencyPairsError("AppService not initialized");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoadingCurrencyPairs(true);
|
||||||
|
setCurrencyPairsError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
let pairs = new Set<string>();
|
||||||
|
|
||||||
|
// Retry a few times so we can catch late metadata initialization.
|
||||||
|
for (let attempt = 0; attempt < 4; attempt += 1) {
|
||||||
|
pairs = await appService.rates.listPairs();
|
||||||
|
if (pairs.size > 0) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||||
|
}
|
||||||
|
|
||||||
|
const currencies = Array.from(pairs)
|
||||||
|
.map((pair) => pair.toUpperCase())
|
||||||
|
.filter((pair) => pair.endsWith("/BCH"))
|
||||||
|
.map((pair) => pair.split("/")[0] ?? "")
|
||||||
|
.filter((currency) => currency.length > 0)
|
||||||
|
.sort((a, b) => a.localeCompare(b));
|
||||||
|
|
||||||
|
const uniqueCurrencies = Array.from(new Set(currencies));
|
||||||
|
setAvailableCurrencies(uniqueCurrencies);
|
||||||
|
|
||||||
|
if (uniqueCurrencies.length === 0) {
|
||||||
|
setCurrencyPairsError(
|
||||||
|
"No X/BCH rates are currently available. Try again in a moment.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
setCurrencyPairsError(
|
||||||
|
`Failed to load currency pairs: ${error instanceof Error ? error.message : String(error)}`,
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setLoadingCurrencyPairs(false);
|
||||||
|
}
|
||||||
|
}, [appService]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Opens the fiat currency dialog and triggers pair discovery.
|
||||||
|
*/
|
||||||
|
const openCurrencyDialog = useCallback(() => {
|
||||||
|
setCurrencyDialogOpen(true);
|
||||||
|
void loadAvailableCurrencies();
|
||||||
|
}, [loadAvailableCurrencies]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Applies the selected fiat currency to persisted settings.
|
||||||
|
*/
|
||||||
|
const applyCurrencySelection = useCallback(
|
||||||
|
(currencyCode: string) => {
|
||||||
|
if (!appService) {
|
||||||
|
showError("AppService not initialized");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
appService.settings.setCurrency(currencyCode);
|
||||||
|
setStatus(`Fiat currency updated to ${currencyCode}`);
|
||||||
|
setCurrencyDialogOpen(false);
|
||||||
|
} catch (error) {
|
||||||
|
showError(
|
||||||
|
`Failed to update currency: ${error instanceof Error ? error.message : String(error)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[appService, setStatus, showError],
|
||||||
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handles menu action.
|
* Handles menu action.
|
||||||
*/
|
*/
|
||||||
@@ -263,6 +370,9 @@ export function WalletStateScreen(): React.ReactElement {
|
|||||||
case 'new-address':
|
case 'new-address':
|
||||||
generateNewAddress();
|
generateNewAddress();
|
||||||
break;
|
break;
|
||||||
|
case 'set-currency':
|
||||||
|
openCurrencyDialog();
|
||||||
|
break;
|
||||||
case 'unreserve-all':
|
case 'unreserve-all':
|
||||||
unreserveAll();
|
unreserveAll();
|
||||||
break;
|
break;
|
||||||
@@ -270,7 +380,7 @@ export function WalletStateScreen(): React.ReactElement {
|
|||||||
refresh();
|
refresh();
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}, [navigate, generateNewAddress, unreserveAll, refresh]);
|
}, [navigate, generateNewAddress, openCurrencyDialog, unreserveAll, refresh]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handle menu item activation.
|
* Handle menu item activation.
|
||||||
@@ -297,6 +407,26 @@ export function WalletStateScreen(): React.ReactElement {
|
|||||||
});
|
});
|
||||||
}, [history]);
|
}, [history]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fiat values are memoized so we only recompute when balance or rate changes.
|
||||||
|
*/
|
||||||
|
const formattedUsdPerBchRate = useMemo(() => {
|
||||||
|
return formattedFiatPerBchRate;
|
||||||
|
}, [formattedFiatPerBchRate]);
|
||||||
|
|
||||||
|
const formattedUsdBalance = useMemo(() => {
|
||||||
|
if (!balance || fiatPerBchRate === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return formatSatoshisToFiat(balance.totalSatoshis);
|
||||||
|
}, [balance, fiatPerBchRate, formatSatoshisToFiat]);
|
||||||
|
|
||||||
|
const getFiatSuffix = useCallback((satoshis: bigint): string => {
|
||||||
|
const fiatValue = formatSatoshisToFiat(satoshis);
|
||||||
|
return fiatValue ? ` (~${fiatValue})` : '';
|
||||||
|
}, [formatSatoshisToFiat]);
|
||||||
|
|
||||||
// Screen input — automatically blocked when any dialog/overlay is capturing.
|
// Screen input — automatically blocked when any dialog/overlay is capturing.
|
||||||
const isCaptured = useIsInputCaptured();
|
const isCaptured = useIsInputCaptured();
|
||||||
|
|
||||||
@@ -323,24 +453,32 @@ export function WalletStateScreen(): React.ReactElement {
|
|||||||
const indicator = isFocused ? '▸ ' : ' ';
|
const indicator = isFocused ? '▸ ' : ' ';
|
||||||
const groupingPrefix = row.isNested ? ' -> ' : '';
|
const groupingPrefix = row.isNested ? ' -> ' : '';
|
||||||
|
|
||||||
if (row.type === 'invitation') {
|
if (row.type === 'history_item') {
|
||||||
|
const sats = row.valueSatoshis ?? 0n;
|
||||||
|
const fiatSuffix = getFiatSuffix(sats);
|
||||||
return (
|
return (
|
||||||
<Box flexDirection="row" justifyContent="space-between">
|
<Box flexDirection="row" justifyContent="space-between">
|
||||||
<Text color={itemColor}>
|
<Box flexDirection="row">
|
||||||
{indicator}[Invitation] {row.label}
|
<Text color={itemColor}>
|
||||||
</Text>
|
{indicator}{formatSatoshis(sats)}{fiatSuffix}
|
||||||
|
</Text>
|
||||||
|
<Text color={colors.textMuted}> {row.label}</Text>
|
||||||
|
</Box>
|
||||||
{dateStr && <Text color={colors.textMuted}>{dateStr}</Text>}
|
{dateStr && <Text color={colors.textMuted}>{dateStr}</Text>}
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (row.type === 'invitation_input') {
|
if (row.type === 'history_input') {
|
||||||
|
const sats = row.valueSatoshis ?? 0n;
|
||||||
return (
|
return (
|
||||||
<Box flexDirection="row" justifyContent="space-between">
|
<Box flexDirection="row" justifyContent="space-between">
|
||||||
<Box>
|
<Box>
|
||||||
<Text color={itemColor}>
|
<Text color={itemColor}>
|
||||||
{indicator}{groupingPrefix}[Input] {row.label}
|
{indicator}{groupingPrefix}[Input] {formatSatoshis(sats)}
|
||||||
|
{getFiatSuffix(sats)}
|
||||||
</Text>
|
</Text>
|
||||||
|
<Text color={colors.textMuted}> {row.label}</Text>
|
||||||
{row.description && <Text color={colors.textMuted}> {row.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>}
|
||||||
@@ -348,14 +486,17 @@ export function WalletStateScreen(): React.ReactElement {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (row.type === 'invitation_output') {
|
if (row.type === 'history_output') {
|
||||||
const sats = row.utxo?.valueSatoshis ?? 0n;
|
const sats = row.valueSatoshis ?? 0n;
|
||||||
|
const reservedTag = row.reserved ? ' [Reserved]' : '';
|
||||||
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}{groupingPrefix}[Output] {formatSatoshis(sats)}
|
{indicator}{groupingPrefix}[Output] {formatSatoshis(sats)}
|
||||||
|
{getFiatSuffix(sats)}
|
||||||
</Text>
|
</Text>
|
||||||
|
<Text color={colors.textMuted}> {row.label}{reservedTag}</Text>
|
||||||
{row.description && <Text color={colors.textMuted}> {row.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>}
|
||||||
@@ -363,20 +504,6 @@ export function WalletStateScreen(): React.ReactElement {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
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>
|
|
||||||
{dateStr && <Text color={colors.textMuted}>{dateStr}</Text>}
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback for other types
|
// Fallback for other types
|
||||||
return (
|
return (
|
||||||
<Box flexDirection="row" justifyContent="space-between">
|
<Box flexDirection="row" justifyContent="space-between">
|
||||||
@@ -386,7 +513,7 @@ export function WalletStateScreen(): React.ReactElement {
|
|||||||
{dateStr && <Text color={colors.textMuted}>{dateStr}</Text>}
|
{dateStr && <Text color={colors.textMuted}>{dateStr}</Text>}
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
}, []);
|
}, [getFiatSuffix]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box flexDirection="column" flexGrow={1}>
|
<Box flexDirection="column" flexGrow={1}>
|
||||||
@@ -418,6 +545,20 @@ export function WalletStateScreen(): React.ReactElement {
|
|||||||
<Text color={colors.success} bold>
|
<Text color={colors.success} bold>
|
||||||
{formatSatoshis(balance.totalSatoshis)}
|
{formatSatoshis(balance.totalSatoshis)}
|
||||||
</Text>
|
</Text>
|
||||||
|
{formattedUsdBalance ? (
|
||||||
|
<Text color={colors.info}>
|
||||||
|
Approx. Fiat ({currencyCode}): {formattedUsdBalance}
|
||||||
|
</Text>
|
||||||
|
) : (
|
||||||
|
<Text color={colors.textMuted}>
|
||||||
|
Approx. Fiat ({currencyCode}): Waiting for BCH/{currencyCode} rate...
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
{formattedUsdPerBchRate && (
|
||||||
|
<Text color={colors.textMuted}>
|
||||||
|
1 BCH = {formattedUsdPerBchRate}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
<Text color={colors.textMuted}>
|
<Text color={colors.textMuted}>
|
||||||
UTXOs: {balance.utxoCount}
|
UTXOs: {balance.utxoCount}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -465,7 +606,7 @@ export function WalletStateScreen(): React.ReactElement {
|
|||||||
height={14}
|
height={14}
|
||||||
overflow="hidden"
|
overflow="hidden"
|
||||||
>
|
>
|
||||||
<Text color={colors.primary} bold> Wallet History {history.length > 0 ? `(${selectedHistoryIndex + 1}/${history.length})` : ''}</Text>
|
<Text color={colors.primary} bold> Wallet History {historyListItems.length > 0 ? `(${selectedHistoryIndex + 1}/${historyListItems.length})` : ''}</Text>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<Box marginTop={1}>
|
<Box marginTop={1}>
|
||||||
<Text color={colors.textMuted}>Loading...</Text>
|
<Text color={colors.textMuted}>Loading...</Text>
|
||||||
@@ -498,6 +639,27 @@ export function WalletStateScreen(): React.ReactElement {
|
|||||||
onClose={() => setQrAddress(null)}
|
onClose={() => setQrAddress(null)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Fiat currency selection dialog overlay */}
|
||||||
|
{isCurrencyDialogOpen && (
|
||||||
|
<Box
|
||||||
|
position="absolute"
|
||||||
|
flexDirection="column"
|
||||||
|
alignItems="center"
|
||||||
|
justifyContent="center"
|
||||||
|
width="100%"
|
||||||
|
height="100%"
|
||||||
|
>
|
||||||
|
<CurrencySelectionDialog
|
||||||
|
currentCurrency={currencyCode}
|
||||||
|
currencies={availableCurrencies}
|
||||||
|
isLoading={isLoadingCurrencyPairs}
|
||||||
|
errorMessage={currencyPairsError}
|
||||||
|
onSelectCurrency={applyCurrencySelection}
|
||||||
|
onCancel={() => setCurrencyDialogOpen(false)}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ export { DataWizardFlow } from "./DataWizardFlow.js";
|
|||||||
*/
|
*/
|
||||||
export function createWizardFlow(action: XOTemplateAction): WizardFlow {
|
export function createWizardFlow(action: XOTemplateAction): WizardFlow {
|
||||||
if (action.data?.length && !action.transaction) {
|
if (action.data?.length && !action.transaction) {
|
||||||
return new DataWizardFlow(action.data);
|
return new DataWizardFlow([action.data]);
|
||||||
}
|
}
|
||||||
return new TransactionWizardFlow();
|
return new TransactionWizardFlow();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Box, Text } from 'ink';
|
import { Box, Text } from 'ink';
|
||||||
import { colors, formatSatoshis, formatHex } from '../../../theme.js';
|
import { colors, formatSatoshis, formatHex } from '../../../theme.js';
|
||||||
|
import { useSatoshisConversion } from '../../../hooks/useSatoshisConversion.js';
|
||||||
import type { SelectableUTXO, FocusArea } from '../types.js';
|
import type { SelectableUTXO, FocusArea } from '../types.js';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -22,6 +23,13 @@ export function InputsStep({
|
|||||||
changeAmount,
|
changeAmount,
|
||||||
focusArea,
|
focusArea,
|
||||||
}: Props): React.ReactElement {
|
}: Props): React.ReactElement {
|
||||||
|
const { formatSatoshisToFiat } = useSatoshisConversion();
|
||||||
|
|
||||||
|
const getFiatSuffix = (satoshis: bigint): string => {
|
||||||
|
const fiatValue = formatSatoshisToFiat(satoshis);
|
||||||
|
return fiatValue ? ` (~${fiatValue})` : '';
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box flexDirection='column'>
|
<Box flexDirection='column'>
|
||||||
<Text color={colors.text} bold>
|
<Text color={colors.text} bold>
|
||||||
@@ -32,6 +40,7 @@ export function InputsStep({
|
|||||||
<Text color={colors.textMuted}>
|
<Text color={colors.textMuted}>
|
||||||
Required: {formatSatoshis(requiredAmount)} +{' '}
|
Required: {formatSatoshis(requiredAmount)} +{' '}
|
||||||
{formatSatoshis(fee)} fee
|
{formatSatoshis(fee)} fee
|
||||||
|
{getFiatSuffix(requiredAmount + fee)}
|
||||||
</Text>
|
</Text>
|
||||||
<Text
|
<Text
|
||||||
color={
|
color={
|
||||||
@@ -41,10 +50,12 @@ export function InputsStep({
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
Selected: {formatSatoshis(selectedAmount)}
|
Selected: {formatSatoshis(selectedAmount)}
|
||||||
|
{getFiatSuffix(selectedAmount)}
|
||||||
</Text>
|
</Text>
|
||||||
{selectedAmount > requiredAmount + fee && (
|
{selectedAmount > requiredAmount + fee && (
|
||||||
<Text color={colors.info}>
|
<Text color={colors.info}>
|
||||||
Change: {formatSatoshis(changeAmount)}
|
Change: {formatSatoshis(changeAmount)}
|
||||||
|
{getFiatSuffix(changeAmount)}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
@@ -65,6 +76,7 @@ export function InputsStep({
|
|||||||
return (
|
return (
|
||||||
<Box
|
<Box
|
||||||
key={`${utxo.outpointTransactionHash}:${utxo.outpointIndex}`}
|
key={`${utxo.outpointTransactionHash}:${utxo.outpointIndex}`}
|
||||||
|
flexDirection='column'
|
||||||
>
|
>
|
||||||
<Text
|
<Text
|
||||||
color={isCursor ? colors.focus : colors.text}
|
color={isCursor ? colors.focus : colors.text}
|
||||||
@@ -75,6 +87,15 @@ export function InputsStep({
|
|||||||
{formatHex(utxo.outpointTransactionHash, 12)}:
|
{formatHex(utxo.outpointTransactionHash, 12)}:
|
||||||
{utxo.outpointIndex}
|
{utxo.outpointIndex}
|
||||||
</Text>
|
</Text>
|
||||||
|
{(() => {
|
||||||
|
const fiatValue = formatSatoshisToFiat(utxo.valueSatoshis);
|
||||||
|
if (!fiatValue) return null;
|
||||||
|
return (
|
||||||
|
<Text color={colors.textMuted}>
|
||||||
|
{' '}≈ {fiatValue}
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Box, Text } from 'ink';
|
import { Box, Text } from 'ink';
|
||||||
import { colors, formatSatoshis } from '../../../theme.js';
|
import { colors, formatSatoshis } from '../../../theme.js';
|
||||||
|
import { useSatoshisConversion } from '../../../hooks/useSatoshisConversion.js';
|
||||||
import type { VariableInput, SelectableUTXO } from '../types.js';
|
import type { VariableInput, SelectableUTXO } from '../types.js';
|
||||||
import type { XOTemplate } from '@xo-cash/types';
|
import type { XOTemplate } from '@xo-cash/types';
|
||||||
|
|
||||||
@@ -22,6 +23,32 @@ export function ReviewStep({
|
|||||||
changeAmount,
|
changeAmount,
|
||||||
}: ReviewStepProps): React.ReactElement {
|
}: ReviewStepProps): React.ReactElement {
|
||||||
const selectedUtxos = availableUtxos.filter((u) => u.selected);
|
const selectedUtxos = availableUtxos.filter((u) => u.selected);
|
||||||
|
const { formatSatoshisToFiat } = useSatoshisConversion();
|
||||||
|
|
||||||
|
const getFiatSuffix = (satoshis: bigint): string => {
|
||||||
|
const fiatValue = formatSatoshisToFiat(satoshis);
|
||||||
|
return fiatValue ? ` (~${fiatValue})` : '';
|
||||||
|
};
|
||||||
|
|
||||||
|
const getVariableFiatSuffix = (variable: VariableInput): string => {
|
||||||
|
if (variable.type !== 'integer') {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (variable.hint?.toLowerCase().includes('satoshi') !== true) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!/^[-]?\d+$/.test(variable.value.trim())) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return getFiatSuffix(BigInt(variable.value));
|
||||||
|
} catch {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box flexDirection='column'>
|
<Box flexDirection='column'>
|
||||||
@@ -44,6 +71,7 @@ export function ReviewStep({
|
|||||||
<Text key={v.id} color={colors.textMuted}>
|
<Text key={v.id} color={colors.textMuted}>
|
||||||
{' '}
|
{' '}
|
||||||
{v.name}: {v.value || '(empty)'}
|
{v.name}: {v.value || '(empty)'}
|
||||||
|
{v.value ? getVariableFiatSuffix(v) : ''}
|
||||||
</Text>
|
</Text>
|
||||||
))}
|
))}
|
||||||
</Box>
|
</Box>
|
||||||
@@ -62,6 +90,7 @@ export function ReviewStep({
|
|||||||
>
|
>
|
||||||
{' '}
|
{' '}
|
||||||
{formatSatoshis(u.valueSatoshis)}
|
{formatSatoshis(u.valueSatoshis)}
|
||||||
|
{getFiatSuffix(u.valueSatoshis)}
|
||||||
</Text>
|
</Text>
|
||||||
))}
|
))}
|
||||||
{selectedUtxos.length > 3 && (
|
{selectedUtxos.length > 3 && (
|
||||||
@@ -78,6 +107,7 @@ export function ReviewStep({
|
|||||||
<Text color={colors.text}>Outputs:</Text>
|
<Text color={colors.text}>Outputs:</Text>
|
||||||
<Text color={colors.textMuted}>
|
<Text color={colors.textMuted}>
|
||||||
{' '}Change: {formatSatoshis(changeAmount)}
|
{' '}Change: {formatSatoshis(changeAmount)}
|
||||||
|
{getFiatSuffix(changeAmount)}
|
||||||
</Text>
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -61,13 +61,16 @@ export function RoleSelectStep({
|
|||||||
{availableRoles.length === 0 ? (
|
{availableRoles.length === 0 ? (
|
||||||
<Text color={colors.textMuted}>No roles available</Text>
|
<Text color={colors.textMuted}>No roles available</Text>
|
||||||
) : (
|
) : (
|
||||||
availableRoles.map((roleId, index) => {
|
availableRoles.map((roleId: string, index: number) => {
|
||||||
const isCursor =
|
const isCursor =
|
||||||
selectedRoleIndex === index && focusArea === 'content';
|
selectedRoleIndex === index && focusArea === 'content';
|
||||||
const roleDef = template.roles?.[roleId];
|
const roleDef = template.roles?.[roleId];
|
||||||
const actionRole = action?.roles?.[roleId];
|
const actionRole = action?.roles?.[roleId];
|
||||||
const requirements = actionRole?.requirements;
|
const requirements = actionRole?.requirements;
|
||||||
|
|
||||||
|
const actionRequirements = action?.requirements;
|
||||||
|
const actionRoleRequirements = actionRole && actionRequirements && actionRequirements.participants?.find((participant) => participant.role === roleId);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box key={roleId} flexDirection="column" marginY={0}>
|
<Box key={roleId} flexDirection="column" marginY={0}>
|
||||||
<Text
|
<Text
|
||||||
@@ -96,10 +99,10 @@ export function RoleSelectStep({
|
|||||||
{' '}
|
{' '}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
{requirements.slots && requirements.slots.min > 0 && (
|
{actionRoleRequirements && actionRoleRequirements.slots && actionRoleRequirements.slots.min > 0 && (
|
||||||
<Text color={colors.textMuted} dimColor>
|
<Text color={colors.textMuted} dimColor>
|
||||||
{requirements.slots.min} input slot
|
{actionRoleRequirements.slots.min} input slot
|
||||||
{requirements.slots.min !== 1 ? 's' : ''}
|
{actionRoleRequirements.slots.min !== 1 ? 's' : ''}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -7,4 +7,3 @@ export { SeedInputScreen } from './SeedInput.js';
|
|||||||
export { WalletStateScreen } from './WalletState.js';
|
export { WalletStateScreen } from './WalletState.js';
|
||||||
export { TemplateListScreen } from './TemplateList.js';
|
export { TemplateListScreen } from './TemplateList.js';
|
||||||
export { InvitationScreen } from './invitations/InvitationScreen.js';
|
export { InvitationScreen } from './invitations/InvitationScreen.js';
|
||||||
export { TransactionScreen } from './Transaction.js';
|
|
||||||
|
|||||||
@@ -17,23 +17,22 @@ import { useNavigation } from '../../hooks/useNavigation.js';
|
|||||||
import { useAppContext, useStatus } from '../../hooks/useAppContext.js';
|
import { useAppContext, useStatus } from '../../hooks/useAppContext.js';
|
||||||
import { useBlockableInput, useIsInputCaptured } from '../../hooks/useInputLayer.js';
|
import { useBlockableInput, useIsInputCaptured } from '../../hooks/useInputLayer.js';
|
||||||
import { useInvitations } from '../../hooks/useInvitations.js';
|
import { useInvitations } from '../../hooks/useInvitations.js';
|
||||||
|
import { useSatoshisConversion } from '../../hooks/useSatoshisConversion.js';
|
||||||
import { colors, logoSmall, formatSatoshis } from '../../theme.js';
|
import { colors, logoSmall, formatSatoshis } from '../../theme.js';
|
||||||
import { copyToClipboard } from '../../utils/clipboard.js';
|
import { copyToClipboard } from '../../utils/clipboard.js';
|
||||||
import type { Invitation } from '../../../services/invitation.js';
|
import type { Invitation } from '../../../services/invitation.js';
|
||||||
import type { XOTemplate } from '@xo-cash/types';
|
import type { XOInvitationCommit, XOInvitationVariableValue, XOTemplate } from '@xo-cash/types';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
getInvitationState,
|
getInvitationState,
|
||||||
getStateColorName,
|
getStateColorName,
|
||||||
getInvitationInputs,
|
|
||||||
getInvitationOutputs,
|
|
||||||
getInvitationVariables,
|
|
||||||
getUserRole,
|
|
||||||
formatInvitationListItem,
|
formatInvitationListItem,
|
||||||
formatInvitationId,
|
formatInvitationId,
|
||||||
} from '../../../utils/invitation-utils.js';
|
} from '../../../utils/invitation-utils.js';
|
||||||
|
import type { ResolvedInvitationVariable } from '../../../utils/resolve-invitation-data.js';
|
||||||
|
|
||||||
import { InvitationImportFlow } from './invitation-import/InvitationImportFlow.js';
|
import { InvitationImportFlow } from './invitation-import/InvitationImportFlow.js';
|
||||||
|
import { compileCashAssemblyString } from '@xo-cash/engine';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Map state color name to theme color.
|
* Map state color name to theme color.
|
||||||
@@ -62,8 +61,9 @@ const actionItems: ListItemData<string>[] = [
|
|||||||
{ key: 'accept', label: 'Accept & Join', value: 'accept' },
|
{ key: 'accept', label: 'Accept & Join', value: 'accept' },
|
||||||
{ key: 'fill', label: 'Fill Requirements', value: 'fill' },
|
{ key: 'fill', label: 'Fill Requirements', value: 'fill' },
|
||||||
{ key: 'sign', label: 'Sign Transaction', value: 'sign' },
|
{ key: 'sign', label: 'Sign Transaction', value: 'sign' },
|
||||||
{ key: 'transaction', label: 'View Transaction', value: 'transaction' },
|
{ key: 'broadcast', label: 'Broadcast Transaction', value: 'broadcast' },
|
||||||
{ key: 'copy', label: 'Copy Invitation ID', value: 'copy' },
|
{ key: 'copy', label: 'Copy Invitation ID', value: 'copy' },
|
||||||
|
{ key: 'delete', label: 'Delete Invitation', value: 'delete' },
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -79,6 +79,29 @@ const invitationListGroups: ListGroup[] = [
|
|||||||
{ id: 'invitations', separator: true },
|
{ id: 'invitations', separator: true },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
type OwnInvitationContext = {
|
||||||
|
entityIdentifier: string | null;
|
||||||
|
roleIdentifier: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
function getRoleIdentifierFromCommits(commits: XOInvitationCommit[]): string | null {
|
||||||
|
for (const commit of commits) {
|
||||||
|
for (const input of commit.data.inputs ?? []) {
|
||||||
|
if (input.roleIdentifier) return input.roleIdentifier;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const output of commit.data.outputs ?? []) {
|
||||||
|
if (output.roleIdentifier) return output.roleIdentifier;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const variable of commit.data.variables ?? []) {
|
||||||
|
if (variable.roleIdentifier) return variable.roleIdentifier;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Invitation Screen Component.
|
* Invitation Screen Component.
|
||||||
*/
|
*/
|
||||||
@@ -88,6 +111,8 @@ export function InvitationScreen(): React.ReactElement {
|
|||||||
const { setStatus } = useStatus();
|
const { setStatus } = useStatus();
|
||||||
|
|
||||||
const invitations = useInvitations();
|
const invitations = useInvitations();
|
||||||
|
const { currencyCode, formattedFiatPerBchRate, formatSatoshisToFiat } =
|
||||||
|
useSatoshisConversion();
|
||||||
|
|
||||||
// ── UI state ─────────────────────────────────────────────────────────────
|
// ── UI state ─────────────────────────────────────────────────────────────
|
||||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||||
@@ -99,10 +124,15 @@ export function InvitationScreen(): React.ReactElement {
|
|||||||
// Two phases: first the ID input dialog, then the multi-step import flow.
|
// Two phases: first the ID input dialog, then the multi-step import flow.
|
||||||
const [showIdDialog, setShowIdDialog] = useState(false);
|
const [showIdDialog, setShowIdDialog] = useState(false);
|
||||||
const [importingId, setImportingId] = useState<string | null>(null);
|
const [importingId, setImportingId] = useState<string | null>(null);
|
||||||
|
const [pendingImportedInvitationId, setPendingImportedInvitationId] = useState<string | null>(null);
|
||||||
|
|
||||||
// ── Template cache ───────────────────────────────────────────────────────
|
// ── Template cache ───────────────────────────────────────────────────────
|
||||||
const [templateCache, setTemplateCache] = useState<Map<string, XOTemplate>>(new Map());
|
const [templateCache, setTemplateCache] = useState<Map<string, XOTemplate>>(new Map());
|
||||||
const [selectedTemplate, setSelectedTemplate] = useState<XOTemplate | null>(null);
|
const [selectedTemplate, setSelectedTemplate] = useState<XOTemplate | null>(null);
|
||||||
|
const [ownInvitationContext, setOwnInvitationContext] = useState<OwnInvitationContext>({
|
||||||
|
entityIdentifier: null,
|
||||||
|
roleIdentifier: null,
|
||||||
|
});
|
||||||
|
|
||||||
// Check if we should open import dialog on mount
|
// Check if we should open import dialog on mount
|
||||||
const initialMode = navData.mode as string | undefined;
|
const initialMode = navData.mode as string | undefined;
|
||||||
@@ -158,7 +188,7 @@ export function InvitationScreen(): React.ReactElement {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return [importItem, ...invitationItems];
|
return [importItem, ...invitationItems];
|
||||||
}, [invitations, templateCache]);
|
}, [invitations.length, templateCache]);
|
||||||
|
|
||||||
const selectedItem = listItems[selectedIndex];
|
const selectedItem = listItems[selectedIndex];
|
||||||
const selectedInvitation = selectedItem?.value ?? null;
|
const selectedInvitation = selectedItem?.value ?? null;
|
||||||
@@ -176,6 +206,43 @@ export function InvitationScreen(): React.ReactElement {
|
|||||||
.then(template => setSelectedTemplate(template ?? null));
|
.then(template => setSelectedTemplate(template ?? null));
|
||||||
}, [selectedInvitation, appService]);
|
}, [selectedInvitation, appService]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load the current engine entity's commits for the selected invitation.
|
||||||
|
*/
|
||||||
|
useEffect(() => {
|
||||||
|
if (!selectedInvitation || !appService) {
|
||||||
|
setOwnInvitationContext({
|
||||||
|
entityIdentifier: null,
|
||||||
|
roleIdentifier: null,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let isCurrent = true;
|
||||||
|
|
||||||
|
appService.engine.findOwnCommits(selectedInvitation.data.invitationIdentifier)
|
||||||
|
.then((ownCommits) => {
|
||||||
|
if (!isCurrent) return;
|
||||||
|
|
||||||
|
setOwnInvitationContext({
|
||||||
|
entityIdentifier: ownCommits[0]?.entityIdentifier ?? null,
|
||||||
|
roleIdentifier: getRoleIdentifierFromCommits(ownCommits),
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (!isCurrent) return;
|
||||||
|
|
||||||
|
setOwnInvitationContext({
|
||||||
|
entityIdentifier: null,
|
||||||
|
roleIdentifier: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
isCurrent = false;
|
||||||
|
};
|
||||||
|
}, [selectedInvitation, appService]);
|
||||||
|
|
||||||
// ── Import flow callbacks ──────────────────────────────────────────────
|
// ── Import flow callbacks ──────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -193,10 +260,30 @@ export function InvitationScreen(): React.ReactElement {
|
|||||||
/**
|
/**
|
||||||
* Import flow closed (completed or cancelled).
|
* Import flow closed (completed or cancelled).
|
||||||
*/
|
*/
|
||||||
const handleImportFlowClose = useCallback(() => {
|
const handleImportFlowClose = useCallback((importedInvitationId?: string) => {
|
||||||
|
if (importedInvitationId) {
|
||||||
|
setPendingImportedInvitationId(importedInvitationId);
|
||||||
|
}
|
||||||
setImportingId(null);
|
setImportingId(null);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Once imported invitation is visible in the list, select and focus it.
|
||||||
|
*/
|
||||||
|
useEffect(() => {
|
||||||
|
if (!pendingImportedInvitationId) return;
|
||||||
|
|
||||||
|
const importedIndex = listItems.findIndex((item) => {
|
||||||
|
return item.value?.data.invitationIdentifier === pendingImportedInvitationId;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (importedIndex >= 0) {
|
||||||
|
setSelectedIndex(importedIndex);
|
||||||
|
setFocusedPanel('list');
|
||||||
|
setPendingImportedInvitationId(null);
|
||||||
|
}
|
||||||
|
}, [pendingImportedInvitationId, listItems]);
|
||||||
|
|
||||||
// ── Action handlers ────────────────────────────────────────────────────
|
// ── Action handlers ────────────────────────────────────────────────────
|
||||||
|
|
||||||
const acceptInvitation = useCallback(async () => {
|
const acceptInvitation = useCallback(async () => {
|
||||||
@@ -244,6 +331,52 @@ export function InvitationScreen(): React.ReactElement {
|
|||||||
}
|
}
|
||||||
}, [selectedInvitation, showInfo, showError, setStatus]);
|
}, [selectedInvitation, showInfo, showError, setStatus]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Broadcast transaction.
|
||||||
|
*/
|
||||||
|
const broadcastTransaction = useCallback(async () => {
|
||||||
|
if (!selectedInvitation) return;
|
||||||
|
|
||||||
|
setIsLoading(true);
|
||||||
|
setStatus('Broadcasting transaction...');
|
||||||
|
|
||||||
|
try {
|
||||||
|
await selectedInvitation.broadcast();
|
||||||
|
showInfo(
|
||||||
|
`Transaction Broadcast Successful!\n\n` +
|
||||||
|
`The transaction has been submitted to the network.`
|
||||||
|
);
|
||||||
|
setStatus('Ready');
|
||||||
|
} catch (error) {
|
||||||
|
showError(`Failed to broadcast: ${error instanceof Error ? error.message : String(error)}`);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
setStatus('Ready');
|
||||||
|
}
|
||||||
|
}, [selectedInvitation, showInfo, showError, setStatus]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete the selected invitation from both our SQLite db and the engine's db
|
||||||
|
* NOTE: This uses methods marked "DANGEROUSLY" internally, and may change in the future.
|
||||||
|
*/
|
||||||
|
const deleteInvitation = useCallback(async () => {
|
||||||
|
if (!selectedInvitation) return;
|
||||||
|
|
||||||
|
setIsLoading(true)
|
||||||
|
setStatus('Removing invitation...')
|
||||||
|
|
||||||
|
try {
|
||||||
|
await selectedInvitation.delete();
|
||||||
|
showInfo('Invitation successfully deleted')
|
||||||
|
setStatus('Ready')
|
||||||
|
} catch (error) {
|
||||||
|
showError(`Failed to delete invitation: ${error instanceof Error ? error.message : String(error)}`)
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false)
|
||||||
|
setStatus('Ready')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
const copyId = useCallback(async () => {
|
const copyId = useCallback(async () => {
|
||||||
if (!selectedInvitation) {
|
if (!selectedInvitation) {
|
||||||
showError('No invitation selected');
|
showError('No invitation selected');
|
||||||
@@ -289,16 +422,11 @@ export function InvitationScreen(): React.ReactElement {
|
|||||||
setStatus('Analyzing invitation...');
|
setStatus('Analyzing invitation...');
|
||||||
|
|
||||||
let requiredAmount = 0n;
|
let requiredAmount = 0n;
|
||||||
const commits = selectedInvitation.data.commits || [];
|
for (const variable of selectedInvitation.resolvedData.variables) {
|
||||||
for (const commit of commits) {
|
if (variable.variableIdentifier.toLowerCase().includes('satoshi')) {
|
||||||
const variables = commit.data?.variables || [];
|
requiredAmount = BigInt(variable.value?.toString() || '0');
|
||||||
for (const variable of variables) {
|
break;
|
||||||
if (variable.variableIdentifier?.toLowerCase().includes('satoshi')) {
|
|
||||||
requiredAmount = BigInt(variable.value?.toString() || '0');
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if (requiredAmount > 0n) break;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const fee = 500n;
|
const fee = 500n;
|
||||||
@@ -327,10 +455,10 @@ export function InvitationScreen(): React.ReactElement {
|
|||||||
const seenLockingBytecodes = new Set<string>();
|
const seenLockingBytecodes = new Set<string>();
|
||||||
|
|
||||||
for (const utxo of utxos) {
|
for (const utxo of utxos) {
|
||||||
const lockingBytecodeHex = utxo.lockingBytecode
|
const lockingBytecodeHex = utxo.scriptHash
|
||||||
? typeof utxo.lockingBytecode === 'string'
|
? typeof utxo.scriptHash === 'string'
|
||||||
? utxo.lockingBytecode
|
? utxo.scriptHash
|
||||||
: Buffer.from(utxo.lockingBytecode).toString('hex')
|
: Buffer.from(utxo.scriptHash).toString('hex')
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
if (lockingBytecodeHex && seenLockingBytecodes.has(lockingBytecodeHex)) continue;
|
if (lockingBytecodeHex && seenLockingBytecodes.has(lockingBytecodeHex)) continue;
|
||||||
@@ -401,13 +529,14 @@ export function InvitationScreen(): React.ReactElement {
|
|||||||
case 'sign':
|
case 'sign':
|
||||||
signInvitation();
|
signInvitation();
|
||||||
break;
|
break;
|
||||||
case 'transaction':
|
case 'broadcast':
|
||||||
if (selectedInvitation) {
|
broadcastTransaction();
|
||||||
navigate('transaction', { invitationId: selectedInvitation.data.invitationIdentifier });
|
break;
|
||||||
}
|
case 'delete':
|
||||||
|
deleteInvitation();
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}, [selectedInvitation, copyId, acceptInvitation, fillRequirements, signInvitation, navigate]);
|
}, [selectedInvitation, copyId, acceptInvitation, fillRequirements, signInvitation, broadcastTransaction, navigate]);
|
||||||
|
|
||||||
const handleListItemActivate = useCallback((item: InvitationListItem, _index: number) => {
|
const handleListItemActivate = useCallback((item: InvitationListItem, _index: number) => {
|
||||||
if (item.key === 'import') {
|
if (item.key === 'import') {
|
||||||
@@ -485,15 +614,68 @@ export function InvitationScreen(): React.ReactElement {
|
|||||||
|
|
||||||
const state = getInvitationState(selectedInvitation);
|
const state = getInvitationState(selectedInvitation);
|
||||||
const action = selectedTemplate?.actions?.[selectedInvitation.data.actionIdentifier];
|
const action = selectedTemplate?.actions?.[selectedInvitation.data.actionIdentifier];
|
||||||
const inputs = getInvitationInputs(selectedInvitation);
|
const { inputs, outputs, variables } = selectedInvitation.resolvedData;
|
||||||
const outputs = getInvitationOutputs(selectedInvitation);
|
const userEntityId = ownInvitationContext.entityIdentifier;
|
||||||
const variables = getInvitationVariables(selectedInvitation);
|
const userRole = ownInvitationContext.roleIdentifier;
|
||||||
|
|
||||||
const userEntityId = selectedInvitation.data.commits?.[0]?.entityIdentifier ?? null;
|
|
||||||
const userRole = getUserRole(selectedInvitation, userEntityId);
|
|
||||||
const roleInfoRaw = userRole && selectedTemplate?.roles?.[userRole];
|
const roleInfoRaw = userRole && selectedTemplate?.roles?.[userRole];
|
||||||
const roleInfo = roleInfoRaw && typeof roleInfoRaw === 'object' ? roleInfoRaw : null;
|
const roleInfo = roleInfoRaw && typeof roleInfoRaw === 'object' ? roleInfoRaw : null;
|
||||||
|
|
||||||
|
const variableValues = variables.reduce((acc, variable) => {
|
||||||
|
acc[variable.variableIdentifier] = variable.value as XOInvitationVariableValue;
|
||||||
|
return acc;
|
||||||
|
}, {} as Record<string, XOInvitationVariableValue>);
|
||||||
|
|
||||||
|
const getFiatSuffix = (satoshis: bigint): string => {
|
||||||
|
const fiatValue = formatSatoshisToFiat(satoshis);
|
||||||
|
return fiatValue ? ` (~${fiatValue})` : '';
|
||||||
|
};
|
||||||
|
|
||||||
|
const parseNumberishToBigInt = (value: unknown): bigint | null => {
|
||||||
|
if (typeof value === 'bigint') {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
const asString = String(value).trim();
|
||||||
|
if (!/^[-]?\d+$/.test(asString)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return BigInt(asString);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const isSatoshisVariable = (variable: ResolvedInvitationVariable): boolean => {
|
||||||
|
const templateHint = variable.hint?.toLowerCase();
|
||||||
|
const templateType = variable.type?.toLowerCase();
|
||||||
|
const identifier = variable.variableIdentifier.toLowerCase();
|
||||||
|
|
||||||
|
if (templateHint?.includes('satoshi')) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
templateType === 'integer' &&
|
||||||
|
(identifier.includes('satoshi') || identifier.includes('amount'))
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const compileResolvedDescription = (description?: string): string | null => {
|
||||||
|
if (!description) return null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
return compileCashAssemblyString({
|
||||||
|
cashAssemblyText: description,
|
||||||
|
variables: variableValues,
|
||||||
|
evaluationDecodeMode: 'bigint',
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return description;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box flexDirection="column">
|
<Box flexDirection="column">
|
||||||
{/* Type & Status */}
|
{/* Type & Status */}
|
||||||
@@ -514,6 +696,11 @@ export function InvitationScreen(): React.ReactElement {
|
|||||||
<Text color={colors.textMuted}>
|
<Text color={colors.textMuted}>
|
||||||
Action: {action?.name ?? selectedInvitation.data.actionIdentifier}
|
Action: {action?.name ?? selectedInvitation.data.actionIdentifier}
|
||||||
</Text>
|
</Text>
|
||||||
|
{formattedFiatPerBchRate && (
|
||||||
|
<Text color={colors.textMuted}>
|
||||||
|
1 BCH = {formattedFiatPerBchRate}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
{action?.description && (
|
{action?.description && (
|
||||||
<Text color={colors.textMuted} dimColor>{action.description}</Text>
|
<Text color={colors.textMuted} dimColor>{action.description}</Text>
|
||||||
)}
|
)}
|
||||||
@@ -541,15 +728,22 @@ export function InvitationScreen(): React.ReactElement {
|
|||||||
) : (
|
) : (
|
||||||
inputs.map((input, idx) => {
|
inputs.map((input, idx) => {
|
||||||
const isUserInput = input.entityIdentifier === userEntityId;
|
const isUserInput = input.entityIdentifier === userEntityId;
|
||||||
const inputTemplate = selectedTemplate?.inputs?.[input.inputIdentifier ?? ''];
|
const inputSatoshis = (
|
||||||
|
'valueSatoshis' in input && input.valueSatoshis !== undefined
|
||||||
|
)
|
||||||
|
? parseNumberishToBigInt(input.valueSatoshis)
|
||||||
|
: null;
|
||||||
|
const inputDescription = compileResolvedDescription(input.description);
|
||||||
return (
|
return (
|
||||||
<Text
|
<Text
|
||||||
key={`input-${idx}`}
|
key={`input-${idx}`}
|
||||||
color={isUserInput ? colors.success : colors.text}
|
color={isUserInput ? colors.success : colors.text}
|
||||||
>
|
>
|
||||||
{' '}{isUserInput ? '• ' : '○ '}
|
{' '}{isUserInput ? '• ' : '○ '}
|
||||||
{inputTemplate?.name ?? input.inputIdentifier ?? `Input ${idx}`}
|
{input.name ?? input.inputIdentifier ?? `Input ${idx}`}
|
||||||
{input.roleIdentifier && ` (${input.roleIdentifier})`}
|
{input.roleIdentifier && ` (${input.roleIdentifier})`}
|
||||||
|
{inputDescription && ` - ${inputDescription}`}
|
||||||
|
{inputSatoshis !== null && ` ${formatSatoshis(inputSatoshis)}${getFiatSuffix(inputSatoshis)}`}
|
||||||
</Text>
|
</Text>
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
@@ -563,15 +757,19 @@ export function InvitationScreen(): React.ReactElement {
|
|||||||
) : (
|
) : (
|
||||||
outputs.map((output, idx) => {
|
outputs.map((output, idx) => {
|
||||||
const isUserOutput = output.entityIdentifier === userEntityId;
|
const isUserOutput = output.entityIdentifier === userEntityId;
|
||||||
const outputTemplate = selectedTemplate?.outputs?.[output.outputIdentifier ?? ''];
|
const outputSatoshis = output.valueSatoshis !== undefined
|
||||||
|
? parseNumberishToBigInt(output.valueSatoshis)
|
||||||
|
: null;
|
||||||
|
const outputDescription = compileResolvedDescription(output.description);
|
||||||
return (
|
return (
|
||||||
<Text
|
<Text
|
||||||
key={`output-${idx}`}
|
key={`output-${idx}`}
|
||||||
color={isUserOutput ? colors.success : colors.text}
|
color={isUserOutput ? colors.success : colors.text}
|
||||||
>
|
>
|
||||||
{' '}{isUserOutput ? '• ' : '○ '}
|
{' '}{isUserOutput ? '• ' : '○ '}
|
||||||
{outputTemplate?.name ?? output.outputIdentifier ?? `Output ${idx}`}
|
{output.name ?? output.outputIdentifier ?? `Output ${idx}`}
|
||||||
{output.valueSatoshis !== undefined && ` (${formatSatoshis(output.valueSatoshis)})`}
|
{outputDescription && ` - ${outputDescription}`}
|
||||||
|
{outputSatoshis !== null && ` (${formatSatoshis(outputSatoshis)}${getFiatSuffix(outputSatoshis)})`}
|
||||||
</Text>
|
</Text>
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
@@ -587,19 +785,23 @@ export function InvitationScreen(): React.ReactElement {
|
|||||||
) : (
|
) : (
|
||||||
variables.map((variable, idx) => {
|
variables.map((variable, idx) => {
|
||||||
const isUserVariable = variable.entityIdentifier === userEntityId;
|
const isUserVariable = variable.entityIdentifier === userEntityId;
|
||||||
const varTemplate = selectedTemplate?.variables?.[variable.variableIdentifier];
|
|
||||||
const displayValue = typeof variable.value === 'bigint'
|
const displayValue = typeof variable.value === 'bigint'
|
||||||
? variable.value.toString()
|
? variable.value.toString()
|
||||||
: String(variable.value);
|
: String(variable.value);
|
||||||
|
const parsedVariableSatoshis = isSatoshisVariable(variable)
|
||||||
|
? parseNumberishToBigInt(variable.value)
|
||||||
|
: null;
|
||||||
return (
|
return (
|
||||||
<Text
|
<Text
|
||||||
key={`var-${idx}`}
|
key={`var-${idx}`}
|
||||||
color={isUserVariable ? colors.success : colors.text}
|
color={isUserVariable ? colors.success : colors.text}
|
||||||
>
|
>
|
||||||
{' '}{isUserVariable ? '• ' : '○ '}
|
{' '}{isUserVariable ? '• ' : '○ '}
|
||||||
{varTemplate?.name ?? variable.variableIdentifier}: {displayValue}
|
{variable.name ?? variable.variableIdentifier}: {displayValue}
|
||||||
{varTemplate?.description && (
|
{parsedVariableSatoshis !== null &&
|
||||||
<Text color={colors.textMuted} dimColor> - {varTemplate.description}</Text>
|
` (${formatSatoshis(parsedVariableSatoshis)}${getFiatSuffix(parsedVariableSatoshis)})`}
|
||||||
|
{variable.description && (
|
||||||
|
<Text color={colors.textMuted} dimColor> - {variable.description}</Text>
|
||||||
)}
|
)}
|
||||||
</Text>
|
</Text>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -17,15 +17,15 @@ import { StepIndicator, type Step } from '../../../components/ProgressBar.js';
|
|||||||
import { FetchInvitationStep } from './steps/FetchInvitationStep.js';
|
import { FetchInvitationStep } from './steps/FetchInvitationStep.js';
|
||||||
import { PreviewInvitationStep } from './steps/PreviewInvitationStep.js';
|
import { PreviewInvitationStep } from './steps/PreviewInvitationStep.js';
|
||||||
import { RoleSelectStep } from './steps/RoleSelectStep.js';
|
import { RoleSelectStep } from './steps/RoleSelectStep.js';
|
||||||
|
import { VariablesStep } from './steps/VariablesStep.js';
|
||||||
import { InputsSelectStep } from './steps/InputsSelectStep.js';
|
import { InputsSelectStep } from './steps/InputsSelectStep.js';
|
||||||
import { ReviewStep } from './steps/ReviewStep.js';
|
import { ReviewStep } from './steps/ReviewStep.js';
|
||||||
|
|
||||||
import { IMPORT_STEPS, type ImportFlowProps, type SelectableUTXO } from './types.js';
|
import { IMPORT_STEPS, type ImportFlowProps, type ImportStepType, type ImportVariableInput, type SelectableUTXO } from './types.js';
|
||||||
import type { Invitation } from '../../../../services/invitation.js';
|
import type { Invitation } from '../../../../services/invitation.js';
|
||||||
import type { XOTemplate } from '@xo-cash/types';
|
import type { XOTemplate } from '@xo-cash/types';
|
||||||
import { DialogWrapper } from '../../../components/Dialog.js';
|
import { DialogWrapper } from '../../../components/Dialog.js';
|
||||||
import { useInputLayer, useLayeredInput } from '../../../hooks/useInputLayer.js';
|
import { useInputLayer, useLayeredInput } from '../../../hooks/useInputLayer.js';
|
||||||
import { InvitationBuilder } from '@xo-cash/engine';
|
|
||||||
import { hexToBin } from '@bitauth/libauth';
|
import { hexToBin } from '@bitauth/libauth';
|
||||||
|
|
||||||
/** Default fee estimate in satoshis. */
|
/** Default fee estimate in satoshis. */
|
||||||
@@ -34,6 +34,24 @@ const DEFAULT_FEE = 500n;
|
|||||||
/** Dust threshold — outputs below this are unspendable. */
|
/** Dust threshold — outputs below this are unspendable. */
|
||||||
const DUST_THRESHOLD = 546n;
|
const DUST_THRESHOLD = 546n;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the fixed index of a flow step from `IMPORT_STEPS`.
|
||||||
|
* We centralize this so step transitions do not rely on magic numbers.
|
||||||
|
*/
|
||||||
|
function getStepIndex(type: ImportStepType): number {
|
||||||
|
const index = IMPORT_STEPS.findIndex((step) => step.type === type);
|
||||||
|
if (index === -1) {
|
||||||
|
throw new Error(`Import step not found: ${type}`);
|
||||||
|
}
|
||||||
|
return index;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PREVIEW_STEP_INDEX = getStepIndex('preview');
|
||||||
|
const ROLE_SELECT_STEP_INDEX = getStepIndex('role-select');
|
||||||
|
const VARIABLES_STEP_INDEX = getStepIndex('variables');
|
||||||
|
const INPUTS_SELECT_STEP_INDEX = getStepIndex('inputs-select');
|
||||||
|
const REVIEW_STEP_INDEX = getStepIndex('review');
|
||||||
|
|
||||||
export function InvitationImportFlow({
|
export function InvitationImportFlow({
|
||||||
invitationId,
|
invitationId,
|
||||||
mode,
|
mode,
|
||||||
@@ -46,10 +64,10 @@ export function InvitationImportFlow({
|
|||||||
// ── Accumulated state ────────────────────────────────────────────────────
|
// ── Accumulated state ────────────────────────────────────────────────────
|
||||||
const [currentStep, setCurrentStep] = useState(0);
|
const [currentStep, setCurrentStep] = useState(0);
|
||||||
const [invitation, setInvitation] = useState<Invitation | null>(null);
|
const [invitation, setInvitation] = useState<Invitation | null>(null);
|
||||||
const [buildableInvitation, setBuildableInvitation] = useState<InvitationBuilder | null>(null);
|
|
||||||
const [template, setTemplate] = useState<XOTemplate | null>(null);
|
const [template, setTemplate] = useState<XOTemplate | null>(null);
|
||||||
const [availableRoles, setAvailableRoles] = useState<string[]>([]);
|
const [availableRoles, setAvailableRoles] = useState<string[]>([]);
|
||||||
const [selectedRole, setSelectedRole] = useState<string | null>(null);
|
const [selectedRole, setSelectedRole] = useState<string | null>(null);
|
||||||
|
const [variableInputs, setVariableInputs] = useState<ImportVariableInput[]>([]);
|
||||||
const [selectedInputs, setSelectedInputs] = useState<SelectableUTXO[]>([]);
|
const [selectedInputs, setSelectedInputs] = useState<SelectableUTXO[]>([]);
|
||||||
const [changeAmount, setChangeAmount] = useState(0n);
|
const [changeAmount, setChangeAmount] = useState(0n);
|
||||||
const [requiredAmount, setRequiredAmount] = useState(0n);
|
const [requiredAmount, setRequiredAmount] = useState(0n);
|
||||||
@@ -79,9 +97,6 @@ export function InvitationImportFlow({
|
|||||||
setInvitation(inv);
|
setInvitation(inv);
|
||||||
setTemplate(tmpl);
|
setTemplate(tmpl);
|
||||||
|
|
||||||
const builder = InvitationBuilder.fromInvitation(inv.data);
|
|
||||||
setBuildableInvitation(builder);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const roles = await inv.getAvailableRoles();
|
const roles = await inv.getAvailableRoles();
|
||||||
setAvailableRoles(roles);
|
setAvailableRoles(roles);
|
||||||
@@ -89,20 +104,98 @@ export function InvitationImportFlow({
|
|||||||
showError(`Failed to get roles: ${err instanceof Error ? err.message : String(err)}`);
|
showError(`Failed to get roles: ${err instanceof Error ? err.message : String(err)}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
setCurrentStep(1); // → Preview
|
setCurrentStep(PREVIEW_STEP_INDEX); // → Preview
|
||||||
}, [showError]);
|
}, [showError]);
|
||||||
|
|
||||||
/** PreviewStep completed — user reviewed the invitation state and wants to proceed. */
|
/** PreviewStep completed — user reviewed the invitation state and wants to proceed. */
|
||||||
const handlePreviewComplete = useCallback(() => {
|
const handlePreviewComplete = useCallback(() => {
|
||||||
setCurrentStep(2); // → Role Select
|
setCurrentStep(ROLE_SELECT_STEP_INDEX); // → Role Select
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
/** RoleSelectStep completed — user picked a role. */
|
/** RoleSelectStep completed — user picked a role. */
|
||||||
const handleRoleComplete = useCallback((role: string) => {
|
const handleRoleComplete = useCallback((role: string) => {
|
||||||
setSelectedRole(role);
|
setSelectedRole(role);
|
||||||
setCurrentStep(3); // → Inputs Select
|
|
||||||
|
const action = template?.actions?.[invitation?.data.actionIdentifier ?? ""];
|
||||||
|
const roleRequirements = action?.roles?.[role]?.requirements?.variables ?? [];
|
||||||
|
const hasRequiredVariables = roleRequirements.length > 0;
|
||||||
|
|
||||||
|
if (!hasRequiredVariables) {
|
||||||
|
setVariableInputs([]);
|
||||||
|
setCurrentStep(INPUTS_SELECT_STEP_INDEX); // → Inputs Select
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const initializedVariables: ImportVariableInput[] = roleRequirements.map((variableId) => {
|
||||||
|
const variableDefinition = template?.variables?.[variableId];
|
||||||
|
return {
|
||||||
|
id: variableId,
|
||||||
|
name: variableDefinition?.name ?? variableId,
|
||||||
|
type: variableDefinition?.type ?? 'string',
|
||||||
|
hint: variableDefinition?.hint,
|
||||||
|
value: '',
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
setVariableInputs(initializedVariables);
|
||||||
|
setCurrentStep(VARIABLES_STEP_INDEX); // → Variables
|
||||||
|
}, [template, invitation]);
|
||||||
|
|
||||||
|
/** VariablesStep edited a field value. */
|
||||||
|
const handleVariableUpdate = useCallback((index: number, value: string) => {
|
||||||
|
setVariableInputs((previous) => {
|
||||||
|
const updated = [...previous];
|
||||||
|
const current = updated[index];
|
||||||
|
if (current) {
|
||||||
|
updated[index] = { ...current, value };
|
||||||
|
}
|
||||||
|
return updated;
|
||||||
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert variable input value to its invitation payload representation.
|
||||||
|
* Numeric variables are persisted as bigint so they match action wizard behavior.
|
||||||
|
*/
|
||||||
|
const parseVariableValue = useCallback((variable: ImportVariableInput) => {
|
||||||
|
const variableHint = variable.hint?.toLowerCase();
|
||||||
|
const isNumeric =
|
||||||
|
['integer', 'number', 'satoshis'].includes(variable.type) ||
|
||||||
|
(variableHint !== undefined && ['satoshis', 'amount'].includes(variableHint));
|
||||||
|
|
||||||
|
if (!isNumeric) {
|
||||||
|
return variable.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return BigInt(variable.value || '0');
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
/** VariablesStep completed — persist variables then continue to input selection. */
|
||||||
|
const handleVariablesComplete = useCallback(async () => {
|
||||||
|
if (!invitation || !selectedRole) return;
|
||||||
|
|
||||||
|
const emptyVariables = variableInputs.filter((variable) => variable.value.trim() === '');
|
||||||
|
if (emptyVariables.length > 0) {
|
||||||
|
showError(`Please enter values for: ${emptyVariables.map((variable) => variable.name).join(', ')}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await invitation.addVariables(
|
||||||
|
variableInputs.map((variable) => ({
|
||||||
|
variableIdentifier: variable.id,
|
||||||
|
roleIdentifier: selectedRole,
|
||||||
|
value: parseVariableValue(variable),
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
setCurrentStep(INPUTS_SELECT_STEP_INDEX); // → Inputs Select
|
||||||
|
} catch (error) {
|
||||||
|
showError(
|
||||||
|
`Failed to add variables: ${error instanceof Error ? error.message : String(error)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}, [invitation, selectedRole, variableInputs, parseVariableValue, showError]);
|
||||||
|
|
||||||
/** InputsSelectStep completed — user selected UTXOs. */
|
/** InputsSelectStep completed — user selected UTXOs. */
|
||||||
const handleInputsComplete = useCallback(async (inputs: SelectableUTXO[]) => {
|
const handleInputsComplete = useCallback(async (inputs: SelectableUTXO[]) => {
|
||||||
setSelectedInputs(inputs);
|
setSelectedInputs(inputs);
|
||||||
@@ -130,8 +223,8 @@ export function InvitationImportFlow({
|
|||||||
}]);
|
}]);
|
||||||
}
|
}
|
||||||
|
|
||||||
setCurrentStep(4); // → Review
|
setCurrentStep(REVIEW_STEP_INDEX); // → Review
|
||||||
}, [invitation, buildableInvitation, selectedInputs]);
|
}, [invitation]);
|
||||||
|
|
||||||
/** ReviewStep completed — invitation import is done. */
|
/** ReviewStep completed — invitation import is done. */
|
||||||
const handleReviewComplete = useCallback(() => {
|
const handleReviewComplete = useCallback(() => {
|
||||||
@@ -148,7 +241,7 @@ export function InvitationImportFlow({
|
|||||||
`Action: ${invitation?.data.actionIdentifier ?? 'Unknown'}`
|
`Action: ${invitation?.data.actionIdentifier ?? 'Unknown'}`
|
||||||
);
|
);
|
||||||
setStatus('Ready');
|
setStatus('Ready');
|
||||||
onClose();
|
onClose(invitation?.data.invitationIdentifier);
|
||||||
}, [selectedRole, template, invitation, showInfo, setStatus, onClose]);
|
}, [selectedRole, template, invitation, showInfo, setStatus, onClose]);
|
||||||
|
|
||||||
// ── Keyboard handling ────────────────────────────────────────────────────
|
// ── Keyboard handling ────────────────────────────────────────────────────
|
||||||
@@ -205,6 +298,17 @@ export function InvitationImportFlow({
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
case 'variables':
|
||||||
|
return (
|
||||||
|
<VariablesStep
|
||||||
|
variables={variableInputs}
|
||||||
|
onUpdateVariable={handleVariableUpdate}
|
||||||
|
onComplete={handleVariablesComplete}
|
||||||
|
onCancel={handleCancel}
|
||||||
|
isActive={true}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
case 'inputs-select':
|
case 'inputs-select':
|
||||||
if (!invitation || !selectedRole) return null;
|
if (!invitation || !selectedRole) return null;
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
||||||
import { Box, Text } from 'ink';
|
import { Box, Text } from 'ink';
|
||||||
import { colors, formatSatoshis } from '../../../../theme.js';
|
import { colors, formatSatoshis } from '../../../../theme.js';
|
||||||
|
import { useSatoshisConversion } from '../../../../hooks/useSatoshisConversion.js';
|
||||||
import { useLayeredInput } from '../../../../hooks/useInputLayer.js';
|
import { useLayeredInput } from '../../../../hooks/useInputLayer.js';
|
||||||
import type { InputsSelectStepProps, SelectableUTXO } from '../types.js';
|
import type { InputsSelectStepProps, SelectableUTXO } from '../types.js';
|
||||||
import { autoSelectGreedyUtxos, mapUnspentOutputsToSelectable } from '../../../../../utils/invitation-flow.js';
|
import { autoSelectGreedyUtxos, mapUnspentOutputsToSelectable } from '../../../../../utils/invitation-flow.js';
|
||||||
@@ -32,6 +33,7 @@ export function InputsSelectStep({
|
|||||||
const [requiredAmount, setRequiredAmount] = useState(0n);
|
const [requiredAmount, setRequiredAmount] = useState(0n);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const { formatSatoshisToFiat } = useSatoshisConversion();
|
||||||
|
|
||||||
const fee = DEFAULT_FEE;
|
const fee = DEFAULT_FEE;
|
||||||
|
|
||||||
@@ -42,6 +44,11 @@ export function InputsSelectStep({
|
|||||||
const changeAmount = selectedAmount - requiredAmount - fee;
|
const changeAmount = selectedAmount - requiredAmount - fee;
|
||||||
const hasEnough = selectedAmount >= requiredAmount + fee;
|
const hasEnough = selectedAmount >= requiredAmount + fee;
|
||||||
|
|
||||||
|
const getFiatSuffix = (satoshis: bigint): string => {
|
||||||
|
const fiatValue = formatSatoshisToFiat(satoshis);
|
||||||
|
return fiatValue ? ` (~${fiatValue})` : '';
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Determine the required satoshi amount from the invitation's variables.
|
* Determine the required satoshi amount from the invitation's variables.
|
||||||
*/
|
*/
|
||||||
@@ -193,18 +200,32 @@ export function InputsSelectStep({
|
|||||||
{/* Summary bar */}
|
{/* Summary bar */}
|
||||||
<Box flexDirection="row" marginBottom={1}>
|
<Box flexDirection="row" marginBottom={1}>
|
||||||
<Text color={colors.primary} bold>Required: </Text>
|
<Text color={colors.primary} bold>Required: </Text>
|
||||||
<Text color={colors.text}>{formatSatoshis(requiredAmount + fee)}</Text>
|
<Text color={colors.text}>
|
||||||
|
{formatSatoshis(requiredAmount + fee)}
|
||||||
|
{getFiatSuffix(requiredAmount + fee)}
|
||||||
|
</Text>
|
||||||
<Text color={colors.textMuted}> (amount {formatSatoshis(requiredAmount)} + fee {formatSatoshis(fee)})</Text>
|
<Text color={colors.textMuted}> (amount {formatSatoshis(requiredAmount)} + fee {formatSatoshis(fee)})</Text>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Box flexDirection="row" marginBottom={1}>
|
<Box flexDirection="row" marginBottom={1}>
|
||||||
<Text color={colors.primary} bold>Selected: </Text>
|
<Text color={colors.primary} bold>Selected: </Text>
|
||||||
<Text color={hasEnough ? colors.success : colors.error}>{formatSatoshis(selectedAmount)}</Text>
|
<Text color={hasEnough ? colors.success : colors.error}>
|
||||||
|
{formatSatoshis(selectedAmount)}
|
||||||
|
{getFiatSuffix(selectedAmount)}
|
||||||
|
</Text>
|
||||||
{hasEnough && changeAmount >= DUST_THRESHOLD && (
|
{hasEnough && changeAmount >= DUST_THRESHOLD && (
|
||||||
<Text color={colors.textMuted}> (change: {formatSatoshis(changeAmount)})</Text>
|
<Text color={colors.textMuted}>
|
||||||
|
{' '}
|
||||||
|
(change: {formatSatoshis(changeAmount)}
|
||||||
|
{getFiatSuffix(changeAmount)})
|
||||||
|
</Text>
|
||||||
)}
|
)}
|
||||||
{!hasEnough && (
|
{!hasEnough && (
|
||||||
<Text color={colors.error}> — need {formatSatoshis(requiredAmount + fee - selectedAmount)} more</Text>
|
<Text color={colors.error}>
|
||||||
|
{' '}
|
||||||
|
— need {formatSatoshis(requiredAmount + fee - selectedAmount)}
|
||||||
|
{getFiatSuffix(requiredAmount + fee - selectedAmount)} more
|
||||||
|
</Text>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
@@ -216,13 +237,22 @@ export function InputsSelectStep({
|
|||||||
const txShort = utxo.outpointTransactionHash.slice(0, 8);
|
const txShort = utxo.outpointTransactionHash.slice(0, 8);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Text
|
<Box
|
||||||
key={`${utxo.outpointTransactionHash}:${utxo.outpointIndex}`}
|
key={`${utxo.outpointTransactionHash}:${utxo.outpointIndex}`}
|
||||||
color={isFocused ? colors.focus : utxo.selected ? colors.success : colors.text}
|
flexDirection="column"
|
||||||
bold={isFocused}
|
|
||||||
>
|
>
|
||||||
{isFocused ? '▸ ' : ' '}{checkMark} {formatSatoshis(utxo.valueSatoshis)} ({txShort}…:{utxo.outpointIndex})
|
<Text
|
||||||
</Text>
|
color={isFocused ? colors.focus : utxo.selected ? colors.success : colors.text}
|
||||||
|
bold={isFocused}
|
||||||
|
>
|
||||||
|
{isFocused ? '▸ ' : ' '}{checkMark} {formatSatoshis(utxo.valueSatoshis)} ({txShort}…:{utxo.outpointIndex})
|
||||||
|
</Text>
|
||||||
|
{formatSatoshisToFiat(utxo.valueSatoshis) && (
|
||||||
|
<Text color={colors.textMuted}>
|
||||||
|
{' '}≈ {formatSatoshisToFiat(utxo.valueSatoshis)}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
||||||
|
|||||||
@@ -9,16 +9,34 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Box, Text } from 'ink';
|
import { Box, Text } from 'ink';
|
||||||
import { colors, formatSatoshis } from '../../../../theme.js';
|
import { colors, formatSatoshis } from '../../../../theme.js';
|
||||||
|
import { useSatoshisConversion } from '../../../../hooks/useSatoshisConversion.js';
|
||||||
import { useLayeredInput } from '../../../../hooks/useInputLayer.js';
|
import { useLayeredInput } from '../../../../hooks/useInputLayer.js';
|
||||||
import {
|
import {
|
||||||
getInvitationState,
|
getInvitationState,
|
||||||
getStateColorName,
|
getStateColorName,
|
||||||
getInvitationInputs,
|
|
||||||
getInvitationOutputs,
|
|
||||||
getInvitationVariables,
|
|
||||||
} from '../../../../../utils/invitation-utils.js';
|
} from '../../../../../utils/invitation-utils.js';
|
||||||
import type { PreviewStepProps } from '../types.js';
|
import type { PreviewStepProps } from '../types.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map a semantic color name to an actual theme color value.
|
||||||
|
*/
|
||||||
|
function parseNumberishToBigInt(value: unknown): bigint | null {
|
||||||
|
if (typeof value === 'bigint') {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
const asString = String(value).trim();
|
||||||
|
if (!/^[-]?\d+$/.test(asString)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return BigInt(asString);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Map a semantic color name to an actual theme color value.
|
* Map a semantic color name to an actual theme color value.
|
||||||
*/
|
*/
|
||||||
@@ -41,6 +59,8 @@ export function PreviewInvitationStep({
|
|||||||
onCancel,
|
onCancel,
|
||||||
isActive,
|
isActive,
|
||||||
}: PreviewStepProps): React.ReactElement {
|
}: PreviewStepProps): React.ReactElement {
|
||||||
|
const { formatSatoshisToFiat } = useSatoshisConversion();
|
||||||
|
|
||||||
useLayeredInput('import-flow', (_input, key) => {
|
useLayeredInput('import-flow', (_input, key) => {
|
||||||
if (key.return) onComplete();
|
if (key.return) onComplete();
|
||||||
if (key.escape) onCancel();
|
if (key.escape) onCancel();
|
||||||
@@ -48,16 +68,18 @@ export function PreviewInvitationStep({
|
|||||||
|
|
||||||
const state = getInvitationState(invitation);
|
const state = getInvitationState(invitation);
|
||||||
const action = template?.actions?.[invitation.data.actionIdentifier];
|
const action = template?.actions?.[invitation.data.actionIdentifier];
|
||||||
const inputs = getInvitationInputs(invitation);
|
const { inputs, outputs, variables } = invitation.resolvedData;
|
||||||
const outputs = getInvitationOutputs(invitation);
|
|
||||||
const variables = getInvitationVariables(invitation);
|
|
||||||
|
|
||||||
// Collect role identifiers that appear across all commits
|
// Collect role identifiers that appear across resolved invitation data
|
||||||
const filledRoles = new Set<string>();
|
const filledRoles = new Set<string>();
|
||||||
for (const commit of invitation.data.commits ?? []) {
|
for (const input of inputs) {
|
||||||
for (const input of commit.data?.inputs ?? []) {
|
if (input.roleIdentifier) filledRoles.add(input.roleIdentifier);
|
||||||
if (input.roleIdentifier) filledRoles.add(input.roleIdentifier);
|
}
|
||||||
}
|
for (const output of outputs) {
|
||||||
|
if (output.roleIdentifier) filledRoles.add(output.roleIdentifier);
|
||||||
|
}
|
||||||
|
for (const variable of variables) {
|
||||||
|
if (variable.roleIdentifier) filledRoles.add(variable.roleIdentifier);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -140,11 +162,10 @@ export function PreviewInvitationStep({
|
|||||||
</Box>
|
</Box>
|
||||||
) : (
|
) : (
|
||||||
inputs.map((input, idx) => {
|
inputs.map((input, idx) => {
|
||||||
const inputTemplate = template?.inputs?.[input.inputIdentifier ?? ''];
|
|
||||||
return (
|
return (
|
||||||
<Box key={`input-${idx}`}>
|
<Box key={`input-${idx}`}>
|
||||||
<Text color={colors.text}>
|
<Text color={colors.text}>
|
||||||
{' '}• {inputTemplate?.name ?? input.inputIdentifier ?? `Input ${idx}`}
|
{' '}• {input.name ?? input.inputIdentifier ?? `Input ${idx}`}
|
||||||
{input.roleIdentifier && ` (${input.roleIdentifier})`}
|
{input.roleIdentifier && ` (${input.roleIdentifier})`}
|
||||||
</Text>
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
@@ -167,12 +188,18 @@ export function PreviewInvitationStep({
|
|||||||
</Box>
|
</Box>
|
||||||
) : (
|
) : (
|
||||||
outputs.map((output, idx) => {
|
outputs.map((output, idx) => {
|
||||||
const outputTemplate = template?.outputs?.[output.outputIdentifier ?? ''];
|
const fiatValue = output.valueSatoshis !== undefined
|
||||||
|
? formatSatoshisToFiat(output.valueSatoshis)
|
||||||
|
: null;
|
||||||
|
const outputSatoshis = output.valueSatoshis !== undefined
|
||||||
|
? parseNumberishToBigInt(output.valueSatoshis)
|
||||||
|
: null;
|
||||||
return (
|
return (
|
||||||
<Box key={`output-${idx}`}>
|
<Box key={`output-${idx}`}>
|
||||||
<Text color={colors.text}>
|
<Text color={colors.text}>
|
||||||
{' '}• {outputTemplate?.name ?? output.outputIdentifier ?? `Output ${idx}`}
|
{' '}• {output.name ?? output.outputIdentifier ?? `Output ${idx}`}
|
||||||
{output.valueSatoshis !== undefined && ` (${formatSatoshis(output.valueSatoshis)})`}
|
{outputSatoshis !== null && ` (${formatSatoshis(outputSatoshis)})`}
|
||||||
|
{fiatValue && ` (~${fiatValue})`}
|
||||||
</Text>
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
@@ -194,14 +221,13 @@ export function PreviewInvitationStep({
|
|||||||
</Box>
|
</Box>
|
||||||
) : (
|
) : (
|
||||||
variables.map((variable, idx) => {
|
variables.map((variable, idx) => {
|
||||||
const varTemplate = template?.variables?.[variable.variableIdentifier];
|
|
||||||
const displayValue = typeof variable.value === 'bigint'
|
const displayValue = typeof variable.value === 'bigint'
|
||||||
? variable.value.toString()
|
? variable.value.toString()
|
||||||
: String(variable.value);
|
: String(variable.value);
|
||||||
return (
|
return (
|
||||||
<Box key={`var-${idx}`}>
|
<Box key={`var-${idx}`}>
|
||||||
<Text color={colors.text}>
|
<Text color={colors.text}>
|
||||||
{' '}• {varTemplate?.name ?? variable.variableIdentifier}: {displayValue}
|
{' '}• {variable.name ?? variable.variableIdentifier}: {displayValue}
|
||||||
</Text>
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
import React, { useState, useCallback } from 'react';
|
import React, { useState, useCallback } from 'react';
|
||||||
import { Box, Text } from 'ink';
|
import { Box, Text } from 'ink';
|
||||||
import { colors, formatSatoshis } from '../../../../theme.js';
|
import { colors, formatSatoshis } from '../../../../theme.js';
|
||||||
|
import { useSatoshisConversion } from '../../../../hooks/useSatoshisConversion.js';
|
||||||
import { useLayeredInput } from '../../../../hooks/useInputLayer.js';
|
import { useLayeredInput } from '../../../../hooks/useInputLayer.js';
|
||||||
import type { ReviewStepProps, SelectableUTXO } from '../types.js';
|
import type { ReviewStepProps, SelectableUTXO } from '../types.js';
|
||||||
|
|
||||||
@@ -32,6 +33,7 @@ export function ReviewStep({
|
|||||||
}: ReviewStepProps): React.ReactElement {
|
}: ReviewStepProps): React.ReactElement {
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const { formatSatoshisToFiat } = useSatoshisConversion();
|
||||||
|
|
||||||
const fee = DEFAULT_FEE;
|
const fee = DEFAULT_FEE;
|
||||||
const action = template?.actions?.[invitation.data.actionIdentifier];
|
const action = template?.actions?.[invitation.data.actionIdentifier];
|
||||||
@@ -39,6 +41,11 @@ export function ReviewStep({
|
|||||||
// Compute totals from selected inputs
|
// Compute totals from selected inputs
|
||||||
const totalSelected = selectedInputs.reduce((sum, u) => sum + u.valueSatoshis, 0n);
|
const totalSelected = selectedInputs.reduce((sum, u) => sum + u.valueSatoshis, 0n);
|
||||||
|
|
||||||
|
const getFiatSuffix = (satoshis: bigint): string => {
|
||||||
|
const fiatValue = formatSatoshisToFiat(satoshis);
|
||||||
|
return fiatValue ? ` (~${fiatValue})` : '';
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Execute the import: add inputs (with role) and optional change output.
|
* Execute the import: add inputs (with role) and optional change output.
|
||||||
*/
|
*/
|
||||||
@@ -85,14 +92,34 @@ export function ReviewStep({
|
|||||||
<Box marginTop={1} flexDirection="column">
|
<Box marginTop={1} flexDirection="column">
|
||||||
<Text color={colors.primary} bold>Funding:</Text>
|
<Text color={colors.primary} bold>Funding:</Text>
|
||||||
<Text color={colors.text}> • UTXOs: {selectedInputs.length}</Text>
|
<Text color={colors.text}> • UTXOs: {selectedInputs.length}</Text>
|
||||||
<Text color={colors.text}> • Total: {formatSatoshis(totalSelected)}</Text>
|
<Text color={colors.text}> • Total: {formatSatoshis(totalSelected)}{getFiatSuffix(totalSelected)}</Text>
|
||||||
<Text color={colors.text}> • Required: {formatSatoshis(requiredAmount)}</Text>
|
<Text color={colors.text}> • Required: {formatSatoshis(requiredAmount)}{getFiatSuffix(requiredAmount)}</Text>
|
||||||
<Text color={colors.text}> • Fee: {formatSatoshis(fee)}</Text>
|
<Text color={colors.text}> • Fee: {formatSatoshis(fee)}{getFiatSuffix(fee)}</Text>
|
||||||
{changeAmount >= DUST_THRESHOLD && (
|
{changeAmount >= DUST_THRESHOLD && (
|
||||||
<Text color={colors.text}> • Change: {formatSatoshis(changeAmount)}</Text>
|
<Text color={colors.text}> • Change: {formatSatoshis(changeAmount)}{getFiatSuffix(changeAmount)}</Text>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
|
{selectedInputs.length > 0 && (
|
||||||
|
<Box marginTop={1} flexDirection="column">
|
||||||
|
<Text color={colors.primary} bold>Selected UTXOs:</Text>
|
||||||
|
{selectedInputs.slice(0, 3).map((utxo) => (
|
||||||
|
<Text
|
||||||
|
key={`${utxo.outpointTransactionHash}:${utxo.outpointIndex}`}
|
||||||
|
color={colors.textMuted}
|
||||||
|
>
|
||||||
|
{' '}• {formatSatoshis(utxo.valueSatoshis)}
|
||||||
|
{getFiatSuffix(utxo.valueSatoshis)}
|
||||||
|
</Text>
|
||||||
|
))}
|
||||||
|
{selectedInputs.length > 3 && (
|
||||||
|
<Text color={colors.textMuted}>
|
||||||
|
{' '}...and {selectedInputs.length - 3} more
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Error display */}
|
{/* Error display */}
|
||||||
{error && (
|
{error && (
|
||||||
<Box marginTop={1}>
|
<Box marginTop={1}>
|
||||||
|
|||||||
@@ -0,0 +1,113 @@
|
|||||||
|
/**
|
||||||
|
* VariablesStep — collects all required variable values for invitation import.
|
||||||
|
*
|
||||||
|
* This runs after role selection and before input selection so cashasm
|
||||||
|
* expressions can resolve required variables during `getSatsOut()`.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useMemo, useState, useCallback } from "react";
|
||||||
|
import { Box, Text } from "ink";
|
||||||
|
import { colors } from "../../../../theme.js";
|
||||||
|
import { useLayeredInput } from "../../../../hooks/useInputLayer.js";
|
||||||
|
import { VariableInputField } from "../../../../components/VariableInputField.js";
|
||||||
|
import type { VariablesStepProps } from "../types.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a user-facing validation error for empty required fields.
|
||||||
|
*/
|
||||||
|
function validateVariables(
|
||||||
|
variables: VariablesStepProps["variables"],
|
||||||
|
): string | null {
|
||||||
|
const empty = variables.filter((v) => v.value.trim() === "");
|
||||||
|
if (empty.length === 0) return null;
|
||||||
|
return `Please enter values for: ${empty.map((v) => v.name).join(", ")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function VariablesStep({
|
||||||
|
variables,
|
||||||
|
onUpdateVariable,
|
||||||
|
onComplete,
|
||||||
|
onCancel,
|
||||||
|
isActive,
|
||||||
|
}: VariablesStepProps): React.ReactElement {
|
||||||
|
const [focusedInput, setFocusedInput] = useState(0);
|
||||||
|
const [validationError, setValidationError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const helpText = useMemo(() => {
|
||||||
|
if (variables.length === 0) {
|
||||||
|
return "No variables required for this role.";
|
||||||
|
}
|
||||||
|
return "Enter a value for each variable, then press Enter on the last field to continue.";
|
||||||
|
}, [variables.length]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Move focus to next input, or finish the step if this is the last one.
|
||||||
|
*/
|
||||||
|
const handleInputSubmit = useCallback(() => {
|
||||||
|
if (variables.length === 0) {
|
||||||
|
onComplete();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (focusedInput < variables.length - 1) {
|
||||||
|
setFocusedInput((prev) => prev + 1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const validation = validateVariables(variables);
|
||||||
|
setValidationError(validation);
|
||||||
|
if (!validation) {
|
||||||
|
onComplete();
|
||||||
|
}
|
||||||
|
}, [variables, focusedInput, onComplete]);
|
||||||
|
|
||||||
|
// Keyboard navigation for non-text actions.
|
||||||
|
useLayeredInput(
|
||||||
|
"import-flow",
|
||||||
|
(input, key) => {
|
||||||
|
if (key.upArrow || input === "k") {
|
||||||
|
setFocusedInput((prev) => Math.max(0, prev - 1));
|
||||||
|
} else if (key.downArrow || input === "j") {
|
||||||
|
setFocusedInput((prev) => Math.min(variables.length - 1, prev + 1));
|
||||||
|
} else if (key.escape) {
|
||||||
|
onCancel();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ isActive },
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box flexDirection="column">
|
||||||
|
<Text color={colors.primary} bold>
|
||||||
|
Required Variables
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Box marginTop={1} flexDirection="column">
|
||||||
|
{variables.map((variable, index) => (
|
||||||
|
<VariableInputField
|
||||||
|
key={variable.id}
|
||||||
|
variable={variable}
|
||||||
|
index={index}
|
||||||
|
isFocused={focusedInput === index}
|
||||||
|
onChange={onUpdateVariable}
|
||||||
|
onSubmit={handleInputSubmit}
|
||||||
|
borderColor={colors.border as string}
|
||||||
|
focusColor={colors.primary as string}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{validationError && (
|
||||||
|
<Box marginTop={1}>
|
||||||
|
<Text color={colors.error}>{validationError}</Text>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Box marginTop={1}>
|
||||||
|
<Text color={colors.textMuted}>
|
||||||
|
{helpText} ↑↓: Change field • Esc: Cancel
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -16,6 +16,7 @@ export type ImportStepType =
|
|||||||
| "fetch"
|
| "fetch"
|
||||||
| "preview"
|
| "preview"
|
||||||
| "role-select"
|
| "role-select"
|
||||||
|
| "variables"
|
||||||
| "inputs-select"
|
| "inputs-select"
|
||||||
| "review";
|
| "review";
|
||||||
|
|
||||||
@@ -30,6 +31,7 @@ export const IMPORT_STEPS: ImportStep[] = [
|
|||||||
{ name: "Fetch", type: "fetch" },
|
{ name: "Fetch", type: "fetch" },
|
||||||
{ name: "Preview", type: "preview" },
|
{ name: "Preview", type: "preview" },
|
||||||
{ name: "Select Role", type: "role-select" },
|
{ name: "Select Role", type: "role-select" },
|
||||||
|
{ name: "Variables", type: "variables" },
|
||||||
{ name: "Select Inputs", type: "inputs-select" },
|
{ name: "Select Inputs", type: "inputs-select" },
|
||||||
{ name: "Review", type: "review" },
|
{ name: "Review", type: "review" },
|
||||||
];
|
];
|
||||||
@@ -81,6 +83,24 @@ export interface RoleSelectStepProps {
|
|||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A single variable input required by the selected action role. */
|
||||||
|
export interface ImportVariableInput {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
type: string;
|
||||||
|
hint?: string;
|
||||||
|
value: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Props for VariablesStep — collects required role/action variable values. */
|
||||||
|
export interface VariablesStepProps {
|
||||||
|
variables: ImportVariableInput[];
|
||||||
|
onUpdateVariable: (index: number, value: string) => void;
|
||||||
|
onComplete: () => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
isActive: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
/** Props for InputsSelectStep — lets user pick UTXOs to fund the invitation. */
|
/** Props for InputsSelectStep — lets user pick UTXOs to fund the invitation. */
|
||||||
export interface InputsSelectStepProps {
|
export interface InputsSelectStepProps {
|
||||||
invitation: Invitation;
|
invitation: Invitation;
|
||||||
@@ -116,8 +136,12 @@ export interface ImportFlowProps {
|
|||||||
mode: ImportFlowMode;
|
mode: ImportFlowMode;
|
||||||
/** The application service — injected, not pulled from context. */
|
/** The application service — injected, not pulled from context. */
|
||||||
appService: AppService;
|
appService: AppService;
|
||||||
/** Called when the flow completes or is cancelled. */
|
/**
|
||||||
onClose: () => void;
|
* Called when the flow completes or is cancelled.
|
||||||
|
* When import succeeds, the invitation identifier is provided so callers can
|
||||||
|
* select/focus the imported invitation in their UI.
|
||||||
|
*/
|
||||||
|
onClose: (importedInvitationId?: string) => void;
|
||||||
/** Display an error message to the user. */
|
/** Display an error message to the user. */
|
||||||
showError: (message: string) => void;
|
showError: (message: string) => void;
|
||||||
/** Display an info message to the user. */
|
/** Display an info message to the user. */
|
||||||
|
|||||||
@@ -8,6 +8,42 @@ import { promisify } from "util";
|
|||||||
|
|
||||||
const execAsync = promisify(exec);
|
const execAsync = promisify(exec);
|
||||||
|
|
||||||
|
// Define a list of clipboard methods with their platform and command.
|
||||||
|
// The platform is a function that returns true if the method is available on the current platform.
|
||||||
|
// The command is a function that returns a promise that resolves to the result of the command.
|
||||||
|
const clipboardMethods = {
|
||||||
|
pbCopy: {
|
||||||
|
platform: (platform: string) => platform === "darwin",
|
||||||
|
command: async (text: string) =>
|
||||||
|
execAsync(`printf '%s' '${text}' | pbcopy`),
|
||||||
|
},
|
||||||
|
xclip: {
|
||||||
|
platform: (platform: string) => platform === "linux",
|
||||||
|
command: async (text: string) =>
|
||||||
|
execAsync(`printf '%s' '${text}' | xclip -selection clipboard`),
|
||||||
|
},
|
||||||
|
xsel: {
|
||||||
|
platform: (platform: string) => platform === "linux",
|
||||||
|
command: async (text: string) =>
|
||||||
|
execAsync(`printf '%s' '${text}' | xsel --clipboard --input`),
|
||||||
|
},
|
||||||
|
ssh: {
|
||||||
|
platform: (platform: string) => platform === "linux",
|
||||||
|
command: async (text: string) =>
|
||||||
|
process.stdout.write(
|
||||||
|
`\x1b]52;c;${Buffer.from(text, "utf-8").toString("base64")}\x07`,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
clip: {
|
||||||
|
platform: (platform: string) => platform === "windows",
|
||||||
|
command: async (text: string) => execAsync(`echo|set /p="${text}" | clip`),
|
||||||
|
},
|
||||||
|
clipboardy: {
|
||||||
|
platform: (platform: string) => platform === "windows",
|
||||||
|
command: async (text: string) => clipboardy.writeSync(text),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Attempts to copy text to clipboard using multiple methods.
|
* Attempts to copy text to clipboard using multiple methods.
|
||||||
* Tries native commands first (most reliable), then clipboardy as fallback.
|
* Tries native commands first (most reliable), then clipboardy as fallback.
|
||||||
@@ -21,46 +57,29 @@ export async function copyToClipboard(text: string): Promise<void> {
|
|||||||
// Escape the text for shell commands
|
// Escape the text for shell commands
|
||||||
const escapedText = text.replace(/'/g, "'\\''");
|
const escapedText = text.replace(/'/g, "'\\''");
|
||||||
|
|
||||||
// Try native commands first - they're more reliable
|
const availableMethods = Object.values(clipboardMethods).filter((method) =>
|
||||||
try {
|
method.platform(platform),
|
||||||
if (platform === "darwin") {
|
);
|
||||||
// macOS - use pbcopy directly
|
|
||||||
await execAsync(`printf '%s' '${escapedText}' | pbcopy`);
|
|
||||||
return;
|
|
||||||
} else if (platform === "linux") {
|
|
||||||
// Linux - try xclip, then xsel
|
|
||||||
try {
|
|
||||||
await execAsync(
|
|
||||||
`printf '%s' '${escapedText}' | xclip -selection clipboard`,
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
} catch {
|
|
||||||
try {
|
|
||||||
await execAsync(
|
|
||||||
`printf '%s' '${escapedText}' | xsel --clipboard --input`,
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
} catch {
|
|
||||||
// Fall through to clipboardy
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if (platform === "win32") {
|
|
||||||
// Windows - use clip.exe
|
|
||||||
await execAsync(`echo|set /p="${text}" | clip`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Native command failed, try clipboardy
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback to clipboardy
|
const errors: Error[] = [];
|
||||||
try {
|
|
||||||
clipboardy.writeSync(text);
|
for (const method of availableMethods) {
|
||||||
return;
|
try {
|
||||||
} catch {
|
if (method.platform(platform)) {
|
||||||
// clipboardy also failed
|
await method.command(escapedText);
|
||||||
|
} else {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Error) {
|
||||||
|
errors.push(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// All methods failed
|
// All methods failed
|
||||||
throw new Error(`Clipboard not available. Install xclip or xsel on Linux.`);
|
throw new Error(
|
||||||
|
`Clipboard not available. ${errors.map((error) => error.message).join("\n")}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
121
src/tui/utils/format-dialog-message.ts
Normal file
121
src/tui/utils/format-dialog-message.ts
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
/**
|
||||||
|
* Formats multi-line dialog messages for readable terminal display.
|
||||||
|
*
|
||||||
|
* Ink's `wrap="wrap"` breaks long lines mid-word, which looks broken for
|
||||||
|
* dot-separated template validation paths. We pre-split on newlines and break
|
||||||
|
* long lines at `.` segment boundaries instead.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hard-wraps text when a single segment still exceeds the maximum width.
|
||||||
|
*/
|
||||||
|
function hardWrapLine(line: string, maxWidth: number): string[] {
|
||||||
|
if (line.length <= maxWidth) {
|
||||||
|
return [line];
|
||||||
|
}
|
||||||
|
|
||||||
|
const wrapped: string[] = [];
|
||||||
|
let remaining = line;
|
||||||
|
|
||||||
|
while (remaining.length > maxWidth) {
|
||||||
|
wrapped.push(remaining.slice(0, maxWidth));
|
||||||
|
remaining = ` ${remaining.slice(maxWidth)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (remaining.length > 0) {
|
||||||
|
wrapped.push(remaining);
|
||||||
|
}
|
||||||
|
|
||||||
|
return wrapped;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Breaks a long line at dot-separated segments, indenting continuations.
|
||||||
|
*/
|
||||||
|
function breakLongLineAtDots(line: string, maxWidth: number): string[] {
|
||||||
|
const segments: string[] = [];
|
||||||
|
let segmentStart = 0;
|
||||||
|
|
||||||
|
for (let index = 0; index < line.length; index += 1) {
|
||||||
|
if (line[index] === "." && index > 0) {
|
||||||
|
segments.push(line.slice(segmentStart, index + 1));
|
||||||
|
segmentStart = index + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (segmentStart < line.length) {
|
||||||
|
segments.push(line.slice(segmentStart));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (segments.length === 0) {
|
||||||
|
return hardWrapLine(line, maxWidth);
|
||||||
|
}
|
||||||
|
|
||||||
|
const lines: string[] = [];
|
||||||
|
let current = "";
|
||||||
|
|
||||||
|
for (const segment of segments) {
|
||||||
|
const candidate = current + segment;
|
||||||
|
|
||||||
|
if (candidate.length > maxWidth && current.length > 0) {
|
||||||
|
lines.push(current);
|
||||||
|
current = ` ${segment}`;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (candidate.length > maxWidth) {
|
||||||
|
lines.push(...hardWrapLine(segment, maxWidth));
|
||||||
|
current = "";
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
current = candidate;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (current.length > 0) {
|
||||||
|
lines.push(current);
|
||||||
|
}
|
||||||
|
|
||||||
|
return lines;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Splits a dialog message into display lines that fit the available width.
|
||||||
|
*/
|
||||||
|
export function formatDialogMessageLines(
|
||||||
|
message: string,
|
||||||
|
contentWidth: number,
|
||||||
|
): string[] {
|
||||||
|
const output: string[] = [];
|
||||||
|
|
||||||
|
for (const rawLine of message.split("\n")) {
|
||||||
|
const line = rawLine.trimEnd();
|
||||||
|
if (line.length === 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (line.length <= contentWidth) {
|
||||||
|
output.push(line);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
output.push(...breakLongLineAtDots(line, contentWidth));
|
||||||
|
}
|
||||||
|
|
||||||
|
return output;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Computes dialog width from the terminal size.
|
||||||
|
*/
|
||||||
|
export function getMessageDialogWidth(terminalColumns: number): number {
|
||||||
|
return Math.min(Math.max(terminalColumns - 4, 60), 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Inner text width after dialog border and horizontal padding. */
|
||||||
|
export function getMessageContentWidth(dialogWidth: number): number {
|
||||||
|
return Math.max(dialogWidth - 6, 40);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Maximum number of body lines shown before truncating with a summary. */
|
||||||
|
export const MAX_MESSAGE_DIALOG_LINES = 24;
|
||||||
169
src/tui/utils/list-directory-entries.ts
Normal file
169
src/tui/utils/list-directory-entries.ts
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
/**
|
||||||
|
* Directory listing helpers for terminal file pickers.
|
||||||
|
*
|
||||||
|
* Uses synchronous filesystem APIs to match other TUI screens (e.g. SeedInput).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kind of entry shown in a file picker list.
|
||||||
|
*/
|
||||||
|
export type DirectoryEntryKind = "parent" | "directory" | "file";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A single row in a directory listing.
|
||||||
|
*/
|
||||||
|
export interface DirectoryEntry {
|
||||||
|
/** Display name (e.g. ".." or "foo.json"). */
|
||||||
|
name: string;
|
||||||
|
/** Absolute path on disk. */
|
||||||
|
absolutePath: string;
|
||||||
|
/** Whether this row navigates up, into a folder, or selects a file. */
|
||||||
|
kind: DirectoryEntryKind;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Options for {@link listDirectoryEntries}.
|
||||||
|
*/
|
||||||
|
export interface ListDirectoryEntriesOptions {
|
||||||
|
/**
|
||||||
|
* Allowed file extensions without a leading dot (e.g. `['json']`).
|
||||||
|
* When omitted or empty, all non-hidden files are included.
|
||||||
|
* Directories are always included regardless of this filter.
|
||||||
|
*/
|
||||||
|
extensions?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Result of listing a directory for the file picker.
|
||||||
|
*/
|
||||||
|
export interface ListDirectoryEntriesResult {
|
||||||
|
entries: DirectoryEntry[];
|
||||||
|
/** Set when the directory could not be read. */
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns true when the file extension matches one of the allowed extensions.
|
||||||
|
* Comparison is case-insensitive; extensions may be passed with or without a dot.
|
||||||
|
*/
|
||||||
|
function matchesExtension(
|
||||||
|
filename: string,
|
||||||
|
extensions: string[] | undefined,
|
||||||
|
): boolean {
|
||||||
|
if (extensions === undefined || extensions.length === 0) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fileExtension = path.extname(filename).slice(1).toLowerCase();
|
||||||
|
if (fileExtension.length === 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return extensions.some((extension) => {
|
||||||
|
const normalized = extension.startsWith(".")
|
||||||
|
? extension.slice(1)
|
||||||
|
: extension;
|
||||||
|
return normalized.toLowerCase() === fileExtension;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lists files and folders in `directory` for display in a terminal file picker.
|
||||||
|
*
|
||||||
|
* - Prepends `..` when not at the filesystem root.
|
||||||
|
* - Always shows subdirectories (except `.` and `..` from readdir).
|
||||||
|
* - Filters files by optional `extensions`.
|
||||||
|
* - Sort order: parent link first, then directories A→Z, then files A→Z.
|
||||||
|
* - Returns an empty list and `error` instead of throwing on permission or missing paths.
|
||||||
|
*/
|
||||||
|
export function listDirectoryEntries(
|
||||||
|
directory: string,
|
||||||
|
options: ListDirectoryEntriesOptions = {},
|
||||||
|
): ListDirectoryEntriesResult {
|
||||||
|
const resolvedDirectory = path.resolve(directory);
|
||||||
|
const { extensions } = options;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (!fs.existsSync(resolvedDirectory)) {
|
||||||
|
return {
|
||||||
|
entries: [],
|
||||||
|
error: `Directory does not exist: ${resolvedDirectory}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const directoryStat = fs.statSync(resolvedDirectory);
|
||||||
|
if (!directoryStat.isDirectory()) {
|
||||||
|
return {
|
||||||
|
entries: [],
|
||||||
|
error: `Not a directory: ${resolvedDirectory}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const entries: DirectoryEntry[] = [];
|
||||||
|
const parentDirectory = path.dirname(resolvedDirectory);
|
||||||
|
|
||||||
|
if (parentDirectory !== resolvedDirectory) {
|
||||||
|
entries.push({
|
||||||
|
name: "..",
|
||||||
|
absolutePath: parentDirectory,
|
||||||
|
kind: "parent",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const childNames = fs.readdirSync(resolvedDirectory);
|
||||||
|
const directories: DirectoryEntry[] = [];
|
||||||
|
const files: DirectoryEntry[] = [];
|
||||||
|
|
||||||
|
for (const name of childNames) {
|
||||||
|
if (name === "." || name === "..") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const absolutePath = path.join(resolvedDirectory, name);
|
||||||
|
|
||||||
|
let childStat: fs.Stats;
|
||||||
|
try {
|
||||||
|
childStat = fs.statSync(absolutePath);
|
||||||
|
} catch {
|
||||||
|
// Skip broken symlinks or entries we cannot stat.
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (childStat.isDirectory()) {
|
||||||
|
directories.push({
|
||||||
|
name,
|
||||||
|
absolutePath,
|
||||||
|
kind: "directory",
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (childStat.isFile() && matchesExtension(name, extensions)) {
|
||||||
|
files.push({
|
||||||
|
name,
|
||||||
|
absolutePath,
|
||||||
|
kind: "file",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const sortByName = (a: DirectoryEntry, b: DirectoryEntry): number =>
|
||||||
|
a.name.localeCompare(b.name, undefined, { sensitivity: "base" });
|
||||||
|
|
||||||
|
directories.sort(sortByName);
|
||||||
|
files.sort(sortByName);
|
||||||
|
|
||||||
|
return {
|
||||||
|
entries: [...entries, ...directories, ...files],
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
return {
|
||||||
|
entries: [],
|
||||||
|
error: `Unable to read directory: ${message}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
import type {
|
import type {
|
||||||
HistoryItem,
|
HistoryItem,
|
||||||
HistoryInvitationItem,
|
WalletHistoryInput,
|
||||||
HistoryUtxoItem,
|
WalletHistoryItem,
|
||||||
|
WalletHistoryOutput,
|
||||||
} from "../services/history.js";
|
} from "../services/history.js";
|
||||||
|
|
||||||
export type HistoryColorName =
|
export type HistoryColorName =
|
||||||
@@ -13,10 +14,9 @@ export type HistoryColorName =
|
|||||||
| "text";
|
| "text";
|
||||||
|
|
||||||
export type HistoryRowType =
|
export type HistoryRowType =
|
||||||
| "invitation"
|
| "history_item"
|
||||||
| "invitation_input"
|
| "history_input"
|
||||||
| "invitation_output"
|
| "history_output";
|
||||||
| "utxo";
|
|
||||||
|
|
||||||
export interface HistoryDisplayRow {
|
export interface HistoryDisplayRow {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -25,8 +25,11 @@ export interface HistoryDisplayRow {
|
|||||||
description?: string;
|
description?: string;
|
||||||
timestamp?: number;
|
timestamp?: number;
|
||||||
isNested: boolean;
|
isNested: boolean;
|
||||||
utxo?: HistoryUtxoItem;
|
valueSatoshis?: bigint;
|
||||||
invitation?: HistoryInvitationItem;
|
reserved?: boolean;
|
||||||
|
input?: WalletHistoryInput;
|
||||||
|
output?: WalletHistoryOutput;
|
||||||
|
item?: WalletHistoryItem;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatHistoryDate(timestamp?: number): string | undefined {
|
export function formatHistoryDate(timestamp?: number): string | undefined {
|
||||||
@@ -40,61 +43,68 @@ export function buildHistoryDisplayRows(
|
|||||||
const rows: HistoryDisplayRow[] = [];
|
const rows: HistoryDisplayRow[] = [];
|
||||||
|
|
||||||
for (const item of items) {
|
for (const item of items) {
|
||||||
if (item.kind === "invitation") {
|
const roles = item.roles.length > 0 ? item.roles.join(", ") : "unknown";
|
||||||
rows.push({
|
if (item.source === "utxo") {
|
||||||
id: item.id,
|
|
||||||
type: "invitation",
|
|
||||||
label: item.description,
|
|
||||||
timestamp: item.createdAtTimestamp,
|
|
||||||
isNested: false,
|
|
||||||
invitation: item,
|
|
||||||
});
|
|
||||||
|
|
||||||
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) {
|
for (const output of item.outputs) {
|
||||||
rows.push({
|
rows.push({
|
||||||
id: `${item.id}-output-${output.id}`,
|
id: `${item.id}-output-${output.id}`,
|
||||||
type: "invitation_output",
|
type: "history_output",
|
||||||
label:
|
label: output.outpoint
|
||||||
output.valueSatoshis !== undefined
|
? `${output.outpoint.txid}:${output.outpoint.index}`
|
||||||
? `${output.valueSatoshis.toLocaleString()} sats`
|
: (output.outputIdentifier ?? "Output"),
|
||||||
: "Output",
|
description: `${item.template} | ${roles} | ${output.description}`,
|
||||||
description: output.description,
|
timestamp: item.createdAtTimestamp,
|
||||||
isNested: true,
|
isNested: false,
|
||||||
utxo: output,
|
valueSatoshis: output.valueSatoshis,
|
||||||
invitation: item,
|
reserved: output.reserved,
|
||||||
|
output,
|
||||||
|
item,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
rows.push({
|
rows.push({
|
||||||
id: item.id,
|
id: item.id,
|
||||||
type: "utxo",
|
type: "history_item",
|
||||||
label:
|
label: `${item.template} | ${roles} | ${item.description}`,
|
||||||
item.valueSatoshis !== undefined
|
description: item.action,
|
||||||
? `${item.valueSatoshis.toLocaleString()} sats`
|
timestamp: item.createdAtTimestamp,
|
||||||
: "UTXO",
|
|
||||||
description: item.description,
|
|
||||||
isNested: false,
|
isNested: false,
|
||||||
utxo: item,
|
valueSatoshis: item.valueSatoshis,
|
||||||
|
item,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (item.source !== "invitation") continue;
|
||||||
|
|
||||||
|
for (const input of item.inputs) {
|
||||||
|
rows.push({
|
||||||
|
id: `${item.id}-input-${input.id}`,
|
||||||
|
type: "history_input",
|
||||||
|
label: `${input.outpoint.txid}:${input.outpoint.index}`,
|
||||||
|
description: input.description,
|
||||||
|
isNested: true,
|
||||||
|
valueSatoshis: input.valueSatoshis,
|
||||||
|
input,
|
||||||
|
item,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const output of item.outputs) {
|
||||||
|
rows.push({
|
||||||
|
id: `${item.id}-output-${output.id}`,
|
||||||
|
type: "history_output",
|
||||||
|
label: output.outpoint
|
||||||
|
? `${output.outpoint.txid}:${output.outpoint.index}`
|
||||||
|
: (output.outputIdentifier ?? "Output"),
|
||||||
|
description: output.description,
|
||||||
|
isNested: true,
|
||||||
|
valueSatoshis: output.valueSatoshis,
|
||||||
|
reserved: output.reserved,
|
||||||
|
output,
|
||||||
|
item,
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return rows;
|
return rows;
|
||||||
@@ -106,14 +116,14 @@ export function getHistoryItemColorName(
|
|||||||
): HistoryColorName {
|
): HistoryColorName {
|
||||||
if (isSelected) return "info";
|
if (isSelected) return "info";
|
||||||
switch (row.type) {
|
switch (row.type) {
|
||||||
case "invitation":
|
case "history_input":
|
||||||
return "text";
|
|
||||||
case "invitation_input":
|
|
||||||
return "error";
|
return "error";
|
||||||
case "invitation_output":
|
case "history_output":
|
||||||
return "success";
|
return row.reserved ? "warning" : "success";
|
||||||
case "utxo":
|
case "history_item":
|
||||||
return row.utxo?.reserved ? "warning" : "success";
|
if ((row.valueSatoshis ?? 0n) < 0n) return "error";
|
||||||
|
if ((row.valueSatoshis ?? 0n) > 0n) return "success";
|
||||||
|
return "text";
|
||||||
default:
|
default:
|
||||||
return "text";
|
return "text";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ export interface SelectableUtxoLike {
|
|||||||
selected: boolean;
|
selected: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TODO: Move to engine
|
||||||
export const hasMissingRequirements = (missingRequirements: {
|
export const hasMissingRequirements = (missingRequirements: {
|
||||||
variables?: string[];
|
variables?: string[];
|
||||||
inputs?: string[];
|
inputs?: string[];
|
||||||
@@ -29,9 +30,10 @@ export const isInvitationRequirementsComplete = async (
|
|||||||
invitation: Invitation,
|
invitation: Invitation,
|
||||||
): Promise<boolean> => {
|
): Promise<boolean> => {
|
||||||
const missingRequirements = await invitation.getMissingRequirements();
|
const missingRequirements = await invitation.getMissingRequirements();
|
||||||
return !hasMissingRequirements(missingRequirements);
|
return !hasMissingRequirements(missingRequirements.templateRequirements);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// TODO: Move to engine in templates.ts
|
||||||
export const resolveActionRoles = (
|
export const resolveActionRoles = (
|
||||||
template: XOTemplate | undefined,
|
template: XOTemplate | undefined,
|
||||||
actionIdentifier: string | undefined,
|
actionIdentifier: string | undefined,
|
||||||
@@ -45,11 +47,13 @@ export const resolveActionRoles = (
|
|||||||
const starts = template.start ?? [];
|
const starts = template.start ?? [];
|
||||||
const roleIds = starts
|
const roleIds = starts
|
||||||
.filter((entry) => entry.action === actionIdentifier)
|
.filter((entry) => entry.action === actionIdentifier)
|
||||||
.map((entry) => entry.role);
|
.map((entry) => entry.role)
|
||||||
|
.filter((roleId) => roleId !== undefined);
|
||||||
|
|
||||||
return [...new Set(roleIds)];
|
return [...new Set(roleIds)];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// TODO: Move to engine
|
||||||
export const roleRequiresInputs = (
|
export const roleRequiresInputs = (
|
||||||
template: XOTemplate | undefined,
|
template: XOTemplate | undefined,
|
||||||
actionIdentifier: string | undefined,
|
actionIdentifier: string | undefined,
|
||||||
@@ -60,17 +64,21 @@ export const roleRequiresInputs = (
|
|||||||
if (!action) return false;
|
if (!action) return false;
|
||||||
|
|
||||||
const actionRole = action.roles?.[roleIdentifier];
|
const actionRole = action.roles?.[roleIdentifier];
|
||||||
const roleSlotsMin = actionRole?.requirements?.slots?.min ?? 0;
|
const actionRequirements = action.requirements;
|
||||||
|
const actionRoleRequirements =
|
||||||
|
actionRole &&
|
||||||
|
actionRequirements &&
|
||||||
|
actionRequirements.participants?.find(
|
||||||
|
(participant) => participant.role === roleIdentifier,
|
||||||
|
);
|
||||||
|
const roleSlotsMin =
|
||||||
|
actionRoleRequirements &&
|
||||||
|
actionRoleRequirements.slots &&
|
||||||
|
actionRoleRequirements.slots.min > 0
|
||||||
|
? actionRoleRequirements.slots.min
|
||||||
|
: 0;
|
||||||
if (roleSlotsMin > 0) return true;
|
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 transactionIdentifier = action.transaction;
|
||||||
const transaction = transactionIdentifier
|
const transaction = transactionIdentifier
|
||||||
? template.transactions?.[transactionIdentifier]
|
? template.transactions?.[transactionIdentifier]
|
||||||
@@ -126,19 +134,20 @@ export const tryCashAddressToLockingBytecodeHex = (
|
|||||||
return binToHex(result.bytecode);
|
return binToHex(result.bytecode);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Replace with libauth compiler in the engine
|
||||||
export const resolveProvidedLockingBytecodeHex = (
|
export const resolveProvidedLockingBytecodeHex = (
|
||||||
template: XOTemplate,
|
template: XOTemplate,
|
||||||
outputIdentifier: string,
|
outputIdentifier: string,
|
||||||
variableValues: Record<string, string>,
|
variableValues: Record<string, string>,
|
||||||
): string | undefined => {
|
): string | undefined => {
|
||||||
const outputDefinition = template.outputs?.[outputIdentifier];
|
const outputDefinition = template.outputs?.[outputIdentifier];
|
||||||
if (!outputDefinition || typeof outputDefinition.lockscript !== "string")
|
if (!outputDefinition || typeof outputDefinition.lockingScript !== "string") {
|
||||||
return undefined;
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
const lockingScriptDefinition = (
|
const lockingScriptDefinition =
|
||||||
template.lockingScripts as Record<string, unknown> | undefined
|
template.lockingScripts?.[outputDefinition.lockingScript];
|
||||||
)?.[outputDefinition.lockscript] as { lockingScript?: string } | undefined;
|
const scriptIdentifier = lockingScriptDefinition?.lockingBytecode;
|
||||||
const scriptIdentifier = lockingScriptDefinition?.lockingScript;
|
|
||||||
if (!scriptIdentifier) return undefined;
|
if (!scriptIdentifier) return undefined;
|
||||||
|
|
||||||
const scriptExpression = (
|
const scriptExpression = (
|
||||||
|
|||||||
193
src/utils/load-template-from-file.ts
Normal file
193
src/utils/load-template-from-file.ts
Normal file
@@ -0,0 +1,193 @@
|
|||||||
|
/**
|
||||||
|
* Loads template file contents for {@link Engine.importTemplate}.
|
||||||
|
*
|
||||||
|
* - `.json` files are read directly.
|
||||||
|
* - `.ts`, `.js`, `.mts`, `.cts`, `.mjs`, `.cjs` files are evaluated in a
|
||||||
|
* short-lived child process and serialized to Extended JSON on stdout.
|
||||||
|
* TypeScript templates (and the loader in dev) run via tsx; plain JS uses node.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { spawn } from "node:child_process";
|
||||||
|
import { createRequire } from "node:module";
|
||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
|
/** Extensions loaded via subprocess module evaluation. */
|
||||||
|
const MODULE_TEMPLATE_EXTENSIONS = new Set([
|
||||||
|
".ts",
|
||||||
|
".tsx",
|
||||||
|
".mts",
|
||||||
|
".cts",
|
||||||
|
".js",
|
||||||
|
".jsx",
|
||||||
|
".mjs",
|
||||||
|
".cjs",
|
||||||
|
]);
|
||||||
|
|
||||||
|
/** Maximum time allowed for a template module child process. */
|
||||||
|
const MODULE_LOAD_TIMEOUT_MS = 30_000;
|
||||||
|
|
||||||
|
/** Maximum stdout size from the loader child (50 MiB). */
|
||||||
|
const MODULE_LOAD_MAX_BUFFER_BYTES = 50 * 1024 * 1024;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Thrown when a template file cannot be read or loaded.
|
||||||
|
*/
|
||||||
|
export class TemplateLoadError extends Error {
|
||||||
|
constructor(message: string) {
|
||||||
|
super(message);
|
||||||
|
this.name = "TemplateLoadError";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves the tsx CLI binary shipped with this package.
|
||||||
|
*/
|
||||||
|
function resolveTsxCliPath(): string {
|
||||||
|
const require = createRequire(import.meta.url);
|
||||||
|
const tsxPackageJsonPath = require.resolve("tsx/package.json");
|
||||||
|
return path.join(path.dirname(tsxPackageJsonPath), "dist/cli.mjs");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves the loader script path for dev (.ts) and production (.js) layouts.
|
||||||
|
*/
|
||||||
|
function resolveTemplateModuleLoaderPath(): string {
|
||||||
|
const directory = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
const compiledLoaderPath = path.join(directory, "template-module-loader.js");
|
||||||
|
if (fs.existsSync(compiledLoaderPath)) {
|
||||||
|
return compiledLoaderPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sourceLoaderPath = path.join(directory, "template-module-loader.ts");
|
||||||
|
if (fs.existsSync(sourceLoaderPath)) {
|
||||||
|
return sourceLoaderPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new TemplateLoadError(
|
||||||
|
"Template module loader script was not found in the xo-cli package.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** TypeScript extensions that require tsx to evaluate the template module. */
|
||||||
|
const TYPESCRIPT_TEMPLATE_EXTENSIONS = new Set([".ts", ".tsx", ".mts", ".cts"]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loads a TS/JS template module in an isolated child process.
|
||||||
|
* Returns Extended JSON suitable for {@link parseTemplate}.
|
||||||
|
*/
|
||||||
|
async function loadTemplateModuleViaChildProcess(
|
||||||
|
absolutePath: string,
|
||||||
|
): Promise<string> {
|
||||||
|
const loaderPath = resolveTemplateModuleLoaderPath();
|
||||||
|
const extension = path.extname(absolutePath).toLowerCase();
|
||||||
|
const loaderIsTypeScript = loaderPath.endsWith(".ts");
|
||||||
|
const useTsx =
|
||||||
|
TYPESCRIPT_TEMPLATE_EXTENSIONS.has(extension) || loaderIsTypeScript;
|
||||||
|
const executable = useTsx ? resolveTsxCliPath() : process.execPath;
|
||||||
|
const args = [loaderPath, absolutePath];
|
||||||
|
|
||||||
|
return new Promise<string>((resolve, reject) => {
|
||||||
|
const child = spawn(executable, args, {
|
||||||
|
cwd: path.dirname(absolutePath),
|
||||||
|
stdio: ["ignore", "pipe", "pipe"],
|
||||||
|
env: process.env,
|
||||||
|
});
|
||||||
|
|
||||||
|
let stdout = "";
|
||||||
|
let stderr = "";
|
||||||
|
let stdoutBytes = 0;
|
||||||
|
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
child.kill("SIGTERM");
|
||||||
|
reject(
|
||||||
|
new TemplateLoadError(
|
||||||
|
`Template module load timed out after ${MODULE_LOAD_TIMEOUT_MS / 1000}s`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}, MODULE_LOAD_TIMEOUT_MS);
|
||||||
|
|
||||||
|
child.stdout.on("data", (chunk: Buffer | string) => {
|
||||||
|
const text = typeof chunk === "string" ? chunk : chunk.toString("utf8");
|
||||||
|
stdoutBytes += Buffer.byteLength(text, "utf8");
|
||||||
|
if (stdoutBytes > MODULE_LOAD_MAX_BUFFER_BYTES) {
|
||||||
|
child.kill("SIGTERM");
|
||||||
|
reject(
|
||||||
|
new TemplateLoadError(
|
||||||
|
"Template module output exceeded the maximum allowed size.",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
stdout += text;
|
||||||
|
});
|
||||||
|
|
||||||
|
child.stderr.on("data", (chunk: Buffer | string) => {
|
||||||
|
stderr += typeof chunk === "string" ? chunk : chunk.toString("utf8");
|
||||||
|
});
|
||||||
|
|
||||||
|
child.on("error", (error) => {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
reject(
|
||||||
|
new TemplateLoadError(
|
||||||
|
`Failed to start template module loader: ${error.message}`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
child.on("close", (code) => {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
|
||||||
|
if (code !== 0) {
|
||||||
|
reject(
|
||||||
|
new TemplateLoadError(
|
||||||
|
stderr.trim() ||
|
||||||
|
`Template module loader exited with code ${code ?? "unknown"}`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stdout.trim().length === 0) {
|
||||||
|
reject(
|
||||||
|
new TemplateLoadError("Template module loader returned no output."),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
resolve(stdout);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loads template contents from disk.
|
||||||
|
*
|
||||||
|
* @param filePath - Absolute or relative path to a JSON or module template file.
|
||||||
|
* @returns Extended JSON string for {@link Engine.importTemplate}.
|
||||||
|
*/
|
||||||
|
export async function loadTemplateFromFile(filePath: string): Promise<string> {
|
||||||
|
const absolutePath = path.resolve(filePath);
|
||||||
|
|
||||||
|
if (!fs.existsSync(absolutePath)) {
|
||||||
|
throw new TemplateLoadError(
|
||||||
|
`Template file does not exist: ${absolutePath}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const extension = path.extname(absolutePath).toLowerCase();
|
||||||
|
|
||||||
|
if (extension === ".json") {
|
||||||
|
return fs.promises.readFile(absolutePath, "utf8");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (MODULE_TEMPLATE_EXTENSIONS.has(extension)) {
|
||||||
|
return loadTemplateModuleViaChildProcess(absolutePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new TemplateLoadError(
|
||||||
|
`Unsupported template file extension "${extension}". ` +
|
||||||
|
"Use .json or a JavaScript/TypeScript module that exports an XOTemplate.",
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
export class Logger {
|
|
||||||
constructor(
|
|
||||||
private readonly endpoint: string,
|
|
||||||
private readonly token: string,
|
|
||||||
private readonly path: string,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
send(
|
|
||||||
level: "log" | "error" | "warn" | "info",
|
|
||||||
message: string,
|
|
||||||
...metadata: unknown[]
|
|
||||||
) {
|
|
||||||
const data = {
|
|
||||||
level,
|
|
||||||
message: `${this.path}: ${message}`,
|
|
||||||
metadata,
|
|
||||||
};
|
|
||||||
|
|
||||||
fetch(`${this.endpoint}`, {
|
|
||||||
method: "POST",
|
|
||||||
body: JSON.stringify(data),
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
"x-api-key": this.token,
|
|
||||||
},
|
|
||||||
}).catch((error) => {
|
|
||||||
console.error("Failed to send log to logger:", error);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
log(message: string, ...metadata: unknown[]) {
|
|
||||||
this.send("log", message, ...metadata);
|
|
||||||
}
|
|
||||||
|
|
||||||
error(message: string, ...metadata: unknown[]) {
|
|
||||||
this.send("error", message, ...metadata);
|
|
||||||
}
|
|
||||||
|
|
||||||
warn(message: string, ...metadata: unknown[]) {
|
|
||||||
this.send("warn", message, ...metadata);
|
|
||||||
}
|
|
||||||
|
|
||||||
info(message: string, ...metadata: unknown[]) {
|
|
||||||
this.send("info", message, ...metadata);
|
|
||||||
}
|
|
||||||
|
|
||||||
child(path: string): Logger {
|
|
||||||
return new Logger(this.endpoint, this.token, `${this.path}.${path}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
/**
|
/**
|
||||||
* Global XO CLI config layout (XDG-style: ~/.config/xo-cli/).
|
* Global XO CLI config layout (`XO_CONFIG_DIR` or ~/.config/xo-cli/).
|
||||||
* User-provided paths (templates, invitation JSON) stay relative to cwd.
|
* User-provided paths (templates, invitation JSON) stay relative to cwd.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@@ -11,7 +11,8 @@ import { basename, isAbsolute, join, resolve } from "node:path";
|
|||||||
* Base config directory. Created on first access.
|
* Base config directory. Created on first access.
|
||||||
*/
|
*/
|
||||||
export function getConfigDir(): string {
|
export function getConfigDir(): string {
|
||||||
const dir = join(homedir(), ".config", "xo-cli");
|
const dir =
|
||||||
|
process.env["XO_CONFIG_DIR"] || join(homedir(), ".config", "xo-cli");
|
||||||
mkdirSync(dir, { recursive: true });
|
mkdirSync(dir, { recursive: true });
|
||||||
return dir;
|
return dir;
|
||||||
}
|
}
|
||||||
@@ -35,15 +36,22 @@ export function getDataDir(): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* File storing the last-used mnemonic reference for `-m` omission.
|
* File storing CLI settings (JSON), including the last-used mnemonic reference.
|
||||||
*/
|
*/
|
||||||
export function getWalletConfigPath(): string {
|
export function getSettingsPath(): string {
|
||||||
return join(getConfigDir(), ".wallet");
|
return join(getConfigDir(), ".wallet");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @deprecated Prefer {@link getSettingsPath}.
|
||||||
|
*/
|
||||||
|
export function getWalletConfigPath(): string {
|
||||||
|
return getSettingsPath();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolves a mnemonic reference to an absolute path.
|
* Resolves a mnemonic reference to an absolute path.
|
||||||
* Order: absolute path if it exists → path relative to cwd → ~/.config/xo-cli/mnemonics/<basename>.
|
* Order: absolute path if it exists → path relative to cwd → config mnemonics directory/<basename>.
|
||||||
*
|
*
|
||||||
* @param mnemonicRef - Path or basename (e.g. `mnemonic-nuclear`)
|
* @param mnemonicRef - Path or basename (e.g. `mnemonic-nuclear`)
|
||||||
* @returns Absolute path to the mnemonic file
|
* @returns Absolute path to the mnemonic file
|
||||||
|
|||||||
66
src/utils/pick-template-export.ts
Normal file
66
src/utils/pick-template-export.ts
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
/**
|
||||||
|
* Helpers for finding an {@link XOTemplate} export in a loaded ES module.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns true when `value` looks like an XOTemplate object (pre-schema check).
|
||||||
|
* Used only to pick the correct export before {@link parseTemplate} validates fully.
|
||||||
|
*/
|
||||||
|
export function isTemplateLike(
|
||||||
|
value: unknown,
|
||||||
|
): value is Record<string, unknown> {
|
||||||
|
if (value === null || typeof value !== "object" || Array.isArray(value)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const candidate = value as Record<string, unknown>;
|
||||||
|
return (
|
||||||
|
typeof candidate.$schema === "string" &&
|
||||||
|
typeof candidate.name === "string" &&
|
||||||
|
typeof candidate.roles === "object" &&
|
||||||
|
candidate.roles !== null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Picks the single XOTemplate export from a dynamically loaded module.
|
||||||
|
*
|
||||||
|
* Resolution order:
|
||||||
|
* 1. `default` export, when template-like
|
||||||
|
* 2. Exactly one named template-like export
|
||||||
|
*
|
||||||
|
* @throws When no template export exists or multiple template exports are found.
|
||||||
|
*/
|
||||||
|
export function pickTemplateExport(
|
||||||
|
moduleExports: Record<string, unknown>,
|
||||||
|
): Record<string, unknown> {
|
||||||
|
const defaultExport = moduleExports.default;
|
||||||
|
if (isTemplateLike(defaultExport)) {
|
||||||
|
return defaultExport;
|
||||||
|
}
|
||||||
|
|
||||||
|
const namedTemplateExports = Object.entries(moduleExports).filter(
|
||||||
|
([exportName, exportValue]) =>
|
||||||
|
exportName !== "default" && isTemplateLike(exportValue),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (namedTemplateExports.length === 1) {
|
||||||
|
const [, exportValue] = namedTemplateExports[0]!;
|
||||||
|
if (!isTemplateLike(exportValue)) {
|
||||||
|
throw new Error("No XOTemplate export found.");
|
||||||
|
}
|
||||||
|
return exportValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (namedTemplateExports.length > 1) {
|
||||||
|
const exportNames = namedTemplateExports.map(([name]) => name).join(", ");
|
||||||
|
throw new Error(
|
||||||
|
`Multiple template exports found (${exportNames}). ` +
|
||||||
|
"Use a single named export or a default export.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(
|
||||||
|
"No XOTemplate export found. Export a template object as `default` or a named export.",
|
||||||
|
);
|
||||||
|
}
|
||||||
72
src/utils/rates/base-rates.ts
Normal file
72
src/utils/rates/base-rates.ts
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
import { EventEmitter } from "../event-emitter.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Events emitted by our Rates Adapters
|
||||||
|
*/
|
||||||
|
export type RatesEventMap = {
|
||||||
|
rateUpdated: {
|
||||||
|
numeratorUnitCode: string;
|
||||||
|
denominatorUnitCode: string;
|
||||||
|
price: number;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export abstract class BaseRates<
|
||||||
|
T extends RatesEventMap = RatesEventMap,
|
||||||
|
> extends EventEmitter<T> {
|
||||||
|
/** Starts the given rates adapter so that it will emit events on price updates. */
|
||||||
|
public abstract start(): Promise<void>;
|
||||||
|
|
||||||
|
/** Stops the given rates adapter so that it will stop checking for price updates. */
|
||||||
|
public abstract stop(): Promise<void>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List all available market products (pairs).
|
||||||
|
* @returns A set of strings in the format "NUMERATOR/DENOMINATOR"
|
||||||
|
*/
|
||||||
|
public abstract listPairs(): Promise<Set<string>>;
|
||||||
|
|
||||||
|
// TODO: Consider whether we actually want the below.
|
||||||
|
// Ideally, we will want to replace this with something like the Units class:
|
||||||
|
// See: https://gitlab.com/GeneralProtocols/xo/stack/-/issues/44
|
||||||
|
/**
|
||||||
|
* Format the amount in the target currency to the correct number of decimal places.
|
||||||
|
*
|
||||||
|
* @param {number} amount - The amount to format.
|
||||||
|
* @param {string} targetCurrency - The target currency.
|
||||||
|
*
|
||||||
|
* @returns The formatted amount.
|
||||||
|
*/
|
||||||
|
public formatCurrency(amount: number, targetCurrency: string): string {
|
||||||
|
const normalizedCurrency = targetCurrency.toUpperCase();
|
||||||
|
const minimumFractionDigitsMap: { [currency: string]: number } = {
|
||||||
|
AUD: 2,
|
||||||
|
BCH: 8,
|
||||||
|
USD: 2,
|
||||||
|
};
|
||||||
|
const minimumFractionDigits =
|
||||||
|
minimumFractionDigitsMap[normalizedCurrency] ?? 2;
|
||||||
|
const maximumFractionDigits = Math.max(minimumFractionDigits, 8);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const formatter = new Intl.NumberFormat("en-US", {
|
||||||
|
style: "currency",
|
||||||
|
currency: normalizedCurrency,
|
||||||
|
currencyDisplay: "narrowSymbol",
|
||||||
|
minimumFractionDigits,
|
||||||
|
maximumFractionDigits,
|
||||||
|
});
|
||||||
|
|
||||||
|
return formatter.format(amount);
|
||||||
|
} catch {
|
||||||
|
// Some numerator symbols from oracle pairs (e.g. DOGE/BCH) are not ISO-4217
|
||||||
|
// fiat currency codes, so Intl currency formatting will throw a RangeError.
|
||||||
|
// In that case we still return a human-readable formatted value.
|
||||||
|
const numericFormatter = new Intl.NumberFormat("en-US", {
|
||||||
|
minimumFractionDigits,
|
||||||
|
maximumFractionDigits,
|
||||||
|
});
|
||||||
|
return `${numericFormatter.format(amount)} ${normalizedCurrency}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
234
src/utils/rates/rates-oracles.ts
Normal file
234
src/utils/rates/rates-oracles.ts
Normal file
@@ -0,0 +1,234 @@
|
|||||||
|
import {
|
||||||
|
OracleClient,
|
||||||
|
OracleMetadataMessage,
|
||||||
|
OraclePriceMessage,
|
||||||
|
type OracleMetadataMap,
|
||||||
|
} from "@generalprotocols/oracle-client";
|
||||||
|
|
||||||
|
import { type RatesEventMap, BaseRates } from "./base-rates.js";
|
||||||
|
import { type OffCallback } from "../event-emitter.js";
|
||||||
|
import { SettingsService } from "../../services/settings.js";
|
||||||
|
|
||||||
|
// Add the Oracle Price Message to our Events for this Adapter.
|
||||||
|
export type RatesOracleEventMap = RatesEventMap & {
|
||||||
|
rateUpdated: {
|
||||||
|
oraclePriceMessage: OraclePriceMessage;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// TODO: Add RatesHistorical trait since Oracles can provide historical rates.
|
||||||
|
export class RatesOracle extends BaseRates<RatesOracleEventMap> {
|
||||||
|
/**
|
||||||
|
* Create a new rates oracle.
|
||||||
|
*
|
||||||
|
* @param client The underlying oracle client. If not provided, a new client will be created.
|
||||||
|
* @returns The rates oracle.
|
||||||
|
*/
|
||||||
|
static async from(
|
||||||
|
client?: OracleClient,
|
||||||
|
settings: SettingsService = new SettingsService(),
|
||||||
|
) {
|
||||||
|
const ratesOracle = new RatesOracle(
|
||||||
|
client ?? (await OracleClient.from()),
|
||||||
|
settings,
|
||||||
|
);
|
||||||
|
|
||||||
|
return ratesOracle;
|
||||||
|
}
|
||||||
|
|
||||||
|
private client: OracleClient;
|
||||||
|
private settings: SettingsService;
|
||||||
|
private oracles: OracleMetadataMap;
|
||||||
|
|
||||||
|
private started: boolean = false;
|
||||||
|
private targetNumeratorUnitCode: string;
|
||||||
|
private targetDenominatorUnitCode: string = "BCH";
|
||||||
|
private unsubscribeFromSettings: OffCallback | null = null;
|
||||||
|
|
||||||
|
public constructor(client: OracleClient, settings: SettingsService) {
|
||||||
|
super();
|
||||||
|
|
||||||
|
this.client = client;
|
||||||
|
this.settings = settings;
|
||||||
|
this.oracles = {};
|
||||||
|
this.targetNumeratorUnitCode = settings.getCurrency().toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start the rates oracle and the underlying client.
|
||||||
|
*/
|
||||||
|
async start() {
|
||||||
|
if (this.started) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.started = true;
|
||||||
|
this.unsubscribeFromSettings = this.settings.on(
|
||||||
|
"settings-updated",
|
||||||
|
this.handleSettingsUpdated.bind(this),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Create event listeners for the client.
|
||||||
|
this.client.setOnMetadataMessage(this.handleMetadataMessage.bind(this));
|
||||||
|
this.client.setOnPriceMessage(this.handlePriceMessage.bind(this));
|
||||||
|
|
||||||
|
// Get the metadata for the client.
|
||||||
|
this.oracles = await this.client.getMetadataMap();
|
||||||
|
|
||||||
|
// Start the client.
|
||||||
|
await this.client.start();
|
||||||
|
|
||||||
|
// Refresh the prices.
|
||||||
|
await this.refreshPrices();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stop the rates oracle and the underlying client.
|
||||||
|
*/
|
||||||
|
async stop() {
|
||||||
|
if (!this.started) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.started = false;
|
||||||
|
this.unsubscribeFromSettings?.();
|
||||||
|
this.unsubscribeFromSettings = null;
|
||||||
|
|
||||||
|
// Remove event listeners by setting them to empty functions.
|
||||||
|
this.client.setOnMetadataMessage(() => {});
|
||||||
|
this.client.setOnPriceMessage(() => {});
|
||||||
|
|
||||||
|
await this.client.stop();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List the pairs that we are tracking.
|
||||||
|
*
|
||||||
|
* @returns A set of pairs.
|
||||||
|
*/
|
||||||
|
async listPairs() {
|
||||||
|
// If metadata has not arrived yet but the client is running, query once so
|
||||||
|
// callers (like the currency picker) can still discover available pairs.
|
||||||
|
if (Object.keys(this.oracles).length === 0 && this.started) {
|
||||||
|
this.oracles = await this.client.getMetadataMap();
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Set(
|
||||||
|
Object.values(this.oracles).map((oracle) => {
|
||||||
|
return `${oracle.SOURCE_NUMERATOR_UNIT_CODE}/${oracle.SOURCE_DENOMINATOR_UNIT_CODE}`;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the latest prices for all the pairs and emit a rate updated event for each.
|
||||||
|
*/
|
||||||
|
public async refreshPrices() {
|
||||||
|
const oracles = await this.client.getOracles();
|
||||||
|
|
||||||
|
// For each oracle, get the lastest dataSequence (price) message and emit a rate updated event.
|
||||||
|
await Promise.allSettled(
|
||||||
|
oracles.map(async (oracle) => {
|
||||||
|
try {
|
||||||
|
const messages = await this.client.getOracleMessages({
|
||||||
|
publicKey: oracle.publicKey,
|
||||||
|
minDataSequence: 1,
|
||||||
|
count: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
// We are only expecting a single message back. Just in case, we take the latest one.
|
||||||
|
const message = messages.reduce((latest, msg) => {
|
||||||
|
if (
|
||||||
|
msg instanceof OraclePriceMessage &&
|
||||||
|
msg.messageSequence > (latest?.messageSequence ?? 0)
|
||||||
|
) {
|
||||||
|
return msg;
|
||||||
|
}
|
||||||
|
return latest;
|
||||||
|
}, messages[0]);
|
||||||
|
|
||||||
|
// If the message is a price message, handle it.
|
||||||
|
if (message instanceof OraclePriceMessage) {
|
||||||
|
this.handlePriceMessage(message);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(
|
||||||
|
"Error refreshing prices for oracle:",
|
||||||
|
oracle.publicKey,
|
||||||
|
error,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update the metadata map that we use to track the pairs.
|
||||||
|
*
|
||||||
|
* @param message The metadata message.
|
||||||
|
*/
|
||||||
|
private handleMetadataMessage(message: OracleMetadataMessage) {
|
||||||
|
this.oracles = OracleClient.updateMetadataMap(this.oracles, message);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Emit a rate updated event for the given pair.
|
||||||
|
*
|
||||||
|
* @param message The price message.
|
||||||
|
*/
|
||||||
|
private handlePriceMessage(message: OraclePriceMessage) {
|
||||||
|
const oracle = this.oracles[message.toHexObject().publicKey];
|
||||||
|
|
||||||
|
// If the oracle doesn't have the required metadata, we can't use it.
|
||||||
|
if (
|
||||||
|
!oracle ||
|
||||||
|
!oracle.SOURCE_NUMERATOR_UNIT_CODE ||
|
||||||
|
!oracle.SOURCE_DENOMINATOR_UNIT_CODE ||
|
||||||
|
!oracle.ATTESTATION_SCALING
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sourceNumeratorUnitCode =
|
||||||
|
oracle.SOURCE_NUMERATOR_UNIT_CODE.toUpperCase();
|
||||||
|
const sourceDenominatorUnitCode =
|
||||||
|
oracle.SOURCE_DENOMINATOR_UNIT_CODE.toUpperCase();
|
||||||
|
|
||||||
|
// Only emit the pair currently selected in settings.
|
||||||
|
if (
|
||||||
|
sourceNumeratorUnitCode !== this.targetNumeratorUnitCode ||
|
||||||
|
sourceDenominatorUnitCode !== this.targetDenominatorUnitCode
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scale the price
|
||||||
|
const priceValue = message.priceValue / oracle.ATTESTATION_SCALING;
|
||||||
|
|
||||||
|
this.emit("rateUpdated", {
|
||||||
|
numeratorUnitCode: sourceNumeratorUnitCode,
|
||||||
|
denominatorUnitCode: sourceDenominatorUnitCode,
|
||||||
|
price: priceValue,
|
||||||
|
oraclePriceMessage: message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tracks updates to settings and switches the actively emitted fiat pair.
|
||||||
|
*/
|
||||||
|
private handleSettingsUpdated(event: {
|
||||||
|
key: "currency" | "default-mnemonic";
|
||||||
|
value: string | undefined;
|
||||||
|
}) {
|
||||||
|
if (event.key !== "currency" || !event.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.targetNumeratorUnitCode = event.value.toUpperCase();
|
||||||
|
|
||||||
|
// Refresh so listeners get the latest value for the new currency quickly.
|
||||||
|
if (this.started) {
|
||||||
|
this.refreshPrices().catch((error) => {
|
||||||
|
console.error("Error refreshing prices after currency update:", error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
452
src/utils/resolve-invitation-data.ts
Normal file
452
src/utils/resolve-invitation-data.ts
Normal file
@@ -0,0 +1,452 @@
|
|||||||
|
/**
|
||||||
|
* Transforms a raw XO invitation into a flattened, template-enriched structure
|
||||||
|
* suitable for UI display without manually resolving template references.
|
||||||
|
*
|
||||||
|
* The original invitation format is unchanged in storage and transport; this
|
||||||
|
* function produces a read model that merges commit data with template metadata
|
||||||
|
* (names, descriptions, icons, roles, etc.).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { mergeInvitationCommits } from "@xo-cash/engine";
|
||||||
|
import { binToHex } from "@bitauth/libauth";
|
||||||
|
import type {
|
||||||
|
XOInvitation,
|
||||||
|
XOInvitationCommit,
|
||||||
|
XOInvitationInput,
|
||||||
|
XOInvitationOutput,
|
||||||
|
XOInvitationVariable,
|
||||||
|
XOInvitationVariableValue,
|
||||||
|
XOTemplate,
|
||||||
|
XOTemplateInput,
|
||||||
|
XOTemplateOutput,
|
||||||
|
XOTemplateVariable,
|
||||||
|
} from "@xo-cash/types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A variable from invitation commits enriched with its template definition.
|
||||||
|
*/
|
||||||
|
export interface ResolvedInvitationVariable {
|
||||||
|
entityIdentifier: string;
|
||||||
|
variableIdentifier: string;
|
||||||
|
roleIdentifier?: string;
|
||||||
|
value: XOInvitationVariableValue;
|
||||||
|
name?: string;
|
||||||
|
description?: string;
|
||||||
|
type?: string;
|
||||||
|
hint?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A transaction input from invitation commits enriched with its template definition.
|
||||||
|
*/
|
||||||
|
export type ResolvedInvitationInput = XOInvitationInput & {
|
||||||
|
entityIdentifier: string;
|
||||||
|
name?: string;
|
||||||
|
description?: string;
|
||||||
|
icon?: string;
|
||||||
|
unlockingScript?: string;
|
||||||
|
omitChangeAmounts?: XOTemplateInput["omitChangeAmounts"];
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A transaction output from invitation commits enriched with its template definition.
|
||||||
|
*/
|
||||||
|
export type ResolvedInvitationOutput = XOInvitationOutput & {
|
||||||
|
entityIdentifier: string;
|
||||||
|
name?: string;
|
||||||
|
description?: string;
|
||||||
|
icon?: string;
|
||||||
|
roles?: Record<
|
||||||
|
string,
|
||||||
|
{ name?: string; description?: string; icon?: string }
|
||||||
|
>;
|
||||||
|
lockingScript?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Flattened, template-enriched invitation data for UI consumption.
|
||||||
|
*/
|
||||||
|
export interface ResolvedInvitationData {
|
||||||
|
invitationIdentifier: string;
|
||||||
|
templateIdentifier: string;
|
||||||
|
actionIdentifier: string;
|
||||||
|
variables: ResolvedInvitationVariable[];
|
||||||
|
inputs: ResolvedInvitationInput[];
|
||||||
|
outputs: ResolvedInvitationOutput[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Picks human-readable view fields from a template definition.
|
||||||
|
*/
|
||||||
|
export const pickTemplateViewMetadata = (definition?: {
|
||||||
|
name?: string;
|
||||||
|
description?: string;
|
||||||
|
icon?: string;
|
||||||
|
}) => {
|
||||||
|
if (!definition) return {};
|
||||||
|
|
||||||
|
// Only copy fields that are present so absent template metadata does not
|
||||||
|
// overwrite committed values when this object is spread onto a commit row.
|
||||||
|
return {
|
||||||
|
...(definition.name !== undefined && { name: definition.name }),
|
||||||
|
...(definition.description !== undefined && {
|
||||||
|
description: definition.description,
|
||||||
|
}),
|
||||||
|
...(definition.icon !== undefined && { icon: definition.icon }),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Picks variable metadata from a template variable definition.
|
||||||
|
*/
|
||||||
|
export const pickTemplateVariableMetadata = (
|
||||||
|
definition?: XOTemplateVariable,
|
||||||
|
) => {
|
||||||
|
if (!definition) return {};
|
||||||
|
|
||||||
|
return {
|
||||||
|
...pickTemplateViewMetadata(definition),
|
||||||
|
...(definition.type !== undefined && { type: definition.type }),
|
||||||
|
...(definition.hint !== undefined && { hint: definition.hint }),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Picks input metadata from a template input definition.
|
||||||
|
*/
|
||||||
|
export const pickTemplateInputMetadata = (definition?: XOTemplateInput) => {
|
||||||
|
if (!definition) return {};
|
||||||
|
|
||||||
|
return {
|
||||||
|
...pickTemplateViewMetadata(definition),
|
||||||
|
...(definition.unlockingScript !== undefined && {
|
||||||
|
unlockingScript: definition.unlockingScript,
|
||||||
|
}),
|
||||||
|
...(definition.omitChangeAmounts !== undefined && {
|
||||||
|
omitChangeAmounts: definition.omitChangeAmounts,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Picks output metadata from a template output definition.
|
||||||
|
*
|
||||||
|
* Committed output values (e.g. lockingBytecode) take precedence over template
|
||||||
|
* defaults; display-oriented fields like name, description, and template
|
||||||
|
* valueSatoshis expressions are layered on for UI rendering.
|
||||||
|
*/
|
||||||
|
export const pickTemplateOutputMetadata = (definition?: XOTemplateOutput) => {
|
||||||
|
if (!definition) return {};
|
||||||
|
|
||||||
|
const roles = definition.roles
|
||||||
|
? Object.fromEntries(
|
||||||
|
Object.entries(definition.roles).map(([roleId, roleDefinition]) => [
|
||||||
|
roleId,
|
||||||
|
pickTemplateViewMetadata(roleDefinition),
|
||||||
|
]),
|
||||||
|
)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...pickTemplateViewMetadata(definition),
|
||||||
|
...(roles !== undefined && Object.keys(roles).length > 0 && { roles }),
|
||||||
|
...(definition.lockingScript !== undefined && {
|
||||||
|
lockingScript: definition.lockingScript,
|
||||||
|
}),
|
||||||
|
// Keep CashAssembly expressions (e.g. "$(<totalSatoshis>)") for UI compilation;
|
||||||
|
// committed bigint values on the output row take precedence when spread later.
|
||||||
|
...(definition.valueSatoshis !== undefined && {
|
||||||
|
valueSatoshis: definition.valueSatoshis,
|
||||||
|
}),
|
||||||
|
...(definition.token !== undefined && { token: definition.token }),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enriches a committed variable with its template definition.
|
||||||
|
*/
|
||||||
|
export const resolveVariable = (
|
||||||
|
variable: XOInvitationVariable,
|
||||||
|
entityIdentifier: string,
|
||||||
|
template: XOTemplate,
|
||||||
|
): ResolvedInvitationVariable => ({
|
||||||
|
entityIdentifier,
|
||||||
|
variableIdentifier: variable.variableIdentifier,
|
||||||
|
...(variable.roleIdentifier !== undefined && {
|
||||||
|
roleIdentifier: variable.roleIdentifier,
|
||||||
|
}),
|
||||||
|
value: variable.value,
|
||||||
|
...pickTemplateVariableMetadata(
|
||||||
|
template.variables?.[variable.variableIdentifier],
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enriches a committed input with its template definition when an identifier is present.
|
||||||
|
*/
|
||||||
|
export const resolveInput = (
|
||||||
|
input: XOInvitationInput,
|
||||||
|
entityIdentifier: string,
|
||||||
|
template: XOTemplate,
|
||||||
|
): ResolvedInvitationInput => ({
|
||||||
|
entityIdentifier,
|
||||||
|
...input,
|
||||||
|
...pickTemplateInputMetadata(
|
||||||
|
input.inputIdentifier
|
||||||
|
? template.inputs?.[input.inputIdentifier]
|
||||||
|
: undefined,
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enriches a committed output with its template definition when an identifier is present.
|
||||||
|
*
|
||||||
|
* Template metadata is spread after commit fields so display expressions (e.g.
|
||||||
|
* `valueSatoshis: "$(<totalSatoshis>)"`) layer on for the UI even when the merger
|
||||||
|
* already resolved a bigint for transaction encoding.
|
||||||
|
*/
|
||||||
|
export const resolveOutput = (
|
||||||
|
output: XOInvitationOutput,
|
||||||
|
entityIdentifier: string,
|
||||||
|
template: XOTemplate,
|
||||||
|
): ResolvedInvitationOutput =>
|
||||||
|
({
|
||||||
|
entityIdentifier,
|
||||||
|
...output,
|
||||||
|
...pickTemplateOutputMetadata(
|
||||||
|
output.outputIdentifier
|
||||||
|
? template.outputs?.[output.outputIdentifier]
|
||||||
|
: undefined,
|
||||||
|
),
|
||||||
|
// Template valueSatoshis may be a CashAssembly string while XOInvitationOutput
|
||||||
|
// expects bigint — the read model intentionally allows both for display.
|
||||||
|
}) as ResolvedInvitationOutput;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts hex or binary invitation bytecode fields to hex strings for display.
|
||||||
|
*/
|
||||||
|
export const hexOrBinToHex = (value?: string | Uint8Array) => {
|
||||||
|
if (value === undefined) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return typeof value === "string" ? value : binToHex(value);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalizes a merged input row for UI display (hex strings, no encoding placeholders).
|
||||||
|
*
|
||||||
|
* The engine merger returns libauth-ready binary fields and fills in encoding
|
||||||
|
* defaults (empty unlocking bytecode, sequence 0) that are not useful in the TUI.
|
||||||
|
*/
|
||||||
|
export const normalizeMergedInputForDisplay = (input: XOInvitationInput) => {
|
||||||
|
const normalized = { ...input };
|
||||||
|
|
||||||
|
if (input.outpointTransactionHash !== undefined) {
|
||||||
|
normalized.outpointTransactionHash = hexOrBinToHex(
|
||||||
|
input.outpointTransactionHash,
|
||||||
|
) as XOInvitationInput["outpointTransactionHash"];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input.unlockingBytecode !== undefined) {
|
||||||
|
// Engine uses an empty Uint8Array as a placeholder until the input is signed.
|
||||||
|
const isPlaceholder =
|
||||||
|
input.unlockingBytecode instanceof Uint8Array &&
|
||||||
|
input.unlockingBytecode.length === 0;
|
||||||
|
|
||||||
|
if (isPlaceholder) {
|
||||||
|
delete normalized.unlockingBytecode;
|
||||||
|
} else {
|
||||||
|
normalized.unlockingBytecode = hexOrBinToHex(
|
||||||
|
input.unlockingBytecode,
|
||||||
|
) as XOInvitationInput["unlockingBytecode"];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default sequence from the merger is not meaningful for display.
|
||||||
|
if (normalized.sequenceNumber === 0) {
|
||||||
|
delete normalized.sequenceNumber;
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalized;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalizes a merged output row for UI display (hex strings).
|
||||||
|
*/
|
||||||
|
export const normalizeMergedOutputForDisplay = (output: XOInvitationOutput) => {
|
||||||
|
const normalized = { ...output };
|
||||||
|
|
||||||
|
if (output.lockingBytecode !== undefined) {
|
||||||
|
normalized.lockingBytecode = hexOrBinToHex(
|
||||||
|
output.lockingBytecode,
|
||||||
|
) as XOInvitationOutput["lockingBytecode"];
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalized;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recovers `outputIdentifier` from the source commit because the merger strips it
|
||||||
|
* after template resolution.
|
||||||
|
*/
|
||||||
|
export const findOutputIdentifierForMergedOutput = (
|
||||||
|
commit: XOInvitationCommit | undefined,
|
||||||
|
mergedOutput: XOInvitationOutput,
|
||||||
|
) => {
|
||||||
|
const outputs = commit?.data?.outputs ?? [];
|
||||||
|
const mergedBytecodeHex = hexOrBinToHex(mergedOutput.lockingBytecode);
|
||||||
|
|
||||||
|
for (const commitOutput of outputs) {
|
||||||
|
if (commitOutput.outputIdentifier === undefined) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const commitBytecodeHex = hexOrBinToHex(commitOutput.lockingBytecode);
|
||||||
|
|
||||||
|
// Match merged binary bytecode back to the committed row that carried the identifier.
|
||||||
|
if (
|
||||||
|
mergedBytecodeHex !== undefined &&
|
||||||
|
commitBytecodeHex !== undefined &&
|
||||||
|
mergedBytecodeHex === commitBytecodeHex
|
||||||
|
) {
|
||||||
|
return commitOutput.outputIdentifier;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fall back when the commit has a single identified output (common case).
|
||||||
|
const outputsWithIdentifier = outputs.filter(
|
||||||
|
(commitOutput) => commitOutput.outputIdentifier !== undefined,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (outputsWithIdentifier.length === 1) {
|
||||||
|
return outputsWithIdentifier[0]?.outputIdentifier;
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether two invitation variable rows refer to the same template variable slot.
|
||||||
|
*/
|
||||||
|
export const matchesInvitationVariable = (
|
||||||
|
left: XOInvitationVariable,
|
||||||
|
right: XOInvitationVariable,
|
||||||
|
) =>
|
||||||
|
left.variableIdentifier === right.variableIdentifier &&
|
||||||
|
left.roleIdentifier === right.roleIdentifier;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Finds the entity that authored a merged variable by scanning invitation commits.
|
||||||
|
* Last matching commit in array order wins. Best-effort until the engine orders
|
||||||
|
* commits internally or exposes source attribution on merged variables.
|
||||||
|
*/
|
||||||
|
export const findVariableEntityIdentifier = (
|
||||||
|
variable: XOInvitationVariable,
|
||||||
|
commits: XOInvitationCommit[],
|
||||||
|
) => {
|
||||||
|
let entityIdentifier = "";
|
||||||
|
|
||||||
|
// Merged variables do not carry sourceCommitIdentifier today; walk commits and
|
||||||
|
// let the last array match win (ordering deferred to the engine merger).
|
||||||
|
for (const commit of commits) {
|
||||||
|
for (const commitVariable of commit.data?.variables ?? []) {
|
||||||
|
if (matchesInvitationVariable(commitVariable, variable)) {
|
||||||
|
entityIdentifier = commit.entityIdentifier;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return entityIdentifier;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns template-enriched invitation data for UI display.
|
||||||
|
*
|
||||||
|
* Uses {@link mergeInvitationCommits} for inputs and outputs so `mergesWith`
|
||||||
|
* extensions and transaction indices are resolved. Variables come from the merged
|
||||||
|
* result and are enriched with template metadata. Commit ordering is delegated to
|
||||||
|
* the engine merger.
|
||||||
|
*/
|
||||||
|
export const resolveCommitReferences = (
|
||||||
|
invitation: XOInvitation,
|
||||||
|
template: XOTemplate,
|
||||||
|
): ResolvedInvitationData => {
|
||||||
|
const commits = invitation.commits ?? [];
|
||||||
|
const commitsMap = new Map(
|
||||||
|
commits.map((commit) => [commit.commitIdentifier, commit]),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Merge rather than flatten so mergesWith input extensions and transactionIndex
|
||||||
|
// ordering are handled by the engine (see signing flow in engine.append/sign).
|
||||||
|
const merged = mergeInvitationCommits(
|
||||||
|
invitation as Parameters<typeof mergeInvitationCommits>[0],
|
||||||
|
template,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (merged === null) {
|
||||||
|
return {
|
||||||
|
invitationIdentifier: invitation.invitationIdentifier,
|
||||||
|
templateIdentifier: invitation.templateIdentifier,
|
||||||
|
actionIdentifier: invitation.actionIdentifier,
|
||||||
|
variables: [],
|
||||||
|
inputs: [],
|
||||||
|
outputs: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const variables = merged.variables.map((variable) =>
|
||||||
|
resolveVariable(
|
||||||
|
variable,
|
||||||
|
findVariableEntityIdentifier(variable, commits),
|
||||||
|
template,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const inputs = merged.inputs.map((mergedInput) => {
|
||||||
|
const entityIdentifier =
|
||||||
|
commitsMap.get(mergedInput.sourceCommitIdentifier)?.entityIdentifier ??
|
||||||
|
"";
|
||||||
|
// Strip merger-only fields before normalization and template enrichment.
|
||||||
|
const {
|
||||||
|
sourceCommitIdentifier: _sourceCommitIdentifier,
|
||||||
|
mergesWith: _mergesWith,
|
||||||
|
...input
|
||||||
|
} = mergedInput;
|
||||||
|
|
||||||
|
return resolveInput(
|
||||||
|
normalizeMergedInputForDisplay(input),
|
||||||
|
entityIdentifier,
|
||||||
|
template,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const outputs = merged.outputs.map((mergedOutput) => {
|
||||||
|
const commit = commitsMap.get(mergedOutput.sourceCommitIdentifier);
|
||||||
|
const entityIdentifier = commit?.entityIdentifier ?? "";
|
||||||
|
const {
|
||||||
|
sourceCommitIdentifier: _sourceCommitIdentifier,
|
||||||
|
mergesWith: _mergesWith,
|
||||||
|
...output
|
||||||
|
} = mergedOutput;
|
||||||
|
const outputIdentifier = findOutputIdentifierForMergedOutput(
|
||||||
|
commit,
|
||||||
|
output,
|
||||||
|
);
|
||||||
|
// Re-attach outputIdentifier so pickTemplateOutputMetadata can resolve names/roles.
|
||||||
|
const outputForDisplay = normalizeMergedOutputForDisplay(
|
||||||
|
outputIdentifier !== undefined ? { ...output, outputIdentifier } : output,
|
||||||
|
);
|
||||||
|
|
||||||
|
return resolveOutput(outputForDisplay, entityIdentifier, template);
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
invitationIdentifier: invitation.invitationIdentifier,
|
||||||
|
templateIdentifier: invitation.templateIdentifier,
|
||||||
|
actionIdentifier: invitation.actionIdentifier,
|
||||||
|
variables,
|
||||||
|
inputs,
|
||||||
|
outputs,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -1,7 +1,14 @@
|
|||||||
import type { XOInvitation } from "@xo-cash/types";
|
import type { XOInvitation } from "@xo-cash/types";
|
||||||
import { EventEmitter } from "./event-emitter.js";
|
import { EventEmitter } from "./event-emitter.js";
|
||||||
import { SSESession, type SSEvent } from "./sse-client.js";
|
import { SSESession, type SSEvent } from "@xo-cash/utils";
|
||||||
import { decodeExtendedJson, encodeExtendedJson } from "./ext-json.js";
|
import { deserializeInvitation, serializeInvitation } from "@xo-cash/engine";
|
||||||
|
|
||||||
|
function stripLocalInvitationMetadata(invitation: XOInvitation): XOInvitation {
|
||||||
|
const { entityIdentifier: _entityIdentifier, ...sharedInvitation } =
|
||||||
|
invitation as XOInvitation & { entityIdentifier?: string };
|
||||||
|
|
||||||
|
return sharedInvitation;
|
||||||
|
}
|
||||||
|
|
||||||
export type SyncServerEventMap = {
|
export type SyncServerEventMap = {
|
||||||
connected: void;
|
connected: void;
|
||||||
@@ -20,97 +27,90 @@ export class SyncServer extends EventEmitter<SyncServerEventMap> {
|
|||||||
return server;
|
return server;
|
||||||
}
|
}
|
||||||
|
|
||||||
private sse: SSESession;
|
private sse: SSESession | null = null;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly baseUrl: string,
|
private readonly baseUrl: string,
|
||||||
private readonly invitationIdentifier: string,
|
private readonly invitationIdentifier: string,
|
||||||
) {
|
) {
|
||||||
super();
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
// Create an SSE Session
|
async connect(): Promise<void> {
|
||||||
this.sse = new SSESession(
|
if (this.sse) {
|
||||||
`${baseUrl}/invitations?invitationIdentifier=${invitationIdentifier}`,
|
await this.sse.connect();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.createSSESession();
|
||||||
|
}
|
||||||
|
|
||||||
|
async disconnect(): Promise<void> {
|
||||||
|
await this.sse?.disconnect();
|
||||||
|
this.sse = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async createSSESession(): Promise<void> {
|
||||||
|
const sse = await SSESession.create(
|
||||||
|
`${this.baseUrl}/invitations?invitationIdentifier=${encodeURIComponent(this.invitationIdentifier)}`,
|
||||||
{
|
{
|
||||||
method: "GET",
|
method: "GET",
|
||||||
headers: {
|
headers: {
|
||||||
Accept: "text/event-stream",
|
Accept: "text/event-stream",
|
||||||
},
|
},
|
||||||
|
persistent: true,
|
||||||
// Create our event bubblers
|
onRequest: async (request) => {
|
||||||
onMessage: (event: SSEvent) => this.emit("message", event),
|
const { body: _body, ...requestWithoutBody } = request;
|
||||||
onError: (error: unknown) =>
|
return requestWithoutBody;
|
||||||
|
},
|
||||||
|
onError: (error: unknown) => {
|
||||||
this.emit(
|
this.emit(
|
||||||
"error",
|
"error",
|
||||||
error instanceof Error ? error : new Error(String(error)),
|
error instanceof Error ? error : new Error(String(error)),
|
||||||
),
|
);
|
||||||
onDisconnected: () => this.emit("disconnected", undefined),
|
},
|
||||||
onConnected: () => this.emit("connected", undefined),
|
onDisconnected: () => {
|
||||||
|
this.emit("disconnected", undefined);
|
||||||
|
},
|
||||||
|
onConnected: () => {
|
||||||
|
this.emit("connected", undefined);
|
||||||
|
},
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
this.sse = sse;
|
||||||
|
sse.on("message", (event: SSEvent) => {
|
||||||
|
this.emit("message", event);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Connect to the sync server.
|
|
||||||
*/
|
|
||||||
async connect(): Promise<void> {
|
|
||||||
// Connect to the SSE Session
|
|
||||||
await this.sse.connect();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Disconnect from the sync server.
|
|
||||||
*/
|
|
||||||
async disconnect(): Promise<void> {
|
|
||||||
// Disconnect from the SSE Session
|
|
||||||
this.sse.close();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the invitation by identifier.
|
|
||||||
* @param identifier - The invitation identifier.
|
|
||||||
* @returns The invitation.
|
|
||||||
*/
|
|
||||||
async getInvitation(identifier: string): Promise<XOInvitation | undefined> {
|
async getInvitation(identifier: string): Promise<XOInvitation | undefined> {
|
||||||
// Send a GET request to the sync server
|
|
||||||
const response = await fetch(
|
const response = await fetch(
|
||||||
`${this.baseUrl}/invitations?invitationIdentifier=${identifier}`,
|
`${this.baseUrl}/invitations?invitationIdentifier=${encodeURIComponent(identifier)}`,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`Failed to get invitation: ${response.statusText}`);
|
throw new Error(`Failed to get invitation: ${response.statusText}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const invitation = decodeExtendedJson(await response.text()) as
|
const invitation = deserializeInvitation(await response.text());
|
||||||
| XOInvitation
|
return stripLocalInvitationMetadata(invitation);
|
||||||
| undefined;
|
|
||||||
return invitation;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Publish an invitation.
|
|
||||||
* @param invitation - The invitation to create.
|
|
||||||
* @returns The invitation.
|
|
||||||
*/
|
|
||||||
async publishInvitation(invitation: XOInvitation): Promise<XOInvitation> {
|
async publishInvitation(invitation: XOInvitation): Promise<XOInvitation> {
|
||||||
// Send a POST request to the sync server
|
|
||||||
const response = await fetch(`${this.baseUrl}/invitations`, {
|
const response = await fetch(`${this.baseUrl}/invitations`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: encodeExtendedJson(invitation),
|
body: serializeInvitation(stripLocalInvitationMetadata(invitation)),
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Throw is there was an issue with the request
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`Failed to publish invitation: ${response.statusText}`);
|
throw new Error(`Failed to publish invitation: ${response.statusText}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Read the returned JSON
|
const data = deserializeInvitation(await response.text());
|
||||||
// TODO: This should use zod to verify the response
|
return stripLocalInvitationMetadata(data);
|
||||||
const data = decodeExtendedJson(await response.text()) as XOInvitation;
|
|
||||||
|
|
||||||
return data;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
33
src/utils/template-module-loader.ts
Normal file
33
src/utils/template-module-loader.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
/**
|
||||||
|
* Child-process entry point for loading a TS/JS template module.
|
||||||
|
*
|
||||||
|
* Usage (via tsx): `tsx template-module-loader.js <absolute-template-path>`
|
||||||
|
*
|
||||||
|
* Writes serialized Extended JSON to stdout. Errors go to stderr with exit code 1.
|
||||||
|
* Running in a subprocess isolates module evaluation from the wallet process.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { pathToFileURL } from "node:url";
|
||||||
|
|
||||||
|
import { serializeTemplate } from "@xo-cash/utils";
|
||||||
|
import type { XOTemplate } from "@xo-cash/types";
|
||||||
|
|
||||||
|
import { pickTemplateExport } from "./pick-template-export.js";
|
||||||
|
|
||||||
|
const templateFilePath = process.argv[2];
|
||||||
|
|
||||||
|
if (templateFilePath === undefined || templateFilePath.length === 0) {
|
||||||
|
console.error("Usage: template-module-loader <absolute-template-path>");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const moduleUrl = pathToFileURL(templateFilePath).href;
|
||||||
|
const loadedModule = (await import(moduleUrl)) as Record<string, unknown>;
|
||||||
|
const template = pickTemplateExport(loadedModule);
|
||||||
|
process.stdout.write(serializeTemplate(template as XOTemplate));
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
console.error(`Failed to load template module: ${message}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
@@ -188,11 +188,13 @@ export function getRolesForAction(
|
|||||||
);
|
);
|
||||||
|
|
||||||
return startEntries.map((entry) => {
|
return startEntries.map((entry) => {
|
||||||
const roleDef = template.roles?.[entry.role];
|
const roleDef = template.roles?.[entry.role || ""];
|
||||||
const roleObj = typeof roleDef === "object" ? roleDef : null;
|
const roleObj = typeof roleDef === "object" ? roleDef : null;
|
||||||
|
|
||||||
|
// TODO: This is ugly. Lot of conditionals. Need to take a much closer look at this.
|
||||||
return {
|
return {
|
||||||
roleId: entry.role,
|
roleId: entry.role || "",
|
||||||
name: roleObj?.name || entry.role,
|
name: roleObj?.name || entry.role || "",
|
||||||
description: roleObj?.description,
|
description: roleObj?.description,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
59
src/utils/utxo-metadata.ts
Normal file
59
src/utils/utxo-metadata.ts
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import type { Engine } from "@xo-cash/engine";
|
||||||
|
import type { ScriptHashData, UnspentOutputData } from "@xo-cash/state";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Template and output identifiers resolved from script hash storage.
|
||||||
|
*/
|
||||||
|
export type UnspentOutputMetadata = {
|
||||||
|
templateIdentifier?: string;
|
||||||
|
outputIdentifier?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type UnspentOutputWithMetadata = UnspentOutputData &
|
||||||
|
UnspentOutputMetadata;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds a lookup map from script hash to its stored metadata.
|
||||||
|
*/
|
||||||
|
export const buildScriptHashDataMap = async (
|
||||||
|
engine: Engine,
|
||||||
|
): Promise<Map<string, ScriptHashData>> => {
|
||||||
|
const scriptHashes = await engine.listScriptHashes();
|
||||||
|
const scriptHashDataByScriptHash = new Map<string, ScriptHashData>();
|
||||||
|
|
||||||
|
for (const scriptHashRow of scriptHashes) {
|
||||||
|
scriptHashDataByScriptHash.set(scriptHashRow.scriptHash, scriptHashRow);
|
||||||
|
}
|
||||||
|
|
||||||
|
return scriptHashDataByScriptHash;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves template/output metadata for a single UTXO via its script hash.
|
||||||
|
*/
|
||||||
|
export const getUnspentOutputMetadata = (
|
||||||
|
utxo: UnspentOutputData,
|
||||||
|
scriptHashDataByScriptHash: Map<string, ScriptHashData>,
|
||||||
|
): UnspentOutputMetadata => {
|
||||||
|
const scriptRow = scriptHashDataByScriptHash.get(utxo.scriptHash);
|
||||||
|
|
||||||
|
if (scriptRow === undefined) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
templateIdentifier: scriptRow.templateIdentifier,
|
||||||
|
outputIdentifier: scriptRow.outputIdentifier,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a UTXO enriched with template/output metadata from script hash storage.
|
||||||
|
*/
|
||||||
|
export const enrichUnspentOutput = (
|
||||||
|
utxo: UnspentOutputData,
|
||||||
|
scriptHashDataByScriptHash: Map<string, ScriptHashData>,
|
||||||
|
): UnspentOutputWithMetadata => ({
|
||||||
|
...utxo,
|
||||||
|
...getUnspentOutputMetadata(utxo, scriptHashDataByScriptHash),
|
||||||
|
});
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { describe, it, expect } from "vitest"
|
import { describe, it, expect } from "vitest";
|
||||||
|
|
||||||
import { convertArgsToObject } from "../../src/cli/arguments";
|
import { convertArgsToObject } from "../../src/cli/arguments";
|
||||||
|
|
||||||
@@ -11,45 +11,59 @@ const testCases = [
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
input: ['-var-requested-satohis', '1000', '-role', 'receiver'],
|
input: ["-var-requested-satohis", "1000", "-role", "receiver"],
|
||||||
expected: {
|
expected: {
|
||||||
args: [],
|
args: [],
|
||||||
options: { "varRequestedSatohis": "1000", role: "receiver" },
|
options: { varRequestedSatohis: "1000", role: "receiver" },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
input: ['-o', 'output.json', '-var-requested-satohis', '1000', '-role', 'receiver'],
|
input: [
|
||||||
|
"-o",
|
||||||
|
"output.json",
|
||||||
|
"-var-requested-satohis",
|
||||||
|
"1000",
|
||||||
|
"-role",
|
||||||
|
"receiver",
|
||||||
|
],
|
||||||
expected: {
|
expected: {
|
||||||
args: [],
|
args: [],
|
||||||
options: { output: "output.json", "varRequestedSatohis": "1000", role: "receiver" },
|
options: {
|
||||||
|
output: "output.json",
|
||||||
|
varRequestedSatohis: "1000",
|
||||||
|
role: "receiver",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
input: ['mnemonic', 'create', 'page', 'pencil', '-v', '-o', 'mnemonic.txt'],
|
input: ["mnemonic", "create", "page", "pencil", "-v", "-o", "mnemonic.txt"],
|
||||||
expected: {
|
expected: {
|
||||||
args: ['mnemonic', 'create', 'page', 'pencil'],
|
args: ["mnemonic", "create", "page", "pencil"],
|
||||||
options: { verbose: "true", output: "mnemonic.txt" },
|
options: { verbose: "true", output: "mnemonic.txt" },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
input: ['-v', 'invitation', 'list', '-m', 'mnemonicFile'],
|
input: ["-v", "invitation", "list", "-m", "mnemonicFile"],
|
||||||
expected: {
|
expected: {
|
||||||
args: ['invitation', 'list'],
|
args: ["invitation", "list"],
|
||||||
options: { verbose: "true", mnemonicFile: "mnemonicFile" },
|
options: { verbose: "true", mnemonicFile: "mnemonicFile" },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
input: ['--help', 'template', 'import', 'template.json'],
|
input: ["--help", "template", "import", "template.json"],
|
||||||
expected: {
|
expected: {
|
||||||
args: ['template', 'import', 'template.json'],
|
args: ["template", "import", "template.json"],
|
||||||
options: { help: "true" },
|
options: { help: "true" },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
describe("convertArgsToObject", () => {
|
describe("convertArgsToObject", () => {
|
||||||
it.each(testCases)("should split positional args from options", ({ input, expected }) => {
|
it.each(testCases)(
|
||||||
const result = convertArgsToObject(input);
|
"should split positional args from options",
|
||||||
expect(result).toEqual(expected);
|
({ input, expected }) => {
|
||||||
});
|
const result = convertArgsToObject(input);
|
||||||
|
expect(result).toEqual(expected);
|
||||||
|
},
|
||||||
|
);
|
||||||
});
|
});
|
||||||
114
tests/cli/autocomplete-completions.test.ts
Normal file
114
tests/cli/autocomplete-completions.test.ts
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
import { afterEach, describe, expect, test } from "vitest";
|
||||||
|
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
|
||||||
|
import {
|
||||||
|
generateBashCompletions,
|
||||||
|
generateFishCompletions,
|
||||||
|
generateZshCompletions,
|
||||||
|
installCompletions,
|
||||||
|
} from "../../src/cli/autocomplete/completions";
|
||||||
|
|
||||||
|
describe("shell completions", () => {
|
||||||
|
const tempDirs: string[] = [];
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
for (const tempDir of tempDirs) {
|
||||||
|
rmSync(tempDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
tempDirs.length = 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
function createConfigFile(contents = ""): string {
|
||||||
|
const tempDir = mkdtempSync(join(tmpdir(), "xo-cli-completions-test-"));
|
||||||
|
tempDirs.push(tempDir);
|
||||||
|
const configFile = join(tempDir, "shellrc");
|
||||||
|
writeFileSync(configFile, contents);
|
||||||
|
return configFile;
|
||||||
|
}
|
||||||
|
|
||||||
|
test("uses shell-native mnemonic completion in bash", () => {
|
||||||
|
const completions = generateBashCompletions("xo-cli");
|
||||||
|
|
||||||
|
expect(completions).toContain(
|
||||||
|
'local config_dir="${XO_CONFIG_DIR:-${HOME}/.config/xo-cli}"',
|
||||||
|
);
|
||||||
|
expect(completions).toContain('__xo_complete_mnemonics "${cur}"');
|
||||||
|
expect(completions).not.toContain('__xo_complete mnemonics "${cur}"');
|
||||||
|
});
|
||||||
|
|
||||||
|
test("uses shell-native mnemonic completion in zsh", () => {
|
||||||
|
const completions = generateZshCompletions("xo-cli");
|
||||||
|
|
||||||
|
expect(completions).toContain(
|
||||||
|
'local config_dir="${XO_CONFIG_DIR:-${HOME}/.config/xo-cli}"',
|
||||||
|
);
|
||||||
|
expect(completions).toContain(
|
||||||
|
'__xo_complete_mnemonics "${words[CURRENT]}"',
|
||||||
|
);
|
||||||
|
expect(completions).not.toContain(
|
||||||
|
'__xo_complete mnemonics "${words[CURRENT]}"',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("uses shell-native mnemonic completion in fish", () => {
|
||||||
|
const completions = generateFishCompletions("xo-cli");
|
||||||
|
|
||||||
|
expect(completions).toContain('set -l config_dir "$XO_CONFIG_DIR"');
|
||||||
|
expect(completions).toContain("(__xo_cli_complete_mnemonics)");
|
||||||
|
expect(completions).not.toContain("(__xo_cli_complete_dynamic mnemonics)");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("installs the config default and completion loader once", () => {
|
||||||
|
const configFile = createConfigFile();
|
||||||
|
|
||||||
|
expect(installCompletions("bash", "xo-cli", configFile)).toBe(true);
|
||||||
|
expect(installCompletions("bash", "xo-cli", configFile)).toBe(false);
|
||||||
|
|
||||||
|
const contents = readFileSync(configFile, "utf8");
|
||||||
|
expect(contents.match(/XO_CONFIG_DIR/g)).toHaveLength(2);
|
||||||
|
expect(
|
||||||
|
contents.match(/eval "\$\(xo-cli completions bash\)"/g),
|
||||||
|
).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("adds a missing default without duplicating an existing loader", () => {
|
||||||
|
const configFile = createConfigFile('eval "$(xo-cli completions bash)"\n');
|
||||||
|
|
||||||
|
expect(installCompletions("bash", "xo-cli", configFile)).toBe(true);
|
||||||
|
|
||||||
|
const contents = readFileSync(configFile, "utf8");
|
||||||
|
expect(
|
||||||
|
contents.match(/eval "\$\(xo-cli completions bash\)"/g),
|
||||||
|
).toHaveLength(1);
|
||||||
|
expect(contents).toContain(
|
||||||
|
'export XO_CONFIG_DIR="${XO_CONFIG_DIR:-$HOME/.config/xo-cli}"',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("preserves an existing custom config directory assignment", () => {
|
||||||
|
const configFile = createConfigFile(
|
||||||
|
"export XO_CONFIG_DIR=/tmp/custom-xo\n",
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(installCompletions("zsh", "xo-cli", configFile)).toBe(true);
|
||||||
|
|
||||||
|
const contents = readFileSync(configFile, "utf8");
|
||||||
|
expect(contents).toContain("export XO_CONFIG_DIR=/tmp/custom-xo");
|
||||||
|
expect(contents).not.toContain("${XO_CONFIG_DIR:-$HOME/.config/xo-cli}");
|
||||||
|
expect(contents).toContain('eval "$(xo-cli completions zsh)"');
|
||||||
|
});
|
||||||
|
|
||||||
|
test("uses fish syntax when installing fish completions", () => {
|
||||||
|
const configFile = createConfigFile();
|
||||||
|
|
||||||
|
expect(installCompletions("fish", "xo-cli", configFile)).toBe(true);
|
||||||
|
|
||||||
|
const contents = readFileSync(configFile, "utf8");
|
||||||
|
expect(contents).toContain(
|
||||||
|
'set -q XO_CONFIG_DIR; or set -gx XO_CONFIG_DIR "$HOME/.config/xo-cli"',
|
||||||
|
);
|
||||||
|
expect(contents).toContain("xo-cli completions fish | source");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -73,11 +73,19 @@ describe("command handler contracts", () => {
|
|||||||
const { io } = createMockIO();
|
const { io } = createMockIO();
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
handleResourceCommand(createCommandDeps(fakeApp, io), ["does-not-exist"], {}),
|
handleResourceCommand(
|
||||||
|
createCommandDeps(fakeApp, io),
|
||||||
|
["does-not-exist"],
|
||||||
|
{},
|
||||||
|
),
|
||||||
).rejects.toThrow(CommandError);
|
).rejects.toThrow(CommandError);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await handleResourceCommand(createCommandDeps(fakeApp, io), ["does-not-exist"], {});
|
await handleResourceCommand(
|
||||||
|
createCommandDeps(fakeApp, io),
|
||||||
|
["does-not-exist"],
|
||||||
|
{},
|
||||||
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
expect(error).toBeInstanceOf(CommandError);
|
expect(error).toBeInstanceOf(CommandError);
|
||||||
expect((error as CommandError).event).toBe("resource.subcommand.unknown");
|
expect((error as CommandError).event).toBe("resource.subcommand.unknown");
|
||||||
|
|||||||
@@ -11,18 +11,41 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { expect, test, describe, beforeEach, afterEach } from "vitest";
|
import { expect, test, describe, beforeEach, afterEach } from "vitest";
|
||||||
import { existsSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
|
import {
|
||||||
|
existsSync,
|
||||||
|
mkdtempSync,
|
||||||
|
readFileSync,
|
||||||
|
readdirSync,
|
||||||
|
rmSync,
|
||||||
|
writeFileSync,
|
||||||
|
} from "node:fs";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
|
|
||||||
import { addFakeResource, createMockAppService, createMockEngine, DEFAULT_SEED, randomTxHash } from "../mocks/engine";
|
import {
|
||||||
|
addFakeResource,
|
||||||
|
createMockAppService,
|
||||||
|
createMockEngine,
|
||||||
|
DEFAULT_SEED,
|
||||||
|
randomTxHash,
|
||||||
|
} from "../mocks/engine";
|
||||||
import { type Engine } from "@xo-cash/engine";
|
import { type Engine } from "@xo-cash/engine";
|
||||||
import { p2pkhTemplate, p2pkhTemplateIdentifier } from "../mocks/template-p2pkh";
|
import {
|
||||||
|
p2pkhTemplate,
|
||||||
|
p2pkhTemplateIdentifier,
|
||||||
|
} from "../mocks/template-p2pkh";
|
||||||
import { AppService } from "../../../src/services/app";
|
import { AppService } from "../../../src/services/app";
|
||||||
|
|
||||||
import { handleInvitationCommand } from "../../../src/cli/commands/invitation";
|
import { handleInvitationCommand } from "../../../src/cli/commands/invitation";
|
||||||
import { CommandError, CommandPaths } from "../../../src/cli/commands/types";
|
import { CommandError, CommandPaths } from "../../../src/cli/commands/types";
|
||||||
import { createCommandDeps, createMockIO, createMockPaths, expectLogs, type LogExpectation } from "../mocks/command";
|
import {
|
||||||
|
createCommandDeps,
|
||||||
|
createMockIO,
|
||||||
|
createMockPaths,
|
||||||
|
expectLogs,
|
||||||
|
type LogExpectation,
|
||||||
|
} from "../mocks/command";
|
||||||
|
import { State } from "@xo-cash/state";
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Error Cases - Validate argument parsing and error handling
|
// Error Cases - Validate argument parsing and error handling
|
||||||
@@ -134,7 +157,8 @@ describe("invitation command - error cases", () => {
|
|||||||
let paths: CommandPaths;
|
let paths: CommandPaths;
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
engine = await createMockEngine(DEFAULT_SEED);
|
const mockEngine = await createMockEngine(DEFAULT_SEED);
|
||||||
|
engine = mockEngine.engine;
|
||||||
await engine.importTemplate(p2pkhTemplate);
|
await engine.importTemplate(p2pkhTemplate);
|
||||||
app = await createMockAppService(engine);
|
app = await createMockAppService(engine);
|
||||||
tempDir = mkdtempSync(path.join(tmpdir(), "xo-cli-invitation-errors-"));
|
tempDir = mkdtempSync(path.join(tmpdir(), "xo-cli-invitation-errors-"));
|
||||||
@@ -150,7 +174,11 @@ describe("invitation command - error cases", () => {
|
|||||||
const { io } = createMockIO();
|
const { io } = createMockIO();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await handleInvitationCommand(createCommandDeps(app, io, paths), inputs, {});
|
await handleInvitationCommand(
|
||||||
|
createCommandDeps(app, io, paths),
|
||||||
|
inputs,
|
||||||
|
{},
|
||||||
|
);
|
||||||
expect.fail("Expected command to throw");
|
expect.fail("Expected command to throw");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
expect(error).toBeInstanceOf(CommandError);
|
expect(error).toBeInstanceOf(CommandError);
|
||||||
@@ -170,7 +198,8 @@ describe("invitation command - receive flow", () => {
|
|||||||
let paths: CommandPaths;
|
let paths: CommandPaths;
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
engine = await createMockEngine(DEFAULT_SEED);
|
const mockEngine = await createMockEngine(DEFAULT_SEED);
|
||||||
|
engine = mockEngine.engine;
|
||||||
await engine.importTemplate(p2pkhTemplate);
|
await engine.importTemplate(p2pkhTemplate);
|
||||||
app = await createMockAppService(engine);
|
app = await createMockAppService(engine);
|
||||||
tempDir = mkdtempSync(path.join(tmpdir(), "xo-cli-invitation-receive-"));
|
tempDir = mkdtempSync(path.join(tmpdir(), "xo-cli-invitation-receive-"));
|
||||||
@@ -211,7 +240,10 @@ describe("invitation command - receive flow", () => {
|
|||||||
{},
|
{},
|
||||||
);
|
);
|
||||||
|
|
||||||
const expectedFile = path.join(tempDir, `inv-${result.invitationIdentifier}.json`);
|
const expectedFile = path.join(
|
||||||
|
tempDir,
|
||||||
|
`inv-${result.invitationIdentifier}.json`,
|
||||||
|
);
|
||||||
expect(existsSync(expectedFile)).toBe(true);
|
expect(existsSync(expectedFile)).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -279,7 +311,8 @@ describe("invitation command - request satoshis flow", () => {
|
|||||||
let paths: CommandPaths;
|
let paths: CommandPaths;
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
engine = await createMockEngine(DEFAULT_SEED);
|
const mockEngine = await createMockEngine(DEFAULT_SEED);
|
||||||
|
engine = mockEngine.engine;
|
||||||
await engine.importTemplate(p2pkhTemplate);
|
await engine.importTemplate(p2pkhTemplate);
|
||||||
app = await createMockAppService(engine);
|
app = await createMockAppService(engine);
|
||||||
tempDir = mkdtempSync(path.join(tmpdir(), "xo-cli-invitation-request-"));
|
tempDir = mkdtempSync(path.join(tmpdir(), "xo-cli-invitation-request-"));
|
||||||
@@ -325,8 +358,12 @@ describe("invitation command - request satoshis flow", () => {
|
|||||||
);
|
);
|
||||||
expect(invitation).toBeDefined();
|
expect(invitation).toBeDefined();
|
||||||
|
|
||||||
const variables = invitation?.data.commits.flatMap((c) => c.data.variables ?? []);
|
const variables = invitation?.data.commits.flatMap(
|
||||||
const requestedSatoshis = variables?.find((v) => v.variableIdentifier === "requestedSatoshis");
|
(c) => c.data.variables ?? [],
|
||||||
|
);
|
||||||
|
const requestedSatoshis = variables?.find(
|
||||||
|
(v) => v.variableIdentifier === "requestedSatoshis",
|
||||||
|
);
|
||||||
expect(requestedSatoshis).toBeDefined();
|
expect(requestedSatoshis).toBeDefined();
|
||||||
expect(requestedSatoshis?.value).toBe("10000");
|
expect(requestedSatoshis?.value).toBe("10000");
|
||||||
});
|
});
|
||||||
@@ -347,8 +384,12 @@ describe("invitation command - request satoshis flow", () => {
|
|||||||
(inv) => inv.data.invitationIdentifier === result.invitationIdentifier,
|
(inv) => inv.data.invitationIdentifier === result.invitationIdentifier,
|
||||||
);
|
);
|
||||||
|
|
||||||
const variables = invitation?.data.commits.flatMap((c) => c.data.variables ?? []);
|
const variables = invitation?.data.commits.flatMap(
|
||||||
const requestedSatoshis = variables?.find((v) => v.variableIdentifier === "requestedSatoshis");
|
(c) => c.data.variables ?? [],
|
||||||
|
);
|
||||||
|
const requestedSatoshis = variables?.find(
|
||||||
|
(v) => v.variableIdentifier === "requestedSatoshis",
|
||||||
|
);
|
||||||
expect(requestedSatoshis?.roleIdentifier).toBe("receiver");
|
expect(requestedSatoshis?.roleIdentifier).toBe("receiver");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -359,12 +400,15 @@ describe("invitation command - request satoshis flow", () => {
|
|||||||
|
|
||||||
describe("invitation command - send flow with resources", () => {
|
describe("invitation command - send flow with resources", () => {
|
||||||
let engine: Engine;
|
let engine: Engine;
|
||||||
|
let state: State;
|
||||||
let app: AppService;
|
let app: AppService;
|
||||||
let tempDir: string;
|
let tempDir: string;
|
||||||
let paths: CommandPaths;
|
let paths: CommandPaths;
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
engine = await createMockEngine(DEFAULT_SEED);
|
const mockEngine = await createMockEngine(DEFAULT_SEED);
|
||||||
|
engine = mockEngine.engine;
|
||||||
|
state = mockEngine.state;
|
||||||
await engine.importTemplate(p2pkhTemplate);
|
await engine.importTemplate(p2pkhTemplate);
|
||||||
app = await createMockAppService(engine);
|
app = await createMockAppService(engine);
|
||||||
tempDir = mkdtempSync(path.join(tmpdir(), "xo-cli-invitation-send-"));
|
tempDir = mkdtempSync(path.join(tmpdir(), "xo-cli-invitation-send-"));
|
||||||
@@ -388,7 +432,8 @@ describe("invitation command - send flow with resources", () => {
|
|||||||
["create", "Wallet (P2PKH)", "sendSatoshis"],
|
["create", "Wallet (P2PKH)", "sendSatoshis"],
|
||||||
{
|
{
|
||||||
varTransferredSatoshis: "10000",
|
varTransferredSatoshis: "10000",
|
||||||
varRecipientLockingscript: "76a91489abcdefabbaabbaabbaabbaabbaabbaabbaabba88ac",
|
varRecipientLockingscript:
|
||||||
|
"76a91489abcdefabbaabbaabbaabbaabbaabbaabbaabba88ac",
|
||||||
role: "sender",
|
role: "sender",
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -408,7 +453,8 @@ describe("invitation command - send flow with resources", () => {
|
|||||||
["create", "Wallet (P2PKH)", "sendSatoshis"],
|
["create", "Wallet (P2PKH)", "sendSatoshis"],
|
||||||
{
|
{
|
||||||
varTransferredSatoshis: "10000",
|
varTransferredSatoshis: "10000",
|
||||||
varRecipientLockingscript: "76a91489abcdefabbaabbaabbaabbaabbaabbaabbaabba88ac",
|
varRecipientLockingscript:
|
||||||
|
"76a91489abcdefabbaabbaabbaabbaabbaabbaabbaabba88ac",
|
||||||
role: "sender",
|
role: "sender",
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -418,8 +464,12 @@ describe("invitation command - send flow with resources", () => {
|
|||||||
);
|
);
|
||||||
expect(invitation).toBeDefined();
|
expect(invitation).toBeDefined();
|
||||||
|
|
||||||
const variables = invitation?.data.commits.flatMap((c) => c.data.variables ?? []);
|
const variables = invitation?.data.commits.flatMap(
|
||||||
const transferredSatoshis = variables?.find((v) => v.variableIdentifier === "transferredSatoshis");
|
(c) => c.data.variables ?? [],
|
||||||
|
);
|
||||||
|
const transferredSatoshis = variables?.find(
|
||||||
|
(v) => v.variableIdentifier === "transferredSatoshis",
|
||||||
|
);
|
||||||
expect(transferredSatoshis).toBeDefined();
|
expect(transferredSatoshis).toBeDefined();
|
||||||
expect(transferredSatoshis?.value).toBe("10000");
|
expect(transferredSatoshis?.value).toBe("10000");
|
||||||
});
|
});
|
||||||
@@ -436,8 +486,10 @@ describe("invitation command - send flow with resources", () => {
|
|||||||
["create", "Wallet (P2PKH)", "sendSatoshis"],
|
["create", "Wallet (P2PKH)", "sendSatoshis"],
|
||||||
{
|
{
|
||||||
varTransferredSatoshis: "10000",
|
varTransferredSatoshis: "10000",
|
||||||
varRecipientLockingscript: "76a91489abcdefabbaabbaabbaabbaabbaabbaabbaabba88ac",
|
varRecipientLockingscript:
|
||||||
addInput: "0000000000000000000000000000000000000000000000000000000000000000:0",
|
"76a91489abcdefabbaabbaabbaabbaabbaabbaabbaabba88ac",
|
||||||
|
addInput:
|
||||||
|
"0000000000000000000000000000000000000000000000000000000000000000:0",
|
||||||
role: "sender",
|
role: "sender",
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -452,7 +504,7 @@ describe("invitation command - send flow with resources", () => {
|
|||||||
* This validates our test infrastructure works correctly.
|
* This validates our test infrastructure works correctly.
|
||||||
*/
|
*/
|
||||||
test("fake resources are accessible via engine", async () => {
|
test("fake resources are accessible via engine", async () => {
|
||||||
const resource = await addFakeResource(engine, {
|
const resource = await addFakeResource(state!, {
|
||||||
valueSatoshis: 50000,
|
valueSatoshis: 50000,
|
||||||
templateIdentifier: p2pkhTemplateIdentifier,
|
templateIdentifier: p2pkhTemplateIdentifier,
|
||||||
outputIdentifier: "receiveOutput",
|
outputIdentifier: "receiveOutput",
|
||||||
@@ -481,7 +533,8 @@ describe("invitation command - multi-step append", () => {
|
|||||||
let paths: CommandPaths;
|
let paths: CommandPaths;
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
engine = await createMockEngine(DEFAULT_SEED);
|
const mockEngine = await createMockEngine(DEFAULT_SEED);
|
||||||
|
engine = mockEngine.engine;
|
||||||
await engine.importTemplate(p2pkhTemplate);
|
await engine.importTemplate(p2pkhTemplate);
|
||||||
app = await createMockAppService(engine);
|
app = await createMockAppService(engine);
|
||||||
tempDir = mkdtempSync(path.join(tmpdir(), "xo-cli-invitation-append-"));
|
tempDir = mkdtempSync(path.join(tmpdir(), "xo-cli-invitation-append-"));
|
||||||
@@ -516,10 +569,15 @@ describe("invitation command - multi-step append", () => {
|
|||||||
expectLogs(spies, [{ out: "Invitation appended" }]);
|
expectLogs(spies, [{ out: "Invitation appended" }]);
|
||||||
|
|
||||||
const invitation = app.invitations.find(
|
const invitation = app.invitations.find(
|
||||||
(inv) => inv.data.invitationIdentifier === createResult.invitationIdentifier,
|
(inv) =>
|
||||||
|
inv.data.invitationIdentifier === createResult.invitationIdentifier,
|
||||||
|
);
|
||||||
|
const variables = invitation?.data.commits.flatMap(
|
||||||
|
(c) => c.data.variables ?? [],
|
||||||
|
);
|
||||||
|
const requestedSatoshis = variables?.find(
|
||||||
|
(v) => v.variableIdentifier === "requestedSatoshis",
|
||||||
);
|
);
|
||||||
const variables = invitation?.data.commits.flatMap((c) => c.data.variables ?? []);
|
|
||||||
const requestedSatoshis = variables?.find((v) => v.variableIdentifier === "requestedSatoshis");
|
|
||||||
expect(requestedSatoshis?.value).toBe("25000");
|
expect(requestedSatoshis?.value).toBe("25000");
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -569,7 +627,10 @@ describe("invitation command - multi-step append", () => {
|
|||||||
|
|
||||||
expectLogs(spies, [{ out: "Invitation updated" }]);
|
expectLogs(spies, [{ out: "Invitation updated" }]);
|
||||||
|
|
||||||
const expectedFile = path.join(tempDir, `inv-${createResult.invitationIdentifier}.json`);
|
const expectedFile = path.join(
|
||||||
|
tempDir,
|
||||||
|
`inv-${createResult.invitationIdentifier}.json`,
|
||||||
|
);
|
||||||
expect(existsSync(expectedFile)).toBe(true);
|
expect(existsSync(expectedFile)).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -594,12 +655,17 @@ describe("invitation command - multi-step append", () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const invitation = app.invitations.find(
|
const invitation = app.invitations.find(
|
||||||
(inv) => inv.data.invitationIdentifier === createResult.invitationIdentifier,
|
(inv) =>
|
||||||
|
inv.data.invitationIdentifier === createResult.invitationIdentifier,
|
||||||
);
|
);
|
||||||
expect(invitation?.data.commits.length).toBeGreaterThan(1);
|
expect(invitation?.data.commits.length).toBeGreaterThan(1);
|
||||||
|
|
||||||
const variables = invitation?.data.commits.flatMap((c) => c.data.variables ?? []);
|
const variables = invitation?.data.commits.flatMap(
|
||||||
const requestedSatoshis = variables?.find((v) => v.variableIdentifier === "requestedSatoshis");
|
(c) => c.data.variables ?? [],
|
||||||
|
);
|
||||||
|
const requestedSatoshis = variables?.find(
|
||||||
|
(v) => v.variableIdentifier === "requestedSatoshis",
|
||||||
|
);
|
||||||
expect(requestedSatoshis?.value).toBe("10000");
|
expect(requestedSatoshis?.value).toBe("10000");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -615,7 +681,8 @@ describe("invitation command - list and inspect", () => {
|
|||||||
let paths: CommandPaths;
|
let paths: CommandPaths;
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
engine = await createMockEngine(DEFAULT_SEED);
|
const mockEngine = await createMockEngine(DEFAULT_SEED);
|
||||||
|
engine = mockEngine.engine;
|
||||||
await engine.importTemplate(p2pkhTemplate);
|
await engine.importTemplate(p2pkhTemplate);
|
||||||
app = await createMockAppService(engine);
|
app = await createMockAppService(engine);
|
||||||
tempDir = mkdtempSync(path.join(tmpdir(), "xo-cli-invitation-list-"));
|
tempDir = mkdtempSync(path.join(tmpdir(), "xo-cli-invitation-list-"));
|
||||||
@@ -724,7 +791,10 @@ describe("invitation command - list and inspect", () => {
|
|||||||
{},
|
{},
|
||||||
);
|
);
|
||||||
|
|
||||||
const invitationFilePath = path.join(tempDir, `inv-${createResult.invitationIdentifier}.json`);
|
const invitationFilePath = path.join(
|
||||||
|
tempDir,
|
||||||
|
`inv-${createResult.invitationIdentifier}.json`,
|
||||||
|
);
|
||||||
|
|
||||||
const { io: inspectIO } = createMockIO();
|
const { io: inspectIO } = createMockIO();
|
||||||
|
|
||||||
@@ -780,7 +850,8 @@ describe("invitation command - sign flow", () => {
|
|||||||
let paths: CommandPaths;
|
let paths: CommandPaths;
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
engine = await createMockEngine(DEFAULT_SEED);
|
const mockEngine = await createMockEngine(DEFAULT_SEED);
|
||||||
|
engine = mockEngine.engine;
|
||||||
await engine.importTemplate(p2pkhTemplate);
|
await engine.importTemplate(p2pkhTemplate);
|
||||||
app = await createMockAppService(engine);
|
app = await createMockAppService(engine);
|
||||||
tempDir = mkdtempSync(path.join(tmpdir(), "xo-cli-invitation-sign-"));
|
tempDir = mkdtempSync(path.join(tmpdir(), "xo-cli-invitation-sign-"));
|
||||||
@@ -813,7 +884,9 @@ describe("invitation command - sign flow", () => {
|
|||||||
{},
|
{},
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(signResult.invitationIdentifier).toBe(createResult.invitationIdentifier);
|
expect(signResult.invitationIdentifier).toBe(
|
||||||
|
createResult.invitationIdentifier,
|
||||||
|
);
|
||||||
expectLogs(spies, [{ out: "Invitation signed" }]);
|
expectLogs(spies, [{ out: "Invitation signed" }]);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -840,7 +913,8 @@ describe("invitation command - sign flow", () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const invitation = app.invitations.find(
|
const invitation = app.invitations.find(
|
||||||
(inv) => inv.data.invitationIdentifier === createResult.invitationIdentifier,
|
(inv) =>
|
||||||
|
inv.data.invitationIdentifier === createResult.invitationIdentifier,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(invitation).toBeDefined();
|
expect(invitation).toBeDefined();
|
||||||
@@ -897,7 +971,8 @@ describe("invitation command - import flow", () => {
|
|||||||
let paths: CommandPaths;
|
let paths: CommandPaths;
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
engine = await createMockEngine(DEFAULT_SEED);
|
const mockEngine = await createMockEngine(DEFAULT_SEED);
|
||||||
|
engine = mockEngine.engine;
|
||||||
await engine.importTemplate(p2pkhTemplate);
|
await engine.importTemplate(p2pkhTemplate);
|
||||||
app = await createMockAppService(engine);
|
app = await createMockAppService(engine);
|
||||||
tempDir = mkdtempSync(path.join(tmpdir(), "xo-cli-invitation-import-"));
|
tempDir = mkdtempSync(path.join(tmpdir(), "xo-cli-invitation-import-"));
|
||||||
@@ -921,7 +996,10 @@ describe("invitation command - import flow", () => {
|
|||||||
{},
|
{},
|
||||||
);
|
);
|
||||||
|
|
||||||
const invitationFilePath = path.join(tempDir, `inv-${createResult.invitationIdentifier}.json`);
|
const invitationFilePath = path.join(
|
||||||
|
tempDir,
|
||||||
|
`inv-${createResult.invitationIdentifier}.json`,
|
||||||
|
);
|
||||||
|
|
||||||
const secondApp = await createMockAppService(engine);
|
const secondApp = await createMockAppService(engine);
|
||||||
const { io: importIO } = createMockIO();
|
const { io: importIO } = createMockIO();
|
||||||
@@ -965,7 +1043,10 @@ describe("invitation command - import flow", () => {
|
|||||||
{},
|
{},
|
||||||
);
|
);
|
||||||
|
|
||||||
const invitationFilePath = path.join(tempDir, `inv-${createResult.invitationIdentifier}.json`);
|
const invitationFilePath = path.join(
|
||||||
|
tempDir,
|
||||||
|
`inv-${createResult.invitationIdentifier}.json`,
|
||||||
|
);
|
||||||
|
|
||||||
const secondApp = await createMockAppService(engine);
|
const secondApp = await createMockAppService(engine);
|
||||||
const { io: importIO } = createMockIO();
|
const { io: importIO } = createMockIO();
|
||||||
@@ -991,7 +1072,10 @@ describe("invitation command - import flow", () => {
|
|||||||
{},
|
{},
|
||||||
);
|
);
|
||||||
|
|
||||||
const invitationFilePath = path.join(tempDir, `inv-${createResult.invitationIdentifier}.json`);
|
const invitationFilePath = path.join(
|
||||||
|
tempDir,
|
||||||
|
`inv-${createResult.invitationIdentifier}.json`,
|
||||||
|
);
|
||||||
|
|
||||||
const secondApp = await createMockAppService(engine);
|
const secondApp = await createMockAppService(engine);
|
||||||
const { io: importIO } = createMockIO();
|
const { io: importIO } = createMockIO();
|
||||||
@@ -1019,7 +1103,8 @@ describe("invitation command - auto-inputs flow", () => {
|
|||||||
let paths: CommandPaths;
|
let paths: CommandPaths;
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
engine = await createMockEngine(DEFAULT_SEED);
|
const mockEngine = await createMockEngine(DEFAULT_SEED);
|
||||||
|
engine = mockEngine.engine;
|
||||||
await engine.importTemplate(p2pkhTemplate);
|
await engine.importTemplate(p2pkhTemplate);
|
||||||
app = await createMockAppService(engine);
|
app = await createMockAppService(engine);
|
||||||
tempDir = mkdtempSync(path.join(tmpdir(), "xo-cli-invitation-autoinputs-"));
|
tempDir = mkdtempSync(path.join(tmpdir(), "xo-cli-invitation-autoinputs-"));
|
||||||
@@ -1044,7 +1129,8 @@ describe("invitation command - auto-inputs flow", () => {
|
|||||||
["create", "Wallet (P2PKH)", "sendSatoshis"],
|
["create", "Wallet (P2PKH)", "sendSatoshis"],
|
||||||
{
|
{
|
||||||
varTransferredSatoshis: "10000",
|
varTransferredSatoshis: "10000",
|
||||||
varRecipientLockingscript: "76a91489abcdefabbaabbaabbaabbaabbaabbaabbaabba88ac",
|
varRecipientLockingscript:
|
||||||
|
"76a91489abcdefabbaabbaabbaabbaabbaabbaabbaabba88ac",
|
||||||
role: "sender",
|
role: "sender",
|
||||||
autoInputs: "true",
|
autoInputs: "true",
|
||||||
},
|
},
|
||||||
@@ -1052,7 +1138,9 @@ describe("invitation command - auto-inputs flow", () => {
|
|||||||
expect.fail("Expected command to throw");
|
expect.fail("Expected command to throw");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
expect(error).toBeInstanceOf(CommandError);
|
expect(error).toBeInstanceOf(CommandError);
|
||||||
expect((error as CommandError).event).toBe("invitation.create.append_params_failed");
|
expect((error as CommandError).event).toBe(
|
||||||
|
"invitation.create.append_params_failed",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1068,7 +1156,8 @@ describe("invitation command - auto-inputs flow", () => {
|
|||||||
["create", "Wallet (P2PKH)", "sendSatoshis"],
|
["create", "Wallet (P2PKH)", "sendSatoshis"],
|
||||||
{
|
{
|
||||||
varTransferredSatoshis: "10000",
|
varTransferredSatoshis: "10000",
|
||||||
varRecipientLockingscript: "76a91489abcdefabbaabbaabbaabbaabbaabbaabbaabba88ac",
|
varRecipientLockingscript:
|
||||||
|
"76a91489abcdefabbaabbaabbaabbaabbaabbaabbaabba88ac",
|
||||||
role: "sender",
|
role: "sender",
|
||||||
autoInputs: "true",
|
autoInputs: "true",
|
||||||
},
|
},
|
||||||
@@ -1090,7 +1179,8 @@ describe("invitation command - broadcast flow", () => {
|
|||||||
let paths: CommandPaths;
|
let paths: CommandPaths;
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
engine = await createMockEngine(DEFAULT_SEED);
|
const mockEngine = await createMockEngine(DEFAULT_SEED);
|
||||||
|
engine = mockEngine.engine;
|
||||||
await engine.importTemplate(p2pkhTemplate);
|
await engine.importTemplate(p2pkhTemplate);
|
||||||
app = await createMockAppService(engine);
|
app = await createMockAppService(engine);
|
||||||
tempDir = mkdtempSync(path.join(tmpdir(), "xo-cli-invitation-broadcast-"));
|
tempDir = mkdtempSync(path.join(tmpdir(), "xo-cli-invitation-broadcast-"));
|
||||||
@@ -1117,7 +1207,9 @@ describe("invitation command - broadcast flow", () => {
|
|||||||
expect.fail("Expected command to throw");
|
expect.fail("Expected command to throw");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
expect(error).toBeInstanceOf(CommandError);
|
expect(error).toBeInstanceOf(CommandError);
|
||||||
expect((error as CommandError).event).toBe("invitation.broadcast.not_found");
|
expect((error as CommandError).event).toBe(
|
||||||
|
"invitation.broadcast.not_found",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1152,7 +1244,8 @@ describe("invitation command - full lifecycle", () => {
|
|||||||
let paths: CommandPaths;
|
let paths: CommandPaths;
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
engine = await createMockEngine(DEFAULT_SEED);
|
const mockEngine = await createMockEngine(DEFAULT_SEED);
|
||||||
|
engine = mockEngine.engine;
|
||||||
await engine.importTemplate(p2pkhTemplate);
|
await engine.importTemplate(p2pkhTemplate);
|
||||||
app = await createMockAppService(engine);
|
app = await createMockAppService(engine);
|
||||||
tempDir = mkdtempSync(path.join(tmpdir(), "xo-cli-invitation-lifecycle-"));
|
tempDir = mkdtempSync(path.join(tmpdir(), "xo-cli-invitation-lifecycle-"));
|
||||||
@@ -1188,11 +1281,14 @@ describe("invitation command - full lifecycle", () => {
|
|||||||
{},
|
{},
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(signResult.invitationIdentifier).toBe(createResult.invitationIdentifier);
|
expect(signResult.invitationIdentifier).toBe(
|
||||||
|
createResult.invitationIdentifier,
|
||||||
|
);
|
||||||
expectLogs(signSpies, [{ out: "Invitation signed" }]);
|
expectLogs(signSpies, [{ out: "Invitation signed" }]);
|
||||||
|
|
||||||
const invitation = app.invitations.find(
|
const invitation = app.invitations.find(
|
||||||
(inv) => inv.data.invitationIdentifier === createResult.invitationIdentifier,
|
(inv) =>
|
||||||
|
inv.data.invitationIdentifier === createResult.invitationIdentifier,
|
||||||
);
|
);
|
||||||
expect(invitation).toBeDefined();
|
expect(invitation).toBeDefined();
|
||||||
expect(invitation?.data.commits.length).toBeGreaterThan(0);
|
expect(invitation?.data.commits.length).toBeGreaterThan(0);
|
||||||
@@ -1210,7 +1306,10 @@ describe("invitation command - full lifecycle", () => {
|
|||||||
{},
|
{},
|
||||||
);
|
);
|
||||||
|
|
||||||
const invitationFilePath = path.join(tempDir, `inv-${createResult.invitationIdentifier}.json`);
|
const invitationFilePath = path.join(
|
||||||
|
tempDir,
|
||||||
|
`inv-${createResult.invitationIdentifier}.json`,
|
||||||
|
);
|
||||||
expect(existsSync(invitationFilePath)).toBe(true);
|
expect(existsSync(invitationFilePath)).toBe(true);
|
||||||
|
|
||||||
const { io: inspectIO } = createMockIO();
|
const { io: inspectIO } = createMockIO();
|
||||||
@@ -1254,7 +1353,9 @@ describe("invitation command - full lifecycle", () => {
|
|||||||
{},
|
{},
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(signResult.invitationIdentifier).toBe(createResult.invitationIdentifier);
|
expect(signResult.invitationIdentifier).toBe(
|
||||||
|
createResult.invitationIdentifier,
|
||||||
|
);
|
||||||
expectLogs(signSpies, [{ out: "Invitation signed" }]);
|
expectLogs(signSpies, [{ out: "Invitation signed" }]);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1270,7 +1371,10 @@ describe("invitation command - full lifecycle", () => {
|
|||||||
{},
|
{},
|
||||||
);
|
);
|
||||||
|
|
||||||
const invitationFilePath = path.join(tempDir, `inv-${createResult.invitationIdentifier}.json`);
|
const invitationFilePath = path.join(
|
||||||
|
tempDir,
|
||||||
|
`inv-${createResult.invitationIdentifier}.json`,
|
||||||
|
);
|
||||||
|
|
||||||
const afterCreate = JSON.parse(readFileSync(invitationFilePath, "utf-8"));
|
const afterCreate = JSON.parse(readFileSync(invitationFilePath, "utf-8"));
|
||||||
const createCommitCount = afterCreate.commits?.length ?? 0;
|
const createCommitCount = afterCreate.commits?.length ?? 0;
|
||||||
@@ -1335,7 +1439,9 @@ describe("invitation command - full lifecycle", () => {
|
|||||||
{},
|
{},
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(reqResult.invitationIdentifier).toBe(createResult.invitationIdentifier);
|
expect(reqResult.invitationIdentifier).toBe(
|
||||||
|
createResult.invitationIdentifier,
|
||||||
|
);
|
||||||
expect(spies.out).toHaveBeenCalled();
|
expect(spies.out).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -7,7 +7,12 @@ import { DEFAULT_SEED } from "../mocks/engine";
|
|||||||
|
|
||||||
import { handleMnemonicCommand } from "../../../src/cli/commands/mnemonic";
|
import { handleMnemonicCommand } from "../../../src/cli/commands/mnemonic";
|
||||||
import { CommandError } from "../../../src/cli/commands/types";
|
import { CommandError } from "../../../src/cli/commands/types";
|
||||||
import { createMockIO, createMockPaths, expectLogs, type LogExpectation } from "../mocks/command";
|
import {
|
||||||
|
createMockIO,
|
||||||
|
createMockPaths,
|
||||||
|
expectLogs,
|
||||||
|
type LogExpectation,
|
||||||
|
} from "../mocks/command";
|
||||||
import { BCHMnemonicURL } from "../../../src/utils/bch-mnemonic-url";
|
import { BCHMnemonicURL } from "../../../src/utils/bch-mnemonic-url";
|
||||||
|
|
||||||
type TestCase = {
|
type TestCase = {
|
||||||
@@ -116,31 +121,45 @@ describe("mnemonic commands", () => {
|
|||||||
rmSync(tempDir, { recursive: true, force: true });
|
rmSync(tempDir, { recursive: true, force: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
test.each(testCases)("mnemonic command: $inputs", async ({ inputs, options, shouldThrow, expectedEvent, expectedData, logs }) => {
|
test.each(testCases)(
|
||||||
const { io, spies } = createMockIO();
|
"mnemonic command: $inputs",
|
||||||
const paths = createMockPaths(tempDir);
|
async ({
|
||||||
|
inputs,
|
||||||
|
options,
|
||||||
|
shouldThrow,
|
||||||
|
expectedEvent,
|
||||||
|
expectedData,
|
||||||
|
logs,
|
||||||
|
}) => {
|
||||||
|
const { io, spies } = createMockIO();
|
||||||
|
const paths = createMockPaths(tempDir);
|
||||||
|
|
||||||
if (shouldThrow) {
|
if (shouldThrow) {
|
||||||
try {
|
try {
|
||||||
await handleMnemonicCommand({ io, paths }, inputs, options ?? {});
|
await handleMnemonicCommand({ io, paths }, inputs, options ?? {});
|
||||||
expect.fail("Expected command to throw");
|
expect.fail("Expected command to throw");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (expectedEvent) {
|
if (expectedEvent) {
|
||||||
expect(error).toBeInstanceOf(CommandError);
|
expect(error).toBeInstanceOf(CommandError);
|
||||||
expect((error as CommandError).event).toBe(expectedEvent);
|
expect((error as CommandError).event).toBe(expectedEvent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const result = await handleMnemonicCommand(
|
||||||
|
{ io, paths },
|
||||||
|
inputs,
|
||||||
|
options ?? {},
|
||||||
|
);
|
||||||
|
if (expectedData) {
|
||||||
|
Object.entries(expectedData).forEach(([key, value]) => {
|
||||||
|
expect(result[key as keyof typeof result]).toEqual(value);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
const result = await handleMnemonicCommand({ io, paths }, inputs, options ?? {});
|
|
||||||
if (expectedData) {
|
|
||||||
Object.entries(expectedData).forEach(([key, value]) => {
|
|
||||||
expect(result[key as keyof typeof result]).toEqual(value);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (logs) {
|
if (logs) {
|
||||||
expectLogs(spies, logs);
|
expectLogs(spies, logs);
|
||||||
}
|
}
|
||||||
});
|
},
|
||||||
|
);
|
||||||
});
|
});
|
||||||
@@ -3,14 +3,23 @@ import { mkdtempSync, rmSync } from "node:fs";
|
|||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
|
|
||||||
import { createMockAppService, createMockEngine, DEFAULT_SEED } from "../mocks/engine";
|
import {
|
||||||
|
createMockAppService,
|
||||||
|
createMockEngine,
|
||||||
|
DEFAULT_SEED,
|
||||||
|
} from "../mocks/engine";
|
||||||
import { type Engine } from "@xo-cash/engine";
|
import { type Engine } from "@xo-cash/engine";
|
||||||
import { p2pkhTemplate } from "../mocks/template-p2pkh";
|
import { p2pkhTemplate } from "../mocks/template-p2pkh";
|
||||||
import { AppService } from "../../../src/services/app";
|
import { AppService } from "../../../src/services/app";
|
||||||
|
|
||||||
import { handleReceiveCommand } from "../../../src/cli/commands/receive";
|
import { handleReceiveCommand } from "../../../src/cli/commands/receive";
|
||||||
import { CommandError } from "../../../src/cli/commands/types";
|
import { CommandError } from "../../../src/cli/commands/types";
|
||||||
import { createCommandDeps, createMockIO, expectLogs, type LogExpectation } from "../mocks/command";
|
import {
|
||||||
|
createCommandDeps,
|
||||||
|
createMockIO,
|
||||||
|
expectLogs,
|
||||||
|
type LogExpectation,
|
||||||
|
} from "../mocks/command";
|
||||||
|
|
||||||
type TestCase = {
|
type TestCase = {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -72,7 +81,8 @@ describe("receive command", () => {
|
|||||||
let tempDir: string;
|
let tempDir: string;
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
engine = await createMockEngine(DEFAULT_SEED);
|
const mockEngine = await createMockEngine(DEFAULT_SEED);
|
||||||
|
engine = mockEngine.engine;
|
||||||
await engine.importTemplate(p2pkhTemplate);
|
await engine.importTemplate(p2pkhTemplate);
|
||||||
|
|
||||||
app = await createMockAppService(engine);
|
app = await createMockAppService(engine);
|
||||||
@@ -85,30 +95,48 @@ describe("receive command", () => {
|
|||||||
rmSync(tempDir, { recursive: true, force: true });
|
rmSync(tempDir, { recursive: true, force: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
test.each(testCases)("$name", async ({ inputs, options, shouldThrow, expectedEvent, expectedData, logs }) => {
|
test.each(testCases)(
|
||||||
const { io, spies } = createMockIO();
|
"$name",
|
||||||
|
async ({
|
||||||
|
inputs,
|
||||||
|
options,
|
||||||
|
shouldThrow,
|
||||||
|
expectedEvent,
|
||||||
|
expectedData,
|
||||||
|
logs,
|
||||||
|
}) => {
|
||||||
|
const { io, spies } = createMockIO();
|
||||||
|
|
||||||
if (shouldThrow) {
|
if (shouldThrow) {
|
||||||
try {
|
try {
|
||||||
await handleReceiveCommand(createCommandDeps(app, io), inputs, options ?? {});
|
await handleReceiveCommand(
|
||||||
expect.fail("Expected command to throw");
|
createCommandDeps(app, io),
|
||||||
} catch (error) {
|
inputs,
|
||||||
if (expectedEvent) {
|
options ?? {},
|
||||||
expect(error).toBeInstanceOf(CommandError);
|
);
|
||||||
expect((error as CommandError).event).toBe(expectedEvent);
|
expect.fail("Expected command to throw");
|
||||||
|
} catch (error) {
|
||||||
|
if (expectedEvent) {
|
||||||
|
expect(error).toBeInstanceOf(CommandError);
|
||||||
|
expect((error as CommandError).event).toBe(expectedEvent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const result = await handleReceiveCommand(
|
||||||
|
createCommandDeps(app, io),
|
||||||
|
inputs,
|
||||||
|
options ?? {},
|
||||||
|
);
|
||||||
|
if (expectedData) {
|
||||||
|
Object.entries(expectedData).forEach(([key, value]) => {
|
||||||
|
expect(result[key as keyof typeof result]).toEqual(value);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
const result = await handleReceiveCommand(createCommandDeps(app, io), inputs, options ?? {});
|
|
||||||
if (expectedData) {
|
|
||||||
Object.entries(expectedData).forEach(([key, value]) => {
|
|
||||||
expect(result[key as keyof typeof result]).toEqual(value);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (logs) {
|
if (logs) {
|
||||||
expectLogs(spies, logs);
|
expectLogs(spies, logs);
|
||||||
}
|
}
|
||||||
});
|
},
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,14 +3,26 @@ import { mkdtempSync, rmSync } from "node:fs";
|
|||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
|
|
||||||
import { addFakeResource, createMockAppService, createMockEngine, DEFAULT_SEED, reserveResource } from "../mocks/engine";
|
import {
|
||||||
|
addFakeResource,
|
||||||
|
createMockAppService,
|
||||||
|
createMockEngine,
|
||||||
|
DEFAULT_SEED,
|
||||||
|
reserveResource,
|
||||||
|
} from "../mocks/engine";
|
||||||
import { type Engine } from "@xo-cash/engine";
|
import { type Engine } from "@xo-cash/engine";
|
||||||
import { p2pkhTemplate } from "../mocks/template-p2pkh";
|
import { p2pkhTemplate } from "../mocks/template-p2pkh";
|
||||||
import { AppService } from "../../../src/services/app";
|
import { AppService } from "../../../src/services/app";
|
||||||
|
|
||||||
import { handleResourceCommand } from "../../../src/cli/commands/resource";
|
import { handleResourceCommand } from "../../../src/cli/commands/resource";
|
||||||
import { CommandError } from "../../../src/cli/commands/types";
|
import { CommandError } from "../../../src/cli/commands/types";
|
||||||
import { createCommandDeps, createMockIO, expectLogs, type LogExpectation } from "../mocks/command";
|
import {
|
||||||
|
createCommandDeps,
|
||||||
|
createMockIO,
|
||||||
|
expectLogs,
|
||||||
|
type LogExpectation,
|
||||||
|
} from "../mocks/command";
|
||||||
|
import { State } from "@xo-cash/state";
|
||||||
|
|
||||||
type TestCase = {
|
type TestCase = {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -94,7 +106,10 @@ const testCases: TestCase[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "throws when unreserve called with non-existent UTXO",
|
name: "throws when unreserve called with non-existent UTXO",
|
||||||
inputs: ["unreserve", "0000000000000000000000000000000000000000000000000000000000000000:0"],
|
inputs: [
|
||||||
|
"unreserve",
|
||||||
|
"0000000000000000000000000000000000000000000000000000000000000000:0",
|
||||||
|
],
|
||||||
shouldThrow: true,
|
shouldThrow: true,
|
||||||
expectedEvent: "resource.unreserve.utxo_missing",
|
expectedEvent: "resource.unreserve.utxo_missing",
|
||||||
},
|
},
|
||||||
@@ -106,7 +121,8 @@ describe("resource command", () => {
|
|||||||
let tempDir: string;
|
let tempDir: string;
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
engine = await createMockEngine(DEFAULT_SEED);
|
const mockEngine = await createMockEngine(DEFAULT_SEED);
|
||||||
|
engine = mockEngine.engine;
|
||||||
await engine.importTemplate(p2pkhTemplate);
|
await engine.importTemplate(p2pkhTemplate);
|
||||||
|
|
||||||
app = await createMockAppService(engine);
|
app = await createMockAppService(engine);
|
||||||
@@ -119,41 +135,62 @@ describe("resource command", () => {
|
|||||||
rmSync(tempDir, { recursive: true, force: true });
|
rmSync(tempDir, { recursive: true, force: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
test.each(testCases)("$name", async ({ inputs, options, shouldThrow, expectedEvent, expectedData, logs }) => {
|
test.each(testCases)(
|
||||||
const { io, spies } = createMockIO();
|
"$name",
|
||||||
|
async ({
|
||||||
|
inputs,
|
||||||
|
options,
|
||||||
|
shouldThrow,
|
||||||
|
expectedEvent,
|
||||||
|
expectedData,
|
||||||
|
logs,
|
||||||
|
}) => {
|
||||||
|
const { io, spies } = createMockIO();
|
||||||
|
|
||||||
if (shouldThrow) {
|
if (shouldThrow) {
|
||||||
try {
|
try {
|
||||||
await handleResourceCommand(createCommandDeps(app, io), inputs, options ?? {});
|
await handleResourceCommand(
|
||||||
expect.fail("Expected command to throw");
|
createCommandDeps(app, io),
|
||||||
} catch (error) {
|
inputs,
|
||||||
if (expectedEvent) {
|
options ?? {},
|
||||||
expect(error).toBeInstanceOf(CommandError);
|
);
|
||||||
expect((error as CommandError).event).toBe(expectedEvent);
|
expect.fail("Expected command to throw");
|
||||||
|
} catch (error) {
|
||||||
|
if (expectedEvent) {
|
||||||
|
expect(error).toBeInstanceOf(CommandError);
|
||||||
|
expect((error as CommandError).event).toBe(expectedEvent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const result = await handleResourceCommand(
|
||||||
|
createCommandDeps(app, io),
|
||||||
|
inputs,
|
||||||
|
options ?? {},
|
||||||
|
);
|
||||||
|
if (expectedData) {
|
||||||
|
Object.entries(expectedData).forEach(([key, value]) => {
|
||||||
|
expect(result[key as keyof typeof result]).toEqual(value);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
const result = await handleResourceCommand(createCommandDeps(app, io), inputs, options ?? {});
|
|
||||||
if (expectedData) {
|
|
||||||
Object.entries(expectedData).forEach(([key, value]) => {
|
|
||||||
expect(result[key as keyof typeof result]).toEqual(value);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (logs) {
|
if (logs) {
|
||||||
expectLogs(spies, logs);
|
expectLogs(spies, logs);
|
||||||
}
|
}
|
||||||
});
|
},
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("resource command with populated data", () => {
|
describe("resource command with populated data", () => {
|
||||||
let engine: Engine;
|
let engine: Engine;
|
||||||
|
let state: State;
|
||||||
let app: AppService;
|
let app: AppService;
|
||||||
let tempDir: string;
|
let tempDir: string;
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
engine = await createMockEngine(DEFAULT_SEED);
|
const mockEngine = await createMockEngine(DEFAULT_SEED);
|
||||||
|
engine = mockEngine.engine;
|
||||||
|
state = mockEngine.state;
|
||||||
await engine.importTemplate(p2pkhTemplate);
|
await engine.importTemplate(p2pkhTemplate);
|
||||||
app = await createMockAppService(engine);
|
app = await createMockAppService(engine);
|
||||||
tempDir = mkdtempSync(path.join(tmpdir(), "xo-cli-resource-tests-"));
|
tempDir = mkdtempSync(path.join(tmpdir(), "xo-cli-resource-tests-"));
|
||||||
@@ -165,19 +202,23 @@ describe("resource command with populated data", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("list returns count when resources exist", async () => {
|
test("list returns count when resources exist", async () => {
|
||||||
await addFakeResource(engine, { valueSatoshis: 50000 });
|
await addFakeResource(state, { valueSatoshis: 50000 });
|
||||||
await addFakeResource(engine, { valueSatoshis: 25000 });
|
await addFakeResource(state, { valueSatoshis: 25000 });
|
||||||
|
|
||||||
const { io, spies } = createMockIO();
|
const { io, spies } = createMockIO();
|
||||||
const result = await handleResourceCommand(createCommandDeps(app, io), ["list"], {});
|
const result = await handleResourceCommand(
|
||||||
|
createCommandDeps(app, io),
|
||||||
|
["list"],
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
|
||||||
expect(result.count).toBe(2);
|
expect(result.count).toBe(2);
|
||||||
expectLogs(spies, [{ out: "Total resources: 2" }]);
|
expectLogs(spies, [{ out: "Total resources: 2" }]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("list shows total satoshis", async () => {
|
test("list shows total satoshis", async () => {
|
||||||
await addFakeResource(engine, { valueSatoshis: 50000 });
|
await addFakeResource(state, { valueSatoshis: 50000 });
|
||||||
await addFakeResource(engine, { valueSatoshis: 25000 });
|
await addFakeResource(state, { valueSatoshis: 25000 });
|
||||||
|
|
||||||
const { io, spies } = createMockIO();
|
const { io, spies } = createMockIO();
|
||||||
await handleResourceCommand(createCommandDeps(app, io), ["list"], {});
|
await handleResourceCommand(createCommandDeps(app, io), ["list"], {});
|
||||||
@@ -186,63 +227,99 @@ describe("resource command with populated data", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("list excludes reserved resources by default", async () => {
|
test("list excludes reserved resources by default", async () => {
|
||||||
await addFakeResource(engine, { valueSatoshis: 50000 });
|
await addFakeResource(state, { valueSatoshis: 50000 });
|
||||||
await addFakeResource(engine, { valueSatoshis: 25000, reservedBy: "inv-123" });
|
await addFakeResource(state, {
|
||||||
|
valueSatoshis: 25000,
|
||||||
|
reservedBy: "inv-123",
|
||||||
|
});
|
||||||
|
|
||||||
const { io } = createMockIO();
|
const { io } = createMockIO();
|
||||||
const result = await handleResourceCommand(createCommandDeps(app, io), ["list"], {});
|
const result = await handleResourceCommand(
|
||||||
|
createCommandDeps(app, io),
|
||||||
|
["list"],
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
|
||||||
expect(result.count).toBe(1);
|
expect(result.count).toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("list reserved shows only reserved resources", async () => {
|
test("list reserved shows only reserved resources", async () => {
|
||||||
await addFakeResource(engine, { valueSatoshis: 50000 });
|
await addFakeResource(state, { valueSatoshis: 50000 });
|
||||||
await addFakeResource(engine, { valueSatoshis: 25000, reservedBy: "inv-123" });
|
await addFakeResource(state, {
|
||||||
await addFakeResource(engine, { valueSatoshis: 10000, reservedBy: "inv-456" });
|
valueSatoshis: 25000,
|
||||||
|
reservedBy: "inv-123",
|
||||||
|
});
|
||||||
|
await addFakeResource(state, {
|
||||||
|
valueSatoshis: 10000,
|
||||||
|
reservedBy: "inv-456",
|
||||||
|
});
|
||||||
|
|
||||||
const { io, spies } = createMockIO();
|
const { io, spies } = createMockIO();
|
||||||
const result = await handleResourceCommand(createCommandDeps(app, io), ["list", "reserved"], {});
|
const result = await handleResourceCommand(
|
||||||
|
createCommandDeps(app, io),
|
||||||
|
["list", "reserved"],
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
|
||||||
expect(result.count).toBe(2);
|
expect(result.count).toBe(2);
|
||||||
expectLogs(spies, [{ out: "reserved for inv-123" }]);
|
expectLogs(spies, [{ out: "reserved for inv-123" }]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("list all shows both reserved and unreserved", async () => {
|
test("list all shows both reserved and unreserved", async () => {
|
||||||
await addFakeResource(engine, { valueSatoshis: 50000 });
|
await addFakeResource(state, { valueSatoshis: 50000 });
|
||||||
await addFakeResource(engine, { valueSatoshis: 25000, reservedBy: "inv-123" });
|
await addFakeResource(state, {
|
||||||
|
valueSatoshis: 25000,
|
||||||
|
reservedBy: "inv-123",
|
||||||
|
});
|
||||||
|
|
||||||
const { io } = createMockIO();
|
const { io } = createMockIO();
|
||||||
const result = await handleResourceCommand(createCommandDeps(app, io), ["list", "all"], {});
|
const result = await handleResourceCommand(
|
||||||
|
createCommandDeps(app, io),
|
||||||
|
["list", "all"],
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
|
||||||
expect(result.count).toBe(2);
|
expect(result.count).toBe(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("unreserve releases a reserved UTXO", async () => {
|
test("unreserve releases a reserved UTXO", async () => {
|
||||||
const resource = await addFakeResource(engine, { valueSatoshis: 25000, reservedBy: "inv-123" });
|
const resource = await addFakeResource(state, {
|
||||||
|
valueSatoshis: 25000,
|
||||||
|
reservedBy: "inv-123",
|
||||||
|
});
|
||||||
|
|
||||||
const { io, spies } = createMockIO();
|
const { io, spies } = createMockIO();
|
||||||
await handleResourceCommand(
|
await handleResourceCommand(
|
||||||
createCommandDeps(app, io),
|
createCommandDeps(app, io),
|
||||||
["unreserve", `${resource.outpointTransactionHash}:${resource.outpointIndex}`],
|
[
|
||||||
|
"unreserve",
|
||||||
|
`${resource.outpointTransactionHash}:${resource.outpointIndex}`,
|
||||||
|
],
|
||||||
{},
|
{},
|
||||||
);
|
);
|
||||||
|
|
||||||
expectLogs(spies, [{ out: "Unreserved" }, { out: "was reserved for inv-123" }]);
|
expectLogs(spies, [
|
||||||
|
{ out: "Unreserved" },
|
||||||
|
{ out: "was reserved for inv-123" },
|
||||||
|
]);
|
||||||
|
|
||||||
const resources = await engine.listUnspentOutputsData();
|
const resources = await engine.listUnspentOutputsData();
|
||||||
const target = resources.find(
|
const target = resources.find(
|
||||||
r => r.outpointTransactionHash === resource.outpointTransactionHash,
|
(r) => r.outpointTransactionHash === resource.outpointTransactionHash,
|
||||||
);
|
);
|
||||||
expect(target?.reservedBy).toBeUndefined();
|
expect(target?.reservedBy).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("unreserve reports when UTXO is not reserved", async () => {
|
test("unreserve reports when UTXO is not reserved", async () => {
|
||||||
const resource = await addFakeResource(engine, { valueSatoshis: 25000 });
|
const resource = await addFakeResource(state, { valueSatoshis: 25000 });
|
||||||
|
|
||||||
const { io, spies } = createMockIO();
|
const { io, spies } = createMockIO();
|
||||||
await handleResourceCommand(
|
await handleResourceCommand(
|
||||||
createCommandDeps(app, io),
|
createCommandDeps(app, io),
|
||||||
["unreserve", `${resource.outpointTransactionHash}:${resource.outpointIndex}`],
|
[
|
||||||
|
"unreserve",
|
||||||
|
`${resource.outpointTransactionHash}:${resource.outpointIndex}`,
|
||||||
|
],
|
||||||
{},
|
{},
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -250,23 +327,33 @@ describe("resource command with populated data", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("unreserve-all releases all reserved UTXOs", async () => {
|
test("unreserve-all releases all reserved UTXOs", async () => {
|
||||||
await addFakeResource(engine, { valueSatoshis: 50000 });
|
await addFakeResource(state, { valueSatoshis: 50000 });
|
||||||
await addFakeResource(engine, { valueSatoshis: 25000, reservedBy: "inv-123" });
|
await addFakeResource(state, {
|
||||||
await addFakeResource(engine, { valueSatoshis: 10000, reservedBy: "inv-456" });
|
valueSatoshis: 25000,
|
||||||
|
reservedBy: "inv-123",
|
||||||
|
});
|
||||||
|
await addFakeResource(state, {
|
||||||
|
valueSatoshis: 10000,
|
||||||
|
reservedBy: "inv-456",
|
||||||
|
});
|
||||||
|
|
||||||
const { io, spies } = createMockIO();
|
const { io, spies } = createMockIO();
|
||||||
const result = await handleResourceCommand(createCommandDeps(app, io), ["unreserve-all"], {});
|
const result = await handleResourceCommand(
|
||||||
|
createCommandDeps(app, io),
|
||||||
|
["unreserve-all"],
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
|
||||||
expect(result.count).toBe(2);
|
expect(result.count).toBe(2);
|
||||||
expectLogs(spies, [{ out: "Unreserved" }, { out: "2" }]);
|
expectLogs(spies, [{ out: "Unreserved" }, { out: "2" }]);
|
||||||
|
|
||||||
const resources = await engine.listUnspentOutputsData();
|
const resources = await engine.listUnspentOutputsData();
|
||||||
const reserved = resources.filter(r => r.reservedBy);
|
const reserved = resources.filter((r) => r.reservedBy);
|
||||||
expect(reserved).toHaveLength(0);
|
expect(reserved).toHaveLength(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("list displays outpoint information", async () => {
|
test("list displays outpoint information", async () => {
|
||||||
const resource = await addFakeResource(engine, { valueSatoshis: 12345 });
|
const resource = await addFakeResource(state, { valueSatoshis: 12345 });
|
||||||
|
|
||||||
const { io, spies } = createMockIO();
|
const { io, spies } = createMockIO();
|
||||||
await handleResourceCommand(createCommandDeps(app, io), ["list"], {});
|
await handleResourceCommand(createCommandDeps(app, io), ["list"], {});
|
||||||
|
|||||||
85
tests/cli/commands/settings.test.ts
Normal file
85
tests/cli/commands/settings.test.ts
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
/// <reference types="node" />
|
||||||
|
|
||||||
|
import { afterEach, beforeEach, describe, expect, test } from "vitest";
|
||||||
|
import { mkdtempSync, readFileSync, rmSync } from "node:fs";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
import { handleSettingsCommand } from "../../../src/cli/commands/settings";
|
||||||
|
import { CommandError } from "../../../src/cli/commands/types";
|
||||||
|
import { createMockIO, createMockPaths } from "../mocks/command";
|
||||||
|
|
||||||
|
describe("settings command", () => {
|
||||||
|
let tempDir: string;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
tempDir = mkdtempSync(path.join(tmpdir(), "xo-cli-settings-command-test-"));
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
rmSync(tempDir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("shows default settings when .wallet does not exist", async () => {
|
||||||
|
const { io, capture } = createMockIO();
|
||||||
|
const paths = createMockPaths(tempDir);
|
||||||
|
|
||||||
|
const result = await handleSettingsCommand({ io, paths }, ["show"], {});
|
||||||
|
|
||||||
|
expect(result).toEqual({ currency: "USD" });
|
||||||
|
expect(capture.out.join("\n")).toContain("currency");
|
||||||
|
expect(capture.out.join("\n")).toContain("USD");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("sets and gets currency", async () => {
|
||||||
|
const { io, capture } = createMockIO();
|
||||||
|
const paths = createMockPaths(tempDir);
|
||||||
|
|
||||||
|
await handleSettingsCommand({ io, paths }, ["set", "currency", "aud"], {});
|
||||||
|
const getResult = await handleSettingsCommand(
|
||||||
|
{ io, paths },
|
||||||
|
["get", "currency"],
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(getResult).toEqual({ key: "currency", value: "AUD" });
|
||||||
|
expect(capture.out).toContain("Updated currency: AUD");
|
||||||
|
expect(capture.out).toContain("AUD");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("sets default-mnemonic and persists JSON .wallet", async () => {
|
||||||
|
const { io } = createMockIO();
|
||||||
|
const paths = createMockPaths(tempDir);
|
||||||
|
|
||||||
|
await handleSettingsCommand(
|
||||||
|
{ io, paths },
|
||||||
|
["set", "default-mnemonic", "mnemonic-primary"],
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
|
||||||
|
const persisted = JSON.parse(
|
||||||
|
readFileSync(paths.walletConfigPath, "utf8"),
|
||||||
|
) as {
|
||||||
|
currency: string;
|
||||||
|
"default-mnemonic"?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(persisted).toEqual({
|
||||||
|
currency: "USD",
|
||||||
|
"default-mnemonic": "mnemonic-primary",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("throws command error for unknown subcommand", async () => {
|
||||||
|
const { io } = createMockIO();
|
||||||
|
const paths = createMockPaths(tempDir);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await handleSettingsCommand({ io, paths }, ["unknown"], {});
|
||||||
|
expect.fail("Expected settings command to throw");
|
||||||
|
} catch (error) {
|
||||||
|
expect(error).toBeInstanceOf(CommandError);
|
||||||
|
expect((error as CommandError).event).toBe("settings.subcommand.unknown");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,16 +1,28 @@
|
|||||||
import { expect, test, describe, beforeEach, afterEach } from "vitest";
|
import { expect, test, describe, beforeEach, afterEach } from "vitest";
|
||||||
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
|
|
||||||
import { createMockAppService, createMockEngine, DEFAULT_SEED } from "../mocks/engine";
|
import {
|
||||||
|
createMockAppService,
|
||||||
|
createMockEngine,
|
||||||
|
DEFAULT_SEED,
|
||||||
|
} from "../mocks/engine";
|
||||||
import { type Engine } from "@xo-cash/engine";
|
import { type Engine } from "@xo-cash/engine";
|
||||||
import { p2pkhTemplate, p2pkhTemplateIdentifier } from "../mocks/template-p2pkh";
|
import {
|
||||||
|
p2pkhTemplate,
|
||||||
|
p2pkhTemplateIdentifier,
|
||||||
|
} from "../mocks/template-p2pkh";
|
||||||
import { AppService } from "../../../src/services/app";
|
import { AppService } from "../../../src/services/app";
|
||||||
|
|
||||||
import { handleTemplateCommand } from "../../../src/cli/commands/template";
|
import { handleTemplateCommand } from "../../../src/cli/commands/template";
|
||||||
import { CommandError } from "../../../src/cli/commands/types";
|
import { CommandError } from "../../../src/cli/commands/types";
|
||||||
import { createCommandDeps, createMockIO, expectLogs, type LogExpectation } from "../mocks/command";
|
import {
|
||||||
|
createCommandDeps,
|
||||||
|
createMockIO,
|
||||||
|
expectLogs,
|
||||||
|
type LogExpectation,
|
||||||
|
} from "../mocks/command";
|
||||||
|
|
||||||
type TestCase = {
|
type TestCase = {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -86,6 +98,13 @@ const testCases: TestCase[] = [
|
|||||||
shouldThrow: false,
|
shouldThrow: false,
|
||||||
expectedData: {},
|
expectedData: {},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "export returns raw template json to stdout",
|
||||||
|
inputs: ["export", p2pkhTemplateIdentifier],
|
||||||
|
shouldThrow: false,
|
||||||
|
expectedData: {},
|
||||||
|
logs: [{ out: '"name":"Wallet (P2PKH)"' }],
|
||||||
|
},
|
||||||
// Error cases - subcommand
|
// Error cases - subcommand
|
||||||
{
|
{
|
||||||
name: "throws when no subcommand provided",
|
name: "throws when no subcommand provided",
|
||||||
@@ -112,6 +131,18 @@ const testCases: TestCase[] = [
|
|||||||
shouldThrow: true,
|
shouldThrow: true,
|
||||||
expectedEvent: "template.import.file_not_found",
|
expectedEvent: "template.import.file_not_found",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "throws when export called without template identifier",
|
||||||
|
inputs: ["export"],
|
||||||
|
shouldThrow: true,
|
||||||
|
expectedEvent: "template.export.identifier_missing",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "throws when export called with unknown template",
|
||||||
|
inputs: ["export", "unknown-template"],
|
||||||
|
shouldThrow: true,
|
||||||
|
expectedEvent: "template.export.not_found",
|
||||||
|
},
|
||||||
// Error cases - list category
|
// Error cases - list category
|
||||||
{
|
{
|
||||||
name: "throws when list category called without template identifier",
|
name: "throws when list category called without template identifier",
|
||||||
@@ -171,7 +202,8 @@ describe("template command", () => {
|
|||||||
let tempDir: string;
|
let tempDir: string;
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
engine = await createMockEngine(DEFAULT_SEED);
|
const mockEngine = await createMockEngine(DEFAULT_SEED);
|
||||||
|
engine = mockEngine.engine;
|
||||||
await engine.importTemplate(p2pkhTemplate);
|
await engine.importTemplate(p2pkhTemplate);
|
||||||
|
|
||||||
app = await createMockAppService(engine);
|
app = await createMockAppService(engine);
|
||||||
@@ -184,32 +216,50 @@ describe("template command", () => {
|
|||||||
rmSync(tempDir, { recursive: true, force: true });
|
rmSync(tempDir, { recursive: true, force: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
test.each(testCases)("$name", async ({ inputs, options, shouldThrow, expectedEvent, expectedData, logs }) => {
|
test.each(testCases)(
|
||||||
const { io, spies } = createMockIO();
|
"$name",
|
||||||
|
async ({
|
||||||
|
inputs,
|
||||||
|
options,
|
||||||
|
shouldThrow,
|
||||||
|
expectedEvent,
|
||||||
|
expectedData,
|
||||||
|
logs,
|
||||||
|
}) => {
|
||||||
|
const { io, spies } = createMockIO();
|
||||||
|
|
||||||
if (shouldThrow) {
|
if (shouldThrow) {
|
||||||
try {
|
try {
|
||||||
await handleTemplateCommand(createCommandDeps(app, io), inputs, options ?? {});
|
await handleTemplateCommand(
|
||||||
expect.fail("Expected command to throw");
|
createCommandDeps(app, io),
|
||||||
} catch (error) {
|
inputs,
|
||||||
if (expectedEvent) {
|
options ?? {},
|
||||||
expect(error).toBeInstanceOf(CommandError);
|
);
|
||||||
expect((error as CommandError).event).toBe(expectedEvent);
|
expect.fail("Expected command to throw");
|
||||||
|
} catch (error) {
|
||||||
|
if (expectedEvent) {
|
||||||
|
expect(error).toBeInstanceOf(CommandError);
|
||||||
|
expect((error as CommandError).event).toBe(expectedEvent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const result = await handleTemplateCommand(
|
||||||
|
createCommandDeps(app, io),
|
||||||
|
inputs,
|
||||||
|
options ?? {},
|
||||||
|
);
|
||||||
|
if (expectedData) {
|
||||||
|
Object.entries(expectedData).forEach(([key, value]) => {
|
||||||
|
expect(result[key as keyof typeof result]).toEqual(value);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
const result = await handleTemplateCommand(createCommandDeps(app, io), inputs, options ?? {});
|
|
||||||
if (expectedData) {
|
|
||||||
Object.entries(expectedData).forEach(([key, value]) => {
|
|
||||||
expect(result[key as keyof typeof result]).toEqual(value);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (logs) {
|
if (logs) {
|
||||||
expectLogs(spies, logs);
|
expectLogs(spies, logs);
|
||||||
}
|
}
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
test("import imports template from file", async () => {
|
test("import imports template from file", async () => {
|
||||||
const templatePath = path.join(tempDir, "test-template.json");
|
const templatePath = path.join(tempDir, "test-template.json");
|
||||||
@@ -232,4 +282,42 @@ describe("template command", () => {
|
|||||||
process.chdir(originalCwd);
|
process.chdir(originalCwd);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("export prints exact engine template JSON to stdout", async () => {
|
||||||
|
const { io, capture } = createMockIO();
|
||||||
|
const expectedTemplate = await engine.getTemplate(p2pkhTemplateIdentifier);
|
||||||
|
expect(expectedTemplate).toBeDefined();
|
||||||
|
|
||||||
|
await handleTemplateCommand(
|
||||||
|
createCommandDeps(app, io),
|
||||||
|
["export", p2pkhTemplateIdentifier],
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(capture.out[0]).toBe(JSON.stringify(expectedTemplate));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("export writes exact engine template JSON to file", async () => {
|
||||||
|
const outputFile = "exported-template.json";
|
||||||
|
const outputPath = path.join(tempDir, outputFile);
|
||||||
|
const { io } = createMockIO();
|
||||||
|
const expectedTemplate = await engine.getTemplate(p2pkhTemplateIdentifier);
|
||||||
|
expect(expectedTemplate).toBeDefined();
|
||||||
|
|
||||||
|
const originalCwd = process.cwd();
|
||||||
|
process.chdir(tempDir);
|
||||||
|
try {
|
||||||
|
const result = await handleTemplateCommand(
|
||||||
|
createCommandDeps(app, io),
|
||||||
|
["export", p2pkhTemplateIdentifier],
|
||||||
|
{ output: outputFile },
|
||||||
|
);
|
||||||
|
|
||||||
|
const exportedTemplate = readFileSync(outputPath, "utf8");
|
||||||
|
expect(exportedTemplate).toBe(JSON.stringify(expectedTemplate));
|
||||||
|
expect(result.outputFile).toBe(path.resolve(process.cwd(), outputFile));
|
||||||
|
} finally {
|
||||||
|
process.chdir(originalCwd);
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,12 @@
|
|||||||
import { expect, test, describe, beforeEach, afterEach } from "vitest";
|
import { expect, test, describe, beforeEach, afterEach } from "vitest";
|
||||||
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
import {
|
||||||
|
existsSync,
|
||||||
|
mkdirSync,
|
||||||
|
readFileSync,
|
||||||
|
realpathSync,
|
||||||
|
rmSync,
|
||||||
|
writeFileSync,
|
||||||
|
} from "node:fs";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
|
|
||||||
@@ -12,8 +19,8 @@ import {
|
|||||||
} from "../../src/cli/mnemonic";
|
} from "../../src/cli/mnemonic";
|
||||||
import { BCHMnemonicURL } from "../../src/utils/bch-mnemonic-url";
|
import { BCHMnemonicURL } from "../../src/utils/bch-mnemonic-url";
|
||||||
|
|
||||||
const TEST_SEED = "page pencil stock planet limb cluster assault speak off joke private pioneer";
|
const TEST_SEED =
|
||||||
|
"oven crop same above under tower promote decrease vocal pretty require slow";
|
||||||
describe("mnemonic utilities", () => {
|
describe("mnemonic utilities", () => {
|
||||||
let tempDir: string;
|
let tempDir: string;
|
||||||
|
|
||||||
@@ -47,7 +54,7 @@ describe("mnemonic utilities", () => {
|
|||||||
test("creates a mnemonic file with auto-generated name", () => {
|
test("creates a mnemonic file with auto-generated name", () => {
|
||||||
const filename = createMnemonicFile(tempDir, TEST_SEED);
|
const filename = createMnemonicFile(tempDir, TEST_SEED);
|
||||||
|
|
||||||
expect(filename).toMatch(/^mnemonic-page$/);
|
expect(filename).toMatch(/^mnemonic-oven$/);
|
||||||
expect(existsSync(path.join(tempDir, filename))).toBe(true);
|
expect(existsSync(path.join(tempDir, filename))).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -68,7 +75,11 @@ describe("mnemonic utilities", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("sanitizes filename to basename only", () => {
|
test("sanitizes filename to basename only", () => {
|
||||||
const filename = createMnemonicFile(tempDir, TEST_SEED, "../../../evil-path");
|
const filename = createMnemonicFile(
|
||||||
|
tempDir,
|
||||||
|
TEST_SEED,
|
||||||
|
"../../../evil-path",
|
||||||
|
);
|
||||||
|
|
||||||
expect(filename).toBe("evil-path");
|
expect(filename).toBe("evil-path");
|
||||||
expect(existsSync(path.join(tempDir, "evil-path"))).toBe(true);
|
expect(existsSync(path.join(tempDir, "evil-path"))).toBe(true);
|
||||||
@@ -95,8 +106,19 @@ describe("mnemonic utilities", () => {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
writeFileSync(path.join(tempDir, "mnemonic-relative"), "test");
|
writeFileSync(path.join(tempDir, "mnemonic-relative"), "test");
|
||||||
const resolved = resolveMnemonicFilePath("/nonexistent", "mnemonic-relative");
|
const resolved = resolveMnemonicFilePath(
|
||||||
expect(resolved).toBe(path.join(tempDir, "mnemonic-relative"));
|
"/nonexistent",
|
||||||
|
"mnemonic-relative",
|
||||||
|
);
|
||||||
|
|
||||||
|
// Due to some weird MacOS behavior we need to use realpathSync to get the correct path
|
||||||
|
// Basically, tmpDir() returns a symlink, but moving to that path puts us at `/private/${tmpDir()}`
|
||||||
|
const expectedPath = realpathSync(
|
||||||
|
path.join(tempDir, "mnemonic-relative"),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Compare to the expected path
|
||||||
|
expect(resolved).toBe(expectedPath);
|
||||||
} finally {
|
} finally {
|
||||||
process.chdir(originalCwd);
|
process.chdir(originalCwd);
|
||||||
}
|
}
|
||||||
@@ -110,15 +132,18 @@ describe("mnemonic utilities", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("throws when file not found anywhere", () => {
|
test("throws when file not found anywhere", () => {
|
||||||
expect(() => resolveMnemonicFilePath(tempDir, "nonexistent-file")).toThrow(
|
expect(() =>
|
||||||
/Mnemonic file not found/,
|
resolveMnemonicFilePath(tempDir, "nonexistent-file"),
|
||||||
);
|
).toThrow(/Mnemonic file not found/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("strips path components and looks up basename in mnemonicsDir", () => {
|
test("strips path components and looks up basename in mnemonicsDir", () => {
|
||||||
writeFileSync(path.join(tempDir, "mnemonic-basename"), "test");
|
writeFileSync(path.join(tempDir, "mnemonic-basename"), "test");
|
||||||
|
|
||||||
const resolved = resolveMnemonicFilePath(tempDir, "some/path/mnemonic-basename");
|
const resolved = resolveMnemonicFilePath(
|
||||||
|
tempDir,
|
||||||
|
"some/path/mnemonic-basename",
|
||||||
|
);
|
||||||
expect(resolved).toBe(path.join(tempDir, "mnemonic-basename"));
|
expect(resolved).toBe(path.join(tempDir, "mnemonic-basename"));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -140,11 +165,16 @@ describe("mnemonic utilities", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("throws when file not found", () => {
|
test("throws when file not found", () => {
|
||||||
expect(() => loadMnemonic(tempDir, "nonexistent")).toThrow(/Mnemonic file not found/);
|
expect(() => loadMnemonic(tempDir, "nonexistent")).toThrow(
|
||||||
|
/Mnemonic file not found/,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("throws when file contains invalid data", () => {
|
test("throws when file contains invalid data", () => {
|
||||||
writeFileSync(path.join(tempDir, "mnemonic-invalid"), "not a valid mnemonic url");
|
writeFileSync(
|
||||||
|
path.join(tempDir, "mnemonic-invalid"),
|
||||||
|
"not a valid mnemonic url",
|
||||||
|
);
|
||||||
|
|
||||||
expect(() => loadMnemonic(tempDir, "mnemonic-invalid")).toThrow();
|
expect(() => loadMnemonic(tempDir, "mnemonic-invalid")).toThrow();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -92,27 +92,36 @@ export const createMockIO = (): MockIO => {
|
|||||||
* @param spies - The mock IO spies from createMockIO
|
* @param spies - The mock IO spies from createMockIO
|
||||||
* @param logs - Array of log expectations to validate
|
* @param logs - Array of log expectations to validate
|
||||||
*/
|
*/
|
||||||
export const expectLogs = (spies: MockIOSpies, logs: LogExpectation[]): void => {
|
export const expectLogs = (
|
||||||
|
spies: MockIOSpies,
|
||||||
|
logs: LogExpectation[],
|
||||||
|
): void => {
|
||||||
for (const log of logs) {
|
for (const log of logs) {
|
||||||
if (log.out !== undefined) {
|
if (log.out !== undefined) {
|
||||||
if (log.exact) {
|
if (log.exact) {
|
||||||
expect(spies.out).toHaveBeenCalledWith(log.out);
|
expect(spies.out).toHaveBeenCalledWith(log.out);
|
||||||
} else {
|
} else {
|
||||||
expect(spies.out).toHaveBeenCalledWith(expect.stringContaining(log.out));
|
expect(spies.out).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining(log.out),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (log.err !== undefined) {
|
if (log.err !== undefined) {
|
||||||
if (log.exact) {
|
if (log.exact) {
|
||||||
expect(spies.err).toHaveBeenCalledWith(log.err);
|
expect(spies.err).toHaveBeenCalledWith(log.err);
|
||||||
} else {
|
} else {
|
||||||
expect(spies.err).toHaveBeenCalledWith(expect.stringContaining(log.err));
|
expect(spies.err).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining(log.err),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (log.verbose !== undefined) {
|
if (log.verbose !== undefined) {
|
||||||
if (log.exact) {
|
if (log.exact) {
|
||||||
expect(spies.verbose).toHaveBeenCalledWith(log.verbose);
|
expect(spies.verbose).toHaveBeenCalledWith(log.verbose);
|
||||||
} else {
|
} else {
|
||||||
expect(spies.verbose).toHaveBeenCalledWith(expect.stringContaining(log.verbose));
|
expect(spies.verbose).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining(log.verbose),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
/**
|
||||||
|
* Mock Electrum service for testing.
|
||||||
|
* NOTE & TODO: Do we even need this in the actual app? I forget why we had this, but it seems like its just overly complicating things
|
||||||
|
* And we end up in stupid situations where we are creating a mock for a single function class.
|
||||||
|
*/
|
||||||
export class MockElectrumService {
|
export class MockElectrumService {
|
||||||
constructor() {}
|
constructor() {}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,15 @@
|
|||||||
|
// Node js tool for temp dir
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
|
||||||
import { BlockchainMonitor, Engine } from "@xo-cash/engine";
|
import { BlockchainMonitor, Engine } from "@xo-cash/engine";
|
||||||
|
|
||||||
import { createStorageAdapter, State, StorageType, type UnspentOutputData } from "@xo-cash/state";
|
import {
|
||||||
|
createStorageAdapter,
|
||||||
|
State,
|
||||||
|
StorageType,
|
||||||
|
UnspentOutputStatus,
|
||||||
|
type UnspentOutputData,
|
||||||
|
} from "@xo-cash/state";
|
||||||
import { InMemoryBlockchainProvider } from "@xo-cash/engine";
|
import { InMemoryBlockchainProvider } from "@xo-cash/engine";
|
||||||
import { convertMnemonicToSeedBytes } from "@xo-cash/crypto";
|
import { convertMnemonicToSeedBytes } from "@xo-cash/crypto";
|
||||||
|
|
||||||
@@ -8,8 +17,12 @@ import { binToHex, sha256 } from "@bitauth/libauth";
|
|||||||
import { AppService } from "../../../src/services/app";
|
import { AppService } from "../../../src/services/app";
|
||||||
import { InMemoryStorage } from "../../../src/services/storage";
|
import { InMemoryStorage } from "../../../src/services/storage";
|
||||||
import { MockElectrumService } from "./electrum-service";
|
import { MockElectrumService } from "./electrum-service";
|
||||||
|
import { MockRatesService } from "./rates-service";
|
||||||
|
import { RatesService } from "../../../src/services/rates";
|
||||||
|
import { SettingsService } from "../../../src/services/settings";
|
||||||
|
|
||||||
export const DEFAULT_SEED = "page pencil stock planet limb cluster assault speak off joke private pioneer";
|
export const DEFAULT_SEED =
|
||||||
|
"oven crop same above under tower promote decrease vocal pretty require slow";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Options for creating a fake resource (UTXO) in tests.
|
* Options for creating a fake resource (UTXO) in tests.
|
||||||
@@ -39,7 +52,9 @@ export type FakeResourceOptions = {
|
|||||||
export const randomTxHash = (): string => {
|
export const randomTxHash = (): string => {
|
||||||
const bytes = new Uint8Array(32);
|
const bytes = new Uint8Array(32);
|
||||||
crypto.getRandomValues(bytes);
|
crypto.getRandomValues(bytes);
|
||||||
return Array.from(bytes).map(b => b.toString(16).padStart(2, "0")).join("");
|
return Array.from(bytes)
|
||||||
|
.map((b) => b.toString(16).padStart(2, "0"))
|
||||||
|
.join("");
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -49,24 +64,24 @@ export const randomTxHash = (): string => {
|
|||||||
* @returns The created UnspentOutputData object.
|
* @returns The created UnspentOutputData object.
|
||||||
*/
|
*/
|
||||||
export const addFakeResource = async (
|
export const addFakeResource = async (
|
||||||
engine: Engine,
|
state: State,
|
||||||
options: FakeResourceOptions = {},
|
options: FakeResourceOptions = {},
|
||||||
): Promise<UnspentOutputData> => {
|
): Promise<UnspentOutputData> => {
|
||||||
const resource: UnspentOutputData = {
|
const resource: UnspentOutputData = {
|
||||||
status: "confirmed",
|
status: UnspentOutputStatus.CONFIRMED,
|
||||||
selectable: true,
|
selectable: true,
|
||||||
privacy: false,
|
privacy: false,
|
||||||
templateIdentifier: options.templateIdentifier ?? "test-template",
|
|
||||||
outputIdentifier: options.outputIdentifier ?? "receiveOutput",
|
|
||||||
outpointIndex: options.outpointIndex ?? 0,
|
outpointIndex: options.outpointIndex ?? 0,
|
||||||
outpointTransactionHash: options.outpointTransactionHash ?? randomTxHash(),
|
outpointTransactionHash: options.outpointTransactionHash ?? randomTxHash(),
|
||||||
minedAtHeight: options.minedAtHeight ?? 800000,
|
minedAtHeight: options.minedAtHeight ?? 800000,
|
||||||
valueSatoshis: options.valueSatoshis ?? 10000,
|
valueSatoshis: options.valueSatoshis ?? 10000,
|
||||||
lockingBytecode: options.lockingBytecode ?? "76a914000000000000000000000000000000000000000088ac",
|
scriptHash:
|
||||||
|
options.lockingBytecode ??
|
||||||
|
"76a914000000000000000000000000000000000000000088ac",
|
||||||
reservedBy: options.reservedBy,
|
reservedBy: options.reservedBy,
|
||||||
};
|
};
|
||||||
|
|
||||||
await engine.state.storeUnspentOutputData(resource);
|
await state.storeUnspentOutputData(resource);
|
||||||
return resource;
|
return resource;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -78,12 +93,12 @@ export const addFakeResource = async (
|
|||||||
* @param invitationIdentifier - The invitation identifier to reserve for.
|
* @param invitationIdentifier - The invitation identifier to reserve for.
|
||||||
*/
|
*/
|
||||||
export const reserveResource = async (
|
export const reserveResource = async (
|
||||||
engine: Engine,
|
state: State,
|
||||||
outpointTransactionHash: string,
|
outpointTransactionHash: string,
|
||||||
outpointIndex: number,
|
outpointIndex: number,
|
||||||
invitationIdentifier: string,
|
invitationIdentifier: string,
|
||||||
): Promise<void> => {
|
): Promise<void> => {
|
||||||
await engine.state.executeBulkUnspentOutputReservation(
|
await state.executeBulkUnspentOutputReservation(
|
||||||
[{ outpointTransactionHash, outpointIndex }],
|
[{ outpointTransactionHash, outpointIndex }],
|
||||||
true,
|
true,
|
||||||
invitationIdentifier,
|
invitationIdentifier,
|
||||||
@@ -98,12 +113,12 @@ export const reserveResource = async (
|
|||||||
* @param invitationIdentifier - The invitation identifier to unreserve from.
|
* @param invitationIdentifier - The invitation identifier to unreserve from.
|
||||||
*/
|
*/
|
||||||
export const unreserveResource = async (
|
export const unreserveResource = async (
|
||||||
engine: Engine,
|
state: State,
|
||||||
outpointTransactionHash: string,
|
outpointTransactionHash: string,
|
||||||
outpointIndex: number,
|
outpointIndex: number,
|
||||||
invitationIdentifier: string,
|
invitationIdentifier: string,
|
||||||
): Promise<void> => {
|
): Promise<void> => {
|
||||||
await engine.state.executeBulkUnspentOutputReservation(
|
await state.executeBulkUnspentOutputReservation(
|
||||||
[{ outpointTransactionHash, outpointIndex }],
|
[{ outpointTransactionHash, outpointIndex }],
|
||||||
false,
|
false,
|
||||||
invitationIdentifier,
|
invitationIdentifier,
|
||||||
@@ -118,7 +133,7 @@ export const unreserveResource = async (
|
|||||||
export const createMockEngine = async (seed: string) => {
|
export const createMockEngine = async (seed: string) => {
|
||||||
// Create the in-memory storage adapter.
|
// Create the in-memory storage adapter.
|
||||||
const storage = await createStorageAdapter({
|
const storage = await createStorageAdapter({
|
||||||
storageType: StorageType.INMEMORY,
|
storageType: "inmemory",
|
||||||
accountHash: binToHex(sha256.hash(convertMnemonicToSeedBytes(seed))),
|
accountHash: binToHex(sha256.hash(convertMnemonicToSeedBytes(seed))),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -130,10 +145,7 @@ export const createMockEngine = async (seed: string) => {
|
|||||||
|
|
||||||
// Create the in-memory blockchain provider.
|
// Create the in-memory blockchain provider.
|
||||||
const blockchainProvider = new InMemoryBlockchainProvider();
|
const blockchainProvider = new InMemoryBlockchainProvider();
|
||||||
await blockchainProvider.initialize({
|
await blockchainProvider.initialize();
|
||||||
applicationIdentifier: "xo-cli-tests",
|
|
||||||
electrumOptions: {},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Create the blockchain monitor instance.
|
// Create the blockchain monitor instance.
|
||||||
const blockchainMonitor = new BlockchainMonitor(state, blockchainProvider);
|
const blockchainMonitor = new BlockchainMonitor(state, blockchainProvider);
|
||||||
@@ -143,13 +155,21 @@ export const createMockEngine = async (seed: string) => {
|
|||||||
const engine = new Engine(seed, state, blockchainMonitor, blockchainProvider);
|
const engine = new Engine(seed, state, blockchainMonitor, blockchainProvider);
|
||||||
await engine.initializeStateSync();
|
await engine.initializeStateSync();
|
||||||
|
|
||||||
return engine;
|
return { engine, state, blockchainMonitor, blockchainProvider };
|
||||||
};
|
};
|
||||||
|
|
||||||
export const createMockAppService = async (engine: Engine) => {
|
export const createMockAppService = async (engine: Engine) => {
|
||||||
|
const settings = new SettingsService(
|
||||||
|
`${tmpdir()}/xo-cli-tests-settings.json`,
|
||||||
|
);
|
||||||
|
settings.setCurrency("USD");
|
||||||
|
|
||||||
const storage = await InMemoryStorage.create();
|
const storage = await InMemoryStorage.create();
|
||||||
|
|
||||||
const electrum = new MockElectrumService();
|
const mockRates = new MockRatesService();
|
||||||
|
const rates = new RatesService(mockRates, settings);
|
||||||
|
|
||||||
|
const mockElectrum = new MockElectrumService();
|
||||||
|
|
||||||
const config = {
|
const config = {
|
||||||
syncServerUrl: "http://localhost:3000",
|
syncServerUrl: "http://localhost:3000",
|
||||||
@@ -160,5 +180,5 @@ export const createMockAppService = async (engine: Engine) => {
|
|||||||
invitationStoragePath: "test-invitations.db",
|
invitationStoragePath: "test-invitations.db",
|
||||||
};
|
};
|
||||||
|
|
||||||
return new AppService(engine, storage, config, electrum);
|
return new AppService(engine, storage, config, mockElectrum, rates, settings);
|
||||||
};
|
};
|
||||||
26
tests/cli/mocks/rates-service.ts
Normal file
26
tests/cli/mocks/rates-service.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import { BaseRates } from "../../../src/utils/rates/base-rates";
|
||||||
|
|
||||||
|
export class MockRatesService extends BaseRates {
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
|
async getRate(
|
||||||
|
numeratorUnitCode: string,
|
||||||
|
denominatorUnitCode: string,
|
||||||
|
): Promise<number> {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
async start(): Promise<void> {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
async stop(): Promise<void> {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
async listPairs(): Promise<Set<string>> {
|
||||||
|
return new Set();
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,11 @@
|
|||||||
import { expect, test, describe, beforeEach, afterEach } from "vitest";
|
import { expect, test, describe, beforeEach, afterEach } from "vitest";
|
||||||
import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
|
import {
|
||||||
|
existsSync,
|
||||||
|
mkdirSync,
|
||||||
|
rmSync,
|
||||||
|
writeFileSync,
|
||||||
|
realpathSync,
|
||||||
|
} from "node:fs";
|
||||||
import { homedir, tmpdir } from "node:os";
|
import { homedir, tmpdir } from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
|
|
||||||
@@ -12,6 +18,20 @@ import {
|
|||||||
} from "../../src/utils/paths";
|
} from "../../src/utils/paths";
|
||||||
|
|
||||||
describe("paths utilities", () => {
|
describe("paths utilities", () => {
|
||||||
|
const originalConfigDir = process.env["XO_CONFIG_DIR"];
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
delete process.env["XO_CONFIG_DIR"];
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
if (originalConfigDir === undefined) {
|
||||||
|
delete process.env["XO_CONFIG_DIR"];
|
||||||
|
} else {
|
||||||
|
process.env["XO_CONFIG_DIR"] = originalConfigDir;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
describe("getConfigDir", () => {
|
describe("getConfigDir", () => {
|
||||||
test("returns path under ~/.config/xo-cli", () => {
|
test("returns path under ~/.config/xo-cli", () => {
|
||||||
const configDir = getConfigDir();
|
const configDir = getConfigDir();
|
||||||
@@ -24,13 +44,35 @@ describe("paths utilities", () => {
|
|||||||
|
|
||||||
expect(existsSync(configDir)).toBe(true);
|
expect(existsSync(configDir)).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("uses XO_CONFIG_DIR when configured", () => {
|
||||||
|
const customDir = path.join(tmpdir(), `xo-cli-config-test-${Date.now()}`);
|
||||||
|
process.env["XO_CONFIG_DIR"] = customDir;
|
||||||
|
|
||||||
|
try {
|
||||||
|
expect(getConfigDir()).toBe(customDir);
|
||||||
|
expect(getMnemonicsDir()).toBe(path.join(customDir, "mnemonics"));
|
||||||
|
expect(getDataDir()).toBe(path.join(customDir, "data"));
|
||||||
|
expect(getWalletConfigPath()).toBe(path.join(customDir, ".wallet"));
|
||||||
|
} finally {
|
||||||
|
rmSync(customDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("uses the default when XO_CONFIG_DIR is empty", () => {
|
||||||
|
process.env["XO_CONFIG_DIR"] = "";
|
||||||
|
|
||||||
|
expect(getConfigDir()).toBe(path.join(homedir(), ".config", "xo-cli"));
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("getMnemonicsDir", () => {
|
describe("getMnemonicsDir", () => {
|
||||||
test("returns path under config dir", () => {
|
test("returns path under config dir", () => {
|
||||||
const mnemonicsDir = getMnemonicsDir();
|
const mnemonicsDir = getMnemonicsDir();
|
||||||
|
|
||||||
expect(mnemonicsDir).toBe(path.join(homedir(), ".config", "xo-cli", "mnemonics"));
|
expect(mnemonicsDir).toBe(
|
||||||
|
path.join(homedir(), ".config", "xo-cli", "mnemonics"),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("creates the directory if it does not exist", () => {
|
test("creates the directory if it does not exist", () => {
|
||||||
@@ -58,7 +100,9 @@ describe("paths utilities", () => {
|
|||||||
test("returns .wallet file path under config dir", () => {
|
test("returns .wallet file path under config dir", () => {
|
||||||
const walletConfigPath = getWalletConfigPath();
|
const walletConfigPath = getWalletConfigPath();
|
||||||
|
|
||||||
expect(walletConfigPath).toBe(path.join(homedir(), ".config", "xo-cli", ".wallet"));
|
expect(walletConfigPath).toBe(
|
||||||
|
path.join(homedir(), ".config", "xo-cli", ".wallet"),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -89,13 +133,22 @@ describe("paths utilities", () => {
|
|||||||
try {
|
try {
|
||||||
writeFileSync(path.join(tempDir, "mnemonic-cwd-test"), "test");
|
writeFileSync(path.join(tempDir, "mnemonic-cwd-test"), "test");
|
||||||
const resolved = resolveMnemonicFilePath("mnemonic-cwd-test");
|
const resolved = resolveMnemonicFilePath("mnemonic-cwd-test");
|
||||||
expect(resolved).toBe(path.join(tempDir, "mnemonic-cwd-test"));
|
|
||||||
|
// Due to some weird MacOS behavior we need to use realpathSync to get the correct path
|
||||||
|
// Basically, tmpDir() returns a symlink, but moving to that path puts us at `/private/${tmpDir()}`
|
||||||
|
const expectedPath = realpathSync(
|
||||||
|
path.join(tempDir, "mnemonic-cwd-test"),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Compare to the expected path
|
||||||
|
expect(resolved).toBe(expectedPath);
|
||||||
} finally {
|
} finally {
|
||||||
process.chdir(originalCwd);
|
process.chdir(originalCwd);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test("resolves from global mnemonics dir when file exists there", () => {
|
test("resolves from global mnemonics dir when file exists there", () => {
|
||||||
|
process.env["XO_CONFIG_DIR"] = tempDir;
|
||||||
const mnemonicsDir = getMnemonicsDir();
|
const mnemonicsDir = getMnemonicsDir();
|
||||||
const testFile = path.join(mnemonicsDir, "mnemonic-global-test");
|
const testFile = path.join(mnemonicsDir, "mnemonic-global-test");
|
||||||
|
|
||||||
@@ -111,9 +164,9 @@ describe("paths utilities", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("throws when file not found anywhere", () => {
|
test("throws when file not found anywhere", () => {
|
||||||
expect(() => resolveMnemonicFilePath("nonexistent-mnemonic-file-xyz")).toThrow(
|
expect(() =>
|
||||||
/Mnemonic file not found/,
|
resolveMnemonicFilePath("nonexistent-mnemonic-file-xyz"),
|
||||||
);
|
).toThrow(/Mnemonic file not found/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("does not resolve absolute path if file does not exist", () => {
|
test("does not resolve absolute path if file does not exist", () => {
|
||||||
|
|||||||
40
tests/cli/rates-format.test.ts
Normal file
40
tests/cli/rates-format.test.ts
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
/// <reference types="node" />
|
||||||
|
|
||||||
|
import { describe, expect, test } from "vitest";
|
||||||
|
|
||||||
|
import { BaseRates } from "../../src/utils/rates/base-rates";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Minimal concrete adapter used only for testing BaseRates helpers.
|
||||||
|
*/
|
||||||
|
class TestRatesAdapter extends BaseRates {
|
||||||
|
public async start(): Promise<void> {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async stop(): Promise<void> {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async listPairs(): Promise<Set<string>> {
|
||||||
|
return new Set(["USD/BCH", "DOGE/BCH"]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("BaseRates.formatCurrency", () => {
|
||||||
|
test("formats ISO currency codes with Intl currency style", () => {
|
||||||
|
const rates = new TestRatesAdapter();
|
||||||
|
const formatted = rates.formatCurrency(12.5, "USD");
|
||||||
|
|
||||||
|
expect(formatted).toContain("$");
|
||||||
|
expect(formatted).toContain("12.50");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("formats non-ISO symbols without throwing", () => {
|
||||||
|
const rates = new TestRatesAdapter();
|
||||||
|
const formatted = rates.formatCurrency(12.3456789, "DOGE");
|
||||||
|
|
||||||
|
expect(formatted).toContain("DOGE");
|
||||||
|
expect(formatted).toContain("12.3456789");
|
||||||
|
});
|
||||||
|
});
|
||||||
96
tests/cli/settings.test.ts
Normal file
96
tests/cli/settings.test.ts
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
/// <reference types="node" />
|
||||||
|
|
||||||
|
import { beforeEach, afterEach, describe, expect, test } from "vitest";
|
||||||
|
import {
|
||||||
|
existsSync,
|
||||||
|
mkdtempSync,
|
||||||
|
readFileSync,
|
||||||
|
rmSync,
|
||||||
|
writeFileSync,
|
||||||
|
} from "node:fs";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
|
||||||
|
import { SettingsService } from "../../src/services/settings";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests for SettingsService persistence and migration behavior.
|
||||||
|
*/
|
||||||
|
describe("SettingsService", () => {
|
||||||
|
let testDir: string;
|
||||||
|
let settingsPath: string;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
testDir = mkdtempSync(join(tmpdir(), "xo-cli-settings-test-"));
|
||||||
|
settingsPath = join(testDir, ".wallet");
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
rmSync(testDir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns defaults when settings file does not exist", () => {
|
||||||
|
const settings = new SettingsService(settingsPath);
|
||||||
|
|
||||||
|
expect(settings.getDefaultMnemonic()).toBeUndefined();
|
||||||
|
expect(settings.getCurrency()).toBe("USD");
|
||||||
|
expect(settings.getSettings()).toEqual({ currency: "USD" });
|
||||||
|
expect(existsSync(settingsPath)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("migrates legacy .wallet plaintext mnemonic to JSON", () => {
|
||||||
|
writeFileSync(settingsPath, "mnemonic-legacy", "utf8");
|
||||||
|
|
||||||
|
const settings = new SettingsService(settingsPath);
|
||||||
|
|
||||||
|
expect(settings.getDefaultMnemonic()).toBe("mnemonic-legacy");
|
||||||
|
expect(settings.getCurrency()).toBe("USD");
|
||||||
|
|
||||||
|
const persisted = JSON.parse(readFileSync(settingsPath, "utf8")) as {
|
||||||
|
"default-mnemonic"?: string;
|
||||||
|
currency: string;
|
||||||
|
};
|
||||||
|
expect(persisted).toEqual({
|
||||||
|
"default-mnemonic": "mnemonic-legacy",
|
||||||
|
currency: "USD",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("normalizes and persists currency and default-mnemonic", () => {
|
||||||
|
const settings = new SettingsService(settingsPath);
|
||||||
|
|
||||||
|
settings.setDefaultMnemonic(" mnemonic-primary ");
|
||||||
|
settings.setCurrency("aud");
|
||||||
|
|
||||||
|
expect(settings.getSettings()).toEqual({
|
||||||
|
"default-mnemonic": "mnemonic-primary",
|
||||||
|
currency: "AUD",
|
||||||
|
});
|
||||||
|
|
||||||
|
const persisted = JSON.parse(readFileSync(settingsPath, "utf8")) as {
|
||||||
|
"default-mnemonic"?: string;
|
||||||
|
currency: string;
|
||||||
|
};
|
||||||
|
expect(persisted).toEqual({
|
||||||
|
"default-mnemonic": "mnemonic-primary",
|
||||||
|
currency: "AUD",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("emits settings-updated events on setting changes", () => {
|
||||||
|
const settings = new SettingsService(settingsPath);
|
||||||
|
const events: Array<{ key: string; value: string | undefined }> = [];
|
||||||
|
|
||||||
|
settings.on("settings-updated", (event) => {
|
||||||
|
events.push({ key: event.key, value: event.value });
|
||||||
|
});
|
||||||
|
|
||||||
|
settings.setCurrency("cad");
|
||||||
|
settings.setDefaultMnemonic("mnemonic-blue");
|
||||||
|
|
||||||
|
expect(events).toEqual([
|
||||||
|
{ key: "currency", value: "CAD" },
|
||||||
|
{ key: "default-mnemonic", value: "mnemonic-blue" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
44
tests/tui/format-dialog-message.test.ts
Normal file
44
tests/tui/format-dialog-message.test.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import { describe, expect, test } from "vitest";
|
||||||
|
|
||||||
|
import {
|
||||||
|
formatDialogMessageLines,
|
||||||
|
getMessageContentWidth,
|
||||||
|
getMessageDialogWidth,
|
||||||
|
} from "../../src/tui/utils/format-dialog-message.js";
|
||||||
|
|
||||||
|
describe("formatDialogMessageLines", () => {
|
||||||
|
test("drops empty lines from leading newlines", () => {
|
||||||
|
const lines = formatDialogMessageLines("\n- first\n- second", 80);
|
||||||
|
|
||||||
|
expect(lines).toEqual(["- first", "- second"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("keeps short lines unchanged", () => {
|
||||||
|
const lines = formatDialogMessageLines("- actions.receive: Invalid", 80);
|
||||||
|
|
||||||
|
expect(lines).toEqual(["- actions.receive: Invalid"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("breaks long dot-separated paths at segment boundaries", () => {
|
||||||
|
const line =
|
||||||
|
'- actions.requestFungibleTokens.roles.receiver.requirements: Unrecognized key: "generate"';
|
||||||
|
const lines = formatDialogMessageLines(line, 56);
|
||||||
|
|
||||||
|
expect(lines.length).toBeGreaterThan(1);
|
||||||
|
expect(lines.join("\n")).toContain("actions.requestFungibleTokens.");
|
||||||
|
expect(lines.every((entry) => entry.length <= 58)).toBe(true);
|
||||||
|
expect(lines[1]?.startsWith(" ")).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("dialog width helpers", () => {
|
||||||
|
test("getMessageDialogWidth respects terminal bounds", () => {
|
||||||
|
expect(getMessageDialogWidth(120)).toBe(100);
|
||||||
|
expect(getMessageDialogWidth(80)).toBe(76);
|
||||||
|
expect(getMessageDialogWidth(40)).toBe(60);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("getMessageContentWidth subtracts border and padding", () => {
|
||||||
|
expect(getMessageContentWidth(76)).toBe(70);
|
||||||
|
});
|
||||||
|
});
|
||||||
114
tests/tui/list-directory-entries.test.ts
Normal file
114
tests/tui/list-directory-entries.test.ts
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, test } from "vitest";
|
||||||
|
import {
|
||||||
|
existsSync,
|
||||||
|
mkdirSync,
|
||||||
|
mkdtempSync,
|
||||||
|
rmSync,
|
||||||
|
writeFileSync,
|
||||||
|
} from "node:fs";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
import { listDirectoryEntries } from "../../src/tui/utils/list-directory-entries.js";
|
||||||
|
|
||||||
|
describe("listDirectoryEntries", () => {
|
||||||
|
let tempRoot: string;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
tempRoot = mkdtempSync(path.join(tmpdir(), "xo-file-picker-"));
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
if (existsSync(tempRoot)) {
|
||||||
|
rmSync(tempRoot, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("includes parent entry and sorts directories before files", () => {
|
||||||
|
mkdirSync(path.join(tempRoot, "beta-dir"));
|
||||||
|
mkdirSync(path.join(tempRoot, "alpha-dir"));
|
||||||
|
writeFileSync(path.join(tempRoot, "zebra.json"), "{}");
|
||||||
|
writeFileSync(path.join(tempRoot, "apple.txt"), "x");
|
||||||
|
|
||||||
|
const childDir = path.join(tempRoot, "child");
|
||||||
|
mkdirSync(childDir);
|
||||||
|
writeFileSync(path.join(childDir, "nested.json"), "{}");
|
||||||
|
|
||||||
|
const rootResult = listDirectoryEntries(tempRoot);
|
||||||
|
expect(rootResult.error).toBeUndefined();
|
||||||
|
expect(rootResult.entries.map((entry) => entry.name)).toEqual([
|
||||||
|
"..",
|
||||||
|
"alpha-dir",
|
||||||
|
"beta-dir",
|
||||||
|
"child",
|
||||||
|
"apple.txt",
|
||||||
|
"zebra.json",
|
||||||
|
]);
|
||||||
|
|
||||||
|
const childResult = listDirectoryEntries(childDir);
|
||||||
|
expect(childResult.entries[0]).toMatchObject({
|
||||||
|
name: "..",
|
||||||
|
kind: "parent",
|
||||||
|
absolutePath: tempRoot,
|
||||||
|
});
|
||||||
|
expect(childResult.entries.slice(1).map((entry) => entry.name)).toEqual([
|
||||||
|
"nested.json",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("filters files by extension when extensions are provided", () => {
|
||||||
|
writeFileSync(path.join(tempRoot, "template.json"), "{}");
|
||||||
|
writeFileSync(path.join(tempRoot, "readme.md"), "# hi");
|
||||||
|
writeFileSync(path.join(tempRoot, "UPPER.JSON"), "{}");
|
||||||
|
|
||||||
|
const result = listDirectoryEntries(tempRoot, { extensions: ["json"] });
|
||||||
|
|
||||||
|
expect(result.error).toBeUndefined();
|
||||||
|
expect(result.entries.map((entry) => entry.name)).toEqual([
|
||||||
|
"..",
|
||||||
|
"template.json",
|
||||||
|
"UPPER.JSON",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("shows all files when extensions are omitted", () => {
|
||||||
|
writeFileSync(path.join(tempRoot, "a.json"), "{}");
|
||||||
|
writeFileSync(path.join(tempRoot, "b.txt"), "x");
|
||||||
|
|
||||||
|
const result = listDirectoryEntries(tempRoot);
|
||||||
|
|
||||||
|
expect(result.entries.map((entry) => entry.name)).toEqual([
|
||||||
|
"..",
|
||||||
|
"a.json",
|
||||||
|
"b.txt",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("omits parent entry at filesystem root", () => {
|
||||||
|
const rootResult = listDirectoryEntries(path.parse(tempRoot).root);
|
||||||
|
|
||||||
|
expect(rootResult.error).toBeUndefined();
|
||||||
|
expect(rootResult.entries.some((entry) => entry.kind === "parent")).toBe(
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns error for missing directory without throwing", () => {
|
||||||
|
const missingPath = path.join(tempRoot, "does-not-exist");
|
||||||
|
|
||||||
|
const result = listDirectoryEntries(missingPath);
|
||||||
|
|
||||||
|
expect(result.entries).toEqual([]);
|
||||||
|
expect(result.error).toContain("Directory does not exist");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns error when path is a file", () => {
|
||||||
|
const filePath = path.join(tempRoot, "file.txt");
|
||||||
|
writeFileSync(filePath, "hello");
|
||||||
|
|
||||||
|
const result = listDirectoryEntries(filePath);
|
||||||
|
|
||||||
|
expect(result.entries).toEqual([]);
|
||||||
|
expect(result.error).toContain("Not a directory");
|
||||||
|
});
|
||||||
|
});
|
||||||
78
tests/utils/load-template-from-file.test.ts
Normal file
78
tests/utils/load-template-from-file.test.ts
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
import { existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import { afterEach, beforeEach, describe, expect, test } from "vitest";
|
||||||
|
|
||||||
|
import { parseTemplate } from "@xo-cash/utils";
|
||||||
|
|
||||||
|
import {
|
||||||
|
loadTemplateFromFile,
|
||||||
|
TemplateLoadError,
|
||||||
|
} from "../../src/utils/load-template-from-file.js";
|
||||||
|
import { p2pkhTemplate } from "../cli/mocks/template-p2pkh.js";
|
||||||
|
|
||||||
|
describe("loadTemplateFromFile", () => {
|
||||||
|
let tempRoot: string;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
tempRoot = mkdtempSync(path.join(tmpdir(), "xo-load-template-"));
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
if (existsSync(tempRoot)) {
|
||||||
|
rmSync(tempRoot, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("loads JSON templates directly", async () => {
|
||||||
|
const jsonPath = path.join(tempRoot, "template.json");
|
||||||
|
writeFileSync(jsonPath, JSON.stringify(p2pkhTemplate));
|
||||||
|
|
||||||
|
const contents = await loadTemplateFromFile(jsonPath);
|
||||||
|
const parsed = parseTemplate(contents);
|
||||||
|
|
||||||
|
expect(parsed.name).toBe(p2pkhTemplate.name);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("loads TypeScript templates via child process", async () => {
|
||||||
|
const tsTemplatePath = path.resolve(
|
||||||
|
process.cwd(),
|
||||||
|
"../templates/source/p2pkh.ts",
|
||||||
|
);
|
||||||
|
expect(existsSync(tsTemplatePath)).toBe(true);
|
||||||
|
|
||||||
|
const contents = await loadTemplateFromFile(tsTemplatePath);
|
||||||
|
const parsed = parseTemplate(contents);
|
||||||
|
|
||||||
|
expect(parsed.name).toBe("Wallet (P2PKH)");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("loads JavaScript templates via child process", async () => {
|
||||||
|
const jsPath = path.join(tempRoot, "template.mjs");
|
||||||
|
writeFileSync(
|
||||||
|
jsPath,
|
||||||
|
`export default ${JSON.stringify(p2pkhTemplate)};\n`,
|
||||||
|
"utf8",
|
||||||
|
);
|
||||||
|
|
||||||
|
const contents = await loadTemplateFromFile(jsPath);
|
||||||
|
const parsed = parseTemplate(contents);
|
||||||
|
|
||||||
|
expect(parsed.name).toBe(p2pkhTemplate.name);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("throws TemplateLoadError for missing files", async () => {
|
||||||
|
await expect(
|
||||||
|
loadTemplateFromFile(path.join(tempRoot, "missing.json")),
|
||||||
|
).rejects.toBeInstanceOf(TemplateLoadError);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("throws TemplateLoadError for unsupported extensions", async () => {
|
||||||
|
const txtPath = path.join(tempRoot, "template.txt");
|
||||||
|
writeFileSync(txtPath, "hello");
|
||||||
|
|
||||||
|
await expect(loadTemplateFromFile(txtPath)).rejects.toThrow(
|
||||||
|
/Unsupported template file extension/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
57
tests/utils/pick-template-export.test.ts
Normal file
57
tests/utils/pick-template-export.test.ts
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import { describe, expect, test } from "vitest";
|
||||||
|
|
||||||
|
import {
|
||||||
|
isTemplateLike,
|
||||||
|
pickTemplateExport,
|
||||||
|
} from "../../src/utils/pick-template-export.js";
|
||||||
|
|
||||||
|
const sampleTemplate = {
|
||||||
|
$schema: "https://libauth.org/schemas/wallet-template-v0.schema.json",
|
||||||
|
name: "Sample",
|
||||||
|
roles: { owner: { name: "Owner" } },
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("pickTemplateExport", () => {
|
||||||
|
test("isTemplateLike accepts objects with schema, name, and roles", () => {
|
||||||
|
expect(isTemplateLike(sampleTemplate)).toBe(true);
|
||||||
|
expect(isTemplateLike(null)).toBe(false);
|
||||||
|
expect(isTemplateLike({ name: "Missing schema" })).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("prefers default export when template-like", () => {
|
||||||
|
const picked = pickTemplateExport({
|
||||||
|
default: sampleTemplate,
|
||||||
|
otherTemplate: {
|
||||||
|
...sampleTemplate,
|
||||||
|
name: "Other",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(picked).toBe(sampleTemplate);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("uses a single named export when no default export exists", () => {
|
||||||
|
const picked = pickTemplateExport({
|
||||||
|
p2pkhTemplate: sampleTemplate,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(picked).toBe(sampleTemplate);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("throws when multiple template exports exist", () => {
|
||||||
|
expect(() =>
|
||||||
|
pickTemplateExport({
|
||||||
|
firstTemplate: sampleTemplate,
|
||||||
|
secondTemplate: { ...sampleTemplate, name: "Second" },
|
||||||
|
}),
|
||||||
|
).toThrow(/Multiple template exports found/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("throws when no template export exists", () => {
|
||||||
|
expect(() =>
|
||||||
|
pickTemplateExport({
|
||||||
|
notATemplate: { foo: "bar" },
|
||||||
|
}),
|
||||||
|
).toThrow(/No XOTemplate export found/);
|
||||||
|
});
|
||||||
|
});
|
||||||
291
tests/utils/resolve-invitation-data.test.ts
Normal file
291
tests/utils/resolve-invitation-data.test.ts
Normal file
@@ -0,0 +1,291 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import type { XOInvitation } from "@xo-cash/types";
|
||||||
|
|
||||||
|
import { vendingMachineTemplate } from "../../src/templates/vending-machine.js";
|
||||||
|
import { resolveCommitReferences } from "../../src/utils/resolve-invitation-data.js";
|
||||||
|
|
||||||
|
const MERCHANT_ENTITY =
|
||||||
|
"xpub6EUk69HMQk83Ay3QEFWhYgvqLvT6tGTnzWK33fao2fvnDyzhbBeoSc6JbQkvnKq33bH7HjqQmZ9H29hsesC53ZgxQfGBadBZL5jmSa7kbTD";
|
||||||
|
const CUSTOMER_ENTITY =
|
||||||
|
"xpub6FHRsCb1ma6VFGZpRYZL8A3X1Gwwc8JjRcaDJR2vgirrttmdvJX5VNYceA84RDVjy1c2a2oYEwuayLDZ9gssDgU52UXDGFTDa19z5ceXfFh";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Minimal reproduction of OriginalInvitation.json for the vending machine flow.
|
||||||
|
*/
|
||||||
|
const originalInvitation: XOInvitation = {
|
||||||
|
invitationIdentifier: "c57b1f8f8534df28b359e323c5fbd5ba",
|
||||||
|
createdAtTimestamp: 1779488689379,
|
||||||
|
templateIdentifier:
|
||||||
|
"feadd05c6566c5eded68f321efe7150cb765fda070d027c89f285e5b42a00652",
|
||||||
|
actionIdentifier: "purchaseItems",
|
||||||
|
commits: [
|
||||||
|
{
|
||||||
|
commitIdentifier: "76b935a35ca45f1065f9c66769d1a957",
|
||||||
|
previousCommitIdentifier: undefined,
|
||||||
|
entityIdentifier: MERCHANT_ENTITY,
|
||||||
|
data: {},
|
||||||
|
signature:
|
||||||
|
"5f487c045657f3939ecfeaaacf239a7cfd44b485c2be591f5280bf0cc3a6e5fe304e8ea23311d82b2afa4f0ad7e0a6d07ec1e0b1aaee9c44097613694390966b",
|
||||||
|
expiresAtTimestamp: 1779506689379,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
commitIdentifier: "cbf2d6242144f6761d0efc3bbbbf6660",
|
||||||
|
previousCommitIdentifier: "76b935a35ca45f1065f9c66769d1a957",
|
||||||
|
entityIdentifier: MERCHANT_ENTITY,
|
||||||
|
data: {
|
||||||
|
variables: [
|
||||||
|
{
|
||||||
|
variableIdentifier: "totalSatoshis",
|
||||||
|
roleIdentifier: "merchant",
|
||||||
|
value: 3000,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
variableIdentifier: "orderId",
|
||||||
|
roleIdentifier: "merchant",
|
||||||
|
value: "eb5a30b3-ec8c-4b81-89dd-c53371f55a0e",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
variableIdentifier: "merchantName",
|
||||||
|
roleIdentifier: "merchant",
|
||||||
|
value: "XO Snack Machine",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
variableIdentifier: "receiptSummary",
|
||||||
|
roleIdentifier: "merchant",
|
||||||
|
value: "2× Chips",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
variableIdentifier: "lineItemsJson",
|
||||||
|
roleIdentifier: "merchant",
|
||||||
|
value:
|
||||||
|
'[{"id":"225e37f4-14f2-4b33-86fd-763018bbfd7c","name":"Chips","quantity":2,"price":1500}]',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
signature:
|
||||||
|
"7cfc53860ec81403a79a03521a7674ee8d2a11365ee031e4f7f2e36a045bd6e2999510264b29045582a74e1190f0176950a855361f02bc67ff7877fabcf794f4",
|
||||||
|
expiresAtTimestamp: 1779506689390,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
commitIdentifier: "583208aa304c0aa9841d1400efe6b6aa",
|
||||||
|
previousCommitIdentifier: "cbf2d6242144f6761d0efc3bbbbf6660",
|
||||||
|
entityIdentifier: MERCHANT_ENTITY,
|
||||||
|
data: {
|
||||||
|
outputs: [
|
||||||
|
{
|
||||||
|
outputIdentifier: "purchaseOutput",
|
||||||
|
lockingBytecode:
|
||||||
|
"76a9146a4715fe1cc1ce228336502f1711b06045ef361088ac",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
signature:
|
||||||
|
"d9bdd3b24fef6afd13f12da92e832672c6c1b83fb372506faeb7fa4ea0e39e3a32ad74493fbe7a393aed58bc18226431dabae09948ce371ad3f77b0219cb3831",
|
||||||
|
expiresAtTimestamp: 1779506689412,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
commitIdentifier: "4f3f9a3361c8070ab589cc44248a6a80",
|
||||||
|
previousCommitIdentifier: "583208aa304c0aa9841d1400efe6b6aa",
|
||||||
|
entityIdentifier: CUSTOMER_ENTITY,
|
||||||
|
data: {},
|
||||||
|
signature:
|
||||||
|
"63be8af81622da4fccc7eb6b81c6174879fe6aa113b8dae794bd42d4d5c87ae550a18be1e6cb5edf231e774bdc7883eb5a78bd02188579dce58da0d449c43865",
|
||||||
|
expiresAtTimestamp: 1779506979194,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
commitIdentifier: "d18b9a0caa638eaa1d0711f333e9c114",
|
||||||
|
previousCommitIdentifier: "4f3f9a3361c8070ab589cc44248a6a80",
|
||||||
|
entityIdentifier: CUSTOMER_ENTITY,
|
||||||
|
data: {
|
||||||
|
inputs: [
|
||||||
|
{
|
||||||
|
outpointTransactionHash:
|
||||||
|
"b1e8f77cdc60efac19f668fc5c7177ace42a46e2532f230979559c7190c3c80a",
|
||||||
|
outpointIndex: 1,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
signature:
|
||||||
|
"e36942eb5f147e620659d20b7059630da871944e74fe5ffb3c4ff0298a5aedb101bc7468b19750114cbcfa56b99bd4a080453a31084f18173adcd9442fca4303",
|
||||||
|
expiresAtTimestamp: 1779507006272,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
commitIdentifier: "7823f7ae7a365f87f6acdfee8896f508",
|
||||||
|
previousCommitIdentifier: "d18b9a0caa638eaa1d0711f333e9c114",
|
||||||
|
entityIdentifier: CUSTOMER_ENTITY,
|
||||||
|
data: {
|
||||||
|
outputs: [
|
||||||
|
{
|
||||||
|
valueSatoshis: 74881n,
|
||||||
|
lockingBytecode:
|
||||||
|
"76a9141730ca066d4b9c8d542f8c9bdce645f77697d46088ac",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
signature:
|
||||||
|
"2c1d1ed1259a2e4b1bc7187b93029e99e590a4e92ff9c39031319766b7fbcdabab9c3dc20b3d27d05eee198cbc717b9aedfbef92bd3e519c62c60e4731bd936a",
|
||||||
|
expiresAtTimestamp: 1779507008169,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Customer input commit extended with unlocking bytecode via mergesWith (signing flow).
|
||||||
|
*/
|
||||||
|
const invitationWithSignedInput: XOInvitation = {
|
||||||
|
...originalInvitation,
|
||||||
|
commits: [
|
||||||
|
...originalInvitation.commits.slice(0, 5),
|
||||||
|
{
|
||||||
|
commitIdentifier: "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6",
|
||||||
|
previousCommitIdentifier: "d18b9a0caa638eaa1d0711f333e9c114",
|
||||||
|
entityIdentifier: CUSTOMER_ENTITY,
|
||||||
|
data: {
|
||||||
|
inputs: [
|
||||||
|
{
|
||||||
|
mergesWith: {
|
||||||
|
commitIdentifier: "d18b9a0caa638eaa1d0711f333e9c114",
|
||||||
|
index: 0,
|
||||||
|
},
|
||||||
|
unlockingBytecode:
|
||||||
|
"41226b2be7c2890c8bbde2f79e79640e56d866843f2e822ec51c469019d13db0",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
signature:
|
||||||
|
"3045022001a1b2c3d4e5f6789012345678901234567890abcdef1234567890abcdef123456789022100fedcba0987654321fedcba0987654321fedcba0987654321fedcba0987654321",
|
||||||
|
expiresAtTimestamp: 1779507008000,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("resolveCommitReferences", () => {
|
||||||
|
it("flattens commits and enriches items with template metadata", () => {
|
||||||
|
const resolved = resolveCommitReferences(
|
||||||
|
originalInvitation,
|
||||||
|
vendingMachineTemplate,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(resolved).toEqual({
|
||||||
|
invitationIdentifier: "c57b1f8f8534df28b359e323c5fbd5ba",
|
||||||
|
templateIdentifier:
|
||||||
|
"feadd05c6566c5eded68f321efe7150cb765fda070d027c89f285e5b42a00652",
|
||||||
|
actionIdentifier: "purchaseItems",
|
||||||
|
variables: [
|
||||||
|
{
|
||||||
|
entityIdentifier: MERCHANT_ENTITY,
|
||||||
|
name: "Total Price",
|
||||||
|
description: "Total purchase price in satoshis",
|
||||||
|
type: "integer",
|
||||||
|
hint: "satoshis",
|
||||||
|
variableIdentifier: "totalSatoshis",
|
||||||
|
roleIdentifier: "merchant",
|
||||||
|
value: 3000,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
entityIdentifier: MERCHANT_ENTITY,
|
||||||
|
name: "Order ID",
|
||||||
|
description: "Unique order identifier",
|
||||||
|
type: "string",
|
||||||
|
variableIdentifier: "orderId",
|
||||||
|
roleIdentifier: "merchant",
|
||||||
|
value: "eb5a30b3-ec8c-4b81-89dd-c53371f55a0e",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
entityIdentifier: MERCHANT_ENTITY,
|
||||||
|
name: "Merchant Name",
|
||||||
|
description: "Display name of the vending machine",
|
||||||
|
type: "string",
|
||||||
|
variableIdentifier: "merchantName",
|
||||||
|
roleIdentifier: "merchant",
|
||||||
|
value: "XO Snack Machine",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
entityIdentifier: MERCHANT_ENTITY,
|
||||||
|
name: "Receipt Summary",
|
||||||
|
description: "Human-readable list of purchased items",
|
||||||
|
type: "string",
|
||||||
|
variableIdentifier: "receiptSummary",
|
||||||
|
roleIdentifier: "merchant",
|
||||||
|
value: "2× Chips",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
entityIdentifier: MERCHANT_ENTITY,
|
||||||
|
name: "Line Items",
|
||||||
|
description: "JSON-encoded line items for the purchase",
|
||||||
|
type: "string",
|
||||||
|
variableIdentifier: "lineItemsJson",
|
||||||
|
roleIdentifier: "merchant",
|
||||||
|
value:
|
||||||
|
'[{"id":"225e37f4-14f2-4b33-86fd-763018bbfd7c","name":"Chips","quantity":2,"price":1500}]',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
inputs: [
|
||||||
|
{
|
||||||
|
entityIdentifier: CUSTOMER_ENTITY,
|
||||||
|
outpointTransactionHash:
|
||||||
|
"b1e8f77cdc60efac19f668fc5c7177ace42a46e2532f230979559c7190c3c80a",
|
||||||
|
outpointIndex: 1,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
outputs: [
|
||||||
|
{
|
||||||
|
entityIdentifier: MERCHANT_ENTITY,
|
||||||
|
outputIdentifier: "purchaseOutput",
|
||||||
|
lockingBytecode: "76a9146a4715fe1cc1ce228336502f1711b06045ef361088ac",
|
||||||
|
name: "Purchase Payment",
|
||||||
|
description: "$(<totalSatoshis>) sats to $(<merchantName>)",
|
||||||
|
icon: "request",
|
||||||
|
roles: {
|
||||||
|
merchant: {
|
||||||
|
name: "Payment Received",
|
||||||
|
description:
|
||||||
|
"Received $(<totalSatoshis>) sats for $(<receiptSummary>)",
|
||||||
|
},
|
||||||
|
customer: {
|
||||||
|
name: "Payment Sent",
|
||||||
|
description:
|
||||||
|
"Sent $(<totalSatoshis>) sats for $(<receiptSummary>)",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
lockingScript: "merchantReceivingLockingScript",
|
||||||
|
valueSatoshis: "$(<totalSatoshis>)",
|
||||||
|
token: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
entityIdentifier: CUSTOMER_ENTITY,
|
||||||
|
valueSatoshis: 74881n,
|
||||||
|
lockingBytecode: "76a9141730ca066d4b9c8d542f8c9bdce645f77697d46088ac",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves unidentified inputs and outputs without template metadata", () => {
|
||||||
|
const resolved = resolveCommitReferences(
|
||||||
|
originalInvitation,
|
||||||
|
vendingMachineTemplate,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(resolved.inputs[0]).not.toHaveProperty("name");
|
||||||
|
expect(resolved.outputs[1]).not.toHaveProperty("name");
|
||||||
|
expect(resolved.outputs[1]).not.toHaveProperty("outputIdentifier");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("merges input extension commits via mergesWith into a single input", () => {
|
||||||
|
const resolved = resolveCommitReferences(
|
||||||
|
invitationWithSignedInput,
|
||||||
|
vendingMachineTemplate,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(resolved.inputs).toHaveLength(1);
|
||||||
|
expect(resolved.inputs[0]).toMatchObject({
|
||||||
|
entityIdentifier: CUSTOMER_ENTITY,
|
||||||
|
outpointTransactionHash:
|
||||||
|
"b1e8f77cdc60efac19f668fc5c7177ace42a46e2532f230979559c7190c3c80a",
|
||||||
|
outpointIndex: 1,
|
||||||
|
unlockingBytecode:
|
||||||
|
"41226b2be7c2890c8bbde2f79e79640e56d866843f2e822ec51c469019d13db0",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user