Compare commits

..

19 Commits

Author SHA1 Message Date
2f2e515d72 Fix change output and default locking script function 2026-05-04 10:00:49 +00:00
7ffb5c44b5 Fix history. Fix invitation accept 2026-05-04 09:28:23 +00:00
f978d740fe Add autocomplete installation scripts to package.json. Update readme. 2026-05-04 05:09:40 +00:00
6196d33b2a Fix build issues 2026-05-04 05:04:28 +00:00
ccfaf3fd70 Update to use published packages. Update types. Update readme. Fix tests. 2026-05-04 04:45:31 +00:00
531e53d2ae Fix test 2026-04-27 12:47:02 +00:00
b708c8c1f8 Fix invitation import reactivity and focus imported invitation 2026-04-27 12:46:52 +00:00
53ad7b729e Fix clipboard actions over ssh 2026-04-27 12:45:04 +00:00
e73fb24422 Add installation instruction as readme 2026-04-27 12:35:54 +00:00
b282bbf5d6 Update readme for cli and command parsing 2026-04-27 09:48:10 +00:00
bd1ae909b5 Fix tests 2026-04-27 09:45:38 +00:00
e97054fa34 Fix help docs 2026-04-27 09:45:07 +00:00
a43a45831c Missed the utils file during previous commit 2026-04-27 09:44:42 +00:00
1bbc21c742 Remove [next] from template actions 2026-04-27 09:14:44 +00:00
9fa87d01b3 Combine cli-utils with utils 2026-04-27 09:14:30 +00:00
7ad17a7c0e Add oracle rates 2026-04-27 08:42:51 +00:00
dbfb2c68d2 Formatting 2026-04-20 12:26:35 +00:00
32c42cdc2d Remove ESBuild experiment. Add --install option for bash completions. Move shell scripts to separate files for auditability. Fix template inspect command autocomplete and output formatting 2026-04-20 11:12:26 +00:00
ff2fe126c6 Tests. Autocomplete. Few Fixes. Mocks for Electrum Service. Template-to-Json parser. Fix global paths. Use IO Dependency injection for logging from cli. Additional commands in CLI. 2026-04-20 10:30:38 +00:00
73 changed files with 11378 additions and 2360 deletions

3
.gitignore vendored
View File

@@ -3,6 +3,7 @@ Electrum.sqlite
XO.sqlite XO.sqlite
node_modules/ node_modules/
dist/ dist/
coverage/
*.db *.db
*.db-shm *.db-shm
*.db-wal *.db-wal
@@ -10,5 +11,5 @@ dist/
*.sqlite-journal *.sqlite-journal
resolvedTemplate.json resolvedTemplate.json
mnemonic-* mnemonic-*
.xo-cli-wallet
inv-*.json inv-*.json
.xo-cli-wallet

File diff suppressed because one or more lines are too long

2590
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -3,17 +3,27 @@
"version": "1.0.0", "version": "1.0.0",
"main": "dist/index.js", "main": "dist/index.js",
"type": "module", "type": "module",
"bin": {
"xo-cli": "./dist/cli/index.js",
"xo-tui": "./dist/index.js",
"xo-complete": "./dist/cli/autocomplete/complete.js"
},
"scripts": { "scripts": {
"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", "build": "tsc && npm run build:copy-scripts",
"build:copy-scripts": "cp -r src/cli/autocomplete/scripts dist/cli/autocomplete/",
"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", "test": "vitest --run --passWithNoTests",
"test:watch": "vitest", "test:watch": "vitest",
"test:coverage": "vitest run --coverage", "test:coverage": "vitest run --coverage --passWithNoTests",
"nuke": "tsx scripts/rm-dbs.ts", "nuke": "tsx scripts/rm-dbs.ts",
"nuke:dry": "tsx scripts/rm-dbs.ts --dry", "nuke:dry": "tsx scripts/rm-dbs.ts --dry",
"format": "prettier --write \"**/*.{js,ts,md,json}\" --ignore-path .gitignore", "format": "prettier --write \"**/*.{js,ts,md,json}\" --ignore-path .gitignore",
"format:check": "prettier --check ." "format:check": "prettier --check .",
"autocomplete:install": "node dist/cli/index.js completions bash --install",
"autocomplete:install:bash": "node dist/cli/index.js completions bash --install",
"autocomplete:install:zsh": "node dist/cli/index.js completions zsh --install",
"autocomplete:install:fish": "node dist/cli/index.js completions fish --install"
}, },
"keywords": [ "keywords": [
"crypto", "crypto",
@@ -27,10 +37,12 @@
"dependencies": { "dependencies": {
"@bitauth/libauth": "^3.0.0", "@bitauth/libauth": "^3.0.0",
"@electrum-cash/protocol": "^2.3.1", "@electrum-cash/protocol": "^2.3.1",
"@generalprotocols/oracle-client": "^0.0.1-development.11945476152",
"@xo-cash/crypto": "^0.0.1",
"@xo-cash/engine": "file:../engine", "@xo-cash/engine": "file:../engine",
"@xo-cash/state": "file:../state", "@xo-cash/state": "file:../state",
"@xo-cash/templates": "file:../templates", "@xo-cash/templates": "^0.0.1",
"@xo-cash/types": "file:../types", "@xo-cash/types": "^0.0.1",
"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",
@@ -44,6 +56,7 @@
"@types/node": "^25.0.10", "@types/node": "^25.0.10",
"@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",
"tsx": "^4.21.0", "tsx": "^4.21.0",
"typescript": "^5.9.3", "typescript": "^5.9.3",
"vitest": "^4.1.2" "vitest": "^4.1.2"

107
readme.md Normal file
View File

@@ -0,0 +1,107 @@
# XO-CLI & XO-TUI
## Installation
### Full Installation
```bash
# Create a new directory since we are going to be pulling in engine too
mkdir xo-terminal && cd xo-terminal
# ----- Start Engine Setup -----
# Clone the Engine Repo (Note, this uses harvey's fork of the engine repo to access the cli-test branch)
git clone git@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 -----
# Clone the State Repo
git clone git@gitlab.com:Harvmaster/state.git
# Move into the state directory
cd state
git checkout in-memory-adapter
# Install the dependencies
npm ci
# Build the state
npm run build
# ----- End State Setup -----
# Move back to the top level directory
cd ..
# ----- Start CLI Setup -----
# Clone the CLI Repo
git clone git@git.harvmaster.com:Harvmaster/xo-cli.git
# Move into the cli directory
cd xo-cli
# Install the dependencies
npm ci
# Build the cli
npm run build
# ----- End CLI Setup -----
```
### Run TUI in dev mode
```bash
npm run dev
```
### Install globally
```bash
# (From the xo-cli directory)
npm install -g .
```
### Install autocomplete completions (From the xo-cli directory)
#### Install for bash
```bash
npm run autocomplete:install:bash
```
#### Install for zsh
```bash
npm run autocomplete:install:zsh
```
#### Install for fish
```bash
npm run autocomplete:install:fish
```
### Run the CLI
```bash
# If globally installed (Not really usable if not globally installed)
xo-cli
```
### Run the TUI
```bash
# If globally installed
xo-tui
# If not globally installed
npm run dev
```

104
scripts/template-to-json.ts Normal file
View File

@@ -0,0 +1,104 @@
import fs from "node:fs/promises";
import path from "node:path";
import { pathToFileURL } from "node:url";
/**
* This just convers the <template>.ts file to a <template>.json file.
* Im fairly sure there is a util in the engine or engine-packages for this, but I decided to just keep it as simple as possible because I didn't feel like digging around for it.
*
* Usage:
* tsx scripts/template-to-json.ts ../templates/source/p2pkh.ts ./p2pkh.json p2pkhTemplate
*/
/**
* Prints usage to stderr and exits with a non-zero code.
*/
function printUsageAndExit(): never {
console.error(
[
"Usage: tsx scripts/template-to-json.ts <input.ts> <output.json> [exportName]",
"",
"Loads a TypeScript module, picks one exported value, and writes JSON.stringify to the output path.",
"If exportName is omitted: uses default export, or the only non-function export if there is exactly one.",
"",
"Example:",
" tsx scripts/template-to-json.ts ../templates/source/p2pkh.ts ./p2pkh.json p2pkhTemplate",
].join("\n"),
);
process.exit(1);
}
/**
* Collects runtime export keys whose values are not functions (typical for data/template objects).
*/
function listDataExportKeys(mod: Record<string, unknown>): string[] {
return Object.keys(mod).filter((key) => {
if (key === "__esModule") {
return false;
}
const value = mod[key];
return typeof value !== "function";
});
}
/**
* Resolves which export to serialize: explicit name, default, or a single unambiguous data export.
*/
function resolveExportedValue(
mod: Record<string, unknown>,
exportName: string | undefined,
): unknown {
if (exportName !== undefined) {
if (!(exportName in mod)) {
const keys = listDataExportKeys(mod);
console.error(
`Export "${exportName}" not found. Available data exports: ${keys.length ? keys.join(", ") : "(none)"}`,
);
process.exit(1);
}
return mod[exportName];
}
if ("default" in mod && mod.default !== undefined) {
return mod.default;
}
const keys = listDataExportKeys(mod);
if (keys.length === 1) {
return mod[keys[0]!];
}
if (keys.length === 0) {
console.error(
"No suitable exports found (need default or a non-function export).",
);
} else {
console.error(
`Multiple data exports found; pass exportName. Candidates: ${keys.join(", ")}`,
);
}
process.exit(1);
}
async function main(): Promise<void> {
const args = process.argv.slice(2);
if (args.length < 2) {
printUsageAndExit();
}
const [inputRel, outputRel, exportName] = args;
const inputPath = path.resolve(process.cwd(), inputRel!);
const outputPath = path.resolve(process.cwd(), outputRel!);
/** Dynamic import needs a file URL so Windows paths and ESM resolution behave. */
const fileUrl = pathToFileURL(inputPath).href;
const mod = (await import(fileUrl)) as Record<string, unknown>;
const value = resolveExportedValue(mod, exportName);
const json = `${JSON.stringify(value, null, 2)}\n`;
await fs.mkdir(path.dirname(outputPath), { recursive: true });
await fs.writeFile(outputPath, json, "utf8");
console.log(`Wrote ${outputPath}`);
}
await main();

View File

@@ -3,9 +3,11 @@
* Simplified to render TUI immediately and let it handle AppService creation. * Simplified to render TUI immediately and let it handle AppService creation.
*/ */
import { join } from "node:path";
import React from "react"; import React from "react";
import { render, type Instance } from "ink"; import { render, type Instance } from "ink";
import { App as AppComponent } from "./tui/App.js"; import { App as AppComponent } from "./tui/App.js";
import { getDataDir } from "./utils/paths.js";
/** /**
* Configuration options for the CLI application. * Configuration options for the CLI application.
@@ -46,13 +48,14 @@ export class App {
* @returns Running App instance * @returns Running App instance
*/ */
static async create(config: Partial<AppConfig> = {}): Promise<App> { static async create(config: Partial<AppConfig> = {}): Promise<App> {
const dataDir = getDataDir();
// Set default configuration // Set default configuration
const fullConfig: AppConfig = { const fullConfig: AppConfig = {
syncServerUrl: config.syncServerUrl ?? "http://localhost:3000", syncServerUrl: config.syncServerUrl ?? "http://localhost:3000",
databasePath: config.databasePath ?? "./", databasePath: config.databasePath ?? dataDir,
databaseFilename: config.databaseFilename ?? "xo-wallet.db", databaseFilename: config.databaseFilename ?? "xo-wallet.db",
invitationStoragePath: invitationStoragePath:
config.invitationStoragePath ?? "./xo-invitations.db", config.invitationStoragePath ?? join(dataDir, "xo-invitations.db"),
}; };
console.log("Full config:", fullConfig); console.log("Full config:", fullConfig);

View File

@@ -2,112 +2,137 @@
Command-line interface for the XO Engine. Create wallets, manage templates, build invitations, sign transactions, and broadcast them to the Bitcoin Cash network. Command-line interface for the XO Engine. Create wallets, manage templates, build invitations, sign transactions, and broadcast them to the Bitcoin Cash network.
## Getting Started There are two global commands after install:
- **`xo-cli`** — non-interactive commands (this document).
- **`xo-tui`** — interactive terminal wallet UI (Ink/React).
## Global config directory
Wallet state lives under **`~/.config/xo-cli/`** (XDG-style), so you can run commands from any directory:
| Path | Purpose |
| ----------------------------- | ----------------------------------------------------------------------- |
| `~/.config/xo-cli/mnemonics/` | Mnemonic files (`mnemonic-*`) |
| `~/.config/xo-cli/data/` | Engine DB (`xo-wallet.db`) and invitation storage (`xo-invitations.db`) |
| `~/.config/xo-cli/.wallet` | Last-used mnemonic reference (so `-m` can be omitted) |
**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`).
## Install (global, from this repo)
`@xo-cash/*` dependencies use `file:` paths, so publish to npm is a separate step. For local development:
```bash ```bash
# Run any command via tsx from the cli/ directory cd engine/cli
npx tsx src/cli/index.ts <command> [options] npm install
npm run build
npm link
# xo-cli and xo-tui are now on your PATH
``` ```
Development without linking:
```bash
cd engine/cli
npx tsx src/cli/index.ts <command> [options]
npx tsx src/index.ts # TUI
```
### Environment variables (TUI / `xo-tui`)
| Variable | Default |
| ------------------------- | ----------------------------------------- |
| `SYNC_SERVER_URL` | `http://localhost:3000` |
| `DB_PATH` | `~/.config/xo-cli/data` |
| `DB_FILENAME` | `xo-wallet.db` |
| `INVITATION_STORAGE_PATH` | `~/.config/xo-cli/data/xo-invitations.db` |
## Getting Started
### Wallet Setup ### Wallet Setup
Before using most commands you need a mnemonic wallet file.
```bash ```bash
# Generate a new mnemonic and save it to a file # Generate a new mnemonic (saved under ~/.config/xo-cli/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 page pencil stock planet limb cluster assault speak off joke private pioneer
# List available mnemonic files in the current directory # List mnemonic basenames (use with -m)
xo-cli mnemonic list xo-cli mnemonic list
``` ```
Mnemonic files are stored in the working directory with the prefix `mnemonic-`. **Options:** `-o <filename>` — basename only; file is written under the global mnemonics directory.
### Wallet Persistence ### Wallet Persistence
The first time you pass `-m <file>`, the choice is saved to `.xo-cli-wallet`. Subsequent commands will use that wallet automatically so you can omit `-m`. The first time you pass `-m <name>`, that reference is saved to `~/.config/xo-cli/.wallet`. Later runs can omit `-m`.
Mnemonic resolution order:
1. Absolute path, if the file exists
2. Path relative to the current working directory
3. `~/.config/xo-cli/mnemonics/<basename>`
```bash ```bash
# First run — pass the wallet explicitly
xo-cli resource list -m mnemonic-nuclear xo-cli resource list -m mnemonic-nuclear
# All future runs remember the wallet
xo-cli resource list xo-cli resource list
``` ```
To switch wallets, pass `-m` again with a different file. ## Global Options (`xo-cli`)
## Global Options
| Flag | Description | | Flag | Description |
|---|---| | ------------------------------ | --------------------------------------------------- |
| `-m`, `--mnemonic-file <file>` | Mnemonic file to use (persisted after first use) | | `-m`, `--mnemonic-file <file>` | Mnemonic file (basename, cwd-relative, or absolute) |
| `-v`, `--verbose` | Show detailed debug output | | `-o`, `--output <filename>` | Output filename (used by `mnemonic create`/`import`) |
| `-h`, `--help` | Show help message | | `-v`, `--verbose` | Verbose output |
| `-h`, `--help` | Help |
Advanced: you can pass `--database-path`, `--database-filename`, and `--invitation-storage-path` to override the defaults under `~/.config/xo-cli/data/` (see `src/cli/index.ts`).
## Commands ## Commands
### `mnemonic` — Manage Wallet Files ### `mnemonic` — Manage Wallet Files
```bash ```bash
xo-cli mnemonic create # Generate a new mnemonic xo-cli mnemonic create
xo-cli mnemonic import <seed words...> # Import a mnemonic from seed words xo-cli mnemonic import <seed words...>
xo-cli mnemonic list # List mnemonic files in cwd xo-cli mnemonic list
xo-cli mnemonic expose <mnemonic-file>
``` ```
**Options:**
- `-o <filename>` — Custom output filename for the mnemonic file.
### `template` — Manage Templates ### `template` — Manage Templates
```bash ```bash
xo-cli template import <template-file> # Import a template xo-cli template import <template-file>
xo-cli template list # List imported templates xo-cli template list
xo-cli template list <category> <template-id> # List items in a category xo-cli template list <category> <template-id>
xo-cli template inspect <category> <template-id> <field> # Inspect a specific field xo-cli template inspect <category> <template-id> <field>
xo-cli template set-default <template-file> <output-id> <role> # Set default locking params xo-cli template set-default <template-file> <output-id> <role>
``` ```
**Categories:** `action`, `transaction`, `output`, `lockingscript`, `variable` **Categories:** `action`, `transaction`, `output`, `lockingscript`, `variable`
**Example — discover what a template offers:** Template paths are resolved relative to the **current working directory**.
```bash
xo-cli template import p2pkh-template.json
xo-cli template list
xo-cli template list action <template-identifier>
```
### `resource` — Manage UTXOs ### `resource` — Manage UTXOs
```bash ```bash
xo-cli resource list # List unreserved (spendable) UTXOs xo-cli resource list
xo-cli resource list reserved # List UTXOs reserved by invitations xo-cli resource list reserved
xo-cli resource list all # List all UTXOs (reserved + unreserved) xo-cli resource list all
xo-cli resource unreserve <txhash:vout> # Unreserve a specific UTXO xo-cli resource unreserve <txhash:vout>
xo-cli resource unreserve-all # Unreserve all reserved UTXOs xo-cli resource unreserve-all
``` ```
Each UTXO is displayed as `<txhash>:<vout> <sats> <outputId> (height <n>)`.
### `receive` — Generate a Receiving Address ### `receive` — Generate a Receiving Address
```bash ```bash
xo-cli receive <template-file> <output-identifier> [role-identifier] xo-cli receive <template-file> <output-identifier> [role-identifier]
``` ```
Generates a single-use BCH cash address from a template. If the role is omitted, the first available role is used. ### `invitation` — Build, Sign & Broadcast
```bash
xo-cli receive p2pkh-template.json receiveOutput receiver
```
### `invitation` — Build, Sign & Broadcast Transactions
This is the core command for sending funds. An invitation goes through these stages: **create****sign****broadcast**.
```bash ```bash
xo-cli invitation create <template-file> <action-id> [options] xo-cli invitation create <template-file> <action-id> [options]
@@ -116,45 +141,28 @@ xo-cli invitation sign <invitation-id>
xo-cli invitation broadcast <invitation-id> xo-cli invitation broadcast <invitation-id>
xo-cli invitation requirements <invitation-id> xo-cli invitation requirements <invitation-id>
xo-cli invitation import <invitation-file> xo-cli invitation import <invitation-file>
xo-cli invitation inspect <invitation-file>
xo-cli invitation list xo-cli invitation list
``` ```
#### Create / Append Options **Create / append options:**
| Flag | Description | | Flag | Description |
|---|---| | --------------------------- | ---------------------------------------- |
| `-var-<name> <value>` | Set a template variable (kebab-case → camelCase) | | `-var-<name> <value>` | Template variable |
| `--add-input <txhash:vout>` | Add UTXO input(s), comma-separated | | `--add-input <txhash:vout>` | Inputs (comma-separated) |
| `--add-output <id>` | Override output(s) — **omit to auto-discover from template** | | `--add-output <id>` | Override outputs (omit to auto-discover) |
| `--auto-inputs` | Automatically select UTXOs to cover the required amount | | `--auto-inputs` | Auto-select UTXOs |
| `-role <role>` | Role identifier for variable scoping and bytecode generation | | `-role <role>` | Role for variables / bytecode |
| `--sign` | Auto-sign after all requirements are satisfied | | `--sign` | Auto-sign when complete |
| `--broadcast` | Auto-broadcast after signing (implies `--sign`) | | `--broadcast` | Auto-broadcast (implies `--sign`) |
When inputs are provided, a **change output is automatically added** if the input total exceeds the required amount + fee (500 sats). Change below the dust threshold (546 sats) is donated as fee. Invitation JSON files from `create` / `append` are written to the **current working directory**.
Outputs are **auto-discovered from the template** when `--add-output` is omitted, so you typically don't need to specify them. ### One-command send
#### Variable Naming
Variable flags use kebab-case which maps to the template's camelCase identifiers:
```
-var-transferred-satoshis 4678 → transferredSatoshis = "4678"
-var-recipient-lockingscript <addr> → recipientLockingscript = "<addr>"
```
## Full Send Flow
### One-Command Send (Recommended)
With `--broadcast`, the entire create → sign → broadcast flow happens in a single invocation:
```bash ```bash
# 1. List UTXOs to pick an input
xo-cli resource list xo-cli resource list
# 2. Send in one shot
xo-cli invitation create p2pkh-template.json sendSatoshis \ xo-cli invitation create p2pkh-template.json sendSatoshis \
-var-transferred-satoshis 4678 \ -var-transferred-satoshis 4678 \
-var-recipient-lockingscript "bitcoincash:qz..." \ -var-recipient-lockingscript "bitcoincash:qz..." \
@@ -163,72 +171,25 @@ xo-cli invitation create p2pkh-template.json sendSatoshis \
--broadcast --broadcast
``` ```
Output: ### `xo-tui`
```
Invitation created: abc123.json (abc123)
Invitation signed: abc123
Transaction broadcast: <txid>
```
### Step-by-Step Send
For multi-party transactions or debugging, you can run each step separately:
```bash ```bash
# 1. Import template (only needed once) xo-tui
xo-cli template import p2pkh-template.json
# 2. Check available UTXOs
xo-cli resource list
# 3. Create the invitation with variables and inputs
xo-cli invitation create p2pkh-template.json sendSatoshis \
-var-transferred-satoshis 4678 \
-var-recipient-lockingscript "bitcoincash:qz..." \
--add-input <txhash>:<vout> \
-role sender
# 4. Sign
xo-cli invitation sign <invitation-id>
# 5. Broadcast
xo-cli invitation broadcast <invitation-id>
``` ```
### Using Auto-Inputs Launches the full-screen wallet UI; uses the same global data directory unless overridden by env vars.
Instead of manually selecting UTXOs, let the CLI pick them:
```bash
xo-cli invitation create p2pkh-template.json sendSatoshis \
-var-transferred-satoshis 4678 \
-var-recipient-lockingscript "bitcoincash:qz..." \
--auto-inputs \
-role sender \
--broadcast
```
## Shell Completions ## Shell Completions
Tab-completion is available for bash, zsh, and fish:
```bash ```bash
# Bash
eval "$(xo-cli completions bash)" eval "$(xo-cli completions bash)"
# Zsh
eval "$(xo-cli completions zsh)" eval "$(xo-cli completions zsh)"
# Fish
xo-cli completions fish | source xo-cli completions fish | source
``` ```
## File Conventions ## File Conventions
| File/Pattern | Purpose | | Location | Purpose |
|---|---| | ------------------- | ------------------------------------------ |
| `mnemonic-*` | Wallet mnemonic files | | `~/.config/xo-cli/` | Global wallet state |
| `.xo-cli-wallet` | Persisted wallet selection | | `./` (cwd) | Templates, invitation JSON, explicit paths |
| `*.json` | Invitation files (saved by create/append) |
| `*.db` | Engine database files |

View File

@@ -19,23 +19,27 @@ import { z } from "zod";
* @param args - The CLI args to convert. * @param args - The CLI args to convert.
* @returns The key-value object. * @returns The key-value object.
*/ */
export function convertArgsToObject(args: string[]): { args: string[], options: Record<string, string> } { export function convertArgsToObject(args: string[]): {
args: string[];
options: Record<string, string>;
} {
// Map of single-character short flags to their canonical long names // Map of single-character short flags to their canonical long names
const shortToFull: Record<string, string> = { const shortToFull: Record<string, string> = {
'm': 'mnemonicFile', m: "mnemonicFile",
'o': 'output', o: "output",
'v': 'verbose', v: "verbose",
'h': 'help', h: "help",
}; };
// Flags that are always boolean and never consume the next argument as a value. // Flags that are always boolean and never consume the next argument as a value.
// Uses the canonical (expanded) names so the check works after short-form resolution. // Uses the canonical (expanded) names so the check works after short-form resolution.
const booleanFlags = new Set<string>([ const booleanFlags = new Set<string>([
'verbose', "verbose",
'help', "help",
'autoInputs', "autoInputs",
'sign', "sign",
'broadcast', "broadcast",
"install",
]); ]);
const positionalArgs: string[] = []; const positionalArgs: string[] = [];
@@ -54,7 +58,9 @@ export function convertArgsToObject(args: string[]): { args: string[], options:
// - Remove the leading `-`s // - Remove the leading `-`s
// - Convert kebab-case to camelCase // - Convert kebab-case to camelCase
// - Expand known short forms to their full names // - Expand known short forms to their full names
let key = arg.replace(/^-+/, "").replace(/-([a-z])/g, (_, letter) => letter.toUpperCase()); let key = arg
.replace(/^-+/, "")
.replace(/-([a-z])/g, (_, letter) => letter.toUpperCase());
key = shortToFull[key] ?? key; key = shortToFull[key] ?? key;
// Known boolean flags never take a value // Known boolean flags never take a value

View File

@@ -0,0 +1,393 @@
#!/usr/bin/env node
/**
* Lightweight shell completion helper for xo-cli.
*
* This script reads from local SQLite only - no network connections.
* It's designed to be fast enough for interactive tab completion.
*
* Usage: xo-complete <context> [args...]
*
* Contexts:
* mnemonics - List mnemonic file names
* templates - List template names/IDs
* actions <template> - List actions for a template
* fields <category> <template> - List fields for a template category
* invitations - List invitation IDs
* resources - List UTXO outpoints (txhash:vout)
* subcommands <command> - List subcommands for a top-level command
*
* Output: One completion suggestion per line, suitable for shell completion.
*
* Exit codes:
* 0 - Success (may output zero or more completions)
* 1 - Error (no output, fails silently for shell integration)
*/
import { existsSync, readdirSync, readFileSync } from "node:fs";
import { join } from "node:path";
import { createHash } from "node:crypto";
import {
getDataDir,
getMnemonicsDir,
getWalletConfigPath,
} from "../../utils/paths.js";
import { loadMnemonic } from "../mnemonic.js";
import { Storage } from "../../services/storage.js";
import { COMMAND_TREE } from "./completions.js";
// Lazy-loaded modules (only loaded when needed for dynamic completions)
let _offlineEngineModule: typeof import("./offline-engine.js") | null = null;
let _engineModule: typeof import("@xo-cash/engine") | null = null;
async function getOfflineEngineModule() {
if (!_offlineEngineModule) {
_offlineEngineModule = await import("./offline-engine.js");
}
return _offlineEngineModule;
}
async function getEngineModule() {
if (!_engineModule) {
_engineModule = await import("@xo-cash/engine");
}
return _engineModule;
}
/**
* Outputs completions to stdout, one per line.
* Optionally filters by a prefix (for partial word completion).
*/
function outputCompletions(items: readonly string[], prefix?: string): void {
const filtered = prefix
? items.filter((item) =>
item.toLowerCase().startsWith(prefix.toLowerCase()),
)
: items;
for (const item of filtered) {
console.log(item);
}
}
/**
* Lists mnemonic file names from the mnemonics directory.
* Fast path: no engine needed, just filesystem.
*/
function listMnemonics(prefix?: string): void {
try {
const mnemonicsDir = getMnemonicsDir();
const files = readdirSync(mnemonicsDir).filter((f) =>
f.startsWith("mnemonic-"),
);
outputCompletions(files, prefix);
} catch {
// Silently fail - no completions available
}
}
/**
* Lists subcommands for a given top-level command.
* Uses the static COMMAND_TREE.
*/
function listSubcommands(command: string, prefix?: string): void {
if (command in COMMAND_TREE) {
const subcommands = COMMAND_TREE[command as keyof typeof COMMAND_TREE];
outputCompletions(subcommands, prefix);
}
}
/**
* Gets the current wallet's mnemonic seed from the saved config.
* Returns null if no wallet is configured.
*/
function getCurrentMnemonic(): string | null {
try {
const walletConfigPath = getWalletConfigPath();
if (!existsSync(walletConfigPath)) {
return null;
}
const mnemonicFile = readFileSync(walletConfigPath, "utf8").trim();
if (!mnemonicFile) {
return null;
}
const mnemonicsDir = getMnemonicsDir();
return loadMnemonic(mnemonicsDir, mnemonicFile);
} catch {
return null;
}
}
/**
* Lists templates from the engine.
*/
async function listTemplates(prefix?: string): Promise<void> {
const mnemonic = getCurrentMnemonic();
if (!mnemonic) return;
const { tryCreateOfflineEngine } = await getOfflineEngineModule();
const { generateTemplateIdentifier } = await getEngineModule();
const engine = await tryCreateOfflineEngine(mnemonic, {
databasePath: getDataDir(),
databaseFilename: "xo-wallet.db",
});
if (!engine) return;
try {
const templates = await engine.listImportedTemplates();
const completions: string[] = [];
for (const template of templates) {
// Add template name (for user-friendly completion)
if (template.name) {
completions.push(template.name);
}
// Also add template identifier (for precise matching)
const id = generateTemplateIdentifier(template);
if (id && !completions.includes(id)) {
completions.push(id);
}
}
outputCompletions(completions, prefix);
} finally {
await engine.stop();
}
}
/**
* Resolves a template by name or ID.
*/
async function resolveTemplate(
engine: Awaited<
ReturnType<
Awaited<
ReturnType<typeof getOfflineEngineModule>
>["tryCreateOfflineEngine"]
>
>,
templateQuery: string,
) {
if (!engine) return null;
const { generateTemplateIdentifier } = await getEngineModule();
const templates = await engine.listImportedTemplates();
// Try exact match on name or ID
let template = templates.find(
(t) =>
t.name === templateQuery ||
generateTemplateIdentifier(t) === templateQuery,
);
// Try partial match on name
if (!template) {
template = templates.find((t) =>
t.name?.toLowerCase().includes(templateQuery.toLowerCase()),
);
}
return template ?? null;
}
/**
* Lists actions for a specific template.
*/
async function listActions(
templateQuery: string,
prefix?: string,
): Promise<void> {
const mnemonic = getCurrentMnemonic();
if (!mnemonic) return;
const { tryCreateOfflineEngine } = await getOfflineEngineModule();
const engine = await tryCreateOfflineEngine(mnemonic, {
databasePath: getDataDir(),
databaseFilename: "xo-wallet.db",
});
if (!engine) return;
try {
const template = await resolveTemplate(engine, templateQuery);
if (template && template.actions) {
const actions = Object.keys(template.actions);
outputCompletions(actions, prefix);
}
} finally {
await engine.stop();
}
}
/**
* Lists fields (actions, transactions, outputs, etc.) for a specific template category.
* Used for completing the 3rd argument of `template inspect <category> <template> <field>`.
*/
async function listFields(
category: string,
templateQuery: string,
prefix?: string,
): Promise<void> {
const mnemonic = getCurrentMnemonic();
if (!mnemonic) return;
const { tryCreateOfflineEngine } = await getOfflineEngineModule();
const engine = await tryCreateOfflineEngine(mnemonic, {
databasePath: getDataDir(),
databaseFilename: "xo-wallet.db",
});
if (!engine) return;
try {
const template = await resolveTemplate(engine, templateQuery);
if (!template) return;
let fields: string[] = [];
switch (category) {
case "action":
fields = Object.keys(template.actions || {});
break;
case "transaction":
fields = Object.keys(template.transactions || {});
break;
case "output":
fields = Object.keys(template.outputs || {});
break;
case "lockingscript":
fields = Object.keys(template.lockingScripts || {});
break;
case "variable":
fields = Object.keys(template.variables || {});
break;
}
outputCompletions(fields, prefix);
} finally {
await engine.stop();
}
}
/**
* Lists invitation IDs from the invitation storage.
*/
async function listInvitations(prefix?: string): Promise<void> {
const mnemonic = getCurrentMnemonic();
if (!mnemonic) return;
try {
// Compute seed hash to find the right storage namespace
const seedHash = createHash("sha256").update(mnemonic).digest("hex");
const invitationsDbPath = join(getDataDir(), "xo-invitations.db");
if (!existsSync(invitationsDbPath)) {
return;
}
const storage = await Storage.create(invitationsDbPath);
const walletStorage = storage.child(seedHash.slice(0, 8));
const invitationsStorage = walletStorage.child("invitations");
const invitations = await invitationsStorage.all();
const ids = invitations.map((inv) => inv.key);
outputCompletions(ids, prefix);
} catch {
// Silently fail - no completions available
}
}
/**
* Lists UTXO outpoints (resources) from the engine.
*/
async function listResources(prefix?: string): Promise<void> {
const mnemonic = getCurrentMnemonic();
if (!mnemonic) return;
const { tryCreateOfflineEngine } = await getOfflineEngineModule();
const engine = await tryCreateOfflineEngine(mnemonic, {
databasePath: getDataDir(),
databaseFilename: "xo-wallet.db",
});
if (!engine) return;
try {
const utxos = await engine.listUnspentOutputsData();
const outpoints = utxos.map(
(u) => `${u.outpointTransactionHash}:${u.outpointIndex}`,
);
outputCompletions(outpoints, prefix);
} finally {
await engine.stop();
}
}
/**
* Main entry point.
*/
async function main(): Promise<void> {
const context = process.argv[2];
const arg1 = process.argv[3];
const arg2 = process.argv[4];
if (!context) {
// No context provided - output nothing
process.exit(0);
}
switch (context) {
case "mnemonics":
listMnemonics(arg1);
break;
case "subcommands":
if (arg1) {
listSubcommands(arg1, arg2);
}
break;
case "templates":
await listTemplates(arg1);
break;
case "actions":
if (arg1) {
await listActions(arg1, arg2);
}
break;
case "fields":
// fields <category> <template> [prefix]
if (arg1 && arg2) {
await listFields(arg1, arg2, process.argv[5]);
}
break;
case "invitations":
await listInvitations(arg1);
break;
case "resources":
await listResources(arg1);
break;
default:
// Unknown context - output nothing
break;
}
}
main().catch(() => {
// Silently fail for shell integration
process.exit(1);
});

View File

@@ -0,0 +1,307 @@
/**
* Shell completion script generation.
*
* Loads shell-native template files and replaces placeholders with
* dynamic values. This approach keeps the shell scripts readable
* and auditable in their native format.
*
* The generated scripts use the `xo-complete` helper binary for dynamic
* completions (invitation IDs, template names, resources, etc.).
*
* Usage:
* eval "$(xo-cli completions bash)"
* eval "$(xo-cli completions zsh)"
* xo-cli completions fish | source
*
* Install to shell config:
* xo-cli completions bash --install
* xo-cli completions zsh --install
* xo-cli completions fish --install
*/
import {
existsSync,
readFileSync,
appendFileSync,
writeFileSync,
} from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { homedir } from "node:os";
/**
* Single source of truth for the CLI command tree.
* Each top-level key is a command, and its value is an array of sub-commands.
*
* IMPORTANT: Keep this in sync with actual switch statements in command handlers:
* - mnemonic.ts: create, import, list, expose
* - template.ts: import, list, inspect, set-default
* - invitation.ts: create, append, sign, broadcast, requirements, import, inspect, list
* - resource.ts: list, unreserve, unreserve-all
*/
/** Subcommands for the mnemonic command */
const MNEMONIC_SUBS = ["create", "import", "list", "expose"];
/** Subcommands for the template command */
const TEMPLATE_SUBS = ["import", "list", "inspect", "set-default"];
/** Subcommands for the invitation command */
const INVITATION_SUBS = [
"create",
"append",
"sign",
"broadcast",
"requirements",
"import",
"inspect",
"list",
];
/** Subcommands for the resource command */
const RESOURCE_SUBS = ["list", "unreserve", "unreserve-all"];
/** Subcommands for the completions command */
const COMPLETIONS_SUBS = ["bash", "zsh", "fish"];
export const COMMAND_TREE = {
mnemonic: MNEMONIC_SUBS,
template: TEMPLATE_SUBS,
invitation: INVITATION_SUBS,
receive: [],
resource: RESOURCE_SUBS,
help: [],
completions: COMPLETIONS_SUBS,
} as const;
/** Global option flags available on every command. */
const GLOBAL_OPTIONS = [
"-h",
"--help",
"-v",
"--verbose",
"-m",
"--mnemonic-file",
"-o",
"--output",
];
/**
* Gets the path to the scripts directory containing shell templates.
*/
function getScriptsDir(): string {
const currentFile = fileURLToPath(import.meta.url);
return join(dirname(currentFile), "scripts");
}
/**
* Loads a shell template file and replaces placeholders.
* @param templateName - The template file name (e.g., "bash.sh")
* @param binName - The CLI binary name
*/
function loadAndProcessTemplate(templateName: string, binName: string): string {
const scriptsDir = getScriptsDir();
const templatePath = join(scriptsDir, templateName);
if (!existsSync(templatePath)) {
throw new Error(`Template file not found: ${templatePath}`);
}
let content = readFileSync(templatePath, "utf8");
const funcName = binName.replace(/-/g, "_");
const commands = Object.keys(COMMAND_TREE).join(" ");
const options = GLOBAL_OPTIONS.join(" ");
// Replace all placeholders
content = content.replace(/\{\{BIN_NAME\}\}/g, binName);
content = content.replace(/\{\{FUNC_NAME\}\}/g, funcName);
content = content.replace(/\{\{COMMANDS\}\}/g, commands);
content = content.replace(/\{\{OPTIONS\}\}/g, options);
content = content.replace(/\{\{MNEMONIC_SUBS\}\}/g, MNEMONIC_SUBS.join(" "));
content = content.replace(/\{\{TEMPLATE_SUBS\}\}/g, TEMPLATE_SUBS.join(" "));
content = content.replace(
/\{\{INVITATION_SUBS\}\}/g,
INVITATION_SUBS.join(" "),
);
content = content.replace(/\{\{RESOURCE_SUBS\}\}/g, RESOURCE_SUBS.join(" "));
// Fish-specific placeholders
if (templateName.endsWith(".fish")) {
content = content.replace(
/\{\{TOP_LEVEL_COMMANDS\}\}/g,
generateFishTopLevelCommands(binName),
);
content = content.replace(
/\{\{STATIC_SUBCOMMANDS\}\}/g,
generateFishStaticSubcommands(binName),
);
}
return content;
}
/**
* Generates fish top-level command completions.
*/
function generateFishTopLevelCommands(binName: string): string {
const lines: string[] = [];
for (const cmd of Object.keys(COMMAND_TREE)) {
lines.push(
`complete -c ${binName} -n "__fish_use_subcommand" -a "${cmd}" -d "${cmd} command"`,
);
}
return lines.join("\n");
}
/**
* Generates fish static subcommand completions.
*/
function generateFishStaticSubcommands(binName: string): string {
const lines: string[] = [];
for (const [cmd, subs] of Object.entries(COMMAND_TREE)) {
for (const sub of subs) {
lines.push(
`complete -c ${binName} -n "__fish_seen_subcommand_from ${cmd}; and not __fish_seen_subcommand_from ${subs.join(" ")}" -a "${sub}" -d "${cmd} ${sub}"`,
);
}
}
return lines.join("\n");
}
/**
* Generates a bash completion script.
* @param binName - The name of the CLI binary.
*/
export function generateBashCompletions(binName: string): string {
return loadAndProcessTemplate("bash.sh", binName);
}
/**
* Generates a zsh completion script.
* @param binName - The name of the CLI binary.
*/
export function generateZshCompletions(binName: string): string {
return loadAndProcessTemplate("zsh.zsh", binName);
}
/**
* Generates a fish completion script.
* @param binName - The name of the CLI binary.
*/
export function generateFishCompletions(binName: string): string {
return loadAndProcessTemplate("fish.fish", binName);
}
type ShellType = "bash" | "zsh" | "fish";
const generators: Record<ShellType, (binName: string) => string> = {
bash: generateBashCompletions,
zsh: generateZshCompletions,
fish: generateFishCompletions,
};
/**
* Shell config file paths and eval commands for each shell type.
*/
const shellConfigs: Record<
ShellType,
{ configFile: string; evalCommand: (binName: string) => string }
> = {
bash: {
configFile: join(homedir(), ".bashrc"),
evalCommand: (binName) => `eval "$(${binName} completions bash)"`,
},
zsh: {
configFile: join(homedir(), ".zshrc"),
evalCommand: (binName) => `eval "$(${binName} completions zsh)"`,
},
fish: {
configFile: join(homedir(), ".config", "fish", "config.fish"),
evalCommand: (binName) => `${binName} completions fish | source`,
},
};
/**
* Installs completions to the user's shell config file.
* Adds the eval command if not already present.
* @param shell - The shell type
* @param binName - The CLI binary name
* @returns true if installed, false if already present
*/
function installCompletions(shell: ShellType, binName: string): boolean {
const config = shellConfigs[shell];
const evalCommand = config.evalCommand(binName);
// Check if config file exists and already has the completion line
let existingContent = "";
if (existsSync(config.configFile)) {
existingContent = readFileSync(config.configFile, "utf8");
if (existingContent.includes(evalCommand)) {
return false; // Already installed
}
}
// Append the completion line
const newLine =
existingContent.endsWith("\n") || existingContent === "" ? "" : "\n";
const completionBlock = `${newLine}\n# ${binName} shell completions\n${evalCommand}\n`;
appendFileSync(config.configFile, completionBlock);
return true;
}
/**
* Handles the `completions` command.
* Prints the generated completion script for the given shell to stdout,
* or installs it to the shell config file with --install.
* @param args - Positional args after "completions", e.g. ["bash"].
* @param options - Parsed command options (may include "install").
* @param binName - The CLI binary name to use in the completion script.
*/
export function handleCompletionsCommand(
args: string[],
options: Record<string, string> = {},
binName: string = "xo-cli",
): void {
const shell = args[0] as ShellType | undefined;
const installFlag = options["install"] === "true";
if (!shell || !generators[shell]) {
const supported = Object.keys(generators).join(", ");
console.error(`Usage: ${binName} completions <${supported}> [--install]`);
console.error("");
console.error("Examples:");
console.error(
` eval "$(${binName} completions bash)" # Output to stdout (add to ~/.bashrc)`,
);
console.error(
` eval "$(${binName} completions zsh)" # Output to stdout (add to ~/.zshrc)`,
);
console.error(
` ${binName} completions fish | source # Output to stdout (add to fish config)`,
);
console.error("");
console.error("Install directly to shell config:");
console.error(
` ${binName} completions bash --install # Appends to ~/.bashrc`,
);
console.error(
` ${binName} completions zsh --install # Appends to ~/.zshrc`,
);
console.error(
` ${binName} completions fish --install # Appends to ~/.config/fish/config.fish`,
);
process.exit(1);
}
if (installFlag) {
const config = shellConfigs[shell];
const installed = installCompletions(shell, binName);
if (installed) {
console.log(`Completions installed to ${config.configFile}`);
console.log(`Restart your shell or run: source ${config.configFile}`);
} else {
console.log(`Completions already installed in ${config.configFile}`);
}
return;
}
process.stdout.write(generators[shell](binName));
}

View File

@@ -0,0 +1,103 @@
/**
* Offline Engine Factory
*
* Creates a lightweight engine instance that reads from SQLite without any
* network connections. Used for fast shell completion queries.
*
* This bypasses the normal Engine.create() which initializes electrum connections,
* and instead constructs the engine directly with an in-memory blockchain provider.
*/
import {
BlockchainMonitor,
Engine,
InMemoryBlockchainProvider,
} from "@xo-cash/engine";
import { createStorageAdapter, State, StorageType } from "@xo-cash/state";
import { convertMnemonicToSeedBytes } from "@xo-cash/crypto";
import { binToHex, hash256 } from "@bitauth/libauth";
import { createHash } from "crypto";
/**
* Options for creating an offline engine.
*/
export interface OfflineEngineOptions {
/** Path to the directory containing SQLite database files. */
databasePath: string;
/** Filename of the SQLite database (will be prefixed with seed hash). */
databaseFilename: string;
}
/**
* Creates an engine instance in offline mode - SQLite only, no network.
* Used for fast completion queries where we only need to read local data.
*
* This is significantly faster than Engine.create() because it:
* - Skips electrum application initialization
* - Uses an in-memory blockchain provider (no network)
* - Skips state sync initialization
*
* @param seed - The wallet seed phrase
* @param options - Database configuration options
* @returns An engine instance configured for offline/read-only use
*/
export async function createOfflineEngine(
seed: string,
options: OfflineEngineOptions,
): Promise<Engine> {
// Compute the seed hash (same logic as AppService.create)
const seedHash = createHash("sha256").update(seed).digest("hex");
const prefixedDatabaseFilename = `${seedHash.slice(0, 8)}-${options.databaseFilename}`;
// Generate account hash for storage namespace (must match Engine.create which uses hash256)
const seedAccountHash = hash256(convertMnemonicToSeedBytes(seed));
// Create the IndexedDB storage adapter (matches Engine.create default)
// Note: IndexedDB in Node uses a shim that stores data in SQLite files with .sqlite extension
const storageAdapter = await createStorageAdapter({
storageType: StorageType.INDEXEDDB,
databasePath: options.databasePath,
databaseFilename: prefixedDatabaseFilename,
accountHash: binToHex(seedAccountHash),
});
// Initialize the storage adapter
await storageAdapter.initialize();
// Create the state instance
const state = new State(storageAdapter);
// Use in-memory blockchain provider (no network connections)
const blockchainProvider = new InMemoryBlockchainProvider();
await blockchainProvider.initialize({
applicationIdentifier: "xo-cli-completions",
electrumOptions: {},
});
// Create a minimal blockchain monitor
const blockchainMonitor = new BlockchainMonitor(state, blockchainProvider);
// Construct engine directly without state sync
const engine = new Engine(seed, state, blockchainMonitor, blockchainProvider);
return engine;
}
/**
* Attempts to create an offline engine, returning null on failure.
* Useful for completion scripts where we don't want to crash on errors.
*
* @param seed - The wallet seed phrase
* @param options - Database configuration options
* @returns An engine instance or null if creation failed
*/
export async function tryCreateOfflineEngine(
seed: string,
options: OfflineEngineOptions,
): Promise<Engine | null> {
try {
return await createOfflineEngine(seed, options);
} catch {
return null;
}
}

View File

@@ -0,0 +1,199 @@
# bash completion for {{BIN_NAME}}
# Add to ~/.bashrc: eval "$({{BIN_NAME}} completions bash)"
# Find xo-complete in the same directory as xo-cli
__xo_complete_bin=""
if command -v xo-complete &>/dev/null; then
__xo_complete_bin="xo-complete"
elif command -v {{BIN_NAME}} &>/dev/null; then
__xo_complete_bin="$(dirname "$(command -v {{BIN_NAME}})")/xo-complete"
fi
# Wrapper to call xo-complete helper
__xo_complete() {
[[ -n "${__xo_complete_bin}" ]] && "${__xo_complete_bin}" "$@" 2>/dev/null
}
_{{FUNC_NAME}}_completions() {
local cur prev words cword
_init_completion || return
# Handle -m/--mnemonic-file argument (previous word was -m)
if [[ "${prev}" == "-m" || "${prev}" == "--mnemonic-file" ]]; then
local mnemonics
mnemonics=$(__xo_complete mnemonics "${cur}")
if [[ -n "${mnemonics}" ]]; then
while IFS= read -r line; do
COMPREPLY+=("$line")
done <<< "${mnemonics}"
return 0
fi
fi
# If the current word starts with "-", offer option flags
if [[ "${cur}" == -* ]]; then
COMPREPLY=($(compgen -W "{{OPTIONS}}" -- "${cur}"))
return 0
fi
# Find the command and subcommand positions
local cmd="" subcmd="" cmd_idx=0 subcmd_idx=0
for ((i=1; i < cword; i++)); do
if [[ "${words[i]}" != -* ]]; then
if [[ -z "${cmd}" ]]; then
cmd="${words[i]}"
cmd_idx=$i
else
subcmd="${words[i]}"
subcmd_idx=$i
break
fi
fi
done
# No command yet — offer the top-level commands
if [[ -z "${cmd}" ]]; then
COMPREPLY=($(compgen -W "{{COMMANDS}}" -- "${cur}"))
return 0
fi
# Handle each command's completion
case "${cmd}" in
mnemonic)
if [[ -z "${subcmd}" ]]; then
COMPREPLY=($(compgen -W "{{MNEMONIC_SUBS}}" -- "${cur}"))
fi
;;
template)
if [[ -z "${subcmd}" ]]; then
COMPREPLY=($(compgen -W "{{TEMPLATE_SUBS}}" -- "${cur}"))
elif [[ "${subcmd}" == "list" || "${subcmd}" == "inspect" ]]; then
# template list/inspect <category> <template> [field] - category first, then template, then field
local pos=$((cword - subcmd_idx))
if [[ $pos -eq 1 ]]; then
COMPREPLY=($(compgen -W "action transaction output lockingscript variable" -- "${cur}"))
elif [[ $pos -eq 2 ]]; then
local templates
templates=$(__xo_complete templates "${cur}")
if [[ -n "${templates}" ]]; then
while IFS= read -r line; do
COMPREPLY+=("$line")
done <<< "${templates}"
fi
elif [[ $pos -eq 3 && "${subcmd}" == "inspect" ]]; then
# Get the category and template from previous args
local category="${words[subcmd_idx + 1]}"
local template_arg="${words[subcmd_idx + 2]}"
local fields
fields=$(__xo_complete fields "${category}" "${template_arg}" "${cur}")
if [[ -n "${fields}" ]]; then
while IFS= read -r line; do
COMPREPLY+=("$line")
done <<< "${fields}"
fi
fi
elif [[ "${subcmd}" == "set-default" ]]; then
# template set-default <template> <output> <role> - template first
local pos=$((cword - subcmd_idx))
if [[ $pos -eq 1 ]]; then
local templates
templates=$(__xo_complete templates "${cur}")
if [[ -n "${templates}" ]]; then
while IFS= read -r line; do
COMPREPLY+=("$line")
done <<< "${templates}"
fi
fi
fi
;;
invitation)
if [[ -z "${subcmd}" ]]; then
COMPREPLY=($(compgen -W "{{INVITATION_SUBS}}" -- "${cur}"))
else
case "${subcmd}" in
create)
# invitation create <template> <action> - offer templates then actions
local pos=$((cword - subcmd_idx))
if [[ $pos -eq 1 ]]; then
local templates
templates=$(__xo_complete templates "${cur}")
if [[ -n "${templates}" ]]; then
while IFS= read -r line; do
COMPREPLY+=("$line")
done <<< "${templates}"
fi
elif [[ $pos -eq 2 ]]; then
local template_arg="${words[subcmd_idx + 1]}"
local actions
actions=$(__xo_complete actions "${template_arg}" "${cur}")
if [[ -n "${actions}" ]]; then
while IFS= read -r line; do
COMPREPLY+=("$line")
done <<< "${actions}"
fi
fi
;;
append|sign|broadcast|requirements|inspect)
# These take an invitation ID
local pos=$((cword - subcmd_idx))
if [[ $pos -eq 1 ]]; then
local invitations
invitations=$(__xo_complete invitations "${cur}")
if [[ -n "${invitations}" ]]; then
while IFS= read -r line; do
COMPREPLY+=("$line")
done <<< "${invitations}"
fi
fi
;;
import)
# import takes a file path - use default file completion
COMPREPLY=($(compgen -f -- "${cur}"))
;;
esac
fi
;;
resource)
if [[ -z "${subcmd}" ]]; then
COMPREPLY=($(compgen -W "{{RESOURCE_SUBS}}" -- "${cur}"))
elif [[ "${subcmd}" == "unreserve" ]]; then
# resource unreserve <txhash:vout> - offer resources
local pos=$((cword - subcmd_idx))
if [[ $pos -eq 1 ]]; then
local resources
resources=$(__xo_complete resources "${cur}")
if [[ -n "${resources}" ]]; then
while IFS= read -r line; do
COMPREPLY+=("$line")
done <<< "${resources}"
fi
fi
fi
;;
receive)
# receive <template> [output] - offer templates
local pos=$((cword - cmd_idx))
if [[ $pos -eq 1 ]]; then
local templates
templates=$(__xo_complete templates "${cur}")
if [[ -n "${templates}" ]]; then
while IFS= read -r line; do
COMPREPLY+=("$line")
done <<< "${templates}"
fi
fi
;;
completions)
if [[ -z "${subcmd}" ]]; then
COMPREPLY=($(compgen -W "bash zsh fish" -- "${cur}"))
fi
;;
esac
}
complete -F _{{FUNC_NAME}}_completions {{BIN_NAME}}

View File

@@ -0,0 +1,70 @@
# fish completion for {{BIN_NAME}}
# Add to fish config: {{BIN_NAME}} completions fish | source
# Disable file completions by default
complete -c {{BIN_NAME}} -f
# Helper function to get dynamic completions
# Finds xo-complete in the same directory as {{BIN_NAME}}
function __{{FUNC_NAME}}_complete_dynamic
set -l xo_complete_bin ""
if command -q xo-complete
set xo_complete_bin xo-complete
else if command -q {{BIN_NAME}}
set xo_complete_bin (dirname (command -s {{BIN_NAME}}))/xo-complete
end
if test -n "$xo_complete_bin"
$xo_complete_bin $argv 2>/dev/null
end
end
# Global options
complete -c {{BIN_NAME}} -s h -d "Show help"
complete -c {{BIN_NAME}} -l help -d "Show help"
complete -c {{BIN_NAME}} -s v -d "Verbose output"
complete -c {{BIN_NAME}} -l verbose -d "Verbose output"
complete -c {{BIN_NAME}} -s o -d "Output file"
complete -c {{BIN_NAME}} -l output -d "Output file"
# Dynamic mnemonic file completion for -m
complete -c {{BIN_NAME}} -s m -l mnemonic-file -xa '(__{{FUNC_NAME}}_complete_dynamic mnemonics)'
# Top-level commands
{{TOP_LEVEL_COMMANDS}}
# Static sub-commands
{{STATIC_SUBCOMMANDS}}
# Dynamic completions
# invitation create: template names
complete -c {{BIN_NAME}} -n "__fish_seen_subcommand_from invitation; and __fish_seen_subcommand_from create; and test (count (commandline -opc)) -eq 3" -xa '(__{{FUNC_NAME}}_complete_dynamic templates)'
# invitation create: action names (2nd arg)
complete -c {{BIN_NAME}} -n "__fish_seen_subcommand_from invitation; and __fish_seen_subcommand_from create; and test (count (commandline -opc)) -eq 4" -xa '(__{{FUNC_NAME}}_complete_dynamic actions (commandline -opc)[4])'
# invitation append/sign/broadcast/requirements/inspect: invitation IDs
complete -c {{BIN_NAME}} -n "__fish_seen_subcommand_from invitation; and __fish_seen_subcommand_from append; and test (count (commandline -opc)) -eq 3" -xa '(__{{FUNC_NAME}}_complete_dynamic invitations)'
complete -c {{BIN_NAME}} -n "__fish_seen_subcommand_from invitation; and __fish_seen_subcommand_from sign; and test (count (commandline -opc)) -eq 3" -xa '(__{{FUNC_NAME}}_complete_dynamic invitations)'
complete -c {{BIN_NAME}} -n "__fish_seen_subcommand_from invitation; and __fish_seen_subcommand_from 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 inspect; and test (count (commandline -opc)) -eq 3" -xa '(__{{FUNC_NAME}}_complete_dynamic invitations)'
# invitation import: file completion
complete -c {{BIN_NAME}} -n "__fish_seen_subcommand_from invitation; and __fish_seen_subcommand_from import" -F
# template list/inspect: category first (pos 3), then template (pos 4), then field (pos 5 for inspect)
complete -c {{BIN_NAME}} -n "__fish_seen_subcommand_from template; and __fish_seen_subcommand_from list; and test (count (commandline -opc)) -eq 3" -xa 'action transaction output lockingscript variable'
complete -c {{BIN_NAME}} -n "__fish_seen_subcommand_from template; and __fish_seen_subcommand_from list; and test (count (commandline -opc)) -eq 4" -xa '(__{{FUNC_NAME}}_complete_dynamic templates)'
complete -c {{BIN_NAME}} -n "__fish_seen_subcommand_from template; and __fish_seen_subcommand_from inspect; and test (count (commandline -opc)) -eq 3" -xa 'action transaction output lockingscript variable'
complete -c {{BIN_NAME}} -n "__fish_seen_subcommand_from template; and __fish_seen_subcommand_from inspect; and test (count (commandline -opc)) -eq 4" -xa '(__{{FUNC_NAME}}_complete_dynamic templates)'
complete -c {{BIN_NAME}} -n "__fish_seen_subcommand_from template; and __fish_seen_subcommand_from inspect; and test (count (commandline -opc)) -eq 5" -xa '(__{{FUNC_NAME}}_complete_dynamic fields (commandline -opc)[4] (commandline -opc)[5])'
# template set-default: template first
complete -c {{BIN_NAME}} -n "__fish_seen_subcommand_from template; and __fish_seen_subcommand_from set-default; and test (count (commandline -opc)) -eq 3" -xa '(__{{FUNC_NAME}}_complete_dynamic templates)'
# resource unreserve: UTXO outpoints
complete -c {{BIN_NAME}} -n "__fish_seen_subcommand_from resource; and __fish_seen_subcommand_from unreserve; and test (count (commandline -opc)) -eq 3" -xa '(__{{FUNC_NAME}}_complete_dynamic resources)'
# receive: template names
complete -c {{BIN_NAME}} -n "__fish_seen_subcommand_from receive; and test (count (commandline -opc)) -eq 2" -xa '(__{{FUNC_NAME}}_complete_dynamic templates)'

View File

@@ -0,0 +1,176 @@
# zsh completion for {{BIN_NAME}}
# Add to ~/.zshrc: eval "$({{BIN_NAME}} completions zsh)"
# Find xo-complete in the same directory as xo-cli
__xo_complete_bin=""
if (( $+commands[xo-complete] )); then
__xo_complete_bin="xo-complete"
elif (( $+commands[{{BIN_NAME}}] )); then
__xo_complete_bin="${commands[{{BIN_NAME}}]:h}/xo-complete"
fi
# Wrapper to call xo-complete helper
__xo_complete() {
[[ -n "${__xo_complete_bin}" ]] && "${__xo_complete_bin}" "$@" 2>/dev/null
}
_{{FUNC_NAME}}_completions() {
local -a commands
commands=({{COMMANDS}})
# Handle -m/--mnemonic-file argument (previous word was -m)
if [[ "${words[CURRENT-1]}" == "-m" || "${words[CURRENT-1]}" == "--mnemonic-file" ]]; then
local mnemonics
mnemonics=("${(@f)$(__xo_complete mnemonics "${words[CURRENT]}")}")
if [[ ${#mnemonics[@]} -gt 0 ]]; then
compadd -- "${mnemonics[@]}"
return
fi
fi
# If typing an option flag, complete options
if [[ "${words[${CURRENT}]}" == -* ]]; then
compadd -- {{OPTIONS}}
return
fi
# Find the command and subcommand
local cmd="" subcmd="" cmd_idx=0 subcmd_idx=0
for ((i=2; i < CURRENT; i++)); do
if [[ "${words[i]}" != -* ]]; then
if [[ -z "${cmd}" ]]; then
cmd="${words[i]}"
cmd_idx=$i
else
subcmd="${words[i]}"
subcmd_idx=$i
break
fi
fi
done
# No command yet — offer top-level commands
if [[ -z "${cmd}" ]]; then
compadd -- ${commands[@]}
return
fi
# Handle each command's completion
case "${cmd}" in
mnemonic)
if [[ -z "${subcmd}" ]]; then
compadd -- {{MNEMONIC_SUBS}}
fi
;;
template)
if [[ -z "${subcmd}" ]]; then
compadd -- {{TEMPLATE_SUBS}}
elif [[ "${subcmd}" == "list" || "${subcmd}" == "inspect" ]]; then
# template list/inspect <category> <template> [field] - category first, then template, then field
local pos=$((CURRENT - subcmd_idx))
if [[ $pos -eq 1 ]]; then
compadd -- action transaction output lockingscript variable
elif [[ $pos -eq 2 ]]; then
local templates
templates=("${(@f)$(__xo_complete templates "${words[CURRENT]}")}")
if [[ ${#templates[@]} -gt 0 ]]; then
compadd -- "${templates[@]}"
fi
elif [[ $pos -eq 3 && "${subcmd}" == "inspect" ]]; then
# Get the category and template from previous args
local category="${words[subcmd_idx + 1]}"
local template_arg="${words[subcmd_idx + 2]}"
local fields
fields=("${(@f)$(__xo_complete fields "${category}" "${template_arg}" "${words[CURRENT]}")}")
if [[ ${#fields[@]} -gt 0 ]]; then
compadd -- "${fields[@]}"
fi
fi
elif [[ "${subcmd}" == "set-default" ]]; then
# template set-default <template> <output> <role> - template first
local pos=$((CURRENT - subcmd_idx))
if [[ $pos -eq 1 ]]; then
local templates
templates=("${(@f)$(__xo_complete templates "${words[CURRENT]}")}")
if [[ ${#templates[@]} -gt 0 ]]; then
compadd -- "${templates[@]}"
fi
fi
fi
;;
invitation)
if [[ -z "${subcmd}" ]]; then
compadd -- {{INVITATION_SUBS}}
else
case "${subcmd}" in
create)
local pos=$((CURRENT - subcmd_idx))
if [[ $pos -eq 1 ]]; then
local templates
templates=("${(@f)$(__xo_complete templates "${words[CURRENT]}")}")
if [[ ${#templates[@]} -gt 0 ]]; then
compadd -- "${templates[@]}"
fi
elif [[ $pos -eq 2 ]]; then
local template_arg="${words[subcmd_idx + 1]}"
local actions
actions=("${(@f)$(__xo_complete actions "${template_arg}" "${words[CURRENT]}")}")
if [[ ${#actions[@]} -gt 0 ]]; then
compadd -- "${actions[@]}"
fi
fi
;;
append|sign|broadcast|requirements|inspect)
local pos=$((CURRENT - subcmd_idx))
if [[ $pos -eq 1 ]]; then
local invitations
invitations=("${(@f)$(__xo_complete invitations "${words[CURRENT]}")}")
if [[ ${#invitations[@]} -gt 0 ]]; then
compadd -- "${invitations[@]}"
fi
fi
;;
import)
_files
;;
esac
fi
;;
resource)
if [[ -z "${subcmd}" ]]; then
compadd -- {{RESOURCE_SUBS}}
elif [[ "${subcmd}" == "unreserve" ]]; then
local pos=$((CURRENT - subcmd_idx))
if [[ $pos -eq 1 ]]; then
local resources
resources=("${(@f)$(__xo_complete resources "${words[CURRENT]}")}")
if [[ ${#resources[@]} -gt 0 ]]; then
compadd -- "${resources[@]}"
fi
fi
fi
;;
receive)
local pos=$((CURRENT - cmd_idx))
if [[ $pos -eq 1 ]]; then
local templates
templates=("${(@f)$(__xo_complete templates "${words[CURRENT]}")}")
if [[ ${#templates[@]} -gt 0 ]]; then
compadd -- "${templates[@]}"
fi
fi
;;
completions)
if [[ -z "${subcmd}" ]]; then
compadd -- bash zsh fish
fi
;;
esac
}
compdef _{{FUNC_NAME}}_completions {{BIN_NAME}}

View File

@@ -1,48 +0,0 @@
import util from "node:util";
/**
* Text formatting utilities for the CLI.
*
* Uses ANSI escape codes to format text.
*
* AI Generated links:
* @see https://en.wikipedia.org/wiki/ANSI_escape_code
* @see https://en.wikipedia.org/wiki/ANSI_escape_code#SGR_(Select_Graphic_Rendition)_parameters
* @see https://en.wikipedia.org/wiki/ANSI_escape_code#Colors
* @see https://en.wikipedia.org/wiki/ANSI_escape_code#Formatting
* @see https://en.wikipedia.org/wiki/ANSI_escape_code#Cursor_movement
* @see https://en.wikipedia.org/wiki/ANSI_escape_code#Screen_manipulation
*/
const BOLD = "\x1b[1m";
export const bold = (text: string) => `${BOLD}${text}${RESET}`;
const DIM = "\x1b[2m";
export const dim = (text: string) => `${DIM}${text}${RESET}`;
const UNDERLINE = "\x1b[4m";
export const underline = (text: string) => `${UNDERLINE}${text}${RESET}`;
const INVERSE = "\x1b[7m";
export const inverse = (text: string) => `${INVERSE}${text}${RESET}`;
const HIDDEN = "\x1b[8m";
export const hidden = (text: string) => `${HIDDEN}${text}${RESET}`;
const STRIKETHROUGH = "\x1b[9m";
export const strikethrough = (text: string) => `${STRIKETHROUGH}${text}${RESET}`;
const RESET = "\x1b[0m";
export const reset = (text: string) => `${RESET}${text}${RESET}`;
export const formatObject = (obj: unknown) => {
return util.inspect(obj, {
depth: null,
colors: true,
compact: false
});
};
export const objectPrint = (obj: unknown) => {
console.log(formatObject(obj));
};

View File

@@ -1,7 +1,7 @@
export type { CommandDependencies } from "./types.js";
export { handleMnemonicCommand, printMnemonicHelp } from "./mnemonic.js"; export { handleMnemonicCommand, printMnemonicHelp } from "./mnemonic.js";
export { handleTemplateCommand, printTemplateHelp } from "./template.js"; export { handleTemplateCommand, printTemplateHelp } from "./template.js";
export { handleInvitationCommand, printInvitationHelp } from "./invitation.js"; export { handleInvitationCommand, printInvitationHelp } from "./invitation.js";
export { handleReceiveCommand, printReceiveHelp } from "./receive.js"; export { handleReceiveCommand, printReceiveHelp } from "./receive.js";
export { handleResourceCommand, printResourceHelp } from "./resource.js"; export { handleResourceCommand, printResourceHelp } from "./resource.js";
export * from "./types.js";

View File

@@ -1,10 +1,11 @@
import { existsSync, readFileSync, writeFileSync } from "fs"; import { readFileSync, writeFileSync } from "fs";
import path from "path"; import path from "path";
import { generateTemplateIdentifier } from "@xo-cash/engine"; import { generateTemplateIdentifier } from "@xo-cash/engine";
import { binToHex, hexToBin } from "@bitauth/libauth"; import { binToHex, hexToBin } from "@bitauth/libauth";
import { bold, dim, formatObject } from "../cli-utils.js"; import { bold, dim, formatObject } from "../utils.js";
import type { CommandDependencies } from "./types.js"; import type { CommandDependencies, CommandIO } from "./types.js";
import { CommandError } from "./types.js";
import type { Invitation } from "../../services/invitation.js"; import type { Invitation } from "../../services/invitation.js";
import { import {
resolveProvidedLockingBytecodeHex, resolveProvidedLockingBytecodeHex,
@@ -12,6 +13,7 @@ import {
autoSelectGreedyUtxos, autoSelectGreedyUtxos,
} from "../../utils/invitation-flow.js"; } from "../../utils/invitation-flow.js";
import { encodeExtendedJson } from "../../utils/ext-json.js"; import { encodeExtendedJson } from "../../utils/ext-json.js";
import { resolveTemplate } from "../utils.js";
const DEFAULT_FEE = 500n; const DEFAULT_FEE = 500n;
const DUST_THRESHOLD = 546n; const DUST_THRESHOLD = 546n;
@@ -71,14 +73,16 @@ async function buildAppendParams(
// --- Inputs --- // --- Inputs ---
// Accepts comma-separated <txhash>:<vout> pairs via --add-input, // Accepts comma-separated <txhash>:<vout> pairs via --add-input,
// OR automatic selection via --auto-inputs. // OR automatic selection via --auto-inputs.
let inputs: { outpointTransactionHash: Uint8Array; outpointIndex: number }[] = []; let inputs: { outpointTransactionHash: Uint8Array; outpointIndex: number }[] =
[];
if (options["autoInputs"] === "true") { if (options["autoInputs"] === "true") {
// Auto-select UTXOs using the greedy algorithm from invitation-flow. // Auto-select UTXOs using the greedy algorithm from invitation-flow.
const suitableResources = await invitation.findSuitableResources(); const suitableResources = await invitation.findSuitableResources();
const selectable = mapUnspentOutputsToSelectable(suitableResources); const selectable = mapUnspentOutputsToSelectable(suitableResources);
const requiredWithFee = (await invitation.getSatsOut().catch(() => 0n)) + DEFAULT_FEE; const requiredWithFee =
(await invitation.getSatsOut().catch(() => 0n)) + DEFAULT_FEE;
autoSelectGreedyUtxos(selectable, requiredWithFee); autoSelectGreedyUtxos(selectable, requiredWithFee);
inputs = selectable inputs = selectable
@@ -89,20 +93,24 @@ async function buildAppendParams(
})); }));
if (inputs.length === 0) { if (inputs.length === 0) {
console.error("No suitable UTXOs found for auto-input selection."); deps.io.err("No suitable UTXOs found for auto-input selection.");
return null; return null;
} }
deps.verboseLogger(`Auto-selected ${inputs.length} input(s)`); deps.io.verbose(`Auto-selected ${inputs.length} input(s)`);
} else if (options["addInput"]) { } else if (options["addInput"]) {
inputs = options["addInput"].split(",").map((entry) => { inputs = options["addInput"].split(",").map((entry) => {
const separatorIndex = entry.lastIndexOf(":"); const separatorIndex = entry.lastIndexOf(":");
if (separatorIndex === -1) { if (separatorIndex === -1) {
throw new Error(`Invalid input format "${entry}". Expected <txhash>:<vout> (e.g. abc123:0)`); throw new Error(
`Invalid input format "${entry}". Expected <txhash>:<vout> (e.g. abc123:0)`,
);
} }
const txHash = entry.substring(0, separatorIndex); const txHash = entry.substring(0, separatorIndex);
const vout = parseInt(entry.substring(separatorIndex + 1), 10); const vout = parseInt(entry.substring(separatorIndex + 1), 10);
if (!txHash || isNaN(vout)) { if (!txHash || isNaN(vout)) {
throw new Error(`Invalid input format "${entry}". Expected <txhash>:<vout> (e.g. abc123:0)`); throw new Error(
`Invalid input format "${entry}". Expected <txhash>:<vout> (e.g. abc123:0)`,
);
} }
return { return {
outpointTransactionHash: hexToBin(txHash), outpointTransactionHash: hexToBin(txHash),
@@ -110,7 +118,9 @@ async function buildAppendParams(
}; };
}); });
} }
deps.verboseLogger(`Inputs: ${formatObject(inputs.map(i => ({ txHash: binToHex(i.outpointTransactionHash), vout: i.outpointIndex })))}`); deps.io.verbose(
`Inputs: ${formatObject(inputs.map((i) => ({ txHash: binToHex(i.outpointTransactionHash), vout: i.outpointIndex })))}`,
);
// --- Outputs --- // --- Outputs ---
// When --add-output is provided, use those identifiers explicitly. // When --add-output is provided, use those identifiers explicitly.
@@ -133,7 +143,9 @@ async function buildAppendParams(
} }
outputIdentifiers = [...discovered]; outputIdentifiers = [...discovered];
if (outputIdentifiers.length > 0) { if (outputIdentifiers.length > 0) {
deps.verboseLogger(`Auto-discovered output(s) from template: ${outputIdentifiers.join(", ")}`); deps.io.verbose(
`Auto-discovered output(s) from template: ${outputIdentifiers.join(", ")}`,
);
} }
} }
@@ -149,62 +161,86 @@ async function buildAppendParams(
} }
} }
const template = await deps.app.engine.getTemplate(invitation.data.templateIdentifier); const template = await deps.app.engine.getTemplate(
invitation.data.templateIdentifier,
);
const outputs: any[] = await Promise.all( const outputs: any[] = await Promise.all(
outputIdentifiers.map(async (outputId) => { outputIdentifiers.map(async (outputId) => {
// Try variable-based resolution first (e.g. sendSatoshis → recipientLockingscript) // Try variable-based resolution first (e.g. sendSatoshis → recipientLockingscript)
const providedHex = template const providedHex = template
? resolveProvidedLockingBytecodeHex(template, outputId, variableValuesByIdentifier) ? resolveProvidedLockingBytecodeHex(
template,
outputId,
variableValuesByIdentifier,
)
: undefined; : undefined;
const lockingBytecodeHex = providedHex const lockingBytecodeHex =
?? await invitation.generateLockingBytecode(outputId, roleIdentifier); providedHex ??
(await invitation.generateLockingBytecode(outputId, roleIdentifier));
deps.verboseLogger(`Locking bytecode for output "${outputId}": ${lockingBytecodeHex}`); deps.io.verbose(
`Locking bytecode for output "${outputId}": ${lockingBytecodeHex}`,
);
return { return {
outputIdentifier: outputId, outputIdentifier: outputId,
lockingBytecode: new Uint8Array(Buffer.from(lockingBytecodeHex, "hex")), lockingBytecode: new Uint8Array(Buffer.from(lockingBytecodeHex, "hex")),
}; };
}), }),
); );
deps.verboseLogger(`Outputs: ${formatObject(outputs.map(o => o.outputIdentifier))}`); deps.io.verbose(
`Outputs: ${formatObject(outputs.map((o) => o.outputIdentifier))}`,
);
// --- Auto change output --- // --- Auto change output ---
// When inputs are provided, look up each UTXO's value, compute the // When inputs are provided, look up each UTXO's value, compute the
// required sats, and return the excess minus fees back to the user. // required sats, and return the excess minus fees back to the user.
if (inputs.length > 0) { if (inputs.length > 0) {
const allUtxos = await deps.app.engine.listUnspentOutputsData(); const allUtxos = await deps.app.engine.listUnspentOutputsData();
const utxoMap = new Map(allUtxos.map(u => [`${u.outpointTransactionHash}:${u.outpointIndex}`, u])); const utxoMap = new Map(
allUtxos.map((u) => [
`${u.outpointTransactionHash}:${u.outpointIndex}`,
u,
]),
);
let totalInputSats = 0n; let totalInputSats = 0n;
for (const input of inputs) { for (const input of inputs) {
const txHashHex = binToHex(input.outpointTransactionHash); const txHashHex = binToHex(input.outpointTransactionHash);
const utxo = utxoMap.get(`${txHashHex}:${input.outpointIndex}`); const utxo = utxoMap.get(`${txHashHex}:${input.outpointIndex}`);
if (!utxo) { if (!utxo) {
console.error(`UTXO not found: ${txHashHex}:${input.outpointIndex}. Make sure it exists in your wallet.`); deps.io.err(
`UTXO not found: ${txHashHex}:${input.outpointIndex}. Make sure it exists in your wallet.`,
);
return null; return null;
} }
totalInputSats += BigInt(utxo.valueSatoshis); totalInputSats += BigInt(utxo.valueSatoshis);
} }
deps.verboseLogger(`Total input value: ${totalInputSats} satoshis`); deps.io.verbose(`Total input value: ${totalInputSats} satoshis`);
const requiredSats = await invitation.getSatsOut(); const requiredSats = await invitation.getSatsOut();
deps.verboseLogger(`Required output value: ${requiredSats} satoshis`); deps.io.verbose(`Required output value: ${requiredSats} satoshis`);
const changeAmount = totalInputSats - requiredSats - DEFAULT_FEE; const changeAmount = totalInputSats - requiredSats - DEFAULT_FEE;
deps.verboseLogger(`Change amount: ${changeAmount} satoshis (fee: ${DEFAULT_FEE})`); deps.io.verbose(
`Change amount: ${changeAmount} satoshis (fee: ${DEFAULT_FEE})`,
);
if (changeAmount < 0n) { if (changeAmount < 0n) {
console.error(`Insufficient funds. Inputs total ${totalInputSats} sats, but need ${requiredSats + DEFAULT_FEE} sats (${requiredSats} required + ${DEFAULT_FEE} fee).`); deps.io.err(
`Insufficient funds. Inputs total ${totalInputSats} sats, but need ${requiredSats + DEFAULT_FEE} sats (${requiredSats} required + ${DEFAULT_FEE} fee).`,
);
return null; return null;
} }
if (changeAmount >= DUST_THRESHOLD) { if (changeAmount >= DUST_THRESHOLD) {
outputs.push({ valueSatoshis: changeAmount }); outputs.push({ valueSatoshis: changeAmount });
console.log(`Auto-adding change output: ${changeAmount} satoshis`); deps.io.out(`Auto-adding change output: ${changeAmount} satoshis`);
} else if (changeAmount > 0n) { } else if (changeAmount > 0n) {
console.log(`Change ${changeAmount} sats is below dust threshold (${DUST_THRESHOLD} sats), donating to miners as fee.`); deps.io.out(
`Change ${changeAmount} sats is below dust threshold (${DUST_THRESHOLD} sats), donating to miners as fee.`,
);
} }
} }
@@ -214,8 +250,8 @@ async function buildAppendParams(
/** /**
* Prints the help message for the invitation command * Prints the help message for the invitation command
*/ */
export const printInvitationHelp = () => { export const printInvitationHelp = (io: CommandIO): void => {
console.log( io.out(
` `
${bold("Usage:")} xo-cli invitation <sub-command> ${bold("Usage:")} xo-cli invitation <sub-command>
@@ -226,6 +262,7 @@ ${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")}
- inspect <invitation-id | invitation-file> ${dim("Inspect an invitation")}
- list ${dim("List all invitations")} - list ${dim("List all invitations")}
${bold("Create / Append options:")} ${bold("Create / Append options:")}
@@ -239,324 +276,499 @@ ${bold("Create / Append options:")}
${dim("When inputs are provided, a change output is automatically added if the")} ${dim("When inputs are provided, a change output is automatically added if the")}
${dim("input total exceeds the required amount + fee.")} ${dim("input total exceeds the required amount + fee.")}
`); `,
);
};
/**
* Result data returned by invitation commands on success.
*/
export type InvitationCommandResult = {
invitationIdentifier?: string;
txHash?: string;
count?: number;
templateName?: string;
actionIdentifier?: string;
status?: string;
entities?: { entityIdentifier: string; roles: (string | undefined)[] }[];
inputs?: unknown[];
outputs?: unknown[];
variables?: unknown[];
}; };
/** /**
* Handles the invitation command. * Handles the invitation command.
* Throws CommandError on failure, returns result data on success.
* @param deps - The command dependencies. * @param deps - The command dependencies.
* @param args - Positional args after the command name, e.g. ["create", "template.json", "action-id"]. * @param args - Positional args after the command name, e.g. ["create", "template.json", "action-id"].
* @param options - Parsed option flags, e.g. { varRequestedSatohis: "1000", role: "receiver" }. * @param options - Parsed option flags, e.g. { varRequestedSatohis: "1000", role: "receiver" }.
*/ */
export const handleInvitationCommand = async (deps: CommandDependencies, args: string[], options: Record<string, string>): Promise<void> => { export const handleInvitationCommand = async (
deps: CommandDependencies,
args: string[],
options: Record<string, string>,
): Promise<InvitationCommandResult> => {
const subCommand = args[0]; const subCommand = args[0];
deps.verboseLogger(`Invitation sub-command: ${subCommand}`); deps.io.verbose(`Invitation sub-command: ${subCommand}`);
if (!subCommand) { if (!subCommand) {
deps.verboseLogger("No sub-command provided"); deps.io.verbose("No sub-command provided");
printInvitationHelp(); printInvitationHelp(deps.io);
return; throw new CommandError(
"invitation.subcommand.missing",
"No sub-command provided",
);
} }
switch (subCommand) { switch (subCommand) {
case "create": { case "create": {
const templateFile = args[1]; const templateQuery = args[1];
const actionIdentifier = args[2]; const actionIdentifier = args[2];
deps.verboseLogger(`Template file: ${templateFile}, action identifier: ${actionIdentifier}`); deps.io.verbose(
`Template query: ${templateQuery}, action identifier: ${actionIdentifier}`,
);
if (!templateFile || !actionIdentifier) { if (!templateQuery || !actionIdentifier) {
deps.verboseLogger("No template file or action identifier provided"); deps.io.verbose("No template file or action identifier provided");
printInvitationHelp(); printInvitationHelp(deps.io);
return; throw new CommandError(
"invitation.create.arguments_missing",
"No template file or action identifier provided",
);
} }
// Resolve and validate the template file path const template = await resolveTemplate(deps, templateQuery);
const templatePath = path.resolve(`${process.cwd()}/${templateFile}`); const templateIdentifier = generateTemplateIdentifier(template);
deps.verboseLogger(`Template path: ${templatePath}`);
if (!existsSync(templatePath)) {
console.error(`Template file does not exist: ${templatePath}`);
printInvitationHelp();
return;
}
const template = await readFileSync(templatePath, "utf8");
const templateIdentifier = generateTemplateIdentifier(JSON.parse(template));
// Create the base invitation via the engine
const rawInvitation = await deps.app.engine.createInvitation({ const rawInvitation = await deps.app.engine.createInvitation({
templateIdentifier: templateIdentifier, templateIdentifier,
actionIdentifier: actionIdentifier, actionIdentifier,
}); });
deps.verboseLogger(`XOInvitation created: ${formatObject(rawInvitation)}`); deps.io.verbose(`XOInvitation created: ${formatObject(rawInvitation)}`);
const invitationInstance = await deps.app.createInvitation(rawInvitation); const invitationInstance = await deps.app.createInvitation(rawInvitation);
deps.verboseLogger(`Invitation created: ${formatObject(invitationInstance.data)}`); deps.io.verbose(
`Invitation created: ${formatObject(invitationInstance.data)}`,
);
// Commit variables first so getSatsOut can resolve them for change calc
// and resolveProvidedLockingBytecodeHex can read them from commits.
const variables = parseVariablesFromOptions(options); const variables = parseVariablesFromOptions(options);
deps.verboseLogger(`Variables: ${formatObject(variables)}`); deps.io.verbose(`Variables: ${formatObject(variables)}`);
if (variables.length > 0) { if (variables.length > 0) {
await invitationInstance.addVariables(variables); await invitationInstance.addVariables(variables);
} }
// Parse inputs/outputs and calculate change (variables are now committed)
const params = await buildAppendParams(deps, invitationInstance, options); const params = await buildAppendParams(deps, invitationInstance, options);
if (!params) return; if (!params) {
throw new CommandError(
"invitation.create.append_params_failed",
"Failed to build append parameters",
);
}
const { inputs, outputs } = params; const { inputs, outputs } = params;
if (inputs.length > 0 || outputs.length > 0) { if (inputs.length > 0 || outputs.length > 0) {
await invitationInstance.append({ inputs, outputs }); await invitationInstance.append({ inputs, outputs });
} }
// Save the invitation to a file const invitationFilePath = `${deps.paths.workingDir}/inv-${invitationInstance.data.invitationIdentifier}.json`;
const invitationFilePath = `${process.cwd()}/inv-${invitationInstance.data.invitationIdentifier}.json`; deps.io.verbose(`Invitation file path: ${invitationFilePath}`);
deps.verboseLogger(`Invitation file path: ${invitationFilePath}`); writeFileSync(
writeFileSync(invitationFilePath, encodeExtendedJson(invitationInstance.data, 2)); invitationFilePath,
console.log(`Invitation created: ${path.basename(invitationFilePath)} (${invitationInstance.data.invitationIdentifier})`); encodeExtendedJson(invitationInstance.data, 2),
);
deps.io.out(
`Invitation created: ${path.basename(invitationFilePath)} (${invitationInstance.data.invitationIdentifier})`,
);
// Check remaining requirements const missingRequirements =
const missingRequirements = await invitationInstance.getMissingRequirements(); await invitationInstance.getMissingRequirements();
const hasMissing = const hasMissing =
(missingRequirements.variables?.length ?? 0) > 0 || (missingRequirements.variables?.length ?? 0) > 0 ||
(missingRequirements.inputs?.length ?? 0) > 0 || (missingRequirements.inputs?.length ?? 0) > 0 ||
(missingRequirements.outputs?.length ?? 0) > 0 || (missingRequirements.outputs?.length ?? 0) > 0 ||
(missingRequirements.roles !== undefined && Object.keys(missingRequirements.roles).length > 0); (missingRequirements.roles !== undefined &&
Object.keys(missingRequirements.roles).length > 0);
if (hasMissing) { if (hasMissing) {
console.log(`\n${bold("Remaining requirements:")}`); deps.io.out(`\n${bold("Remaining requirements:")}`);
console.log(formatObject(missingRequirements)); deps.io.out(formatObject(missingRequirements));
} else { } else {
// --broadcast implies --sign const shouldSign =
const shouldSign = options["sign"] === "true" || options["broadcast"] === "true"; options["sign"] === "true" || options["broadcast"] === "true";
const shouldBroadcast = options["broadcast"] === "true"; const shouldBroadcast = options["broadcast"] === "true";
if (shouldSign) { if (shouldSign) {
await invitationInstance.sign(); await invitationInstance.sign();
console.log(`Invitation signed: ${invitationInstance.data.invitationIdentifier}`); deps.io.out(
`Invitation signed: ${invitationInstance.data.invitationIdentifier}`,
);
} }
if (shouldBroadcast) { if (shouldBroadcast) {
const txHash = await invitationInstance.broadcast(); const txHash = await invitationInstance.broadcast();
console.log(`Transaction broadcast: ${bold(txHash)}`); deps.io.out(`Transaction broadcast: ${bold(txHash)}`);
} else if (!shouldSign) { } else if (!shouldSign) {
console.log(`\n${bold("All requirements satisfied.")} You can now sign with: xo-cli invitation sign ${invitationInstance.data.invitationIdentifier}`); deps.io.out(
`\n${bold("All requirements satisfied.")} You can now sign with: xo-cli invitation sign ${invitationInstance.data.invitationIdentifier}`,
);
} }
} }
break; return {
invitationIdentifier: invitationInstance.data.invitationIdentifier,
};
} }
case "append": { case "append": {
const invitationIdentifier = args[1]; const invitationIdentifier = args[1];
deps.verboseLogger(`Invitation identifier: ${invitationIdentifier}`); deps.io.verbose(`Invitation identifier: ${invitationIdentifier}`);
if (!invitationIdentifier) { if (!invitationIdentifier) {
deps.verboseLogger("No invitation identifier provided"); deps.io.verbose("No invitation identifier provided");
printInvitationHelp(); printInvitationHelp(deps.io);
return; throw new CommandError(
"invitation.append.identifier_missing",
"No invitation identifier provided",
);
} }
// Find the invitation by identifier const invitation = deps.app.invitations.find(
const invitation = deps.app.invitations.find(inv => inv.data.invitationIdentifier === invitationIdentifier); (inv) => inv.data.invitationIdentifier === invitationIdentifier,
);
if (!invitation) { if (!invitation) {
console.error(`Invitation not found: ${invitationIdentifier}`); deps.io.err(`Invitation not found: ${invitationIdentifier}`);
return; throw new CommandError(
"invitation.append.not_found",
`Invitation not found: ${invitationIdentifier}`,
);
} }
deps.verboseLogger(`Invitation: ${formatObject(invitation.data)}`); deps.io.verbose(`Invitation: ${formatObject(invitation.data)}`);
// Commit variables first so getSatsOut can resolve them for change calc
const variables = parseVariablesFromOptions(options); const variables = parseVariablesFromOptions(options);
deps.verboseLogger(`Variables to append: ${formatObject(variables)}`); deps.io.verbose(`Variables to append: ${formatObject(variables)}`);
if (variables.length > 0) { if (variables.length > 0) {
await invitation.addVariables(variables); await invitation.addVariables(variables);
} }
// Parse inputs/outputs and calculate change (variables are now committed)
const params = await buildAppendParams(deps, invitation, options); const params = await buildAppendParams(deps, invitation, options);
if (!params) return; if (!params) {
throw new CommandError(
"invitation.append.params_failed",
"Failed to build append parameters",
);
}
const { inputs, outputs } = params; const { inputs, outputs } = params;
if (
if (variables.length === 0 && inputs.length === 0 && outputs.length === 0) { variables.length === 0 &&
console.error("Nothing to append. Provide variables (-var-<name> <value>), inputs (--add-input <txhash>:<vout>), or outputs (--add-output <identifier>)."); inputs.length === 0 &&
return; outputs.length === 0
) {
const error =
"Nothing to append. Provide variables (-var-<name> <value>), inputs (--add-input <txhash>:<vout>), or outputs (--add-output <identifier>).";
deps.io.err(error);
throw new CommandError("invitation.append.empty", error);
} }
if (inputs.length > 0 || outputs.length > 0) { if (inputs.length > 0 || outputs.length > 0) {
await invitation.append({ inputs, outputs }); await invitation.append({ inputs, outputs });
} }
deps.verboseLogger(`Invitation appended: ${formatObject(invitation.data)}`); deps.io.verbose(`Invitation appended: ${formatObject(invitation.data)}`);
console.log(`Invitation appended: ${invitationIdentifier}`); deps.io.out(`Invitation appended: ${invitationIdentifier}`);
// Save the updated invitation to a file const invitationFilePath = `${deps.paths.workingDir}/inv-${invitation.data.invitationIdentifier}.json`;
const invitationFilePath = `${process.cwd()}/inv-${invitation.data.invitationIdentifier}.json`;
writeFileSync(invitationFilePath, encodeExtendedJson(invitation.data, 2)); writeFileSync(invitationFilePath, encodeExtendedJson(invitation.data, 2));
console.log(`Invitation updated: ${path.basename(invitationFilePath)} (${invitation.data.invitationIdentifier})`); deps.io.out(
`Invitation updated: ${path.basename(invitationFilePath)} (${invitation.data.invitationIdentifier})`,
);
// Check remaining requirements
const missingRequirements = await invitation.getMissingRequirements(); const missingRequirements = await invitation.getMissingRequirements();
const hasMissing = const hasMissing =
(missingRequirements.variables?.length ?? 0) > 0 || (missingRequirements.variables?.length ?? 0) > 0 ||
(missingRequirements.inputs?.length ?? 0) > 0 || (missingRequirements.inputs?.length ?? 0) > 0 ||
(missingRequirements.outputs?.length ?? 0) > 0 || (missingRequirements.outputs?.length ?? 0) > 0 ||
(missingRequirements.roles !== undefined && Object.keys(missingRequirements.roles).length > 0); (missingRequirements.roles !== undefined &&
Object.keys(missingRequirements.roles).length > 0);
if (hasMissing) { if (hasMissing) {
console.log(`\n${bold("Remaining requirements:")}`); deps.io.out(`\n${bold("Remaining requirements:")}`);
console.log(formatObject(missingRequirements)); deps.io.out(formatObject(missingRequirements));
} else { } else {
const shouldSign = options["sign"] === "true" || options["broadcast"] === "true"; const shouldSign =
options["sign"] === "true" || options["broadcast"] === "true";
const shouldBroadcast = options["broadcast"] === "true"; const shouldBroadcast = options["broadcast"] === "true";
if (shouldSign) { if (shouldSign) {
await invitation.sign(); await invitation.sign();
console.log(`Invitation signed: ${invitationIdentifier}`); deps.io.out(`Invitation signed: ${invitationIdentifier}`);
} }
if (shouldBroadcast) { if (shouldBroadcast) {
const txHash = await invitation.broadcast(); const txHash = await invitation.broadcast();
console.log(`Transaction broadcast: ${bold(txHash)}`); deps.io.out(`Transaction broadcast: ${bold(txHash)}`);
} else if (!shouldSign) { } else if (!shouldSign) {
console.log(`\n${bold("All requirements satisfied.")} You can now sign with: xo-cli invitation sign ${invitationIdentifier}`); deps.io.out(
`\n${bold("All requirements satisfied.")} You can now sign with: xo-cli invitation sign ${invitationIdentifier}`,
);
} }
} }
break; return { invitationIdentifier };
} }
case "sign": { case "sign": {
const invitationIdentifier = args[1]; const invitationIdentifier = args[1];
deps.verboseLogger(`Invitation identifier: ${invitationIdentifier}`); deps.io.verbose(`Invitation identifier: ${invitationIdentifier}`);
// Check if the invitation identifier is provided
if (!invitationIdentifier) { if (!invitationIdentifier) {
deps.verboseLogger("No invitation identifier provided"); deps.io.verbose("No invitation identifier provided");
printInvitationHelp(); printInvitationHelp(deps.io);
return; throw new CommandError(
"invitation.sign.identifier_missing",
"No invitation identifier provided",
);
} }
// Find the invitation by identifier const invitation = deps.app.invitations.find(
const invitation = await deps.app.invitations.find(invitation => invitation.data.invitationIdentifier === invitationIdentifier); (candidate) =>
candidate.data.invitationIdentifier === invitationIdentifier,
);
if (!invitation) { if (!invitation) {
console.error(`Invitation not found: ${invitationIdentifier}`); deps.io.err(`Invitation not found: ${invitationIdentifier}`);
return; throw new CommandError(
"invitation.sign.not_found",
`Invitation not found: ${invitationIdentifier}`,
);
} }
deps.verboseLogger(`Invitation: ${formatObject(invitation.data)}`); deps.io.verbose(`Invitation: ${formatObject(invitation.data)}`);
// Sign the invitation
await invitation.sign(); await invitation.sign();
deps.verboseLogger(`Invitation signed: ${formatObject(invitation.data)}`); deps.io.verbose(`Invitation signed: ${formatObject(invitation.data)}`);
console.log(`Invitation signed: ${invitationIdentifier}`); deps.io.out(`Invitation signed: ${invitationIdentifier}`);
break; return { invitationIdentifier };
} }
case "broadcast": { case "broadcast": {
const invitationIdentifier = args[1]; const invitationIdentifier = args[1];
deps.verboseLogger(`Invitation identifier: ${invitationIdentifier}`); deps.io.verbose(`Invitation identifier: ${invitationIdentifier}`);
// Check if the invitation identifier is provided
if (!invitationIdentifier) { if (!invitationIdentifier) {
deps.verboseLogger("No invitation identifier provided"); deps.io.verbose("No invitation identifier provided");
printInvitationHelp(); printInvitationHelp(deps.io);
return; throw new CommandError(
"invitation.broadcast.identifier_missing",
"No invitation identifier provided",
);
} }
// Find the invitation by identifier const invitation = deps.app.invitations.find(
const invitation = await deps.app.invitations.find(invitation => invitation.data.invitationIdentifier === invitationIdentifier); (candidate) =>
candidate.data.invitationIdentifier === invitationIdentifier,
);
if (!invitation) { if (!invitation) {
console.error(`Invitation not found: ${invitationIdentifier}`); deps.io.err(`Invitation not found: ${invitationIdentifier}`);
return; throw new CommandError(
"invitation.broadcast.not_found",
`Invitation not found: ${invitationIdentifier}`,
);
} }
deps.verboseLogger(`Invitation: ${formatObject(invitation.data)}`); deps.io.verbose(`Invitation: ${formatObject(invitation.data)}`);
// Broadcast the invitation
const txHash = await invitation.broadcast(); const txHash = await invitation.broadcast();
deps.verboseLogger(`Invitation broadcasted: ${formatObject(invitation.data)}`); deps.io.verbose(
console.log(`Transaction broadcast: ${bold(txHash)}`); `Invitation broadcasted: ${formatObject(invitation.data)}`,
break; );
deps.io.out(`Transaction broadcast: ${bold(txHash)}`);
return { invitationIdentifier, txHash };
} }
case "requirements": { case "requirements": {
const invitationIdentifier = args[1]; const invitationIdentifier = args[1];
deps.verboseLogger(`Invitation identifier: ${invitationIdentifier}`); deps.io.verbose(`Invitation identifier: ${invitationIdentifier}`);
// Check if the invitation identifier is provided
if (!invitationIdentifier) { if (!invitationIdentifier) {
deps.verboseLogger("No invitation identifier provided"); deps.io.verbose("No invitation identifier provided");
printInvitationHelp(); printInvitationHelp(deps.io);
return; throw new CommandError(
"invitation.requirements.identifier_missing",
"No invitation identifier provided",
);
} }
// Find the invitation by identifier const invitation = deps.app.invitations.find(
const invitation = await deps.app.invitations.find(invitation => invitation.data.invitationIdentifier === invitationIdentifier); (candidate) =>
candidate.data.invitationIdentifier === invitationIdentifier,
);
if (!invitation) { if (!invitation) {
console.error(`Invitation not found: ${invitationIdentifier}`); deps.io.err(`Invitation not found: ${invitationIdentifier}`);
return; throw new CommandError(
"invitation.requirements.not_found",
`Invitation not found: ${invitationIdentifier}`,
);
} }
deps.verboseLogger(`Invitation: ${formatObject(invitation.data)}`); deps.io.verbose(`Invitation: ${formatObject(invitation.data)}`);
// List the requirements for the invitation const requirements = await deps.app.engine.listRequirements(
const requirements = await deps.app.engine.listRequirements(invitation.data); invitation.data,
deps.verboseLogger(`Requirements: ${formatObject(requirements)}`); );
console.log(formatObject(requirements)); deps.io.verbose(`Requirements: ${formatObject(requirements)}`);
break; deps.io.out(formatObject(requirements));
return { invitationIdentifier };
}
case "inspect": {
const invitationFilePath = args[1];
deps.io.verbose(`Invitation file path: ${invitationFilePath}`);
if (!invitationFilePath) {
deps.io.verbose("No invitation file provided");
printInvitationHelp(deps.io);
throw new CommandError(
"invitation.inspect.file_missing",
"No invitation file provided",
);
}
const invitationFile = await readFileSync(invitationFilePath, "utf8");
deps.io.verbose(`Invitation file: ${invitationFile}`);
const invitation = JSON.parse(invitationFile);
deps.io.verbose(`Invitation: ${formatObject(invitation)}`);
const invitationInstance = await deps.app.createInvitation(invitation);
deps.io.verbose(
`Invitation created: ${formatObject(invitationInstance.data)}`,
);
const template = await deps.app.engine.getTemplate(
invitationInstance.data.templateIdentifier,
);
const action =
template?.actions[invitationInstance.data.actionIdentifier];
deps.io.verbose(`Action: ${formatObject(action)}`);
if (!action) {
deps.io.err(
`Action not found: ${invitationInstance.data.actionIdentifier}`,
);
throw new CommandError(
"invitation.inspect.action_not_found",
`Action not found: ${invitationInstance.data.actionIdentifier}`,
);
}
const status = invitationInstance.status;
deps.io.verbose(`Status: ${status}`);
const entities = Array.from(
new Set(
invitationInstance.data.commits.map(
(commit) => commit.entityIdentifier,
),
),
);
deps.io.verbose(`Entities: ${formatObject(entities)}`);
const entitiesWithRoles = entities.map((entity) => {
return {
entityIdentifier: entity,
roles: invitationInstance.data.commits
.filter((commit) => commit.entityIdentifier === entity)
.map((commit) => {
return [
...(commit.data.inputs?.map((input) => input.roleIdentifier) ??
[]),
...(commit.data.outputs?.map(
(output) => output.roleIdentifier,
) ?? []),
...(commit.data.variables?.map(
(variable) => variable.roleIdentifier,
) ?? []),
];
})
.flat()
.filter((role) => role !== undefined),
};
});
const inputs = invitationInstance.data.commits.flatMap(
(commit) => commit.data.inputs ?? [],
);
deps.io.verbose(`Inputs: ${formatObject(inputs)}`);
const outputs = invitationInstance.data.commits.flatMap(
(commit) => commit.data.outputs ?? [],
);
deps.io.verbose(`Outputs: ${formatObject(outputs)}`);
const variables = invitationInstance.data.commits.flatMap(
(commit) => commit.data.variables ?? [],
);
deps.io.verbose(`Variables: ${formatObject(variables)}`);
return {
templateName: template?.name ?? "Unknown",
actionIdentifier: invitationInstance.data.actionIdentifier,
status: status,
entities: entitiesWithRoles,
inputs: inputs,
outputs: outputs,
variables: variables,
};
} }
case "import": { case "import": {
// Check if the invitation file path is provided
const invitationFilePath = args[1]; const invitationFilePath = args[1];
deps.verboseLogger(`Invitation file path: ${invitationFilePath}`); deps.io.verbose(`Invitation file path: ${invitationFilePath}`);
// Check if the invitation file path is provided
if (!invitationFilePath) { if (!invitationFilePath) {
deps.verboseLogger("No invitation file provided"); deps.io.verbose("No invitation file provided");
printInvitationHelp(); printInvitationHelp(deps.io);
return; throw new CommandError(
"invitation.import.file_missing",
"No invitation file provided",
);
} }
// Read the invitation file
const invitationFile = await readFileSync(invitationFilePath, "utf8"); const invitationFile = await readFileSync(invitationFilePath, "utf8");
deps.verboseLogger(`Invitation file: ${invitationFile}`); deps.io.verbose(`Invitation file: ${invitationFile}`);
// Parse the invitation file
const invitation = JSON.parse(invitationFile); const invitation = JSON.parse(invitationFile);
deps.verboseLogger(`Invitation: ${formatObject(invitation)}`); deps.io.verbose(`Invitation: ${formatObject(invitation)}`);
// Create the invitation (internal to XO Engine)
const xoInvitation = await deps.app.engine.createInvitation(invitation); const xoInvitation = await deps.app.engine.createInvitation(invitation);
// Create the invitation instance
const invitationInstance = await deps.app.createInvitation(xoInvitation); const invitationInstance = await deps.app.createInvitation(xoInvitation);
deps.verboseLogger(`Invitation created: ${formatObject(invitationInstance.data)}`); deps.io.verbose(
break; `Invitation created: ${formatObject(invitationInstance.data)}`,
);
return {
invitationIdentifier: invitationInstance.data.invitationIdentifier,
};
} }
case "list": { case "list": {
// List all invitations const invitations = await Promise.all(
const invitations = await Promise.all(deps.app.invitations.map(async invitation => { deps.app.invitations.map(async (invitation) => {
// Get the template for the invitation so we can display the name of it const template = await deps.app.engine.getTemplate(
const template = await deps.app.engine.getTemplate(invitation.data.templateIdentifier); invitation.data.templateIdentifier,
);
return { return {
invitationIdentifier: invitation.data.invitationIdentifier, invitationIdentifier: invitation.data.invitationIdentifier,
templateIdentifier: invitation.data.templateIdentifier, templateIdentifier: invitation.data.templateIdentifier,
actionIdentifier: invitation.data.actionIdentifier, actionIdentifier: invitation.data.actionIdentifier,
templateName: template?.name ?? "Unknown", templateName: template?.name ?? "Unknown",
status: invitation.status, status: invitation.status,
roleIdentifier: 'TODO: Get role identifier', roleIdentifier: "TODO: Get role identifier",
}; };
})); }),
deps.verboseLogger(`Invitations: ${formatObject(invitations)}`); );
deps.io.verbose(`Invitations: ${formatObject(invitations)}`);
// Format the invitations for display and print it const formattedInvitations = invitations.map(
const formattedInvitations = invitations.map(invitation => `${bold(invitation.templateName)} ${dim(invitation.status)} ${dim(invitation.invitationIdentifier)} ${dim(invitation.actionIdentifier)} (${dim(invitation.roleIdentifier)})`); (invitation) =>
console.log(formattedInvitations.join('\n')); `${bold(invitation.templateName)} ${dim(invitation.status)} ${dim(invitation.invitationIdentifier)} ${dim(invitation.actionIdentifier)} (${dim(invitation.roleIdentifier)})`,
break; );
deps.io.out(formattedInvitations.join("\n"));
return { count: invitations.length };
} }
default: default:
deps.verboseLogger(`Unknown invitation sub-command: ${subCommand}`); deps.io.verbose(`Unknown invitation sub-command: ${subCommand}`);
printInvitationHelp(); printInvitationHelp(deps.io);
return; throw new CommandError(
"invitation.subcommand.unknown",
`Unknown invitation sub-command: ${subCommand}`,
);
} }
}; };

View File

@@ -1,73 +1,129 @@
import { bold, dim } from "../cli-utils.js"; import { bold, dim } from "../utils.js";
import { listMnemonicFiles, createMnemonicFile, createMnemonicSeed } from "../mnemonic.js"; import {
import type { CommandDependencies } from "./types.js"; listMnemonicFiles,
createMnemonicFile,
createMnemonicSeed,
loadMnemonic,
} from "../mnemonic.js";
import type { BaseCommandDependencies, CommandIO } from "./types.js";
import { CommandError } from "./types.js";
/** /**
* Prints the help message for the mnemonic command * Prints the help message for the mnemonic command
*/ */
export const printMnemonicHelp = () => { export const printMnemonicHelp = (io: CommandIO): void => {
console.log( io.out(
` `
${bold("Usage:")} xo-cli mnemonic <sub-command> ${bold("Usage:")} xo-cli mnemonic <sub-command>
${bold("Sub-commands:")} ${bold("Sub-commands:")}
- create <mnemonic-seed> ${dim("Create a new mnemonic file")} - create <mnemonic-seed> ${dim("Create a new mnemonic file")}
- list ${dim("List all mnemonic files")} - list ${dim("List all mnemonic files")}
- import <mnemonic-seed> ${dim("Import a mnemonic seed from a file")}
- expose <mnemonic-file> ${dim("Expose a mnemonic file")}
${bold("Options:")} ${bold("Options:")}
-o --output <output-filename> ${dim("Output filename for the mnemonic file")} -o --output <output-filename> ${dim("Output filename for the mnemonic file")}
-h --help ${dim("Show this help message")} -h --help ${dim("Show this help message")}
`); `,
);
}; };
/** /**
* Handles the mnemonic command. * Handles the mnemonic command.
* Throws CommandError on failure, returns result data on success.
* @param deps - The command dependencies. * @param deps - The command dependencies.
* @param args - Positional args after the command name, e.g. ["create"] or ["import", "page", "pencil", ...]. * @param args - Positional args after the command name, e.g. ["create"] or ["import", "page", "pencil", ...].
* @param options - Parsed option flags, e.g. { output: "mnemonic.txt" }. * @param options - Parsed option flags, e.g. { output: "mnemonic.txt" }.
*/ */
export const handleMnemonicCommand = async (deps: Omit<CommandDependencies, "app">, args: string[], options: Record<string, string>): Promise<void> => { export const handleMnemonicCommand = async (
deps: BaseCommandDependencies,
args: string[],
options: Record<string, string>,
): Promise<{ savedAs?: string; count?: number; mnemonic?: string }> => {
const subCommand = args[0]; const subCommand = args[0];
const { mnemonicsDir } = deps.paths;
if (!subCommand) { if (!subCommand) {
deps.verboseLogger("No sub-command provided"); deps.io.verbose("No sub-command provided");
printMnemonicHelp(); printMnemonicHelp(deps.io);
return; throw new CommandError(
"mnemonic.subcommand.missing",
"No sub-command provided",
);
} }
switch (subCommand) { switch (subCommand) {
case "create": { case "create": {
const mnemonicSeed = createMnemonicSeed(); const mnemonicSeed = createMnemonicSeed();
await createMnemonicFile(mnemonicSeed, options["output"]); const savedAs = createMnemonicFile(
mnemonicsDir,
mnemonicSeed,
options["output"],
);
console.log(`Mnemonic file created: ${options["output"]} (${mnemonicSeed})`); deps.io.out(`Mnemonic file created: ${savedAs} (${mnemonicSeed})`);
break; return { savedAs };
} }
case "import": { case "import": {
// The mnemonic seed words are all the positional args after the sub-command
const mnemonicSeed = args.slice(1).join(" "); const mnemonicSeed = args.slice(1).join(" ");
if (!mnemonicSeed) { if (!mnemonicSeed) {
deps.verboseLogger("No mnemonic seed provided"); deps.io.verbose("No mnemonic seed provided");
printMnemonicHelp(); printMnemonicHelp(deps.io);
return; throw new CommandError(
"mnemonic.import.seed_missing",
"No mnemonic seed provided",
);
} }
deps.verboseLogger(`Mnemonic seed: ${mnemonicSeed}`); deps.io.verbose(`Mnemonic seed: ${mnemonicSeed}`);
await createMnemonicFile(mnemonicSeed, options["output"]); const savedAs = createMnemonicFile(
break; mnemonicsDir,
mnemonicSeed,
options["output"],
);
deps.io.out(`Mnemonic file created: ${savedAs}`);
return { savedAs };
} }
case "list": { case "list": {
const mnemonicFiles = listMnemonicFiles(); const mnemonicFiles = listMnemonicFiles(mnemonicsDir);
console.log(mnemonicFiles.join('\n')); deps.io.out(mnemonicFiles.join("\n"));
break; return { count: mnemonicFiles.length };
}
case "expose": {
const mnemonicFile = args[1];
if (!mnemonicFile) {
deps.io.verbose("No mnemonic file provided");
printMnemonicHelp(deps.io);
throw new CommandError(
"mnemonic.expose.file_missing",
"No mnemonic file provided",
);
}
try {
const mnemonic = loadMnemonic(mnemonicsDir, mnemonicFile);
deps.io.out(mnemonic);
return { mnemonic };
} catch (error) {
throw new CommandError(
"mnemonic.expose.file_not_found",
`Mnemonic file not found: ${mnemonicFile}`,
);
}
} }
default: default:
console.error(`Unknown sub-command: ${subCommand}`); deps.io.err(`Unknown sub-command: ${subCommand}`);
printMnemonicHelp(); printMnemonicHelp(deps.io);
return; throw new CommandError(
"mnemonic.subcommand.unknown",
`Unknown sub-command: ${subCommand}`,
);
} }
}; };

View File

@@ -1,16 +1,17 @@
import { existsSync, readFileSync } from "fs";
import path from "path";
import { generateTemplateIdentifier } from "@xo-cash/engine"; import { generateTemplateIdentifier } from "@xo-cash/engine";
import { hexToBin, lockingBytecodeToCashAddress } from "@bitauth/libauth"; import { hexToBin, lockingBytecodeToCashAddress } from "@bitauth/libauth";
import { bold, dim } from "../cli-utils.js"; import { bold, dim } from "../utils.js";
import type { CommandDependencies } from "./types.js"; import type { CommandDependencies, CommandIO } from "./types.js";
import { CommandError } from "./types.js";
import { resolveTemplate } from "../utils.js";
/** /**
* Prints the help message for the receive command * Prints the help message for the receive command
*/ */
export const printReceiveHelp = () => { export const printReceiveHelp = (io: CommandIO): void => {
console.log( io.out(
` `
${bold("Usage:")} xo-cli receive <template-file> <output-identifier> [role-identifier] ${bold("Usage:")} xo-cli receive <template-file> <output-identifier> [role-identifier]
@@ -24,41 +25,46 @@ ${bold("Arguments:")}
${bold("Options:")} ${bold("Options:")}
-h --help ${dim("Show this help message")} -h --help ${dim("Show this help message")}
`); `,
);
}; };
/** /**
* Command which creates a single-use address/lockingScript for a given template and role. * Command which creates a single-use address/lockingScript for a given template and role.
* Throws CommandError on failure, returns address data on success.
*
* @param deps - The command dependencies. * @param deps - The command dependencies.
* @param args - Positional args after the command name, e.g. ["template.json", "receiveOutput", "receiver"]. * @param args - Positional args after the command name, e.g. ["template.json", "receiveOutput", "receiver"].
* @param options - Parsed option flags. * @param options - Parsed option flags.
* @returns The address data.
* @throws CommandError if the command fails.
*/ */
export const handleReceiveCommand = async (deps: CommandDependencies, args: string[], options: Record<string, string>): Promise<void> => { export const handleReceiveCommand = async (
const templateFile = args[0]; deps: CommandDependencies,
args: string[],
_options: Record<string, string>,
): Promise<{ address: string }> => {
const templateQuery = args[0];
const outputIdentifier = args[1]; const outputIdentifier = args[1];
const roleIdentifier = args[2]; const roleIdentifier = args[2];
deps.verboseLogger(`Receive args - template: ${templateFile}, output: ${outputIdentifier}, role: ${roleIdentifier}`); deps.io.verbose(
`Receive args - template: ${templateQuery}, output: ${outputIdentifier}, role: ${roleIdentifier}`,
);
if (!templateFile || !outputIdentifier) { if (!templateQuery || !outputIdentifier) {
deps.verboseLogger("Missing required arguments"); deps.io.verbose("Missing required arguments");
printReceiveHelp(); printReceiveHelp(deps.io);
return; throw new CommandError(
"receive.arguments.missing",
"Missing required arguments",
);
} }
// Resolve and read the template file // Resolve and read the template file
const templatePath = path.resolve(`${process.cwd()}/${templateFile}`); const template = await resolveTemplate(deps, templateQuery);
deps.verboseLogger(`Template path: ${templatePath}`); const templateIdentifier = generateTemplateIdentifier(template);
deps.io.verbose(`Template identifier: ${templateIdentifier}`);
if (!existsSync(templatePath)) {
console.error(`Template file does not exist: ${templatePath}`);
printReceiveHelp();
return;
}
const template = readFileSync(templatePath, "utf8");
const templateIdentifier = generateTemplateIdentifier(JSON.parse(template));
deps.verboseLogger(`Template identifier: ${templateIdentifier}`);
// Generate the locking bytecode (returned as a hex string) // Generate the locking bytecode (returned as a hex string)
const lockingBytecodeHex = await deps.app.engine.generateLockingBytecode( const lockingBytecodeHex = await deps.app.engine.generateLockingBytecode(
@@ -66,15 +72,22 @@ export const handleReceiveCommand = async (deps: CommandDependencies, args: stri
outputIdentifier, outputIdentifier,
roleIdentifier, roleIdentifier,
); );
deps.verboseLogger(`Locking bytecode hex: ${lockingBytecodeHex}`); deps.io.verbose(`Locking bytecode hex: ${lockingBytecodeHex}`);
// Convert the locking bytecode to a BCH cash address // Convert the locking bytecode to a BCH cash address
const result = lockingBytecodeToCashAddress({ bytecode: hexToBin(lockingBytecodeHex), prefix: 'bitcoincash' }); const result = lockingBytecodeToCashAddress({
bytecode: hexToBin(lockingBytecodeHex),
prefix: "bitcoincash",
});
if (typeof result === 'string') { if (typeof result === "string") {
console.error(`Failed to encode address: ${result}`); deps.io.err(`Failed to encode address: ${result}`);
return; throw new CommandError(
"receive.address.encode_failed",
`Failed to encode address: ${result}`,
);
} }
console.log(result.address); deps.io.out(result.address);
return { address: result.address };
}; };

View File

@@ -1,13 +1,15 @@
import { hexToBin } from "@bitauth/libauth"; import { hexToBin } from "@bitauth/libauth";
import { bold, dim } from "../cli-utils.js"; import { bold, dim } from "../utils.js";
import type { CommandDependencies } from "./types.js"; import type { CommandDependencies, CommandIO } from "./types.js";
import type { UnspentOutputData } from "@xo-cash/state";
import { CommandError } from "./types.js";
/** /**
* Prints the help message for the resource command. * Prints the help message for the resource command.
*/ */
export const printResourceHelp = () => { export const printResourceHelp = (io: CommandIO): void => {
console.log( io.out(
` `
${bold("Usage:")} xo-cli resource <sub-command> ${bold("Usage:")} xo-cli resource <sub-command>
@@ -17,20 +19,26 @@ ${bold("Sub-commands:")}
- list all ${dim("List all resources (reserved + unreserved)")} - list all ${dim("List all resources (reserved + unreserved)")}
- unreserve <txhash:vout> ${dim("Unreserve a specific UTXO")} - unreserve <txhash:vout> ${dim("Unreserve a specific UTXO")}
- unreserve-all ${dim("Unreserve all reserved UTXOs")} - unreserve-all ${dim("Unreserve all reserved UTXOs")}
`); `,
);
}; };
/** /**
* Formats a single UTXO for display, optionally including reservation info. * Formats a single UTXO for display, optionally including reservation info.
*/ */
function formatResource(resource: { outpointTransactionHash: string; outpointIndex: number; valueSatoshis: number; outputIdentifier: string; minedAtHeight: number; reserved?: boolean; invitationIdentifier?: string }, showReserved = false): string { function formatResource(
const outpoint = bold(`${resource.outpointTransactionHash}:${resource.outpointIndex}`); resource: UnspentOutputData,
showReserved = false,
): string {
const outpoint = bold(
`${resource.outpointTransactionHash}:${resource.outpointIndex}`,
);
const value = dim(`${resource.valueSatoshis} sats`); const value = dim(`${resource.valueSatoshis} sats`);
const output = dim(resource.outputIdentifier); const output = dim(resource.outputIdentifier);
const height = dim(`(height ${resource.minedAtHeight})`); const height = dim(`(height ${resource.minedAtHeight})`);
if (showReserved && resource.reserved) { if (showReserved && resource.reservedBy) {
const inv = dim(`reserved for ${resource.invitationIdentifier}`); const inv = dim(`reserved for ${resource.reservedBy}`);
return `${outpoint} ${value} ${output} ${height} ${inv}`; return `${outpoint} ${value} ${output} ${height} ${inv}`;
} }
@@ -39,107 +47,138 @@ function formatResource(resource: { outpointTransactionHash: string; outpointInd
/** /**
* Handles the resource command. * Handles the resource command.
* Throws CommandError on failure, returns result data on success.
* @param deps - The command dependencies. * @param deps - The command dependencies.
* @param args - Positional args after the command name, e.g. ["list"]. * @param args - Positional args after the command name, e.g. ["list"].
* @param options - Parsed option flags. * @param options - Parsed option flags.
*/ */
export const handleResourceCommand = async (deps: CommandDependencies, args: string[], options: Record<string, string>): Promise<void> => { export const handleResourceCommand = async (
deps: CommandDependencies,
args: string[],
_options: Record<string, string>,
): Promise<{ count?: number }> => {
const subCommand = args[0]; const subCommand = args[0];
deps.verboseLogger(`Resource sub-command: ${subCommand}`); deps.io.verbose(`Resource sub-command: ${subCommand}`);
if (!subCommand) { if (!subCommand) {
deps.verboseLogger("No sub-command provided"); deps.io.verbose("No sub-command provided");
printResourceHelp(); printResourceHelp(deps.io);
return; throw new CommandError(
"resource.subcommand.missing",
"No sub-command provided",
);
} }
switch (subCommand) { switch (subCommand) {
case "list": { case "list": {
const qualifier = args[1]; // "reserved", "all", or undefined (defaults to unreserved) const qualifier = args[1];
const allResources = await deps.app.engine.listUnspentOutputsData(); const allResources = await deps.app.engine.listUnspentOutputsData();
let filtered; let filtered;
if (qualifier === "reserved") { if (qualifier === "reserved") {
filtered = allResources.filter((r) => r.reserved); filtered = allResources.filter((r) => r.reservedBy);
} else if (qualifier === "all") { } else if (qualifier === "all") {
filtered = allResources; filtered = allResources;
} else { } else {
// Default: show only unreserved (selectable) resources filtered = allResources.filter((r) => !r.reservedBy);
filtered = allResources.filter((r) => !r.reserved);
} }
if (filtered.length === 0) { if (filtered.length === 0) {
console.log(dim("No resources found.")); deps.io.out(dim("No resources found."));
return; return { count: 0 };
} }
const showReserved = qualifier === "all" || qualifier === "reserved"; const showReserved = qualifier === "all" || qualifier === "reserved";
const formattedResources = filtered.map((r) => formatResource(r, showReserved)); const formattedResources = filtered.map((r) =>
console.log(formattedResources.join("\n")); formatResource(r, showReserved),
console.log(`Total satoshis: ${filtered.reduce((acc, r) => acc + r.valueSatoshis, 0)}`); );
console.log(`Total resources: ${filtered.length}`); deps.io.out(formattedResources.join("\n"));
break; deps.io.out(
`Total satoshis: ${filtered.reduce((acc, r) => acc + r.valueSatoshis, 0)}`,
);
deps.io.out(`Total resources: ${filtered.length}`);
return { count: filtered.length };
} }
case "unreserve": { case "unreserve": {
const outpointArg = args[1]; const outpointArg = args[1];
if (!outpointArg) { if (!outpointArg) {
console.error("Please provide a UTXO in <txhash>:<vout> format."); deps.io.err("Please provide a UTXO in <txhash>:<vout> format.");
printResourceHelp(); printResourceHelp(deps.io);
return; throw new CommandError(
"resource.unreserve.outpoint_missing",
"Please provide a UTXO in <txhash>:<vout> format.",
);
} }
const separatorIndex = outpointArg.lastIndexOf(":"); const separatorIndex = outpointArg.lastIndexOf(":");
if (separatorIndex === -1) { if (separatorIndex === -1) {
console.error(`Invalid format "${outpointArg}". Expected <txhash>:<vout>.`); deps.io.err(
return; `Invalid format "${outpointArg}". Expected <txhash>:<vout>.`,
);
throw new CommandError(
"resource.unreserve.outpoint_invalid",
`Invalid format "${outpointArg}". Expected <txhash>:<vout>.`,
);
} }
const txHash = outpointArg.substring(0, separatorIndex); const txHash = outpointArg.substring(0, separatorIndex);
const vout = parseInt(outpointArg.substring(separatorIndex + 1), 10); const vout = parseInt(outpointArg.substring(separatorIndex + 1), 10);
if (!txHash || isNaN(vout)) { if (!txHash || isNaN(vout)) {
console.error(`Invalid format "${outpointArg}". Expected <txhash>:<vout>.`); deps.io.err(
return; `Invalid format "${outpointArg}". Expected <txhash>:<vout>.`,
);
throw new CommandError(
"resource.unreserve.outpoint_invalid",
`Invalid format "${outpointArg}". Expected <txhash>:<vout>.`,
);
} }
// Look up the UTXO to get its invitation identifier (required by the engine).
const allResources = await deps.app.engine.listUnspentOutputsData(); const allResources = await deps.app.engine.listUnspentOutputsData();
const target = allResources.find( const target = allResources.find(
(r) => r.outpointTransactionHash === txHash && r.outpointIndex === vout, (r) => r.outpointTransactionHash === txHash && r.outpointIndex === vout,
); );
if (!target) { if (!target) {
console.error(`UTXO not found: ${txHash}:${vout}`); deps.io.err(`UTXO not found: ${txHash}:${vout}`);
return; throw new CommandError(
"resource.unreserve.utxo_missing",
`UTXO not found: ${txHash}:${vout}`,
);
} }
if (!target.reserved) { if (!target.reservedBy) {
console.log(dim("UTXO is not reserved. Nothing to do.")); deps.io.out(dim("UTXO is not reserved. Nothing to do."));
return; return {};
} }
await deps.app.engine.unreserveResources( await deps.app.engine.unreserveResources(
[{ outpointTransactionHash: hexToBin(txHash), outpointIndex: vout }], [{ outpointTransactionHash: hexToBin(txHash), outpointIndex: vout }],
target.invitationIdentifier, target.reservedBy,
); );
console.log(`Unreserved ${bold(`${txHash}:${vout}`)} (was reserved for ${target.invitationIdentifier})`); deps.io.out(
break; `Unreserved ${bold(`${txHash}:${vout}`)} (was reserved for ${target.reservedBy})`,
);
return {};
} }
case "unreserve-all": { case "unreserve-all": {
const count = await deps.app.unreserveAllResources(); const count = await deps.app.unreserveAllResources();
if (count === 0) { if (count === 0) {
console.log(dim("No reserved resources to unreserve.")); deps.io.out(dim("No reserved resources to unreserve."));
} else { } else {
console.log(`Unreserved ${bold(String(count))} resource(s).`); deps.io.out(`Unreserved ${bold(String(count))} resource(s).`);
} }
break; return { count };
} }
default: { default: {
deps.verboseLogger(`Unknown resource sub-command: ${subCommand}`); deps.io.verbose(`Unknown resource sub-command: ${subCommand}`);
printResourceHelp(); printResourceHelp(deps.io);
return; throw new CommandError(
"resource.subcommand.unknown",
`Unknown resource sub-command: ${subCommand}`,
);
} }
} }
}; };

View File

@@ -3,15 +3,17 @@ 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, objectPrint } from "../cli-utils.js"; import { bold, dim, formatObject } from "../utils.js";
import { resolveTemplateReferences } from "../../utils/templates.js"; import { resolveTemplateReferences } from "../../utils/templates.js";
import type { CommandDependencies } from "./types.js"; import type { CommandDependencies, CommandIO } from "./types.js";
import { CommandError } from "./types.js";
import { resolveTemplate } from "../utils.js";
/** /**
* Prints the help message for the template command * Prints the help message for the template command
*/ */
export const printTemplateHelp = () => { export const printTemplateHelp = (io: CommandIO): void => {
console.log( io.out(
` `
${bold("Usage:")} xo-cli template <sub-command> ${bold("Usage:")} xo-cli template <sub-command>
@@ -21,90 +23,117 @@ ${bold("Sub-commands:")}
- 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")}
`); `,
);
}; };
/** /**
* Handles the template list command. * Handles the template list command.
* Throws CommandError on failure, returns result data on success.
* @param deps - The command dependencies. * @param deps - The command dependencies.
* @param args - Positional args after the command name, e.g. ["list", "action"] or ["list", "action", "1234567890"]. * @param args - Positional args after the command name, e.g. ["list", "action"] or ["list", "action", "1234567890"].
*/ */
export const handleTemplateListCommand = async (deps: CommandDependencies, args: string[]): Promise<void> => { export const handleTemplateListCommand = async (
deps: CommandDependencies,
args: string[],
): Promise<{ count?: number }> => {
const templateCategory = args[0]; const templateCategory = args[0];
deps.verboseLogger(`Template list category: ${templateCategory}`); deps.io.verbose(`Template list category: ${templateCategory}`);
// If no category was provided to list, we assume its listing out the templates
if (!templateCategory) { if (!templateCategory) {
const templates = await deps.app.engine.listImportedTemplates(); const templates = await deps.app.engine.listImportedTemplates();
const formattedTemplates = templates.map((template: XOTemplate) => `${bold(generateTemplateIdentifier(template))} - ${dim(template.name)} ${dim(template.description)}`); const formattedTemplates = templates.map(
console.log(formattedTemplates.join('\n')); (template: XOTemplate) =>
return; `${bold(generateTemplateIdentifier(template))} - ${dim(template.name)} ${dim(template.description)}`,
);
deps.io.out(formattedTemplates.join("\n"));
return { count: templates.length };
} }
// Extract the template identifier from the positional args
const templateIdentifier = args[1]; const templateIdentifier = args[1];
deps.verboseLogger(`Template identifier: ${templateIdentifier}`); deps.io.verbose(`Template identifier: ${templateIdentifier}`);
if (!templateIdentifier) { if (!templateIdentifier) {
console.error("No template identifier provided"); deps.io.err("No template identifier provided");
return; throw new CommandError(
"template.list.identifier_missing",
"No template identifier provided",
);
} }
// Get the template from the engine
const rawTemplate = await deps.app.engine.getTemplate(templateIdentifier); const rawTemplate = await deps.app.engine.getTemplate(templateIdentifier);
if (!rawTemplate) { if (!rawTemplate) {
console.error(`No template found: ${templateIdentifier}`); deps.io.err(`No template found: ${templateIdentifier}`);
return; throw new CommandError(
"template.list.not_found",
`No template found: ${templateIdentifier}`,
);
} }
// Resolve the template references
const template = await resolveTemplateReferences(rawTemplate); const template = await resolveTemplateReferences(rawTemplate);
deps.verboseLogger(`Template: ${formatObject(template)}`); deps.io.verbose(`Template: ${formatObject(template)}`);
// List the templates in the category
switch (templateCategory) { switch (templateCategory) {
case "action": { case "action": {
const actions = template.actions; const actions = template.actions;
const formattedActions = Object.entries(actions).map(([actionIdentifier, action]) => `${bold(actionIdentifier)} ${dim(action.name)} ${dim(action.description)}`); const formattedActions = Object.entries(actions).map(
console.log(formattedActions.join('\n')); ([actionIdentifier, action]) =>
break; `${bold(actionIdentifier)} ${dim(action.name)} ${dim(action.description)}`,
);
deps.io.out(formattedActions.join("\n"));
return {};
} }
case "transaction": { case "transaction": {
const transactions = template.transactions; const transactions = template.transactions;
const formattedTransactions = Object.entries(transactions).map(([transactionIdentifier, transaction]) => `${bold(transactionIdentifier)} ${dim(transaction.name)} ${dim(transaction.description)}`); const formattedTransactions = Object.entries(transactions).map(
console.log(formattedTransactions.join('\n')); ([transactionIdentifier, transaction]) =>
break; `${bold(transactionIdentifier)} ${dim(transaction.name)} ${dim(transaction.description)}`,
);
deps.io.out(formattedTransactions.join("\n"));
return {};
} }
case "output": { case "output": {
const outputs = template.outputs; const outputs = template.outputs;
const formattedOutputs = Object.entries(outputs).map(([outputIdentifier, output]) => `${bold(outputIdentifier)} ${dim(output.name)} ${dim(output.description)}`); const formattedOutputs = Object.entries(outputs).map(
console.log(formattedOutputs.join('\n')); ([outputIdentifier, output]) =>
break; `${bold(outputIdentifier)} ${dim(output.name)} ${dim(output.description)}`,
);
deps.io.out(formattedOutputs.join("\n"));
return {};
} }
case "lockingscript": { case "lockingscript": {
const lockingscripts = template.lockingScripts; const lockingscripts = template.lockingScripts;
const formattedLockingscripts = Object.entries(lockingscripts).map(([lockingScriptIdentifier, lockingScript]) => `${bold(lockingScriptIdentifier)} ${dim(lockingScript.name)} ${dim(lockingScript.description)}`); const formattedLockingscripts = Object.entries(lockingscripts).map(
console.log(formattedLockingscripts.join('\n')); ([lockingScriptIdentifier, lockingScript]) =>
break; `${bold(lockingScriptIdentifier)} ${dim(lockingScript.name)} ${dim(lockingScript.description)}`,
);
deps.io.out(formattedLockingscripts.join("\n"));
return {};
} }
case "variable": { case "variable": {
const variables = template.variables || {}; const variables = template.variables || {};
const formattedVariables = Object.entries(variables).map(([variableIdentifier, variable]) => `${bold(variableIdentifier)} ${dim(variable.name)} ${dim(variable.description)}`); const formattedVariables = Object.entries(variables).map(
console.log(formattedVariables.join('\n')); ([variableIdentifier, variable]) =>
break; `${bold(variableIdentifier)} ${dim(variable.name)} ${dim(variable.description)}`,
);
deps.io.out(formattedVariables.join("\n"));
return {};
} }
default: { default: {
deps.verboseLogger(`Unknown template category: ${templateCategory}`); deps.io.verbose(`Unknown template category: ${templateCategory}`);
return; throw new CommandError(
} "template.list.category_unknown",
`Unknown template category: ${templateCategory}`,
);
} }
} }
};
/** /**
* Prints the help message for the template inspect command * Prints the help message for the template inspect command
*/ */
export const printTemplateInspectHelp = () => { export const printTemplateInspectHelp = (io: CommandIO): void => {
console.log( io.out(
` `
${bold("Usage:")} xo-cli template inspect <category> <identifier> <field> ${bold("Usage:")} xo-cli template inspect <category> <identifier> <field>
@@ -119,158 +148,206 @@ ${bold("Categories:")}
- output <output-identifier> ${dim("Inspect an output")} - output <output-identifier> ${dim("Inspect an output")}
- lockingscript <lockingscript-identifier> ${dim("Inspect a lockingscript")} - lockingscript <lockingscript-identifier> ${dim("Inspect a lockingscript")}
- variable <variable-identifier> ${dim("Inspect a variable")} - variable <variable-identifier> ${dim("Inspect a variable")}
`); `,
);
}; };
/** /**
* Handles the template inspect command. * Handles the template inspect command.
* Throws CommandError on failure, returns empty object on success.
* @param deps - The command dependencies. * @param deps - The command dependencies.
* @param args - Positional args after the command name, e.g. ["inspect", "transaction", "1234567890"]. * @param args - Positional args after the command name, e.g. ["inspect", "transaction", "1234567890"].
*/ */
export const handleTemplateInspectCommand = async (deps: CommandDependencies, args: string[]): Promise<void> => { export const handleTemplateInspectCommand = async (
deps: CommandDependencies,
args: string[],
): Promise<Record<string, never>> => {
const templateCategory = args[0]; const templateCategory = args[0];
const templateIdentifier = args[1]; const templateQuery = args[1];
const templateField = args[2]; const templateField = args[2];
deps.verboseLogger(`Template inspect args - category: ${templateCategory}, identifier: ${templateIdentifier}, field: ${templateField}`); deps.io.verbose(
`Template inspect args - category: ${templateCategory}, identifier: ${templateQuery}, field: ${templateField}`,
);
if (!templateCategory || !templateIdentifier || !templateField) { if (!templateCategory || !templateQuery || !templateField) {
console.log("No template category, identifier, or field provided"); deps.io.err("No template category, identifier, or field provided");
printTemplateInspectHelp(); printTemplateInspectHelp(deps.io);
return; throw new CommandError(
"template.inspect.arguments_missing",
"No template category, identifier, or field provided",
);
} }
// Get the template from the engine const originalTemplate = await resolveTemplate(deps, templateQuery);
const rawTemplate = await deps.app.engine.getTemplate(templateIdentifier); deps.io.verbose(`Original Template: ${formatObject(originalTemplate)}`);
if (!rawTemplate) {
console.error(`No template found: ${templateIdentifier}`);
return;
}
// Resolve the template references const template = await resolveTemplateReferences(originalTemplate);
const template = await resolveTemplateReferences(rawTemplate); deps.io.verbose(`Extended Template: ${formatObject(template)}`);
deps.verboseLogger(`Template: ${formatObject(template)}`);
// Inspect the template in the category
switch (templateCategory) { switch (templateCategory) {
case "action": { case "action": {
const action = template.actions[templateField]; const action = template.actions[templateField];
if (!action) { if (!action) {
console.error(`No action found: ${templateField}`); deps.io.err(`No action found: ${templateField}`);
return; throw new CommandError(
"template.inspect.action_missing",
`No action found: ${templateField}`,
);
} }
objectPrint(action); deps.io.out(formatObject(action));
break; return {};
} }
case "transaction": { case "transaction": {
const transaction = template.transactions[templateField]; const transaction = template.transactions?.[templateField];
if (!transaction) { if (!transaction) {
console.error(`No transaction found: ${templateField}`); deps.io.err(`No transaction found: ${templateField}`);
return; throw new CommandError(
"template.inspect.transaction_missing",
`No transaction found: ${templateField}`,
);
} }
objectPrint(transaction); deps.io.out(formatObject(transaction));
break; return {};
} }
case "output": { case "output": {
const output = template.outputs[templateField]; const output = template.outputs[templateField];
if (!output) { if (!output) {
console.error(`No output found: ${templateField}`); deps.io.err(`No output found: ${templateField}`);
return; throw new CommandError(
"template.inspect.output_missing",
`No output found: ${templateField}`,
);
} }
objectPrint(output); deps.io.out(formatObject(output));
break; return {};
} }
case "lockingscript": { case "lockingscript": {
const lockingscript = template.lockingScripts[templateField]; const lockingscript = template.lockingScripts[templateField];
if (!lockingscript) { if (!lockingscript) {
console.error(`No lockingscript found: ${templateField}`); deps.io.err(`No lockingscript found: ${templateField}`);
return; throw new CommandError(
"template.inspect.lockingscript_missing",
`No lockingscript found: ${templateField}`,
);
} }
objectPrint(lockingscript); deps.io.out(formatObject(lockingscript));
break; return {};
} }
case "variable": { case "variable": {
const variable = template.variables?.[templateField]; const variable = template.variables?.[templateField];
if (!variable) { if (!variable) {
console.error(`No variable found: ${templateField}`); deps.io.err(`No variable found: ${templateField}`);
return; throw new CommandError(
"template.inspect.variable_missing",
`No variable found: ${templateField}`,
);
} }
objectPrint(variable); deps.io.out(formatObject(variable));
break; return {};
} }
default: { default: {
deps.verboseLogger(`Unknown template category: ${templateCategory}`); deps.io.verbose(`Unknown template category: ${templateCategory}`);
return; throw new CommandError(
} "template.inspect.category_unknown",
`Unknown template category: ${templateCategory}`,
);
} }
} }
};
/** /**
* Handles the template command. * Handles the template command.
* Throws CommandError on failure, returns result data on success.
* @param deps - The command dependencies. * @param deps - The command dependencies.
* @param args - Positional args after the command name, e.g. ["import", "template.json"] or ["set-default", "tpl", "out", "role"]. * @param args - Positional args after the command name, e.g. ["import", "template.json"] or ["set-default", "tpl", "out", "role"].
* @param options - Parsed option flags. * @param options - Parsed option flags.
*/ */
export const handleTemplateCommand = async (deps: CommandDependencies, args: string[], options: Record<string, string>): Promise<void> => { export const handleTemplateCommand = async (
deps: CommandDependencies,
args: string[],
_options: Record<string, string>,
): Promise<{ templateFile?: string; count?: number }> => {
const subCommand = args[0]; const subCommand = args[0];
if (!subCommand) { if (!subCommand) {
deps.verboseLogger("No sub-command provided"); deps.io.verbose("No sub-command provided");
printTemplateHelp(); printTemplateHelp(deps.io);
return; throw new CommandError(
"template.subcommand.missing",
"No sub-command provided",
);
} }
switch (subCommand) { switch (subCommand) {
case "import": { case "import": {
const templateFile = args[1]; const templateFile = args[1];
deps.verboseLogger(`Template file: ${templateFile}`); deps.io.verbose(`Template file: ${templateFile}`);
if (!templateFile) { if (!templateFile) {
deps.verboseLogger("No template file provided"); deps.io.verbose("No template file provided");
printTemplateHelp(); printTemplateHelp(deps.io);
return; throw new CommandError(
"template.import.file_missing",
"No template file provided",
);
} }
const templatePath = path.resolve(`${process.cwd()}/${templateFile}`); const templatePath = path.resolve(`${process.cwd()}/${templateFile}`);
deps.verboseLogger(`Template path: ${templatePath}`); deps.io.verbose(`Template path: ${templatePath}`);
if (!existsSync(templatePath)) { if (!existsSync(templatePath)) {
console.error(`Template file does not exist: ${templatePath}`); deps.io.err(`Template file does not exist: ${templatePath}`);
printTemplateHelp(); printTemplateHelp(deps.io);
return; throw new CommandError(
"template.import.file_not_found",
`Template file does not exist: ${templatePath}`,
);
} }
const template = await readFileSync(templatePath, "utf8"); const template = await readFileSync(templatePath, "utf8");
deps.verboseLogger(`Importing template: ${templateFile}`); deps.io.verbose(`Importing template: ${templateFile}`);
await deps.app.engine.importTemplate(template); await deps.app.engine.importTemplate(template);
deps.verboseLogger(`Template imported: ${templateFile}`); deps.io.verbose(`Template imported: ${templateFile}`);
break; return { templateFile };
} }
case "list": { case "list": {
await handleTemplateListCommand(deps, args.slice(1)); return handleTemplateListCommand(deps, args.slice(1));
break;
} }
case "inspect": { case "inspect": {
await handleTemplateInspectCommand(deps, args.slice(1)); return handleTemplateInspectCommand(deps, args.slice(1));
break;
} }
case "set-default": { case "set-default": {
const templateFile = args[1]; const templateFile = args[1];
const outputIdentifier = args[2]; const outputIdentifier = args[2];
const roleIdentifier = args[3]; const roleIdentifier = args[3];
if (!templateFile || !outputIdentifier || !roleIdentifier) { if (!templateFile || !outputIdentifier || !roleIdentifier) {
deps.verboseLogger("No template file, output identifier, or role identifier provided"); deps.io.verbose(
printTemplateHelp(); "No template file, output identifier, or role identifier provided",
return; );
printTemplateHelp(deps.io);
throw new CommandError(
"template.default.arguments_missing",
"No template file, output identifier, or role identifier provided",
);
} }
deps.verboseLogger(`Template file: ${templateFile}, output identifier: ${outputIdentifier}, role identifier: ${roleIdentifier}`); deps.io.verbose(
await deps.app.engine.setDefaultLockingParameters(templateFile, outputIdentifier, roleIdentifier); `Template file: ${templateFile}, output identifier: ${outputIdentifier}, role identifier: ${roleIdentifier}`,
break; );
await deps.app.engine.setDefaultLockingParameters(
templateFile,
outputIdentifier,
roleIdentifier,
);
return {};
} }
default: default:
deps.verboseLogger(`Unknown template sub-command: ${subCommand}`); deps.io.verbose(`Unknown template sub-command: ${subCommand}`);
printTemplateHelp(); printTemplateHelp(deps.io);
return; throw new CommandError(
"template.subcommand.unknown",
`Unknown template sub-command: ${subCommand}`,
);
} }
}; };

View File

@@ -1,6 +1,59 @@
import type { AppService } from "../../services/app.js"; import type { AppService } from "../../services/app.js";
export type CommandDependencies = { /**
verboseLogger: (message: string) => void; * IO contract for CLI command handlers.
* Handlers write user-visible output through this abstraction so unit tests can
* assert behavior without spying on global console methods.
*/
export type CommandIO = {
out: (message: string) => void;
err: (message: string) => void;
verbose: (message: string) => void;
};
/**
* Paths configuration for CLI commands.
* Allows injection of custom paths for testing.
*/
export type CommandPaths = {
/** Directory for mnemonic wallet files */
mnemonicsDir: string;
/** Directory for engine DB and invitation storage files */
dataDir: string;
/** File storing the last-used mnemonic reference */
walletConfigPath: string;
/** Working directory for file output (invitation files, etc.) */
workingDir: string;
};
/**
* Base dependencies available to every command handler.
*/
export type BaseCommandDependencies = {
io: CommandIO;
paths: CommandPaths;
};
/**
* Dependencies for app-backed commands.
*/
export type CommandDependencies = BaseCommandDependencies & {
app: AppService; app: AppService;
}; };
/**
* Custom error class for command failures.
* Thrown by command handlers when an operation fails.
* The `event` property can be used for telemetry/testing to identify failure types.
*/
export class CommandError extends Error {
public readonly event: string;
public readonly code: number;
constructor(event: string, message: string, code = 1) {
super(message);
this.name = "CommandError";
this.event = event;
this.code = code;
}
}

View File

@@ -1,204 +0,0 @@
/**
* Shell completion script generation.
*
* Defines the CLI command tree in one place and generates
* bash/zsh/fish completion scripts from it. Users source the output
* in their shell profile for tab-completion support.
*
* Usage:
* eval "$(xo-cli completions bash)"
* eval "$(xo-cli completions zsh)"
* xo-cli completions fish | source
*/
/**
* Single source of truth for the CLI command tree.
* Each top-level key is a command, and its value is an array of sub-commands.
*/
export const COMMAND_TREE: Record<string, string[]> = {
mnemonic: ["create", "import", "list"],
template: ["import", "list", "set-default"],
invitation: ["create", "import", "list"],
receive: [],
resource: ["list"],
help: [],
completions: ["bash", "zsh", "fish"],
};
/** Global option flags available on every command. */
const GLOBAL_OPTIONS = ["-h", "--help", "-v", "--verbose", "-m", "--mnemonic-file", "-o", "--output"];
/**
* Generates a bash completion script.
* @param binName - The name of the CLI binary (used in the `complete` registration).
*/
export function generateBashCompletions(binName: string): string {
const commands = Object.keys(COMMAND_TREE).join(" ");
const options = GLOBAL_OPTIONS.join(" ");
// Build the case arms for each command's sub-commands
const caseArms = Object.entries(COMMAND_TREE)
.filter(([, subs]) => subs.length > 0)
.map(([cmd, subs]) => ` ${cmd})\n COMPREPLY=($(compgen -W "${subs.join(" ")}" -- "\${cur}"))\n return 0\n ;;`)
.join("\n");
return `# bash completion for ${binName}
# Add to ~/.bashrc: eval "$(${binName} completions bash)"
_${binName.replace(/-/g, "_")}_completions() {
local cur prev words cword
_init_completion || return
# If the current word starts with "-", offer option flags
if [[ "\${cur}" == -* ]]; then
COMPREPLY=($(compgen -W "${options}" -- "\${cur}"))
return 0
fi
# Find the command (first non-option positional arg after the binary)
local cmd=""
for ((i=1; i < cword; i++)); do
if [[ "\${words[i]}" != -* ]]; then
cmd="\${words[i]}"
break
fi
done
# No command yet — offer the top-level commands
if [[ -z "\${cmd}" ]]; then
COMPREPLY=($(compgen -W "${commands}" -- "\${cur}"))
return 0
fi
# Offer sub-commands for the matched command
case "\${cmd}" in
${caseArms}
esac
}
complete -F _${binName.replace(/-/g, "_")}_completions ${binName}
`;
}
/**
* Generates a zsh completion script.
* @param binName - The name of the CLI binary.
*/
export function generateZshCompletions(binName: string): string {
const commands = Object.keys(COMMAND_TREE).join(" ");
const options = GLOBAL_OPTIONS.join(" ");
const caseArms = Object.entries(COMMAND_TREE)
.filter(([, subs]) => subs.length > 0)
.map(([cmd, subs]) => ` ${cmd})\n compadd -- ${subs.join(" ")}\n ;;`)
.join("\n");
return `# zsh completion for ${binName}
# Add to ~/.zshrc: eval "$(${binName} completions zsh)"
_${binName.replace(/-/g, "_")}_completions() {
local -a commands
commands=(${commands})
# If typing an option flag, complete options
if [[ "\${words[\${CURRENT}]}" == -* ]]; then
compadd -- ${options}
return
fi
# Find the command (first non-option positional arg)
local cmd=""
for ((i=2; i < CURRENT; i++)); do
if [[ "\${words[i]}" != -* ]]; then
cmd="\${words[i]}"
break
fi
done
# No command yet — offer top-level commands
if [[ -z "\${cmd}" ]]; then
compadd -- \${commands[@]}
return
fi
# Offer sub-commands
case "\${cmd}" in
${caseArms}
esac
}
compdef _${binName.replace(/-/g, "_")}_completions ${binName}
`;
}
/**
* Generates a fish completion script.
* @param binName - The name of the CLI binary.
*/
export function generateFishCompletions(binName: string): string {
const lines: string[] = [
`# fish completion for ${binName}`,
`# Add to fish config: ${binName} completions fish | source`,
"",
`# Disable file completions by default`,
`complete -c ${binName} -f`,
"",
];
// Global options
for (const opt of GLOBAL_OPTIONS) {
const isShort = !opt.startsWith("--");
const flag = opt.replace(/^-+/, "");
if (isShort) {
lines.push(`complete -c ${binName} -s ${flag} -d "Option flag"`);
} else {
lines.push(`complete -c ${binName} -l ${flag} -d "Option flag"`);
}
}
lines.push("");
// Top-level commands (only when no sub-command is given yet)
const commandNames = Object.keys(COMMAND_TREE);
for (const cmd of commandNames) {
lines.push(`complete -c ${binName} -n "__fish_use_subcommand" -a "${cmd}" -d "${cmd} command"`);
}
lines.push("");
// Sub-commands for each command
for (const [cmd, subs] of Object.entries(COMMAND_TREE)) {
for (const sub of subs) {
lines.push(`complete -c ${binName} -n "__fish_seen_subcommand_from ${cmd}" -a "${sub}" -d "${cmd} ${sub}"`);
}
}
return lines.join("\n") + "\n";
}
type ShellType = "bash" | "zsh" | "fish";
const generators: Record<ShellType, (binName: string) => string> = {
bash: generateBashCompletions,
zsh: generateZshCompletions,
fish: generateFishCompletions,
};
/**
* Handles the `completions` command.
* Prints the generated completion script for the given shell to stdout.
* @param args - Positional args after "completions", e.g. ["bash"].
* @param binName - The CLI binary name to use in the completion script.
*/
export function handleCompletionsCommand(args: string[], binName: string = "xo-cli"): void {
const shell = args[0] as ShellType | undefined;
if (!shell || !generators[shell]) {
const supported = Object.keys(generators).join(", ");
console.error(`Usage: ${binName} completions <${supported}>`);
console.error("");
console.error("Examples:");
console.error(` eval "$(${binName} completions bash)" # Add to ~/.bashrc`);
console.error(` eval "$(${binName} completions zsh)" # Add to ~/.zshrc`);
console.error(` ${binName} completions fish | source # Add to fish config`);
process.exit(1);
}
process.stdout.write(generators[shell](binName));
}

View File

@@ -1,3 +1,4 @@
#!/usr/bin/env node
/** /**
* CLI entry point. * CLI entry point.
* *
@@ -35,17 +36,23 @@
*/ */
import { existsSync, readFileSync, writeFileSync } from "fs"; import { existsSync, readFileSync, writeFileSync } from "fs";
import { join } from "path";
import { AppService } from "../services/app.js"; import { AppService } from "../services/app.js";
import { convertArgsToObject } from "./arguments.js"; import { convertArgsToObject } from "./arguments.js";
import { bold, dim, formatObject } from "./cli-utils.js"; import { bold, dim, formatObject } from "./utils.js";
import { listMnemonicFiles, loadMnemonic } from "./mnemonic.js"; import { listGlobalMnemonicFiles, loadMnemonic } from "./mnemonic.js";
import {
/** File that remembers the last-used mnemonic so `-m` can be omitted. */ getDataDir,
const WALLET_CONFIG_FILE = ".xo-cli-wallet"; getMnemonicsDir,
getWalletConfigPath,
} from "../utils/paths.js";
import { import {
type CommandDependencies, type CommandDependencies,
type CommandIO,
type CommandPaths,
CommandError,
handleMnemonicCommand, handleMnemonicCommand,
handleTemplateCommand, handleTemplateCommand,
handleInvitationCommand, handleInvitationCommand,
@@ -53,15 +60,19 @@ import {
handleResourceCommand, handleResourceCommand,
} from "./commands/index.js"; } from "./commands/index.js";
import { handleCompletionsCommand } from "./completions.js"; import { handleCompletionsCommand } from "./autocomplete/completions.js";
const createConditionalLogger = (verbose: boolean) => { const createCommandIO = (verbose: boolean): CommandIO => ({
return (message: string) => { out: (message: string) => {
if (verbose) {
console.log(message); console.log(message);
} },
}; err: (message: string) => {
}; console.error(message);
},
verbose: (message: string) => {
if (verbose) console.log(message);
},
});
/** /**
* Main entry point. * Main entry point.
@@ -79,106 +90,161 @@ async function main(): Promise<void> {
const { args, options } = convertArgsToObject(process.argv.slice(2)); const { args, options } = convertArgsToObject(process.argv.slice(2));
// Create a verbose logger if the user set the verbose flag // Create a verbose logger if the user set the verbose flag
const verboseLogger = createConditionalLogger(options["verbose"] === "true"); const io = createCommandIO(options["verbose"] === "true");
// Log the parsed app args // Log the parsed app args
verboseLogger(`Parsed args: ${formatObject(args)}`); io.verbose(`Parsed args: ${formatObject(args)}`);
verboseLogger(`Parsed options: ${formatObject(options)}`); io.verbose(`Parsed options: ${formatObject(options)}`);
// Handle the command // Handle the command
const command = args[0]; const command = args[0];
verboseLogger(`Command: ${command}`); io.verbose(`Command: ${command}`);
if (!command) { if (!command) {
// TODO: Print help, probably... // TODO: Print help, probably...
console.error("No command provided"); io.err("No command provided");
process.exit(1); process.exit(1);
} }
// Positional args after the command name (sub-command, files, etc.) // Positional args after the command name (sub-command, files, etc.)
const subArgs = args.slice(1); const subArgs = args.slice(1);
// Early handling if we are calling the mnemonic command // Build paths object from global path functions
// TODO: This is ugly. I would like to find a nicer way of doing this. const paths: CommandPaths = {
mnemonicsDir: getMnemonicsDir(),
dataDir: getDataDir(),
walletConfigPath: getWalletConfigPath(),
workingDir: process.cwd(),
};
// Early handling for completions command
if (command === "completions") { if (command === "completions") {
handleCompletionsCommand(subArgs); handleCompletionsCommand(subArgs, options);
return; process.exit(0);
} }
if (command === "mnemonic") { if (command === "mnemonic") {
await handleMnemonicCommand({ verboseLogger }, subArgs, options); try {
return; await handleMnemonicCommand({ io, paths }, subArgs, options);
process.exit(0);
} catch (error) {
if (error instanceof CommandError) {
process.exit(error.code);
}
throw error;
}
} }
// Resolve mnemonic file: explicit flag > persisted config > error. // Resolve mnemonic file: explicit flag > persisted config > error.
let mnemonicFile = options["mnemonicFile"]; let mnemonicFile = options["mnemonicFile"];
if (!mnemonicFile && existsSync(WALLET_CONFIG_FILE)) { if (!mnemonicFile && existsSync(paths.walletConfigPath)) {
mnemonicFile = readFileSync(WALLET_CONFIG_FILE, "utf8").trim(); mnemonicFile = readFileSync(paths.walletConfigPath, "utf8").trim();
verboseLogger(`Using persisted wallet: ${mnemonicFile}`); io.verbose(`Using persisted wallet: ${mnemonicFile}`);
} }
if (!mnemonicFile) { if (!mnemonicFile) {
console.error("No mnemonic file provided"); io.err("No mnemonic file provided");
console.log(`You can create a mnemonic file with the following command: xo-cli mnemonic create <mnemonic-seed> or use one of the following files: \n${listMnemonicFiles().join("\n")}`); io.out(
console.log(`\nTip: pass -m <file> once and it will be remembered in ${WALLET_CONFIG_FILE}`); `You can create a mnemonic file with the following command: xo-cli mnemonic create <mnemonic-seed> or use one of the following files: \n${listGlobalMnemonicFiles().join("\n")}`,
);
io.out(
`\nTip: pass -m <file> once and it will be remembered in ${paths.walletConfigPath}`,
);
process.exit(1); process.exit(1);
} }
// Persist the choice so subsequent commands can omit -m. // Persist the choice so subsequent commands can omit -m.
writeFileSync(WALLET_CONFIG_FILE, mnemonicFile); writeFileSync(paths.walletConfigPath, mnemonicFile);
const mnemonic = await loadMnemonic(mnemonicFile); const mnemonic = loadMnemonic(paths.mnemonicsDir, mnemonicFile);
verboseLogger(`Loaded mnemonic from file: ${mnemonicFile} ${mnemonic}`); io.verbose(`Loaded mnemonic from file: ${mnemonicFile} ${mnemonic}`);
// Create an App instance // Create an App instance
verboseLogger("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"] ?? "./", databasePath: options["databasePath"] ?? paths.dataDir,
databaseFilename: options["databaseFilename"] ?? 'xo-wallet.db', databaseFilename: options["databaseFilename"] ?? "xo-wallet.db",
}, },
invitationStoragePath: options["invitationStoragePath"] ?? "./xo-invitations.db", invitationStoragePath:
options["invitationStoragePath"] ??
join(paths.dataDir, "xo-invitations.db"),
}); });
verboseLogger("App instance created"); io.verbose("App instance created");
// Start the app // Start the app
verboseLogger("Starting app..."); // TODO: Rethink this. Do we really want to start the app here? It just slows it down if we dont actually have to have it started for the command
io.verbose("Starting app...");
await app.start(); await app.start();
verboseLogger("App started"); io.verbose("App started");
const commandDependencies: CommandDependencies = { const commandDependencies: CommandDependencies = {
verboseLogger, io,
paths,
app, app,
}; };
// Handle the command // Handle the command
try {
let result: unknown;
switch (command) { switch (command) {
case "template": case "template":
await handleTemplateCommand(commandDependencies, subArgs, options); result = await handleTemplateCommand(
commandDependencies,
subArgs,
options,
);
break; break;
case "invitation": case "invitation":
await handleInvitationCommand(commandDependencies, subArgs, options); result = await handleInvitationCommand(
commandDependencies,
subArgs,
options,
);
break; break;
case "receive": case "receive":
await handleReceiveCommand(commandDependencies, subArgs, options); result = await handleReceiveCommand(
commandDependencies,
subArgs,
options,
);
break; break;
case "resource": case "resource":
await handleResourceCommand(commandDependencies, subArgs, options); result = await handleResourceCommand(
commandDependencies,
subArgs,
options,
);
break; break;
case "help": case "help":
await handleHelpCommand(commandDependencies, subArgs, options); result = await handleHelpCommand(commandDependencies, subArgs, options);
break; break;
default: default:
console.error(`Unknown command: ${command}`); io.err(`Unknown command: ${command}`);
process.exit(1); throw new CommandError(
"cli.command.unknown",
`Unknown command: ${command}`,
);
} }
// Exit the process // console.log(result);
// objectPrint(result);
process.exit(0); process.exit(0);
} catch (error) {
if (error instanceof CommandError) {
io.err(error.message);
process.exit(error.code);
}
throw error;
}
} }
const handleHelpCommand = async (deps: CommandDependencies, args: string[], options: Record<string, string>): Promise<void> => { const handleHelpCommand = async (
// Im sorry about the formatting here. I'm not sure how to handle this better. deps: CommandDependencies,
console.log( _args: string[],
_options: Record<string, string>,
): Promise<Record<string, never>> => {
deps.io.out(
`${bold("XO-CLI Help:")} `${bold("XO-CLI Help:")}
${bold("Usage:")} xo-cli <command> [options] ${bold("Usage:")} xo-cli <command> [options]
@@ -190,12 +256,14 @@ Commands:
receive ${dim("Generate a single-use receiving address")} receive ${dim("Generate a single-use receiving address")}
resource ${dim("Manage resources")} resource ${dim("Manage resources")}
completions ${dim("Generate shell completion scripts (bash, zsh, fish)")} completions ${dim("Generate shell completion scripts (bash, zsh, fish)")}
help ${dim("Show this help message")}
Options: Options:
-h, --help ${dim("Show this help message")} -h, --help ${dim("Show this help message")}
-m, --mnemonic-file <mnemonic-file> ${dim("Use a specific mnemonic file")} -m, --mnemonic-file <mnemonic-file> ${dim("Use a specific mnemonic file")}
-v, --verbose ${dim("Show verbose output")}` -v, --verbose ${dim("Show verbose output")}`,
); );
return {};
}; };
main().catch((error) => { main().catch((error) => {

View File

@@ -1,72 +1,123 @@
import { readdirSync, readFileSync, writeFileSync } from "node:fs"; import { existsSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
import { BCHMnemonicURL, } from "../utils/bch-mnemonic-url.js"; import { basename, isAbsolute, join, resolve } from "node:path";
import { encodeBip39Mnemonic, generateBip39Mnemonic } from "@bitauth/libauth"; import { encodeBip39Mnemonic, generateBip39Mnemonic } from "@bitauth/libauth";
import { BCHMnemonicURL } from "../utils/bch-mnemonic-url.js";
import { getMnemonicsDir as getGlobalMnemonicsDir } from "../utils/paths.js";
/** /**
* Create a new mnemonic seed phrase * Create a new mnemonic seed phrase
*/ */
export const createMnemonicSeed = (): string => { export const createMnemonicSeed = (): string => {
// Generate a new mnemonic seed return generateBip39Mnemonic();
const mnemonic = generateBip39Mnemonic();
// Return the mnemonic phrase
return mnemonic;
}; };
/** /**
* Creates a mnemonic file from a mnemonic seed * Creates a mnemonic file from a mnemonic seed
* @param mnemonicsDir - Directory to store mnemonic files
* @param mnemonic - The mnemonic seed * @param mnemonic - The mnemonic seed
* @param outputFilename - The filename to write the mnemonic to. If not provided, the first word from the mnemonic will be used as the filename * @param outputFilename - The filename to write the mnemonic to. If not provided, the first word from the mnemonic will be used as the filename
* @returns The filename of the created mnemonic file * @returns The filename of the created mnemonic file
*/ */
export const createMnemonicFile = (mnemonic: string, outputFilename?: string): string => { export const createMnemonicFile = (
// Convert the mnemonic seed to a BCH Mnemonic URL mnemonicsDir: string,
mnemonic: string,
outputFilename?: string,
): string => {
const mnemonicUrl = BCHMnemonicURL.fromSeed(mnemonic); const mnemonicUrl = BCHMnemonicURL.fromSeed(mnemonic);
// If no output filename is provided, use the first word from the mnemonic as the filename
let fileName = outputFilename; let fileName = outputFilename;
if (!fileName) { if (!fileName) {
const firstWord = mnemonic.at(0)?.toLowerCase(); const firstWord = mnemonic.split(" ")[0]?.toLowerCase();
if (!firstWord) { if (!firstWord) {
throw new Error("Failed to create mnemonic file: Unable to extract first word from the mnemonic"); throw new Error(
"Failed to create mnemonic file: Unable to extract first word from the mnemonic",
);
} }
fileName = `mnemonic-${firstWord}`; fileName = `mnemonic-${firstWord}`;
} }
// Write the mnemonic URL to a file const safeName = basename(fileName);
// TODO: May need PWD or something to ensure we are writing to the correct directory const outPath = join(mnemonicsDir, safeName);
writeFileSync(fileName, mnemonicUrl.toURL()); writeFileSync(outPath, mnemonicUrl.toURL());
return fileName; return safeName;
};
/**
* Resolves a mnemonic reference to an absolute path.
* Order: absolute path if it exists → path relative to cwd → mnemonicsDir/<basename>.
*
* @param mnemonicsDir - Directory containing mnemonic files
* @param mnemonicRef - Path or basename (e.g. `mnemonic-nuclear`)
* @returns Absolute path to the mnemonic file
* @throws If no matching file exists
*/
export const resolveMnemonicFilePath = (
mnemonicsDir: string,
mnemonicRef: string,
): string => {
if (isAbsolute(mnemonicRef) && existsSync(mnemonicRef)) {
return mnemonicRef;
}
const relativeToCwd = resolve(process.cwd(), mnemonicRef);
if (existsSync(relativeToCwd)) {
return relativeToCwd;
}
const inMnemonics = join(mnemonicsDir, basename(mnemonicRef));
if (existsSync(inMnemonics)) {
return inMnemonics;
}
throw new Error(
`Mnemonic file not found: ${mnemonicRef}. Run "xo-cli mnemonic list" to see available files.`,
);
}; };
/** /**
* Loads a mnemonic from a mnemonic file * Loads a mnemonic from a mnemonic file
* @param mnemonicsDir - Directory containing mnemonic files
* @param mnemonicFile - The filename of the mnemonic file * @param mnemonicFile - The filename of the mnemonic file
* @returns The mnemonic seed * @returns The mnemonic seed
*/ */
export const loadMnemonic = (mnemonicFile: string): string => { export const loadMnemonic = (
const mnemonicUrl = BCHMnemonicURL.fromURL(readFileSync(mnemonicFile, "utf8")); mnemonicsDir: string,
mnemonicFile: string,
): string => {
const resolvedPath = resolveMnemonicFilePath(mnemonicsDir, mnemonicFile);
const mnemonicUrl = BCHMnemonicURL.fromURL(
readFileSync(resolvedPath, "utf8"),
);
const { entropy } = mnemonicUrl.toObject(); const { entropy } = mnemonicUrl.toObject();
// Convert the entropy to a mnemonic seed
const mnemonic = encodeBip39Mnemonic(entropy); const mnemonic = encodeBip39Mnemonic(entropy);
// If the conversion failed, throw an error
if (typeof mnemonic === "string") { if (typeof mnemonic === "string") {
throw new Error(`Failed to convert entropy to mnemonic: ${mnemonic}`); throw new Error(`Failed to convert entropy to mnemonic: ${mnemonic}`);
} }
// Return the mnemonic phrase
return mnemonic.phrase; return mnemonic.phrase;
}; };
/** /**
* Lists all mnemonic files in the current directory * Lists mnemonic files in the given directory.
* @returns An array of mnemonic file names * @param mnemonicsDir - Directory containing mnemonic files
* @returns Basenames suitable for `-m <name>`
*/ */
export const listMnemonicFiles = (): string[] => { export const listMnemonicFiles = (mnemonicsDir: string): string[] => {
const cwd = process.cwd(); const filenames = readdirSync(mnemonicsDir).filter((f: string) =>
const filenames = readdirSync(cwd).filter((f: string) => f.startsWith('mnemonic-')); f.startsWith("mnemonic-"),
);
return filenames; return filenames;
}; };
/**
* Lists mnemonic files using the global mnemonics directory.
* Convenience function for use in the CLI entry point before paths are resolved.
* @returns Basenames suitable for `-m <name>`
*/
export const listGlobalMnemonicFiles = (): string[] => {
return listMnemonicFiles(getGlobalMnemonicsDir());
};

106
src/cli/utils.ts Normal file
View File

@@ -0,0 +1,106 @@
import util from "node:util";
import type { XOTemplate } from "@xo-cash/types";
import { generateTemplateIdentifier } from "@xo-cash/engine";
import type { CommandDependencies } from "./commands/types.js";
import { CommandError } from "./commands/types.js";
/**
* Iterate through the templates, trying to match the id or the name with the given input.
* Use multiple for-loops.
* First, check the id of every template
* Then, check the name of every template. If multiple names match, throw an error.
* If no match is found, throw an error.
*
* @param deps - The command dependencies.
* @param query - The id or name of the template to resolve.
* @returns The template object.
* @throws CommandError if no template is found.
* @throws CommandError if multiple templates are found.
*/
export const resolveTemplate = async (
deps: CommandDependencies,
query: string,
): Promise<XOTemplate> => {
const templates = await deps.app.engine.listImportedTemplates();
const matches = new Set<XOTemplate>();
for (const template of templates) {
if (generateTemplateIdentifier(template) === query) {
return template;
}
}
for (const template of templates) {
if (template.name === query) {
matches.add(template);
}
}
if (matches.size > 1) {
throw new CommandError(
"template.resolve.multiple_matches",
`Multiple templates found for "${query}": ${Array.from(matches)
.map(
(template) =>
`${template.name} (${generateTemplateIdentifier(template)})`,
)
.join(", ")}`,
);
}
if (matches.size === 1) {
return matches.values().next().value!;
}
throw new CommandError(
"template.resolve.not_found",
`Template not found: ${query}`,
);
};
/**
* Text formatting utilities for the CLI.
*
* Uses ANSI escape codes to format text.
*
* AI Generated links:
* @see https://en.wikipedia.org/wiki/ANSI_escape_code
* @see https://en.wikipedia.org/wiki/ANSI_escape_code#SGR_(Select_Graphic_Rendition)_parameters
* @see https://en.wikipedia.org/wiki/ANSI_escape_code#Colors
* @see https://en.wikipedia.org/wiki/ANSI_escape_code#Formatting
* @see https://en.wikipedia.org/wiki/ANSI_escape_code#Cursor_movement
* @see https://en.wikipedia.org/wiki/ANSI_escape_code#Screen_manipulation
*/
const BOLD = "\x1b[1m";
export const bold = (text: string) => `${BOLD}${text}${RESET}`;
const DIM = "\x1b[2m";
export const dim = (text: string) => `${DIM}${text}${RESET}`;
const UNDERLINE = "\x1b[4m";
export const underline = (text: string) => `${UNDERLINE}${text}${RESET}`;
const INVERSE = "\x1b[7m";
export const inverse = (text: string) => `${INVERSE}${text}${RESET}`;
const HIDDEN = "\x1b[8m";
export const hidden = (text: string) => `${HIDDEN}${text}${RESET}`;
const STRIKETHROUGH = "\x1b[9m";
export const strikethrough = (text: string) =>
`${STRIKETHROUGH}${text}${RESET}`;
const RESET = "\x1b[0m";
export const reset = (text: string) => `${RESET}${text}${RESET}`;
export const formatObject = (obj: unknown) => {
return util.inspect(obj, {
depth: null,
colors: true,
compact: false,
});
};

View File

@@ -1,3 +1,4 @@
#!/usr/bin/env node
/** /**
* XO Wallet CLI - Terminal User Interface for XO crypto wallet. * XO Wallet CLI - Terminal User Interface for XO crypto wallet.
* *
@@ -9,18 +10,25 @@
* 5. Real-time updates via SSE * 5. Real-time updates via SSE
*/ */
import { join } from "node:path";
import { App } from "./app.js"; import { App } from "./app.js";
import { getDataDir } from "./utils/paths.js";
/** /**
* Main entry point. * Main entry point.
*/ */
async function main(): Promise<void> { async function main(): Promise<void> {
try { try {
const dataDir = getDataDir();
// Create and start the application // Create and start the application
await App.create({ await App.create({
syncServerUrl: process.env["SYNC_SERVER_URL"] ?? "http://localhost:3000", syncServerUrl: process.env["SYNC_SERVER_URL"] ?? "http://localhost:3000",
databasePath: process.env["DB_PATH"] ?? "./", databasePath: process.env["DB_PATH"] ?? dataDir,
databaseFilename: process.env["DB_FILENAME"] ?? "xo-wallet.db", databaseFilename: process.env["DB_FILENAME"] ?? "xo-wallet.db",
invitationStoragePath:
process.env["INVITATION_STORAGE_PATH"] ??
join(dataDir, "xo-invitations.db"),
}); });
} catch (error) { } catch (error) {
console.error("Failed to start XO Wallet CLI:", error); console.error("Failed to start XO Wallet CLI:", error);

View File

@@ -7,10 +7,11 @@ import {
import type { XOInvitation } from "@xo-cash/types"; import type { XOInvitation } from "@xo-cash/types";
import { Invitation } from "./invitation.js"; import { Invitation } from "./invitation.js";
import { Storage } from "./storage.js"; import { BaseStorage, Storage } from "./storage.js";
import { SyncServer } from "../utils/sync-server.js"; import { SyncServer } from "../utils/sync-server.js";
import { HistoryService } from "./history.js"; import { HistoryService } from "./history.js";
import { ElectrumService } from "./electrum.js"; import { type BlockchainService, ElectrumService } from "./electrum.js";
import { RatesService } from "./rates.js";
import { EventEmitter } from "../utils/event-emitter.js"; import { EventEmitter } from "../utils/event-emitter.js";
@@ -18,6 +19,7 @@ import { EventEmitter } from "../utils/event-emitter.js";
import { createHash } from "crypto"; import { createHash } from "crypto";
import { p2pkhTemplate } from "@xo-cash/templates"; import { p2pkhTemplate } from "@xo-cash/templates";
import { hexToBin } from "@bitauth/libauth"; import { hexToBin } from "@bitauth/libauth";
import { parseTemplate } from "@xo-cash/engine";
export type AppEventMap = { export type AppEventMap = {
"invitation-added": Invitation; "invitation-added": Invitation;
@@ -42,10 +44,11 @@ export interface AppConfig {
export class AppService extends EventEmitter<AppEventMap> { export class AppService extends EventEmitter<AppEventMap> {
public engine: Engine; public engine: Engine;
public storage: Storage; public storage: BaseStorage;
public config: AppConfig; public config: AppConfig;
public history: HistoryService; public history: HistoryService;
public electrum: ElectrumService; public electrum: BlockchainService;
public rates: RatesService;
public invitations: Invitation[] = []; public invitations: Invitation[] = [];
private invitationEventCleanup = new Map< private invitationEventCleanup = new Map<
@@ -74,14 +77,26 @@ export class AppService extends EventEmitter<AppEventMap> {
// Import the default P2PKH template // Import the default P2PKH template
const { templateIdentifier } = await engine.importTemplate(p2pkhTemplate); const { templateIdentifier } = await engine.importTemplate(p2pkhTemplate);
engine.subscribeToLockingBytecodesForTemplate(templateIdentifier).catch(err => console.error(`Error subscribing to locking bytecodes for template ${templateIdentifier}: ${err}`)); engine
engine.updateUnspentOutputsForTemplate(templateIdentifier).catch(err => console.error(`Error updating unspent outputs for template ${templateIdentifier}: ${err}`)); .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}`,
),
);
// Set default locking parameters for P2PKH // Set default locking parameters for P2PKH
// To my knowledge, this doesnt generate any lockscript, so discovery of funds will not work automatically. // To my knowledge, this doesnt generate any lockscript, so discovery of funds will not work automatically.
// TODO: Add discovery for funds in the first index? Or until we return 0 TXs? // TODO: Add discovery for funds in the first index? Or until we return 0 TXs?
await engine.setDefaultLockingParameters( await engine.setDefaultLockingParameters(
generateTemplateIdentifier(p2pkhTemplate), generateTemplateIdentifier(parseTemplate(p2pkhTemplate)),
"receiveOutput", "receiveOutput",
"receiver", "receiver",
); );
@@ -95,15 +110,17 @@ export class AppService extends EventEmitter<AppEventMap> {
host: config.electrumHost, host: config.electrumHost,
applicationIdentifier: config.electrumApplicationIdentifier, applicationIdentifier: config.electrumApplicationIdentifier,
}); });
const rates = await RatesService.create();
return new AppService(engine, walletStorage, config, electrum); return new AppService(engine, walletStorage, config, electrum, rates);
} }
constructor( constructor(
engine: Engine, engine: Engine,
storage: Storage, storage: BaseStorage,
config: AppConfig, config: AppConfig,
electrum: ElectrumService, electrum: BlockchainService,
rates: RatesService,
) { ) {
super(); super();
@@ -111,6 +128,7 @@ export class AppService extends EventEmitter<AppEventMap> {
this.storage = storage; this.storage = storage;
this.config = config; this.config = config;
this.electrum = electrum; this.electrum = electrum;
this.rates = rates;
this.history = new HistoryService(engine, this.invitations); this.history = new HistoryService(engine, this.invitations);
} }
@@ -209,10 +227,7 @@ export class AppService extends EventEmitter<AppEventMap> {
if (!trackedInvitation || !cleanup) return; if (!trackedInvitation || !cleanup) return;
trackedInvitation.off("invitation-updated", cleanup.onUpdated); trackedInvitation.off("invitation-updated", cleanup.onUpdated);
trackedInvitation.off( trackedInvitation.off("invitation-status-changed", cleanup.onStatusChanged);
"invitation-status-changed",
cleanup.onStatusChanged,
);
this.invitationEventCleanup.delete(invitationIdentifier); this.invitationEventCleanup.delete(invitationIdentifier);
} }
@@ -224,14 +239,14 @@ export class AppService extends EventEmitter<AppEventMap> {
*/ */
async unreserveAllResources(): Promise<number> { async unreserveAllResources(): Promise<number> {
const allUnspentOutputs = await this.engine.listUnspentOutputsData(); const allUnspentOutputs = await this.engine.listUnspentOutputsData();
const reserved = allUnspentOutputs.filter((o) => o.reserved); const reserved = allUnspentOutputs.filter((o) => o.reservedBy);
// Group by invitation identifier so the engine can clear them properly. // Group by invitation identifier so the engine can clear them properly.
const byInvitation = new Map<string, typeof reserved>(); const byInvitation = new Map<string, typeof reserved>();
for (const output of reserved) { for (const output of reserved) {
const existing = byInvitation.get(output.invitationIdentifier) ?? []; const existing = byInvitation.get(output.reservedBy!) ?? [];
existing.push(output); existing.push(output);
byInvitation.set(output.invitationIdentifier, existing); byInvitation.set(output.reservedBy!, existing);
} }
for (const [invitationIdentifier, outputs] of byInvitation) { for (const [invitationIdentifier, outputs] of byInvitation) {
@@ -248,6 +263,11 @@ export class AppService extends EventEmitter<AppEventMap> {
} }
async start(): Promise<void> { async start(): Promise<void> {
// Start rates in the background so BCH -> fiat conversions become reactive in the TUI.
this.rates.start().catch((err) =>
console.error('Error starting rates service:', err),
);
// Get the invitations db // Get the invitations db
const invitationsDb = this.storage.child("invitations"); const invitationsDb = this.storage.child("invitations");
@@ -259,7 +279,9 @@ export class AppService extends EventEmitter<AppEventMap> {
await Promise.all( await Promise.all(
invitations.map(async ({ key }) => { invitations.map(async ({ key }) => {
await this.createInvitation(key).catch(err => console.error(`Error creating invitation ${key}: ${err}`)); await this.createInvitation(key).catch((err) =>
console.error(`Error creating invitation ${key}: ${err}`),
);
}), }),
); );
} }

View File

@@ -8,6 +8,10 @@ export interface ElectrumServiceConfig {
applicationIdentifier?: string; applicationIdentifier?: string;
} }
export abstract class BlockchainService {
abstract hasSeenTransaction(transactionHash: string): Promise<boolean>;
}
/** /**
* Small Electrum adapter used by CLI services. * Small Electrum adapter used by CLI services.
* Keeps connection logic in one place and exposes a tiny API. * Keeps connection logic in one place and exposes a tiny API.

View File

@@ -1,5 +1,9 @@
import { binToHex } from "@bitauth/libauth"; import { binToHex } from "@bitauth/libauth";
import { compileCashAssemblyString, type Engine } from "@xo-cash/engine"; import {
compileCashAssemblyString,
type Engine,
listInvitationCommitsByEntity,
} from "@xo-cash/engine";
import type { UnspentOutputData } from "@xo-cash/state"; import type { UnspentOutputData } from "@xo-cash/state";
import type { import type {
XOInvitation, XOInvitation,
@@ -59,6 +63,7 @@ interface InvitationContext {
invitation: Invitation; invitation: Invitation;
template: XOTemplate | null; template: XOTemplate | null;
variables: Record<string, XOInvitationVariableValue>; variables: Record<string, XOInvitationVariableValue>;
walletCommits: XOInvitationCommit[];
walletEntityIdentifier?: string; walletEntityIdentifier?: string;
} }
@@ -73,12 +78,8 @@ export class HistoryService {
private invitations: Invitation[], private invitations: Invitation[],
) {} ) {}
async extractEntities(invitation: XOInvitation): Promise<string[]> { extractEntities(invitation: XOInvitation): Record<string, XOInvitationCommit[]> {
const entities = new Set<string>(); return listInvitationCommitsByEntity(invitation);
for (const commit of invitation.commits) {
entities.add(commit.entityIdentifier);
}
return Array.from(entities);
} }
// Entities are currently static per invitation. So, we can try to match the roles to entities by: // Entities are currently static per invitation. So, we can try to match the roles to entities by:
@@ -127,8 +128,6 @@ export class HistoryService {
async getHistory(): Promise<HistoryItem[]> { async getHistory(): Promise<HistoryItem[]> {
const allUtxos = await this.engine.listUnspentOutputsData(); const allUtxos = await this.engine.listUnspentOutputsData();
const ownOutpoints = new Set<string>();
const ownLockingBytecodes = new Set<string>();
const invitationByOrigin = new Map<string, UtxoOriginContext>(); const invitationByOrigin = new Map<string, UtxoOriginContext>();
const outpointValueSatoshis = new Map<string, bigint>(); const outpointValueSatoshis = new Map<string, bigint>();
@@ -137,8 +136,6 @@ export class HistoryService {
utxo.outpointTransactionHash, utxo.outpointTransactionHash,
utxo.outpointIndex, utxo.outpointIndex,
); );
ownOutpoints.add(outpointKey);
ownLockingBytecodes.add(utxo.lockingBytecode);
outpointValueSatoshis.set(outpointKey, BigInt(utxo.valueSatoshis)); outpointValueSatoshis.set(outpointKey, BigInt(utxo.valueSatoshis));
} }
@@ -148,15 +145,15 @@ export class HistoryService {
const template = const template =
(await this.engine.getTemplate(invitation.data.templateIdentifier)) ?? (await this.engine.getTemplate(invitation.data.templateIdentifier)) ??
null; null;
const walletEntityIdentifier = this.resolveWalletEntityIdentifier( const walletCommits = await this.getWalletCommitsForInvitation(
invitation, invitation.data,
ownOutpoints,
ownLockingBytecodes,
); );
const walletEntityIdentifier = walletCommits[0]?.entityIdentifier;
contexts.set(invitation.data.invitationIdentifier, { contexts.set(invitation.data.invitationIdentifier, {
invitation, invitation,
template, template,
variables, variables,
walletCommits,
walletEntityIdentifier, walletEntityIdentifier,
}); });
this.indexInvitationOutputsByUtxoOrigin(invitationByOrigin, invitation); this.indexInvitationOutputsByUtxoOrigin(invitationByOrigin, invitation);
@@ -186,7 +183,6 @@ export class HistoryService {
const invitationInputs = this.buildWalletInputItemsForInvitation( const invitationInputs = this.buildWalletInputItemsForInvitation(
context, context,
roles[0], roles[0],
invitationOutputs.length > 0,
outpointValueSatoshis, outpointValueSatoshis,
); );
const invitationDescription = this.deriveInvitationDescription( const invitationDescription = this.deriveInvitationDescription(
@@ -287,51 +283,25 @@ export class HistoryService {
return outputs; return outputs;
} }
private async getWalletCommitsForInvitation(
invitation: XOInvitation,
): Promise<XOInvitationCommit[]> {
try {
return await this.engine.getOwnCommits(invitation);
} catch {
return [];
}
}
private buildWalletInputItemsForInvitation( private buildWalletInputItemsForInvitation(
context: InvitationContext, context: InvitationContext,
walletRole?: string, walletRole?: string,
hasWalletOutputs: boolean = false,
outpointValueSatoshis: Map<string, bigint> = new Map(), outpointValueSatoshis: Map<string, bigint> = new Map(),
): HistoryUtxoItem[] { ): HistoryUtxoItem[] {
const invitation = context.invitation.data; const invitation = context.invitation.data;
const commits = invitation.commits ?? []; const relevantCommits = context.walletCommits.filter(
const commitsByEntity = context.walletEntityIdentifier
? commits.filter(
(commit) =>
commit.entityIdentifier === context.walletEntityIdentifier,
)
: [];
const commitsByRole = walletRole
? commits.filter(
(commit) =>
this.deriveCommitRoleIdentifier(
commit,
invitation,
context.template,
) === walletRole,
)
: [];
let relevantCommits = commitsByEntity.filter(
(commit) => (commit.data.inputs?.length ?? 0) > 0, (commit) => (commit.data.inputs?.length ?? 0) > 0,
); );
if (relevantCommits.length === 0) {
relevantCommits = commitsByRole.filter(
(commit) => (commit.data.inputs?.length ?? 0) > 0,
);
}
if (relevantCommits.length === 0 && walletRole === "sender") {
relevantCommits = commits.filter(
(commit) => (commit.data.inputs?.length ?? 0) > 0,
);
}
// Sender fallback only when no wallet outputs were matched.
if (relevantCommits.length === 0 && !hasWalletOutputs) {
relevantCommits = commits.filter(
(commit) => (commit.data.inputs?.length ?? 0) > 0,
);
}
const txDescription = this.deriveTransactionActivityDescription( const txDescription = this.deriveTransactionActivityDescription(
invitation, invitation,
context.template, context.template,
@@ -355,7 +325,10 @@ export class HistoryService {
context.variables, context.variables,
); );
const templateName = context.template?.name ?? "UnknownTemplate"; const templateName = context.template?.name ?? "UnknownTemplate";
const role = walletRole ?? "sender"; const role =
this.deriveCommitRoleIdentifier(commit, invitation, context.template) ??
walletRole ??
"sender";
const inputValue = this.resolveInputSatoshis( const inputValue = this.resolveInputSatoshis(
txHash, txHash,
inputIndex, inputIndex,
@@ -401,7 +374,7 @@ export class HistoryService {
return { return {
kind: "utxo", kind: "utxo",
id: this.getUtxoId(utxo), id: this.getUtxoId(utxo),
invitationIdentifier: utxo.invitationIdentifier || undefined, invitationIdentifier: utxo.reservedBy || undefined,
templateIdentifier: utxo.templateIdentifier, templateIdentifier: utxo.templateIdentifier,
outputIdentifier: utxo.outputIdentifier, outputIdentifier: utxo.outputIdentifier,
outpoint: { outpoint: {
@@ -409,7 +382,7 @@ export class HistoryService {
index: utxo.outpointIndex, index: utxo.outpointIndex,
}, },
valueSatoshis: BigInt(utxo.valueSatoshis), valueSatoshis: BigInt(utxo.valueSatoshis),
reserved: utxo.reserved, reserved: utxo.reservedBy ? true : false,
direction, direction,
description, description,
descriptionParts: { descriptionParts: {
@@ -422,13 +395,6 @@ export class HistoryService {
}; };
} }
/**
* TODO: This is completely incorrect. It should be deriving the roles of the user based on the entity IDs in the commits, then mapping each commit to Inputs/Outputs so we know what belongs to the user.
* There are a few changes that will need to be made to make this work:
* 1. Provide a way to derive all entity IDs of the user for the invitation (If we are going with XPub)
* 2. Provide a way to get only the User's commits (and their inputs/outputs)
* 3. (Maybe) Include role on inputs and outputs - This one might be fine with just using the commit entity id
*/
private deriveWalletRolesForInvitation( private deriveWalletRolesForInvitation(
context: InvitationContext, context: InvitationContext,
outputs: HistoryUtxoItem[], outputs: HistoryUtxoItem[],
@@ -444,33 +410,20 @@ export class HistoryService {
roles.add("receiver"); roles.add("receiver");
} }
const hasInputCommit = ( for (const commit of context.walletCommits) {
context.walletEntityIdentifier const role = this.deriveCommitRoleIdentifier(
? context.invitation.data.commits.filter( commit,
(c) => c.entityIdentifier === context.walletEntityIdentifier,
)
: context.invitation.data.commits
).some((c) => (c.data.inputs?.length ?? 0) > 0);
if (hasInputCommit) roles.add("sender");
if (
!hasInputCommit &&
outputs.length === 0 &&
context.invitation.data.commits.some(
(c) => (c.data.inputs?.length ?? 0) > 0,
)
) {
roles.add("sender");
}
if (roles.size === 0) {
const inferred = this.extractInvitationRoleIdentifier(
context.invitation.data, context.invitation.data,
context.template, context.template,
context.walletEntityIdentifier,
); );
if (inferred) roles.add(inferred); if (role) roles.add(role);
} }
const hasInputCommit = context.walletCommits.some(
(c) => (c.data.inputs?.length ?? 0) > 0,
);
if (hasInputCommit) roles.add("sender");
return roles.size > 0 ? Array.from(roles) : ["unknown"]; return roles.size > 0 ? Array.from(roles) : ["unknown"];
} }
@@ -517,11 +470,11 @@ export class HistoryService {
utxo: UnspentOutputData, utxo: UnspentOutputData,
invitationByUtxoOrigin: Map<string, UtxoOriginContext>, invitationByUtxoOrigin: Map<string, UtxoOriginContext>,
): string | undefined { ): string | undefined {
if (utxo.invitationIdentifier) return utxo.invitationIdentifier; if (utxo.reservedBy) return utxo.reservedBy;
const originKey = this.getUtxoOriginKey( const originKey = this.getUtxoOriginKey(
utxo.templateIdentifier, utxo.templateIdentifier,
utxo.outputIdentifier, utxo.outputIdentifier,
utxo.lockingBytecode, utxo.scriptHash,
); );
return invitationByUtxoOrigin.get(originKey)?.invitationIdentifier; return invitationByUtxoOrigin.get(originKey)?.invitationIdentifier;
} }
@@ -533,59 +486,11 @@ export class HistoryService {
const originKey = this.getUtxoOriginKey( const originKey = this.getUtxoOriginKey(
utxo.templateIdentifier, utxo.templateIdentifier,
utxo.outputIdentifier, utxo.outputIdentifier,
utxo.lockingBytecode, utxo.scriptHash,
); );
return invitationByUtxoOrigin.get(originKey)?.roleIdentifier; return invitationByUtxoOrigin.get(originKey)?.roleIdentifier;
} }
private resolveWalletEntityIdentifier(
invitation: Invitation,
ownUtxoOutpointKeys: Set<string>,
ownLockingBytecodes: Set<string>,
): string | undefined {
const scores = new Map<string, number>();
const addScore = (entityIdentifier: string, delta: number): void => {
scores.set(entityIdentifier, (scores.get(entityIdentifier) ?? 0) + delta);
};
for (const commit of invitation.data.commits) {
for (const input of commit.data.inputs ?? []) {
const txHash = input.outpointTransactionHash
? input.outpointTransactionHash instanceof Uint8Array
? binToHex(input.outpointTransactionHash)
: String(input.outpointTransactionHash)
: undefined;
if (!txHash || input.outpointIndex === undefined) continue;
if (
ownUtxoOutpointKeys.has(
this.getOutpointKey(txHash, input.outpointIndex),
)
) {
addScore(commit.entityIdentifier, 3);
}
}
for (const output of commit.data.outputs ?? []) {
const lockingBytecodeHex = output.lockingBytecode
? this.toLockingBytecodeHex(output.lockingBytecode)
: undefined;
if (!lockingBytecodeHex) continue;
if (ownLockingBytecodes.has(lockingBytecodeHex)) {
addScore(commit.entityIdentifier, 2);
}
}
}
let bestEntity: string | undefined;
let bestScore = 0;
for (const [entity, score] of scores.entries()) {
if (score > bestScore) {
bestScore = score;
bestEntity = entity;
}
}
return bestEntity;
}
private deriveUtxoDescription( private deriveUtxoDescription(
utxo: UnspentOutputData, utxo: UnspentOutputData,
template: XOTemplate | null, template: XOTemplate | null,
@@ -715,27 +620,6 @@ export class HistoryService {
return undefined; return undefined;
} }
private extractInvitationRoleIdentifier(
invitation: XOInvitation,
template: XOTemplate | null,
walletEntityIdentifier?: string,
): string | undefined {
if (walletEntityIdentifier) {
const commits = invitation.commits.filter(
(commit) => commit.entityIdentifier === walletEntityIdentifier,
);
for (const commit of commits) {
const role = this.deriveCommitRoleIdentifier(
commit,
invitation,
template,
);
if (role) return role;
}
}
return undefined;
}
private inferRoleFromOutputIdentifier( private inferRoleFromOutputIdentifier(
outputIdentifier: string, outputIdentifier: string,
): string | undefined { ): string | undefined {

View File

@@ -2,7 +2,7 @@ import type {
AcceptInvitationParameters, AcceptInvitationParameters,
AppendInvitationParameters, AppendInvitationParameters,
Engine, Engine,
FindSuitableResourcesParameters, GetSpendableResourcesParameters,
} from "@xo-cash/engine"; } from "@xo-cash/engine";
import { hasInvitationExpired, mergeInvitationCommits } from "@xo-cash/engine"; import { hasInvitationExpired, mergeInvitationCommits } from "@xo-cash/engine";
import type { import type {
@@ -24,8 +24,8 @@ import {
import type { SSEvent } from "../utils/sse-client.js"; import type { SSEvent } from "../utils/sse-client.js";
import type { SyncServer } from "../utils/sync-server.js"; import type { SyncServer } from "../utils/sync-server.js";
import type { Storage } from "./storage.js"; import type { BaseStorage } from "./storage.js";
import type { ElectrumService } from "./electrum.js"; 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";
@@ -34,14 +34,14 @@ import { compileCashAssemblyString } from "@xo-cash/engine";
export type InvitationEventMap = { export type InvitationEventMap = {
"invitation-updated": XOInvitation; "invitation-updated": XOInvitation;
"invitation-status-changed": string; "invitation-status-changed": string;
"error": Error; error: Error;
}; };
export type InvitationDependencies = { export type InvitationDependencies = {
syncServer: SyncServer; syncServer: SyncServer;
storage: Storage; storage: BaseStorage;
engine: Engine; engine: Engine;
electrum: ElectrumService; electrum: BlockchainService;
}; };
export class Invitation extends EventEmitter<InvitationEventMap> { export class Invitation extends EventEmitter<InvitationEventMap> {
@@ -85,8 +85,11 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
throw new Error(`Template not found: ${invitation.templateIdentifier}`); throw new Error(`Template not found: ${invitation.templateIdentifier}`);
} }
// engine invitation (I have no idea if this is required)
const engineInvitation = await dependencies.engine.acceptInvitation(invitation);
// Create the invitation // Create the invitation
const invitationInstance = new Invitation(invitation, dependencies); const invitationInstance = new Invitation(engineInvitation, dependencies);
// Start the invitation and its tracking // Start the invitation and its tracking
await invitationInstance.start(); await invitationInstance.start();
@@ -119,8 +122,8 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
* The storage instance. * The storage instance.
* TODO: This should be a composite with the sync server (probably. We currently double handle this work, which is stupid) * TODO: This should be a composite with the sync server (probably. We currently double handle this work, which is stupid)
*/ */
private storage: Storage; private storage: BaseStorage;
private electrum: ElectrumService; private electrum: BlockchainService;
/** /**
* 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).
@@ -215,7 +218,9 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
/** /**
* Publish the invitation to the sync server * Publish the invitation to the sync server
*/ */
private async publishInvitation(invitation: XOInvitation = this.data): Promise<void> { private async publishInvitation(
invitation: XOInvitation = this.data,
): Promise<void> {
try { try {
await this.syncServer.publishInvitation(invitation); await this.syncServer.publishInvitation(invitation);
} catch (err) { } catch (err) {
@@ -481,12 +486,27 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
} }
async findSuitableResources( async findSuitableResources(
options: Partial<FindSuitableResourcesParameters> = {}, options: Partial<GetSpendableResourcesParameters> = {},
): Promise<UnspentOutputData[]> { ): Promise<UnspentOutputData[]> {
const templateIdentifier =
options.templateIdentifier ?? this.data.templateIdentifier;
const template = await this.engine.getTemplate(templateIdentifier);
const fallbackOutputIdentifier = Object.keys(template?.outputs ?? {})[0];
if (!fallbackOutputIdentifier && !options.outputIdentifier) {
throw new Error(
`No output identifiers found for template: ${templateIdentifier}`,
);
}
const resolvedOptions: GetSpendableResourcesParameters = {
templateIdentifier,
outputIdentifier: options.outputIdentifier ?? fallbackOutputIdentifier ?? "",
};
// Find the suitable resources // Find the suitable resources
const { unspentOutputs } = await this.engine.findSuitableResources( const { unspentOutputs } = await this.engine.getSpendableResources(
this.data, this.data,
options, resolvedOptions,
); );
// Update the status of the invitation // Update the status of the invitation

197
src/services/rates.ts Normal file
View File

@@ -0,0 +1,197 @@
import { EventEmitter } from '../utils/event-emitter.js';
import {
type RatesEventMap,
} from '../utils/rates/base-rates.js';
import { RatesOracle } from '../utils/rates/rates-oracles.js';
/**
* Event map emitted by {@link RatesService}.
*/
export type RatesServiceEventMap = {
'rate-updated': {
numeratorUnitCode: string;
denominatorUnitCode: string;
price: number;
pair: string;
updatedAt: number;
};
};
/**
* In-memory representation of a market rate.
*/
type CachedRate = {
price: number;
updatedAt: number;
};
/**
* Minimal adapter contract that RatesService depends on.
*
* Using a small interface keeps the service decoupled and avoids inheriting
* implementation-specific type constraints from concrete adapters.
*/
export interface RatesAdapter {
start(): Promise<void>;
stop(): Promise<void>;
listPairs(): Promise<Set<string>>;
formatCurrency(amount: number, targetCurrency: string): string;
on(
type: 'rateUpdated',
listener: (detail: RatesEventMap['rateUpdated']) => void,
): () => void;
}
/**
* Orchestrates the rates adapter lifecycle and provides BCH -> fiat helpers
* for the TUI.
*
* This service keeps a small in-memory snapshot of the latest prices and emits
* a normalized event whenever a pair changes. React components can subscribe
* through `useSyncExternalStore` for clean and predictable reactivity.
*/
export class RatesService extends EventEmitter<RatesServiceEventMap> {
private readonly adapter: RatesAdapter;
private readonly ratesByPair = new Map<string, CachedRate>();
private unsubscribeFromAdapter: (() => void) | null = null;
private started = false;
constructor(adapter: RatesAdapter) {
super();
this.adapter = adapter;
}
/**
* Creates a rates service.
*
* If no adapter is passed, this defaults to the Oracle-backed adapter.
*/
public static async create(adapter?: RatesAdapter): Promise<RatesService> {
const resolvedAdapter = adapter ?? (await RatesOracle.from());
return new RatesService(resolvedAdapter);
}
/**
* Starts the underlying adapter and begins collecting live updates.
*/
public async start(): Promise<void> {
if (this.started) {
return;
}
this.started = true;
this.unsubscribeFromAdapter = this.adapter.on('rateUpdated', (event) => {
this.handleRateUpdated(event);
});
try {
await this.adapter.start();
} catch (error) {
this.unsubscribeFromAdapter?.();
this.unsubscribeFromAdapter = null;
this.started = false;
throw error;
}
}
/**
* Stops live rate collection.
*/
public async stop(): Promise<void> {
if (!this.started) {
return;
}
this.started = false;
this.unsubscribeFromAdapter?.();
this.unsubscribeFromAdapter = null;
await this.adapter.stop();
}
/**
* Returns the latest price for a pair in NUMERATOR/DENOMINATOR form.
*
* Example: `getRate("USD", "BCH")`.
*/
public getRate(
numeratorUnitCode: string,
denominatorUnitCode: string,
): number | null {
const pair = this.getPairKey(numeratorUnitCode, denominatorUnitCode);
return this.ratesByPair.get(pair)?.price ?? null;
}
/**
* Converts satoshis to fiat using the latest BCH/fiat rate.
*
* Example: `convertBchToFiat(1234n, "USD")`.
*/
public convertBchToFiat(
satoshis: bigint,
targetCurrency: string = 'USD',
): number | null {
const rate = this.getRate(targetCurrency, 'BCH');
if (rate === null) {
return null;
}
const amountInBch = Number(satoshis) / 100_000_000;
return amountInBch * rate;
}
/**
* Formats a BCH -> fiat converted amount using the adapter formatter.
*/
public formatBchToFiat(
satoshis: bigint,
targetCurrency: string = 'USD',
): string | null {
const normalizedCurrency = targetCurrency.toUpperCase();
const amount = this.convertBchToFiat(satoshis, normalizedCurrency);
if (amount === null) {
return null;
}
return this.adapter.formatCurrency(amount, normalizedCurrency);
}
/**
* Formats an arbitrary fiat amount in a currency-aware way.
*/
public formatCurrency(amount: number, currencyCode: string): string {
return this.adapter.formatCurrency(amount, currencyCode.toUpperCase());
}
/**
* Handles normalized updates from the underlying adapter.
*/
private handleRateUpdated(event: RatesEventMap['rateUpdated']): void {
const numeratorUnitCode = event.numeratorUnitCode.toUpperCase();
const denominatorUnitCode = event.denominatorUnitCode.toUpperCase();
const pair = this.getPairKey(numeratorUnitCode, denominatorUnitCode);
const updatedAt = Date.now();
this.ratesByPair.set(pair, {
price: event.price,
updatedAt,
});
this.emit('rate-updated', {
numeratorUnitCode,
denominatorUnitCode,
price: event.price,
pair,
updatedAt,
});
}
/**
* Creates a stable key for pair lookups.
*/
private getPairKey(
numeratorUnitCode: string,
denominatorUnitCode: string,
): string {
return `${numeratorUnitCode.toUpperCase()}/${denominatorUnitCode.toUpperCase()}`;
}
}

View File

@@ -1,7 +1,16 @@
import Database from "better-sqlite3"; import Database from "better-sqlite3";
import { decodeExtendedJson, encodeExtendedJson } from "../utils/ext-json.js"; import { decodeExtendedJson, encodeExtendedJson } from "../utils/ext-json.js";
export class Storage { export abstract class BaseStorage {
abstract all(): Promise<{ key: string; value: any }[]>;
abstract set(key: string, value: any): Promise<void>;
abstract get(key: string): Promise<any>;
abstract remove(key: string): Promise<void>;
abstract clear(): Promise<void>;
abstract child(key: string): BaseStorage;
}
export class Storage extends BaseStorage {
static async create(dbPath: string): Promise<Storage> { static async create(dbPath: string): Promise<Storage> {
// Create the database // Create the database
const database = new Database(dbPath); const database = new Database(dbPath);
@@ -19,7 +28,9 @@ export class Storage {
constructor( constructor(
private readonly database: Database.Database, private readonly database: Database.Database,
private readonly basePath: string, private readonly basePath: string,
) {} ) {
super();
}
/** /**
* Get the full key with basePath prefix * Get the full key with basePath prefix
@@ -117,3 +128,104 @@ export class Storage {
return new Storage(this.database, this.getFullKey(key)); return new Storage(this.database, this.getFullKey(key));
} }
} }
/**
* In-memory storage adapter with the same namespaced API as {@link Storage}.
*
* This adapter is useful for tests and short-lived sessions where persisted
* SQLite state is not needed.
*/
export class InMemoryStorage extends BaseStorage {
static async create(): Promise<InMemoryStorage> {
return new InMemoryStorage(new Map<string, string>(), "");
}
constructor(
private readonly store: Map<string, string>,
private readonly basePath: string,
) {
super();
}
/**
* Get the full key with basePath prefix.
*/
private getFullKey(key: string): string {
return this.basePath ? `${this.basePath}.${key}` : key;
}
/**
* Strip the basePath prefix from a key.
*/
private stripBasePath(fullKey: string): string {
if (!this.basePath) return fullKey;
const prefix = `${this.basePath}.`;
return fullKey.startsWith(prefix) ? fullKey.slice(prefix.length) : fullKey;
}
async set(key: string, value: any): Promise<void> {
const fullKey = this.getFullKey(key);
const encodedValue = encodeExtendedJson(value);
this.store.set(fullKey, encodedValue);
}
/**
* Get all key-value pairs from this storage namespace (shallow only).
*/
async all(): Promise<{ key: string; value: any }[]> {
const rows: Array<{ key: string; value: string }> = [];
const prefix = this.basePath ? `${this.basePath}.` : "";
for (const [key, value] of this.store.entries()) {
if (this.basePath && !key.startsWith(prefix)) continue;
rows.push({ key, value });
}
const filteredRows = rows.filter((row) => {
const strippedKey = this.stripBasePath(row.key);
return !strippedKey.includes(".");
});
return filteredRows.map((row) => ({
key: this.stripBasePath(row.key),
value: decodeExtendedJson(row.value),
}));
}
async get(key: string): Promise<any> {
const fullKey = this.getFullKey(key);
const encodedValue = this.store.get(fullKey);
if (encodedValue === undefined) return null;
return decodeExtendedJson(encodedValue);
}
async remove(key: string): Promise<void> {
const fullKey = this.getFullKey(key);
this.store.delete(fullKey);
}
async clear(): Promise<void> {
if (!this.basePath) {
this.store.clear();
return;
}
const prefix = `${this.basePath}.`;
const keysToDelete: string[] = [];
for (const key of this.store.keys()) {
if (key.startsWith(prefix)) {
keysToDelete.push(key);
}
}
for (const key of keysToDelete) {
this.store.delete(key);
}
}
child(key: string): InMemoryStorage {
return new InMemoryStorage(this.store, this.getFullKey(key));
}
}

View File

@@ -1,6 +1,7 @@
import React from "react"; import React, { useMemo } from "react";
import { Box, Text } from "ink"; import { Box, Text } from "ink";
import TextInput from "./TextInput.js"; import TextInput from "./TextInput.js";
import { useSatoshisConversion } from "../hooks/useSatoshisConversion.js";
interface VariableInputFieldProps { interface VariableInputFieldProps {
variable: { variable: {
@@ -18,6 +19,45 @@ interface VariableInputFieldProps {
focusColor: string; focusColor: string;
} }
const SATOSHIS_PER_BCH = 100_000_000n;
/**
* Returns true when the variable is an integer satoshis field.
*/
function isSatoshisVariable(variable: VariableInputFieldProps["variable"]): boolean {
return (
variable.type === "integer" &&
variable.hint?.toLowerCase().includes("satoshi") === true
);
}
/**
* Parse a strict integer string into bigint.
*/
function parseSatoshis(value: string): bigint | null {
const trimmed = value.trim();
if (!/^[-]?\d+$/.test(trimmed)) {
return null;
}
try {
return BigInt(trimmed);
} catch {
return null;
}
}
/**
* Format satoshis as BCH with fixed 8 decimals, preserving bigint precision.
*/
function formatBchFromSatoshis(satoshis: bigint): string {
const sign = satoshis < 0n ? "-" : "";
const absolute = satoshis < 0n ? satoshis * -1n : satoshis;
const whole = absolute / SATOSHIS_PER_BCH;
const fractional = absolute % SATOSHIS_PER_BCH;
return `${sign}${whole.toString()}.${fractional.toString().padStart(8, "0")} BCH`;
}
export function VariableInputField({ export function VariableInputField({
variable, variable,
index, index,
@@ -27,6 +67,26 @@ export function VariableInputField({
borderColor, borderColor,
focusColor, focusColor,
}: VariableInputFieldProps): React.ReactElement { }: VariableInputFieldProps): React.ReactElement {
const { currencyCode, formattedFiatPerBchRate, formatSatoshisToFiat } =
useSatoshisConversion("USD");
const satoshisValue = useMemo(
() => parseSatoshis(variable.value),
[variable.value],
);
const formattedBch = useMemo(() => {
if (satoshisValue === null) {
return null;
}
return formatBchFromSatoshis(satoshisValue);
}, [satoshisValue]);
const formattedFiat = useMemo(() => {
if (satoshisValue === null) {
return null;
}
return formatSatoshisToFiat(satoshisValue);
}, [satoshisValue, formatSatoshisToFiat]);
const shouldShowSatoshisConversion = isSatoshisVariable(variable);
return ( return (
<Box flexDirection="column" marginBottom={1}> <Box flexDirection="column" marginBottom={1}>
<Text color={focusColor}>{variable.name}</Text> <Text color={focusColor}>{variable.name}</Text>
@@ -54,12 +114,29 @@ export function VariableInputField({
<Text color={borderColor} dimColor>{variable.hint}</Text> <Text color={borderColor} dimColor>{variable.hint}</Text>
</Box> </Box>
{variable.type === 'integer' && variable.hint === 'satoshis' && ( {shouldShowSatoshisConversion && (
<Box> <Box flexDirection="column">
{formattedBch ? (
<>
<Text color={borderColor} dimColor> <Text color={borderColor} dimColor>
{/* Convert from sats to bch. NOTE: we can't use the formatSatoshis function because it is too verbose and returns too many values in the string*/} {formattedBch}
{(Number(variable.value) / 100_000_000).toFixed(8)} BCH
</Text> </Text>
<Text color={borderColor} dimColor>
{formattedFiat
? `Approx. ${currencyCode}: ${formattedFiat}`
: `Approx. ${currencyCode}: waiting for live rate...`}
</Text>
{formattedFiatPerBchRate && (
<Text color={borderColor} dimColor>
1 BCH = {formattedFiatPerBchRate}
</Text>
)}
</>
) : (
<Text color={borderColor} dimColor>
Enter a whole satoshi amount to preview BCH/{currencyCode} conversion.
</Text>
)}
</Box> </Box>
)} )}
</Box> </Box>

View File

@@ -23,3 +23,5 @@ export {
useBlockableInput, useBlockableInput,
useIsInputCaptured, useIsInputCaptured,
} from "./useInputLayer.js"; } from "./useInputLayer.js";
export { useRate, useBchToFiatRate } from "./useRates.js";
export { useSatoshisConversion } from "./useSatoshisConversion.js";

View File

@@ -0,0 +1,68 @@
import { useCallback, useMemo, useSyncExternalStore } from 'react';
import type { RatesServiceEventMap } from '../../services/rates.js';
import { useAppContext } from './useAppContext.js';
/**
* Reactive hook for a single market pair.
*
* Pair format is NUMERATOR / DENOMINATOR, e.g. USD / BCH.
*/
export function useRate(
numeratorUnitCode: string,
denominatorUnitCode: string,
): number | null {
const { appService } = useAppContext();
const normalizedNumerator = useMemo(
() => numeratorUnitCode.toUpperCase(),
[numeratorUnitCode],
);
const normalizedDenominator = useMemo(
() => denominatorUnitCode.toUpperCase(),
[denominatorUnitCode],
);
const subscribe = useCallback(
(callback: () => void) => {
if (!appService) {
return () => {};
}
const onRateUpdated = (event: RatesServiceEventMap['rate-updated']) => {
if (
event.numeratorUnitCode === normalizedNumerator &&
event.denominatorUnitCode === normalizedDenominator
) {
callback();
}
};
const unsubscribe = appService.rates.on('rate-updated', onRateUpdated);
return () => {
unsubscribe();
};
},
[appService, normalizedNumerator, normalizedDenominator],
);
const getSnapshot = useCallback(() => {
if (!appService) {
return null;
}
return appService.rates.getRate(normalizedNumerator, normalizedDenominator);
}, [appService, normalizedNumerator, normalizedDenominator]);
return useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
}
/**
* Convenience hook for BCH -> fiat market rates.
*/
export function useBchToFiatRate(
targetCurrency: string = 'USD',
): number | null {
return useRate(targetCurrency, 'BCH');
}

View File

@@ -0,0 +1,42 @@
import { useCallback, useMemo } from 'react';
import { useAppContext } from './useAppContext.js';
import { useBchToFiatRate } from './useRates.js';
/**
* Reactive BCH satoshis -> fiat conversion helpers for TUI screens.
*
* This hook subscribes to rate updates through `useBchToFiatRate`, so any
* component using it will re-render automatically when the selected pair
* receives a new quote.
*/
export function useSatoshisConversion(targetCurrency: string = 'USD') {
const { appService } = useAppContext();
const currencyCode = useMemo(() => targetCurrency.toUpperCase(), [targetCurrency]);
const fiatPerBchRate = useBchToFiatRate(currencyCode);
const formattedFiatPerBchRate = useMemo(() => {
if (!appService || fiatPerBchRate === null) {
return null;
}
return appService.rates.formatCurrency(fiatPerBchRate, currencyCode);
}, [appService, fiatPerBchRate, currencyCode]);
const formatSatoshisToFiat = useCallback(
(satoshis: bigint): string | null => {
if (!appService || fiatPerBchRate === null) {
return null;
}
return appService.rates.formatBchToFiat(satoshis, currencyCode);
},
[appService, fiatPerBchRate, currencyCode],
);
return {
currencyCode,
fiatPerBchRate,
formattedFiatPerBchRate,
formatSatoshisToFiat,
} as const;
}

View File

@@ -16,6 +16,8 @@ import { colors, logo } from '../theme.js';
import fs from 'fs'; import fs from 'fs';
import path from 'path'; import path from 'path';
import { createMnemonicFile } from '../../cli/mnemonic.js';
import { getMnemonicsDir } from '../../utils/paths.js';
import { BCHMnemonicURL } from '../../utils/bch-mnemonic-url.js'; import { BCHMnemonicURL } from '../../utils/bch-mnemonic-url.js';
import { encodeBip39Mnemonic } from '@bitauth/libauth'; import { encodeBip39Mnemonic } from '@bitauth/libauth';
@@ -39,20 +41,26 @@ interface MnemonicFileEntry {
* Focus sections the user can tab between. * Focus sections the user can tab between.
* When saved wallets exist the file list is shown first. * When saved wallets exist the file list is shown first.
*/ */
type FocusSection = 'files' | 'input' | 'button'; type FocusSection = 'files' | 'input' | 'saveCheckbox' | 'button';
/** /**
* Reads mnemonic-* files from cwd, parses each as a BCHMnemonicURL, * Reads mnemonic-* files from ~/.config/xo-cli/mnemonics/ (same as xo-cli),
* and converts the entropy back to a BIP39 English mnemonic phrase. * then from cwd for legacy installs. Parses each as a BCHMnemonicURL.
*/ */
function loadMnemonicFiles(): MnemonicFileEntry[] { function loadMnemonicFiles(): MnemonicFileEntry[] {
const cwd = process.cwd(); const dirs = [getMnemonicsDir(), process.cwd()];
const filenames = fs.readdirSync(cwd).filter((f) => f.startsWith('mnemonic-')); const seenBasenames = new Set<string>();
const entries: MnemonicFileEntry[] = []; const entries: MnemonicFileEntry[] = [];
for (const dir of dirs) {
if (!fs.existsSync(dir)) continue;
const filenames = fs
.readdirSync(dir)
.filter((f) => f.startsWith('mnemonic-'));
for (const filename of filenames) { for (const filename of filenames) {
if (seenBasenames.has(filename)) continue;
try { try {
const content = fs.readFileSync(path.join(cwd, filename), 'utf-8').trim(); const content = fs.readFileSync(path.join(dir, filename), 'utf-8').trim();
const parsed = BCHMnemonicURL.fromURL(content); const parsed = BCHMnemonicURL.fromURL(content);
const raw = parsed.toObject(); const raw = parsed.toObject();
@@ -64,10 +72,12 @@ function loadMnemonicFiles(): MnemonicFileEntry[] {
?? filename.replace(/^mnemonic-/, '').replace(/\.[^.]+$/, ''); ?? filename.replace(/^mnemonic-/, '').replace(/\.[^.]+$/, '');
entries.push({ filename, label, mnemonic: mnemonicResult.phrase }); entries.push({ filename, label, mnemonic: mnemonicResult.phrase });
seenBasenames.add(filename);
} catch { } catch {
// Skip files that can't be parsed // Skip files that can't be parsed
} }
} }
}
return entries; return entries;
} }
@@ -91,6 +101,9 @@ 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. */
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.
const [focusedSection, setFocusedSection] = useState<FocusSection>('input'); const [focusedSection, setFocusedSection] = useState<FocusSection>('input');
@@ -104,8 +117,8 @@ export function SeedInputScreen(): React.ReactElement {
* The ordered list of focusable sections (files section only when entries exist). * The ordered list of focusable sections (files section only when entries exist).
*/ */
const focusSections: FocusSection[] = mnemonicFiles.length > 0 const focusSections: FocusSection[] = mnemonicFiles.length > 0
? ['files', 'input', 'button'] ? ['files', 'input', 'saveCheckbox', 'button']
: ['input', 'button']; : ['input', 'saveCheckbox', 'button'];
/** /**
* Shows a status message with the given type. * Shows a status message with the given type.
@@ -118,7 +131,8 @@ export function SeedInputScreen(): React.ReactElement {
/** /**
* Shared wallet initialization handler used by both manual entry and file selection. * Shared wallet initialization handler used by both manual entry and file selection.
*/ */
const doInitialize = useCallback(async (seed: string) => { const doInitialize = useCallback(
async (seed: string, options?: { saveMnemonic?: boolean }) => {
showStatus('Initializing wallet...', 'loading'); showStatus('Initializing wallet...', 'loading');
setStatus('Initializing wallet...'); setStatus('Initializing wallet...');
setIsSubmitting(true); setIsSubmitting(true);
@@ -126,20 +140,37 @@ export function SeedInputScreen(): React.ReactElement {
try { try {
await initializeWallet(seed); await initializeWallet(seed);
showStatus('Wallet initialized successfully!', 'success'); let statusText = 'Wallet initialized successfully!';
if (options?.saveMnemonic) {
try {
const savedAs = createMnemonicFile(getMnemonicsDir(), seed);
setMnemonicFiles(loadMnemonicFiles());
statusText = `Wallet initialized! Mnemonic saved as ${savedAs}`;
} catch (saveErr) {
const saveMsg =
saveErr instanceof Error ? saveErr.message : String(saveErr);
statusText = `Wallet initialized, but could not save mnemonic: ${saveMsg}`;
}
}
showStatus(statusText, 'success');
setStatus('Wallet ready'); setStatus('Wallet ready');
setSeedPhrase(''); setSeedPhrase('');
setSaveMnemonicChecked(false);
setTimeout(() => { setTimeout(() => {
navigate('wallet'); navigate('wallet');
}, 500); }, 500);
} catch (error) { } catch (error) {
const message = error instanceof Error ? error.message : 'Failed to initialize wallet'; const message =
error instanceof Error ? error.message : 'Failed to initialize wallet';
showStatus(message, 'error'); showStatus(message, 'error');
setStatus('Initialization failed'); setStatus('Initialization failed');
setIsSubmitting(false); setIsSubmitting(false);
} }
}, [initializeWallet, navigate, showStatus, setStatus]); },
[initializeWallet, navigate, showStatus, setStatus],
);
/** /**
* Handles manual seed phrase submission with validation. * Handles manual seed phrase submission with validation.
@@ -158,8 +189,8 @@ export function SeedInputScreen(): React.ReactElement {
return; return;
} }
await doInitialize(seed); await doInitialize(seed, { saveMnemonic: saveMnemonicChecked });
}, [seedPhrase, doInitialize, showStatus]); }, [seedPhrase, saveMnemonicChecked, doInitialize, showStatus]);
/** /**
* Handles selecting a mnemonic file from the list. * Handles selecting a mnemonic file from the list.
@@ -186,6 +217,14 @@ export function SeedInputScreen(): React.ReactElement {
return; return;
} }
// Space or Enter toggles "save mnemonic" when that row is focused
if (focusedSection === 'saveCheckbox') {
if (_input === ' ' || key.return) {
setSaveMnemonicChecked((v) => !v);
return;
}
}
// Arrow keys inside the file list // Arrow keys inside the file list
if (focusedSection === 'files' && mnemonicFiles.length > 0) { if (focusedSection === 'files' && mnemonicFiles.length > 0) {
if (key.upArrow) { if (key.upArrow) {
@@ -319,6 +358,32 @@ export function SeedInputScreen(): React.ReactElement {
/> />
</Box> </Box>
{/* Save mnemonic checkbox (manual entry only; applies on Continue) */}
<Box
marginTop={1}
paddingX={1}
borderStyle='single'
borderColor={
focusedSection === 'saveCheckbox' ? colors.focus : colors.borderMuted
}
>
<Text
color={focusedSection === 'saveCheckbox' ? colors.focus : colors.text}
bold={focusedSection === 'saveCheckbox'}
>
{saveMnemonicChecked ? '[x] ' : '[ ] '}
</Text>
<Text color={colors.text}>Save this mnemonic</Text>
<Text color={colors.textMuted}> (~/.config/xo-cli/mnemonics/)</Text>
</Box>
{focusedSection === 'saveCheckbox' && (
<Box marginTop={0} paddingX={1}>
<Text color={colors.textMuted} dimColor>
Space / Enter: toggle
</Text>
</Box>
)}
{/* Status message */} {/* Status message */}
<Box marginTop={1} height={1}> <Box marginTop={1} height={1}>
{statusMessage && ( {statusMessage && (
@@ -345,7 +410,7 @@ export function SeedInputScreen(): React.ReactElement {
{/* Help text */} {/* Help text */}
<Box marginTop={2}> <Box marginTop={2}>
<Text color={colors.textMuted} dimColor> <Text color={colors.textMuted} dimColor>
Tab: navigate sections Enter: submit Esc: back Tab: navigate Enter: submit, load wallet, or toggle save Space: toggle save Esc: back
</Text> </Text>
</Box> </Box>
</Box> </Box>

View File

@@ -100,8 +100,8 @@ export function TemplateListScreen(): React.ReactElement {
for (const startingAction of rawStartingActions) { for (const startingAction of rawStartingActions) {
const existing = actionMap.get(startingAction.action); const existing = actionMap.get(startingAction.action);
if (existing) { if (existing) {
if (!existing.roles.includes(startingAction.role)) { if (!existing.roles.includes(startingAction.role ?? '')) {
existing.roles.push(startingAction.role); existing.roles.push(startingAction.role ?? '');
} }
continue; continue;
} }
@@ -111,7 +111,7 @@ export function TemplateListScreen(): React.ReactElement {
actionIdentifier: startingAction.action, actionIdentifier: startingAction.action,
name: actionDef?.name || startingAction.action, name: actionDef?.name || startingAction.action,
description: actionDef?.description, description: actionDef?.description,
roles: [startingAction.role], roles: [startingAction.role ?? ''],
source: 'starting', source: 'starting',
}); });
} }
@@ -119,9 +119,9 @@ export function TemplateListScreen(): React.ReactElement {
const ownedOutputIdentifiers = ownedOutputsByTemplate.get(templateIdentifier) ?? new Set<string>(); const ownedOutputIdentifiers = ownedOutputsByTemplate.get(templateIdentifier) ?? new Set<string>();
for (const outputIdentifier of ownedOutputIdentifiers) { for (const outputIdentifier of ownedOutputIdentifiers) {
const outputDef = template.outputs?.[outputIdentifier]; const outputDef = template.outputs?.[outputIdentifier];
if (!outputDef || typeof outputDef.lockscript !== 'string') continue; if (!outputDef || typeof outputDef.lockingScript !== 'string') continue;
const lockingScriptDefinition = (template.lockingScripts as Record<string, unknown> | undefined)?.[outputDef.lockscript] as const lockingScriptDefinition = (template.lockingScripts as Record<string, unknown> | undefined)?.[outputDef.lockingScript] as
| { roles?: Record<string, { actions?: Array<{ action?: string; role?: string } | string> }> } | { roles?: Record<string, { actions?: Array<{ action?: string; role?: string } | string> }> }
| undefined; | undefined;
if (!lockingScriptDefinition?.roles) continue; if (!lockingScriptDefinition?.roles) continue;
@@ -217,14 +217,10 @@ export function TemplateListScreen(): React.ReactElement {
action.roles.length, action.roles.length,
index index
); );
const sourceSuffix = action.source === 'next'
? ' [next]'
: action.source === 'starting+next'
? ' [start+next]'
: '';
return { return {
key: action.actionIdentifier, key: action.actionIdentifier,
label: `${formatted.label}${sourceSuffix}`, label: `${formatted.label}`,
description: formatted.description, description: formatted.description,
value: action, value: action,
hidden: !formatted.isValid, hidden: !formatted.isValid,

View File

@@ -13,6 +13,7 @@ import { ScrollableList, type ListItemData } from '../components/List.js';
import { QRCode } from '../components/QRCode.js'; import { QRCode } from '../components/QRCode.js';
import { useNavigation } from '../hooks/useNavigation.js'; import { useNavigation } from '../hooks/useNavigation.js';
import { useAppContext, useStatus } from '../hooks/useAppContext.js'; import { useAppContext, useStatus } from '../hooks/useAppContext.js';
import { useSatoshisConversion } from '../hooks/useSatoshisConversion.js';
import { useInputLayer, useLayeredInput, useBlockableInput, useIsInputCaptured } from '../hooks/useInputLayer.js'; import { useInputLayer, useLayeredInput, useBlockableInput, useIsInputCaptured } from '../hooks/useInputLayer.js';
import { colors, formatSatoshis, formatHex, logoSmall } from '../theme.js'; import { colors, formatSatoshis, formatHex, logoSmall } from '../theme.js';
import type { HistoryItem } from '../../services/history.js'; import type { HistoryItem } from '../../services/history.js';
@@ -108,6 +109,12 @@ export function WalletStateScreen(): React.ReactElement {
const { navigate } = useNavigation(); const { navigate } = useNavigation();
const { appService, showError, showInfo } = useAppContext(); const { appService, showError, showInfo } = useAppContext();
const { setStatus } = useStatus(); const { setStatus } = useStatus();
const {
currencyCode,
fiatPerBchRate,
formattedFiatPerBchRate,
formatSatoshisToFiat,
} = useSatoshisConversion('USD');
// State // State
const [balance, setBalance] = useState<{ totalSatoshis: bigint; utxoCount: number } | null>(null); const [balance, setBalance] = useState<{ totalSatoshis: bigint; utxoCount: number } | null>(null);
@@ -297,6 +304,26 @@ export function WalletStateScreen(): React.ReactElement {
}); });
}, [history]); }, [history]);
/**
* Fiat values are memoized so we only recompute when balance or rate changes.
*/
const formattedUsdPerBchRate = useMemo(() => {
return formattedFiatPerBchRate;
}, [formattedFiatPerBchRate]);
const formattedUsdBalance = useMemo(() => {
if (!balance || fiatPerBchRate === null) {
return null;
}
return formatSatoshisToFiat(balance.totalSatoshis);
}, [balance, fiatPerBchRate, formatSatoshisToFiat]);
const getFiatSuffix = useCallback((satoshis: bigint): string => {
const fiatValue = formatSatoshisToFiat(satoshis);
return fiatValue ? ` (~${fiatValue})` : '';
}, [formatSatoshisToFiat]);
// Screen input — automatically blocked when any dialog/overlay is capturing. // Screen input — automatically blocked when any dialog/overlay is capturing.
const isCaptured = useIsInputCaptured(); const isCaptured = useIsInputCaptured();
@@ -335,11 +362,16 @@ export function WalletStateScreen(): React.ReactElement {
} }
if (row.type === 'invitation_input') { if (row.type === 'invitation_input') {
const inputSatoshis = row.utxo?.valueSatoshis;
const inputFiatSuffix = inputSatoshis !== undefined
? getFiatSuffix(inputSatoshis)
: '';
return ( return (
<Box flexDirection="row" justifyContent="space-between"> <Box flexDirection="row" justifyContent="space-between">
<Box> <Box>
<Text color={itemColor}> <Text color={itemColor}>
{indicator}{groupingPrefix}[Input] {row.label} {indicator}{groupingPrefix}[Input] {row.label}
{inputFiatSuffix}
</Text> </Text>
{row.description && <Text color={colors.textMuted}> {row.description}</Text>} {row.description && <Text color={colors.textMuted}> {row.description}</Text>}
</Box> </Box>
@@ -355,6 +387,7 @@ export function WalletStateScreen(): React.ReactElement {
<Box flexDirection="row"> <Box flexDirection="row">
<Text color={itemColor}> <Text color={itemColor}>
{indicator}{groupingPrefix}[Output] {formatSatoshis(sats)} {indicator}{groupingPrefix}[Output] {formatSatoshis(sats)}
{getFiatSuffix(sats)}
</Text> </Text>
{row.description && <Text color={colors.textMuted}> {row.description}</Text>} {row.description && <Text color={colors.textMuted}> {row.description}</Text>}
</Box> </Box>
@@ -369,7 +402,10 @@ export function WalletStateScreen(): React.ReactElement {
return ( return (
<Box flexDirection="row" justifyContent="space-between"> <Box flexDirection="row" justifyContent="space-between">
<Box flexDirection="row"> <Box flexDirection="row">
<Text color={itemColor}>{indicator}{formatSatoshis(sats)}</Text> <Text color={itemColor}>
{indicator}{formatSatoshis(sats)}
{getFiatSuffix(sats)}
</Text>
{row.description && <Text color={colors.textMuted}> {row.description}{reservedTag}</Text>} {row.description && <Text color={colors.textMuted}> {row.description}{reservedTag}</Text>}
</Box> </Box>
{dateStr && <Text color={colors.textMuted}>{dateStr}</Text>} {dateStr && <Text color={colors.textMuted}>{dateStr}</Text>}
@@ -386,7 +422,7 @@ export function WalletStateScreen(): React.ReactElement {
{dateStr && <Text color={colors.textMuted}>{dateStr}</Text>} {dateStr && <Text color={colors.textMuted}>{dateStr}</Text>}
</Box> </Box>
); );
}, []); }, [getFiatSuffix]);
return ( return (
<Box flexDirection="column" flexGrow={1}> <Box flexDirection="column" flexGrow={1}>
@@ -418,6 +454,20 @@ export function WalletStateScreen(): React.ReactElement {
<Text color={colors.success} bold> <Text color={colors.success} bold>
{formatSatoshis(balance.totalSatoshis)} {formatSatoshis(balance.totalSatoshis)}
</Text> </Text>
{formattedUsdBalance ? (
<Text color={colors.info}>
Approx. Fiat ({currencyCode}): {formattedUsdBalance}
</Text>
) : (
<Text color={colors.textMuted}>
Approx. Fiat ({currencyCode}): Waiting for BCH/{currencyCode} rate...
</Text>
)}
{formattedUsdPerBchRate && (
<Text color={colors.textMuted}>
1 BCH = {formattedUsdPerBchRate}
</Text>
)}
<Text color={colors.textMuted}> <Text color={colors.textMuted}>
UTXOs: {balance.utxoCount} UTXOs: {balance.utxoCount}
</Text> </Text>

View File

@@ -15,7 +15,7 @@ export { DataWizardFlow } from "./DataWizardFlow.js";
*/ */
export function createWizardFlow(action: XOTemplateAction): WizardFlow { export function createWizardFlow(action: XOTemplateAction): WizardFlow {
if (action.data?.length && !action.transaction) { if (action.data?.length && !action.transaction) {
return new DataWizardFlow(action.data); return new DataWizardFlow([action.data]);
} }
return new TransactionWizardFlow(); return new TransactionWizardFlow();
} }

View File

@@ -1,6 +1,7 @@
import React from 'react'; import React from 'react';
import { Box, Text } from 'ink'; import { Box, Text } from 'ink';
import { colors, formatSatoshis, formatHex } from '../../../theme.js'; import { colors, formatSatoshis, formatHex } from '../../../theme.js';
import { useSatoshisConversion } from '../../../hooks/useSatoshisConversion.js';
import type { SelectableUTXO, FocusArea } from '../types.js'; import type { SelectableUTXO, FocusArea } from '../types.js';
interface Props { interface Props {
@@ -22,6 +23,13 @@ export function InputsStep({
changeAmount, changeAmount,
focusArea, focusArea,
}: Props): React.ReactElement { }: Props): React.ReactElement {
const { formatSatoshisToFiat } = useSatoshisConversion('USD');
const getFiatSuffix = (satoshis: bigint): string => {
const fiatValue = formatSatoshisToFiat(satoshis);
return fiatValue ? ` (~${fiatValue})` : '';
};
return ( return (
<Box flexDirection='column'> <Box flexDirection='column'>
<Text color={colors.text} bold> <Text color={colors.text} bold>
@@ -32,6 +40,7 @@ export function InputsStep({
<Text color={colors.textMuted}> <Text color={colors.textMuted}>
Required: {formatSatoshis(requiredAmount)} +{' '} Required: {formatSatoshis(requiredAmount)} +{' '}
{formatSatoshis(fee)} fee {formatSatoshis(fee)} fee
{getFiatSuffix(requiredAmount + fee)}
</Text> </Text>
<Text <Text
color={ color={
@@ -41,10 +50,12 @@ export function InputsStep({
} }
> >
Selected: {formatSatoshis(selectedAmount)} Selected: {formatSatoshis(selectedAmount)}
{getFiatSuffix(selectedAmount)}
</Text> </Text>
{selectedAmount > requiredAmount + fee && ( {selectedAmount > requiredAmount + fee && (
<Text color={colors.info}> <Text color={colors.info}>
Change: {formatSatoshis(changeAmount)} Change: {formatSatoshis(changeAmount)}
{getFiatSuffix(changeAmount)}
</Text> </Text>
)} )}
</Box> </Box>
@@ -65,6 +76,7 @@ export function InputsStep({
return ( return (
<Box <Box
key={`${utxo.outpointTransactionHash}:${utxo.outpointIndex}`} key={`${utxo.outpointTransactionHash}:${utxo.outpointIndex}`}
flexDirection='column'
> >
<Text <Text
color={isCursor ? colors.focus : colors.text} color={isCursor ? colors.focus : colors.text}
@@ -75,6 +87,15 @@ export function InputsStep({
{formatHex(utxo.outpointTransactionHash, 12)}: {formatHex(utxo.outpointTransactionHash, 12)}:
{utxo.outpointIndex} {utxo.outpointIndex}
</Text> </Text>
{(() => {
const fiatValue = formatSatoshisToFiat(utxo.valueSatoshis);
if (!fiatValue) return null;
return (
<Text color={colors.textMuted}>
{' '} {fiatValue}
</Text>
);
})()}
</Box> </Box>
); );
}) })

View File

@@ -1,6 +1,7 @@
import React from 'react'; import React from 'react';
import { Box, Text } from 'ink'; import { Box, Text } from 'ink';
import { colors, formatSatoshis } from '../../../theme.js'; import { colors, formatSatoshis } from '../../../theme.js';
import { useSatoshisConversion } from '../../../hooks/useSatoshisConversion.js';
import type { VariableInput, SelectableUTXO } from '../types.js'; import type { VariableInput, SelectableUTXO } from '../types.js';
import type { XOTemplate } from '@xo-cash/types'; import type { XOTemplate } from '@xo-cash/types';
@@ -22,6 +23,32 @@ export function ReviewStep({
changeAmount, changeAmount,
}: ReviewStepProps): React.ReactElement { }: ReviewStepProps): React.ReactElement {
const selectedUtxos = availableUtxos.filter((u) => u.selected); const selectedUtxos = availableUtxos.filter((u) => u.selected);
const { formatSatoshisToFiat } = useSatoshisConversion('USD');
const getFiatSuffix = (satoshis: bigint): string => {
const fiatValue = formatSatoshisToFiat(satoshis);
return fiatValue ? ` (~${fiatValue})` : '';
};
const getVariableFiatSuffix = (variable: VariableInput): string => {
if (variable.type !== 'integer') {
return '';
}
if (variable.hint?.toLowerCase().includes('satoshi') !== true) {
return '';
}
if (!/^[-]?\d+$/.test(variable.value.trim())) {
return '';
}
try {
return getFiatSuffix(BigInt(variable.value));
} catch {
return '';
}
};
return ( return (
<Box flexDirection='column'> <Box flexDirection='column'>
@@ -44,6 +71,7 @@ export function ReviewStep({
<Text key={v.id} color={colors.textMuted}> <Text key={v.id} color={colors.textMuted}>
{' '} {' '}
{v.name}: {v.value || '(empty)'} {v.name}: {v.value || '(empty)'}
{v.value ? getVariableFiatSuffix(v) : ''}
</Text> </Text>
))} ))}
</Box> </Box>
@@ -62,6 +90,7 @@ export function ReviewStep({
> >
{' '} {' '}
{formatSatoshis(u.valueSatoshis)} {formatSatoshis(u.valueSatoshis)}
{getFiatSuffix(u.valueSatoshis)}
</Text> </Text>
))} ))}
{selectedUtxos.length > 3 && ( {selectedUtxos.length > 3 && (
@@ -78,6 +107,7 @@ export function ReviewStep({
<Text color={colors.text}>Outputs:</Text> <Text color={colors.text}>Outputs:</Text>
<Text color={colors.textMuted}> <Text color={colors.textMuted}>
{' '}Change: {formatSatoshis(changeAmount)} {' '}Change: {formatSatoshis(changeAmount)}
{getFiatSuffix(changeAmount)}
</Text> </Text>
</Box> </Box>
)} )}

View File

@@ -61,13 +61,16 @@ export function RoleSelectStep({
{availableRoles.length === 0 ? ( {availableRoles.length === 0 ? (
<Text color={colors.textMuted}>No roles available</Text> <Text color={colors.textMuted}>No roles available</Text>
) : ( ) : (
availableRoles.map((roleId, index) => { availableRoles.map((roleId: string, index: number) => {
const isCursor = const isCursor =
selectedRoleIndex === index && focusArea === 'content'; selectedRoleIndex === index && focusArea === 'content';
const roleDef = template.roles?.[roleId]; const roleDef = template.roles?.[roleId];
const actionRole = action?.roles?.[roleId]; const actionRole = action?.roles?.[roleId];
const requirements = actionRole?.requirements; const requirements = actionRole?.requirements;
const actionRequirements = action?.requirements;
const actionRoleRequirements = actionRole && actionRequirements && actionRequirements.participants?.find((participant) => participant.role === roleId);
return ( return (
<Box key={roleId} flexDirection="column" marginY={0}> <Box key={roleId} flexDirection="column" marginY={0}>
<Text <Text
@@ -96,10 +99,10 @@ export function RoleSelectStep({
{' '} {' '}
</Text> </Text>
)} )}
{requirements.slots && requirements.slots.min > 0 && ( {actionRoleRequirements && actionRoleRequirements.slots && actionRoleRequirements.slots.min > 0 && (
<Text color={colors.textMuted} dimColor> <Text color={colors.textMuted} dimColor>
{requirements.slots.min} input slot {actionRoleRequirements.slots.min} input slot
{requirements.slots.min !== 1 ? 's' : ''} {actionRoleRequirements.slots.min !== 1 ? 's' : ''}
</Text> </Text>
)} )}
</Box> </Box>

View File

@@ -17,6 +17,7 @@ import { useNavigation } from '../../hooks/useNavigation.js';
import { useAppContext, useStatus } from '../../hooks/useAppContext.js'; import { useAppContext, useStatus } from '../../hooks/useAppContext.js';
import { useBlockableInput, useIsInputCaptured } from '../../hooks/useInputLayer.js'; import { useBlockableInput, useIsInputCaptured } from '../../hooks/useInputLayer.js';
import { useInvitations } from '../../hooks/useInvitations.js'; import { useInvitations } from '../../hooks/useInvitations.js';
import { useSatoshisConversion } from '../../hooks/useSatoshisConversion.js';
import { colors, logoSmall, formatSatoshis } from '../../theme.js'; import { colors, logoSmall, formatSatoshis } from '../../theme.js';
import { copyToClipboard } from '../../utils/clipboard.js'; import { copyToClipboard } from '../../utils/clipboard.js';
import type { Invitation } from '../../../services/invitation.js'; import type { Invitation } from '../../../services/invitation.js';
@@ -88,6 +89,8 @@ export function InvitationScreen(): React.ReactElement {
const { setStatus } = useStatus(); const { setStatus } = useStatus();
const invitations = useInvitations(); const invitations = useInvitations();
const { currencyCode, formattedFiatPerBchRate, formatSatoshisToFiat } =
useSatoshisConversion('USD');
// ── UI state ───────────────────────────────────────────────────────────── // ── UI state ─────────────────────────────────────────────────────────────
const [selectedIndex, setSelectedIndex] = useState(0); const [selectedIndex, setSelectedIndex] = useState(0);
@@ -99,6 +102,7 @@ export function InvitationScreen(): React.ReactElement {
// Two phases: first the ID input dialog, then the multi-step import flow. // Two phases: first the ID input dialog, then the multi-step import flow.
const [showIdDialog, setShowIdDialog] = useState(false); const [showIdDialog, setShowIdDialog] = useState(false);
const [importingId, setImportingId] = useState<string | null>(null); const [importingId, setImportingId] = useState<string | null>(null);
const [pendingImportedInvitationId, setPendingImportedInvitationId] = useState<string | null>(null);
// ── Template cache ─────────────────────────────────────────────────────── // ── Template cache ───────────────────────────────────────────────────────
const [templateCache, setTemplateCache] = useState<Map<string, XOTemplate>>(new Map()); const [templateCache, setTemplateCache] = useState<Map<string, XOTemplate>>(new Map());
@@ -158,7 +162,7 @@ export function InvitationScreen(): React.ReactElement {
}); });
return [importItem, ...invitationItems]; return [importItem, ...invitationItems];
}, [invitations, templateCache]); }, [invitations.length, templateCache]);
const selectedItem = listItems[selectedIndex]; const selectedItem = listItems[selectedIndex];
const selectedInvitation = selectedItem?.value ?? null; const selectedInvitation = selectedItem?.value ?? null;
@@ -193,10 +197,30 @@ export function InvitationScreen(): React.ReactElement {
/** /**
* Import flow closed (completed or cancelled). * Import flow closed (completed or cancelled).
*/ */
const handleImportFlowClose = useCallback(() => { const handleImportFlowClose = useCallback((importedInvitationId?: string) => {
if (importedInvitationId) {
setPendingImportedInvitationId(importedInvitationId);
}
setImportingId(null); setImportingId(null);
}, []); }, []);
/**
* Once imported invitation is visible in the list, select and focus it.
*/
useEffect(() => {
if (!pendingImportedInvitationId) return;
const importedIndex = listItems.findIndex((item) => {
return item.value?.data.invitationIdentifier === pendingImportedInvitationId;
});
if (importedIndex >= 0) {
setSelectedIndex(importedIndex);
setFocusedPanel('list');
setPendingImportedInvitationId(null);
}
}, [pendingImportedInvitationId, listItems]);
// ── Action handlers ──────────────────────────────────────────────────── // ── Action handlers ────────────────────────────────────────────────────
const acceptInvitation = useCallback(async () => { const acceptInvitation = useCallback(async () => {
@@ -327,10 +351,10 @@ export function InvitationScreen(): React.ReactElement {
const seenLockingBytecodes = new Set<string>(); const seenLockingBytecodes = new Set<string>();
for (const utxo of utxos) { for (const utxo of utxos) {
const lockingBytecodeHex = utxo.lockingBytecode const lockingBytecodeHex = utxo.scriptHash
? typeof utxo.lockingBytecode === 'string' ? typeof utxo.scriptHash === 'string'
? utxo.lockingBytecode ? utxo.scriptHash
: Buffer.from(utxo.lockingBytecode).toString('hex') : Buffer.from(utxo.scriptHash).toString('hex')
: undefined; : undefined;
if (lockingBytecodeHex && seenLockingBytecodes.has(lockingBytecodeHex)) continue; if (lockingBytecodeHex && seenLockingBytecodes.has(lockingBytecodeHex)) continue;
@@ -494,6 +518,44 @@ export function InvitationScreen(): React.ReactElement {
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 getFiatSuffix = (satoshis: bigint): string => {
const fiatValue = formatSatoshisToFiat(satoshis);
return fiatValue ? ` (~${fiatValue})` : '';
};
const parseNumberishToBigInt = (value: unknown): bigint | null => {
if (typeof value === 'bigint') {
return value;
}
const asString = String(value).trim();
if (!/^[-]?\d+$/.test(asString)) {
return null;
}
try {
return BigInt(asString);
} catch {
return null;
}
};
const isSatoshisVariable = (variableIdentifier: string): boolean => {
const templateVariable = selectedTemplate?.variables?.[variableIdentifier];
const templateType = templateVariable?.type?.toLowerCase();
const templateHint = templateVariable?.hint?.toLowerCase();
const identifier = variableIdentifier.toLowerCase();
if (templateHint?.includes('satoshi')) {
return true;
}
return (
templateType === 'integer' &&
(identifier.includes('satoshi') || identifier.includes('amount'))
);
};
return ( return (
<Box flexDirection="column"> <Box flexDirection="column">
{/* Type & Status */} {/* Type & Status */}
@@ -514,6 +576,11 @@ export function InvitationScreen(): React.ReactElement {
<Text color={colors.textMuted}> <Text color={colors.textMuted}>
Action: {action?.name ?? selectedInvitation.data.actionIdentifier} Action: {action?.name ?? selectedInvitation.data.actionIdentifier}
</Text> </Text>
{formattedFiatPerBchRate && (
<Text color={colors.textMuted}>
1 BCH = {formattedFiatPerBchRate}
</Text>
)}
{action?.description && ( {action?.description && (
<Text color={colors.textMuted} dimColor>{action.description}</Text> <Text color={colors.textMuted} dimColor>{action.description}</Text>
)} )}
@@ -542,6 +609,11 @@ 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 inputTemplate = selectedTemplate?.inputs?.[input.inputIdentifier ?? ''];
const inputSatoshis = (
'valueSatoshis' in input && input.valueSatoshis !== undefined
)
? parseNumberishToBigInt(input.valueSatoshis)
: null;
return ( return (
<Text <Text
key={`input-${idx}`} key={`input-${idx}`}
@@ -550,6 +622,7 @@ export function InvitationScreen(): React.ReactElement {
{' '}{isUserInput ? '• ' : '○ '} {' '}{isUserInput ? '• ' : '○ '}
{inputTemplate?.name ?? input.inputIdentifier ?? `Input ${idx}`} {inputTemplate?.name ?? input.inputIdentifier ?? `Input ${idx}`}
{input.roleIdentifier && ` (${input.roleIdentifier})`} {input.roleIdentifier && ` (${input.roleIdentifier})`}
{inputSatoshis !== null && ` ${formatSatoshis(inputSatoshis)}${getFiatSuffix(inputSatoshis)}`}
</Text> </Text>
); );
}) })
@@ -564,6 +637,9 @@ 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 outputTemplate = selectedTemplate?.outputs?.[output.outputIdentifier ?? ''];
const outputSatoshis = output.valueSatoshis !== undefined
? parseNumberishToBigInt(output.valueSatoshis)
: null;
return ( return (
<Text <Text
key={`output-${idx}`} key={`output-${idx}`}
@@ -571,7 +647,7 @@ export function InvitationScreen(): React.ReactElement {
> >
{' '}{isUserOutput ? '• ' : '○ '} {' '}{isUserOutput ? '• ' : '○ '}
{outputTemplate?.name ?? output.outputIdentifier ?? `Output ${idx}`} {outputTemplate?.name ?? output.outputIdentifier ?? `Output ${idx}`}
{output.valueSatoshis !== undefined && ` (${formatSatoshis(output.valueSatoshis)})`} {outputSatoshis !== null && ` (${formatSatoshis(outputSatoshis)}${getFiatSuffix(outputSatoshis)})`}
</Text> </Text>
); );
}) })
@@ -591,6 +667,9 @@ export function InvitationScreen(): React.ReactElement {
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)
? parseNumberishToBigInt(variable.value)
: null;
return ( return (
<Text <Text
key={`var-${idx}`} key={`var-${idx}`}
@@ -598,6 +677,8 @@ export function InvitationScreen(): React.ReactElement {
> >
{' '}{isUserVariable ? '• ' : '○ '} {' '}{isUserVariable ? '• ' : '○ '}
{varTemplate?.name ?? variable.variableIdentifier}: {displayValue} {varTemplate?.name ?? variable.variableIdentifier}: {displayValue}
{parsedVariableSatoshis !== null &&
` (${formatSatoshis(parsedVariableSatoshis)}${getFiatSuffix(parsedVariableSatoshis)})`}
{varTemplate?.description && ( {varTemplate?.description && (
<Text color={colors.textMuted} dimColor> - {varTemplate.description}</Text> <Text color={colors.textMuted} dimColor> - {varTemplate.description}</Text>
)} )}

View File

@@ -148,7 +148,7 @@ export function InvitationImportFlow({
`Action: ${invitation?.data.actionIdentifier ?? 'Unknown'}` `Action: ${invitation?.data.actionIdentifier ?? 'Unknown'}`
); );
setStatus('Ready'); setStatus('Ready');
onClose(); onClose(invitation?.data.invitationIdentifier);
}, [selectedRole, template, invitation, showInfo, setStatus, onClose]); }, [selectedRole, template, invitation, showInfo, setStatus, onClose]);
// ── Keyboard handling ──────────────────────────────────────────────────── // ── Keyboard handling ────────────────────────────────────────────────────

View File

@@ -9,6 +9,7 @@
import React, { useState, useEffect, useCallback, useRef } from 'react'; import React, { useState, useEffect, useCallback, useRef } from 'react';
import { Box, Text } from 'ink'; import { Box, Text } from 'ink';
import { colors, formatSatoshis } from '../../../../theme.js'; import { colors, formatSatoshis } from '../../../../theme.js';
import { useSatoshisConversion } from '../../../../hooks/useSatoshisConversion.js';
import { useLayeredInput } from '../../../../hooks/useInputLayer.js'; import { useLayeredInput } from '../../../../hooks/useInputLayer.js';
import type { InputsSelectStepProps, SelectableUTXO } from '../types.js'; import type { InputsSelectStepProps, SelectableUTXO } from '../types.js';
import { autoSelectGreedyUtxos, mapUnspentOutputsToSelectable } from '../../../../../utils/invitation-flow.js'; import { autoSelectGreedyUtxos, mapUnspentOutputsToSelectable } from '../../../../../utils/invitation-flow.js';
@@ -32,6 +33,7 @@ export function InputsSelectStep({
const [requiredAmount, setRequiredAmount] = useState(0n); const [requiredAmount, setRequiredAmount] = useState(0n);
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const { formatSatoshisToFiat } = useSatoshisConversion('USD');
const fee = DEFAULT_FEE; const fee = DEFAULT_FEE;
@@ -42,6 +44,11 @@ export function InputsSelectStep({
const changeAmount = selectedAmount - requiredAmount - fee; const changeAmount = selectedAmount - requiredAmount - fee;
const hasEnough = selectedAmount >= requiredAmount + fee; const hasEnough = selectedAmount >= requiredAmount + fee;
const getFiatSuffix = (satoshis: bigint): string => {
const fiatValue = formatSatoshisToFiat(satoshis);
return fiatValue ? ` (~${fiatValue})` : '';
};
/** /**
* Determine the required satoshi amount from the invitation's variables. * Determine the required satoshi amount from the invitation's variables.
*/ */
@@ -94,10 +101,7 @@ export function InputsSelectStep({
// Create a map of the utxoID to suitable resource // Create a map of the utxoID to suitable resource
const utxoIdToSuitableResource = new Map<string, UnspentOutputData>(); const utxoIdToSuitableResource = new Map<string, UnspentOutputData>();
for (const outputIdentifier of outputIdentifiers) { for (const outputIdentifier of outputIdentifiers) {
const suitableResources = await invitation.findSuitableResources({ const suitableResources = await invitation.findSuitableResources();
outputIdentifier,
});
for (const suitableResource of suitableResources) { for (const suitableResource of suitableResources) {
utxoIdToSuitableResource.set(suitableResource.outpointTransactionHash + ':' + suitableResource.outpointIndex, suitableResource); utxoIdToSuitableResource.set(suitableResource.outpointTransactionHash + ':' + suitableResource.outpointIndex, suitableResource);
} }
@@ -196,18 +200,32 @@ export function InputsSelectStep({
{/* Summary bar */} {/* Summary bar */}
<Box flexDirection="row" marginBottom={1}> <Box flexDirection="row" marginBottom={1}>
<Text color={colors.primary} bold>Required: </Text> <Text color={colors.primary} bold>Required: </Text>
<Text color={colors.text}>{formatSatoshis(requiredAmount + fee)}</Text> <Text color={colors.text}>
{formatSatoshis(requiredAmount + fee)}
{getFiatSuffix(requiredAmount + fee)}
</Text>
<Text color={colors.textMuted}> (amount {formatSatoshis(requiredAmount)} + fee {formatSatoshis(fee)})</Text> <Text color={colors.textMuted}> (amount {formatSatoshis(requiredAmount)} + fee {formatSatoshis(fee)})</Text>
</Box> </Box>
<Box flexDirection="row" marginBottom={1}> <Box flexDirection="row" marginBottom={1}>
<Text color={colors.primary} bold>Selected: </Text> <Text color={colors.primary} bold>Selected: </Text>
<Text color={hasEnough ? colors.success : colors.error}>{formatSatoshis(selectedAmount)}</Text> <Text color={hasEnough ? colors.success : colors.error}>
{formatSatoshis(selectedAmount)}
{getFiatSuffix(selectedAmount)}
</Text>
{hasEnough && changeAmount >= DUST_THRESHOLD && ( {hasEnough && changeAmount >= DUST_THRESHOLD && (
<Text color={colors.textMuted}> (change: {formatSatoshis(changeAmount)})</Text> <Text color={colors.textMuted}>
{' '}
(change: {formatSatoshis(changeAmount)}
{getFiatSuffix(changeAmount)})
</Text>
)} )}
{!hasEnough && ( {!hasEnough && (
<Text color={colors.error}> need {formatSatoshis(requiredAmount + fee - selectedAmount)} more</Text> <Text color={colors.error}>
{' '}
need {formatSatoshis(requiredAmount + fee - selectedAmount)}
{getFiatSuffix(requiredAmount + fee - selectedAmount)} more
</Text>
)} )}
</Box> </Box>
@@ -219,13 +237,22 @@ export function InputsSelectStep({
const txShort = utxo.outpointTransactionHash.slice(0, 8); const txShort = utxo.outpointTransactionHash.slice(0, 8);
return ( return (
<Text <Box
key={`${utxo.outpointTransactionHash}:${utxo.outpointIndex}`} key={`${utxo.outpointTransactionHash}:${utxo.outpointIndex}`}
flexDirection="column"
>
<Text
color={isFocused ? colors.focus : utxo.selected ? colors.success : colors.text} color={isFocused ? colors.focus : utxo.selected ? colors.success : colors.text}
bold={isFocused} bold={isFocused}
> >
{isFocused ? '▸ ' : ' '}{checkMark} {formatSatoshis(utxo.valueSatoshis)} ({txShort}:{utxo.outpointIndex}) {isFocused ? '▸ ' : ' '}{checkMark} {formatSatoshis(utxo.valueSatoshis)} ({txShort}:{utxo.outpointIndex})
</Text> </Text>
{formatSatoshisToFiat(utxo.valueSatoshis) && (
<Text color={colors.textMuted}>
{' '} {formatSatoshisToFiat(utxo.valueSatoshis)}
</Text>
)}
</Box>
); );
})} })}

View File

@@ -9,6 +9,7 @@
import React from 'react'; import React from 'react';
import { Box, Text } from 'ink'; import { Box, Text } from 'ink';
import { colors, formatSatoshis } from '../../../../theme.js'; import { colors, formatSatoshis } from '../../../../theme.js';
import { useSatoshisConversion } from '../../../../hooks/useSatoshisConversion.js';
import { useLayeredInput } from '../../../../hooks/useInputLayer.js'; import { useLayeredInput } from '../../../../hooks/useInputLayer.js';
import { import {
getInvitationState, getInvitationState,
@@ -41,6 +42,8 @@ export function PreviewInvitationStep({
onCancel, onCancel,
isActive, isActive,
}: PreviewStepProps): React.ReactElement { }: PreviewStepProps): React.ReactElement {
const { formatSatoshisToFiat } = useSatoshisConversion('USD');
useLayeredInput('import-flow', (_input, key) => { useLayeredInput('import-flow', (_input, key) => {
if (key.return) onComplete(); if (key.return) onComplete();
if (key.escape) onCancel(); if (key.escape) onCancel();
@@ -168,11 +171,15 @@ export function PreviewInvitationStep({
) : ( ) : (
outputs.map((output, idx) => { outputs.map((output, idx) => {
const outputTemplate = template?.outputs?.[output.outputIdentifier ?? '']; const outputTemplate = template?.outputs?.[output.outputIdentifier ?? ''];
const fiatValue = output.valueSatoshis !== undefined
? formatSatoshisToFiat(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}`} {' '} {outputTemplate?.name ?? output.outputIdentifier ?? `Output ${idx}`}
{output.valueSatoshis !== undefined && ` (${formatSatoshis(output.valueSatoshis)})`} {output.valueSatoshis !== undefined && ` (${formatSatoshis(output.valueSatoshis)})`}
{fiatValue && ` (~${fiatValue})`}
</Text> </Text>
</Box> </Box>
); );

View File

@@ -10,6 +10,7 @@
import React, { useState, useCallback } from 'react'; import React, { useState, useCallback } from 'react';
import { Box, Text } from 'ink'; import { Box, Text } from 'ink';
import { colors, formatSatoshis } from '../../../../theme.js'; import { colors, formatSatoshis } from '../../../../theme.js';
import { useSatoshisConversion } from '../../../../hooks/useSatoshisConversion.js';
import { useLayeredInput } from '../../../../hooks/useInputLayer.js'; import { useLayeredInput } from '../../../../hooks/useInputLayer.js';
import type { ReviewStepProps, SelectableUTXO } from '../types.js'; import type { ReviewStepProps, SelectableUTXO } from '../types.js';
@@ -32,6 +33,7 @@ export function ReviewStep({
}: ReviewStepProps): React.ReactElement { }: ReviewStepProps): React.ReactElement {
const [isSubmitting, setIsSubmitting] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const { formatSatoshisToFiat } = useSatoshisConversion('USD');
const fee = DEFAULT_FEE; const fee = DEFAULT_FEE;
const action = template?.actions?.[invitation.data.actionIdentifier]; const action = template?.actions?.[invitation.data.actionIdentifier];
@@ -39,6 +41,11 @@ export function ReviewStep({
// Compute totals from selected inputs // Compute totals from selected inputs
const totalSelected = selectedInputs.reduce((sum, u) => sum + u.valueSatoshis, 0n); const totalSelected = selectedInputs.reduce((sum, u) => sum + u.valueSatoshis, 0n);
const getFiatSuffix = (satoshis: bigint): string => {
const fiatValue = formatSatoshisToFiat(satoshis);
return fiatValue ? ` (~${fiatValue})` : '';
};
/** /**
* Execute the import: add inputs (with role) and optional change output. * Execute the import: add inputs (with role) and optional change output.
*/ */
@@ -85,14 +92,34 @@ export function ReviewStep({
<Box marginTop={1} flexDirection="column"> <Box marginTop={1} flexDirection="column">
<Text color={colors.primary} bold>Funding:</Text> <Text color={colors.primary} bold>Funding:</Text>
<Text color={colors.text}> UTXOs: {selectedInputs.length}</Text> <Text color={colors.text}> UTXOs: {selectedInputs.length}</Text>
<Text color={colors.text}> Total: {formatSatoshis(totalSelected)}</Text> <Text color={colors.text}> Total: {formatSatoshis(totalSelected)}{getFiatSuffix(totalSelected)}</Text>
<Text color={colors.text}> Required: {formatSatoshis(requiredAmount)}</Text> <Text color={colors.text}> Required: {formatSatoshis(requiredAmount)}{getFiatSuffix(requiredAmount)}</Text>
<Text color={colors.text}> Fee: {formatSatoshis(fee)}</Text> <Text color={colors.text}> Fee: {formatSatoshis(fee)}{getFiatSuffix(fee)}</Text>
{changeAmount >= DUST_THRESHOLD && ( {changeAmount >= DUST_THRESHOLD && (
<Text color={colors.text}> Change: {formatSatoshis(changeAmount)}</Text> <Text color={colors.text}> Change: {formatSatoshis(changeAmount)}{getFiatSuffix(changeAmount)}</Text>
)} )}
</Box> </Box>
{selectedInputs.length > 0 && (
<Box marginTop={1} flexDirection="column">
<Text color={colors.primary} bold>Selected UTXOs:</Text>
{selectedInputs.slice(0, 3).map((utxo) => (
<Text
key={`${utxo.outpointTransactionHash}:${utxo.outpointIndex}`}
color={colors.textMuted}
>
{' '} {formatSatoshis(utxo.valueSatoshis)}
{getFiatSuffix(utxo.valueSatoshis)}
</Text>
))}
{selectedInputs.length > 3 && (
<Text color={colors.textMuted}>
{' '}...and {selectedInputs.length - 3} more
</Text>
)}
</Box>
)}
{/* Error display */} {/* Error display */}
{error && ( {error && (
<Box marginTop={1}> <Box marginTop={1}>

View File

@@ -116,8 +116,12 @@ export interface ImportFlowProps {
mode: ImportFlowMode; mode: ImportFlowMode;
/** The application service — injected, not pulled from context. */ /** The application service — injected, not pulled from context. */
appService: AppService; appService: AppService;
/** Called when the flow completes or is cancelled. */ /**
onClose: () => void; * Called when the flow completes or is cancelled.
* When import succeeds, the invitation identifier is provided so callers can
* select/focus the imported invitation in their UI.
*/
onClose: (importedInvitationId?: string) => void;
/** Display an error message to the user. */ /** Display an error message to the user. */
showError: (message: string) => void; showError: (message: string) => void;
/** Display an info message to the user. */ /** Display an info message to the user. */

View File

@@ -8,6 +8,36 @@ import { promisify } from "util";
const execAsync = promisify(exec); const execAsync = promisify(exec);
// Define a list of clipboard methods with their platform and command.
// The platform is a function that returns true if the method is available on the current platform.
// The command is a function that returns a promise that resolves to the result of the command.
const clipboardMethods = {
pbCopy: {
platform: (platform: string) => platform === 'darwin',
command: async (text: string) => execAsync(`printf '%s' '${text}' | pbcopy`),
},
xclip: {
platform: (platform: string) => platform === 'linux',
command: async (text: string) => execAsync(`printf '%s' '${text}' | xclip -selection clipboard`),
},
xsel: {
platform: (platform: string) => platform === 'linux',
command: async (text: string) => execAsync(`printf '%s' '${text}' | xsel --clipboard --input`),
},
ssh: {
platform: (platform: string) => platform === 'linux',
command: async (text: string) => process.stdout.write(`\x1b]52;c;${Buffer.from(text, 'utf-8').toString('base64')}\x07`),
},
clip: {
platform: (platform: string) => platform === 'windows',
command: async (text: string) => execAsync(`echo|set /p="${text}" | clip`),
},
clipboardy: {
platform: (platform: string) => platform === 'windows',
command: async (text: string) => clipboardy.writeSync(text),
},
}
/** /**
* Attempts to copy text to clipboard using multiple methods. * Attempts to copy text to clipboard using multiple methods.
* Tries native commands first (most reliable), then clipboardy as fallback. * Tries native commands first (most reliable), then clipboardy as fallback.
@@ -21,46 +51,25 @@ export async function copyToClipboard(text: string): Promise<void> {
// Escape the text for shell commands // Escape the text for shell commands
const escapedText = text.replace(/'/g, "'\\''"); const escapedText = text.replace(/'/g, "'\\''");
// Try native commands first - they're more reliable const availableMethods = Object.values(clipboardMethods).filter(method => method.platform(platform));
try {
if (platform === "darwin") {
// macOS - use pbcopy directly
await execAsync(`printf '%s' '${escapedText}' | pbcopy`);
return;
} else if (platform === "linux") {
// Linux - try xclip, then xsel
try {
await execAsync(
`printf '%s' '${escapedText}' | xclip -selection clipboard`,
);
return;
} catch {
try {
await execAsync(
`printf '%s' '${escapedText}' | xsel --clipboard --input`,
);
return;
} catch {
// Fall through to clipboardy
}
}
} else if (platform === "win32") {
// Windows - use clip.exe
await execAsync(`echo|set /p="${text}" | clip`);
return;
}
} catch {
// Native command failed, try clipboardy
}
// Fallback to clipboardy const errors: Error[] = [];
for (const method of availableMethods) {
try { try {
clipboardy.writeSync(text); if (method.platform(platform)) {
await method.command(escapedText);
} else {
continue;
}
return; return;
} catch { } catch(error) {
// clipboardy also failed if (error instanceof Error) {
errors.push(error);
}
}
} }
// All methods failed // All methods failed
throw new Error(`Clipboard not available. Install xclip or xsel on Linux.`); throw new Error(`Clipboard not available. ${errors.map(error => error.message).join('\n')}`);
} }

View File

@@ -45,7 +45,8 @@ export const resolveActionRoles = (
const starts = template.start ?? []; const starts = template.start ?? [];
const roleIds = starts const roleIds = starts
.filter((entry) => entry.action === actionIdentifier) .filter((entry) => entry.action === actionIdentifier)
.map((entry) => entry.role); .map((entry) => entry.role)
.filter((roleId) => roleId !== undefined);
return [...new Set(roleIds)]; return [...new Set(roleIds)];
}; };
@@ -60,17 +61,11 @@ export const roleRequiresInputs = (
if (!action) return false; if (!action) return false;
const actionRole = action.roles?.[roleIdentifier]; const actionRole = action.roles?.[roleIdentifier];
const roleSlotsMin = actionRole?.requirements?.slots?.min ?? 0; const actionRequirements = action.requirements;
const actionRoleRequirements = actionRole && actionRequirements && actionRequirements.participants?.find((participant) => participant.role === roleIdentifier);
const roleSlotsMin = actionRoleRequirements && actionRoleRequirements.slots && actionRoleRequirements.slots.min > 0 ? actionRoleRequirements.slots.min : 0;
if (roleSlotsMin > 0) return true; if (roleSlotsMin > 0) return true;
// Some templates specify slot/input requirements at action.requirements.roles
// instead of role.requirements. Respect those as well.
const roleRequirement = action.requirements?.roles?.find(
(requirement) => requirement.role === roleIdentifier,
);
const actionLevelSlotsMin = roleRequirement?.slots?.min ?? 0;
if (actionLevelSlotsMin > 0) return true;
const transactionIdentifier = action.transaction; const transactionIdentifier = action.transaction;
const transaction = transactionIdentifier const transaction = transactionIdentifier
? template.transactions?.[transactionIdentifier] ? template.transactions?.[transactionIdentifier]
@@ -132,13 +127,12 @@ export const resolveProvidedLockingBytecodeHex = (
variableValues: Record<string, string>, variableValues: Record<string, string>,
): string | undefined => { ): string | undefined => {
const outputDefinition = template.outputs?.[outputIdentifier]; const outputDefinition = template.outputs?.[outputIdentifier];
if (!outputDefinition || typeof outputDefinition.lockscript !== "string") if (!outputDefinition || typeof outputDefinition.lockingScript !== "string") {
return undefined; return undefined;
}
const lockingScriptDefinition = ( const lockingScriptDefinition = template.lockingScripts?.[outputDefinition.lockingScript];
template.lockingScripts as Record<string, unknown> | undefined const scriptIdentifier = lockingScriptDefinition?.lockingBytecode;
)?.[outputDefinition.lockscript] as { lockingScript?: string } | undefined;
const scriptIdentifier = lockingScriptDefinition?.lockingScript;
if (!scriptIdentifier) return undefined; if (!scriptIdentifier) return undefined;
const scriptExpression = ( const scriptExpression = (

70
src/utils/paths.ts Normal file
View File

@@ -0,0 +1,70 @@
/**
* Global XO CLI config layout (XDG-style: ~/.config/xo-cli/).
* User-provided paths (templates, invitation JSON) stay relative to cwd.
*/
import { existsSync, mkdirSync } from "node:fs";
import { homedir } from "node:os";
import { basename, isAbsolute, join, resolve } from "node:path";
/**
* Base config directory. Created on first access.
*/
export function getConfigDir(): string {
const dir = join(homedir(), ".config", "xo-cli");
mkdirSync(dir, { recursive: true });
return dir;
}
/**
* Directory for mnemonic wallet files (mnemonic-*).
*/
export function getMnemonicsDir(): string {
const dir = join(getConfigDir(), "mnemonics");
mkdirSync(dir, { recursive: true });
return dir;
}
/**
* Directory for engine DB and invitation storage SQLite files.
*/
export function getDataDir(): string {
const dir = join(getConfigDir(), "data");
mkdirSync(dir, { recursive: true });
return dir;
}
/**
* File storing the last-used mnemonic reference for `-m` omission.
*/
export function getWalletConfigPath(): string {
return join(getConfigDir(), ".wallet");
}
/**
* Resolves a mnemonic reference to an absolute path.
* Order: absolute path if it exists → path relative to cwd → ~/.config/xo-cli/mnemonics/<basename>.
*
* @param mnemonicRef - Path or basename (e.g. `mnemonic-nuclear`)
* @returns Absolute path to the mnemonic file
* @throws If no matching file exists
*/
export function resolveMnemonicFilePath(mnemonicRef: string): string {
if (isAbsolute(mnemonicRef) && existsSync(mnemonicRef)) {
return mnemonicRef;
}
const relativeToCwd = resolve(process.cwd(), mnemonicRef);
if (existsSync(relativeToCwd)) {
return relativeToCwd;
}
const inMnemonics = join(getMnemonicsDir(), basename(mnemonicRef));
if (existsSync(inMnemonics)) {
return inMnemonics;
}
throw new Error(
`Mnemonic file not found: ${mnemonicRef}. Run "xo-cli mnemonic list" to see available files.`,
);
}

View File

@@ -0,0 +1,56 @@
import { EventEmitter } from '../event-emitter.js';
/**
* Events emitted by our Rates Adapters
*/
export type RatesEventMap = {
rateUpdated: {
numeratorUnitCode: string;
denominatorUnitCode: string;
price: number;
};
};
export abstract class BaseRates<
T extends RatesEventMap = RatesEventMap,
> extends EventEmitter<T> {
/** Starts the given rates adapter so that it will emit events on price updates. */
public abstract start(): Promise<void>;
/** Stops the given rates adapter so that it will stop checking for price updates. */
public abstract stop(): Promise<void>;
/**
* List all available market products (pairs).
* @returns A set of strings in the format "NUMERATOR/DENOMINATOR"
*/
public abstract listPairs(): Promise<Set<string>>;
// TODO: Consider whether we actually want the below.
// Ideally, we will want to replace this with something like the Units class:
// See: https://gitlab.com/GeneralProtocols/xo/stack/-/issues/44
/**
* Format the amount in the target currency to the correct number of decimal places.
*
* @param {number} amount - The amount to format.
* @param {string} targetCurrency - The target currency.
*
* @returns The formatted amount.
*/
public formatCurrency(amount: number, targetCurrency: string): string {
const minimumFractionDigitsMap: { [currency: string]: number } = {
AUD: 2,
BCH: 8,
USD: 2,
};
const formatter = new Intl.NumberFormat('en-US', {
style: 'currency',
currency: targetCurrency,
currencyDisplay: 'narrowSymbol',
minimumFractionDigits: minimumFractionDigitsMap[targetCurrency] || 0,
});
return formatter.format(amount);
}
}

View File

@@ -0,0 +1,170 @@
import {
OracleClient,
OracleMetadataMessage,
OraclePriceMessage,
type OracleMetadataMap,
} from '@generalprotocols/oracle-client';
import { type RatesEventMap, BaseRates } from './base-rates.js';
// Add the Oracle Price Message to our Events for this Adapter.
export type RatesOracleEventMap = RatesEventMap & {
rateUpdated: {
oraclePriceMessage: OraclePriceMessage;
};
};
// TODO: Add RatesHistorical trait since Oracles can provide historical rates.
export class RatesOracle extends BaseRates<RatesOracleEventMap> {
/**
* Create a new rates oracle.
*
* @param client The underlying oracle client. If not provided, a new client will be created.
* @returns The rates oracle.
*/
static async from(client?: OracleClient) {
const ratesOracle = new RatesOracle(client ?? (await OracleClient.from()));
return ratesOracle;
}
private client: OracleClient;
private oracles: OracleMetadataMap;
private started: boolean = false;
private constructor(client: OracleClient) {
super();
this.client = client;
this.oracles = {};
}
/**
* Start the rates oracle and the underlying client.
*/
async start() {
if (this.started) {
return;
}
this.started = true;
// Create event listeners for the client.
this.client.setOnMetadataMessage(this.handleMetadataMessage.bind(this));
this.client.setOnPriceMessage(this.handlePriceMessage.bind(this));
// Get the metadata for the client.
this.oracles = await this.client.getMetadataMap();
// Start the client.
await this.client.start();
// Refresh the prices.
await this.refreshPrices();
}
/**
* Stop the rates oracle and the underlying client.
*/
async stop() {
if (!this.started) {
return;
}
this.started = false;
// Remove event listeners by setting them to empty functions.
this.client.setOnMetadataMessage(() => {});
this.client.setOnPriceMessage(() => {});
await this.client.stop();
}
/**
* List the pairs that we are tracking.
*
* @returns A set of pairs.
*/
async listPairs() {
return new Set(
Object.values(this.oracles).map((oracle) => {
return `${oracle.SOURCE_NUMERATOR_UNIT_CODE}/${oracle.SOURCE_DENOMINATOR_UNIT_CODE}`;
}),
);
}
/**
* Get the latest prices for all the pairs and emit a rate updated event for each.
*/
public async refreshPrices() {
const oracles = await this.client.getOracles();
// For each oracle, get the lastest dataSequence (price) message and emit a rate updated event.
await Promise.allSettled(
oracles.map(async (oracle) => {
try {
const messages = await this.client.getOracleMessages({
publicKey: oracle.publicKey,
minDataSequence: 1,
count: 1,
});
// We are only expecting a single message back. Just in case, we take the latest one.
const message = messages.reduce((latest, msg) => {
if (
msg instanceof OraclePriceMessage &&
msg.messageSequence > (latest?.messageSequence ?? 0)
) {
return msg;
}
return latest;
}, messages[0]);
// If the message is a price message, handle it.
if (message instanceof OraclePriceMessage) {
this.handlePriceMessage(message);
}
} catch (error) {
console.error('Error refreshing prices for oracle:', oracle.publicKey, error);
}
}),
);
}
/**
* Update the metadata map that we use to track the pairs.
*
* @param message The metadata message.
*/
private handleMetadataMessage(message: OracleMetadataMessage) {
this.oracles = OracleClient.updateMetadataMap(this.oracles, message);
}
/**
* Emit a rate updated event for the given pair.
*
* @param message The price message.
*/
private handlePriceMessage(message: OraclePriceMessage) {
const oracle = this.oracles[message.toHexObject().publicKey];
// If the oracle doesn't have the required metadata, we can't use it.
if (
!oracle ||
!oracle.SOURCE_NUMERATOR_UNIT_CODE ||
!oracle.SOURCE_DENOMINATOR_UNIT_CODE ||
!oracle.ATTESTATION_SCALING
) {
return;
}
// Scale the price
const priceValue = message.priceValue / oracle.ATTESTATION_SCALING;
this.emit('rateUpdated', {
numeratorUnitCode: oracle.SOURCE_NUMERATOR_UNIT_CODE,
denominatorUnitCode: oracle.SOURCE_DENOMINATOR_UNIT_CODE,
price: priceValue,
oraclePriceMessage: message,
});
}
}

View File

@@ -188,11 +188,13 @@ export function getRolesForAction(
); );
return startEntries.map((entry) => { return startEntries.map((entry) => {
const roleDef = template.roles?.[entry.role]; const roleDef = template.roles?.[entry.role || ''];
const roleObj = typeof roleDef === "object" ? roleDef : null; const roleObj = typeof roleDef === "object" ? roleDef : null;
// TODO: This is ugly. Lot of conditionals. Need to take a much closer look at this.
return { return {
roleId: entry.role, roleId: entry.role || '',
name: roleObj?.name || entry.role, name: roleObj?.name || entry.role || '',
description: roleObj?.description, description: roleObj?.description,
}; };
}); });

View File

@@ -1,4 +1,4 @@
import { describe, it, expect } from "vitest" import { describe, it, expect } from "vitest";
import { convertArgsToObject } from "../../src/cli/arguments"; import { convertArgsToObject } from "../../src/cli/arguments";
@@ -11,45 +11,59 @@ const testCases = [
}, },
}, },
{ {
input: ['-var-requested-satohis', '1000', '-role', 'receiver'], input: ["-var-requested-satohis", "1000", "-role", "receiver"],
expected: { expected: {
args: [], args: [],
options: { "varRequestedSatohis": "1000", role: "receiver" }, options: { varRequestedSatohis: "1000", role: "receiver" },
}, },
}, },
{ {
input: ['-o', 'output.json', '-var-requested-satohis', '1000', '-role', 'receiver'], input: [
"-o",
"output.json",
"-var-requested-satohis",
"1000",
"-role",
"receiver",
],
expected: { expected: {
args: [], args: [],
options: { output: "output.json", "varRequestedSatohis": "1000", role: "receiver" }, options: {
output: "output.json",
varRequestedSatohis: "1000",
role: "receiver",
},
}, },
}, },
{ {
input: ['mnemonic', 'create', 'page', 'pencil', '-v', '-o', 'mnemonic.txt'], input: ["mnemonic", "create", "page", "pencil", "-v", "-o", "mnemonic.txt"],
expected: { expected: {
args: ['mnemonic', 'create', 'page', 'pencil'], args: ["mnemonic", "create", "page", "pencil"],
options: { verbose: "true", output: "mnemonic.txt" }, options: { verbose: "true", output: "mnemonic.txt" },
}, },
}, },
{ {
input: ['-v', 'invitation', 'list', '-m', 'mnemonicFile'], input: ["-v", "invitation", "list", "-m", "mnemonicFile"],
expected: { expected: {
args: ['invitation', 'list'], args: ["invitation", "list"],
options: { verbose: "true", mnemonicFile: "mnemonicFile" }, options: { verbose: "true", mnemonicFile: "mnemonicFile" },
}, },
}, },
{ {
input: ['--help', 'template', 'import', 'template.json'], input: ["--help", "template", "import", "template.json"],
expected: { expected: {
args: ['template', 'import', 'template.json'], args: ["template", "import", "template.json"],
options: { help: "true" }, options: { help: "true" },
}, },
}, },
]; ];
describe("convertArgsToObject", () => { describe("convertArgsToObject", () => {
it.each(testCases)("should split positional args from options", ({ input, expected }) => { it.each(testCases)(
"should split positional args from options",
({ input, expected }) => {
const result = convertArgsToObject(input); const result = convertArgsToObject(input);
expect(result).toEqual(expected); expect(result).toEqual(expected);
}); },
);
}); });

View File

@@ -0,0 +1,94 @@
import { describe, expect, test } from "vitest";
import { handleMnemonicCommand } from "../../../src/cli/commands/mnemonic";
import { handleTemplateCommand } from "../../../src/cli/commands/template";
import { handleReceiveCommand } from "../../../src/cli/commands/receive";
import { handleResourceCommand } from "../../../src/cli/commands/resource";
import { CommandError } from "../../../src/cli/commands/types";
import {
createBaseCommandDeps,
createCommandDeps,
createMockIO,
} from "../mocks/command";
const fakeApp = {
engine: {},
invitations: [],
unreserveAllResources: async () => 0,
} as any;
describe("command handler contracts", () => {
test("mnemonic throws and prints help for missing subcommand", async () => {
const { io, capture } = createMockIO();
await expect(
handleMnemonicCommand(createBaseCommandDeps(io), [], {}),
).rejects.toThrow(CommandError);
try {
await handleMnemonicCommand(createBaseCommandDeps(io), [], {});
} catch (error) {
expect(error).toBeInstanceOf(CommandError);
expect((error as CommandError).event).toBe("mnemonic.subcommand.missing");
}
expect(capture.out.join("\n")).toContain("Usage:");
});
test("template throws for missing subcommand", async () => {
const { io, capture } = createMockIO();
await expect(
handleTemplateCommand(createCommandDeps(fakeApp, io), [], {}),
).rejects.toThrow(CommandError);
try {
await handleTemplateCommand(createCommandDeps(fakeApp, io), [], {});
} catch (error) {
expect(error).toBeInstanceOf(CommandError);
expect((error as CommandError).event).toBe("template.subcommand.missing");
}
expect(capture.out.join("\n")).toContain("Usage:");
});
test("receive throws for missing args", async () => {
const { io, capture } = createMockIO();
await expect(
handleReceiveCommand(createCommandDeps(fakeApp, io), [], {}),
).rejects.toThrow(CommandError);
try {
await handleReceiveCommand(createCommandDeps(fakeApp, io), [], {});
} catch (error) {
expect(error).toBeInstanceOf(CommandError);
expect((error as CommandError).event).toBe("receive.arguments.missing");
}
expect(capture.out.join("\n")).toContain("Usage:");
});
test("resource throws for unknown subcommand", async () => {
const { io } = createMockIO();
await expect(
handleResourceCommand(
createCommandDeps(fakeApp, io),
["does-not-exist"],
{},
),
).rejects.toThrow(CommandError);
try {
await handleResourceCommand(
createCommandDeps(fakeApp, io),
["does-not-exist"],
{},
);
} catch (error) {
expect(error).toBeInstanceOf(CommandError);
expect((error as CommandError).event).toBe("resource.subcommand.unknown");
}
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,165 @@
import { expect, test, describe, beforeEach, afterEach } from "vitest";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { DEFAULT_SEED } from "../mocks/engine";
import { handleMnemonicCommand } from "../../../src/cli/commands/mnemonic";
import { CommandError } from "../../../src/cli/commands/types";
import {
createMockIO,
createMockPaths,
expectLogs,
type LogExpectation,
} from "../mocks/command";
import { BCHMnemonicURL } from "../../../src/utils/bch-mnemonic-url";
type TestCase = {
inputs: string[];
options?: Record<string, string>;
shouldThrow: boolean;
expectedEvent?: string;
expectedData?: Record<string, unknown>;
logs?: LogExpectation[];
};
const testCases: TestCase[] = [
// Successful creation of a mnemonic file
{
inputs: ["create"],
shouldThrow: false,
expectedData: {
savedAs: expect.stringMatching(/^mnemonic-\w+$/),
},
logs: [{ out: "Mnemonic file created" }],
},
// Successfully creating a mnemonic file with a custom filename
{
inputs: ["create"],
options: { output: "custom-filename" },
shouldThrow: false,
expectedData: {
savedAs: "custom-filename",
},
logs: [{ out: "custom-filename" }],
},
// Successfully listing mnemonic files
{
inputs: ["list"],
shouldThrow: false,
expectedData: {
count: expect.toSatisfy((count: number) => count >= 1),
},
logs: [{ out: "mnemonic-test" }],
},
// Successfully exposing a mnemonic file
{
inputs: ["expose", "mnemonic-test"],
shouldThrow: false,
expectedData: {
mnemonic: DEFAULT_SEED,
},
logs: [{ out: DEFAULT_SEED }],
},
// Successfully importing a mnemonic file
{
inputs: ["import", ...DEFAULT_SEED.split(" ")],
shouldThrow: false,
expectedData: {
savedAs: expect.stringMatching(/^mnemonic-\w+$/),
},
logs: [{ out: "Mnemonic file created" }],
},
// Failure to import a mnemonic file due to missing arguments
{
inputs: ["import"],
shouldThrow: true,
expectedEvent: "mnemonic.import.seed_missing",
},
// Failure to expose a mnemonic file due to missing arguments
{
inputs: ["expose"],
shouldThrow: true,
expectedEvent: "mnemonic.expose.file_missing",
},
// Failure to expose a mnemonic file due to unknown mnemonic file
{
inputs: ["expose", "unknown-mnemonic-file"],
shouldThrow: true,
expectedEvent: "mnemonic.expose.file_not_found",
},
// Missing sub-command
{
inputs: [],
shouldThrow: true,
expectedEvent: "mnemonic.subcommand.missing",
},
// Unknown sub-command
{
inputs: ["unknown"],
shouldThrow: true,
expectedEvent: "mnemonic.subcommand.unknown",
},
];
describe("mnemonic commands", () => {
let tempDir: string;
beforeEach(async () => {
tempDir = mkdtempSync(path.join(tmpdir(), "xo-cli-mnemonic-tests-"));
// Write a single test mnemonic file to the temp directory
writeFileSync(
path.join(tempDir, "mnemonic-test"),
BCHMnemonicURL.fromSeed(DEFAULT_SEED).toURL(),
"utf8",
);
});
afterEach(async () => {
rmSync(tempDir, { recursive: true, force: true });
});
test.each(testCases)(
"mnemonic command: $inputs",
async ({
inputs,
options,
shouldThrow,
expectedEvent,
expectedData,
logs,
}) => {
const { io, spies } = createMockIO();
const paths = createMockPaths(tempDir);
if (shouldThrow) {
try {
await handleMnemonicCommand({ io, paths }, inputs, options ?? {});
expect.fail("Expected command to throw");
} catch (error) {
if (expectedEvent) {
expect(error).toBeInstanceOf(CommandError);
expect((error as CommandError).event).toBe(expectedEvent);
}
}
} else {
const result = await handleMnemonicCommand(
{ io, paths },
inputs,
options ?? {},
);
if (expectedData) {
Object.entries(expectedData).forEach(([key, value]) => {
expect(result[key as keyof typeof result]).toEqual(value);
});
}
}
if (logs) {
expectLogs(spies, logs);
}
},
);
});

View File

@@ -0,0 +1,142 @@
import { expect, test, describe, beforeEach, afterEach } from "vitest";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import {
createMockAppService,
createMockEngine,
DEFAULT_SEED,
} from "../mocks/engine";
import { type Engine } from "@xo-cash/engine";
import { p2pkhTemplate } from "../mocks/template-p2pkh";
import { AppService } from "../../../src/services/app";
import { handleReceiveCommand } from "../../../src/cli/commands/receive";
import { CommandError } from "../../../src/cli/commands/types";
import {
createCommandDeps,
createMockIO,
expectLogs,
type LogExpectation,
} from "../mocks/command";
type TestCase = {
name: string;
inputs: string[];
options?: Record<string, string>;
shouldThrow: boolean;
expectedEvent?: string;
expectedData?: Record<string, unknown>;
logs?: LogExpectation[];
};
const testCases: TestCase[] = [
// Successful address generation with template name and output identifier
{
name: "generates address with template name and output",
inputs: ["Wallet (P2PKH)", "receiveOutput"],
shouldThrow: false,
expectedData: {
address: expect.stringMatching(/^bitcoincash:q[a-z0-9]+$/),
},
logs: [{ out: "bitcoincash:q" }],
},
// Successful address generation with role specified
{
name: "generates address with template name, output, and role",
inputs: ["Wallet (P2PKH)", "receiveOutput", "receiver"],
shouldThrow: false,
expectedData: {
address: expect.stringMatching(/^bitcoincash:q[a-z0-9]+$/),
},
logs: [{ out: "bitcoincash:q" }],
},
// Missing all required arguments
{
name: "throws when no arguments provided",
inputs: [],
shouldThrow: true,
expectedEvent: "receive.arguments.missing",
},
// Missing output identifier
{
name: "throws when output identifier missing",
inputs: ["Wallet (P2PKH)"],
shouldThrow: true,
expectedEvent: "receive.arguments.missing",
},
// Unknown template
{
name: "throws when template not found",
inputs: ["unknown-template", "receiveOutput"],
shouldThrow: true,
expectedEvent: "template.resolve.not_found",
},
];
describe("receive command", () => {
let engine: Engine;
let app: AppService;
let tempDir: string;
beforeEach(async () => {
const mockEngine = await createMockEngine(DEFAULT_SEED);
engine = mockEngine.engine;
await engine.importTemplate(p2pkhTemplate);
app = await createMockAppService(engine);
tempDir = mkdtempSync(path.join(tmpdir(), "xo-cli-receive-tests-"));
});
afterEach(async () => {
await engine.stop();
rmSync(tempDir, { recursive: true, force: true });
});
test.each(testCases)(
"$name",
async ({
inputs,
options,
shouldThrow,
expectedEvent,
expectedData,
logs,
}) => {
const { io, spies } = createMockIO();
if (shouldThrow) {
try {
await handleReceiveCommand(
createCommandDeps(app, io),
inputs,
options ?? {},
);
expect.fail("Expected command to throw");
} catch (error) {
if (expectedEvent) {
expect(error).toBeInstanceOf(CommandError);
expect((error as CommandError).event).toBe(expectedEvent);
}
}
} else {
const result = await handleReceiveCommand(
createCommandDeps(app, io),
inputs,
options ?? {},
);
if (expectedData) {
Object.entries(expectedData).forEach(([key, value]) => {
expect(result[key as keyof typeof result]).toEqual(value);
});
}
}
if (logs) {
expectLogs(spies, logs);
}
},
);
});

View File

@@ -0,0 +1,366 @@
import { expect, test, describe, beforeEach, afterEach } from "vitest";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import {
addFakeResource,
createMockAppService,
createMockEngine,
DEFAULT_SEED,
reserveResource,
} from "../mocks/engine";
import { type Engine } from "@xo-cash/engine";
import { p2pkhTemplate } from "../mocks/template-p2pkh";
import { AppService } from "../../../src/services/app";
import { handleResourceCommand } from "../../../src/cli/commands/resource";
import { CommandError } from "../../../src/cli/commands/types";
import {
createCommandDeps,
createMockIO,
expectLogs,
type LogExpectation,
} from "../mocks/command";
import { State } from "@xo-cash/state";
type TestCase = {
name: string;
inputs: string[];
options?: Record<string, string>;
shouldThrow: boolean;
expectedEvent?: string;
expectedData?: Record<string, unknown>;
logs?: LogExpectation[];
};
const testCases: TestCase[] = [
// List commands (no resources in empty wallet)
{
name: "list returns empty count when no resources",
inputs: ["list"],
shouldThrow: false,
expectedData: {
count: 0,
},
logs: [{ out: "No resources found" }],
},
{
name: "list reserved returns empty count when no reserved resources",
inputs: ["list", "reserved"],
shouldThrow: false,
expectedData: {
count: 0,
},
logs: [{ out: "No resources found" }],
},
{
name: "list all returns empty count when no resources",
inputs: ["list", "all"],
shouldThrow: false,
expectedData: {
count: 0,
},
logs: [{ out: "No resources found" }],
},
// Unreserve-all with no resources
{
name: "unreserve-all returns zero count when no reserved resources",
inputs: ["unreserve-all"],
shouldThrow: false,
expectedData: {
count: 0,
},
logs: [{ out: "No reserved resources" }],
},
// Error cases
{
name: "throws when no subcommand provided",
inputs: [],
shouldThrow: true,
expectedEvent: "resource.subcommand.missing",
},
{
name: "throws when unknown subcommand provided",
inputs: ["unknown-subcommand"],
shouldThrow: true,
expectedEvent: "resource.subcommand.unknown",
},
{
name: "throws when unreserve called without outpoint",
inputs: ["unreserve"],
shouldThrow: true,
expectedEvent: "resource.unreserve.outpoint_missing",
},
{
name: "throws when unreserve called with invalid outpoint format (no colon)",
inputs: ["unreserve", "invalid-outpoint"],
shouldThrow: true,
expectedEvent: "resource.unreserve.outpoint_invalid",
},
{
name: "throws when unreserve called with invalid outpoint format (no vout)",
inputs: ["unreserve", "abc123:"],
shouldThrow: true,
expectedEvent: "resource.unreserve.outpoint_invalid",
},
{
name: "throws when unreserve called with non-existent UTXO",
inputs: [
"unreserve",
"0000000000000000000000000000000000000000000000000000000000000000:0",
],
shouldThrow: true,
expectedEvent: "resource.unreserve.utxo_missing",
},
];
describe("resource command", () => {
let engine: Engine;
let app: AppService;
let tempDir: string;
beforeEach(async () => {
const mockEngine = await createMockEngine(DEFAULT_SEED);
engine = mockEngine.engine;
await engine.importTemplate(p2pkhTemplate);
app = await createMockAppService(engine);
tempDir = mkdtempSync(path.join(tmpdir(), "xo-cli-resource-tests-"));
});
afterEach(async () => {
await engine.stop();
rmSync(tempDir, { recursive: true, force: true });
});
test.each(testCases)(
"$name",
async ({
inputs,
options,
shouldThrow,
expectedEvent,
expectedData,
logs,
}) => {
const { io, spies } = createMockIO();
if (shouldThrow) {
try {
await handleResourceCommand(
createCommandDeps(app, io),
inputs,
options ?? {},
);
expect.fail("Expected command to throw");
} catch (error) {
if (expectedEvent) {
expect(error).toBeInstanceOf(CommandError);
expect((error as CommandError).event).toBe(expectedEvent);
}
}
} else {
const result = await handleResourceCommand(
createCommandDeps(app, io),
inputs,
options ?? {},
);
if (expectedData) {
Object.entries(expectedData).forEach(([key, value]) => {
expect(result[key as keyof typeof result]).toEqual(value);
});
}
}
if (logs) {
expectLogs(spies, logs);
}
},
);
});
describe("resource command with populated data", () => {
let engine: Engine;
let state: State;
let app: AppService;
let tempDir: string;
beforeEach(async () => {
const mockEngine = await createMockEngine(DEFAULT_SEED);
engine = mockEngine.engine;
state = mockEngine.state;
await engine.importTemplate(p2pkhTemplate);
app = await createMockAppService(engine);
tempDir = mkdtempSync(path.join(tmpdir(), "xo-cli-resource-tests-"));
});
afterEach(async () => {
await engine.stop();
rmSync(tempDir, { recursive: true, force: true });
});
test("list returns count when resources exist", async () => {
await addFakeResource(state, { valueSatoshis: 50000 });
await addFakeResource(state, { valueSatoshis: 25000 });
const { io, spies } = createMockIO();
const result = await handleResourceCommand(
createCommandDeps(app, io),
["list"],
{},
);
expect(result.count).toBe(2);
expectLogs(spies, [{ out: "Total resources: 2" }]);
});
test("list shows total satoshis", async () => {
await addFakeResource(state, { valueSatoshis: 50000 });
await addFakeResource(state, { valueSatoshis: 25000 });
const { io, spies } = createMockIO();
await handleResourceCommand(createCommandDeps(app, io), ["list"], {});
expectLogs(spies, [{ out: "Total satoshis: 75000" }]);
});
test("list excludes reserved resources by default", async () => {
await addFakeResource(state, { valueSatoshis: 50000 });
await addFakeResource(state, {
valueSatoshis: 25000,
reservedBy: "inv-123",
});
const { io } = createMockIO();
const result = await handleResourceCommand(
createCommandDeps(app, io),
["list"],
{},
);
expect(result.count).toBe(1);
});
test("list reserved shows only reserved resources", async () => {
await addFakeResource(state, { valueSatoshis: 50000 });
await addFakeResource(state, {
valueSatoshis: 25000,
reservedBy: "inv-123",
});
await addFakeResource(state, {
valueSatoshis: 10000,
reservedBy: "inv-456",
});
const { io, spies } = createMockIO();
const result = await handleResourceCommand(
createCommandDeps(app, io),
["list", "reserved"],
{},
);
expect(result.count).toBe(2);
expectLogs(spies, [{ out: "reserved for inv-123" }]);
});
test("list all shows both reserved and unreserved", async () => {
await addFakeResource(state, { valueSatoshis: 50000 });
await addFakeResource(state, {
valueSatoshis: 25000,
reservedBy: "inv-123",
});
const { io } = createMockIO();
const result = await handleResourceCommand(
createCommandDeps(app, io),
["list", "all"],
{},
);
expect(result.count).toBe(2);
});
test("unreserve releases a reserved UTXO", async () => {
const resource = await addFakeResource(state, {
valueSatoshis: 25000,
reservedBy: "inv-123",
});
const { io, spies } = createMockIO();
await handleResourceCommand(
createCommandDeps(app, io),
[
"unreserve",
`${resource.outpointTransactionHash}:${resource.outpointIndex}`,
],
{},
);
expectLogs(spies, [
{ out: "Unreserved" },
{ out: "was reserved for inv-123" },
]);
const resources = await engine.listUnspentOutputsData();
const target = resources.find(
(r) => r.outpointTransactionHash === resource.outpointTransactionHash,
);
expect(target?.reservedBy).toBeUndefined();
});
test("unreserve reports when UTXO is not reserved", async () => {
const resource = await addFakeResource(state, { valueSatoshis: 25000 });
const { io, spies } = createMockIO();
await handleResourceCommand(
createCommandDeps(app, io),
[
"unreserve",
`${resource.outpointTransactionHash}:${resource.outpointIndex}`,
],
{},
);
expectLogs(spies, [{ out: "UTXO is not reserved" }]);
});
test("unreserve-all releases all reserved UTXOs", async () => {
await addFakeResource(state, { valueSatoshis: 50000 });
await addFakeResource(state, {
valueSatoshis: 25000,
reservedBy: "inv-123",
});
await addFakeResource(state, {
valueSatoshis: 10000,
reservedBy: "inv-456",
});
const { io, spies } = createMockIO();
const result = await handleResourceCommand(
createCommandDeps(app, io),
["unreserve-all"],
{},
);
expect(result.count).toBe(2);
expectLogs(spies, [{ out: "Unreserved" }, { out: "2" }]);
const resources = await engine.listUnspentOutputsData();
const reserved = resources.filter((r) => r.reservedBy);
expect(reserved).toHaveLength(0);
});
test("list displays outpoint information", async () => {
const resource = await addFakeResource(state, { valueSatoshis: 12345 });
const { io, spies } = createMockIO();
await handleResourceCommand(createCommandDeps(app, io), ["list"], {});
expectLogs(spies, [
{ out: resource.outpointTransactionHash },
{ out: "12345 sats" },
]);
});
});

View File

@@ -0,0 +1,266 @@
import { expect, test, describe, beforeEach, afterEach } from "vitest";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import {
createMockAppService,
createMockEngine,
DEFAULT_SEED,
} from "../mocks/engine";
import { type Engine } from "@xo-cash/engine";
import {
p2pkhTemplate,
p2pkhTemplateIdentifier,
} from "../mocks/template-p2pkh";
import { AppService } from "../../../src/services/app";
import { handleTemplateCommand } from "../../../src/cli/commands/template";
import { CommandError } from "../../../src/cli/commands/types";
import {
createCommandDeps,
createMockIO,
expectLogs,
type LogExpectation,
} from "../mocks/command";
type TestCase = {
name: string;
inputs: string[];
options?: Record<string, string>;
shouldThrow: boolean;
expectedEvent?: string;
expectedData?: Record<string, unknown>;
logs?: LogExpectation[];
};
const testCases: TestCase[] = [
// List command
{
name: "list returns count of imported templates",
inputs: ["list"],
shouldThrow: false,
expectedData: {
count: 1,
},
logs: [{ out: "Wallet (P2PKH)" }],
},
// List by category
{
name: "list action returns actions for template",
inputs: ["list", "action", p2pkhTemplateIdentifier],
shouldThrow: false,
expectedData: {},
logs: [{ out: "receive" }],
},
{
name: "list output returns outputs for template",
inputs: ["list", "output", p2pkhTemplateIdentifier],
shouldThrow: false,
expectedData: {},
logs: [{ out: "receiveOutput" }],
},
{
name: "list variable returns variables for template",
inputs: ["list", "variable", p2pkhTemplateIdentifier],
shouldThrow: false,
expectedData: {},
logs: [{ out: "ownerKey" }],
},
{
name: "list transaction returns transactions for template",
inputs: ["list", "transaction", p2pkhTemplateIdentifier],
shouldThrow: false,
expectedData: {},
},
{
name: "list lockingscript returns locking scripts for template",
inputs: ["list", "lockingscript", p2pkhTemplateIdentifier],
shouldThrow: false,
expectedData: {},
},
// Inspect command
{
name: "inspect action returns action details",
inputs: ["inspect", "action", p2pkhTemplateIdentifier, "receive"],
shouldThrow: false,
expectedData: {},
},
{
name: "inspect output returns output details",
inputs: ["inspect", "output", p2pkhTemplateIdentifier, "receiveOutput"],
shouldThrow: false,
expectedData: {},
},
{
name: "inspect variable returns variable details",
inputs: ["inspect", "variable", p2pkhTemplateIdentifier, "ownerKey"],
shouldThrow: false,
expectedData: {},
},
// Error cases - subcommand
{
name: "throws when no subcommand provided",
inputs: [],
shouldThrow: true,
expectedEvent: "template.subcommand.missing",
},
{
name: "throws when unknown subcommand provided",
inputs: ["unknown-subcommand"],
shouldThrow: true,
expectedEvent: "template.subcommand.unknown",
},
// Error cases - import
{
name: "throws when import called without file",
inputs: ["import"],
shouldThrow: true,
expectedEvent: "template.import.file_missing",
},
{
name: "throws when import called with non-existent file",
inputs: ["import", "non-existent-file.json"],
shouldThrow: true,
expectedEvent: "template.import.file_not_found",
},
// Error cases - list category
{
name: "throws when list category called without template identifier",
inputs: ["list", "action"],
shouldThrow: true,
expectedEvent: "template.list.identifier_missing",
},
{
name: "throws when list category called with unknown template",
inputs: ["list", "action", "unknown-template"],
shouldThrow: true,
expectedEvent: "template.list.not_found",
},
{
name: "throws when list called with unknown category",
inputs: ["list", "unknown-category", p2pkhTemplateIdentifier],
shouldThrow: true,
expectedEvent: "template.list.category_unknown",
},
// Error cases - inspect
{
name: "throws when inspect called without all arguments",
inputs: ["inspect", "action"],
shouldThrow: true,
expectedEvent: "template.inspect.arguments_missing",
},
{
name: "throws when inspect called with unknown template",
inputs: ["inspect", "action", "unknown-template", "receive"],
shouldThrow: true,
expectedEvent: "template.resolve.not_found",
},
{
name: "throws when inspect called with unknown action",
inputs: ["inspect", "action", p2pkhTemplateIdentifier, "unknown-action"],
shouldThrow: true,
expectedEvent: "template.inspect.action_missing",
},
{
name: "throws when inspect called with unknown category",
inputs: ["inspect", "unknown-category", p2pkhTemplateIdentifier, "field"],
shouldThrow: true,
expectedEvent: "template.inspect.category_unknown",
},
// Error cases - set-default
{
name: "throws when set-default called without all arguments",
inputs: ["set-default", "template"],
shouldThrow: true,
expectedEvent: "template.default.arguments_missing",
},
];
describe("template command", () => {
let engine: Engine;
let app: AppService;
let tempDir: string;
beforeEach(async () => {
const mockEngine = await createMockEngine(DEFAULT_SEED);
engine = mockEngine.engine;
await engine.importTemplate(p2pkhTemplate);
app = await createMockAppService(engine);
tempDir = mkdtempSync(path.join(tmpdir(), "xo-cli-template-tests-"));
});
afterEach(async () => {
await engine.stop();
rmSync(tempDir, { recursive: true, force: true });
});
test.each(testCases)(
"$name",
async ({
inputs,
options,
shouldThrow,
expectedEvent,
expectedData,
logs,
}) => {
const { io, spies } = createMockIO();
if (shouldThrow) {
try {
await handleTemplateCommand(
createCommandDeps(app, io),
inputs,
options ?? {},
);
expect.fail("Expected command to throw");
} catch (error) {
if (expectedEvent) {
expect(error).toBeInstanceOf(CommandError);
expect((error as CommandError).event).toBe(expectedEvent);
}
}
} else {
const result = await handleTemplateCommand(
createCommandDeps(app, io),
inputs,
options ?? {},
);
if (expectedData) {
Object.entries(expectedData).forEach(([key, value]) => {
expect(result[key as keyof typeof result]).toEqual(value);
});
}
}
if (logs) {
expectLogs(spies, logs);
}
},
);
test("import imports template from file", async () => {
const templatePath = path.join(tempDir, "test-template.json");
writeFileSync(templatePath, JSON.stringify(p2pkhTemplate));
const { io } = createMockIO();
const originalCwd = process.cwd();
process.chdir(tempDir);
try {
const result = await handleTemplateCommand(
createCommandDeps(app, io),
["import", "test-template.json"],
{},
);
expect(result.templateFile).toBe("test-template.json");
} finally {
process.chdir(originalCwd);
}
});
});

View File

@@ -0,0 +1,28 @@
import { describe, expect, test } from "vitest";
import { spawnSync } from "node:child_process";
import path from "node:path";
const runCli = (args: string[]) => {
const tsxPath = path.resolve(process.cwd(), "node_modules/.bin/tsx");
const cliPath = path.resolve(process.cwd(), "src/cli/index.ts");
return spawnSync(tsxPath, [cliPath, ...args], {
encoding: "utf8",
cwd: process.cwd(),
});
};
describe("cli entry boundary behavior", () => {
test("returns non-zero for incomplete mnemonic invocation", () => {
const result = runCli(["mnemonic"]);
expect(result.status).toBe(1);
expect(result.stdout).toContain("Usage:");
});
test("returns zero for mnemonic list invocation", () => {
const result = runCli(["mnemonic", "list"]);
expect(result.status).toBe(0);
});
});

214
tests/cli/mnemonic.test.ts Normal file
View File

@@ -0,0 +1,214 @@
import { expect, test, describe, beforeEach, afterEach } from "vitest";
import {
existsSync,
mkdirSync,
readFileSync,
rmSync,
writeFileSync,
} from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import {
createMnemonicSeed,
createMnemonicFile,
resolveMnemonicFilePath,
loadMnemonic,
listMnemonicFiles,
} from "../../src/cli/mnemonic";
import { BCHMnemonicURL } from "../../src/utils/bch-mnemonic-url";
const TEST_SEED =
"page pencil stock planet limb cluster assault speak off joke private pioneer";
describe("mnemonic utilities", () => {
let tempDir: string;
beforeEach(() => {
tempDir = path.join(tmpdir(), `xo-cli-mnemonic-utils-test-${Date.now()}`);
mkdirSync(tempDir, { recursive: true });
});
afterEach(() => {
rmSync(tempDir, { recursive: true, force: true });
});
describe("createMnemonicSeed", () => {
test("generates a valid BIP39 mnemonic", () => {
const mnemonic = createMnemonicSeed();
expect(typeof mnemonic).toBe("string");
const words = mnemonic.split(" ");
expect(words.length).toBe(12);
});
test("generates unique mnemonics on each call", () => {
const mnemonic1 = createMnemonicSeed();
const mnemonic2 = createMnemonicSeed();
expect(mnemonic1).not.toBe(mnemonic2);
});
});
describe("createMnemonicFile", () => {
test("creates a mnemonic file with auto-generated name", () => {
const filename = createMnemonicFile(tempDir, TEST_SEED);
expect(filename).toMatch(/^mnemonic-page$/);
expect(existsSync(path.join(tempDir, filename))).toBe(true);
});
test("creates a mnemonic file with custom name", () => {
const filename = createMnemonicFile(tempDir, TEST_SEED, "my-wallet");
expect(filename).toBe("my-wallet");
expect(existsSync(path.join(tempDir, filename))).toBe(true);
});
test("writes valid BCHMnemonicURL format", () => {
const filename = createMnemonicFile(tempDir, TEST_SEED, "test-wallet");
const content = readFileSync(path.join(tempDir, filename), "utf8");
expect(content).toMatch(/^bch-mnemonic:/);
const parsed = BCHMnemonicURL.fromURL(content);
expect(parsed).toBeDefined();
});
test("sanitizes filename to basename only", () => {
const filename = createMnemonicFile(
tempDir,
TEST_SEED,
"../../../evil-path",
);
expect(filename).toBe("evil-path");
expect(existsSync(path.join(tempDir, "evil-path"))).toBe(true);
expect(existsSync(path.join(tempDir, "../../../evil-path"))).toBe(false);
});
test("throws when mnemonic is empty", () => {
expect(() => createMnemonicFile(tempDir, "")).toThrow();
});
});
describe("resolveMnemonicFilePath", () => {
test("resolves absolute path when file exists", () => {
const filePath = path.join(tempDir, "mnemonic-absolute");
writeFileSync(filePath, "test");
const resolved = resolveMnemonicFilePath(tempDir, filePath);
expect(resolved).toBe(filePath);
});
test("resolves path relative to cwd when file exists", () => {
const originalCwd = process.cwd();
process.chdir(tempDir);
try {
writeFileSync(path.join(tempDir, "mnemonic-relative"), "test");
const resolved = resolveMnemonicFilePath(
"/nonexistent",
"mnemonic-relative",
);
expect(resolved).toBe(path.join(tempDir, "mnemonic-relative"));
} finally {
process.chdir(originalCwd);
}
});
test("resolves from mnemonicsDir when file exists there", () => {
writeFileSync(path.join(tempDir, "mnemonic-test"), "test");
const resolved = resolveMnemonicFilePath(tempDir, "mnemonic-test");
expect(resolved).toBe(path.join(tempDir, "mnemonic-test"));
});
test("throws when file not found anywhere", () => {
expect(() =>
resolveMnemonicFilePath(tempDir, "nonexistent-file"),
).toThrow(/Mnemonic file not found/);
});
test("strips path components and looks up basename in mnemonicsDir", () => {
writeFileSync(path.join(tempDir, "mnemonic-basename"), "test");
const resolved = resolveMnemonicFilePath(
tempDir,
"some/path/mnemonic-basename",
);
expect(resolved).toBe(path.join(tempDir, "mnemonic-basename"));
});
});
describe("loadMnemonic", () => {
test("loads mnemonic from file", () => {
createMnemonicFile(tempDir, TEST_SEED, "test-load");
const loaded = loadMnemonic(tempDir, "test-load");
expect(loaded).toBe(TEST_SEED);
});
test("loads mnemonic from absolute path", () => {
const filePath = path.join(tempDir, "mnemonic-absolute-load");
createMnemonicFile(tempDir, TEST_SEED, "mnemonic-absolute-load");
const loaded = loadMnemonic(tempDir, filePath);
expect(loaded).toBe(TEST_SEED);
});
test("throws when file not found", () => {
expect(() => loadMnemonic(tempDir, "nonexistent")).toThrow(
/Mnemonic file not found/,
);
});
test("throws when file contains invalid data", () => {
writeFileSync(
path.join(tempDir, "mnemonic-invalid"),
"not a valid mnemonic url",
);
expect(() => loadMnemonic(tempDir, "mnemonic-invalid")).toThrow();
});
});
describe("listMnemonicFiles", () => {
test("returns empty array when no mnemonic files exist", () => {
const files = listMnemonicFiles(tempDir);
expect(files).toEqual([]);
});
test("lists only files starting with 'mnemonic-'", () => {
writeFileSync(path.join(tempDir, "mnemonic-one"), "test");
writeFileSync(path.join(tempDir, "mnemonic-two"), "test");
writeFileSync(path.join(tempDir, "other-file"), "test");
writeFileSync(path.join(tempDir, "wallet.json"), "test");
const files = listMnemonicFiles(tempDir);
expect(files).toHaveLength(2);
expect(files).toContain("mnemonic-one");
expect(files).toContain("mnemonic-two");
expect(files).not.toContain("other-file");
expect(files).not.toContain("wallet.json");
});
test("returns sorted or consistent ordering", () => {
writeFileSync(path.join(tempDir, "mnemonic-zebra"), "test");
writeFileSync(path.join(tempDir, "mnemonic-alpha"), "test");
writeFileSync(path.join(tempDir, "mnemonic-beta"), "test");
const files = listMnemonicFiles(tempDir);
expect(files).toHaveLength(3);
});
});
describe("round-trip", () => {
test("create and load preserves mnemonic exactly", () => {
const original = createMnemonicSeed();
createMnemonicFile(tempDir, original, "roundtrip-test");
const loaded = loadMnemonic(tempDir, "roundtrip-test");
expect(loaded).toBe(original);
});
});
});

171
tests/cli/mocks/command.ts Normal file
View File

@@ -0,0 +1,171 @@
import { vi, expect, type Mock } from "vitest";
import type {
BaseCommandDependencies,
CommandDependencies,
CommandIO,
CommandPaths,
} from "../../../src/cli/commands/types";
import type { AppService } from "../../../src/services/app";
/**
* Captured CLI IO buffers used by tests.
*/
export type MockIOCapture = {
out: string[];
err: string[];
verbose: string[];
};
/**
* Spy functions for each IO channel.
*/
export type MockIOSpies = {
out: Mock;
err: Mock;
verbose: Mock;
};
/**
* Complete mock IO result including the IO adapter, capture buffers, and spies.
*/
export type MockIO = {
io: CommandIO;
capture: MockIOCapture;
spies: MockIOSpies;
};
/**
* Defines an expected log message for assertion.
* At least one of out, err, or verbose should be specified.
*/
export type LogExpectation = {
/** Expected substring (or exact match if exact=true) in io.out */
out?: string;
/** Expected substring (or exact match if exact=true) in io.err */
err?: string;
/** Expected substring (or exact match if exact=true) in io.verbose */
verbose?: string;
/** If true, match the string exactly instead of using contains (default: false) */
exact?: boolean;
};
/**
* Creates a command IO adapter that records every message using vi.fn() spies.
* This enables vitest's built-in matchers like toHaveBeenCalledWith.
*/
export const createMockIO = (): MockIO => {
const capture: MockIOCapture = {
out: [],
err: [],
verbose: [],
};
const outSpy = vi.fn((message: string) => {
capture.out.push(message);
});
const errSpy = vi.fn((message: string) => {
capture.err.push(message);
});
const verboseSpy = vi.fn((message: string) => {
capture.verbose.push(message);
});
const io: CommandIO = {
out: outSpy,
err: errSpy,
verbose: verboseSpy,
};
return {
io,
capture,
spies: {
out: outSpy,
err: errSpy,
verbose: verboseSpy,
},
};
};
/**
* Asserts that the expected log messages were printed to the appropriate IO channels.
* @param spies - The mock IO spies from createMockIO
* @param logs - Array of log expectations to validate
*/
export const expectLogs = (
spies: MockIOSpies,
logs: LogExpectation[],
): void => {
for (const log of logs) {
if (log.out !== undefined) {
if (log.exact) {
expect(spies.out).toHaveBeenCalledWith(log.out);
} else {
expect(spies.out).toHaveBeenCalledWith(
expect.stringContaining(log.out),
);
}
}
if (log.err !== undefined) {
if (log.exact) {
expect(spies.err).toHaveBeenCalledWith(log.err);
} else {
expect(spies.err).toHaveBeenCalledWith(
expect.stringContaining(log.err),
);
}
}
if (log.verbose !== undefined) {
if (log.exact) {
expect(spies.verbose).toHaveBeenCalledWith(log.verbose);
} else {
expect(spies.verbose).toHaveBeenCalledWith(
expect.stringContaining(log.verbose),
);
}
}
}
};
/**
* Creates mock paths for testing.
* @param tempDir - Optional temp directory to use as base for all paths
*/
export const createMockPaths = (tempDir?: string): CommandPaths => {
const base = tempDir ?? "/tmp/xo-cli-test";
return {
mnemonicsDir: base,
dataDir: base,
walletConfigPath: `${base}/.wallet`,
workingDir: base,
};
};
/**
* Creates base command dependencies for commands that do not require the app.
* @param io - Command IO adapter
* @param paths - Optional custom paths (defaults to mock paths)
*/
export const createBaseCommandDeps = (
io: CommandIO,
paths?: CommandPaths,
): BaseCommandDependencies => ({
io,
paths: paths ?? createMockPaths(),
});
/**
* Creates command dependencies for app-backed command handlers.
* @param app - App service instance
* @param io - Command IO adapter
* @param paths - Optional custom paths (defaults to mock paths)
*/
export const createCommandDeps = (
app: AppService,
io: CommandIO,
paths?: CommandPaths,
): CommandDependencies => ({
app,
io,
paths: paths ?? createMockPaths(),
});

View File

@@ -0,0 +1,12 @@
/**
* Mock Electrum service for testing.
* NOTE & TODO: Do we even need this in the actual app? I forget why we had this, but it seems like its just overly complicating things
* And we end up in stupid situations where we are creating a mock for a single function class.
*/
export class MockElectrumService {
constructor() {}
async hasSeenTransaction(transactionHash: string): Promise<boolean> {
return true;
}
}

180
tests/cli/mocks/engine.ts Normal file
View File

@@ -0,0 +1,180 @@
import { BlockchainMonitor, Engine } from "@xo-cash/engine";
import {
createStorageAdapter,
State,
StorageType,
UnspentOutputStatus,
type UnspentOutputData,
} from "@xo-cash/state";
import { InMemoryBlockchainProvider } from "@xo-cash/engine";
import { convertMnemonicToSeedBytes } from "@xo-cash/crypto";
import { binToHex, sha256 } from "@bitauth/libauth";
import { AppService } from "../../../src/services/app";
import { InMemoryStorage } from "../../../src/services/storage";
import { MockElectrumService } from "./electrum-service";
import { MockRatesService } from "./rates-service";
import { RatesService } from "../../../src/services/rates";
export const DEFAULT_SEED =
"page pencil stock planet limb cluster assault speak off joke private pioneer";
/**
* Options for creating a fake resource (UTXO) in tests.
*/
export type FakeResourceOptions = {
/** Transaction hash of the outpoint. Auto-generated if not provided. */
outpointTransactionHash?: string;
/** Index of the outpoint in the transaction. Defaults to 0. */
outpointIndex?: number;
/** Value in satoshis. Defaults to 10000. */
valueSatoshis?: number;
/** Template identifier. Defaults to "test-template". */
templateIdentifier?: string;
/** Output identifier from the template. Defaults to "receiveOutput". */
outputIdentifier?: string;
/** Locking bytecode for this output. Defaults to a placeholder. */
lockingBytecode?: string;
/** Block height where the UTXO was mined. Defaults to 800000. */
minedAtHeight?: number;
/** Invitation identifier that reserves this output. Undefined means unreserved. */
reservedBy?: string;
};
/**
* Generates a random 64-character hex string representing a transaction hash.
*/
export const randomTxHash = (): string => {
const bytes = new Uint8Array(32);
crypto.getRandomValues(bytes);
return Array.from(bytes)
.map((b) => b.toString(16).padStart(2, "0"))
.join("");
};
/**
* Adds a fake resource (UTXO) to the engine's state for testing purposes.
* @param engine - The engine instance to add the resource to.
* @param options - Options for the fake resource. All fields have sensible defaults.
* @returns The created UnspentOutputData object.
*/
export const addFakeResource = async (
state: State,
options: FakeResourceOptions = {},
): Promise<UnspentOutputData> => {
const resource: UnspentOutputData = {
status: UnspentOutputStatus.CONFIRMED,
selectable: true,
privacy: false,
templateIdentifier: options.templateIdentifier ?? "test-template",
outputIdentifier: options.outputIdentifier ?? "receiveOutput",
outpointIndex: options.outpointIndex ?? 0,
outpointTransactionHash: options.outpointTransactionHash ?? randomTxHash(),
minedAtHeight: options.minedAtHeight ?? 800000,
valueSatoshis: options.valueSatoshis ?? 10000,
scriptHash:
options.lockingBytecode ??
"76a914000000000000000000000000000000000000000088ac",
reservedBy: options.reservedBy,
};
await state.storeUnspentOutputData(resource);
return resource;
};
/**
* Reserves a resource for a specific invitation.
* @param engine - The engine instance.
* @param outpointTransactionHash - The transaction hash of the UTXO to reserve.
* @param outpointIndex - The output index of the UTXO to reserve.
* @param invitationIdentifier - The invitation identifier to reserve for.
*/
export const reserveResource = async (
state: State,
outpointTransactionHash: string,
outpointIndex: number,
invitationIdentifier: string,
): Promise<void> => {
await state.executeBulkUnspentOutputReservation(
[{ outpointTransactionHash, outpointIndex }],
true,
invitationIdentifier,
);
};
/**
* Unreserves a resource from a specific invitation.
* @param engine - The engine instance.
* @param outpointTransactionHash - The transaction hash of the UTXO to unreserve.
* @param outpointIndex - The output index of the UTXO to unreserve.
* @param invitationIdentifier - The invitation identifier to unreserve from.
*/
export const unreserveResource = async (
state: State,
outpointTransactionHash: string,
outpointIndex: number,
invitationIdentifier: string,
): Promise<void> => {
await state.executeBulkUnspentOutputReservation(
[{ outpointTransactionHash, outpointIndex }],
false,
invitationIdentifier,
);
};
/**
* Create a mock engine instance with a given seed. Uses the in-memory storage and blockchain provider.
* @param seed - The seed to use for the engine.
* @returns A mock engine instance.
*/
export const createMockEngine = async (seed: string) => {
// Create the in-memory storage adapter.
const storage = await createStorageAdapter({
storageType: "inmemory",
accountHash: binToHex(sha256.hash(convertMnemonicToSeedBytes(seed))),
});
// Initialize the storage adapter.
await storage.initialize();
// Create the state instance.
const state = new State(storage);
// Create the in-memory blockchain provider.
const blockchainProvider = new InMemoryBlockchainProvider();
await blockchainProvider.initialize({
applicationIdentifier: "xo-cli-tests",
electrumOptions: {},
});
// Create the blockchain monitor instance.
const blockchainMonitor = new BlockchainMonitor(state, blockchainProvider);
await blockchainMonitor.initializeEventListeners();
// Create the engine instance.
const engine = new Engine(seed, state, blockchainMonitor, blockchainProvider);
await engine.initializeStateSync();
return { engine, state, blockchainMonitor, blockchainProvider };
};
export const createMockAppService = async (engine: Engine) => {
const storage = await InMemoryStorage.create();
const mockRates = new MockRatesService();
const rates = new RatesService(mockRates);
const mockElectrum = new MockElectrumService();
const config = {
syncServerUrl: "http://localhost:3000",
engineConfig: {
databasePath: "test-data",
databaseFilename: "xo-wallet.db",
},
invitationStoragePath: "test-invitations.db",
};
return new AppService(engine, storage, config, mockElectrum, rates);
};

View File

@@ -0,0 +1,23 @@
import { BaseRates } from "../../../src/utils/rates/base-rates";
export class MockRatesService extends BaseRates {
constructor() {
super();
}
async getRate(numeratorUnitCode: string, denominatorUnitCode: string): Promise<number> {
return 1;
}
async start(): Promise<void> {
return;
}
async stop(): Promise<void> {
return;
}
async listPairs(): Promise<Set<string>> {
return new Set();
}
}

File diff suppressed because it is too large Load Diff

153
tests/cli/paths.test.ts Normal file
View File

@@ -0,0 +1,153 @@
import { expect, test, describe, beforeEach, afterEach } from "vitest";
import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
import { homedir, tmpdir } from "node:os";
import path from "node:path";
import {
getConfigDir,
getMnemonicsDir,
getDataDir,
getWalletConfigPath,
resolveMnemonicFilePath,
} from "../../src/utils/paths";
describe("paths utilities", () => {
describe("getConfigDir", () => {
test("returns path under ~/.config/xo-cli", () => {
const configDir = getConfigDir();
expect(configDir).toBe(path.join(homedir(), ".config", "xo-cli"));
});
test("creates the directory if it does not exist", () => {
const configDir = getConfigDir();
expect(existsSync(configDir)).toBe(true);
});
});
describe("getMnemonicsDir", () => {
test("returns path under config dir", () => {
const mnemonicsDir = getMnemonicsDir();
expect(mnemonicsDir).toBe(
path.join(homedir(), ".config", "xo-cli", "mnemonics"),
);
});
test("creates the directory if it does not exist", () => {
const mnemonicsDir = getMnemonicsDir();
expect(existsSync(mnemonicsDir)).toBe(true);
});
});
describe("getDataDir", () => {
test("returns path under config dir", () => {
const dataDir = getDataDir();
expect(dataDir).toBe(path.join(homedir(), ".config", "xo-cli", "data"));
});
test("creates the directory if it does not exist", () => {
const dataDir = getDataDir();
expect(existsSync(dataDir)).toBe(true);
});
});
describe("getWalletConfigPath", () => {
test("returns .wallet file path under config dir", () => {
const walletConfigPath = getWalletConfigPath();
expect(walletConfigPath).toBe(
path.join(homedir(), ".config", "xo-cli", ".wallet"),
);
});
});
describe("resolveMnemonicFilePath (global)", () => {
let tempDir: string;
beforeEach(() => {
tempDir = path.join(tmpdir(), `xo-cli-paths-test-${Date.now()}`);
mkdirSync(tempDir, { recursive: true });
});
afterEach(() => {
rmSync(tempDir, { recursive: true, force: true });
});
test("resolves absolute path when file exists", () => {
const filePath = path.join(tempDir, "mnemonic-test");
writeFileSync(filePath, "test");
const resolved = resolveMnemonicFilePath(filePath);
expect(resolved).toBe(filePath);
});
test("resolves path relative to cwd when file exists", () => {
const originalCwd = process.cwd();
process.chdir(tempDir);
try {
writeFileSync(path.join(tempDir, "mnemonic-cwd-test"), "test");
const resolved = resolveMnemonicFilePath("mnemonic-cwd-test");
expect(resolved).toBe(path.join(tempDir, "mnemonic-cwd-test"));
} finally {
process.chdir(originalCwd);
}
});
test("resolves from global mnemonics dir when file exists there", () => {
const mnemonicsDir = getMnemonicsDir();
const testFile = path.join(mnemonicsDir, "mnemonic-global-test");
try {
writeFileSync(testFile, "test");
const resolved = resolveMnemonicFilePath("mnemonic-global-test");
expect(resolved).toBe(testFile);
} finally {
if (existsSync(testFile)) {
rmSync(testFile);
}
}
});
test("throws when file not found anywhere", () => {
expect(() =>
resolveMnemonicFilePath("nonexistent-mnemonic-file-xyz"),
).toThrow(/Mnemonic file not found/);
});
test("does not resolve absolute path if file does not exist", () => {
const nonExistentPath = "/nonexistent/path/mnemonic-test";
expect(() => resolveMnemonicFilePath(nonExistentPath)).toThrow(
/Mnemonic file not found/,
);
});
});
describe("path hierarchy", () => {
test("mnemonics dir is under config dir", () => {
const configDir = getConfigDir();
const mnemonicsDir = getMnemonicsDir();
expect(mnemonicsDir.startsWith(configDir)).toBe(true);
});
test("data dir is under config dir", () => {
const configDir = getConfigDir();
const dataDir = getDataDir();
expect(dataDir.startsWith(configDir)).toBe(true);
});
test("wallet config is under config dir", () => {
const configDir = getConfigDir();
const walletConfig = getWalletConfigPath();
expect(walletConfig.startsWith(configDir)).toBe(true);
});
});
});