22 Commits

Author SHA1 Message Date
cfcba02bb3 Document methods in resolveInvitationData 2026-06-15 20:07:31 +10:00
3ee2d53766 Formatting and reduce explicit typing 2026-06-15 20:04:51 +10:00
d089e909f8 Use arrow function syntax. Export all methods. 2026-06-15 20:00:40 +10:00
771968dfbb Use mergeInvitationCommits in resolveCommitReferences for correct commit merging.
Delegate input/output merging to the engine so mergesWith extensions and
transaction indices resolve correctly instead of flattening raw commits.
2026-06-15 18:36:55 +10:00
d2c37fd957 Add invitation delete to cli 2026-06-08 13:26:41 +02:00
bca736dab4 Add removeInvitation to the invitation screen 2026-06-08 13:22:13 +02:00
69adee180a Add resolveCommitReferences method 2026-06-08 13:09:38 +02:00
c7e1d69e2d Formatting 2026-06-01 12:36:55 +02:00
b30243f674 Add custom path support for cli/tui in terminal config 2026-06-01 11:49:23 +02:00
5e9c6db412 Fix invitation syncing in realtime 2026-06-01 11:28:18 +02:00
5bec49858f Set the cashASM evaluation encoding in the invitation details screen 2026-05-30 22:25:26 +02:00
f1ac89ef91 Remove reference to transaction screen 2026-05-30 21:21:52 +02:00
17a41cf29a Massive speed up during invitation creation at the expense of reliability. Document method for creating a reliability manager of some sort 2026-05-30 21:21:34 +02:00
0b848989a2 Remove transaction screen from the menu in invitationScreen 2026-05-30 20:50:26 +02:00
1776fbbf61 Add TSX as a core dep due to it being used in reading templates from .ts files 2026-05-30 20:50:04 +02:00
0acc70b613 Update readme to use utils and correct branches during install 2026-05-30 12:25:05 +02:00
14e74fab6c Update readme to include pulling in primitives 2026-05-30 12:13:05 +02:00
a7f0ed69a2 Update test mnemonic 2026-05-29 18:16:27 +02:00
2f8dad7d8d Add import template into tui. Fix tests that fail on macos. Fix some updates. 2026-05-29 18:16:00 +02:00
85746c3306 Use xo-cash/utils sse. Add vending machine template. Greatly improve startup times. 2026-05-24 19:35:50 +02:00
def261b568 Breaking-Change: Extremely rough update to work with Kioks wallet 2026-05-22 14:11:07 +02:00
3d6518e465 Completely broken update to latest versions 2026-05-22 10:18:30 +02:00
62 changed files with 5814 additions and 2297 deletions

87
package-lock.json generated
View File

@@ -17,12 +17,14 @@
"@xo-cash/state": "file:../state", "@xo-cash/state": "file:../state",
"@xo-cash/templates": "file:../templates", "@xo-cash/templates": "file:../templates",
"@xo-cash/types": "^0.0.1", "@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": {
@@ -36,7 +38,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"
} }
@@ -47,16 +48,16 @@
"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": "file:../crypto", "@xo-cash/crypto": "0.0.1",
"@xo-cash/primitives": "0.0.1", "@xo-cash/primitives": "file:../primitives",
"@xo-cash/state": "0.0.2", "@xo-cash/state": "file:../state",
"@xo-cash/templates": "0.0.1", "@xo-cash/templates": "0.0.1",
"@xo-cash/types": "0.0.1", "@xo-cash/types": "^0.0.1-development.14519184304",
"@xo-cash/utils": "0.0.1", "@xo-cash/utils": "^0.0.1-development.14519184505",
"eventemitter3": "^5.0.1" "eventemitter3": "^5.0.1"
}, },
"devDependencies": { "devDependencies": {
@@ -140,6 +141,37 @@
"vitest": "^4.0.17" "vitest": "^4.0.17"
} }
}, },
"../utils": {
"name": "@xo-cash/utils",
"version": "0.0.1",
"license": "MIT",
"dependencies": {
"@bitauth/libauth": "^3.1.0-next.8",
"@xo-cash/types": "0.0.1",
"zod": "^4.3.6"
},
"devDependencies": {
"@chalp/eslint-airbnb": "^1.3.0",
"@generalprotocols/cspell-dictionary": "^1.0.1",
"@stylistic/eslint-plugin": "^5.7.0",
"@types/node": "^25.5.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",
"@xo-cash/templates": "0.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"
}
},
"node_modules/@alcalzone/ansi-tokenize": { "node_modules/@alcalzone/ansi-tokenize": {
"version": "0.2.4", "version": "0.2.4",
"resolved": "https://registry.npmjs.org/@alcalzone/ansi-tokenize/-/ansi-tokenize-0.2.4.tgz", "resolved": "https://registry.npmjs.org/@alcalzone/ansi-tokenize/-/ansi-tokenize-0.2.4.tgz",
@@ -977,6 +1009,10 @@
"node": "^12.20.0 || ^14.13.1 || >=16.0.0" "node": "^12.20.0 || ^14.13.1 || >=16.0.0"
} }
}, },
"node_modules/@xo-cash/utils": {
"resolved": "../utils",
"link": true
},
"node_modules/ansi-escapes": { "node_modules/ansi-escapes": {
"version": "7.2.0", "version": "7.2.0",
"resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.2.0.tgz", "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.2.0.tgz",
@@ -1650,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,
@@ -1702,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"
@@ -2554,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"
@@ -2977,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",
@@ -3000,7 +3032,6 @@
"cpu": [ "cpu": [
"ppc64" "ppc64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -3017,7 +3048,6 @@
"cpu": [ "cpu": [
"arm" "arm"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -3034,7 +3064,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -3051,7 +3080,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -3068,7 +3096,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -3085,7 +3112,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -3102,7 +3128,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -3119,7 +3144,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -3136,7 +3160,6 @@
"cpu": [ "cpu": [
"arm" "arm"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -3153,7 +3176,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -3170,7 +3192,6 @@
"cpu": [ "cpu": [
"ia32" "ia32"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -3187,7 +3208,6 @@
"cpu": [ "cpu": [
"loong64" "loong64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -3204,7 +3224,6 @@
"cpu": [ "cpu": [
"mips64el" "mips64el"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -3221,7 +3240,6 @@
"cpu": [ "cpu": [
"ppc64" "ppc64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -3238,7 +3256,6 @@
"cpu": [ "cpu": [
"riscv64" "riscv64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -3255,7 +3272,6 @@
"cpu": [ "cpu": [
"s390x" "s390x"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -3272,7 +3288,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -3289,7 +3304,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -3306,7 +3320,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -3323,7 +3336,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -3340,7 +3352,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -3357,7 +3368,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -3374,7 +3384,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -3391,7 +3400,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -3408,7 +3416,6 @@
"cpu": [ "cpu": [
"ia32" "ia32"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -3425,7 +3432,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -3439,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": {
@@ -4257,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"

View File

@@ -12,6 +12,7 @@
"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",
@@ -41,12 +42,14 @@
"@xo-cash/state": "file:../state", "@xo-cash/state": "file:../state",
"@xo-cash/templates": "file:../templates", "@xo-cash/templates": "file:../templates",
"@xo-cash/types": "^0.0.1", "@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": {
@@ -55,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"
} }

137
readme.md
View File

@@ -3,30 +3,11 @@
## Installation ## Installation
### Full Installation ### Full Installation
```bash ```bash
# Create a new directory since we are going to be pulling in engine too # Create a new directory since we are going to be pulling in engine too
mkdir xo-terminal && cd xo-terminal mkdir xo-terminal && cd xo-terminal
# ----- 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
# Install the dependencies
npm ci
# Build the engine
npm run build
# ----- End Engine Setup -----
# Move back to the top level directory
cd ..
# ----- Start State Setup ----- # ----- Start State Setup -----
# Clone the State Repo # Clone the State Repo
git clone https://gitlab.com/Harvmaster/state.git git clone https://gitlab.com/Harvmaster/state.git
@@ -34,7 +15,7 @@ git clone https://gitlab.com/Harvmaster/state.git
# Move into the state directory # Move into the state directory
cd state cd state
git checkout in-memory-adapter git checkout cli-test
# Install the dependencies # Install the dependencies
npm ci npm ci
@@ -46,6 +27,37 @@ npm run build
# Move back to the top level directory # Move back to the top level directory
cd .. 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 ---- # ----- Start Template Setup ----
# Clone the Template repo # Clone the Template repo
git clone https://gitlab.com/Harvmaster/templates.git git clone https://gitlab.com/Harvmaster/templates.git
@@ -63,6 +75,26 @@ npm run build
# Move back to the top level directory # Move back to the top level directory
cd .. 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 ----- # ----- Start CLI Setup -----
# Clone the CLI Repo # Clone the CLI Repo
git clone https://git.harvmaster.com/Harvmaster/xo-cli.git git clone https://git.harvmaster.com/Harvmaster/xo-cli.git
@@ -70,6 +102,8 @@ git clone https://git.harvmaster.com/Harvmaster/xo-cli.git
# Move into the cli directory # Move into the cli directory
cd xo-cli cd xo-cli
git checkout kiok-update
# Install the dependencies # Install the dependencies
npm ci npm ci
@@ -93,28 +127,37 @@ npm install -g .
### Install autocomplete completions (From the xo-cli directory) ### 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 #### Install for bash
```bash ```bash
npm run autocomplete:install:bash npm run autocomplete:install:bash
``` ```
#### Install for zsh #### Install for zsh
```bash ```bash
npm run autocomplete:install:zsh npm run autocomplete:install:zsh
``` ```
#### Install for fish #### Install for fish
```bash ```bash
npm run autocomplete:install:fish npm run autocomplete:install:fish
``` ```
### Run the CLI ### Run the CLI
```bash ```bash
# If globally installed (Not really usable if not globally installed) # If globally installed (Not really usable if not globally installed)
xo-cli xo-cli
``` ```
### Run the TUI ### Run the TUI
```bash ```bash
# If globally installed # If globally installed
xo-tui xo-tui
@@ -122,3 +165,55 @@ xo-tui
# If not globally installed # If not globally installed
npm run dev 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.

View File

@@ -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` | JSON settings (`default-mnemonic`, `currency`) | | `$XO_CONFIG_DIR/.wallet` | JSON settings (`default-mnemonic`, `currency`) |
**Local to your shells 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 shells 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 |
| ------------------------- | ----------------------------------------- | | ------------------------- | --------------------------------------- |
| `XO_CONFIG_DIR` | `~/.config/xo-cli` |
| `SYNC_SERVER_URL` | `http://localhost:3000` | | `SYNC_SERVER_URL` | `http://localhost:3000` |
| `DB_PATH` | `~/.config/xo-cli/data` | | `DB_PATH` | `$XO_CONFIG_DIR/data` |
| `DB_FILENAME` | `xo-wallet.db` | | `DB_FILENAME` | `xo-wallet.db` |
| `INVITATION_STORAGE_PATH` | `~/.config/xo-cli/data/xo-invitations.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
@@ -68,7 +71,7 @@ xo-cli mnemonic list
### Wallet Persistence ### Wallet Persistence
The first time you pass `-m <name>`, that reference is saved as The first time you pass `-m <name>`, that reference is saved as
`default-mnemonic` in `~/.config/xo-cli/.wallet`. Later runs can omit `-m`. `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. `currency` controls the fiat unit used when showing BCH/sats conversions in the TUI.
@@ -76,7 +79,7 @@ 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
@@ -86,14 +89,14 @@ 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) |
| `--currency <code>` | Fiat display currency (e.g. `USD`, `AUD`) | | `--currency <code>` | Fiat display currency (e.g. `USD`, `AUD`) |
| `-o`, `--output <filename>` | Output filename (used by `mnemonic create`/`import`) | | `-o`, `--output <filename>` | Output filename (used by `mnemonic create`/`import`) |
| `-v`, `--verbose` | Verbose output | | `-v`, `--verbose` | Verbose output |
| `-h`, `--help` | Help | | `-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
@@ -201,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 |

View File

@@ -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",

View File

@@ -19,11 +19,7 @@
* xo-cli completions fish --install * xo-cli completions fish --install
*/ */
import { import { existsSync, readFileSync, appendFileSync, mkdirSync } from "node:fs";
existsSync,
readFileSync,
appendFileSync,
} 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";
@@ -34,8 +30,8 @@ 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 * - settings.ts: show, get, set
*/ */
@@ -43,7 +39,7 @@ import { homedir } from "node:os";
/** 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 = [ const INVITATION_SUBS = [
"create", "create",
@@ -52,6 +48,7 @@ const INVITATION_SUBS = [
"broadcast", "broadcast",
"requirements", "requirements",
"import", "import",
"export",
"inspect", "inspect",
"list", "list",
]; ];
@@ -192,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,
@@ -201,51 +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< const shellConfigs: Record<
ShellType, ShellType,
{ configFile: string; evalCommand: (binName: string) => string } {
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[] = [];
if (!config.configDirPattern.test(existingContent)) {
commands.push(config.configDirCommand);
}
if (!existingContent.includes(evalCommand)) {
commands.push(evalCommand);
}
if (commands.length === 0) {
return false;
}
const newLine = const newLine =
existingContent.endsWith("\n") || existingContent === "" ? "" : "\n"; existingContent.endsWith("\n") || existingContent === "" ? "" : "\n";
const completionBlock = `${newLine}\n# ${binName} shell completions\n${evalCommand}\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;
} }

View File

@@ -8,11 +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 { import { BlockchainMonitor, Engine } from "@xo-cash/engine";
BlockchainMonitor,
Engine,
InMemoryBlockchainProvider,
} 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";
@@ -67,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;
} }

View File

@@ -26,6 +26,19 @@ __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 # @description
# Main completion dispatcher invoked by bash's `complete -F`. # Main completion dispatcher invoked by bash's `complete -F`.
# It determines context (command/subcommand/argument position) and then mixes: # It determines context (command/subcommand/argument position) and then mixes:
@@ -39,10 +52,10 @@ _{{FUNC_NAME}}_completions() {
_init_completion || return _init_completion || return
# If the previous token is `-m/--mnemonic-file`, this argument expects a # If the previous token is `-m/--mnemonic-file`, this argument expects a
# mnemonic file alias/path. Ask the helper for mnemonic suggestions. # 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")
@@ -161,7 +174,7 @@ _{{FUNC_NAME}}_completions() {
fi fi
fi fi
;; ;;
append|sign|broadcast|requirements|inspect) append|sign|broadcast|requirements|export|inspect|delete)
# These subcommands expect an invitation identifier as first arg. # 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

View File

@@ -28,6 +28,21 @@ function __{{FUNC_NAME}}_complete_dynamic
end end
end end
# @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. # 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"
@@ -37,8 +52,8 @@ 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" complete -c {{BIN_NAME}} -l currency -d "Set fiat display currency"
# Dynamic completion for `-m/--mnemonic-file`. # 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 command registrations inserted by template expansion. # Top-level command registrations inserted by template expansion.
{{TOP_LEVEL_COMMANDS}} {{TOP_LEVEL_COMMANDS}}
@@ -70,6 +85,7 @@ complete -c {{BIN_NAME}} -n "__fish_seen_subcommand_from invitation; and __fish_
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 <path> # invitation import <path>

View File

@@ -25,6 +25,19 @@ __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 # @description
# Main zsh completion dispatcher registered via `compdef`. # Main zsh completion dispatcher registered via `compdef`.
# It resolves command context from `$words`/`$CURRENT` and serves: # It resolves command context from `$words`/`$CURRENT` and serves:
@@ -38,7 +51,7 @@ _{{FUNC_NAME}}_completions() {
# If previous token is `-m/--mnemonic-file`, complete mnemonic sources. # 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
@@ -145,7 +158,7 @@ _{{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. # 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

View File

@@ -11,13 +11,24 @@ import {
resolveProvidedLockingBytecodeHex, resolveProvidedLockingBytecodeHex,
mapUnspentOutputsToSelectable, mapUnspentOutputsToSelectable,
autoSelectGreedyUtxos, autoSelectGreedyUtxos,
hasMissingRequirements,
} from "../../utils/invitation-flow.js"; } from "../../utils/invitation-flow.js";
import { encodeExtendedJson } from "../../utils/ext-json.js"; import { deserializeInvitation, serializeInvitation } from "@xo-cash/engine";
import type { XOInvitation } from "@xo-cash/types";
import { resolveTemplate } from "../utils.js"; import { resolveTemplate } from "../utils.js";
const DEFAULT_FEE = 500n; const DEFAULT_FEE = 500n;
const DUST_THRESHOLD = 546n; const DUST_THRESHOLD = 546n;
/**
* Serializes an invitation to pretty-printed JSON for file export.
*/
const formatInvitationForFile = (
invitation: XOInvitation,
indent = 2,
): string =>
JSON.stringify(JSON.parse(serializeInvitation(invitation)), null, indent);
/** /**
* Result of parsing CLI options into inputs and outputs for an append call. * Result of parsing CLI options into inputs and outputs for an append call.
* A `null` return signals a fatal error that was already logged to stderr. * A `null` return signals a fatal error that was already logged to stderr.
@@ -286,9 +297,14 @@ ${bold("Sub-commands:")}
- broadcast <invitation-id> ${dim("Broadcast an invitation")} - broadcast <invitation-id> ${dim("Broadcast an invitation")}
- requirements <invitation-id> ${dim("Show requirements for an invitation")} - requirements <invitation-id> ${dim("Show requirements for an invitation")}
- import <invitation-file> ${dim("Import an invitation from a file")} - import <invitation-file> ${dim("Import an invitation from a file")}
- export <invitation-id> [output-file] ${dim("Export an invitation to stdout or a file")}
- delete <invitation-id> ${dim("Delete an invitation")}
- inspect <invitation-id | invitation-file> ${dim("Inspect an invitation")} - inspect <invitation-id | invitation-file> ${dim("Inspect an invitation")}
- list ${dim("List all invitations")} - list ${dim("List all invitations")}
${bold("Export options:")}
-o --output <output-filename> ${dim("Output filename for the exported invitation")}
${bold("Create / Append options:")} ${bold("Create / Append options:")}
-var-<name> <value> ${dim("Set a variable (e.g. -var-requested-satoshis 1000)")} -var-<name> <value> ${dim("Set a variable (e.g. -var-requested-satoshis 1000)")}
--add-input <txhash:vout> ${dim("Add UTXO input(s), comma-separated (e.g. abc123:0,def456:1)")} --add-input <txhash:vout> ${dim("Add UTXO input(s), comma-separated (e.g. abc123:0,def456:1)")}
@@ -311,6 +327,7 @@ export type InvitationCommandResult = {
invitationIdentifier?: string; invitationIdentifier?: string;
txHash?: string; txHash?: string;
count?: number; count?: number;
outputFile?: string;
templateName?: string; templateName?: string;
actionIdentifier?: string; actionIdentifier?: string;
status?: string; status?: string;
@@ -320,6 +337,65 @@ export type InvitationCommandResult = {
variables?: unknown[]; variables?: unknown[];
}; };
/**
* Handles the invitation export command.
* Throws CommandError on failure, returns result data on success.
* @param deps - The command dependencies.
* @param args - Positional args after "export", e.g. ["invitation-id"] or ["invitation-id", "invitation.json"].
* @param options - Parsed option flags.
*/
export const handleInvitationExportCommand = async (
deps: CommandDependencies,
args: string[],
options: Record<string, string>,
): Promise<{ invitationIdentifier?: string; outputFile?: string }> => {
const invitationIdentifier = args[0];
deps.io.verbose(`Invitation identifier: ${invitationIdentifier}`);
if (!invitationIdentifier) {
deps.io.verbose("No invitation identifier provided");
printInvitationHelp(deps.io);
throw new CommandError(
"invitation.export.identifier_missing",
"No invitation identifier provided",
);
}
const invitation = deps.app.invitations.find(
(candidate) => candidate.data.invitationIdentifier === invitationIdentifier,
);
if (!invitation) {
deps.io.err(`Invitation not found: ${invitationIdentifier}`);
throw new CommandError(
"invitation.export.not_found",
`Invitation not found: ${invitationIdentifier}`,
);
}
const serializedInvitation = serializeInvitation(invitation.data);
const outputFile = options["output"] ?? args[1];
if (!outputFile) {
deps.io.out(serializedInvitation);
return { invitationIdentifier };
}
const outputPath = path.resolve(process.cwd(), outputFile);
try {
writeFileSync(outputPath, serializedInvitation);
} catch (error) {
throw new CommandError(
"invitation.export.write_failed",
`Failed to export invitation to file: ${outputPath} (${error instanceof Error ? error.message : "unknown error"})`,
);
}
deps.io.out(`Invitation exported to: ${outputPath}`);
return { invitationIdentifier, outputFile: outputPath };
};
/** /**
* Handles the invitation command. * Handles the invitation command.
* Throws CommandError on failure, returns result data on success. * Throws CommandError on failure, returns result data on success.
@@ -380,7 +456,7 @@ export const handleInvitationCommand = async (
// Create our own invitation instance out of the raw XOInvitation. This will also initate the SSE Session // Create our own invitation instance out of the raw XOInvitation. This will also initate the SSE Session
const invitationInstance = await deps.app.createInvitation(rawInvitation); const invitationInstance = await deps.app.createInvitation(rawInvitation);
deps.io.verbose( deps.io.verbose(
`Invitation created: ${formatObject(invitationInstance.data)}`, `Invitation instance created: ${formatObject(invitationInstance.data)}`,
); );
// Read the variables that were passed in via `-var-<name> <value>` // Read the variables that were passed in via `-var-<name> <value>`
@@ -401,6 +477,8 @@ export const handleInvitationCommand = async (
// Append the inputs and outputs to the invitation // Append the inputs and outputs to the invitation
const { inputs, outputs } = params; const { inputs, outputs } = params;
deps.io.verbose(`Inputs: ${formatObject(inputs)}`);
deps.io.verbose(`Outputs: ${formatObject(outputs)}`);
if (inputs.length > 0 || outputs.length > 0) { if (inputs.length > 0 || outputs.length > 0) {
await invitationInstance.append({ inputs, outputs }); await invitationInstance.append({ inputs, outputs });
} }
@@ -411,7 +489,7 @@ export const handleInvitationCommand = async (
deps.io.verbose(`Invitation file path: ${invitationFilePath}`); deps.io.verbose(`Invitation file path: ${invitationFilePath}`);
writeFileSync( writeFileSync(
invitationFilePath, invitationFilePath,
encodeExtendedJson(invitationInstance.data, 2), formatInvitationForFile(invitationInstance.data),
); );
deps.io.out( deps.io.out(
`Invitation created: ${path.basename(invitationFilePath)} (${invitationInstance.data.invitationIdentifier})`, `Invitation created: ${path.basename(invitationFilePath)} (${invitationInstance.data.invitationIdentifier})`,
@@ -421,11 +499,13 @@ export const handleInvitationCommand = async (
const missingRequirements = const missingRequirements =
await invitationInstance.getMissingRequirements(); await invitationInstance.getMissingRequirements();
const hasMissing = const hasMissing =
(missingRequirements.variables?.length ?? 0) > 0 || hasMissingRequirements(missingRequirements.templateRequirements) ||
(missingRequirements.inputs?.length ?? 0) > 0 || missingRequirements.inputsMissingSignatures.length > 0;
(missingRequirements.outputs?.length ?? 0) > 0 ||
(missingRequirements.roles !== undefined && deps.io.verbose(
Object.keys(missingRequirements.roles).length > 0); `Missing requirements: ${formatObject(missingRequirements)}`,
);
deps.io.verbose(`Has missing requirements: ${hasMissing}`);
// If there are missing requirements, print them out // If there are missing requirements, print them out
if (hasMissing) { if (hasMissing) {
@@ -437,6 +517,9 @@ export const handleInvitationCommand = async (
options["sign"] === "true" || options["broadcast"] === "true"; options["sign"] === "true" || options["broadcast"] === "true";
const shouldBroadcast = options["broadcast"] === "true"; const shouldBroadcast = options["broadcast"] === "true";
deps.io.verbose(`Should sign: ${shouldSign}`);
deps.io.verbose(`Should broadcast: ${shouldBroadcast}`);
// Sign the invitation if the user has requested it // Sign the invitation if the user has requested it
if (shouldSign) { if (shouldSign) {
await invitationInstance.sign(); await invitationInstance.sign();
@@ -532,7 +615,10 @@ export const handleInvitationCommand = async (
// Write the invitation to a file in the working directory // Write the invitation to a file in the working directory
// TODO: Support the -o flag to specify the output path // TODO: Support the -o flag to specify the output path
const invitationFilePath = `${deps.paths.workingDir}/inv-${invitation.data.invitationIdentifier}.json`; const invitationFilePath = `${deps.paths.workingDir}/inv-${invitation.data.invitationIdentifier}.json`;
writeFileSync(invitationFilePath, encodeExtendedJson(invitation.data, 2)); writeFileSync(
invitationFilePath,
formatInvitationForFile(invitation.data),
);
deps.io.out( deps.io.out(
`Invitation updated: ${path.basename(invitationFilePath)} (${invitation.data.invitationIdentifier})`, `Invitation updated: ${path.basename(invitationFilePath)} (${invitation.data.invitationIdentifier})`,
); );
@@ -540,11 +626,8 @@ export const handleInvitationCommand = async (
// Get the missing requirements for the invitation. This will tell us if we are missing any variables, inputs, outputs, or roles. // Get the missing requirements for the invitation. This will tell us if we are missing any variables, inputs, outputs, or roles.
const missingRequirements = await invitation.getMissingRequirements(); const missingRequirements = await invitation.getMissingRequirements();
const hasMissing = const hasMissing =
(missingRequirements.variables?.length ?? 0) > 0 || hasMissingRequirements(missingRequirements.templateRequirements) ||
(missingRequirements.inputs?.length ?? 0) > 0 || missingRequirements.inputsMissingSignatures.length > 0;
(missingRequirements.outputs?.length ?? 0) > 0 ||
(missingRequirements.roles !== undefined &&
Object.keys(missingRequirements.roles).length > 0);
// If there are missing requirements, print them out // If there are missing requirements, print them out
if (hasMissing) { if (hasMissing) {
@@ -721,11 +804,10 @@ export const handleInvitationCommand = async (
} }
// Read the invitation file (XOInvitation format, can be passed to the engine directly) // Read the invitation file (XOInvitation format, can be passed to the engine directly)
const invitationFile = await readFileSync(invitationFilePath, "utf8"); const invitationFile = readFileSync(invitationFilePath, "utf8");
deps.io.verbose(`Invitation file: ${invitationFile}`); deps.io.verbose(`Invitation file: ${invitationFile}`);
// Parse the invitation file const invitation = deserializeInvitation(invitationFile);
const invitation = JSON.parse(invitationFile);
deps.io.verbose(`Invitation: ${formatObject(invitation)}`); deps.io.verbose(`Invitation: ${formatObject(invitation)}`);
// Create the invitation instance (NOTE: Not an engine compatible invitation. This is the wrapped format) // Create the invitation instance (NOTE: Not an engine compatible invitation. This is the wrapped format)
@@ -839,19 +921,27 @@ export const handleInvitationCommand = async (
} }
// Read the invitation file (XOInvitation format, can be passed to the engine directly) // Read the invitation file (XOInvitation format, can be passed to the engine directly)
const invitationFile = await readFileSync(invitationFilePath, "utf8"); const invitationFile = readFileSync(invitationFilePath, "utf8");
deps.io.verbose(`Invitation file: ${invitationFile}`); deps.io.verbose(`Invitation file: ${invitationFile}`);
// Parse the invitation file const invitation = deserializeInvitation(invitationFile);
const invitation = JSON.parse(invitationFile);
deps.io.verbose(`Invitation: ${formatObject(invitation)}`); deps.io.verbose(`Invitation: ${formatObject(invitation)}`);
// "Creates" the invitiation in the engine. This method acts as both creation or import depending on the data that is being passed in const template = await deps.app.engine.getTemplate(
const xoInvitation = await deps.app.engine.createInvitation(invitation); invitation.templateIdentifier,
deps.io.verbose(`XOInvitation: ${formatObject(xoInvitation)}`); );
if (!template) {
throw new CommandError(
"invitation.import.template_not_found",
`Template not found: ${invitation.templateIdentifier}. ` +
`Import the matching template first with: xo-cli template import <template-file>`,
);
}
// Create the invitation instance (NOTE: Not an engine compatible invitation. This is the wrapped format) // Accept and track the invitation. Invitation.create calls acceptInvitation
const invitationInstance = await deps.app.createInvitation(xoInvitation); // internally — do not call engine.createInvitation here, that creates a new
// invitation and discards the imported commits.
const invitationInstance = await deps.app.createInvitation(invitation);
deps.io.verbose( deps.io.verbose(
`Invitation created: ${formatObject(invitationInstance.data)}`, `Invitation created: ${formatObject(invitationInstance.data)}`,
); );
@@ -862,6 +952,51 @@ export const handleInvitationCommand = async (
}; };
} }
case "export": {
return handleInvitationExportCommand(deps, args.slice(1), options);
}
case "delete": {
// Get the invitation identifier from the arguments
const invitationIdentifier = args[1];
deps.io.verbose(`Invitation identifier: ${invitationIdentifier}`);
// If they didnt provide us with an invitation identifier, print the help message and throw an error
// TODO: Should probably print a specific help message for this command?
if (!invitationIdentifier) {
deps.io.verbose("No invitation identifier provided");
printInvitationHelp(deps.io);
throw new CommandError(
"invitation.delete.identifier_missing",
"No invitation identifier provided",
);
}
// Find the invitation instance in our list of invitations
const invitation = deps.app.invitations.find(
(candidate) =>
candidate.data.invitationIdentifier === invitationIdentifier,
);
// If the invitation is not found, print an error and throw an error
if (!invitation) {
deps.io.err(`Invitation not found: ${invitationIdentifier}`);
throw new CommandError(
"invitation.delete.not_found",
`Invitation not found: ${invitationIdentifier}`,
);
}
deps.io.verbose(`Invitation: ${formatObject(invitation.data)}`);
// Delete the invitation
await invitation.delete();
deps.io.verbose(`Invitation deleted: ${formatObject(invitation.data)}`);
deps.io.out(`Invitation deleted: ${invitationIdentifier}`);
// Return the invitation identifier
return { invitationIdentifier };
}
case "list": { case "list": {
// List all the invitations // List all the invitations
const invitations = await Promise.all( const invitations = await Promise.all(

View File

@@ -2,8 +2,14 @@ import { hexToBin } from "@bitauth/libauth";
import { bold, dim } from "../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.
@@ -27,9 +33,14 @@ ${bold("Sub-commands:")}
* Formats a single UTXO for display, optionally including reservation info. * Formats a single UTXO for display, optionally including reservation info.
*/ */
function formatResource( function formatResource(
resource: UnspentOutputData, resource: UnspentOutputWithMetadata & { template?: XOTemplate },
showReserved = false, showReserved = false,
): string { ): string {
// Format the template
const template = resource.template
? dim(`[${generateTemplateIdentifier(resource.template)}]`)
: "";
// Format the outpoint // Format the outpoint
const outpoint = bold( const outpoint = bold(
`${resource.outpointTransactionHash}:${resource.outpointIndex}`, `${resource.outpointTransactionHash}:${resource.outpointIndex}`,
@@ -39,7 +50,9 @@ function formatResource(
const value = dim(`${resource.valueSatoshis} sats`); const value = dim(`${resource.valueSatoshis} sats`);
// Format the output // Format the output
const output = dim(resource.outputIdentifier); const output = resource.outputIdentifier
? dim(resource.outputIdentifier)
: "";
// Format the height // Format the height
const height = dim(`(height ${resource.minedAtHeight})`); const height = dim(`(height ${resource.minedAtHeight})`);
@@ -47,11 +60,11 @@ function formatResource(
// If the resource is reserved, format the reservation info // 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}`;
} }
// Otherwise, format the resource without reservation info // Otherwise, format the resource without reservation info
return `${outpoint} ${value} ${output} ${height}`; return `${template} ${outpoint} ${value} ${output} ${height}`;
} }
/** /**
@@ -108,9 +121,30 @@ export const handleResourceCommand = async (
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 // 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) => const formattedResources = resourcesWithTemplateInformation.map((r) =>
formatResource(r, showReserved), formatResource(r, showReserved),
); );

View File

@@ -83,7 +83,7 @@ export const handleSettingsCommand = async (
const value = const value =
key === "currency" key === "currency"
? settings.getCurrency() ? settings.getCurrency()
: settings.getDefaultMnemonic() ?? ""; : (settings.getDefaultMnemonic() ?? "");
deps.io.out(value); deps.io.out(value);
return { key, value }; return { key, value };
} }

View File

@@ -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 "../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";
@@ -18,11 +22,15 @@ export const printTemplateHelp = (io: CommandIO): void => {
${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")}
`, `,
); );
}; };
@@ -338,6 +346,71 @@ export const handleTemplateInspectCommand = async (
} }
}; };
/**
* 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.
* Throws CommandError on failure, returns result data on success. * Throws CommandError on failure, returns result data on success.
@@ -348,8 +421,8 @@ 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 // Get the sub-command from the arguments
const subCommand = args[0]; const subCommand = args[0];
@@ -395,12 +468,26 @@ export const handleTemplateCommand = async (
); );
} }
// Read the template file // Read and load the template file (JSON directly, TS/JS via child process).
const template = await readFileSync(templatePath, "utf8"); 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}`);
// Import the template // Import the template
await deps.app.engine.importTemplate(template); await deps.app.engine.importTemplate(templateContents);
deps.io.verbose(`Template imported: ${templateFile}`); deps.io.verbose(`Template imported: ${templateFile}`);
// Return the template file // Return the template file
@@ -414,6 +501,10 @@ export const handleTemplateCommand = async (
// Handle the template inspect command, We offload here as it has lots of arguments and is quite long // 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 // Get the template file, output identifier, and role identifier from the arguments
const templateFile = args[1]; const templateFile = args[1];

View File

@@ -181,7 +181,9 @@ async function main(): Promise<void> {
// 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(
mnemonic,
{
syncServerUrl: options["syncServerUrl"] ?? "http://localhost:3000", syncServerUrl: options["syncServerUrl"] ?? "http://localhost:3000",
engineConfig: { engineConfig: {
databasePath: options["databasePath"] ?? paths.dataDir, databasePath: options["databasePath"] ?? paths.dataDir,
@@ -190,7 +192,9 @@ async function main(): Promise<void> {
invitationStoragePath: invitationStoragePath:
options["invitationStoragePath"] ?? options["invitationStoragePath"] ??
join(paths.dataDir, "xo-invitations.db"), join(paths.dataDir, "xo-invitations.db"),
}, settings); },
settings,
);
io.verbose("App instance created"); io.verbose("App instance created");
// Start the app // Start the app

View File

@@ -18,10 +18,13 @@ 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 { 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;
"invitation-removed": Invitation; "invitation-removed": Invitation;
@@ -53,11 +56,18 @@ export class AppService extends EventEmitter<AppEventMap> {
public settings: SettingsService; 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;
} }
>(); >();
@@ -81,22 +91,9 @@ 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);
// engine await engine.importTemplate(wrapBCHTemplate);
// .subscribeToLockingBytecodesForTemplate(templateIdentifier)
// .catch((err) =>
// console.error(
// `Error subscribing to locking bytecodes for template ${templateIdentifier}: ${err}`,
// ),
// );
// engine
// .updateUnspentOutputsForTemplate(templateIdentifier)
// .catch((err) =>
// console.error(
// `Error updating unspent outputs for template ${templateIdentifier}: ${err}`,
// ),
// );
// Update all the unspents for every template, and subscribe to the locking bytecodes for changes // Update all the unspents for every template, and subscribe to the locking bytecodes for changes
// TODO: Remove the above lines that do the same thing. Minimising changes for BLISS. // TODO: Remove the above lines that do the same thing. Minimising changes for BLISS.
@@ -104,8 +101,12 @@ export class AppService extends EventEmitter<AppEventMap> {
const templates = await engine.listImportedTemplates(); const templates = await engine.listImportedTemplates();
templates.forEach(async (template) => { templates.forEach(async (template) => {
// engine.updateUnspentOutputsForTemplate(generateTemplateIdentifier(template)); engine.updateUnspentOutputsForTemplate(
engine.subscribeToLockingBytecodesForTemplate(generateTemplateIdentifier(template)); generateTemplateIdentifier(template),
);
engine.subscribeToScriptHashForTemplate(
generateTemplateIdentifier(template),
);
}); });
}; };
@@ -131,7 +132,14 @@ export class AppService extends EventEmitter<AppEventMap> {
}); });
const rates = await RatesService.create(settings); const rates = await RatesService.create(settings);
return new AppService(engine, walletStorage, config, electrum, rates, settings); return new AppService(
engine,
walletStorage,
config,
electrum,
rates,
settings,
);
} }
constructor( constructor(
@@ -175,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;
} }
@@ -186,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);
@@ -204,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);
@@ -218,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) =>
@@ -285,9 +322,9 @@ 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. // Start rates in the background so BCH -> fiat conversions become reactive in the TUI.
this.rates.start().catch((err) => this.rates
console.error('Error starting rates service:', err), .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");

View File

@@ -1,9 +1,8 @@
import { binToHex, hexToBin, sha256 } from "@bitauth/libauth"; import { binToHex, hexToBin, sha256 } from "@bitauth/libauth";
import { compileCashAssemblyString, type Engine } from "@xo-cash/engine"; import { compileCashAssemblyString, type Engine } from "@xo-cash/engine";
import type { ScriptHashData, UnspentOutputData } from "@xo-cash/state"; import type { ScriptHashData, State, UnspentOutputData } from "@xo-cash/state";
import type { import type {
XOInvitation, XOInvitation,
XOInvitationCommit,
XOInvitationInput, XOInvitationInput,
XOInvitationOutput, XOInvitationOutput,
XOInvitationVariableValue, XOInvitationVariableValue,
@@ -96,7 +95,6 @@ export class HistoryService {
private invitations: Invitation[], private invitations: Invitation[],
) {} ) {}
/** /**
* I Might swap this over to invitation based history before the event to make it a bit more evident... Really not happy with the UTXO for demo purposes * I Might swap this over to invitation based history before the event to make it a bit more evident... Really not happy with the UTXO for demo purposes
* But for the actual usage, UTXO is easier to follow - just not good for demo * But for the actual usage, UTXO is easier to follow - just not good for demo
@@ -115,7 +113,10 @@ export class HistoryService {
for (const context of utxoContexts) { for (const context of utxoContexts) {
const invitationIdentifier = context.utxo.reservedBy; const invitationIdentifier = context.utxo.reservedBy;
if (invitationIdentifier && invitationContexts.has(invitationIdentifier)) { if (
invitationIdentifier &&
invitationContexts.has(invitationIdentifier)
) {
const group = reservedUtxosByInvitation.get(invitationIdentifier) ?? []; const group = reservedUtxosByInvitation.get(invitationIdentifier) ?? [];
group.push(context); group.push(context);
reservedUtxosByInvitation.set(invitationIdentifier, group); reservedUtxosByInvitation.set(invitationIdentifier, group);
@@ -142,12 +143,16 @@ export class HistoryService {
}); });
} }
private async buildInvitationContextIndex(): Promise<Map<string, InvitationContext>> { private async buildInvitationContextIndex(): Promise<
Map<string, InvitationContext>
> {
const contexts = new Map<string, InvitationContext>(); const contexts = new Map<string, InvitationContext>();
for (const invitation of this.invitations) { for (const invitation of this.invitations) {
const template = const templateIdentifier = invitation.data.templateIdentifier;
(await this.engine.getTemplate(invitation.data.templateIdentifier)) ?? null; const template = templateIdentifier
? ((await this.engine.getTemplate(templateIdentifier)) ?? null)
: null;
contexts.set(invitation.data.invitationIdentifier, { contexts.set(invitation.data.invitationIdentifier, {
invitation, invitation,
template, template,
@@ -164,17 +169,29 @@ export class HistoryService {
const scriptHashDataByScriptHash = new Map<string, ScriptHashData>(); const scriptHashDataByScriptHash = new Map<string, ScriptHashData>();
const templateIdentifiers = new Set<string>(); const templateIdentifiers = new Set<string>();
for (const utxo of allUtxos) {
templateIdentifiers.add(utxo.templateIdentifier);
}
for (const invitation of this.invitations) { for (const invitation of this.invitations) {
if (invitation.data.templateIdentifier) {
templateIdentifiers.add(invitation.data.templateIdentifier); templateIdentifiers.add(invitation.data.templateIdentifier);
} }
}
const uniqueScriptHashes = new Set(allUtxos.map((utxo) => utxo.scriptHash));
for (const scriptHash of uniqueScriptHashes) {
const scriptHashData = await this.getScriptHashData(scriptHash);
if (scriptHashData === undefined) continue;
scriptHashDataByScriptHash.set(scriptHash, scriptHashData);
templateIdentifiers.add(scriptHashData.templateIdentifier);
}
for (const templateIdentifier of templateIdentifiers) { for (const templateIdentifier of templateIdentifiers) {
const scriptHashDataList = await this.engine.listScriptHashesForTemplate(templateIdentifier); const scriptHashDataList =
await this.engine.listScriptHashesForTemplate(templateIdentifier);
for (const scriptHashData of scriptHashDataList) { for (const scriptHashData of scriptHashDataList) {
scriptHashDataByScriptHash.set(scriptHashData.scriptHash, scriptHashData); scriptHashDataByScriptHash.set(
scriptHashData.scriptHash,
scriptHashData,
);
} }
} }
@@ -185,9 +202,13 @@ export class HistoryService {
utxo: UnspentOutputData, utxo: UnspentOutputData,
metadataIndex: WalletMetadataIndex, metadataIndex: WalletMetadataIndex,
): Promise<UtxoContext> { ): Promise<UtxoContext> {
const scriptHashData = metadataIndex.scriptHashDataByScriptHash.get(utxo.scriptHash); const scriptHashData = metadataIndex.scriptHashDataByScriptHash.get(
const templateIdentifier = scriptHashData?.templateIdentifier ?? utxo.templateIdentifier; utxo.scriptHash,
const template = (await this.engine.getTemplate(templateIdentifier)) ?? null; );
const templateIdentifier = scriptHashData?.templateIdentifier;
const template = templateIdentifier
? ((await this.engine.getTemplate(templateIdentifier)) ?? null)
: null;
return { return {
utxo, utxo,
@@ -202,8 +223,15 @@ export class HistoryService {
): WalletHistoryItem { ): WalletHistoryItem {
const invitation = context.invitation.data; const invitation = context.invitation.data;
const entityRoles = this.deriveInvitationEntityRoles(context); const entityRoles = this.deriveInvitationEntityRoles(context);
const inputs = this.projectInvitationInputs(context, reservedContexts, entityRoles); const inputs = this.projectInvitationInputs(
const inputUtxoIds = this.listInvitationInputUtxoIds(context, reservedContexts); context,
reservedContexts,
entityRoles,
);
const inputUtxoIds = this.listInvitationInputUtxoIds(
context,
reservedContexts,
);
const outputs = this.projectInvitationOutputs( const outputs = this.projectInvitationOutputs(
context, context,
reservedContexts, reservedContexts,
@@ -252,7 +280,9 @@ export class HistoryService {
const outpointIndex = input.outpointIndex; const outpointIndex = input.outpointIndex;
if (txid === undefined || outpointIndex === undefined) continue; if (txid === undefined || outpointIndex === undefined) continue;
const utxoContext = reservedByOutpoint.get(this.getOutpointKey(txid, outpointIndex)); const utxoContext = reservedByOutpoint.get(
this.getOutpointKey(txid, outpointIndex),
);
// TODO: Remove this reservation-based filter once Engine/library cleanup releases stale invitation reservations internally. // TODO: Remove this reservation-based filter once Engine/library cleanup releases stale invitation reservations internally.
if (!utxoContext) continue; if (!utxoContext) continue;
@@ -298,13 +328,18 @@ export class HistoryService {
// UTXO-first: committed outputs only matter here if they resolve to a wallet UTXO currently reserved by this invitation. // UTXO-first: committed outputs only matter here if they resolve to a wallet UTXO currently reserved by this invitation.
if (!matchingContext) continue; if (!matchingContext) continue;
const lockingBytecode = this.getOutputLockingBytecodeHex(output) ?? matchingContext.scriptHashData?.lockingBytecode; const lockingBytecode =
const outputIdentifier = output.outputIdentifier ?? matchingContext.scriptHashData?.outputIdentifier ?? matchingContext.utxo.outputIdentifier; this.getOutputLockingBytecodeHex(output) ??
matchingContext.scriptHashData?.lockingBytecode;
const outputIdentifier =
output.outputIdentifier ??
matchingContext.scriptHashData?.outputIdentifier;
const role = const role =
output.roleIdentifier ?? output.roleIdentifier ??
this.getFirstEntityRole(entityRoles, commit.entityIdentifier) ?? this.getFirstEntityRole(entityRoles, commit.entityIdentifier) ??
matchingContext.scriptHashData?.roleIdentifier; matchingContext.scriptHashData?.roleIdentifier;
const valueSatoshis = output.valueSatoshis !== undefined const valueSatoshis =
output.valueSatoshis !== undefined
? BigInt(output.valueSatoshis) ? BigInt(output.valueSatoshis)
: BigInt(matchingContext.utxo.valueSatoshis); : BigInt(matchingContext.utxo.valueSatoshis);
@@ -358,8 +393,11 @@ export class HistoryService {
const outpointIndex = input.outpointIndex; const outpointIndex = input.outpointIndex;
if (txid === undefined || outpointIndex === undefined) continue; if (txid === undefined || outpointIndex === undefined) continue;
const utxoContext = reservedByOutpoint.get(this.getOutpointKey(txid, outpointIndex)); const utxoContext = reservedByOutpoint.get(
if (utxoContext) invitationInputUtxoIds.add(this.getUtxoId(utxoContext.utxo)); this.getOutpointKey(txid, outpointIndex),
);
if (utxoContext)
invitationInputUtxoIds.add(this.getUtxoId(utxoContext.utxo));
} }
} }
@@ -379,21 +417,30 @@ export class HistoryService {
return reservedContexts.find((context) => { return reservedContexts.find((context) => {
if (usedUtxoIds.has(this.getUtxoId(context.utxo))) return false; if (usedUtxoIds.has(this.getUtxoId(context.utxo))) return false;
if (scriptHash && context.utxo.scriptHash === scriptHash) return true; if (scriptHash && context.utxo.scriptHash === scriptHash) return true;
if (lockingBytecode && context.scriptHashData?.lockingBytecode === lockingBytecode) return true; if (
if (output.outputIdentifier && context.utxo.outputIdentifier === output.outputIdentifier) return true; lockingBytecode &&
context.scriptHashData?.lockingBytecode === lockingBytecode
)
return true;
if (
output.outputIdentifier &&
context.scriptHashData?.outputIdentifier === output.outputIdentifier
)
return true;
return false; return false;
}); });
} }
private projectStandaloneUtxo(context: UtxoContext): WalletHistoryItem { private projectStandaloneUtxo(context: UtxoContext): WalletHistoryItem {
const output = this.projectUtxoOutput(context); const output = this.projectUtxoOutput(context);
const templateIdentifier = context.scriptHashData?.templateIdentifier ?? context.utxo.templateIdentifier; const templateIdentifier = context.scriptHashData?.templateIdentifier;
const role = output.role; const role = output.role;
return { return {
id: `utxo-${context.utxo.outpointTransactionHash}:${context.utxo.outpointIndex}`, id: `utxo-${context.utxo.outpointTransactionHash}:${context.utxo.outpointIndex}`,
source: "utxo", source: "utxo",
templateIdentifier, templateIdentifier: templateIdentifier ?? "",
template: context.template?.name ?? "UnknownTemplate", template: context.template?.name ?? "UnknownTemplate",
roles: role ? [role] : ["unknown"], roles: role ? [role] : ["unknown"],
description: output.description, description: output.description,
@@ -404,14 +451,18 @@ export class HistoryService {
} }
private projectUtxoOutput(context: UtxoContext): WalletHistoryOutput { private projectUtxoOutput(context: UtxoContext): WalletHistoryOutput {
const outputIdentifier = context.scriptHashData?.outputIdentifier ?? context.utxo.outputIdentifier; const outputIdentifier = context.scriptHashData?.outputIdentifier;
const role = context.scriptHashData?.roleIdentifier; const role = context.scriptHashData?.roleIdentifier;
return { return {
id: this.getUtxoId(context.utxo), id: this.getUtxoId(context.utxo),
outputIdentifier, outputIdentifier,
role, role,
description: this.describeOutputFromTemplate(outputIdentifier, context.template, {}), description: this.describeOutputFromTemplate(
outputIdentifier,
context.template,
{},
),
valueSatoshis: BigInt(context.utxo.valueSatoshis), valueSatoshis: BigInt(context.utxo.valueSatoshis),
outpoint: { outpoint: {
txid: context.utxo.outpointTransactionHash, txid: context.utxo.outpointTransactionHash,
@@ -423,17 +474,22 @@ export class HistoryService {
}; };
} }
private deriveInvitationEntityRoles(context: InvitationContext): Map<string, string[]> { private deriveInvitationEntityRoles(
context: InvitationContext,
): Map<string, string[]> {
const invitation = context.invitation.data; const invitation = context.invitation.data;
const rolesByEntity = new Map<string, Set<string>>(); const rolesByEntity = new Map<string, Set<string>>();
const allEntities = new Set(invitation.commits.map((commit) => commit.entityIdentifier)); const allEntities = new Set(
invitation.commits.map((commit) => commit.entityIdentifier),
);
for (const entityIdentifier of allEntities) { for (const entityIdentifier of allEntities) {
rolesByEntity.set(entityIdentifier, new Set()); rolesByEntity.set(entityIdentifier, new Set());
} }
for (const commit of invitation.commits) { for (const commit of invitation.commits) {
const roles = rolesByEntity.get(commit.entityIdentifier) ?? new Set<string>(); const roles =
rolesByEntity.get(commit.entityIdentifier) ?? new Set<string>();
for (const input of commit.data.inputs ?? []) { for (const input of commit.data.inputs ?? []) {
if (input.roleIdentifier) roles.add(input.roleIdentifier); if (input.roleIdentifier) roles.add(input.roleIdentifier);
} }
@@ -447,7 +503,8 @@ export class HistoryService {
} }
const action = context.template?.actions?.[invitation.actionIdentifier]; const action = context.template?.actions?.[invitation.actionIdentifier];
const participantRoles = action?.requirements?.participants const participantRoles =
action?.requirements?.participants
?.map((participant) => participant.role) ?.map((participant) => participant.role)
.filter((role): role is string => typeof role === "string") ?? []; .filter((role): role is string => typeof role === "string") ?? [];
const explicitlyFilledRoles = new Set<string>(); const explicitlyFilledRoles = new Set<string>();
@@ -461,7 +518,10 @@ export class HistoryService {
.filter(([, roles]) => roles.size === 0) .filter(([, roles]) => roles.size === 0)
.map(([entityIdentifier]) => entityIdentifier); .map(([entityIdentifier]) => entityIdentifier);
if (unfilledParticipantRoles.length === 1 && entitiesWithoutRoles.length >= 1) { if (
unfilledParticipantRoles.length === 1 &&
entitiesWithoutRoles.length >= 1
) {
const inferredRole = unfilledParticipantRoles[0]; const inferredRole = unfilledParticipantRoles[0];
if (inferredRole !== undefined) { if (inferredRole !== undefined) {
for (const entityIdentifier of entitiesWithoutRoles) { for (const entityIdentifier of entitiesWithoutRoles) {
@@ -505,12 +565,21 @@ export class HistoryService {
inputs: WalletHistoryInput[], inputs: WalletHistoryInput[],
outputs: WalletHistoryOutput[], outputs: WalletHistoryOutput[],
): bigint { ): bigint {
const inputTotal = inputs.reduce((total, input) => total + (input.valueSatoshis ?? 0n), 0n); const inputTotal = inputs.reduce(
const outputTotal = outputs.reduce((total, output) => total + (output.valueSatoshis ?? 0n), 0n); (total, input) => total + (input.valueSatoshis ?? 0n),
0n,
);
const outputTotal = outputs.reduce(
(total, output) => total + (output.valueSatoshis ?? 0n),
0n,
);
return inputTotal + outputTotal; return inputTotal + outputTotal;
} }
private describeInvitation(context: InvitationContext, role?: string): string { private describeInvitation(
context: InvitationContext,
role?: string,
): string {
const invitation = context.invitation.data; const invitation = context.invitation.data;
const template = context.template; const template = context.template;
if (!template) return invitation.actionIdentifier; if (!template) return invitation.actionIdentifier;
@@ -532,14 +601,27 @@ export class HistoryService {
return this.compileDescription(descriptionTemplate, context.variables); return this.compileDescription(descriptionTemplate, context.variables);
} }
private describeInput(inputIdentifier: string | undefined, context: InvitationContext): string { private describeInput(
inputIdentifier: string | undefined,
context: InvitationContext,
): string {
if (!inputIdentifier) return "Input"; if (!inputIdentifier) return "Input";
const input = context.template?.inputs?.[inputIdentifier]; const input = context.template?.inputs?.[inputIdentifier];
return this.compileDescription(input?.description ?? input?.name ?? inputIdentifier, context.variables); return this.compileDescription(
input?.description ?? input?.name ?? inputIdentifier,
context.variables,
);
} }
private describeOutput(outputIdentifier: string | undefined, context: InvitationContext): string { private describeOutput(
return this.describeOutputFromTemplate(outputIdentifier, context.template, context.variables); outputIdentifier: string | undefined,
context: InvitationContext,
): string {
return this.describeOutputFromTemplate(
outputIdentifier,
context.template,
context.variables,
);
} }
private describeOutputFromTemplate( private describeOutputFromTemplate(
@@ -549,7 +631,10 @@ export class HistoryService {
): string { ): string {
if (!outputIdentifier) return "Output"; if (!outputIdentifier) return "Output";
const output = template?.outputs?.[outputIdentifier]; const output = template?.outputs?.[outputIdentifier];
return this.compileDescription(output?.description ?? output?.name ?? outputIdentifier, variables); return this.compileDescription(
output?.description ?? output?.name ?? outputIdentifier,
variables,
);
} }
private compileDescription( private compileDescription(
@@ -557,16 +642,25 @@ export class HistoryService {
variables: Record<string, XOInvitationVariableValue>, variables: Record<string, XOInvitationVariableValue>,
): string { ): string {
try { try {
return compileCashAssemblyString(description, variables); return compileCashAssemblyString({
cashAssemblyText: description,
variables,
evaluationDecodeMode: "utf8",
});
} catch { } catch {
return this.interpolateSimpleCashAssemblyVariables(description, variables); return this.interpolateSimpleCashAssemblyVariables(
description,
variables,
);
} }
} }
private extractInvitationVariables( private extractInvitationVariables(
invitation: XOInvitation, invitation: XOInvitation,
): Record<string, XOInvitationVariableValue> { ): Record<string, XOInvitationVariableValue> {
const committedVariables = invitation.commits.flatMap((c) => c.data.variables ?? []); const committedVariables = invitation.commits.flatMap(
(c) => c.data.variables ?? [],
);
return committedVariables.reduce( return committedVariables.reduce(
(acc, variable) => { (acc, variable) => {
if (!variable.variableIdentifier) return acc; if (!variable.variableIdentifier) return acc;
@@ -584,13 +678,23 @@ export class HistoryService {
: String(input.outpointTransactionHash); : String(input.outpointTransactionHash);
} }
private getOutputLockingBytecodeHex(output: XOInvitationOutput): string | undefined { private getOutputLockingBytecodeHex(
output: XOInvitationOutput,
): string | undefined {
if (output.lockingBytecode === undefined) return undefined; if (output.lockingBytecode === undefined) return undefined;
return typeof output.lockingBytecode === "string" return typeof output.lockingBytecode === "string"
? output.lockingBytecode ? output.lockingBytecode
: binToHex(output.lockingBytecode); : binToHex(output.lockingBytecode);
} }
private async getScriptHashData(
scriptHash: string,
): Promise<ScriptHashData | undefined> {
return (this.engine as unknown as { state: State }).state.getScriptHashData(
scriptHash,
);
}
private getOutpointKey(txid: string, index: number): string { private getOutpointKey(txid: string, index: number): string {
return `${txid}:${index}`; return `${txid}:${index}`;
} }
@@ -611,7 +715,9 @@ export class HistoryService {
return text.replace( return text.replace(
/\$\(<([^>]+)>\)/g, /\$\(<([^>]+)>\)/g,
(match, variableIdentifier: string) => { (match, variableIdentifier: string) => {
if (!Object.prototype.hasOwnProperty.call(variables, variableIdentifier)) { if (
!Object.prototype.hasOwnProperty.call(variables, variableIdentifier)
) {
return match; return match;
} }
return String(variables[variableIdentifier]); return String(variables[variableIdentifier]);

View File

@@ -1,10 +1,15 @@
import type { import type {
AcceptInvitationParameters, InvitationParameters,
AppendInvitationParameters,
Engine, Engine,
GetSpendableResourcesParameters, GetSpendableResourcesParameters,
} from "@xo-cash/engine"; } from "@xo-cash/engine";
import { generateTemplateIdentifier, hasInvitationExpired, mergeInvitationCommits } from "@xo-cash/engine"; import {
generateTemplateIdentifier,
hasInvitationExpired,
mergeInvitationCommits,
serializeInvitation,
deserializeInvitation,
} 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,11 +35,18 @@ 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;
"invitation-removed": void;
error: Error; error: Error;
}; };
@@ -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.
@@ -86,17 +106,38 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
} }
// engine invitation (I have no idea if this is required) // engine invitation (I have no idea if this is required)
const engineInvitation = await dependencies.engine.acceptInvitation(invitation); const engineInvitation = await dependencies.engine.importInvitation(
serializeInvitation(invitation),
);
// Create the invitation // Create the invitation
const invitationInstance = new Invitation(engineInvitation, dependencies); const invitationInstance = new Invitation(
engineInvitation,
// Start the invitation and its tracking dependencies,
await invitationInstance.start(); 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.
*/ */
@@ -124,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).
@@ -133,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([
@@ -157,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.
// Merge the commits
const combinedCommits = this.mergeCommits( const combinedCommits = this.mergeCommits(
sseCommits, this.data.commits,
invitation?.commits ?? [], invitation?.commits ?? [],
); );
// Set the invitation data with the combined commits try {
this.data = { ...this.data, ...invitation, commits: combinedCommits }; // Prefer keeping the engine's local invitation state in sync.
this.updateInvitationData(
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 });
}
// Store the invitation in the storage
await this.storage.set(this.data.invitationIdentifier, this.data); 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);
@@ -178,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)));
} }
} }
@@ -189,30 +275,87 @@ 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) // Filter out commits that already exist
const newCommits = this.mergeCommits( const newCommits = this.mergeCommits(this.data.commits, invitation.commits);
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 });
}
// Set the new commits await this.storage.set(this.data.invitationIdentifier, this.data);
this.data = { ...this.data, commits: newCommits }; await this.updateStatus();
// 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); 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;
} }
/** /**
@@ -221,12 +364,12 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
private async publishInvitation( private async publishInvitation(
invitation: XOInvitation = this.data, invitation: XOInvitation = this.data,
): Promise<void> { ): Promise<void> {
try { this.syncServer.publishInvitation(invitation).catch((error) => {
await this.syncServer.publishInvitation(invitation); this.emit(
} catch (err) { "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. error instanceof Error ? error : new Error(String(error)),
this.emit("error", err instanceof Error ? err : new Error(String(err))); );
} });
} }
/** /**
@@ -279,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";
} }
@@ -370,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()
.then((status) => {
this.status = status; this.status = status;
this.emit("invitation-status-changed", 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);
@@ -402,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();
@@ -413,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(
this.data.invitationIdentifier,
{
broadcastTransaction: true, broadcastTransaction: true,
}); },
);
await this.updateStatus(); await this.updateStatus();
@@ -429,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);
@@ -510,8 +696,8 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
const templates = await this.engine.listImportedTemplates(); const templates = await this.engine.listImportedTemplates();
// For each template, we need to create a 2d array of all the outputs // For each template, we need to create a 2d array of all the outputs
const outputs = templates.map(template => { const outputs = templates.map((template) => {
return Object.keys(template.outputs).map(output => { return Object.keys(template.outputs).map((output) => {
const templateIdentifier = generateTemplateIdentifier(template); const templateIdentifier = generateTemplateIdentifier(template);
return { return {
@@ -522,14 +708,18 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
}); });
// then, for each output, we need to get the spendable resources // then, for each output, we need to get the spendable resources
const spendableResources = await Promise.all(outputs.flat().map(output => { const spendableResources = await Promise.all(
outputs.flat().map((output) => {
return this.engine.getSpendableResources(this.data, { return this.engine.getSpendableResources(this.data, {
templateIdentifier: output.templateIdentifier, templateIdentifier: output.templateIdentifier,
outputIdentifier: output.outputIdentifier, outputIdentifier: output.outputIdentifier,
}); });
})); }),
);
const unspentOutputs = spendableResources.flatMap(resource => resource.unspentOutputs); const unspentOutputs = spendableResources.flatMap(
(resource) => resource.unspentOutputs,
);
// Update the status of the invitation // Update the status of the invitation
await this.updateStatus(); await this.updateStatus();
@@ -546,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);
} }
/** /**
@@ -608,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}`,
); );
@@ -623,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
@@ -688,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();
}
} }

View File

@@ -1,15 +1,14 @@
import { EventEmitter } from '../utils/event-emitter.js'; import { OracleClient } from "@generalprotocols/oracle-client";
import { import { EventEmitter } from "../utils/event-emitter.js";
type RatesEventMap, import { type RatesEventMap } from "../utils/rates/base-rates.js";
} from '../utils/rates/base-rates.js'; import { RatesOracle } from "../utils/rates/rates-oracles.js";
import { RatesOracle } from '../utils/rates/rates-oracles.js'; import { SettingsService } from "./settings.js";
import { SettingsService } from './settings.js';
/** /**
* Event map emitted by {@link RatesService}. * Event map emitted by {@link RatesService}.
*/ */
export type RatesServiceEventMap = { export type RatesServiceEventMap = {
'rate-updated': { "rate-updated": {
numeratorUnitCode: string; numeratorUnitCode: string;
denominatorUnitCode: string; denominatorUnitCode: string;
price: number; price: number;
@@ -38,8 +37,8 @@ export interface RatesAdapter {
listPairs(): Promise<Set<string>>; listPairs(): Promise<Set<string>>;
formatCurrency(amount: number, targetCurrency: string): string; formatCurrency(amount: number, targetCurrency: string): string;
on( on(
type: 'rateUpdated', type: "rateUpdated",
listener: (detail: RatesEventMap['rateUpdated']) => void, listener: (detail: RatesEventMap["rateUpdated"]) => void,
): () => void; ): () => void;
} }
@@ -73,8 +72,17 @@ export class RatesService extends EventEmitter<RatesServiceEventMap> {
settings: SettingsService, settings: SettingsService,
adapter?: RatesAdapter, adapter?: RatesAdapter,
): Promise<RatesService> { ): Promise<RatesService> {
const resolvedAdapter = adapter ?? (await RatesOracle.from(undefined, settings)); if (adapter) {
return new RatesService(resolvedAdapter, settings); return new RatesService(adapter, settings);
}
const oracleClient = new OracleClient();
oracleClient.start();
const ratesOracle = new RatesOracle(oracleClient, settings);
ratesOracle.start();
return new RatesService(ratesOracle, settings);
} }
/** /**
@@ -86,7 +94,7 @@ export class RatesService extends EventEmitter<RatesServiceEventMap> {
} }
this.started = true; this.started = true;
this.unsubscribeFromAdapter = this.adapter.on('rateUpdated', (event) => { this.unsubscribeFromAdapter = this.adapter.on("rateUpdated", (event) => {
this.handleRateUpdated(event); this.handleRateUpdated(event);
}); });
@@ -135,9 +143,9 @@ export class RatesService extends EventEmitter<RatesServiceEventMap> {
*/ */
public convertBchToFiat( public convertBchToFiat(
satoshis: bigint, satoshis: bigint,
targetCurrency: string = 'USD', targetCurrency: string = "USD",
): number | null { ): number | null {
const rate = this.getRate(targetCurrency, 'BCH'); const rate = this.getRate(targetCurrency, "BCH");
if (rate === null) { if (rate === null) {
return null; return null;
} }
@@ -151,7 +159,7 @@ export class RatesService extends EventEmitter<RatesServiceEventMap> {
*/ */
public formatBchToFiat( public formatBchToFiat(
satoshis: bigint, satoshis: bigint,
targetCurrency: string = 'USD', targetCurrency: string = "USD",
): string | null { ): string | null {
const normalizedCurrency = targetCurrency.toUpperCase(); const normalizedCurrency = targetCurrency.toUpperCase();
const amount = this.convertBchToFiat(satoshis, normalizedCurrency); const amount = this.convertBchToFiat(satoshis, normalizedCurrency);
@@ -185,7 +193,7 @@ export class RatesService extends EventEmitter<RatesServiceEventMap> {
/** /**
* Handles normalized updates from the underlying adapter. * Handles normalized updates from the underlying adapter.
*/ */
private handleRateUpdated(event: RatesEventMap['rateUpdated']): void { private handleRateUpdated(event: RatesEventMap["rateUpdated"]): void {
const numeratorUnitCode = event.numeratorUnitCode.toUpperCase(); const numeratorUnitCode = event.numeratorUnitCode.toUpperCase();
const denominatorUnitCode = event.denominatorUnitCode.toUpperCase(); const denominatorUnitCode = event.denominatorUnitCode.toUpperCase();
const pair = this.getPairKey(numeratorUnitCode, denominatorUnitCode); const pair = this.getPairKey(numeratorUnitCode, denominatorUnitCode);
@@ -196,7 +204,7 @@ export class RatesService extends EventEmitter<RatesServiceEventMap> {
updatedAt, updatedAt,
}); });
this.emit('rate-updated', { this.emit("rate-updated", {
numeratorUnitCode, numeratorUnitCode,
denominatorUnitCode, denominatorUnitCode,
price: event.price, price: event.price,

View File

@@ -32,7 +32,7 @@ const DEFAULT_SETTINGS: SettingsData = {
/** /**
* Handles loading, migrating, and persisting wallet settings. * Handles loading, migrating, and persisting wallet settings.
* *
* The backing file is `~/.config/xo-cli/.wallet`. Historically it stored a raw * The backing file is `<XO_CONFIG_DIR>/.wallet`. Historically it stored a raw
* mnemonic reference string. This service migrates that legacy format to JSON: * mnemonic reference string. This service migrates that legacy format to JSON:
* `{ "default-mnemonic": "<value>", "currency": "USD" }`. * `{ "default-mnemonic": "<value>", "currency": "USD" }`.
*/ */
@@ -168,7 +168,9 @@ export class SettingsService extends EventEmitter<SettingsServiceEventMap> {
return normalized; return normalized;
} }
const maybeMnemonic = (input as Record<string, unknown>)["default-mnemonic"]; const maybeMnemonic = (input as Record<string, unknown>)[
"default-mnemonic"
];
if (typeof maybeMnemonic === "string" && maybeMnemonic.trim().length > 0) { if (typeof maybeMnemonic === "string" && maybeMnemonic.trim().length > 0) {
normalized["default-mnemonic"] = maybeMnemonic.trim(); normalized["default-mnemonic"] = maybeMnemonic.trim();
} }

View File

@@ -1,5 +1,6 @@
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. * 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.
@@ -56,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);
@@ -93,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),
})); }));
} }
@@ -111,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> {
@@ -174,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);
} }
@@ -199,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),
})); }));
} }
@@ -208,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> {

View 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: [],
},
},
],
},
],
};

View 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" },
],
};

View File

@@ -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>;
} }

View File

@@ -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>

View 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>
);
}

View File

@@ -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);

View File

@@ -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,36 +52,32 @@ 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(() => {
return appService && invitationId
? appService.getInvitationRevision(invitationId)
: 0;
}, [appService, invitationId]);
useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
if (!appService || !invitationId) { if (!appService || !invitationId) {
return null; return null;
} }
@@ -95,9 +87,6 @@ export function useInvitation(invitationId: string | null): Invitation | null {
(inv) => inv.data.invitationIdentifier === invitationId (inv) => inv.data.invitationIdentifier === invitationId
) ?? null ) ?? null
); );
}, [appService, invitationId]);
return useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
} }
/** /**
@@ -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]);
} }
/** /**

View File

@@ -44,7 +44,7 @@ interface MnemonicFileEntry {
type FocusSection = 'files' | 'input' | 'saveCheckbox' | 'generateRandomSeed' | '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.
@@ -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';
@@ -399,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}>

View File

@@ -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(
@@ -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.
*/ */
@@ -236,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.
@@ -254,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();
}
}); });
/** /**
@@ -328,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}
/> />
@@ -350,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>
@@ -360,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}
/> />
@@ -382,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'}
@@ -461,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>
); );
} }

View File

@@ -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>
);
}

View File

@@ -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';

View File

@@ -26,12 +26,10 @@ import type { XOInvitationCommit, XOInvitationVariableValue, XOTemplate } from '
import { import {
getInvitationState, getInvitationState,
getStateColorName, getStateColorName,
getInvitationInputs,
getInvitationOutputs,
getInvitationVariables,
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'; import { compileCashAssemblyString } from '@xo-cash/engine';
@@ -63,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' },
]; ];
/** /**
@@ -221,7 +220,7 @@ export function InvitationScreen(): React.ReactElement {
let isCurrent = true; let isCurrent = true;
appService.engine.getOwnCommits(selectedInvitation.data) appService.engine.findOwnCommits(selectedInvitation.data.invitationIdentifier)
.then((ownCommits) => { .then((ownCommits) => {
if (!isCurrent) return; if (!isCurrent) return;
@@ -332,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');
@@ -377,17 +422,12 @@ 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 || [];
for (const variable of variables) {
if (variable.variableIdentifier?.toLowerCase().includes('satoshi')) {
requiredAmount = BigInt(variable.value?.toString() || '0'); requiredAmount = BigInt(variable.value?.toString() || '0');
break; break;
} }
} }
if (requiredAmount > 0n) break;
}
const fee = 500n; const fee = 500n;
const dust = 546n; const dust = 546n;
@@ -489,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') {
@@ -573,14 +614,17 @@ 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 variables = getInvitationVariables(selectedInvitation);
const userEntityId = ownInvitationContext.entityIdentifier; const userEntityId = ownInvitationContext.entityIdentifier;
const userRole = ownInvitationContext.roleIdentifier; const userRole = ownInvitationContext.roleIdentifier;
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 getFiatSuffix = (satoshis: bigint): string => {
const fiatValue = formatSatoshisToFiat(satoshis); const fiatValue = formatSatoshisToFiat(satoshis);
return fiatValue ? ` (~${fiatValue})` : ''; return fiatValue ? ` (~${fiatValue})` : '';
@@ -603,11 +647,10 @@ export function InvitationScreen(): React.ReactElement {
} }
}; };
const isSatoshisVariable = (variableIdentifier: string): boolean => { const isSatoshisVariable = (variable: ResolvedInvitationVariable): boolean => {
const templateVariable = selectedTemplate?.variables?.[variableIdentifier]; const templateHint = variable.hint?.toLowerCase();
const templateType = templateVariable?.type?.toLowerCase(); const templateType = variable.type?.toLowerCase();
const templateHint = templateVariable?.hint?.toLowerCase(); const identifier = variable.variableIdentifier.toLowerCase();
const identifier = variableIdentifier.toLowerCase();
if (templateHint?.includes('satoshi')) { if (templateHint?.includes('satoshi')) {
return true; return true;
@@ -619,6 +662,20 @@ export function InvitationScreen(): React.ReactElement {
); );
}; };
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 */}
@@ -671,28 +728,21 @@ 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 = ( const inputSatoshis = (
'valueSatoshis' in input && input.valueSatoshis !== undefined 'valueSatoshis' in input && input.valueSatoshis !== undefined
) )
? parseNumberishToBigInt(input.valueSatoshis) ? parseNumberishToBigInt(input.valueSatoshis)
: null; : 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}
> >
{/* Indicator for whether this is the user's input */}
{' '}{isUserInput ? '• ' : '○ '} {' '}{isUserInput ? '• ' : '○ '}
{input.name ?? input.inputIdentifier ?? `Input ${idx}`}
{/* TODO: Why doesnt this stuff work? It just cant resolve inputs? */}
{/* Input name */}
{inputTemplate?.name ?? input.inputIdentifier ?? `Input ${idx}`}
{/* Input role */}
{input.roleIdentifier && ` (${input.roleIdentifier})`} {input.roleIdentifier && ` (${input.roleIdentifier})`}
{inputDescription && ` - ${inputDescription}`}
{/* Input value */}
{inputSatoshis !== null && ` ${formatSatoshis(inputSatoshis)}${getFiatSuffix(inputSatoshis)}`} {inputSatoshis !== null && ` ${formatSatoshis(inputSatoshis)}${getFiatSuffix(inputSatoshis)}`}
</Text> </Text>
); );
@@ -707,28 +757,18 @@ 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 const outputSatoshis = output.valueSatoshis !== undefined
? parseNumberishToBigInt(output.valueSatoshis) ? parseNumberishToBigInt(output.valueSatoshis)
: null; : 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}
> >
{/* Indicator for whether this is the user's output */}
{' '}{isUserOutput ? '• ' : '○ '} {' '}{isUserOutput ? '• ' : '○ '}
{output.name ?? output.outputIdentifier ?? `Output ${idx}`}
{/* Output name */} {outputDescription && ` - ${outputDescription}`}
{outputTemplate?.name ?? output.outputIdentifier ?? `Output ${idx}`}
{/* Output description */}
{outputTemplate?.description && ' - ' + compileCashAssemblyString(outputTemplate?.description ?? '', variables.reduce((acc, variable) => {
acc[variable.variableIdentifier] = variable.value as XOInvitationVariableValue;
return acc;
}, {} as Record<string, XOInvitationVariableValue>))}
{/* Output value */}
{outputSatoshis !== null && ` (${formatSatoshis(outputSatoshis)}${getFiatSuffix(outputSatoshis)})`} {outputSatoshis !== null && ` (${formatSatoshis(outputSatoshis)}${getFiatSuffix(outputSatoshis)})`}
</Text> </Text>
); );
@@ -745,11 +785,10 @@ 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.variableIdentifier) const parsedVariableSatoshis = isSatoshisVariable(variable)
? parseNumberishToBigInt(variable.value) ? parseNumberishToBigInt(variable.value)
: null; : null;
return ( return (
@@ -758,11 +797,11 @@ export function InvitationScreen(): React.ReactElement {
color={isUserVariable ? colors.success : colors.text} color={isUserVariable ? colors.success : colors.text}
> >
{' '}{isUserVariable ? '• ' : '○ '} {' '}{isUserVariable ? '• ' : '○ '}
{varTemplate?.name ?? variable.variableIdentifier}: {displayValue} {variable.name ?? variable.variableIdentifier}: {displayValue}
{parsedVariableSatoshis !== null && {parsedVariableSatoshis !== null &&
` (${formatSatoshis(parsedVariableSatoshis)}${getFiatSuffix(parsedVariableSatoshis)})`} ` (${formatSatoshis(parsedVariableSatoshis)}${getFiatSuffix(parsedVariableSatoshis)})`}
{varTemplate?.description && ( {variable.description && (
<Text color={colors.textMuted} dimColor> - {varTemplate.description}</Text> <Text color={colors.textMuted} dimColor> - {variable.description}</Text>
)} )}
</Text> </Text>
); );

View File

@@ -14,12 +14,29 @@ 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.
*/ */
@@ -51,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 (
@@ -143,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>
@@ -170,15 +188,17 @@ export function PreviewInvitationStep({
</Box> </Box>
) : ( ) : (
outputs.map((output, idx) => { outputs.map((output, idx) => {
const outputTemplate = template?.outputs?.[output.outputIdentifier ?? ''];
const fiatValue = output.valueSatoshis !== undefined const fiatValue = output.valueSatoshis !== undefined
? formatSatoshisToFiat(output.valueSatoshis) ? formatSatoshisToFiat(output.valueSatoshis)
: null; : 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})`} {fiatValue && ` (~${fiatValue})`}
</Text> </Text>
</Box> </Box>
@@ -201,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>
); );

View File

@@ -13,30 +13,36 @@ const execAsync = promisify(exec);
// The command is a function that returns a promise that resolves to the result of the command. // The command is a function that returns a promise that resolves to the result of the command.
const clipboardMethods = { const clipboardMethods = {
pbCopy: { pbCopy: {
platform: (platform: string) => platform === 'darwin', platform: (platform: string) => platform === "darwin",
command: async (text: string) => execAsync(`printf '%s' '${text}' | pbcopy`), command: async (text: string) =>
execAsync(`printf '%s' '${text}' | pbcopy`),
}, },
xclip: { xclip: {
platform: (platform: string) => platform === 'linux', platform: (platform: string) => platform === "linux",
command: async (text: string) => execAsync(`printf '%s' '${text}' | xclip -selection clipboard`), command: async (text: string) =>
execAsync(`printf '%s' '${text}' | xclip -selection clipboard`),
}, },
xsel: { xsel: {
platform: (platform: string) => platform === 'linux', platform: (platform: string) => platform === "linux",
command: async (text: string) => execAsync(`printf '%s' '${text}' | xsel --clipboard --input`), command: async (text: string) =>
execAsync(`printf '%s' '${text}' | xsel --clipboard --input`),
}, },
ssh: { ssh: {
platform: (platform: string) => platform === 'linux', platform: (platform: string) => platform === "linux",
command: async (text: string) => process.stdout.write(`\x1b]52;c;${Buffer.from(text, 'utf-8').toString('base64')}\x07`), command: async (text: string) =>
process.stdout.write(
`\x1b]52;c;${Buffer.from(text, "utf-8").toString("base64")}\x07`,
),
}, },
clip: { clip: {
platform: (platform: string) => platform === 'windows', platform: (platform: string) => platform === "windows",
command: async (text: string) => execAsync(`echo|set /p="${text}" | clip`), command: async (text: string) => execAsync(`echo|set /p="${text}" | clip`),
}, },
clipboardy: { clipboardy: {
platform: (platform: string) => platform === 'windows', platform: (platform: string) => platform === "windows",
command: async (text: string) => clipboardy.writeSync(text), 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.
@@ -51,7 +57,9 @@ 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, "'\\''");
const availableMethods = Object.values(clipboardMethods).filter(method => method.platform(platform)); const availableMethods = Object.values(clipboardMethods).filter((method) =>
method.platform(platform),
);
const errors: Error[] = []; const errors: Error[] = [];
@@ -63,7 +71,7 @@ export async function copyToClipboard(text: string): Promise<void> {
continue; continue;
} }
return; return;
} catch(error) { } catch (error) {
if (error instanceof Error) { if (error instanceof Error) {
errors.push(error); errors.push(error);
} }
@@ -71,5 +79,7 @@ export async function copyToClipboard(text: string): Promise<void> {
} }
// All methods failed // All methods failed
throw new Error(`Clipboard not available. ${errors.map(error => error.message).join('\n')}`); throw new Error(
`Clipboard not available. ${errors.map((error) => error.message).join("\n")}`,
);
} }

View 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;

View 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}`,
};
}
}

View File

@@ -51,7 +51,7 @@ export function buildHistoryDisplayRows(
type: "history_output", type: "history_output",
label: output.outpoint label: output.outpoint
? `${output.outpoint.txid}:${output.outpoint.index}` ? `${output.outpoint.txid}:${output.outpoint.index}`
: output.outputIdentifier ?? "Output", : (output.outputIdentifier ?? "Output"),
description: `${item.template} | ${roles} | ${output.description}`, description: `${item.template} | ${roles} | ${output.description}`,
timestamp: item.createdAtTimestamp, timestamp: item.createdAtTimestamp,
isNested: false, isNested: false,
@@ -96,7 +96,7 @@ export function buildHistoryDisplayRows(
type: "history_output", type: "history_output",
label: output.outpoint label: output.outpoint
? `${output.outpoint.txid}:${output.outpoint.index}` ? `${output.outpoint.txid}:${output.outpoint.index}`
: output.outputIdentifier ?? "Output", : (output.outputIdentifier ?? "Output"),
description: output.description, description: output.description,
isNested: true, isNested: true,
valueSatoshis: output.valueSatoshis, valueSatoshis: output.valueSatoshis,

View File

@@ -30,7 +30,7 @@ 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 // TODO: Move to engine in templates.ts
@@ -65,8 +65,18 @@ export const roleRequiresInputs = (
const actionRole = action.roles?.[roleIdentifier]; const actionRole = action.roles?.[roleIdentifier];
const actionRequirements = action.requirements; const actionRequirements = action.requirements;
const actionRoleRequirements = actionRole && actionRequirements && actionRequirements.participants?.find((participant) => participant.role === roleIdentifier); const actionRoleRequirements =
const roleSlotsMin = actionRoleRequirements && actionRoleRequirements.slots && actionRoleRequirements.slots.min > 0 ? actionRoleRequirements.slots.min : 0; 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;
const transactionIdentifier = action.transaction; const transactionIdentifier = action.transaction;
@@ -78,7 +88,6 @@ export const roleRequiresInputs = (
return (roleInputs?.length ?? 0) > 0; return (roleInputs?.length ?? 0) > 0;
}; };
export const getTransactionOutputIdentifier = ( export const getTransactionOutputIdentifier = (
output: XOTemplateTransactionOutput, output: XOTemplateTransactionOutput,
): string | undefined => { ): string | undefined => {
@@ -136,7 +145,8 @@ export const resolveProvidedLockingBytecodeHex = (
return undefined; return undefined;
} }
const lockingScriptDefinition = template.lockingScripts?.[outputDefinition.lockingScript]; const lockingScriptDefinition =
template.lockingScripts?.[outputDefinition.lockingScript];
const scriptIdentifier = lockingScriptDefinition?.lockingBytecode; const scriptIdentifier = lockingScriptDefinition?.lockingBytecode;
if (!scriptIdentifier) return undefined; if (!scriptIdentifier) return undefined;

View 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.",
);
}

View File

@@ -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;
} }
@@ -50,7 +51,7 @@ export function getWalletConfigPath(): string {
/** /**
* 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

View 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.",
);
}

View File

@@ -1,4 +1,4 @@
import { EventEmitter } from '../event-emitter.js'; import { EventEmitter } from "../event-emitter.js";
/** /**
* Events emitted by our Rates Adapters * Events emitted by our Rates Adapters
@@ -44,14 +44,15 @@ export abstract class BaseRates<
BCH: 8, BCH: 8,
USD: 2, USD: 2,
}; };
const minimumFractionDigits = minimumFractionDigitsMap[normalizedCurrency] ?? 2; const minimumFractionDigits =
minimumFractionDigitsMap[normalizedCurrency] ?? 2;
const maximumFractionDigits = Math.max(minimumFractionDigits, 8); const maximumFractionDigits = Math.max(minimumFractionDigits, 8);
try { try {
const formatter = new Intl.NumberFormat('en-US', { const formatter = new Intl.NumberFormat("en-US", {
style: 'currency', style: "currency",
currency: normalizedCurrency, currency: normalizedCurrency,
currencyDisplay: 'narrowSymbol', currencyDisplay: "narrowSymbol",
minimumFractionDigits, minimumFractionDigits,
maximumFractionDigits, maximumFractionDigits,
}); });
@@ -61,7 +62,7 @@ export abstract class BaseRates<
// Some numerator symbols from oracle pairs (e.g. DOGE/BCH) are not ISO-4217 // 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. // fiat currency codes, so Intl currency formatting will throw a RangeError.
// In that case we still return a human-readable formatted value. // In that case we still return a human-readable formatted value.
const numericFormatter = new Intl.NumberFormat('en-US', { const numericFormatter = new Intl.NumberFormat("en-US", {
minimumFractionDigits, minimumFractionDigits,
maximumFractionDigits, maximumFractionDigits,
}); });

View File

@@ -3,11 +3,11 @@ import {
OracleMetadataMessage, OracleMetadataMessage,
OraclePriceMessage, OraclePriceMessage,
type OracleMetadataMap, type OracleMetadataMap,
} from '@generalprotocols/oracle-client'; } from "@generalprotocols/oracle-client";
import { type RatesEventMap, BaseRates } from './base-rates.js'; import { type RatesEventMap, BaseRates } from "./base-rates.js";
import { type OffCallback } from '../event-emitter.js'; import { type OffCallback } from "../event-emitter.js";
import { SettingsService } from '../../services/settings.js'; import { SettingsService } from "../../services/settings.js";
// Add the Oracle Price Message to our Events for this Adapter. // Add the Oracle Price Message to our Events for this Adapter.
export type RatesOracleEventMap = RatesEventMap & { export type RatesOracleEventMap = RatesEventMap & {
@@ -42,10 +42,10 @@ export class RatesOracle extends BaseRates<RatesOracleEventMap> {
private started: boolean = false; private started: boolean = false;
private targetNumeratorUnitCode: string; private targetNumeratorUnitCode: string;
private targetDenominatorUnitCode: string = 'BCH'; private targetDenominatorUnitCode: string = "BCH";
private unsubscribeFromSettings: OffCallback | null = null; private unsubscribeFromSettings: OffCallback | null = null;
private constructor(client: OracleClient, settings: SettingsService) { public constructor(client: OracleClient, settings: SettingsService) {
super(); super();
this.client = client; this.client = client;
@@ -63,7 +63,7 @@ export class RatesOracle extends BaseRates<RatesOracleEventMap> {
} }
this.started = true; this.started = true;
this.unsubscribeFromSettings = this.settings.on( this.unsubscribeFromSettings = this.settings.on(
'settings-updated', "settings-updated",
this.handleSettingsUpdated.bind(this), this.handleSettingsUpdated.bind(this),
); );
@@ -150,7 +150,11 @@ export class RatesOracle extends BaseRates<RatesOracleEventMap> {
this.handlePriceMessage(message); this.handlePriceMessage(message);
} }
} catch (error) { } catch (error) {
console.error('Error refreshing prices for oracle:', oracle.publicKey, error); console.error(
"Error refreshing prices for oracle:",
oracle.publicKey,
error,
);
} }
}), }),
); );
@@ -183,8 +187,10 @@ export class RatesOracle extends BaseRates<RatesOracleEventMap> {
return; return;
} }
const sourceNumeratorUnitCode = oracle.SOURCE_NUMERATOR_UNIT_CODE.toUpperCase(); const sourceNumeratorUnitCode =
const sourceDenominatorUnitCode = oracle.SOURCE_DENOMINATOR_UNIT_CODE.toUpperCase(); oracle.SOURCE_NUMERATOR_UNIT_CODE.toUpperCase();
const sourceDenominatorUnitCode =
oracle.SOURCE_DENOMINATOR_UNIT_CODE.toUpperCase();
// Only emit the pair currently selected in settings. // Only emit the pair currently selected in settings.
if ( if (
@@ -197,7 +203,7 @@ export class RatesOracle extends BaseRates<RatesOracleEventMap> {
// Scale the price // Scale the price
const priceValue = message.priceValue / oracle.ATTESTATION_SCALING; const priceValue = message.priceValue / oracle.ATTESTATION_SCALING;
this.emit('rateUpdated', { this.emit("rateUpdated", {
numeratorUnitCode: sourceNumeratorUnitCode, numeratorUnitCode: sourceNumeratorUnitCode,
denominatorUnitCode: sourceDenominatorUnitCode, denominatorUnitCode: sourceDenominatorUnitCode,
price: priceValue, price: priceValue,
@@ -208,13 +214,11 @@ export class RatesOracle extends BaseRates<RatesOracleEventMap> {
/** /**
* Tracks updates to settings and switches the actively emitted fiat pair. * Tracks updates to settings and switches the actively emitted fiat pair.
*/ */
private handleSettingsUpdated( private handleSettingsUpdated(event: {
event: { key: "currency" | "default-mnemonic";
key: 'currency' | 'default-mnemonic';
value: string | undefined; value: string | undefined;
}, }) {
) { if (event.key !== "currency" || !event.value) {
if (event.key !== 'currency' || !event.value) {
return; return;
} }
@@ -223,7 +227,7 @@ export class RatesOracle extends BaseRates<RatesOracleEventMap> {
// Refresh so listeners get the latest value for the new currency quickly. // Refresh so listeners get the latest value for the new currency quickly.
if (this.started) { if (this.started) {
this.refreshPrices().catch((error) => { this.refreshPrices().catch((error) => {
console.error('Error refreshing prices after currency update:', error); console.error("Error refreshing prices after currency update:", error);
}); });
} }
} }

View 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,
};
};

View File

@@ -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;
} }
} }

View 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);
}

View File

@@ -188,13 +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. // 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,
}; };
}); });

View 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),
});

View 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");
});
});

View File

@@ -57,7 +57,9 @@ describe("settings command", () => {
{}, {},
); );
const persisted = JSON.parse(readFileSync(paths.walletConfigPath, "utf8")) as { const persisted = JSON.parse(
readFileSync(paths.walletConfigPath, "utf8"),
) as {
currency: string; currency: string;
"default-mnemonic"?: string; "default-mnemonic"?: string;
}; };

View File

@@ -1,5 +1,5 @@
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";
@@ -98,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",
@@ -124,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",
@@ -263,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);
}
});
}); });

View File

@@ -3,6 +3,7 @@ import {
existsSync, existsSync,
mkdirSync, mkdirSync,
readFileSync, readFileSync,
realpathSync,
rmSync, rmSync,
writeFileSync, writeFileSync,
} from "node:fs"; } from "node:fs";
@@ -19,8 +20,7 @@ import {
import { BCHMnemonicURL } from "../../src/utils/bch-mnemonic-url"; import { BCHMnemonicURL } from "../../src/utils/bch-mnemonic-url";
const TEST_SEED = const TEST_SEED =
"page pencil stock planet limb cluster assault speak off joke private pioneer"; "oven crop same above under tower promote decrease vocal pretty require slow";
describe("mnemonic utilities", () => { describe("mnemonic utilities", () => {
let tempDir: string; let tempDir: string;
@@ -54,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);
}); });
@@ -110,7 +110,15 @@ describe("mnemonic utilities", () => {
"/nonexistent", "/nonexistent",
"mnemonic-relative", "mnemonic-relative",
); );
expect(resolved).toBe(path.join(tempDir, "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);
} }

View File

@@ -1,3 +1,6 @@
// 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 { import {
@@ -16,9 +19,10 @@ import { InMemoryStorage } from "../../../src/services/storage";
import { MockElectrumService } from "./electrum-service"; import { MockElectrumService } from "./electrum-service";
import { MockRatesService } from "./rates-service"; import { MockRatesService } from "./rates-service";
import { RatesService } from "../../../src/services/rates"; import { RatesService } from "../../../src/services/rates";
import { SettingsService } from "../../../src/services/settings";
export const DEFAULT_SEED = export const DEFAULT_SEED =
"page pencil stock planet limb cluster assault speak off joke private pioneer"; "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.
@@ -67,8 +71,6 @@ export const addFakeResource = async (
status: UnspentOutputStatus.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,
@@ -143,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);
@@ -160,10 +159,15 @@ export const createMockEngine = async (seed: string) => {
}; };
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 mockRates = new MockRatesService(); const mockRates = new MockRatesService();
const rates = new RatesService(mockRates); const rates = new RatesService(mockRates, settings);
const mockElectrum = new MockElectrumService(); const mockElectrum = new MockElectrumService();
@@ -176,5 +180,5 @@ export const createMockAppService = async (engine: Engine) => {
invitationStoragePath: "test-invitations.db", invitationStoragePath: "test-invitations.db",
}; };
return new AppService(engine, storage, config, mockElectrum, rates); return new AppService(engine, storage, config, mockElectrum, rates, settings);
}; };

View File

@@ -5,7 +5,10 @@ export class MockRatesService extends BaseRates {
super(); super();
} }
async getRate(numeratorUnitCode: string, denominatorUnitCode: string): Promise<number> { async getRate(
numeratorUnitCode: string,
denominatorUnitCode: string,
): Promise<number> {
return 1; return 1;
} }

File diff suppressed because it is too large Load Diff

View File

@@ -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,6 +44,26 @@ 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", () => {
@@ -93,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");

View 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);
});
});

View 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");
});
});

View 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/,
);
});
});

View 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/);
});
});

View 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",
});
});
});