16 Commits

Author SHA1 Message Date
d45f1ddeb3 Update test mnemonic 2026-05-29 18:16:40 +02:00
bcc3277cb9 Merge pull request 'Add random mnemonic generation in seed input screen' (#1) from add-seed-generation into main
Reviewed-on: #1
2026-05-19 22:02:28 +10:00
b2ccff5b19 Add random mnemonic generation in seed input screen 2026-05-19 13:31:01 +02:00
12b7bde74f Update readme.md 2026-05-16 18:55:56 +10:00
42d23fa35e Update gitea to https 2026-05-16 18:51:23 +10:00
b6ee25d1dd Use HTTPS installation instructions
Use https for the cloning
2026-05-16 18:49:29 +10:00
b4d82b8b1f Before bliss 2026-05-16 05:59:55 +00:00
a0d9775015 Add variable step 2026-05-11 12:18:47 +00:00
6c01ac1c1b Add currency settings, Settings service, and dialog to select fiat currency. Add support for non Official currencies like DOGE when using rates. 2026-05-11 10:41:41 +00:00
ebe1d8acda Add copy to generate address 2026-05-11 02:52:52 +00:00
c2334b2cdd Document that storage should be removed once engine can provide invitation data. Also documented memory adapter should be moved as its only used in the tests 2026-05-04 12:15:39 +00:00
dec228063b Remove the Logger. Was used temporarily as a remote logger for debugging 2026-05-04 12:15:06 +00:00
3c47ee8a4c Add documentation to the commands for the CLI 2026-05-04 12:14:40 +00:00
8d7856f32e Remove scripts 2026-05-04 12:14:15 +00:00
b8b0a4a1ba Improve output resolution in invitation screen 2026-05-04 11:45:52 +00:00
dedfb69dff Fix history for the 100th time. Fix role resolution in the invitation screen 2026-05-04 11:36:09 +00:00
54 changed files with 2698 additions and 1044 deletions

40
package-lock.json generated
View File

@@ -15,7 +15,7 @@
"@xo-cash/crypto": "^0.0.1", "@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": "^0.0.1", "@xo-cash/templates": "file:../templates",
"@xo-cash/types": "^0.0.1", "@xo-cash/types": "^0.0.1",
"better-sqlite3": "^12.6.2", "better-sqlite3": "^12.6.2",
"clipboardy": "^5.1.0", "clipboardy": "^5.1.0",
@@ -51,7 +51,7 @@
"@electrum-cash/network": "^4.2.2", "@electrum-cash/network": "^4.2.2",
"@electrum-cash/protocol": "^2.3.1", "@electrum-cash/protocol": "^2.3.1",
"@electrum-cash/servers": "^3.1.0", "@electrum-cash/servers": "^3.1.0",
"@xo-cash/crypto": "0.0.1", "@xo-cash/crypto": "file:../crypto",
"@xo-cash/primitives": "0.0.1", "@xo-cash/primitives": "0.0.1",
"@xo-cash/state": "0.0.2", "@xo-cash/state": "0.0.2",
"@xo-cash/templates": "0.0.1", "@xo-cash/templates": "0.0.1",
@@ -113,6 +113,33 @@
"vitest": "^4.0.17" "vitest": "^4.0.17"
} }
}, },
"../templates": {
"name": "@xo-cash/templates",
"version": "0.0.1",
"license": "MIT",
"dependencies": {
"@xo-cash/types": "0.0.1"
},
"devDependencies": {
"@chalp/eslint-airbnb": "^1.3.0",
"@generalprotocols/cspell-dictionary": "^1.0.1",
"@stylistic/eslint-plugin": "^5.7.0",
"@typescript-eslint/eslint-plugin": "^8.53.1",
"@typescript-eslint/parser": "^8.53.1",
"@vitest/coverage-v8": "^4.0.17",
"@viz-kit/esbuild-analyzer": "^1.0.0",
"@xo-cash/eslint-config": "1.0.1",
"cspell": "^9.6.0",
"eslint": "^9.39.2",
"prettier": "^3.6.2",
"tsdown": "^0.20.0-beta.4",
"typedoc": "^0.28.16",
"typedoc-plugin-coverage": "^4.0.2",
"typescript": "^5.3.2",
"typescript-eslint": "^8.53.1",
"vitest": "^4.0.17"
}
},
"node_modules/@alcalzone/ansi-tokenize": { "node_modules/@alcalzone/ansi-tokenize": {
"version": "0.2.4", "version": "0.2.4",
"resolved": "https://registry.npmjs.org/@alcalzone/ansi-tokenize/-/ansi-tokenize-0.2.4.tgz", "resolved": "https://registry.npmjs.org/@alcalzone/ansi-tokenize/-/ansi-tokenize-0.2.4.tgz",
@@ -929,13 +956,8 @@
"link": true "link": true
}, },
"node_modules/@xo-cash/templates": { "node_modules/@xo-cash/templates": {
"version": "0.0.1", "resolved": "../templates",
"resolved": "https://registry.npmjs.org/@xo-cash/templates/-/templates-0.0.1.tgz", "link": true
"integrity": "sha512-v5f0YeH9Bw6lNThdE0fI878T4L2jbM8RI1quxdKxnvqHn9hu2jzebqvveEB2TfJWG3sP1GpE1go0Yn87R4sXfw==",
"license": "MIT",
"dependencies": {
"@xo-cash/types": "0.0.1"
}
}, },
"node_modules/@xo-cash/types": { "node_modules/@xo-cash/types": {
"version": "0.0.1", "version": "0.0.1",

View File

@@ -16,8 +16,6 @@
"test": "vitest --run --passWithNoTests", "test": "vitest --run --passWithNoTests",
"test:watch": "vitest", "test:watch": "vitest",
"test:coverage": "vitest run --coverage --passWithNoTests", "test:coverage": "vitest run --coverage --passWithNoTests",
"nuke": "tsx scripts/rm-dbs.ts",
"nuke:dry": "tsx scripts/rm-dbs.ts --dry",
"format": "prettier --write \"**/*.{js,ts,md,json}\" --ignore-path .gitignore", "format": "prettier --write \"**/*.{js,ts,md,json}\" --ignore-path .gitignore",
"format:check": "prettier --check .", "format:check": "prettier --check .",
"autocomplete:install": "node dist/cli/index.js completions bash --install", "autocomplete:install": "node dist/cli/index.js completions bash --install",
@@ -41,7 +39,7 @@
"@xo-cash/crypto": "^0.0.1", "@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": "^0.0.1", "@xo-cash/templates": "file:../templates",
"@xo-cash/types": "^0.0.1", "@xo-cash/types": "^0.0.1",
"better-sqlite3": "^12.6.2", "better-sqlite3": "^12.6.2",
"clipboardy": "^5.1.0", "clipboardy": "^5.1.0",

View File

@@ -9,7 +9,7 @@ mkdir xo-terminal && cd xo-terminal
# ----- Start Engine Setup ----- # ----- Start Engine Setup -----
# Clone the Engine Repo (Note, this uses harvey's fork of the engine repo to access the cli-test branch) # 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 git clone https://gitlab.com/Harvmaster/engine.git
# Move into teh engine directory # Move into teh engine directory
cd engine cd engine
@@ -29,7 +29,7 @@ cd ..
# ----- Start State Setup ----- # ----- Start State Setup -----
# Clone the State Repo # Clone the State Repo
git clone git@gitlab.com:Harvmaster/state.git git clone https://gitlab.com/Harvmaster/state.git
# Move into the state directory # Move into the state directory
cd state cd state
@@ -46,9 +46,26 @@ npm run build
# Move back to the top level directory # Move back to the top level directory
cd .. cd ..
# ----- Start Template Setup ----
# Clone the Template repo
git clone https://gitlab.com/Harvmaster/templates.git
# Move into themplates directory
cd templates
# Install deps
npm ci
#build the templates
npm run build
# ----- End Templates Setup ----
# Move back to the top level directory
cd ..
# ----- Start CLI Setup ----- # ----- Start CLI Setup -----
# Clone the CLI Repo # Clone the CLI Repo
git clone git@git.harvmaster.com:Harvmaster/xo-cli.git git clone https://git.harvmaster.com/Harvmaster/xo-cli.git
# Move into the cli directory # Move into the cli directory
cd xo-cli cd xo-cli

View File

@@ -1,40 +0,0 @@
import fs from "fs/promises";
/**
* Remove all the databases without the use of external tools
* TODO: Fix the ts linking issue here. Should just be adding this as a dir in tsconfig.json
*/
const rmDbs = async (dry = false) => {
// First, we need to find all the database base files
// These end in either .db.sqlite, .sqlite, .db
// Get all the files in the current directory
const files = await fs.readdir("./");
// Filter out the files that end in .db.sqlite, .sqlite, .db
const dbFiles = files.filter(
(file) =>
file.endsWith(".db.sqlite") ||
file.endsWith(".sqlite") ||
file.endsWith(".db"),
);
// We need to remove all the files
await deleteFiles(dbFiles, dry);
};
const deleteFiles = async (files: string[], dry = false) => {
if (dry) {
console.log("Dry run, would delete:", files);
return;
}
await Promise.all(files.map((file) => fs.rm(file)));
console.log("All databases removed");
};
// Read args
const args = process.argv.slice(2);
const dry = args.includes("--dry");
// Delete the files
await rmDbs(dry);

View File

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

View File

@@ -15,7 +15,7 @@ Wallet state lives under **`~/.config/xo-cli/`** (XDG-style), so you can run com
| ----------------------------- | ----------------------------------------------------------------------- | | ----------------------------- | ----------------------------------------------------------------------- |
| `~/.config/xo-cli/mnemonics/` | Mnemonic files (`mnemonic-*`) | | `~/.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/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) | | `~/.config/xo-cli/.wallet` | JSON settings (`default-mnemonic`, `currency`) |
**Local to your shells current directory:** template JSON paths, invitation JSON you create/import, and any path you pass explicitly (e.g. `-m /abs/path/to/file`). **Local to your shells current directory:** template JSON paths, invitation JSON you create/import, and any path you pass explicitly (e.g. `-m /abs/path/to/file`).
@@ -57,7 +57,7 @@ npx tsx src/index.ts # TUI
xo-cli mnemonic create xo-cli mnemonic create
# Import an existing mnemonic seed phrase # Import an existing mnemonic seed phrase
xo-cli mnemonic import page pencil stock planet limb cluster assault speak off joke private pioneer xo-cli mnemonic import oven crop same above under tower promote decrease vocal pretty require slow
# List mnemonic basenames (use with -m) # List mnemonic basenames (use with -m)
xo-cli mnemonic list xo-cli mnemonic list
@@ -67,7 +67,10 @@ xo-cli mnemonic list
### Wallet Persistence ### Wallet Persistence
The first time you pass `-m <name>`, that reference is saved to `~/.config/xo-cli/.wallet`. Later runs can omit `-m`. The first time you pass `-m <name>`, that reference is saved as
`default-mnemonic` in `~/.config/xo-cli/.wallet`. Later runs can omit `-m`.
`currency` controls the fiat unit used when showing BCH/sats conversions in the TUI.
Mnemonic resolution order: Mnemonic resolution order:
@@ -85,6 +88,7 @@ xo-cli resource list
| Flag | Description | | Flag | Description |
| ------------------------------ | --------------------------------------------------- | | ------------------------------ | --------------------------------------------------- |
| `-m`, `--mnemonic-file <file>` | Mnemonic file (basename, cwd-relative, or absolute) | | `-m`, `--mnemonic-file <file>` | Mnemonic file (basename, cwd-relative, or absolute) |
| `--currency <code>` | Fiat display currency (e.g. `USD`, `AUD`) |
| `-o`, `--output <filename>` | Output filename (used by `mnemonic create`/`import`) | | `-o`, `--output <filename>` | Output filename (used by `mnemonic create`/`import`) |
| `-v`, `--verbose` | Verbose output | | `-v`, `--verbose` | Verbose output |
| `-h`, `--help` | Help | | `-h`, `--help` | Help |
@@ -126,6 +130,16 @@ xo-cli resource unreserve <txhash:vout>
xo-cli resource unreserve-all xo-cli resource unreserve-all
``` ```
### `settings` — Manage Persisted Settings
```bash
xo-cli settings show
xo-cli settings get currency
xo-cli settings get default-mnemonic
xo-cli settings set currency AUD
xo-cli settings set default-mnemonic mnemonic-nuclear
```
### `receive` — Generate a Receiving Address ### `receive` — Generate a Receiving Address
```bash ```bash

View File

@@ -7,9 +7,9 @@ import { z } from "zod";
/** /**
* Converts the CLI args to a key-value object and return the options object along with the other arguments still in the array.\ * Converts the CLI args to a key-value object and return the options object along with the other arguments still in the array.\
* eg: `xo-cli mnemonic create page pencil stock planet limb cluster assault speak off joke private pioneer -v -o mnemonic.txt` will return: * eg: `xo-cli mnemonic create oven crop same above under tower promote decrease vocal pretty require slow -v -o mnemonic.txt` will return:
* { * {
* args: ["mnemonic", "create", "page", "pencil", "stock", "planet", "limb", "cluster", "assault", "speak", "off", "joke", "private", "pioneer"], * args: ["mnemonic", "create", "oven", "crop", "same", "above", "under", "tower", "promote", "decrease", "vocal", "pretty", "require", "slow"],
* options: { * options: {
* output: "mnemonic.txt", * output: "mnemonic.txt",
* verbose: "true", * verbose: "true",

View File

@@ -23,7 +23,7 @@
* 1 - Error (no output, fails silently for shell integration) * 1 - Error (no output, fails silently for shell integration)
*/ */
import { existsSync, readdirSync, readFileSync } from "node:fs"; import { existsSync, readdirSync } from "node:fs";
import { join } from "node:path"; import { join } from "node:path";
import { createHash } from "node:crypto"; import { createHash } from "node:crypto";
@@ -34,6 +34,7 @@ import {
} from "../../utils/paths.js"; } from "../../utils/paths.js";
import { loadMnemonic } from "../mnemonic.js"; import { loadMnemonic } from "../mnemonic.js";
import { Storage } from "../../services/storage.js"; import { Storage } from "../../services/storage.js";
import { SettingsService } from "../../services/settings.js";
import { COMMAND_TREE } from "./completions.js"; import { COMMAND_TREE } from "./completions.js";
// Lazy-loaded modules (only loaded when needed for dynamic completions) // Lazy-loaded modules (only loaded when needed for dynamic completions)
@@ -103,12 +104,8 @@ function listSubcommands(command: string, prefix?: string): void {
*/ */
function getCurrentMnemonic(): string | null { function getCurrentMnemonic(): string | null {
try { try {
const walletConfigPath = getWalletConfigPath(); const settings = new SettingsService(getWalletConfigPath());
if (!existsSync(walletConfigPath)) { const mnemonicFile = settings.getDefaultMnemonic();
return null;
}
const mnemonicFile = readFileSync(walletConfigPath, "utf8").trim();
if (!mnemonicFile) { if (!mnemonicFile) {
return null; return null;
} }

View File

@@ -23,7 +23,6 @@ import {
existsSync, existsSync,
readFileSync, readFileSync,
appendFileSync, appendFileSync,
writeFileSync,
} from "node:fs"; } from "node:fs";
import { dirname, join } from "node:path"; import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
@@ -38,6 +37,7 @@ import { homedir } from "node:os";
* - template.ts: import, list, inspect, set-default * - template.ts: import, list, inspect, set-default
* - invitation.ts: create, append, sign, broadcast, requirements, import, inspect, list * - invitation.ts: create, append, sign, broadcast, requirements, import, inspect, list
* - resource.ts: list, unreserve, unreserve-all * - resource.ts: list, unreserve, unreserve-all
* - settings.ts: show, get, set
*/ */
/** Subcommands for the mnemonic command */ /** Subcommands for the mnemonic command */
@@ -57,6 +57,8 @@ const INVITATION_SUBS = [
]; ];
/** Subcommands for the resource command */ /** Subcommands for the resource command */
const RESOURCE_SUBS = ["list", "unreserve", "unreserve-all"]; const RESOURCE_SUBS = ["list", "unreserve", "unreserve-all"];
/** Subcommands for the settings command */
const SETTINGS_SUBS = ["show", "get", "set"];
/** Subcommands for the completions command */ /** Subcommands for the completions command */
const COMPLETIONS_SUBS = ["bash", "zsh", "fish"]; const COMPLETIONS_SUBS = ["bash", "zsh", "fish"];
@@ -66,6 +68,7 @@ export const COMMAND_TREE = {
invitation: INVITATION_SUBS, invitation: INVITATION_SUBS,
receive: [], receive: [],
resource: RESOURCE_SUBS, resource: RESOURCE_SUBS,
settings: SETTINGS_SUBS,
help: [], help: [],
completions: COMPLETIONS_SUBS, completions: COMPLETIONS_SUBS,
} as const; } as const;
@@ -78,6 +81,7 @@ const GLOBAL_OPTIONS = [
"--verbose", "--verbose",
"-m", "-m",
"--mnemonic-file", "--mnemonic-file",
"--currency",
"-o", "-o",
"--output", "--output",
]; ];

View File

@@ -1,7 +1,16 @@
# bash completion for {{BIN_NAME}} # ------------------------------------------------------------------------------
# Add to ~/.bashrc: eval "$({{BIN_NAME}} completions bash)" # Bash completion template for {{BIN_NAME}}
# ------------------------------------------------------------------------------
# Installation:
# eval "$({{BIN_NAME}} completions bash)"
#
# This file is generated from a template. Placeholders (for example `{{OPTIONS}}`)
# are replaced at build/runtime with concrete command data from the CLI.
# ------------------------------------------------------------------------------
# Find xo-complete in the same directory as xo-cli # Prefer a globally-installed helper, but fall back to a helper co-located with
# the CLI binary. This lets completions work in both "installed via PATH" and
# "single extracted directory" workflows.
__xo_complete_bin="" __xo_complete_bin=""
if command -v xo-complete &>/dev/null; then if command -v xo-complete &>/dev/null; then
__xo_complete_bin="xo-complete" __xo_complete_bin="xo-complete"
@@ -9,16 +18,28 @@ elif command -v {{BIN_NAME}} &>/dev/null; then
__xo_complete_bin="$(dirname "$(command -v {{BIN_NAME}})")/xo-complete" __xo_complete_bin="$(dirname "$(command -v {{BIN_NAME}})")/xo-complete"
fi fi
# Wrapper to call xo-complete helper # @description
# Calls the dynamic completion helper and suppresses helper stderr so the shell
# completion menu stays clean even when the helper is unavailable or errors.
# @param "$@" Arguments forwarded to xo-complete.
__xo_complete() { __xo_complete() {
[[ -n "${__xo_complete_bin}" ]] && "${__xo_complete_bin}" "$@" 2>/dev/null [[ -n "${__xo_complete_bin}" ]] && "${__xo_complete_bin}" "$@" 2>/dev/null
} }
# @description
# Main completion dispatcher invoked by bash's `complete -F`.
# It determines context (command/subcommand/argument position) and then mixes:
# - static completions (known command words)
# - dynamic completions (resolved by xo-complete)
# - filesystem completions (when a subcommand expects file paths)
_{{FUNC_NAME}}_completions() { _{{FUNC_NAME}}_completions() {
local cur prev words cword local cur prev words cword
# Populates `cur`, `prev`, `words`, and `cword`.
# `_init_completion` is provided by bash-completion.
_init_completion || return _init_completion || return
# Handle -m/--mnemonic-file argument (previous word was -m) # If the previous token is `-m/--mnemonic-file`, this argument expects a
# mnemonic file alias/path. Ask the helper for mnemonic suggestions.
if [[ "${prev}" == "-m" || "${prev}" == "--mnemonic-file" ]]; then if [[ "${prev}" == "-m" || "${prev}" == "--mnemonic-file" ]]; then
local mnemonics local mnemonics
mnemonics=$(__xo_complete mnemonics "${cur}") mnemonics=$(__xo_complete mnemonics "${cur}")
@@ -30,13 +51,14 @@ _{{FUNC_NAME}}_completions() {
fi fi
fi fi
# If the current word starts with "-", offer option flags # Option context: show global options when the current token starts with `-`.
if [[ "${cur}" == -* ]]; then if [[ "${cur}" == -* ]]; then
COMPREPLY=($(compgen -W "{{OPTIONS}}" -- "${cur}")) COMPREPLY=($(compgen -W "{{OPTIONS}}" -- "${cur}"))
return 0 return 0
fi fi
# Find the command and subcommand positions # Parse command/subcommand from non-option tokens before the current cursor.
# We track indices so argument-position logic can be computed later.
local cmd="" subcmd="" cmd_idx=0 subcmd_idx=0 local cmd="" subcmd="" cmd_idx=0 subcmd_idx=0
for ((i=1; i < cword; i++)); do for ((i=1; i < cword; i++)); do
if [[ "${words[i]}" != -* ]]; then if [[ "${words[i]}" != -* ]]; then
@@ -51,13 +73,13 @@ _{{FUNC_NAME}}_completions() {
fi fi
done done
# No command yet — offer the top-level commands # No command selected yet: complete top-level commands.
if [[ -z "${cmd}" ]]; then if [[ -z "${cmd}" ]]; then
COMPREPLY=($(compgen -W "{{COMMANDS}}" -- "${cur}")) COMPREPLY=($(compgen -W "{{COMMANDS}}" -- "${cur}"))
return 0 return 0
fi fi
# Handle each command's completion # Command-specific completion rules.
case "${cmd}" in case "${cmd}" in
mnemonic) mnemonic)
if [[ -z "${subcmd}" ]]; then if [[ -z "${subcmd}" ]]; then
@@ -69,7 +91,9 @@ _{{FUNC_NAME}}_completions() {
if [[ -z "${subcmd}" ]]; then if [[ -z "${subcmd}" ]]; then
COMPREPLY=($(compgen -W "{{TEMPLATE_SUBS}}" -- "${cur}")) COMPREPLY=($(compgen -W "{{TEMPLATE_SUBS}}" -- "${cur}"))
elif [[ "${subcmd}" == "list" || "${subcmd}" == "inspect" ]]; then elif [[ "${subcmd}" == "list" || "${subcmd}" == "inspect" ]]; then
# template list/inspect <category> <template> [field] - category first, then template, then field # template list/inspect <category> <template> [field]
# Position is computed relative to the subcommand token:
# 1 => category, 2 => template, 3 => field (inspect only)
local pos=$((cword - subcmd_idx)) local pos=$((cword - subcmd_idx))
if [[ $pos -eq 1 ]]; then if [[ $pos -eq 1 ]]; then
COMPREPLY=($(compgen -W "action transaction output lockingscript variable" -- "${cur}")) COMPREPLY=($(compgen -W "action transaction output lockingscript variable" -- "${cur}"))
@@ -82,7 +106,7 @@ _{{FUNC_NAME}}_completions() {
done <<< "${templates}" done <<< "${templates}"
fi fi
elif [[ $pos -eq 3 && "${subcmd}" == "inspect" ]]; then elif [[ $pos -eq 3 && "${subcmd}" == "inspect" ]]; then
# Get the category and template from previous args # Field names depend on both selected category and template.
local category="${words[subcmd_idx + 1]}" local category="${words[subcmd_idx + 1]}"
local template_arg="${words[subcmd_idx + 2]}" local template_arg="${words[subcmd_idx + 2]}"
local fields local fields
@@ -94,7 +118,8 @@ _{{FUNC_NAME}}_completions() {
fi fi
fi fi
elif [[ "${subcmd}" == "set-default" ]]; then elif [[ "${subcmd}" == "set-default" ]]; then
# template set-default <template> <output> <role> - template first # template set-default <template> <output> <role>
# We only complete the first positional argument (template) here.
local pos=$((cword - subcmd_idx)) local pos=$((cword - subcmd_idx))
if [[ $pos -eq 1 ]]; then if [[ $pos -eq 1 ]]; then
local templates local templates
@@ -114,7 +139,8 @@ _{{FUNC_NAME}}_completions() {
else else
case "${subcmd}" in case "${subcmd}" in
create) create)
# invitation create <template> <action> - offer templates then actions # invitation create <template> <action>
# The available actions depend on the selected template.
local pos=$((cword - subcmd_idx)) local pos=$((cword - subcmd_idx))
if [[ $pos -eq 1 ]]; then if [[ $pos -eq 1 ]]; then
local templates local templates
@@ -136,7 +162,7 @@ _{{FUNC_NAME}}_completions() {
fi fi
;; ;;
append|sign|broadcast|requirements|inspect) append|sign|broadcast|requirements|inspect)
# These take an invitation ID # These subcommands expect an invitation identifier as first arg.
local pos=$((cword - subcmd_idx)) local pos=$((cword - subcmd_idx))
if [[ $pos -eq 1 ]]; then if [[ $pos -eq 1 ]]; then
local invitations local invitations
@@ -149,7 +175,7 @@ _{{FUNC_NAME}}_completions() {
fi fi
;; ;;
import) import)
# import takes a file path - use default file completion # File import path: delegate to bash's built-in file completion.
COMPREPLY=($(compgen -f -- "${cur}")) COMPREPLY=($(compgen -f -- "${cur}"))
;; ;;
esac esac
@@ -160,7 +186,8 @@ _{{FUNC_NAME}}_completions() {
if [[ -z "${subcmd}" ]]; then if [[ -z "${subcmd}" ]]; then
COMPREPLY=($(compgen -W "{{RESOURCE_SUBS}}" -- "${cur}")) COMPREPLY=($(compgen -W "{{RESOURCE_SUBS}}" -- "${cur}"))
elif [[ "${subcmd}" == "unreserve" ]]; then elif [[ "${subcmd}" == "unreserve" ]]; then
# resource unreserve <txhash:vout> - offer resources # resource unreserve <txhash:vout>
# Suggest known reserved outpoints from the helper.
local pos=$((cword - subcmd_idx)) local pos=$((cword - subcmd_idx))
if [[ $pos -eq 1 ]]; then if [[ $pos -eq 1 ]]; then
local resources local resources
@@ -174,8 +201,20 @@ _{{FUNC_NAME}}_completions() {
fi fi
;; ;;
settings)
if [[ -z "${subcmd}" ]]; then
COMPREPLY=($(compgen -W "show get set" -- "${cur}"))
elif [[ "${subcmd}" == "get" || "${subcmd}" == "set" ]]; then
local pos=$((cword - subcmd_idx))
if [[ $pos -eq 1 ]]; then
COMPREPLY=($(compgen -W "currency default-mnemonic" -- "${cur}"))
fi
fi
;;
receive) receive)
# receive <template> [output] - offer templates # receive <template> [output]
# Template is the first positional argument after `receive`.
local pos=$((cword - cmd_idx)) local pos=$((cword - cmd_idx))
if [[ $pos -eq 1 ]]; then if [[ $pos -eq 1 ]]; then
local templates local templates
@@ -189,6 +228,7 @@ _{{FUNC_NAME}}_completions() {
;; ;;
completions) completions)
# Shell target for generating completion scripts.
if [[ -z "${subcmd}" ]]; then if [[ -z "${subcmd}" ]]; then
COMPREPLY=($(compgen -W "bash zsh fish" -- "${cur}")) COMPREPLY=($(compgen -W "bash zsh fish" -- "${cur}"))
fi fi
@@ -196,4 +236,5 @@ _{{FUNC_NAME}}_completions() {
esac esac
} }
# Register the completion function for the CLI binary.
complete -F _{{FUNC_NAME}}_completions {{BIN_NAME}} complete -F _{{FUNC_NAME}}_completions {{BIN_NAME}}

View File

@@ -1,11 +1,21 @@
# fish completion for {{BIN_NAME}} # ------------------------------------------------------------------------------
# Add to fish config: {{BIN_NAME}} completions fish | source # Fish completion template for {{BIN_NAME}}
# ------------------------------------------------------------------------------
# Installation:
# {{BIN_NAME}} completions fish | source
#
# This file is generated from a template. Placeholders (for example
# `{{TOP_LEVEL_COMMANDS}}`) are replaced with concrete completion definitions.
# ------------------------------------------------------------------------------
# Disable file completions by default # Fish offers file completion by default. Disable that globally first so command
# words are preferred, then selectively re-enable `-F` where paths are expected.
complete -c {{BIN_NAME}} -f complete -c {{BIN_NAME}} -f
# Helper function to get dynamic completions # @description
# Finds xo-complete in the same directory as {{BIN_NAME}} # Resolves and calls `xo-complete` for dynamic values (templates, invitations,
# fields, etc.). We first try PATH, then a helper next to `{{BIN_NAME}}`.
# @param $argv Arguments forwarded directly to xo-complete.
function __{{FUNC_NAME}}_complete_dynamic function __{{FUNC_NAME}}_complete_dynamic
set -l xo_complete_bin "" set -l xo_complete_bin ""
if command -q xo-complete if command -q xo-complete
@@ -18,53 +28,70 @@ function __{{FUNC_NAME}}_complete_dynamic
end end
end end
# Global options # Global option flags available across top-level command contexts.
complete -c {{BIN_NAME}} -s h -d "Show help" complete -c {{BIN_NAME}} -s h -d "Show help"
complete -c {{BIN_NAME}} -l help -d "Show help" complete -c {{BIN_NAME}} -l help -d "Show help"
complete -c {{BIN_NAME}} -s v -d "Verbose output" complete -c {{BIN_NAME}} -s v -d "Verbose output"
complete -c {{BIN_NAME}} -l verbose -d "Verbose output" complete -c {{BIN_NAME}} -l verbose -d "Verbose output"
complete -c {{BIN_NAME}} -s o -d "Output file" complete -c {{BIN_NAME}} -s o -d "Output file"
complete -c {{BIN_NAME}} -l output -d "Output file" complete -c {{BIN_NAME}} -l output -d "Output file"
complete -c {{BIN_NAME}} -l currency -d "Set fiat display currency"
# Dynamic mnemonic file completion for -m # Dynamic completion for `-m/--mnemonic-file`.
complete -c {{BIN_NAME}} -s m -l mnemonic-file -xa '(__{{FUNC_NAME}}_complete_dynamic mnemonics)' complete -c {{BIN_NAME}} -s m -l mnemonic-file -xa '(__{{FUNC_NAME}}_complete_dynamic mnemonics)'
# Top-level commands # Top-level command registrations inserted by template expansion.
{{TOP_LEVEL_COMMANDS}} {{TOP_LEVEL_COMMANDS}}
# Static sub-commands # Static subcommand registrations inserted by template expansion.
{{STATIC_SUBCOMMANDS}} {{STATIC_SUBCOMMANDS}}
# Dynamic completions # ---------------------------------------------------------------------------
# Dynamic completions by command/subcommand.
#
# Fish condition notes:
# - `__fish_seen_subcommand_from <name>` checks whether `<name>` exists in the
# current tokenized command line.
# - `count (commandline -opc)` returns how many tokens were entered.
# We use this to infer positional argument index.
# ---------------------------------------------------------------------------
# invitation create: template names # invitation create <template> <action>
# Position 3 => template argument.
complete -c {{BIN_NAME}} -n "__fish_seen_subcommand_from invitation; and __fish_seen_subcommand_from create; and test (count (commandline -opc)) -eq 3" -xa '(__{{FUNC_NAME}}_complete_dynamic templates)' complete -c {{BIN_NAME}} -n "__fish_seen_subcommand_from invitation; and __fish_seen_subcommand_from create; and test (count (commandline -opc)) -eq 3" -xa '(__{{FUNC_NAME}}_complete_dynamic templates)'
# invitation create: action names (2nd arg) # invitation create <template> <action>
# Position 4 => action argument, filtered by selected template token.
complete -c {{BIN_NAME}} -n "__fish_seen_subcommand_from invitation; and __fish_seen_subcommand_from create; and test (count (commandline -opc)) -eq 4" -xa '(__{{FUNC_NAME}}_complete_dynamic actions (commandline -opc)[4])' complete -c {{BIN_NAME}} -n "__fish_seen_subcommand_from invitation; and __fish_seen_subcommand_from create; and test (count (commandline -opc)) -eq 4" -xa '(__{{FUNC_NAME}}_complete_dynamic actions (commandline -opc)[4])'
# invitation append/sign/broadcast/requirements/inspect: invitation IDs # invitation append/sign/broadcast/requirements/inspect <invitation-id>
# Position 3 => invitation identifier.
complete -c {{BIN_NAME}} -n "__fish_seen_subcommand_from invitation; and __fish_seen_subcommand_from append; and test (count (commandline -opc)) -eq 3" -xa '(__{{FUNC_NAME}}_complete_dynamic invitations)' complete -c {{BIN_NAME}} -n "__fish_seen_subcommand_from invitation; and __fish_seen_subcommand_from append; and test (count (commandline -opc)) -eq 3" -xa '(__{{FUNC_NAME}}_complete_dynamic invitations)'
complete -c {{BIN_NAME}} -n "__fish_seen_subcommand_from invitation; and __fish_seen_subcommand_from sign; and test (count (commandline -opc)) -eq 3" -xa '(__{{FUNC_NAME}}_complete_dynamic invitations)' complete -c {{BIN_NAME}} -n "__fish_seen_subcommand_from invitation; and __fish_seen_subcommand_from sign; and test (count (commandline -opc)) -eq 3" -xa '(__{{FUNC_NAME}}_complete_dynamic invitations)'
complete -c {{BIN_NAME}} -n "__fish_seen_subcommand_from invitation; and __fish_seen_subcommand_from broadcast; and test (count (commandline -opc)) -eq 3" -xa '(__{{FUNC_NAME}}_complete_dynamic invitations)' complete -c {{BIN_NAME}} -n "__fish_seen_subcommand_from invitation; and __fish_seen_subcommand_from broadcast; and test (count (commandline -opc)) -eq 3" -xa '(__{{FUNC_NAME}}_complete_dynamic invitations)'
complete -c {{BIN_NAME}} -n "__fish_seen_subcommand_from invitation; and __fish_seen_subcommand_from requirements; and test (count (commandline -opc)) -eq 3" -xa '(__{{FUNC_NAME}}_complete_dynamic invitations)' complete -c {{BIN_NAME}} -n "__fish_seen_subcommand_from invitation; and __fish_seen_subcommand_from requirements; and test (count (commandline -opc)) -eq 3" -xa '(__{{FUNC_NAME}}_complete_dynamic invitations)'
complete -c {{BIN_NAME}} -n "__fish_seen_subcommand_from invitation; and __fish_seen_subcommand_from inspect; and test (count (commandline -opc)) -eq 3" -xa '(__{{FUNC_NAME}}_complete_dynamic invitations)' complete -c {{BIN_NAME}} -n "__fish_seen_subcommand_from invitation; and __fish_seen_subcommand_from inspect; and test (count (commandline -opc)) -eq 3" -xa '(__{{FUNC_NAME}}_complete_dynamic invitations)'
# invitation import: file completion # invitation import <path>
# Re-enable default filesystem completion for path argument.
complete -c {{BIN_NAME}} -n "__fish_seen_subcommand_from invitation; and __fish_seen_subcommand_from import" -F complete -c {{BIN_NAME}} -n "__fish_seen_subcommand_from invitation; and __fish_seen_subcommand_from import" -F
# template list/inspect: category first (pos 3), then template (pos 4), then field (pos 5 for inspect) # template list/inspect <category> <template> [field]
# Position 3 => category, 4 => template, 5 => field (inspect only).
complete -c {{BIN_NAME}} -n "__fish_seen_subcommand_from template; and __fish_seen_subcommand_from list; and test (count (commandline -opc)) -eq 3" -xa 'action transaction output lockingscript variable' complete -c {{BIN_NAME}} -n "__fish_seen_subcommand_from template; and __fish_seen_subcommand_from list; and test (count (commandline -opc)) -eq 3" -xa 'action transaction output lockingscript variable'
complete -c {{BIN_NAME}} -n "__fish_seen_subcommand_from template; and __fish_seen_subcommand_from list; and test (count (commandline -opc)) -eq 4" -xa '(__{{FUNC_NAME}}_complete_dynamic templates)' complete -c {{BIN_NAME}} -n "__fish_seen_subcommand_from template; and __fish_seen_subcommand_from list; and test (count (commandline -opc)) -eq 4" -xa '(__{{FUNC_NAME}}_complete_dynamic templates)'
complete -c {{BIN_NAME}} -n "__fish_seen_subcommand_from template; and __fish_seen_subcommand_from inspect; and test (count (commandline -opc)) -eq 3" -xa 'action transaction output lockingscript variable' complete -c {{BIN_NAME}} -n "__fish_seen_subcommand_from template; and __fish_seen_subcommand_from inspect; and test (count (commandline -opc)) -eq 3" -xa 'action transaction output lockingscript variable'
complete -c {{BIN_NAME}} -n "__fish_seen_subcommand_from template; and __fish_seen_subcommand_from inspect; and test (count (commandline -opc)) -eq 4" -xa '(__{{FUNC_NAME}}_complete_dynamic templates)' complete -c {{BIN_NAME}} -n "__fish_seen_subcommand_from template; and __fish_seen_subcommand_from inspect; and test (count (commandline -opc)) -eq 4" -xa '(__{{FUNC_NAME}}_complete_dynamic templates)'
complete -c {{BIN_NAME}} -n "__fish_seen_subcommand_from template; and __fish_seen_subcommand_from inspect; and test (count (commandline -opc)) -eq 5" -xa '(__{{FUNC_NAME}}_complete_dynamic fields (commandline -opc)[4] (commandline -opc)[5])' complete -c {{BIN_NAME}} -n "__fish_seen_subcommand_from template; and __fish_seen_subcommand_from inspect; and test (count (commandline -opc)) -eq 5" -xa '(__{{FUNC_NAME}}_complete_dynamic fields (commandline -opc)[4] (commandline -opc)[5])'
# template set-default: template first # template set-default <template> <output> <role>
# Position 3 => template argument.
complete -c {{BIN_NAME}} -n "__fish_seen_subcommand_from template; and __fish_seen_subcommand_from set-default; and test (count (commandline -opc)) -eq 3" -xa '(__{{FUNC_NAME}}_complete_dynamic templates)' complete -c {{BIN_NAME}} -n "__fish_seen_subcommand_from template; and __fish_seen_subcommand_from set-default; and test (count (commandline -opc)) -eq 3" -xa '(__{{FUNC_NAME}}_complete_dynamic templates)'
# resource unreserve: UTXO outpoints # resource unreserve <txhash:vout>
# Position 3 => outpoint to unreserve.
complete -c {{BIN_NAME}} -n "__fish_seen_subcommand_from resource; and __fish_seen_subcommand_from unreserve; and test (count (commandline -opc)) -eq 3" -xa '(__{{FUNC_NAME}}_complete_dynamic resources)' complete -c {{BIN_NAME}} -n "__fish_seen_subcommand_from resource; and __fish_seen_subcommand_from unreserve; and test (count (commandline -opc)) -eq 3" -xa '(__{{FUNC_NAME}}_complete_dynamic resources)'
# receive: template names # receive <template> [output]
# Position 2 => template argument.
complete -c {{BIN_NAME}} -n "__fish_seen_subcommand_from receive; and test (count (commandline -opc)) -eq 2" -xa '(__{{FUNC_NAME}}_complete_dynamic templates)' complete -c {{BIN_NAME}} -n "__fish_seen_subcommand_from receive; and test (count (commandline -opc)) -eq 2" -xa '(__{{FUNC_NAME}}_complete_dynamic templates)'

View File

@@ -1,7 +1,15 @@
# zsh completion for {{BIN_NAME}} # ------------------------------------------------------------------------------
# Add to ~/.zshrc: eval "$({{BIN_NAME}} completions zsh)" # Zsh completion template for {{BIN_NAME}}
# ------------------------------------------------------------------------------
# Installation:
# eval "$({{BIN_NAME}} completions zsh)"
#
# This file is generated from a template. Placeholders (for example
# `{{MNEMONIC_SUBS}}`) are replaced with concrete command values.
# ------------------------------------------------------------------------------
# Find xo-complete in the same directory as xo-cli # Prefer a helper on PATH; otherwise fall back to helper next to the CLI binary.
# This keeps dynamic completion functional in both installed and portable layouts.
__xo_complete_bin="" __xo_complete_bin=""
if (( $+commands[xo-complete] )); then if (( $+commands[xo-complete] )); then
__xo_complete_bin="xo-complete" __xo_complete_bin="xo-complete"
@@ -9,16 +17,25 @@ elif (( $+commands[{{BIN_NAME}}] )); then
__xo_complete_bin="${commands[{{BIN_NAME}}]:h}/xo-complete" __xo_complete_bin="${commands[{{BIN_NAME}}]:h}/xo-complete"
fi fi
# Wrapper to call xo-complete helper # @description
# Calls the dynamic helper while silencing helper stderr to avoid noisy
# completion menus if helper lookup fails.
# @param "$@" Arguments forwarded to xo-complete.
__xo_complete() { __xo_complete() {
[[ -n "${__xo_complete_bin}" ]] && "${__xo_complete_bin}" "$@" 2>/dev/null [[ -n "${__xo_complete_bin}" ]] && "${__xo_complete_bin}" "$@" 2>/dev/null
} }
# @description
# Main zsh completion dispatcher registered via `compdef`.
# It resolves command context from `$words`/`$CURRENT` and serves:
# - static command words via `compadd`
# - dynamic values from `xo-complete`
# - filesystem completions where file paths are expected
_{{FUNC_NAME}}_completions() { _{{FUNC_NAME}}_completions() {
local -a commands local -a commands
commands=({{COMMANDS}}) commands=({{COMMANDS}})
# Handle -m/--mnemonic-file argument (previous word was -m) # If previous token is `-m/--mnemonic-file`, complete mnemonic sources.
if [[ "${words[CURRENT-1]}" == "-m" || "${words[CURRENT-1]}" == "--mnemonic-file" ]]; then if [[ "${words[CURRENT-1]}" == "-m" || "${words[CURRENT-1]}" == "--mnemonic-file" ]]; then
local mnemonics local mnemonics
mnemonics=("${(@f)$(__xo_complete mnemonics "${words[CURRENT]}")}") mnemonics=("${(@f)$(__xo_complete mnemonics "${words[CURRENT]}")}")
@@ -28,13 +45,14 @@ _{{FUNC_NAME}}_completions() {
fi fi
fi fi
# If typing an option flag, complete options # Option context: if current token starts with `-`, complete known options.
if [[ "${words[${CURRENT}]}" == -* ]]; then if [[ "${words[${CURRENT}]}" == -* ]]; then
compadd -- {{OPTIONS}} compadd -- {{OPTIONS}}
return return
fi fi
# Find the command and subcommand # Find first and second non-option tokens before the cursor.
# `cmd_idx` and `subcmd_idx` are used for positional argument calculations.
local cmd="" subcmd="" cmd_idx=0 subcmd_idx=0 local cmd="" subcmd="" cmd_idx=0 subcmd_idx=0
for ((i=2; i < CURRENT; i++)); do for ((i=2; i < CURRENT; i++)); do
if [[ "${words[i]}" != -* ]]; then if [[ "${words[i]}" != -* ]]; then
@@ -49,13 +67,13 @@ _{{FUNC_NAME}}_completions() {
fi fi
done done
# No command yet offer top-level commands # No command token yet: offer top-level commands.
if [[ -z "${cmd}" ]]; then if [[ -z "${cmd}" ]]; then
compadd -- ${commands[@]} compadd -- ${commands[@]}
return return
fi fi
# Handle each command's completion # Command-specific completion behavior.
case "${cmd}" in case "${cmd}" in
mnemonic) mnemonic)
if [[ -z "${subcmd}" ]]; then if [[ -z "${subcmd}" ]]; then
@@ -67,7 +85,9 @@ _{{FUNC_NAME}}_completions() {
if [[ -z "${subcmd}" ]]; then if [[ -z "${subcmd}" ]]; then
compadd -- {{TEMPLATE_SUBS}} compadd -- {{TEMPLATE_SUBS}}
elif [[ "${subcmd}" == "list" || "${subcmd}" == "inspect" ]]; then elif [[ "${subcmd}" == "list" || "${subcmd}" == "inspect" ]]; then
# template list/inspect <category> <template> [field] - category first, then template, then field # template list/inspect <category> <template> [field]
# Relative positions from subcommand:
# 1 => category, 2 => template, 3 => field (inspect only)
local pos=$((CURRENT - subcmd_idx)) local pos=$((CURRENT - subcmd_idx))
if [[ $pos -eq 1 ]]; then if [[ $pos -eq 1 ]]; then
compadd -- action transaction output lockingscript variable compadd -- action transaction output lockingscript variable
@@ -78,7 +98,7 @@ _{{FUNC_NAME}}_completions() {
compadd -- "${templates[@]}" compadd -- "${templates[@]}"
fi fi
elif [[ $pos -eq 3 && "${subcmd}" == "inspect" ]]; then elif [[ $pos -eq 3 && "${subcmd}" == "inspect" ]]; then
# Get the category and template from previous args # Field suggestions depend on selected category and template.
local category="${words[subcmd_idx + 1]}" local category="${words[subcmd_idx + 1]}"
local template_arg="${words[subcmd_idx + 2]}" local template_arg="${words[subcmd_idx + 2]}"
local fields local fields
@@ -88,7 +108,8 @@ _{{FUNC_NAME}}_completions() {
fi fi
fi fi
elif [[ "${subcmd}" == "set-default" ]]; then elif [[ "${subcmd}" == "set-default" ]]; then
# template set-default <template> <output> <role> - template first # template set-default <template> <output> <role>
# First positional argument is template name.
local pos=$((CURRENT - subcmd_idx)) local pos=$((CURRENT - subcmd_idx))
if [[ $pos -eq 1 ]]; then if [[ $pos -eq 1 ]]; then
local templates local templates
@@ -106,6 +127,8 @@ _{{FUNC_NAME}}_completions() {
else else
case "${subcmd}" in case "${subcmd}" in
create) create)
# invitation create <template> <action>
# Action list is template-specific.
local pos=$((CURRENT - subcmd_idx)) local pos=$((CURRENT - subcmd_idx))
if [[ $pos -eq 1 ]]; then if [[ $pos -eq 1 ]]; then
local templates local templates
@@ -123,6 +146,7 @@ _{{FUNC_NAME}}_completions() {
fi fi
;; ;;
append|sign|broadcast|requirements|inspect) append|sign|broadcast|requirements|inspect)
# These subcommands take invitation ID as first argument.
local pos=$((CURRENT - subcmd_idx)) local pos=$((CURRENT - subcmd_idx))
if [[ $pos -eq 1 ]]; then if [[ $pos -eq 1 ]]; then
local invitations local invitations
@@ -133,6 +157,7 @@ _{{FUNC_NAME}}_completions() {
fi fi
;; ;;
import) import)
# invitation import <path>: delegate to zsh file completion.
_files _files
;; ;;
esac esac
@@ -143,6 +168,7 @@ _{{FUNC_NAME}}_completions() {
if [[ -z "${subcmd}" ]]; then if [[ -z "${subcmd}" ]]; then
compadd -- {{RESOURCE_SUBS}} compadd -- {{RESOURCE_SUBS}}
elif [[ "${subcmd}" == "unreserve" ]]; then elif [[ "${subcmd}" == "unreserve" ]]; then
# resource unreserve <txhash:vout>
local pos=$((CURRENT - subcmd_idx)) local pos=$((CURRENT - subcmd_idx))
if [[ $pos -eq 1 ]]; then if [[ $pos -eq 1 ]]; then
local resources local resources
@@ -154,7 +180,19 @@ _{{FUNC_NAME}}_completions() {
fi fi
;; ;;
settings)
if [[ -z "${subcmd}" ]]; then
compadd -- show get set
elif [[ "${subcmd}" == "get" || "${subcmd}" == "set" ]]; then
local pos=$((CURRENT - subcmd_idx))
if [[ $pos -eq 1 ]]; then
compadd -- currency default-mnemonic
fi
fi
;;
receive) receive)
# receive <template> [output]
local pos=$((CURRENT - cmd_idx)) local pos=$((CURRENT - cmd_idx))
if [[ $pos -eq 1 ]]; then if [[ $pos -eq 1 ]]; then
local templates local templates
@@ -166,6 +204,7 @@ _{{FUNC_NAME}}_completions() {
;; ;;
completions) completions)
# Shell target for completion generation.
if [[ -z "${subcmd}" ]]; then if [[ -z "${subcmd}" ]]; then
compadd -- bash zsh fish compadd -- bash zsh fish
fi fi
@@ -173,4 +212,5 @@ _{{FUNC_NAME}}_completions() {
esac esac
} }
# Register completion function for the executable name.
compdef _{{FUNC_NAME}}_completions {{BIN_NAME}} compdef _{{FUNC_NAME}}_completions {{BIN_NAME}}

View File

@@ -1,4 +1,5 @@
export { handleMnemonicCommand, printMnemonicHelp } from "./mnemonic.js"; export { handleMnemonicCommand, printMnemonicHelp } from "./mnemonic.js";
export { handleSettingsCommand, printSettingsHelp } from "./settings.js";
export { handleTemplateCommand, printTemplateHelp } from "./template.js"; export { handleTemplateCommand, printTemplateHelp } from "./template.js";
export { handleInvitationCommand, printInvitationHelp } from "./invitation.js"; export { handleInvitationCommand, printInvitationHelp } from "./invitation.js";
export { handleReceiveCommand, printReceiveHelp } from "./receive.js"; export { handleReceiveCommand, printReceiveHelp } from "./receive.js";

View File

@@ -42,6 +42,7 @@ function parseVariablesFromOptions(
): { variableIdentifier: string; value: string; roleIdentifier?: string }[] { ): { variableIdentifier: string; value: string; roleIdentifier?: string }[] {
const roleIdentifier = options["role"]; const roleIdentifier = options["role"];
// Parse the variables from the options by checking if its starts with "var"
return Object.entries(options) return Object.entries(options)
.filter(([key]) => key.startsWith("var")) .filter(([key]) => key.startsWith("var"))
.map(([key, value]) => ({ .map(([key, value]) => ({
@@ -81,10 +82,12 @@ async function buildAppendParams(
const suitableResources = await invitation.findSuitableResources(); const suitableResources = await invitation.findSuitableResources();
const selectable = mapUnspentOutputsToSelectable(suitableResources); const selectable = mapUnspentOutputsToSelectable(suitableResources);
// Get the required sats out with the default fee
const requiredWithFee = const requiredWithFee =
(await invitation.getSatsOut().catch(() => 0n)) + DEFAULT_FEE; (await invitation.getSatsOut().catch(() => 0n)) + DEFAULT_FEE;
autoSelectGreedyUtxos(selectable, requiredWithFee); autoSelectGreedyUtxos(selectable, requiredWithFee);
// Get the inputs from the selectable UTXOs
inputs = selectable inputs = selectable
.filter((u) => u.selected) .filter((u) => u.selected)
.map((u) => ({ .map((u) => ({
@@ -92,12 +95,15 @@ async function buildAppendParams(
outpointIndex: u.outpointIndex, outpointIndex: u.outpointIndex,
})); }));
// If no inputs are found, print a message and return null
if (inputs.length === 0) { if (inputs.length === 0) {
deps.io.err("No suitable UTXOs found for auto-input selection."); deps.io.err("No suitable UTXOs found for auto-input selection.");
return null; return null;
} }
deps.io.verbose(`Auto-selected ${inputs.length} input(s)`); deps.io.verbose(`Auto-selected ${inputs.length} input(s)`);
} else if (options["addInput"]) { }
// If the add input option is provided, parse the inputs from the options
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) {
@@ -105,8 +111,12 @@ async function buildAppendParams(
`Invalid input format "${entry}". Expected <txhash>:<vout> (e.g. abc123:0)`, `Invalid input format "${entry}". Expected <txhash>:<vout> (e.g. abc123:0)`,
); );
} }
// Get the tx hash and vout from the entry
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 the tx hash or vout is not a string or isNaN, print a message and throw an error
if (!txHash || isNaN(vout)) { if (!txHash || isNaN(vout)) {
throw new Error( throw new Error(
`Invalid input format "${entry}". Expected <txhash>:<vout> (e.g. abc123:0)`, `Invalid input format "${entry}". Expected <txhash>:<vout> (e.g. abc123:0)`,
@@ -161,10 +171,12 @@ async function buildAppendParams(
} }
} }
// Get the template from the engine
const template = await deps.app.engine.getTemplate( const template = await deps.app.engine.getTemplate(
invitation.data.templateIdentifier, invitation.data.templateIdentifier,
); );
// Get the outputs from the template
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)
@@ -205,28 +217,37 @@ async function buildAppendParams(
]), ]),
); );
// Sum the total input sats
let totalInputSats = 0n; let totalInputSats = 0n;
// Iterate through the inputs and sum the valueSatoshis
for (const input of inputs) { for (const input of inputs) {
// Get the tx hash hex
const txHashHex = binToHex(input.outpointTransactionHash); const txHashHex = binToHex(input.outpointTransactionHash);
// Get the utxo from the utxo map
const utxo = utxoMap.get(`${txHashHex}:${input.outpointIndex}`); const utxo = utxoMap.get(`${txHashHex}:${input.outpointIndex}`);
if (!utxo) { if (!utxo) {
// If the utxo is not found, print a message and return null
deps.io.err( deps.io.err(
`UTXO not found: ${txHashHex}:${input.outpointIndex}. Make sure it exists in your wallet.`, `UTXO not found: ${txHashHex}:${input.outpointIndex}. Make sure it exists in your wallet.`,
); );
return null; return null;
} }
// Sum the valueSatoshis
totalInputSats += BigInt(utxo.valueSatoshis); totalInputSats += BigInt(utxo.valueSatoshis);
} }
deps.io.verbose(`Total input value: ${totalInputSats} satoshis`); deps.io.verbose(`Total input value: ${totalInputSats} satoshis`);
// Get the required sats out
const requiredSats = await invitation.getSatsOut(); const requiredSats = await invitation.getSatsOut();
deps.io.verbose(`Required output value: ${requiredSats} satoshis`); deps.io.verbose(`Required output value: ${requiredSats} satoshis`);
// Get the change amount by subtracting the required sats out from the total input sats and the default fee
const changeAmount = totalInputSats - requiredSats - DEFAULT_FEE; const changeAmount = totalInputSats - requiredSats - DEFAULT_FEE;
deps.io.verbose( deps.io.verbose(
`Change amount: ${changeAmount} satoshis (fee: ${DEFAULT_FEE})`, `Change amount: ${changeAmount} satoshis (fee: ${DEFAULT_FEE})`,
); );
// If the change amount is less than 0, print a message and return null
if (changeAmount < 0n) { if (changeAmount < 0n) {
deps.io.err( deps.io.err(
`Insufficient funds. Inputs total ${totalInputSats} sats, but need ${requiredSats + DEFAULT_FEE} sats (${requiredSats} required + ${DEFAULT_FEE} fee).`, `Insufficient funds. Inputs total ${totalInputSats} sats, but need ${requiredSats + DEFAULT_FEE} sats (${requiredSats} required + ${DEFAULT_FEE} fee).`,
@@ -234,10 +255,13 @@ async function buildAppendParams(
return null; return null;
} }
// If the change amount is greater than or equal to the dust threshold, add the change output
if (changeAmount >= DUST_THRESHOLD) { if (changeAmount >= DUST_THRESHOLD) {
outputs.push({ valueSatoshis: changeAmount }); outputs.push({ valueSatoshis: changeAmount });
deps.io.out(`Auto-adding change output: ${changeAmount} satoshis`); deps.io.out(`Auto-adding change output: ${changeAmount} satoshis`);
} else if (changeAmount > 0n) { }
// If the change amount is greater than 0, print a message
else if (changeAmount > 0n) {
deps.io.out( deps.io.out(
`Change ${changeAmount} sats is below dust threshold (${DUST_THRESHOLD} sats), donating to miners as fee.`, `Change ${changeAmount} sats is below dust threshold (${DUST_THRESHOLD} sats), donating to miners as fee.`,
); );
@@ -311,6 +335,7 @@ export const handleInvitationCommand = async (
const subCommand = args[0]; const subCommand = args[0];
deps.io.verbose(`Invitation sub-command: ${subCommand}`); deps.io.verbose(`Invitation sub-command: ${subCommand}`);
// If there was no subcommand provided, print the help message and throw an error
if (!subCommand) { if (!subCommand) {
deps.io.verbose("No sub-command provided"); deps.io.verbose("No sub-command provided");
printInvitationHelp(deps.io); printInvitationHelp(deps.io);
@@ -320,14 +345,18 @@ export const handleInvitationCommand = async (
); );
} }
// Switch statement to handle the different subcommands
switch (subCommand) { switch (subCommand) {
case "create": { case "create": {
// Get the template query and action identifier from the arguments
const templateQuery = args[1]; const templateQuery = args[1];
const actionIdentifier = args[2]; const actionIdentifier = args[2];
deps.io.verbose( deps.io.verbose(
`Template query: ${templateQuery}, action identifier: ${actionIdentifier}`, `Template query: ${templateQuery}, action identifier: ${actionIdentifier}`,
); );
// If they didnt provide us with a template query or action identifier, print the help message and throw an error
// TODO: Should probably print a specific help message for this command?
if (!templateQuery || !actionIdentifier) { if (!templateQuery || !actionIdentifier) {
deps.io.verbose("No template file or action identifier provided"); deps.io.verbose("No template file or action identifier provided");
printInvitationHelp(deps.io); printInvitationHelp(deps.io);
@@ -337,25 +366,31 @@ export const handleInvitationCommand = async (
); );
} }
// Resolve the template, this will check both filepath and identifier. Because we are flexible here, we will need to generate the identifier again after
const template = await resolveTemplate(deps, templateQuery); const template = await resolveTemplate(deps, templateQuery);
const templateIdentifier = generateTemplateIdentifier(template); const templateIdentifier = generateTemplateIdentifier(template);
// Create an XOInvitation. We will convert this into our own invitation instance afterwards
const rawInvitation = await deps.app.engine.createInvitation({ const rawInvitation = await deps.app.engine.createInvitation({
templateIdentifier, templateIdentifier,
actionIdentifier, actionIdentifier,
}); });
deps.io.verbose(`XOInvitation created: ${formatObject(rawInvitation)}`); deps.io.verbose(`XOInvitation created: ${formatObject(rawInvitation)}`);
// Create our own invitation instance out of the raw XOInvitation. This will also initate the SSE Session
const invitationInstance = await deps.app.createInvitation(rawInvitation); const invitationInstance = await deps.app.createInvitation(rawInvitation);
deps.io.verbose( deps.io.verbose(
`Invitation created: ${formatObject(invitationInstance.data)}`, `Invitation created: ${formatObject(invitationInstance.data)}`,
); );
// Read the variables that were passed in via `-var-<name> <value>`
const variables = parseVariablesFromOptions(options); const variables = parseVariablesFromOptions(options);
deps.io.verbose(`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);
} }
// Build the parameters for the append call. This will resolve the inputs and outputs for the invitation.
const params = await buildAppendParams(deps, invitationInstance, options); const params = await buildAppendParams(deps, invitationInstance, options);
if (!params) { if (!params) {
throw new CommandError( throw new CommandError(
@@ -364,11 +399,14 @@ export const handleInvitationCommand = async (
); );
} }
// Append the inputs and outputs to the invitation
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 });
} }
// Write the invitation to a file in the working directory
// TODO: Support the -o flag to specify the output path
const invitationFilePath = `${deps.paths.workingDir}/inv-${invitationInstance.data.invitationIdentifier}.json`; const invitationFilePath = `${deps.paths.workingDir}/inv-${invitationInstance.data.invitationIdentifier}.json`;
deps.io.verbose(`Invitation file path: ${invitationFilePath}`); deps.io.verbose(`Invitation file path: ${invitationFilePath}`);
writeFileSync( writeFileSync(
@@ -379,6 +417,7 @@ export const handleInvitationCommand = async (
`Invitation created: ${path.basename(invitationFilePath)} (${invitationInstance.data.invitationIdentifier})`, `Invitation created: ${path.basename(invitationFilePath)} (${invitationInstance.data.invitationIdentifier})`,
); );
// Get the missing requirements for the invitation. This will tell us if we are missing any variables, inputs, outputs, or roles.
const missingRequirements = const missingRequirements =
await invitationInstance.getMissingRequirements(); await invitationInstance.getMissingRequirements();
const hasMissing = const hasMissing =
@@ -388,14 +427,17 @@ export const handleInvitationCommand = async (
(missingRequirements.roles !== undefined && (missingRequirements.roles !== undefined &&
Object.keys(missingRequirements.roles).length > 0); Object.keys(missingRequirements.roles).length > 0);
// If there are missing requirements, print them out
if (hasMissing) { if (hasMissing) {
deps.io.out(`\n${bold("Remaining requirements:")}`); deps.io.out(`\n${bold("Remaining requirements:")}`);
deps.io.out(formatObject(missingRequirements)); deps.io.out(formatObject(missingRequirements));
} else { } else {
// If there are no missing requirements, sign the invitation if the user has requested it
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";
// Sign the invitation if the user has requested it
if (shouldSign) { if (shouldSign) {
await invitationInstance.sign(); await invitationInstance.sign();
deps.io.out( deps.io.out(
@@ -403,6 +445,7 @@ export const handleInvitationCommand = async (
); );
} }
// Broadcast the transaction if the user has requested it
if (shouldBroadcast) { if (shouldBroadcast) {
const txHash = await invitationInstance.broadcast(); const txHash = await invitationInstance.broadcast();
deps.io.out(`Transaction broadcast: ${bold(txHash)}`); deps.io.out(`Transaction broadcast: ${bold(txHash)}`);
@@ -412,15 +455,20 @@ export const handleInvitationCommand = async (
); );
} }
} }
// Return the invitation identifier
return { return {
invitationIdentifier: invitationInstance.data.invitationIdentifier, invitationIdentifier: invitationInstance.data.invitationIdentifier,
}; };
} }
case "append": { case "append": {
// Get the invitation identifier from the arguments
const invitationIdentifier = args[1]; const invitationIdentifier = args[1];
deps.io.verbose(`Invitation identifier: ${invitationIdentifier}`); deps.io.verbose(`Invitation identifier: ${invitationIdentifier}`);
// If they didnt provide us with an invitation identifier, print the help message and throw an error
// TODO: Should probably print a specific help message for this command?
if (!invitationIdentifier) { if (!invitationIdentifier) {
deps.io.verbose("No invitation identifier provided"); deps.io.verbose("No invitation identifier provided");
printInvitationHelp(deps.io); printInvitationHelp(deps.io);
@@ -430,9 +478,12 @@ export const handleInvitationCommand = async (
); );
} }
// Find the invitation instance in our list of invitations
const invitation = deps.app.invitations.find( const invitation = deps.app.invitations.find(
(inv) => inv.data.invitationIdentifier === invitationIdentifier, (inv) => inv.data.invitationIdentifier === invitationIdentifier,
); );
// If the invitation is not found, print an error and throw an error
if (!invitation) { if (!invitation) {
deps.io.err(`Invitation not found: ${invitationIdentifier}`); deps.io.err(`Invitation not found: ${invitationIdentifier}`);
throw new CommandError( throw new CommandError(
@@ -442,12 +493,14 @@ export const handleInvitationCommand = async (
} }
deps.io.verbose(`Invitation: ${formatObject(invitation.data)}`); deps.io.verbose(`Invitation: ${formatObject(invitation.data)}`);
// Parse the variables that were passed in via `-var-<name> <value>`
const variables = parseVariablesFromOptions(options); const variables = parseVariablesFromOptions(options);
deps.io.verbose(`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);
} }
// Build the parameters for the append call. This will resolve the inputs and outputs for the invitation.
const params = await buildAppendParams(deps, invitation, options); const params = await buildAppendParams(deps, invitation, options);
if (!params) { if (!params) {
throw new CommandError( throw new CommandError(
@@ -456,6 +509,7 @@ export const handleInvitationCommand = async (
); );
} }
// If there are no variables, inputs, or outputs, print an error and throw an error
const { inputs, outputs } = params; const { inputs, outputs } = params;
if ( if (
variables.length === 0 && variables.length === 0 &&
@@ -468,18 +522,22 @@ export const handleInvitationCommand = async (
throw new CommandError("invitation.append.empty", error); throw new CommandError("invitation.append.empty", error);
} }
// Append the inputs and outputs to the invitation
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.io.verbose(`Invitation appended: ${formatObject(invitation.data)}`); deps.io.verbose(`Invitation appended: ${formatObject(invitation.data)}`);
deps.io.out(`Invitation appended: ${invitationIdentifier}`); deps.io.out(`Invitation appended: ${invitationIdentifier}`);
// Write the invitation to a file in the working directory
// TODO: Support the -o flag to specify the output path
const invitationFilePath = `${deps.paths.workingDir}/inv-${invitation.data.invitationIdentifier}.json`; const invitationFilePath = `${deps.paths.workingDir}/inv-${invitation.data.invitationIdentifier}.json`;
writeFileSync(invitationFilePath, encodeExtendedJson(invitation.data, 2)); writeFileSync(invitationFilePath, encodeExtendedJson(invitation.data, 2));
deps.io.out( deps.io.out(
`Invitation updated: ${path.basename(invitationFilePath)} (${invitation.data.invitationIdentifier})`, `Invitation updated: ${path.basename(invitationFilePath)} (${invitation.data.invitationIdentifier})`,
); );
// Get the missing requirements for the invitation. This will tell us if we are missing any variables, inputs, outputs, or roles.
const missingRequirements = await invitation.getMissingRequirements(); const missingRequirements = await invitation.getMissingRequirements();
const hasMissing = const hasMissing =
(missingRequirements.variables?.length ?? 0) > 0 || (missingRequirements.variables?.length ?? 0) > 0 ||
@@ -488,19 +546,23 @@ export const handleInvitationCommand = async (
(missingRequirements.roles !== undefined && (missingRequirements.roles !== undefined &&
Object.keys(missingRequirements.roles).length > 0); Object.keys(missingRequirements.roles).length > 0);
// If there are missing requirements, print them out
if (hasMissing) { if (hasMissing) {
deps.io.out(`\n${bold("Remaining requirements:")}`); deps.io.out(`\n${bold("Remaining requirements:")}`);
deps.io.out(formatObject(missingRequirements)); deps.io.out(formatObject(missingRequirements));
} else { } else {
// If there are no missing requirements, sign the invitation if the user has requested it
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";
// Sign the invitation if the user has requested it
if (shouldSign) { if (shouldSign) {
await invitation.sign(); await invitation.sign();
deps.io.out(`Invitation signed: ${invitationIdentifier}`); deps.io.out(`Invitation signed: ${invitationIdentifier}`);
} }
// Broadcast the transaction if the user has requested it
if (shouldBroadcast) { if (shouldBroadcast) {
const txHash = await invitation.broadcast(); const txHash = await invitation.broadcast();
deps.io.out(`Transaction broadcast: ${bold(txHash)}`); deps.io.out(`Transaction broadcast: ${bold(txHash)}`);
@@ -514,8 +576,12 @@ export const handleInvitationCommand = async (
} }
case "sign": { case "sign": {
// Get the invitation identifier from the arguments
const invitationIdentifier = args[1]; const invitationIdentifier = args[1];
deps.io.verbose(`Invitation identifier: ${invitationIdentifier}`); deps.io.verbose(`Invitation identifier: ${invitationIdentifier}`);
// If they didnt provide us with an invitation identifier, print the help message and throw an error
// TODO: Should probably print a specific help message for this command?
if (!invitationIdentifier) { if (!invitationIdentifier) {
deps.io.verbose("No invitation identifier provided"); deps.io.verbose("No invitation identifier provided");
printInvitationHelp(deps.io); printInvitationHelp(deps.io);
@@ -525,10 +591,13 @@ export const handleInvitationCommand = async (
); );
} }
// Find the invitation instance in our list of invitations
const invitation = deps.app.invitations.find( const invitation = deps.app.invitations.find(
(candidate) => (candidate) =>
candidate.data.invitationIdentifier === invitationIdentifier, candidate.data.invitationIdentifier === invitationIdentifier,
); );
// If the invitation is not found, print an error and throw an error
if (!invitation) { if (!invitation) {
deps.io.err(`Invitation not found: ${invitationIdentifier}`); deps.io.err(`Invitation not found: ${invitationIdentifier}`);
throw new CommandError( throw new CommandError(
@@ -538,15 +607,22 @@ export const handleInvitationCommand = async (
} }
deps.io.verbose(`Invitation: ${formatObject(invitation.data)}`); deps.io.verbose(`Invitation: ${formatObject(invitation.data)}`);
// Sign the invitation
await invitation.sign(); await invitation.sign();
deps.io.verbose(`Invitation signed: ${formatObject(invitation.data)}`); deps.io.verbose(`Invitation signed: ${formatObject(invitation.data)}`);
deps.io.out(`Invitation signed: ${invitationIdentifier}`); deps.io.out(`Invitation signed: ${invitationIdentifier}`);
// Return the invitation identifier
return { invitationIdentifier }; return { invitationIdentifier };
} }
case "broadcast": { case "broadcast": {
// Get the invitation identifier from the arguments
const invitationIdentifier = args[1]; const invitationIdentifier = args[1];
deps.io.verbose(`Invitation identifier: ${invitationIdentifier}`); deps.io.verbose(`Invitation identifier: ${invitationIdentifier}`);
// If they didnt provide us with an invitation identifier, print the help message and throw an error
// TODO: Should probably print a specific help message for this command?
if (!invitationIdentifier) { if (!invitationIdentifier) {
deps.io.verbose("No invitation identifier provided"); deps.io.verbose("No invitation identifier provided");
printInvitationHelp(deps.io); printInvitationHelp(deps.io);
@@ -556,10 +632,13 @@ export const handleInvitationCommand = async (
); );
} }
// Find the invitation instance in our list of invitations
const invitation = deps.app.invitations.find( const invitation = deps.app.invitations.find(
(candidate) => (candidate) =>
candidate.data.invitationIdentifier === invitationIdentifier, candidate.data.invitationIdentifier === invitationIdentifier,
); );
// If the invitation is not found, print an error and throw an error
if (!invitation) { if (!invitation) {
deps.io.err(`Invitation not found: ${invitationIdentifier}`); deps.io.err(`Invitation not found: ${invitationIdentifier}`);
throw new CommandError( throw new CommandError(
@@ -569,17 +648,24 @@ export const handleInvitationCommand = async (
} }
deps.io.verbose(`Invitation: ${formatObject(invitation.data)}`); deps.io.verbose(`Invitation: ${formatObject(invitation.data)}`);
// Broadcast the transaction
const txHash = await invitation.broadcast(); const txHash = await invitation.broadcast();
deps.io.verbose( deps.io.verbose(
`Invitation broadcasted: ${formatObject(invitation.data)}`, `Invitation broadcasted: ${formatObject(invitation.data)}`,
); );
deps.io.out(`Transaction broadcast: ${bold(txHash)}`); deps.io.out(`Transaction broadcast: ${bold(txHash)}`);
// Return the invitation identifier and transaction hash
return { invitationIdentifier, txHash }; return { invitationIdentifier, txHash };
} }
case "requirements": { case "requirements": {
// Get the invitation identifier from the arguments
const invitationIdentifier = args[1]; const invitationIdentifier = args[1];
deps.io.verbose(`Invitation identifier: ${invitationIdentifier}`); deps.io.verbose(`Invitation identifier: ${invitationIdentifier}`);
// If they didnt provide us with an invitation identifier, print the help message and throw an error
// TODO: Should probably print a specific help message for this command?
if (!invitationIdentifier) { if (!invitationIdentifier) {
deps.io.verbose("No invitation identifier provided"); deps.io.verbose("No invitation identifier provided");
printInvitationHelp(deps.io); printInvitationHelp(deps.io);
@@ -589,10 +675,13 @@ export const handleInvitationCommand = async (
); );
} }
// Find the invitation instance in our list of invitations
const invitation = deps.app.invitations.find( const invitation = deps.app.invitations.find(
(candidate) => (candidate) =>
candidate.data.invitationIdentifier === invitationIdentifier, candidate.data.invitationIdentifier === invitationIdentifier,
); );
// If the invitation is not found, print an error and throw an error
if (!invitation) { if (!invitation) {
deps.io.err(`Invitation not found: ${invitationIdentifier}`); deps.io.err(`Invitation not found: ${invitationIdentifier}`);
throw new CommandError( throw new CommandError(
@@ -602,18 +691,26 @@ export const handleInvitationCommand = async (
} }
deps.io.verbose(`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.io.verbose(`Requirements: ${formatObject(requirements)}`); deps.io.verbose(`Requirements: ${formatObject(requirements)}`);
deps.io.out(formatObject(requirements)); deps.io.out(formatObject(requirements));
// Return the invitation identifier
return { invitationIdentifier }; return { invitationIdentifier };
} }
case "inspect": { case "inspect": {
// Get the invitation file path from the arguments
const invitationFilePath = args[1]; const invitationFilePath = args[1];
// If they didnt provide us with an invitation file path, print the help message and throw an error
// TODO: Should probably print a specific help message for this command?
deps.io.verbose(`Invitation file path: ${invitationFilePath}`); deps.io.verbose(`Invitation file path: ${invitationFilePath}`);
// Read the invitation file
if (!invitationFilePath) { if (!invitationFilePath) {
deps.io.verbose("No invitation file provided"); deps.io.verbose("No invitation file provided");
printInvitationHelp(deps.io); printInvitationHelp(deps.io);
@@ -623,24 +720,31 @@ export const handleInvitationCommand = async (
); );
} }
// Read the invitation file (XOInvitation format, can be passed to the engine directly)
const invitationFile = await readFileSync(invitationFilePath, "utf8"); const invitationFile = await readFileSync(invitationFilePath, "utf8");
deps.io.verbose(`Invitation file: ${invitationFile}`); deps.io.verbose(`Invitation file: ${invitationFile}`);
// Parse the invitation file
const invitation = JSON.parse(invitationFile); const invitation = JSON.parse(invitationFile);
deps.io.verbose(`Invitation: ${formatObject(invitation)}`); deps.io.verbose(`Invitation: ${formatObject(invitation)}`);
// Create the invitation instance (NOTE: Not an engine compatible invitation. This is the wrapped format)
const invitationInstance = await deps.app.createInvitation(invitation); const invitationInstance = await deps.app.createInvitation(invitation);
deps.io.verbose( deps.io.verbose(
`Invitation created: ${formatObject(invitationInstance.data)}`, `Invitation created: ${formatObject(invitationInstance.data)}`,
); );
// Get the template for the invitation
const template = await deps.app.engine.getTemplate( const template = await deps.app.engine.getTemplate(
invitationInstance.data.templateIdentifier, invitationInstance.data.templateIdentifier,
); );
// Get the action for the invitation
const action = const action =
template?.actions[invitationInstance.data.actionIdentifier]; template?.actions[invitationInstance.data.actionIdentifier];
deps.io.verbose(`Action: ${formatObject(action)}`); deps.io.verbose(`Action: ${formatObject(action)}`);
// If the action is not found, print an error and throw an error
if (!action) { if (!action) {
deps.io.err( deps.io.err(
`Action not found: ${invitationInstance.data.actionIdentifier}`, `Action not found: ${invitationInstance.data.actionIdentifier}`,
@@ -651,9 +755,11 @@ export const handleInvitationCommand = async (
); );
} }
// Get the status for the invitation
const status = invitationInstance.status; const status = invitationInstance.status;
deps.io.verbose(`Status: ${status}`); deps.io.verbose(`Status: ${status}`);
// Get the entities for the invitation
const entities = Array.from( const entities = Array.from(
new Set( new Set(
invitationInstance.data.commits.map( invitationInstance.data.commits.map(
@@ -663,6 +769,7 @@ export const handleInvitationCommand = async (
); );
deps.io.verbose(`Entities: ${formatObject(entities)}`); deps.io.verbose(`Entities: ${formatObject(entities)}`);
// Get the entities with roles for the invitation
const entitiesWithRoles = entities.map((entity) => { const entitiesWithRoles = entities.map((entity) => {
return { return {
entityIdentifier: entity, entityIdentifier: entity,
@@ -685,21 +792,25 @@ export const handleInvitationCommand = async (
}; };
}); });
// Get the inputs for the invitation
const inputs = invitationInstance.data.commits.flatMap( const inputs = invitationInstance.data.commits.flatMap(
(commit) => commit.data.inputs ?? [], (commit) => commit.data.inputs ?? [],
); );
deps.io.verbose(`Inputs: ${formatObject(inputs)}`); deps.io.verbose(`Inputs: ${formatObject(inputs)}`);
// Get the outputs for the invitation
const outputs = invitationInstance.data.commits.flatMap( const outputs = invitationInstance.data.commits.flatMap(
(commit) => commit.data.outputs ?? [], (commit) => commit.data.outputs ?? [],
); );
deps.io.verbose(`Outputs: ${formatObject(outputs)}`); deps.io.verbose(`Outputs: ${formatObject(outputs)}`);
// Get the variables for the invitation
const variables = invitationInstance.data.commits.flatMap( const variables = invitationInstance.data.commits.flatMap(
(commit) => commit.data.variables ?? [], (commit) => commit.data.variables ?? [],
); );
deps.io.verbose(`Variables: ${formatObject(variables)}`); deps.io.verbose(`Variables: ${formatObject(variables)}`);
// Return the invitation details
return { return {
templateName: template?.name ?? "Unknown", templateName: template?.name ?? "Unknown",
actionIdentifier: invitationInstance.data.actionIdentifier, actionIdentifier: invitationInstance.data.actionIdentifier,
@@ -712,9 +823,12 @@ export const handleInvitationCommand = async (
} }
case "import": { case "import": {
// Get the invitation file path from the arguments
const invitationFilePath = args[1]; const invitationFilePath = args[1];
deps.io.verbose(`Invitation file path: ${invitationFilePath}`); deps.io.verbose(`Invitation file path: ${invitationFilePath}`);
// If they didnt provide us with an invitation file path, print the help message and throw an error
// TODO: Should probably print a specific help message for this command?
if (!invitationFilePath) { if (!invitationFilePath) {
deps.io.verbose("No invitation file provided"); deps.io.verbose("No invitation file provided");
printInvitationHelp(deps.io); printInvitationHelp(deps.io);
@@ -724,26 +838,41 @@ export const handleInvitationCommand = async (
); );
} }
// Read the invitation file (XOInvitation format, can be passed to the engine directly)
const invitationFile = await readFileSync(invitationFilePath, "utf8"); const invitationFile = await readFileSync(invitationFilePath, "utf8");
deps.io.verbose(`Invitation file: ${invitationFile}`); deps.io.verbose(`Invitation file: ${invitationFile}`);
// Parse the invitation file
const invitation = JSON.parse(invitationFile); const invitation = JSON.parse(invitationFile);
deps.io.verbose(`Invitation: ${formatObject(invitation)}`); deps.io.verbose(`Invitation: ${formatObject(invitation)}`);
// "Creates" the invitiation in the engine. This method acts as both creation or import depending on the data that is being passed in
const xoInvitation = await deps.app.engine.createInvitation(invitation); const xoInvitation = await deps.app.engine.createInvitation(invitation);
deps.io.verbose(`XOInvitation: ${formatObject(xoInvitation)}`);
// Create the invitation instance (NOTE: Not an engine compatible invitation. This is the wrapped format)
const invitationInstance = await deps.app.createInvitation(xoInvitation); const invitationInstance = await deps.app.createInvitation(xoInvitation);
deps.io.verbose( deps.io.verbose(
`Invitation created: ${formatObject(invitationInstance.data)}`, `Invitation created: ${formatObject(invitationInstance.data)}`,
); );
// Return the invitation identifier
return { return {
invitationIdentifier: invitationInstance.data.invitationIdentifier, invitationIdentifier: invitationInstance.data.invitationIdentifier,
}; };
} }
case "list": { case "list": {
// List all the invitations
const invitations = await Promise.all( const invitations = await Promise.all(
// Iterate over the invitations and compile them into a list of data that we can use to display them with another loop later.
deps.app.invitations.map(async (invitation) => { deps.app.invitations.map(async (invitation) => {
// Get the template for the invitation
const template = await deps.app.engine.getTemplate( const template = await deps.app.engine.getTemplate(
invitation.data.templateIdentifier, invitation.data.templateIdentifier,
); );
// Get the role identifier for the invitation
return { return {
invitationIdentifier: invitation.data.invitationIdentifier, invitationIdentifier: invitation.data.invitationIdentifier,
templateIdentifier: invitation.data.templateIdentifier, templateIdentifier: invitation.data.templateIdentifier,
@@ -755,15 +884,22 @@ export const handleInvitationCommand = async (
}), }),
); );
deps.io.verbose(`Invitations: ${formatObject(invitations)}`); deps.io.verbose(`Invitations: ${formatObject(invitations)}`);
// Format the invitations into a list of strings that we can display to the user
const formattedInvitations = invitations.map( const formattedInvitations = invitations.map(
(invitation) => (invitation) =>
`${bold(invitation.templateName)} ${dim(invitation.status)} ${dim(invitation.invitationIdentifier)} ${dim(invitation.actionIdentifier)} (${dim(invitation.roleIdentifier)})`, `${bold(invitation.templateName)} ${dim(invitation.status)} ${dim(invitation.invitationIdentifier)} ${dim(invitation.actionIdentifier)} (${dim(invitation.roleIdentifier)})`,
); );
// Display the invitations to the user
deps.io.out(formattedInvitations.join("\n")); deps.io.out(formattedInvitations.join("\n"));
// Return the number of invitations
return { count: invitations.length }; return { count: invitations.length };
} }
default: default:
// If the sub-command is not found, print an error and throw an error
deps.io.verbose(`Unknown invitation sub-command: ${subCommand}`); deps.io.verbose(`Unknown invitation sub-command: ${subCommand}`);
printInvitationHelp(deps.io); printInvitationHelp(deps.io);
throw new CommandError( throw new CommandError(

View File

@@ -41,9 +41,11 @@ export const handleMnemonicCommand = async (
args: string[], args: string[],
options: Record<string, string>, options: Record<string, string>,
): Promise<{ savedAs?: string; count?: number; mnemonic?: string }> => { ): Promise<{ savedAs?: string; count?: number; mnemonic?: string }> => {
// Get the sub-command from the arguments
const subCommand = args[0]; const subCommand = args[0];
const { mnemonicsDir } = deps.paths; const { mnemonicsDir } = deps.paths;
// If no sub-command is provided, print the help message and throw an error
if (!subCommand) { if (!subCommand) {
deps.io.verbose("No sub-command provided"); deps.io.verbose("No sub-command provided");
printMnemonicHelp(deps.io); printMnemonicHelp(deps.io);
@@ -53,22 +55,29 @@ export const handleMnemonicCommand = async (
); );
} }
// Handle the sub-command
switch (subCommand) { switch (subCommand) {
case "create": { case "create": {
// Create a new mnemonic seed
const mnemonicSeed = createMnemonicSeed(); const mnemonicSeed = createMnemonicSeed();
// Create a new mnemonic file
const savedAs = createMnemonicFile( const savedAs = createMnemonicFile(
mnemonicsDir, mnemonicsDir,
mnemonicSeed, mnemonicSeed,
options["output"], options["output"],
); );
// Display the mnemonic file to the user
deps.io.out(`Mnemonic file created: ${savedAs} (${mnemonicSeed})`); deps.io.out(`Mnemonic file created: ${savedAs} (${mnemonicSeed})`);
return { savedAs }; return { savedAs };
} }
case "import": { case "import": {
// Get the mnemonic seed from the arguments
const mnemonicSeed = args.slice(1).join(" "); const mnemonicSeed = args.slice(1).join(" ");
// If no mnemonic seed is provided, print the help message and throw an error
if (!mnemonicSeed) { if (!mnemonicSeed) {
deps.io.verbose("No mnemonic seed provided"); deps.io.verbose("No mnemonic seed provided");
printMnemonicHelp(deps.io); printMnemonicHelp(deps.io);
@@ -78,25 +87,33 @@ export const handleMnemonicCommand = async (
); );
} }
// Create a new mnemonic file
deps.io.verbose(`Mnemonic seed: ${mnemonicSeed}`); deps.io.verbose(`Mnemonic seed: ${mnemonicSeed}`);
const savedAs = createMnemonicFile( const savedAs = createMnemonicFile(
mnemonicsDir, mnemonicsDir,
mnemonicSeed, mnemonicSeed,
options["output"], options["output"],
); );
// Display the mnemonic file to the user
deps.io.out(`Mnemonic file created: ${savedAs}`); deps.io.out(`Mnemonic file created: ${savedAs}`);
return { savedAs }; return { savedAs };
} }
case "list": { case "list": {
// List all the mnemonic files
const mnemonicFiles = listMnemonicFiles(mnemonicsDir); const mnemonicFiles = listMnemonicFiles(mnemonicsDir);
deps.io.out(mnemonicFiles.join("\n")); deps.io.out(mnemonicFiles.join("\n"));
// Return the number of mnemonic files
return { count: mnemonicFiles.length }; return { count: mnemonicFiles.length };
} }
case "expose": { case "expose": {
// Get the mnemonic file from the arguments
const mnemonicFile = args[1]; const mnemonicFile = args[1];
// If no mnemonic file is provided, print the help message and throw an error
if (!mnemonicFile) { if (!mnemonicFile) {
deps.io.verbose("No mnemonic file provided"); deps.io.verbose("No mnemonic file provided");
printMnemonicHelp(deps.io); printMnemonicHelp(deps.io);
@@ -106,11 +123,15 @@ export const handleMnemonicCommand = async (
); );
} }
// Try to load the mnemonic file
try { try {
const mnemonic = loadMnemonic(mnemonicsDir, mnemonicFile); const mnemonic = loadMnemonic(mnemonicsDir, mnemonicFile);
deps.io.out(mnemonic); deps.io.out(mnemonic);
// Return the mnemonic
return { mnemonic }; return { mnemonic };
} catch (error) { } catch (error) {
// If the mnemonic file is not found, print an error and throw an error
throw new CommandError( throw new CommandError(
"mnemonic.expose.file_not_found", "mnemonic.expose.file_not_found",
`Mnemonic file not found: ${mnemonicFile}`, `Mnemonic file not found: ${mnemonicFile}`,
@@ -119,6 +140,7 @@ export const handleMnemonicCommand = async (
} }
default: default:
// If the sub-command is not found, print an error and throw an error
deps.io.err(`Unknown sub-command: ${subCommand}`); deps.io.err(`Unknown sub-command: ${subCommand}`);
printMnemonicHelp(deps.io); printMnemonicHelp(deps.io);
throw new CommandError( throw new CommandError(

View File

@@ -44,14 +44,17 @@ export const handleReceiveCommand = async (
args: string[], args: string[],
_options: Record<string, string>, _options: Record<string, string>,
): Promise<{ address: string }> => { ): Promise<{ address: string }> => {
// Get the template query, output identifier, and role identifier from the arguments
const templateQuery = args[0]; const templateQuery = args[0];
const outputIdentifier = args[1]; const outputIdentifier = args[1];
const roleIdentifier = args[2]; const roleIdentifier = args[2];
// Log the receive args
deps.io.verbose( deps.io.verbose(
`Receive args - template: ${templateQuery}, output: ${outputIdentifier}, role: ${roleIdentifier}`, `Receive args - template: ${templateQuery}, output: ${outputIdentifier}, role: ${roleIdentifier}`,
); );
// If no template query or output identifier is provided, print the help message and throw an error
if (!templateQuery || !outputIdentifier) { if (!templateQuery || !outputIdentifier) {
deps.io.verbose("Missing required arguments"); deps.io.verbose("Missing required arguments");
printReceiveHelp(deps.io); printReceiveHelp(deps.io);

View File

@@ -30,18 +30,27 @@ function formatResource(
resource: UnspentOutputData, resource: UnspentOutputData,
showReserved = false, showReserved = false,
): string { ): string {
// Format the outpoint
const outpoint = bold( const outpoint = bold(
`${resource.outpointTransactionHash}:${resource.outpointIndex}`, `${resource.outpointTransactionHash}:${resource.outpointIndex}`,
); );
// Format the value
const value = dim(`${resource.valueSatoshis} sats`); const value = dim(`${resource.valueSatoshis} sats`);
// Format the output
const output = dim(resource.outputIdentifier); const output = dim(resource.outputIdentifier);
// Format the height
const height = dim(`(height ${resource.minedAtHeight})`); const height = dim(`(height ${resource.minedAtHeight})`);
// If the resource is reserved, format the reservation info
if (showReserved && resource.reservedBy) { if (showReserved && resource.reservedBy) {
const inv = dim(`reserved for ${resource.reservedBy}`); const inv = dim(`reserved for ${resource.reservedBy}`);
return `${outpoint} ${value} ${output} ${height} ${inv}`; return `${outpoint} ${value} ${output} ${height} ${inv}`;
} }
// Otherwise, format the resource without reservation info
return `${outpoint} ${value} ${output} ${height}`; return `${outpoint} ${value} ${output} ${height}`;
} }
@@ -60,6 +69,7 @@ export const handleResourceCommand = async (
const subCommand = args[0]; const subCommand = args[0];
deps.io.verbose(`Resource sub-command: ${subCommand}`); deps.io.verbose(`Resource sub-command: ${subCommand}`);
// If no sub-command is provided, print the help message and throw an error
if (!subCommand) { if (!subCommand) {
deps.io.verbose("No sub-command provided"); deps.io.verbose("No sub-command provided");
printResourceHelp(deps.io); printResourceHelp(deps.io);
@@ -69,39 +79,59 @@ export const handleResourceCommand = async (
); );
} }
// Handle the sub-command
switch (subCommand) { switch (subCommand) {
case "list": { case "list": {
// Get the qualifier from the arguments - This could be "reserved", "all", or omitted (which defaults to "unreserved")
const qualifier = args[1]; const qualifier = args[1];
// List all the unspent outputs data
const allResources = await deps.app.engine.listUnspentOutputsData(); const allResources = await deps.app.engine.listUnspentOutputsData();
let filtered; let filtered;
// If the qualifier is "reserved", return only the reserved resources
if (qualifier === "reserved") { if (qualifier === "reserved") {
filtered = allResources.filter((r) => r.reservedBy); filtered = allResources.filter((r) => r.reservedBy);
} else if (qualifier === "all") { }
// If the qualifier is "all", return all the resources
else if (qualifier === "all") {
filtered = allResources; filtered = allResources;
} else { }
// If the qualifier is not "reserved" or "all", return only the unreserved resources
else {
filtered = allResources.filter((r) => !r.reservedBy); filtered = allResources.filter((r) => !r.reservedBy);
} }
// If no resources are found, print a message and return 0
if (filtered.length === 0) { if (filtered.length === 0) {
deps.io.out(dim("No resources found.")); deps.io.out(dim("No resources found."));
return { count: 0 }; return { count: 0 };
} }
// Format the resources into a list of strings that we can display to the user
const showReserved = qualifier === "all" || qualifier === "reserved"; const showReserved = qualifier === "all" || qualifier === "reserved";
const formattedResources = filtered.map((r) => const formattedResources = filtered.map((r) =>
formatResource(r, showReserved), formatResource(r, showReserved),
); );
// Display the resources to the user
deps.io.out(formattedResources.join("\n")); deps.io.out(formattedResources.join("\n"));
// Display the total satoshis
deps.io.out( deps.io.out(
`Total satoshis: ${filtered.reduce((acc, r) => acc + r.valueSatoshis, 0)}`, `Total satoshis: ${filtered.reduce((acc, r) => acc + r.valueSatoshis, 0)}`,
); );
// Display the total resources
deps.io.out(`Total resources: ${filtered.length}`); deps.io.out(`Total resources: ${filtered.length}`);
return { count: filtered.length }; return { count: filtered.length };
} }
case "unreserve": { case "unreserve": {
// Get the outpoint from the arguments
const outpointArg = args[1]; const outpointArg = args[1];
// If no outpoint is provided, print a message and throw an error
if (!outpointArg) { if (!outpointArg) {
deps.io.err("Please provide a UTXO in <txhash>:<vout> format."); deps.io.err("Please provide a UTXO in <txhash>:<vout> format.");
printResourceHelp(deps.io); printResourceHelp(deps.io);
@@ -111,8 +141,10 @@ export const handleResourceCommand = async (
); );
} }
// Get the separator index
const separatorIndex = outpointArg.lastIndexOf(":"); const separatorIndex = outpointArg.lastIndexOf(":");
if (separatorIndex === -1) { if (separatorIndex === -1) {
// If the separator index is -1 (not found), print a message and throw an error
deps.io.err( deps.io.err(
`Invalid format "${outpointArg}". Expected <txhash>:<vout>.`, `Invalid format "${outpointArg}". Expected <txhash>:<vout>.`,
); );
@@ -122,8 +154,11 @@ export const handleResourceCommand = async (
); );
} }
// Get the tx hash and vout
const txHash = outpointArg.substring(0, separatorIndex); const txHash = outpointArg.substring(0, separatorIndex);
const vout = parseInt(outpointArg.substring(separatorIndex + 1), 10); const vout = parseInt(outpointArg.substring(separatorIndex + 1), 10);
// If the tx hash or vout is not a string or isNaN, print a message and throw an error
if (!txHash || isNaN(vout)) { if (!txHash || isNaN(vout)) {
deps.io.err( deps.io.err(
`Invalid format "${outpointArg}". Expected <txhash>:<vout>.`, `Invalid format "${outpointArg}". Expected <txhash>:<vout>.`,
@@ -134,11 +169,15 @@ export const handleResourceCommand = async (
); );
} }
// Gather all of our resources
const allResources = await deps.app.engine.listUnspentOutputsData(); const allResources = await deps.app.engine.listUnspentOutputsData();
// Find the target resource
const target = allResources.find( const target = allResources.find(
(r) => r.outpointTransactionHash === txHash && r.outpointIndex === vout, (r) => r.outpointTransactionHash === txHash && r.outpointIndex === vout,
); );
// If the target resource is not found, print a message and throw an error
if (!target) { if (!target) {
deps.io.err(`UTXO not found: ${txHash}:${vout}`); deps.io.err(`UTXO not found: ${txHash}:${vout}`);
throw new CommandError( throw new CommandError(
@@ -147,28 +186,39 @@ export const handleResourceCommand = async (
); );
} }
// If the target resource is not reserved, print a message and return
if (!target.reservedBy) { if (!target.reservedBy) {
deps.io.out(dim("UTXO is not reserved. Nothing to do.")); deps.io.out(dim("UTXO is not reserved. Nothing to do."));
return {}; return {};
} }
// Unreserve the resources
await deps.app.engine.unreserveResources( await deps.app.engine.unreserveResources(
[{ outpointTransactionHash: hexToBin(txHash), outpointIndex: vout }], [{ outpointTransactionHash: hexToBin(txHash), outpointIndex: vout }],
target.reservedBy, target.reservedBy,
); );
deps.io.out( deps.io.out(
`Unreserved ${bold(`${txHash}:${vout}`)} (was reserved for ${target.reservedBy})`, `Unreserved ${bold(`${txHash}:${vout}`)} (was reserved for ${target.reservedBy})`,
); );
// TODO: What do I want to return here?
return {}; return {};
} }
case "unreserve-all": { case "unreserve-all": {
// Unreserve all the resources
const count = await deps.app.unreserveAllResources(); const count = await deps.app.unreserveAllResources();
// If no resources are reserved, print a message and return
if (count === 0) { if (count === 0) {
deps.io.out(dim("No reserved resources to unreserve.")); deps.io.out(dim("No reserved resources to unreserve."));
} else { }
// If some resources were unreserved, print a message and return the count
else {
deps.io.out(`Unreserved ${bold(String(count))} resource(s).`); deps.io.out(`Unreserved ${bold(String(count))} resource(s).`);
} }
return { count }; return { count };
} }

View File

@@ -0,0 +1,131 @@
import { SettingsService } from "../../services/settings.js";
import { formatObject } from "../utils.js";
import type { BaseCommandDependencies, CommandIO } from "./types.js";
import { CommandError } from "./types.js";
/**
* Prints help text for the settings command.
*/
export const printSettingsHelp = (io: CommandIO): void => {
io.out(`Settings Command Help:
Commands:
settings show
settings get <currency|default-mnemonic>
settings set <currency|default-mnemonic> <value>
Examples:
xo-cli settings show
xo-cli settings get currency
xo-cli settings set currency AUD
xo-cli settings set default-mnemonic mnemonic-main`);
};
/**
* Supported settings keys exposed on the CLI.
*/
type SettingsKey = "currency" | "default-mnemonic";
/**
* Normalizes user input to one of the supported settings keys.
*/
function parseSettingsKey(input: string | undefined): SettingsKey | null {
if (!input) {
return null;
}
const normalized = input.trim().toLowerCase();
if (normalized === "currency") {
return "currency";
}
if (normalized === "default-mnemonic" || normalized === "defaultMnemonic") {
return "default-mnemonic";
}
return null;
}
/**
* Handles `xo-cli settings` commands.
*
* This command intentionally does not require wallet initialization so users can
* configure currency/default mnemonic without passing `-m`.
*/
export const handleSettingsCommand = async (
deps: BaseCommandDependencies,
args: string[],
options: Record<string, string>,
): Promise<Record<string, unknown>> => {
const settings = new SettingsService(deps.paths.walletConfigPath);
// settings show (default if no subcommand)
const subCommand = args[0] ?? "show";
if (subCommand === "help" || options["help"] === "true") {
printSettingsHelp(deps.io);
return {};
}
switch (subCommand) {
case "show": {
const snapshot = settings.getSettings();
deps.io.out(formatObject(snapshot));
return snapshot;
}
case "get": {
const key = parseSettingsKey(args[1]);
if (!key) {
printSettingsHelp(deps.io);
throw new CommandError(
"settings.get.key_missing",
'Missing or invalid key. Expected "currency" or "default-mnemonic".',
);
}
const value =
key === "currency"
? settings.getCurrency()
: settings.getDefaultMnemonic() ?? "";
deps.io.out(value);
return { key, value };
}
case "set": {
const key = parseSettingsKey(args[1]);
if (!key) {
printSettingsHelp(deps.io);
throw new CommandError(
"settings.set.key_missing",
'Missing or invalid key. Expected "currency" or "default-mnemonic".',
);
}
const rawValue = args.slice(2).join(" ").trim();
if (!rawValue) {
printSettingsHelp(deps.io);
throw new CommandError(
"settings.set.value_missing",
"Missing value for settings set command.",
);
}
if (key === "currency") {
settings.setCurrency(rawValue);
const currency = settings.getCurrency();
deps.io.out(`Updated currency: ${currency}`);
return { key, value: currency };
}
settings.setDefaultMnemonic(rawValue);
const defaultMnemonic = settings.getDefaultMnemonic() ?? "";
deps.io.out(`Updated default-mnemonic: ${defaultMnemonic}`);
return { key, value: defaultMnemonic };
}
default: {
printSettingsHelp(deps.io);
throw new CommandError(
"settings.subcommand.unknown",
`Unknown settings command: ${subCommand}`,
);
}
}
};

View File

@@ -37,22 +37,33 @@ export const handleTemplateListCommand = async (
deps: CommandDependencies, deps: CommandDependencies,
args: string[], args: string[],
): Promise<{ count?: number }> => { ): Promise<{ count?: number }> => {
// Get the template category from the arguments - This could be "action", "transaction", "output", "lockingscript", or "variable"
const templateCategory = args[0]; const templateCategory = args[0];
deps.io.verbose(`Template list category: ${templateCategory}`); deps.io.verbose(`Template list category: ${templateCategory}`);
// If no template category is provided, list all the imported templates
if (!templateCategory) { if (!templateCategory) {
// List all the imported templates
const templates = await deps.app.engine.listImportedTemplates(); const templates = await deps.app.engine.listImportedTemplates();
// Format the templates into a list of strings that we can display to the user
const formattedTemplates = templates.map( const formattedTemplates = templates.map(
(template: XOTemplate) => (template: XOTemplate) =>
`${bold(generateTemplateIdentifier(template))} - ${dim(template.name)} ${dim(template.description)}`, `${bold(generateTemplateIdentifier(template))} - ${dim(template.name)} ${dim(template.description)}`,
); );
// Display the templates to the user
deps.io.out(formattedTemplates.join("\n")); deps.io.out(formattedTemplates.join("\n"));
// Return the number of templates
return { count: templates.length }; return { count: templates.length };
} }
// Get the template identifier from the arguments
const templateIdentifier = args[1]; const templateIdentifier = args[1];
deps.io.verbose(`Template identifier: ${templateIdentifier}`); deps.io.verbose(`Template identifier: ${templateIdentifier}`);
// If no template identifier is provided, print a message and throw an error
if (!templateIdentifier) { if (!templateIdentifier) {
deps.io.err("No template identifier provided"); deps.io.err("No template identifier provided");
throw new CommandError( throw new CommandError(
@@ -61,7 +72,10 @@ export const handleTemplateListCommand = async (
); );
} }
// Get the raw template from the engine
const rawTemplate = await deps.app.engine.getTemplate(templateIdentifier); const rawTemplate = await deps.app.engine.getTemplate(templateIdentifier);
// If the raw template is not found, print a message and throw an error
if (!rawTemplate) { if (!rawTemplate) {
deps.io.err(`No template found: ${templateIdentifier}`); deps.io.err(`No template found: ${templateIdentifier}`);
throw new CommandError( throw new CommandError(
@@ -70,53 +84,91 @@ export const handleTemplateListCommand = async (
); );
} }
// Resolve the template deeply - Deeply nested objects instead of shallow objects referencing keys at the top level.
// Reduces the load of having to call multiple lookups just to get some resolved value like the outputIdentifer that comes from calling an action.
const template = await resolveTemplateReferences(rawTemplate); const template = await resolveTemplateReferences(rawTemplate);
deps.io.verbose(`Template: ${formatObject(template)}`); deps.io.verbose(`Template: ${formatObject(template)}`);
// Handle the template category
switch (templateCategory) { switch (templateCategory) {
case "action": { case "action": {
// Get the actions from the template
const actions = template.actions; const actions = template.actions;
// Format the actions into a list of strings that we can display to the user
const formattedActions = Object.entries(actions).map( const formattedActions = Object.entries(actions).map(
([actionIdentifier, action]) => ([actionIdentifier, action]) =>
`${bold(actionIdentifier)} ${dim(action.name)} ${dim(action.description)}`, `${bold(actionIdentifier)} ${dim(action.name)} ${dim(action.description)}`,
); );
// Display the actions to the user
deps.io.out(formattedActions.join("\n")); deps.io.out(formattedActions.join("\n"));
// Return the number of actions
return {}; return {};
} }
case "transaction": { case "transaction": {
// Get the transactions from the template
const transactions = template.transactions; const transactions = template.transactions;
// Format the transactions into a list of strings that we can display to the user
const formattedTransactions = Object.entries(transactions).map( const formattedTransactions = Object.entries(transactions).map(
([transactionIdentifier, transaction]) => ([transactionIdentifier, transaction]) =>
`${bold(transactionIdentifier)} ${dim(transaction.name)} ${dim(transaction.description)}`, `${bold(transactionIdentifier)} ${dim(transaction.name)} ${dim(transaction.description)}`,
); );
// Display the transactions to the user
deps.io.out(formattedTransactions.join("\n")); deps.io.out(formattedTransactions.join("\n"));
// Return the number of transactions
return {}; return {};
} }
case "output": { case "output": {
// Get the outputs from the template
const outputs = template.outputs; const outputs = template.outputs;
// Format the outputs into a list of strings that we can display to the user
const formattedOutputs = Object.entries(outputs).map( const formattedOutputs = Object.entries(outputs).map(
([outputIdentifier, output]) => ([outputIdentifier, output]) =>
`${bold(outputIdentifier)} ${dim(output.name)} ${dim(output.description)}`, `${bold(outputIdentifier)} ${dim(output.name)} ${dim(output.description)}`,
); );
// Display the outputs to the user
deps.io.out(formattedOutputs.join("\n")); deps.io.out(formattedOutputs.join("\n"));
// Return the number of outputs
return {}; return {};
} }
case "lockingscript": { case "lockingscript": {
// Get the lockingscripts from the template
const lockingscripts = template.lockingScripts; const lockingscripts = template.lockingScripts;
// Format the lockingscripts into a list of strings that we can display to the user
const formattedLockingscripts = Object.entries(lockingscripts).map( const formattedLockingscripts = Object.entries(lockingscripts).map(
([lockingScriptIdentifier, lockingScript]) => ([lockingScriptIdentifier, lockingScript]) =>
`${bold(lockingScriptIdentifier)} ${dim(lockingScript.name)} ${dim(lockingScript.description)}`, `${bold(lockingScriptIdentifier)} ${dim(lockingScript.name)} ${dim(lockingScript.description)}`,
); );
// Display the lockingscripts to the user
deps.io.out(formattedLockingscripts.join("\n")); deps.io.out(formattedLockingscripts.join("\n"));
// Return the number of lockingscripts
return {}; return {};
} }
case "variable": { case "variable": {
// Get the variables from the template
const variables = template.variables || {}; const variables = template.variables || {};
// Format the variables into a list of strings that we can display to the user
const formattedVariables = Object.entries(variables).map( const formattedVariables = Object.entries(variables).map(
([variableIdentifier, variable]) => ([variableIdentifier, variable]) =>
`${bold(variableIdentifier)} ${dim(variable.name)} ${dim(variable.description)}`, `${bold(variableIdentifier)} ${dim(variable.name)} ${dim(variable.description)}`,
); );
// Display the variables to the user
deps.io.out(formattedVariables.join("\n")); deps.io.out(formattedVariables.join("\n"));
// Return the number of variables
return {}; return {};
} }
default: { default: {
@@ -162,6 +214,7 @@ export const handleTemplateInspectCommand = async (
deps: CommandDependencies, deps: CommandDependencies,
args: string[], args: string[],
): Promise<Record<string, never>> => { ): Promise<Record<string, never>> => {
// Get the template category, identifier, and field from the arguments
const templateCategory = args[0]; const templateCategory = args[0];
const templateQuery = args[1]; const templateQuery = args[1];
const templateField = args[2]; const templateField = args[2];
@@ -170,6 +223,7 @@ export const handleTemplateInspectCommand = async (
`Template inspect args - category: ${templateCategory}, identifier: ${templateQuery}, field: ${templateField}`, `Template inspect args - category: ${templateCategory}, identifier: ${templateQuery}, field: ${templateField}`,
); );
// If no template category, identifier, or field is provided, print a message and throw an error
if (!templateCategory || !templateQuery || !templateField) { if (!templateCategory || !templateQuery || !templateField) {
deps.io.err("No template category, identifier, or field provided"); deps.io.err("No template category, identifier, or field provided");
printTemplateInspectHelp(deps.io); printTemplateInspectHelp(deps.io);
@@ -179,15 +233,21 @@ export const handleTemplateInspectCommand = async (
); );
} }
// Resolve the template
const originalTemplate = await resolveTemplate(deps, templateQuery); const originalTemplate = await resolveTemplate(deps, templateQuery);
deps.io.verbose(`Original Template: ${formatObject(originalTemplate)}`); deps.io.verbose(`Original Template: ${formatObject(originalTemplate)}`);
// Resolve the template references
const template = await resolveTemplateReferences(originalTemplate); const template = await resolveTemplateReferences(originalTemplate);
deps.io.verbose(`Extended Template: ${formatObject(template)}`); deps.io.verbose(`Extended Template: ${formatObject(template)}`);
// Handle the template category
switch (templateCategory) { switch (templateCategory) {
case "action": { case "action": {
// Get the action from the template
const action = template.actions[templateField]; const action = template.actions[templateField];
// If the action is not found, print a message and throw an error
if (!action) { if (!action) {
deps.io.err(`No action found: ${templateField}`); deps.io.err(`No action found: ${templateField}`);
throw new CommandError( throw new CommandError(
@@ -195,11 +255,16 @@ export const handleTemplateInspectCommand = async (
`No action found: ${templateField}`, `No action found: ${templateField}`,
); );
} }
// Display the action to the user
deps.io.out(formatObject(action)); deps.io.out(formatObject(action));
return {}; return {};
} }
case "transaction": { case "transaction": {
// Get the transaction from the template
const transaction = template.transactions?.[templateField]; const transaction = template.transactions?.[templateField];
// If the transaction is not found, print a message and throw an error
if (!transaction) { if (!transaction) {
deps.io.err(`No transaction found: ${templateField}`); deps.io.err(`No transaction found: ${templateField}`);
throw new CommandError( throw new CommandError(
@@ -207,11 +272,16 @@ export const handleTemplateInspectCommand = async (
`No transaction found: ${templateField}`, `No transaction found: ${templateField}`,
); );
} }
// Display the transaction to the user
deps.io.out(formatObject(transaction)); deps.io.out(formatObject(transaction));
return {}; return {};
} }
case "output": { case "output": {
// Get the output from the template
const output = template.outputs[templateField]; const output = template.outputs[templateField];
// If the output is not found, print a message and throw an error
if (!output) { if (!output) {
deps.io.err(`No output found: ${templateField}`); deps.io.err(`No output found: ${templateField}`);
throw new CommandError( throw new CommandError(
@@ -219,11 +289,16 @@ export const handleTemplateInspectCommand = async (
`No output found: ${templateField}`, `No output found: ${templateField}`,
); );
} }
// Display the output to the user
deps.io.out(formatObject(output)); deps.io.out(formatObject(output));
return {}; return {};
} }
case "lockingscript": { case "lockingscript": {
// Get the lockingscript from the template
const lockingscript = template.lockingScripts[templateField]; const lockingscript = template.lockingScripts[templateField];
// If the lockingscript is not found, print a message and throw an error
if (!lockingscript) { if (!lockingscript) {
deps.io.err(`No lockingscript found: ${templateField}`); deps.io.err(`No lockingscript found: ${templateField}`);
throw new CommandError( throw new CommandError(
@@ -231,11 +306,16 @@ export const handleTemplateInspectCommand = async (
`No lockingscript found: ${templateField}`, `No lockingscript found: ${templateField}`,
); );
} }
// Display the lockingscript to the user
deps.io.out(formatObject(lockingscript)); deps.io.out(formatObject(lockingscript));
return {}; return {};
} }
case "variable": { case "variable": {
// Get the variable from the template
const variable = template.variables?.[templateField]; const variable = template.variables?.[templateField];
// If the variable is not found, print a message and throw an error
if (!variable) { if (!variable) {
deps.io.err(`No variable found: ${templateField}`); deps.io.err(`No variable found: ${templateField}`);
throw new CommandError( throw new CommandError(
@@ -243,6 +323,8 @@ export const handleTemplateInspectCommand = async (
`No variable found: ${templateField}`, `No variable found: ${templateField}`,
); );
} }
// Display the variable to the user
deps.io.out(formatObject(variable)); deps.io.out(formatObject(variable));
return {}; return {};
} }
@@ -268,8 +350,10 @@ export const handleTemplateCommand = async (
args: string[], args: string[],
_options: Record<string, string>, _options: Record<string, string>,
): Promise<{ templateFile?: string; count?: number }> => { ): Promise<{ templateFile?: string; count?: number }> => {
// Get the sub-command from the arguments
const subCommand = args[0]; const subCommand = args[0];
// If no sub-command is provided, print a message and throw an error
if (!subCommand) { if (!subCommand) {
deps.io.verbose("No sub-command provided"); deps.io.verbose("No sub-command provided");
printTemplateHelp(deps.io); printTemplateHelp(deps.io);
@@ -279,9 +363,13 @@ export const handleTemplateCommand = async (
); );
} }
// Handle the sub-command
switch (subCommand) { switch (subCommand) {
case "import": { case "import": {
// Get the template file from the arguments
const templateFile = args[1]; const templateFile = args[1];
// If no template file is provided, print a message and throw an error
deps.io.verbose(`Template file: ${templateFile}`); deps.io.verbose(`Template file: ${templateFile}`);
if (!templateFile) { if (!templateFile) {
@@ -293,9 +381,11 @@ export const handleTemplateCommand = async (
); );
} }
// Resolve the template path
const templatePath = path.resolve(`${process.cwd()}/${templateFile}`); const templatePath = path.resolve(`${process.cwd()}/${templateFile}`);
deps.io.verbose(`Template path: ${templatePath}`); deps.io.verbose(`Template path: ${templatePath}`);
// If the template file does not exist, print a message and throw an error
if (!existsSync(templatePath)) { if (!existsSync(templatePath)) {
deps.io.err(`Template file does not exist: ${templatePath}`); deps.io.err(`Template file does not exist: ${templatePath}`);
printTemplateHelp(deps.io); printTemplateHelp(deps.io);
@@ -305,23 +395,32 @@ export const handleTemplateCommand = async (
); );
} }
// Read the template file
const template = await readFileSync(templatePath, "utf8"); const template = await readFileSync(templatePath, "utf8");
deps.io.verbose(`Importing template: ${templateFile}`); deps.io.verbose(`Importing template: ${templateFile}`);
// Import the template
await deps.app.engine.importTemplate(template); await deps.app.engine.importTemplate(template);
deps.io.verbose(`Template imported: ${templateFile}`); deps.io.verbose(`Template imported: ${templateFile}`);
// Return the template file
return { templateFile }; return { templateFile };
} }
case "list": { case "list": {
// Handle the template list command, We offload here as it has lots of arguments and is quite long
return handleTemplateListCommand(deps, args.slice(1)); return handleTemplateListCommand(deps, args.slice(1));
} }
case "inspect": { case "inspect": {
// Handle the template inspect command, We offload here as it has lots of arguments and is quite long
return handleTemplateInspectCommand(deps, args.slice(1)); return handleTemplateInspectCommand(deps, args.slice(1));
} }
case "set-default": { case "set-default": {
// Get the template file, output identifier, and role identifier from the arguments
const templateFile = args[1]; const templateFile = args[1];
const outputIdentifier = args[2]; const outputIdentifier = args[2];
const roleIdentifier = args[3]; const roleIdentifier = args[3];
// If no template file, output identifier, or role identifier is provided, print a message and throw an error
if (!templateFile || !outputIdentifier || !roleIdentifier) { if (!templateFile || !outputIdentifier || !roleIdentifier) {
deps.io.verbose( deps.io.verbose(
"No template file, output identifier, or role identifier provided", "No template file, output identifier, or role identifier provided",
@@ -332,17 +431,24 @@ export const handleTemplateCommand = async (
"No template file, output identifier, or role identifier provided", "No template file, output identifier, or role identifier provided",
); );
} }
// Set the default locking parameters
deps.io.verbose( deps.io.verbose(
`Template file: ${templateFile}, output identifier: ${outputIdentifier}, role identifier: ${roleIdentifier}`, `Template file: ${templateFile}, output identifier: ${outputIdentifier}, role identifier: ${roleIdentifier}`,
); );
// Set the default locking parameters
await deps.app.engine.setDefaultLockingParameters( await deps.app.engine.setDefaultLockingParameters(
templateFile, templateFile,
outputIdentifier, outputIdentifier,
roleIdentifier, roleIdentifier,
); );
// Return an empty object
return {}; return {};
} }
default: default:
// If the sub-command is not found, print a message and throw an error
deps.io.verbose(`Unknown template sub-command: ${subCommand}`); deps.io.verbose(`Unknown template sub-command: ${subCommand}`);
printTemplateHelp(deps.io); printTemplateHelp(deps.io);
throw new CommandError( throw new CommandError(

View File

@@ -35,10 +35,10 @@
* -m --mnemonic-file <mnemonic-file> * -m --mnemonic-file <mnemonic-file>
*/ */
import { existsSync, readFileSync, writeFileSync } from "fs";
import { join } from "path"; import { join } from "path";
import { AppService } from "../services/app.js"; import { AppService } from "../services/app.js";
import { SettingsService } from "../services/settings.js";
import { convertArgsToObject } from "./arguments.js"; import { convertArgsToObject } from "./arguments.js";
import { bold, dim, formatObject } from "./utils.js"; import { bold, dim, formatObject } from "./utils.js";
import { listGlobalMnemonicFiles, loadMnemonic } from "./mnemonic.js"; import { listGlobalMnemonicFiles, loadMnemonic } from "./mnemonic.js";
@@ -54,6 +54,7 @@ import {
type CommandPaths, type CommandPaths,
CommandError, CommandError,
handleMnemonicCommand, handleMnemonicCommand,
handleSettingsCommand,
handleTemplateCommand, handleTemplateCommand,
handleInvitationCommand, handleInvitationCommand,
handleReceiveCommand, handleReceiveCommand,
@@ -115,6 +116,7 @@ async function main(): Promise<void> {
walletConfigPath: getWalletConfigPath(), walletConfigPath: getWalletConfigPath(),
workingDir: process.cwd(), workingDir: process.cwd(),
}; };
const settings = new SettingsService(paths.walletConfigPath);
// Early handling for completions command // Early handling for completions command
if (command === "completions") { if (command === "completions") {
@@ -134,10 +136,26 @@ async function main(): Promise<void> {
} }
} }
// Resolve mnemonic file: explicit flag > persisted config > error. if (command === "settings") {
try {
await handleSettingsCommand({ io, paths }, subArgs, options);
process.exit(0);
} catch (error) {
if (error instanceof CommandError) {
process.exit(error.code);
}
throw error;
}
}
// Resolve mnemonic file: explicit flag > persisted settings > error.
let mnemonicFile = options["mnemonicFile"]; let mnemonicFile = options["mnemonicFile"];
if (!mnemonicFile && existsSync(paths.walletConfigPath)) { let didUsePersistedMnemonic = false;
mnemonicFile = readFileSync(paths.walletConfigPath, "utf8").trim(); if (!mnemonicFile) {
mnemonicFile = settings.getDefaultMnemonic();
didUsePersistedMnemonic = Boolean(mnemonicFile);
}
if (didUsePersistedMnemonic && mnemonicFile) {
io.verbose(`Using persisted wallet: ${mnemonicFile}`); io.verbose(`Using persisted wallet: ${mnemonicFile}`);
} }
if (!mnemonicFile) { if (!mnemonicFile) {
@@ -152,7 +170,11 @@ async function main(): Promise<void> {
} }
// Persist the choice so subsequent commands can omit -m. // Persist the choice so subsequent commands can omit -m.
writeFileSync(paths.walletConfigPath, mnemonicFile); settings.setDefaultMnemonic(mnemonicFile);
if (options["currency"]) {
settings.setCurrency(options["currency"]);
io.verbose(`Using configured currency: ${settings.getCurrency()}`);
}
const mnemonic = loadMnemonic(paths.mnemonicsDir, mnemonicFile); const mnemonic = loadMnemonic(paths.mnemonicsDir, mnemonicFile);
io.verbose(`Loaded mnemonic from file: ${mnemonicFile} ${mnemonic}`); io.verbose(`Loaded mnemonic from file: ${mnemonicFile} ${mnemonic}`);
@@ -168,7 +190,7 @@ async function main(): Promise<void> {
invitationStoragePath: invitationStoragePath:
options["invitationStoragePath"] ?? options["invitationStoragePath"] ??
join(paths.dataDir, "xo-invitations.db"), join(paths.dataDir, "xo-invitations.db"),
}); }, settings);
io.verbose("App instance created"); io.verbose("App instance created");
// Start the app // Start the app
@@ -255,12 +277,14 @@ Commands:
invitation ${dim("Manage invitations")} invitation ${dim("Manage invitations")}
receive ${dim("Generate a single-use receiving address")} receive ${dim("Generate a single-use receiving address")}
resource ${dim("Manage resources")} resource ${dim("Manage resources")}
settings ${dim("Manage persisted wallet settings")}
completions ${dim("Generate shell completion scripts (bash, zsh, fish)")} completions ${dim("Generate shell completion scripts (bash, zsh, fish)")}
help ${dim("Show this help message")} 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")}
--currency <currency-code> ${dim("Set fiat display currency (e.g. USD, AUD)")}
-v, --verbose ${dim("Show verbose output")}`, -v, --verbose ${dim("Show verbose output")}`,
); );
return {}; return {};

View File

@@ -57,20 +57,24 @@ export const resolveMnemonicFilePath = (
mnemonicsDir: string, mnemonicsDir: string,
mnemonicRef: string, mnemonicRef: string,
): string => { ): string => {
// Try to resolve the mnemonic file as an absolute path
if (isAbsolute(mnemonicRef) && existsSync(mnemonicRef)) { if (isAbsolute(mnemonicRef) && existsSync(mnemonicRef)) {
return mnemonicRef; return mnemonicRef;
} }
// Try to resolve the mnemonic file relative to the current working directory
const relativeToCwd = resolve(process.cwd(), mnemonicRef); const relativeToCwd = resolve(process.cwd(), mnemonicRef);
if (existsSync(relativeToCwd)) { if (existsSync(relativeToCwd)) {
return relativeToCwd; return relativeToCwd;
} }
// Try to resolve the mnemonic file in the mnemonics directory
const inMnemonics = join(mnemonicsDir, basename(mnemonicRef)); const inMnemonics = join(mnemonicsDir, basename(mnemonicRef));
if (existsSync(inMnemonics)) { if (existsSync(inMnemonics)) {
return inMnemonics; return inMnemonics;
} }
// If the mnemonic file is not found, throw an error
throw new Error( throw new Error(
`Mnemonic file not found: ${mnemonicRef}. Run "xo-cli mnemonic list" to see available files.`, `Mnemonic file not found: ${mnemonicRef}. Run "xo-cli mnemonic list" to see available files.`,
); );
@@ -86,18 +90,26 @@ export const loadMnemonic = (
mnemonicsDir: string, mnemonicsDir: string,
mnemonicFile: string, mnemonicFile: string,
): string => { ): string => {
// Resolve the mnemonic file path
const resolvedPath = resolveMnemonicFilePath(mnemonicsDir, mnemonicFile); const resolvedPath = resolveMnemonicFilePath(mnemonicsDir, mnemonicFile);
// Read the mnemonic file
const mnemonicUrl = BCHMnemonicURL.fromURL( const mnemonicUrl = BCHMnemonicURL.fromURL(
readFileSync(resolvedPath, "utf8"), readFileSync(resolvedPath, "utf8"),
); );
// Get the entropy from the mnemonic url
const { entropy } = mnemonicUrl.toObject(); const { entropy } = mnemonicUrl.toObject();
// Encode the entropy to a mnemonic
const mnemonic = encodeBip39Mnemonic(entropy); const mnemonic = encodeBip39Mnemonic(entropy);
// If the mnemonic is not a string, throw an error
if (typeof mnemonic === "string") { if (typeof mnemonic === "string") {
throw new Error(`Failed to convert entropy to mnemonic: ${mnemonic}`); throw new Error(`Failed to convert entropy to mnemonic: ${mnemonic}`);
} }
// Return the mnemonic phrase
return mnemonic.phrase; return mnemonic.phrase;
}; };
@@ -107,9 +119,12 @@ export const loadMnemonic = (
* @returns Basenames suitable for `-m <name>` * @returns Basenames suitable for `-m <name>`
*/ */
export const listMnemonicFiles = (mnemonicsDir: string): string[] => { export const listMnemonicFiles = (mnemonicsDir: string): string[] => {
// List the mnemonic files in the given directory
const filenames = readdirSync(mnemonicsDir).filter((f: string) => const filenames = readdirSync(mnemonicsDir).filter((f: string) =>
f.startsWith("mnemonic-"), f.startsWith("mnemonic-"),
); );
// Return the mnemonic files
return filenames; return filenames;
}; };
@@ -119,5 +134,6 @@ export const listMnemonicFiles = (mnemonicsDir: string): string[] => {
* @returns Basenames suitable for `-m <name>` * @returns Basenames suitable for `-m <name>`
*/ */
export const listGlobalMnemonicFiles = (): string[] => { export const listGlobalMnemonicFiles = (): string[] => {
// List the mnemonic files in the global mnemonics directory
return listMnemonicFiles(getGlobalMnemonicsDir()); return listMnemonicFiles(getGlobalMnemonicsDir());
}; };

View File

@@ -23,22 +23,28 @@ export const resolveTemplate = async (
deps: CommandDependencies, deps: CommandDependencies,
query: string, query: string,
): Promise<XOTemplate> => { ): Promise<XOTemplate> => {
// Gather all of our imported templates
const templates = await deps.app.engine.listImportedTemplates(); const templates = await deps.app.engine.listImportedTemplates();
// Create a set to store the matches
const matches = new Set<XOTemplate>(); const matches = new Set<XOTemplate>();
// Iterate through the templates and check if the identifier matches the query
for (const template of templates) { for (const template of templates) {
if (generateTemplateIdentifier(template) === query) { if (generateTemplateIdentifier(template) === query) {
// Return early if we got a match since identifiers are always unique by content
return template; return template;
} }
} }
// Iterate through the templates and check if the name matches the query
for (const template of templates) { for (const template of templates) {
if (template.name === query) { if (template.name === query) {
matches.add(template); matches.add(template);
} }
} }
// If there are multiple matches, throw an error
if (matches.size > 1) { if (matches.size > 1) {
throw new CommandError( throw new CommandError(
"template.resolve.multiple_matches", "template.resolve.multiple_matches",
@@ -51,10 +57,12 @@ export const resolveTemplate = async (
); );
} }
// If there is one match, return the match
if (matches.size === 1) { if (matches.size === 1) {
return matches.values().next().value!; return matches.values().next().value!;
} }
// If there are no matches, throw an error
throw new CommandError( throw new CommandError(
"template.resolve.not_found", "template.resolve.not_found",
`Template not found: ${query}`, `Template not found: ${query}`,

View File

@@ -12,6 +12,7 @@ import { SyncServer } from "../utils/sync-server.js";
import { HistoryService } from "./history.js"; import { HistoryService } from "./history.js";
import { type BlockchainService, ElectrumService } from "./electrum.js"; import { type BlockchainService, ElectrumService } from "./electrum.js";
import { RatesService } from "./rates.js"; import { RatesService } from "./rates.js";
import { SettingsService } from "./settings.js";
import { EventEmitter } from "../utils/event-emitter.js"; import { EventEmitter } from "../utils/event-emitter.js";
@@ -49,6 +50,7 @@ export class AppService extends EventEmitter<AppEventMap> {
public history: HistoryService; public history: HistoryService;
public electrum: BlockchainService; public electrum: BlockchainService;
public rates: RatesService; public rates: RatesService;
public settings: SettingsService;
public invitations: Invitation[] = []; public invitations: Invitation[] = [];
private invitationEventCleanup = new Map< private invitationEventCleanup = new Map<
@@ -59,7 +61,11 @@ export class AppService extends EventEmitter<AppEventMap> {
} }
>(); >();
static async create(seed: string, config: AppConfig): Promise<AppService> { static async create(
seed: string,
config: AppConfig,
settings: SettingsService = new SettingsService(),
): Promise<AppService> {
// Because of a bug that lets wallets read the unspents of other wallets, we are going to manually namespace the storage paths for the app. // Because of a bug that lets wallets read the unspents of other wallets, we are going to manually namespace the storage paths for the app.
// We are going to do this by computing a hash of the seed and prefixing the storage paths with it. // We are going to do this by computing a hash of the seed and prefixing the storage paths with it.
const seedHash = createHash("sha256").update(seed).digest("hex"); const seedHash = createHash("sha256").update(seed).digest("hex");
@@ -77,20 +83,33 @@ 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 // engine
.subscribeToLockingBytecodesForTemplate(templateIdentifier) // .subscribeToLockingBytecodesForTemplate(templateIdentifier)
.catch((err) => // .catch((err) =>
console.error( // console.error(
`Error subscribing to locking bytecodes for template ${templateIdentifier}: ${err}`, // `Error subscribing to locking bytecodes for template ${templateIdentifier}: ${err}`,
), // ),
); // );
engine // engine
.updateUnspentOutputsForTemplate(templateIdentifier) // .updateUnspentOutputsForTemplate(templateIdentifier)
.catch((err) => // .catch((err) =>
console.error( // console.error(
`Error updating unspent outputs for template ${templateIdentifier}: ${err}`, // `Error updating unspent outputs for template ${templateIdentifier}: ${err}`,
), // ),
); // );
// Update all the unspents for every template, and subscribe to the locking bytecodes for changes
// TODO: Remove the above lines that do the same thing. Minimising changes for BLISS.
const updateTemplates = async () => {
const templates = await engine.listImportedTemplates();
templates.forEach(async (template) => {
// engine.updateUnspentOutputsForTemplate(generateTemplateIdentifier(template));
engine.subscribeToLockingBytecodesForTemplate(generateTemplateIdentifier(template));
});
};
updateTemplates();
// Set default locking parameters for P2PKH // Set default locking parameters for P2PKH
// To my knowledge, this doesnt generate any lockscript, so discovery of funds will not work automatically. // To my knowledge, this doesnt generate any lockscript, so discovery of funds will not work automatically.
@@ -110,9 +129,9 @@ export class AppService extends EventEmitter<AppEventMap> {
host: config.electrumHost, host: config.electrumHost,
applicationIdentifier: config.electrumApplicationIdentifier, applicationIdentifier: config.electrumApplicationIdentifier,
}); });
const rates = await RatesService.create(); const rates = await RatesService.create(settings);
return new AppService(engine, walletStorage, config, electrum, rates); return new AppService(engine, walletStorage, config, electrum, rates, settings);
} }
constructor( constructor(
@@ -121,6 +140,7 @@ export class AppService extends EventEmitter<AppEventMap> {
config: AppConfig, config: AppConfig,
electrum: BlockchainService, electrum: BlockchainService,
rates: RatesService, rates: RatesService,
settings: SettingsService,
) { ) {
super(); super();
@@ -129,6 +149,7 @@ export class AppService extends EventEmitter<AppEventMap> {
this.config = config; this.config = config;
this.electrum = electrum; this.electrum = electrum;
this.rates = rates; this.rates = rates;
this.settings = settings;
this.history = new HistoryService(engine, this.invitations); this.history = new HistoryService(engine, this.invitations);
} }

File diff suppressed because it is too large Load Diff

View File

@@ -4,7 +4,7 @@ import type {
Engine, Engine,
GetSpendableResourcesParameters, GetSpendableResourcesParameters,
} from "@xo-cash/engine"; } from "@xo-cash/engine";
import { hasInvitationExpired, mergeInvitationCommits } from "@xo-cash/engine"; import { generateTemplateIdentifier, hasInvitationExpired, mergeInvitationCommits } from "@xo-cash/engine";
import type { import type {
XOInvitation, XOInvitation,
XOInvitationCommit, XOInvitationCommit,
@@ -498,16 +498,38 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
); );
} }
const resolvedOptions: GetSpendableResourcesParameters = { // const resolvedOptions: GetSpendableResourcesParameters = {
templateIdentifier, // templateIdentifier,
outputIdentifier: options.outputIdentifier ?? fallbackOutputIdentifier ?? "", // outputIdentifier: options.outputIdentifier ?? fallbackOutputIdentifier ?? "",
}; // };
// Find the suitable resources // There are disagreements around whether all spendables should be returned from getSpendableResources.
const { unspentOutputs } = await this.engine.getSpendableResources( // I had a fix merged in, but it got overwritten. So, im just going to get all of them manually and go around
this.data, // The engine's expectations.
resolvedOptions, // To do this, we are going to grab all out templates
); const templates = await this.engine.listImportedTemplates();
// For each template, we need to create a 2d array of all the outputs
const outputs = templates.map(template => {
return Object.keys(template.outputs).map(output => {
const templateIdentifier = generateTemplateIdentifier(template);
return {
templateIdentifier,
outputIdentifier: output,
};
});
});
// then, for each output, we need to get the spendable resources
const spendableResources = await Promise.all(outputs.flat().map(output => {
return this.engine.getSpendableResources(this.data, {
templateIdentifier: output.templateIdentifier,
outputIdentifier: output.outputIdentifier,
});
}));
const unspentOutputs = spendableResources.flatMap(resource => resource.unspentOutputs);
// Update the status of the invitation // Update the status of the invitation
await this.updateStatus(); await this.updateStatus();

View File

@@ -3,6 +3,7 @@ import {
type RatesEventMap, type RatesEventMap,
} from '../utils/rates/base-rates.js'; } from '../utils/rates/base-rates.js';
import { RatesOracle } from '../utils/rates/rates-oracles.js'; import { RatesOracle } from '../utils/rates/rates-oracles.js';
import { SettingsService } from './settings.js';
/** /**
* Event map emitted by {@link RatesService}. * Event map emitted by {@link RatesService}.
@@ -52,13 +53,15 @@ export interface RatesAdapter {
*/ */
export class RatesService extends EventEmitter<RatesServiceEventMap> { export class RatesService extends EventEmitter<RatesServiceEventMap> {
private readonly adapter: RatesAdapter; private readonly adapter: RatesAdapter;
private readonly settings: SettingsService;
private readonly ratesByPair = new Map<string, CachedRate>(); private readonly ratesByPair = new Map<string, CachedRate>();
private unsubscribeFromAdapter: (() => void) | null = null; private unsubscribeFromAdapter: (() => void) | null = null;
private started = false; private started = false;
constructor(adapter: RatesAdapter) { constructor(adapter: RatesAdapter, settings: SettingsService) {
super(); super();
this.adapter = adapter; this.adapter = adapter;
this.settings = settings;
} }
/** /**
@@ -66,9 +69,12 @@ export class RatesService extends EventEmitter<RatesServiceEventMap> {
* *
* If no adapter is passed, this defaults to the Oracle-backed adapter. * If no adapter is passed, this defaults to the Oracle-backed adapter.
*/ */
public static async create(adapter?: RatesAdapter): Promise<RatesService> { public static async create(
const resolvedAdapter = adapter ?? (await RatesOracle.from()); settings: SettingsService,
return new RatesService(resolvedAdapter); adapter?: RatesAdapter,
): Promise<RatesService> {
const resolvedAdapter = adapter ?? (await RatesOracle.from(undefined, settings));
return new RatesService(resolvedAdapter, settings);
} }
/** /**
@@ -162,6 +168,20 @@ export class RatesService extends EventEmitter<RatesServiceEventMap> {
return this.adapter.formatCurrency(amount, currencyCode.toUpperCase()); return this.adapter.formatCurrency(amount, currencyCode.toUpperCase());
} }
/**
* Lists available market pairs in NUMERATOR/DENOMINATOR format.
*/
public async listPairs(): Promise<Set<string>> {
return this.adapter.listPairs();
}
/**
* Returns the fiat currency currently configured in settings.
*/
public getConfiguredCurrency(): string {
return this.settings.getCurrency();
}
/** /**
* Handles normalized updates from the underlying adapter. * Handles normalized updates from the underlying adapter.
*/ */

194
src/services/settings.ts Normal file
View File

@@ -0,0 +1,194 @@
import { existsSync, readFileSync, writeFileSync } from "node:fs";
import { EventEmitter } from "../utils/event-emitter.js";
import { getSettingsPath } from "../utils/paths.js";
/**
* Supported persisted settings keys.
*/
export type SettingsData = {
"default-mnemonic"?: string;
currency: string;
};
/**
* Event payloads emitted by {@link SettingsService}.
*/
export type SettingsServiceEventMap = {
"settings-updated": {
key: keyof SettingsData;
value: string | undefined;
settings: SettingsData;
};
};
/**
* Runtime defaults for settings that should always exist in memory.
*/
const DEFAULT_SETTINGS: SettingsData = {
currency: "USD",
};
/**
* Handles loading, migrating, and persisting wallet settings.
*
* The backing file is `~/.config/xo-cli/.wallet`. Historically it stored a raw
* mnemonic reference string. This service migrates that legacy format to JSON:
* `{ "default-mnemonic": "<value>", "currency": "USD" }`.
*/
export class SettingsService extends EventEmitter<SettingsServiceEventMap> {
private readonly settingsPath: string;
private settings: SettingsData;
/**
* Creates a new settings service instance.
*
* @param settingsPath - Optional custom settings file path (useful for tests)
*/
constructor(settingsPath: string = getSettingsPath()) {
super();
this.settingsPath = settingsPath;
this.settings = this.loadSettings();
}
/**
* Returns the current settings snapshot.
*/
public getSettings(): SettingsData {
return { ...this.settings };
}
/**
* Returns the currently selected default mnemonic reference.
*/
public getDefaultMnemonic(): string | undefined {
return this.settings["default-mnemonic"];
}
/**
* Updates the default mnemonic reference and persists it to disk.
*/
public setDefaultMnemonic(mnemonicRef: string): void {
const normalizedMnemonicRef = mnemonicRef.trim();
if (normalizedMnemonicRef.length === 0) {
throw new Error("default-mnemonic cannot be empty");
}
this.settings["default-mnemonic"] = normalizedMnemonicRef;
this.persistSettings();
this.emit("settings-updated", {
key: "default-mnemonic",
value: normalizedMnemonicRef,
settings: this.getSettings(),
});
}
/**
* Returns the selected fiat currency code (ISO-like uppercase).
*/
public getCurrency(): string {
return this.settings.currency;
}
/**
* Updates the selected fiat currency and persists it to disk.
*/
public setCurrency(currencyCode: string): void {
const normalizedCurrency = this.normalizeCurrency(currencyCode);
if (this.settings.currency === normalizedCurrency) {
return;
}
this.settings.currency = normalizedCurrency;
this.persistSettings();
this.emit("settings-updated", {
key: "currency",
value: normalizedCurrency,
settings: this.getSettings(),
});
}
/**
* Reads and normalizes the settings file from disk.
*
* If the file contains the old legacy format (raw mnemonic string), the
* migrated JSON shape is written back immediately.
*/
private loadSettings(): SettingsData {
if (!existsSync(this.settingsPath)) {
return { ...DEFAULT_SETTINGS };
}
const rawContents = readFileSync(this.settingsPath, "utf8").trim();
if (rawContents.length === 0) {
return { ...DEFAULT_SETTINGS };
}
try {
const parsed = JSON.parse(rawContents);
const normalized = this.normalizeSettings(parsed);
return normalized;
} catch {
const migrated = this.normalizeSettings({
"default-mnemonic": rawContents,
});
this.persistSettings(migrated);
return migrated;
}
}
/**
* Writes the given settings object to disk as pretty JSON.
*
* @param nextSettings - Optional explicit value, defaults to in-memory state
*/
private persistSettings(nextSettings?: SettingsData): void {
if (nextSettings) {
this.settings = nextSettings;
}
writeFileSync(
this.settingsPath,
`${JSON.stringify(this.settings, null, 2)}\n`,
"utf8",
);
}
/**
* Coerces unknown input into a safe settings object.
*/
private normalizeSettings(input: unknown): SettingsData {
const normalized: SettingsData = {
...DEFAULT_SETTINGS,
};
if (!input || typeof input !== "object") {
return normalized;
}
const maybeMnemonic = (input as Record<string, unknown>)["default-mnemonic"];
if (typeof maybeMnemonic === "string" && maybeMnemonic.trim().length > 0) {
normalized["default-mnemonic"] = maybeMnemonic.trim();
}
const maybeCurrency = (input as Record<string, unknown>).currency;
if (typeof maybeCurrency === "string" && maybeCurrency.trim().length > 0) {
normalized.currency = this.normalizeCurrency(maybeCurrency);
}
return normalized;
}
/**
* Ensures currency values stay uppercase and non-empty.
*/
private normalizeCurrency(currencyCode: string): string {
const normalizedCurrency = currencyCode.trim().toUpperCase();
if (normalizedCurrency.length === 0) {
throw new Error("currency cannot be empty");
}
return normalizedCurrency;
}
}

View File

@@ -1,6 +1,11 @@
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";
/**
* This is not an actual storage adapter that we want to make use of. This storage adapter is a stop-gap while the engine is under development.
* At the time of writing the storage adapter, the engine provided no way to read data about your currenty invitations, so that is where this is coming in.
* Its providing a Developer facing way to store/read the invitation data and then we can just import them into the engine whenever we want to interact with an invitation.
*/
export abstract class BaseStorage { export abstract class BaseStorage {
abstract all(): Promise<{ key: string; value: any }[]>; abstract all(): Promise<{ key: string; value: any }[]>;
abstract set(key: string, value: any): Promise<void>; abstract set(key: string, value: any): Promise<void>;
@@ -10,6 +15,9 @@ export abstract class BaseStorage {
abstract child(key: string): BaseStorage; abstract child(key: string): BaseStorage;
} }
/**
* SQLite Database Storage Adapter.
*/
export class Storage extends BaseStorage { export class Storage extends BaseStorage {
static async create(dbPath: string): Promise<Storage> { static async create(dbPath: string): Promise<Storage> {
// Create the database // Create the database
@@ -134,6 +142,9 @@ export class Storage extends BaseStorage {
* *
* This adapter is useful for tests and short-lived sessions where persisted * This adapter is useful for tests and short-lived sessions where persisted
* SQLite state is not needed. * SQLite state is not needed.
*
* TODO: Move this somewhere else. There is no reason for this to be in the main codebase. We should put this stricly in the tests beacuse that were its actually being used.
* Ideally, we would provide these kind of generic fills as part of our packages somewhere, but these interfaces dont fit our current design.
*/ */
export class InMemoryStorage extends BaseStorage { export class InMemoryStorage extends BaseStorage {
static async create(): Promise<InMemoryStorage> { static async create(): Promise<InMemoryStorage> {

View File

@@ -0,0 +1,188 @@
import React, { useEffect, useId, useMemo, useState } from "react";
import { Box, Text } from "ink";
import { ScrollableList, type ListItemData } from "./List.js";
import TextInput from "./TextInput.js";
import { DialogWrapper } from "./Dialog.js";
import { useInputLayer, useLayeredInput } from "../hooks/useInputLayer.js";
import { colors } from "../theme.js";
/**
* Props for the currency selection dialog.
*/
interface CurrencySelectionDialogProps {
/** Current wallet currency from persisted settings. */
currentCurrency: string;
/** Available fiat numerator symbols that can be paired with BCH. */
currencies: string[];
/** True while the dialog is loading available pairs. */
isLoading: boolean;
/** Optional loading/error message for pair discovery. */
errorMessage: string | null;
/** Called when the user chooses a currency and confirms. */
onSelectCurrency: (currencyCode: string) => void;
/** Called when the dialog should close without applying changes. */
onCancel: () => void;
}
/**
* Currency picker dialog.
*
* UX requirements:
* - Arrow keys move the highlighted item.
* - Typing immediately filters results.
* - Enter applies current selection.
* - Escape closes without saving.
*/
export function CurrencySelectionDialog({
currentCurrency,
currencies,
isLoading,
errorMessage,
onSelectCurrency,
onCancel,
}: CurrencySelectionDialogProps): React.ReactElement {
const layerId = useId();
const [filterText, setFilterText] = useState("");
const [selectedIndex, setSelectedIndex] = useState(0);
// Mount this as a capturing input layer so background screens stop handling keys.
useInputLayer(layerId);
/**
* Applies the currently selected filtered result.
*/
const applySelection = (): void => {
const selectedCurrency = filteredCurrencies[selectedIndex];
if (!selectedCurrency) {
return;
}
onSelectCurrency(selectedCurrency);
};
useLayeredInput(layerId, (_input, key) => {
if (key.escape) {
onCancel();
return;
}
if (key.upArrow) {
setSelectedIndex((prev) =>
prev <= 0 ? Math.max(filteredCurrencies.length - 1, 0) : prev - 1,
);
return;
}
if (key.downArrow) {
setSelectedIndex((prev) =>
filteredCurrencies.length === 0
? 0
: prev >= filteredCurrencies.length - 1
? 0
: prev + 1,
);
return;
}
});
/**
* Filter currencies as the user types.
*/
const filteredCurrencies = useMemo(() => {
const normalizedFilter = filterText.trim().toUpperCase();
if (!normalizedFilter) {
return currencies;
}
return currencies.filter((currencyCode) =>
currencyCode.toUpperCase().includes(normalizedFilter),
);
}, [currencies, filterText]);
/**
* Keep selected index valid whenever filtering shrinks the result set.
*/
useEffect(() => {
if (filteredCurrencies.length === 0) {
setSelectedIndex(0);
return;
}
if (selectedIndex >= filteredCurrencies.length) {
setSelectedIndex(filteredCurrencies.length - 1);
}
}, [filteredCurrencies, selectedIndex]);
/**
* When the dialog opens or the list updates, default to current currency.
*/
useEffect(() => {
if (filterText.trim().length > 0) {
return;
}
const currentIndex = filteredCurrencies.findIndex(
(currencyCode) => currencyCode.toUpperCase() === currentCurrency.toUpperCase(),
);
if (currentIndex >= 0) {
setSelectedIndex(currentIndex);
}
}, [filteredCurrencies, currentCurrency, filterText]);
const listItems: ListItemData<string>[] = filteredCurrencies.map(
(currencyCode) => ({
key: currencyCode,
label: currencyCode,
description:
currencyCode.toUpperCase() === currentCurrency.toUpperCase()
? "(current)"
: undefined,
value: currencyCode,
}),
);
return (
<DialogWrapper title="Select Fiat Currency" borderColor={colors.info} width={64}>
<Text color={colors.textMuted}>
Available BCH quote pairs are loaded from the live rates adapter.
</Text>
<Box marginTop={1}>
<Text color={colors.primary}>Filter:</Text>
</Box>
<Box borderStyle="single" borderColor={colors.focus} paddingX={1}>
<TextInput
value={filterText}
onChange={setFilterText}
onSubmit={() => applySelection()}
placeholder="Type currency code (e.g. USD, AUD)..."
focus
/>
</Box>
<Box marginTop={1} flexDirection="column">
{isLoading ? (
<Text color={colors.textMuted}>Loading available pairs...</Text>
) : errorMessage ? (
<Text color={colors.error}>{errorMessage}</Text>
) : (
<ScrollableList
items={listItems}
selectedIndex={selectedIndex}
onSelect={setSelectedIndex}
onActivate={() => applySelection()}
focus={false}
maxVisible={8}
emptyMessage="No BCH quote pairs match this filter."
/>
)}
</Box>
<Box marginTop={1}>
<Text color={colors.textMuted}>
Type to filter navigate Enter apply Esc cancel
</Text>
</Box>
</DialogWrapper>
);
}

View File

@@ -47,6 +47,8 @@ interface QRCodeProps {
dialogTitle?: string; dialogTitle?: string;
/** Whether to display the raw encoded value as copyable text above the QR code. */ /** Whether to display the raw encoded value as copyable text above the QR code. */
showValue?: boolean; showValue?: boolean;
/** Optional subtitle to display below the QR code. */
subtitle?: React.ReactNode;
} }
/** /**
@@ -155,6 +157,7 @@ export function QRCode({
dialog = false, dialog = false,
dialogTitle = 'QR Code', dialogTitle = 'QR Code',
showValue = false, showValue = false,
subtitle = null,
}: QRCodeProps): React.ReactElement { }: QRCodeProps): React.ReactElement {
const { rows, moduleCount } = useMemo(() => { const { rows, moduleCount } = useMemo(() => {
const matrix = generateMatrix(value); const matrix = generateMatrix(value);
@@ -190,6 +193,7 @@ export function QRCode({
return ( return (
<DialogWrapper title={dialogTitle} borderColor={colors.primary} width={dialogWidth}> <DialogWrapper title={dialogTitle} borderColor={colors.primary} width={dialogWidth}>
{qrContent} {qrContent}
{subtitle}
</DialogWrapper> </DialogWrapper>
); );
} }

View File

@@ -68,7 +68,7 @@ export function VariableInputField({
focusColor, focusColor,
}: VariableInputFieldProps): React.ReactElement { }: VariableInputFieldProps): React.ReactElement {
const { currencyCode, formattedFiatPerBchRate, formatSatoshisToFiat } = const { currencyCode, formattedFiatPerBchRate, formatSatoshisToFiat } =
useSatoshisConversion("USD"); useSatoshisConversion();
const satoshisValue = useMemo( const satoshisValue = useMemo(
() => parseSatoshis(variable.value), () => parseSatoshis(variable.value),
[variable.value], [variable.value],

View File

@@ -1,4 +1,4 @@
import { useCallback, useMemo } from 'react'; import { useCallback, useMemo, useSyncExternalStore } from 'react';
import { useAppContext } from './useAppContext.js'; import { useAppContext } from './useAppContext.js';
import { useBchToFiatRate } from './useRates.js'; import { useBchToFiatRate } from './useRates.js';
@@ -9,9 +9,40 @@ import { useBchToFiatRate } from './useRates.js';
* component using it will re-render automatically when the selected pair * component using it will re-render automatically when the selected pair
* receives a new quote. * receives a new quote.
*/ */
export function useSatoshisConversion(targetCurrency: string = 'USD') { export function useSatoshisConversion(targetCurrency?: string) {
const { appService } = useAppContext(); const { appService } = useAppContext();
const currencyCode = useMemo(() => targetCurrency.toUpperCase(), [targetCurrency]); const subscribeToCurrency = useCallback(
(callback: () => void) => {
if (!appService || targetCurrency) {
return () => {};
}
return appService.settings.on('settings-updated', (event) => {
if (event.key === 'currency') {
callback();
}
});
},
[appService, targetCurrency],
);
const getCurrencySnapshot = useCallback(() => {
if (targetCurrency) {
return targetCurrency.toUpperCase();
}
if (!appService) {
return 'USD';
}
return appService.settings.getCurrency();
}, [appService, targetCurrency]);
const currencyCode = useSyncExternalStore(
subscribeToCurrency,
getCurrencySnapshot,
getCurrencySnapshot,
);
const fiatPerBchRate = useBchToFiatRate(currencyCode); const fiatPerBchRate = useBchToFiatRate(currencyCode);
const formattedFiatPerBchRate = useMemo(() => { const formattedFiatPerBchRate = useMemo(() => {

View File

@@ -19,7 +19,7 @@ import path from 'path';
import { createMnemonicFile } from '../../cli/mnemonic.js'; import { createMnemonicFile } from '../../cli/mnemonic.js';
import { getMnemonicsDir } from '../../utils/paths.js'; import { getMnemonicsDir } from '../../utils/paths.js';
import { BCHMnemonicURL } from '../../utils/bch-mnemonic-url.js'; import { BCHMnemonicURL } from '../../utils/bch-mnemonic-url.js';
import { encodeBip39Mnemonic } from '@bitauth/libauth'; import { encodeBip39Mnemonic, generateBip39Mnemonic } from '@bitauth/libauth';
/** /**
* Status message type. * Status message type.
@@ -41,7 +41,7 @@ interface MnemonicFileEntry {
* Focus sections the user can tab between. * Focus sections the user can tab between.
* When saved wallets exist the file list is shown first. * When saved wallets exist the file list is shown first.
*/ */
type FocusSection = 'files' | 'input' | 'saveCheckbox' | 'button'; type FocusSection = 'files' | 'input' | 'saveCheckbox' | 'generateRandomSeed' | 'button';
/** /**
* Reads mnemonic-* files from ~/.config/xo-cli/mnemonics/ (same as xo-cli), * Reads mnemonic-* files from ~/.config/xo-cli/mnemonics/ (same as xo-cli),
@@ -117,8 +117,8 @@ export function SeedInputScreen(): React.ReactElement {
* The ordered list of focusable sections (files section only when entries exist). * The ordered list of focusable sections (files section only when entries exist).
*/ */
const focusSections: FocusSection[] = mnemonicFiles.length > 0 const focusSections: FocusSection[] = mnemonicFiles.length > 0
? ['files', 'input', 'saveCheckbox', 'button'] ? ['files', 'input', 'generateRandomSeed', 'saveCheckbox', 'button']
: ['input', 'saveCheckbox', 'button']; : ['input', 'generateRandomSeed', 'saveCheckbox', 'button'];
/** /**
* Shows a status message with the given type. * Shows a status message with the given type.
@@ -202,7 +202,7 @@ export function SeedInputScreen(): React.ReactElement {
}, [mnemonicFiles, doInitialize]); }, [mnemonicFiles, doInitialize]);
// Keyboard navigation // Keyboard navigation
useBlockableInput((_input, key) => { useBlockableInput((input, key) => {
if (isSubmitting) return; if (isSubmitting) return;
// Tab / Shift-Tab to cycle focus sections // Tab / Shift-Tab to cycle focus sections
@@ -219,7 +219,7 @@ export function SeedInputScreen(): React.ReactElement {
// Space or Enter toggles "save mnemonic" when that row is focused // Space or Enter toggles "save mnemonic" when that row is focused
if (focusedSection === 'saveCheckbox') { if (focusedSection === 'saveCheckbox') {
if (_input === ' ' || key.return) { if (input === ' ' || key.return) {
setSaveMnemonicChecked((v) => !v); setSaveMnemonicChecked((v) => !v);
return; return;
} }
@@ -241,6 +241,18 @@ export function SeedInputScreen(): React.ReactElement {
} }
} }
// Ctrl-R generates a random seed phrase and fills it in the input
if (key.ctrl && input === 'r') {
setSeedPhrase(generateBip39Mnemonic());
return;
}
// If pressing enter while the generate random seed section is focused, generate a random seed and fill it in the input
if (key.return && focusedSection === 'generateRandomSeed') {
setSeedPhrase(generateBip39Mnemonic());
return;
}
// Enter on button submits manual seed // Enter on button submits manual seed
if (key.return && focusedSection === 'button') { if (key.return && focusedSection === 'button') {
handleSubmit(); handleSubmit();
@@ -358,6 +370,19 @@ export function SeedInputScreen(): React.ReactElement {
/> />
</Box> </Box>
{/* Generate random seed phrase and fill in the input */}
<Box marginTop={1}>
<Box
paddingX={1}
paddingY={0}
backgroundColor={focusedSection === 'generateRandomSeed' ? colors.focus : colors.bgSelected}
>
<Text color={focusedSection === 'generateRandomSeed' ? colors.bg : colors.text} bold>Generate Random Seed</Text>
</Box>
<Text color={colors.textMuted}> (Ctrl-R)</Text>
</Box>
{/* Save mnemonic checkbox (manual entry only; applies on Continue) */} {/* Save mnemonic checkbox (manual entry only; applies on Continue) */}
<Box <Box
marginTop={1} marginTop={1}

View File

@@ -11,6 +11,7 @@ import React, { useState, useEffect, useCallback, useMemo } from 'react';
import { Box, Text } from 'ink'; import { Box, Text } from 'ink';
import { ScrollableList, type ListItemData } from '../components/List.js'; import { ScrollableList, type ListItemData } from '../components/List.js';
import { QRCode } from '../components/QRCode.js'; import { QRCode } from '../components/QRCode.js';
import { CurrencySelectionDialog } from '../components/CurrencySelectionDialog.js';
import { useNavigation } from '../hooks/useNavigation.js'; import { useNavigation } from '../hooks/useNavigation.js';
import { useAppContext, useStatus } from '../hooks/useAppContext.js'; import { useAppContext, useStatus } from '../hooks/useAppContext.js';
import { useSatoshisConversion } from '../hooks/useSatoshisConversion.js'; import { useSatoshisConversion } from '../hooks/useSatoshisConversion.js';
@@ -29,6 +30,7 @@ import {
type HistoryDisplayRow, type HistoryDisplayRow,
type HistoryColorName, type HistoryColorName,
} from '../../utils/history-utils.js'; } from '../../utils/history-utils.js';
import { copyToClipboard } from '../utils/clipboard.js';
/** /**
* Map history color name to theme color. * Map history color name to theme color.
@@ -59,6 +61,7 @@ const menuItems: ListItemData<string>[] = [
{ key: 'import', label: 'Import Invitation', value: 'import' }, { key: 'import', label: 'Import Invitation', value: 'import' },
{ key: 'invitations', label: 'View Invitations', value: 'invitations' }, { key: 'invitations', label: 'View Invitations', value: 'invitations' },
{ key: 'new-address', label: 'Generate New Address', value: 'new-address' }, { key: 'new-address', label: 'Generate New Address', value: 'new-address' },
{ key: 'set-currency', label: 'Set Fiat Currency', value: 'set-currency' },
{ key: 'unreserve-all', label: 'Unreserve All Resources', value: 'unreserve-all' }, { key: 'unreserve-all', label: 'Unreserve All Resources', value: 'unreserve-all' },
{ key: 'refresh', label: 'Refresh', value: 'refresh' }, { key: 'refresh', label: 'Refresh', value: 'refresh' },
]; ];
@@ -75,7 +78,10 @@ type HistoryListItem = ListItemData<HistoryDisplayRow>;
function QRDialogOverlay({ address, onClose }: { address: string; onClose: () => void }): React.ReactElement { function QRDialogOverlay({ address, onClose }: { address: string; onClose: () => void }): React.ReactElement {
useInputLayer('qr-dialog'); useInputLayer('qr-dialog');
useLayeredInput('qr-dialog', (_input, key) => { useLayeredInput('qr-dialog', (input, key) => {
if (input === 'c' || input === 'C') {
copyToClipboard(address);
}
if (key.escape || key.return) { if (key.escape || key.return) {
onClose(); onClose();
} }
@@ -93,10 +99,13 @@ function QRDialogOverlay({ address, onClose }: { address: string; onClose: () =>
dialog dialog
dialogTitle="Receive Address" dialogTitle="Receive Address"
showValue showValue
subtitle={
<Box flexDirection="column" justifyContent="center" marginTop={1}>
<Text color={colors.textMuted}>Press C to copy to clipboard</Text>
<Text color={colors.textMuted}>Press Enter or Esc to close</Text>
</Box>
}
/> />
<Box justifyContent="center" marginTop={1}>
<Text color={colors.textMuted}>Press Enter or Esc to close</Text>
</Box>
</Box> </Box>
); );
} }
@@ -114,7 +123,7 @@ export function WalletStateScreen(): React.ReactElement {
fiatPerBchRate, fiatPerBchRate,
formattedFiatPerBchRate, formattedFiatPerBchRate,
formatSatoshisToFiat, formatSatoshisToFiat,
} = useSatoshisConversion('USD'); } = useSatoshisConversion();
// State // State
const [balance, setBalance] = useState<{ totalSatoshis: bigint; utxoCount: number } | null>(null); const [balance, setBalance] = useState<{ totalSatoshis: bigint; utxoCount: number } | null>(null);
@@ -126,6 +135,14 @@ export function WalletStateScreen(): React.ReactElement {
/** Cash address to display in the QR code dialog (null when dialog is hidden). */ /** Cash address to display in the QR code dialog (null when dialog is hidden). */
const [qrAddress, setQrAddress] = useState<string | null>(null); const [qrAddress, setQrAddress] = useState<string | null>(null);
/** Whether the fiat currency selection dialog is open. */
const [isCurrencyDialogOpen, setCurrencyDialogOpen] = useState(false);
/** Loading state for rates pair discovery. */
const [isLoadingCurrencyPairs, setLoadingCurrencyPairs] = useState(false);
/** Optional error message shown in the currency dialog. */
const [currencyPairsError, setCurrencyPairsError] = useState<string | null>(null);
/** Available fiat currencies derived from rates pairs in X/BCH format. */
const [availableCurrencies, setAvailableCurrencies] = useState<string[]>([]);
/** /**
* Refreshes wallet state. * Refreshes wallet state.
@@ -253,6 +270,89 @@ export function WalletStateScreen(): React.ReactElement {
} }
}, [appService, setStatus, showError, showInfo, refresh]); }, [appService, setStatus, showError, showInfo, refresh]);
/**
* Loads all available rates pairs, then extracts fiat numerator symbols from
* pairs shaped like X/BCH.
*
* We retry briefly because rates startup is asynchronous and metadata can take
* a moment to hydrate right after wallet initialization.
*/
const loadAvailableCurrencies = useCallback(async (): Promise<void> => {
if (!appService) {
setCurrencyPairsError("AppService not initialized");
return;
}
setLoadingCurrencyPairs(true);
setCurrencyPairsError(null);
try {
let pairs = new Set<string>();
// Retry a few times so we can catch late metadata initialization.
for (let attempt = 0; attempt < 4; attempt += 1) {
pairs = await appService.rates.listPairs();
if (pairs.size > 0) {
break;
}
await new Promise((resolve) => setTimeout(resolve, 200));
}
const currencies = Array.from(pairs)
.map((pair) => pair.toUpperCase())
.filter((pair) => pair.endsWith("/BCH"))
.map((pair) => pair.split("/")[0] ?? "")
.filter((currency) => currency.length > 0)
.sort((a, b) => a.localeCompare(b));
const uniqueCurrencies = Array.from(new Set(currencies));
setAvailableCurrencies(uniqueCurrencies);
if (uniqueCurrencies.length === 0) {
setCurrencyPairsError(
"No X/BCH rates are currently available. Try again in a moment.",
);
}
} catch (error) {
setCurrencyPairsError(
`Failed to load currency pairs: ${error instanceof Error ? error.message : String(error)}`,
);
} finally {
setLoadingCurrencyPairs(false);
}
}, [appService]);
/**
* Opens the fiat currency dialog and triggers pair discovery.
*/
const openCurrencyDialog = useCallback(() => {
setCurrencyDialogOpen(true);
void loadAvailableCurrencies();
}, [loadAvailableCurrencies]);
/**
* Applies the selected fiat currency to persisted settings.
*/
const applyCurrencySelection = useCallback(
(currencyCode: string) => {
if (!appService) {
showError("AppService not initialized");
return;
}
try {
appService.settings.setCurrency(currencyCode);
setStatus(`Fiat currency updated to ${currencyCode}`);
setCurrencyDialogOpen(false);
} catch (error) {
showError(
`Failed to update currency: ${error instanceof Error ? error.message : String(error)}`,
);
}
},
[appService, setStatus, showError],
);
/** /**
* Handles menu action. * Handles menu action.
*/ */
@@ -270,6 +370,9 @@ export function WalletStateScreen(): React.ReactElement {
case 'new-address': case 'new-address':
generateNewAddress(); generateNewAddress();
break; break;
case 'set-currency':
openCurrencyDialog();
break;
case 'unreserve-all': case 'unreserve-all':
unreserveAll(); unreserveAll();
break; break;
@@ -277,7 +380,7 @@ export function WalletStateScreen(): React.ReactElement {
refresh(); refresh();
break; break;
} }
}, [navigate, generateNewAddress, unreserveAll, refresh]); }, [navigate, generateNewAddress, openCurrencyDialog, unreserveAll, refresh]);
/** /**
* Handle menu item activation. * Handle menu item activation.
@@ -350,29 +453,32 @@ export function WalletStateScreen(): React.ReactElement {
const indicator = isFocused ? '▸ ' : ' '; const indicator = isFocused ? '▸ ' : ' ';
const groupingPrefix = row.isNested ? ' -> ' : ''; const groupingPrefix = row.isNested ? ' -> ' : '';
if (row.type === 'invitation') { if (row.type === 'history_item') {
const sats = row.valueSatoshis ?? 0n;
const fiatSuffix = getFiatSuffix(sats);
return ( return (
<Box flexDirection="row" justifyContent="space-between"> <Box flexDirection="row" justifyContent="space-between">
<Text color={itemColor}> <Box flexDirection="row">
{indicator}[Invitation] {row.label} <Text color={itemColor}>
</Text> {indicator}{formatSatoshis(sats)}{fiatSuffix}
</Text>
<Text color={colors.textMuted}> {row.label}</Text>
</Box>
{dateStr && <Text color={colors.textMuted}>{dateStr}</Text>} {dateStr && <Text color={colors.textMuted}>{dateStr}</Text>}
</Box> </Box>
); );
} }
if (row.type === 'invitation_input') { if (row.type === 'history_input') {
const inputSatoshis = row.utxo?.valueSatoshis; const sats = row.valueSatoshis ?? 0n;
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] {formatSatoshis(sats)}
{inputFiatSuffix} {getFiatSuffix(sats)}
</Text> </Text>
<Text color={colors.textMuted}> {row.label}</Text>
{row.description && <Text color={colors.textMuted}> {row.description}</Text>} {row.description && <Text color={colors.textMuted}> {row.description}</Text>}
</Box> </Box>
{dateStr && <Text color={colors.textMuted}>{dateStr}</Text>} {dateStr && <Text color={colors.textMuted}>{dateStr}</Text>}
@@ -380,8 +486,9 @@ export function WalletStateScreen(): React.ReactElement {
); );
} }
if (row.type === 'invitation_output') { if (row.type === 'history_output') {
const sats = row.utxo?.valueSatoshis ?? 0n; const sats = row.valueSatoshis ?? 0n;
const reservedTag = row.reserved ? ' [Reserved]' : '';
return ( return (
<Box flexDirection="row" justifyContent="space-between"> <Box flexDirection="row" justifyContent="space-between">
<Box flexDirection="row"> <Box flexDirection="row">
@@ -389,6 +496,7 @@ export function WalletStateScreen(): React.ReactElement {
{indicator}{groupingPrefix}[Output] {formatSatoshis(sats)} {indicator}{groupingPrefix}[Output] {formatSatoshis(sats)}
{getFiatSuffix(sats)} {getFiatSuffix(sats)}
</Text> </Text>
<Text color={colors.textMuted}> {row.label}{reservedTag}</Text>
{row.description && <Text color={colors.textMuted}> {row.description}</Text>} {row.description && <Text color={colors.textMuted}> {row.description}</Text>}
</Box> </Box>
{dateStr && <Text color={colors.textMuted}>{dateStr}</Text>} {dateStr && <Text color={colors.textMuted}>{dateStr}</Text>}
@@ -396,23 +504,6 @@ export function WalletStateScreen(): React.ReactElement {
); );
} }
if (row.type === 'utxo') {
const sats = row.utxo?.valueSatoshis ?? 0n;
const reservedTag = row.utxo?.reserved ? ' [Reserved]' : '';
return (
<Box flexDirection="row" justifyContent="space-between">
<Box flexDirection="row">
<Text color={itemColor}>
{indicator}{formatSatoshis(sats)}
{getFiatSuffix(sats)}
</Text>
{row.description && <Text color={colors.textMuted}> {row.description}{reservedTag}</Text>}
</Box>
{dateStr && <Text color={colors.textMuted}>{dateStr}</Text>}
</Box>
);
}
// Fallback for other types // Fallback for other types
return ( return (
<Box flexDirection="row" justifyContent="space-between"> <Box flexDirection="row" justifyContent="space-between">
@@ -515,7 +606,7 @@ export function WalletStateScreen(): React.ReactElement {
height={14} height={14}
overflow="hidden" overflow="hidden"
> >
<Text color={colors.primary} bold> Wallet History {history.length > 0 ? `(${selectedHistoryIndex + 1}/${history.length})` : ''}</Text> <Text color={colors.primary} bold> Wallet History {historyListItems.length > 0 ? `(${selectedHistoryIndex + 1}/${historyListItems.length})` : ''}</Text>
{isLoading ? ( {isLoading ? (
<Box marginTop={1}> <Box marginTop={1}>
<Text color={colors.textMuted}>Loading...</Text> <Text color={colors.textMuted}>Loading...</Text>
@@ -548,6 +639,27 @@ export function WalletStateScreen(): React.ReactElement {
onClose={() => setQrAddress(null)} onClose={() => setQrAddress(null)}
/> />
)} )}
{/* Fiat currency selection dialog overlay */}
{isCurrencyDialogOpen && (
<Box
position="absolute"
flexDirection="column"
alignItems="center"
justifyContent="center"
width="100%"
height="100%"
>
<CurrencySelectionDialog
currentCurrency={currencyCode}
currencies={availableCurrencies}
isLoading={isLoadingCurrencyPairs}
errorMessage={currencyPairsError}
onSelectCurrency={applyCurrencySelection}
onCancel={() => setCurrencyDialogOpen(false)}
/>
</Box>
)}
</Box> </Box>
); );
} }

View File

@@ -23,7 +23,7 @@ export function InputsStep({
changeAmount, changeAmount,
focusArea, focusArea,
}: Props): React.ReactElement { }: Props): React.ReactElement {
const { formatSatoshisToFiat } = useSatoshisConversion('USD'); const { formatSatoshisToFiat } = useSatoshisConversion();
const getFiatSuffix = (satoshis: bigint): string => { const getFiatSuffix = (satoshis: bigint): string => {
const fiatValue = formatSatoshisToFiat(satoshis); const fiatValue = formatSatoshisToFiat(satoshis);

View File

@@ -23,7 +23,7 @@ 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 { formatSatoshisToFiat } = useSatoshisConversion();
const getFiatSuffix = (satoshis: bigint): string => { const getFiatSuffix = (satoshis: bigint): string => {
const fiatValue = formatSatoshisToFiat(satoshis); const fiatValue = formatSatoshisToFiat(satoshis);

View File

@@ -21,7 +21,7 @@ import { useSatoshisConversion } from '../../hooks/useSatoshisConversion.js';
import { colors, logoSmall, formatSatoshis } from '../../theme.js'; import { colors, logoSmall, formatSatoshis } from '../../theme.js';
import { copyToClipboard } from '../../utils/clipboard.js'; import { copyToClipboard } from '../../utils/clipboard.js';
import type { Invitation } from '../../../services/invitation.js'; import type { Invitation } from '../../../services/invitation.js';
import type { XOTemplate } from '@xo-cash/types'; import type { XOInvitationCommit, XOInvitationVariableValue, XOTemplate } from '@xo-cash/types';
import { import {
getInvitationState, getInvitationState,
@@ -29,12 +29,12 @@ import {
getInvitationInputs, getInvitationInputs,
getInvitationOutputs, getInvitationOutputs,
getInvitationVariables, getInvitationVariables,
getUserRole,
formatInvitationListItem, formatInvitationListItem,
formatInvitationId, formatInvitationId,
} from '../../../utils/invitation-utils.js'; } from '../../../utils/invitation-utils.js';
import { InvitationImportFlow } from './invitation-import/InvitationImportFlow.js'; import { InvitationImportFlow } from './invitation-import/InvitationImportFlow.js';
import { compileCashAssemblyString } from '@xo-cash/engine';
/** /**
* Map state color name to theme color. * Map state color name to theme color.
@@ -80,6 +80,29 @@ const invitationListGroups: ListGroup[] = [
{ id: 'invitations', separator: true }, { id: 'invitations', separator: true },
]; ];
type OwnInvitationContext = {
entityIdentifier: string | null;
roleIdentifier: string | null;
};
function getRoleIdentifierFromCommits(commits: XOInvitationCommit[]): string | null {
for (const commit of commits) {
for (const input of commit.data.inputs ?? []) {
if (input.roleIdentifier) return input.roleIdentifier;
}
for (const output of commit.data.outputs ?? []) {
if (output.roleIdentifier) return output.roleIdentifier;
}
for (const variable of commit.data.variables ?? []) {
if (variable.roleIdentifier) return variable.roleIdentifier;
}
}
return null;
}
/** /**
* Invitation Screen Component. * Invitation Screen Component.
*/ */
@@ -90,7 +113,7 @@ export function InvitationScreen(): React.ReactElement {
const invitations = useInvitations(); const invitations = useInvitations();
const { currencyCode, formattedFiatPerBchRate, formatSatoshisToFiat } = const { currencyCode, formattedFiatPerBchRate, formatSatoshisToFiat } =
useSatoshisConversion('USD'); useSatoshisConversion();
// ── UI state ───────────────────────────────────────────────────────────── // ── UI state ─────────────────────────────────────────────────────────────
const [selectedIndex, setSelectedIndex] = useState(0); const [selectedIndex, setSelectedIndex] = useState(0);
@@ -107,6 +130,10 @@ export function InvitationScreen(): React.ReactElement {
// ── Template cache ─────────────────────────────────────────────────────── // ── Template cache ───────────────────────────────────────────────────────
const [templateCache, setTemplateCache] = useState<Map<string, XOTemplate>>(new Map()); const [templateCache, setTemplateCache] = useState<Map<string, XOTemplate>>(new Map());
const [selectedTemplate, setSelectedTemplate] = useState<XOTemplate | null>(null); const [selectedTemplate, setSelectedTemplate] = useState<XOTemplate | null>(null);
const [ownInvitationContext, setOwnInvitationContext] = useState<OwnInvitationContext>({
entityIdentifier: null,
roleIdentifier: null,
});
// Check if we should open import dialog on mount // Check if we should open import dialog on mount
const initialMode = navData.mode as string | undefined; const initialMode = navData.mode as string | undefined;
@@ -180,6 +207,43 @@ export function InvitationScreen(): React.ReactElement {
.then(template => setSelectedTemplate(template ?? null)); .then(template => setSelectedTemplate(template ?? null));
}, [selectedInvitation, appService]); }, [selectedInvitation, appService]);
/**
* Load the current engine entity's commits for the selected invitation.
*/
useEffect(() => {
if (!selectedInvitation || !appService) {
setOwnInvitationContext({
entityIdentifier: null,
roleIdentifier: null,
});
return;
}
let isCurrent = true;
appService.engine.getOwnCommits(selectedInvitation.data)
.then((ownCommits) => {
if (!isCurrent) return;
setOwnInvitationContext({
entityIdentifier: ownCommits[0]?.entityIdentifier ?? null,
roleIdentifier: getRoleIdentifierFromCommits(ownCommits),
});
})
.catch(() => {
if (!isCurrent) return;
setOwnInvitationContext({
entityIdentifier: null,
roleIdentifier: null,
});
});
return () => {
isCurrent = false;
};
}, [selectedInvitation, appService]);
// ── Import flow callbacks ────────────────────────────────────────────── // ── Import flow callbacks ──────────────────────────────────────────────
/** /**
@@ -512,9 +576,8 @@ export function InvitationScreen(): React.ReactElement {
const inputs = getInvitationInputs(selectedInvitation); const inputs = getInvitationInputs(selectedInvitation);
const outputs = getInvitationOutputs(selectedInvitation); const outputs = getInvitationOutputs(selectedInvitation);
const variables = getInvitationVariables(selectedInvitation); const variables = getInvitationVariables(selectedInvitation);
const userEntityId = ownInvitationContext.entityIdentifier;
const userEntityId = selectedInvitation.data.commits?.[0]?.entityIdentifier ?? null; const userRole = ownInvitationContext.roleIdentifier;
const userRole = getUserRole(selectedInvitation, userEntityId);
const roleInfoRaw = userRole && selectedTemplate?.roles?.[userRole]; const roleInfoRaw = userRole && selectedTemplate?.roles?.[userRole];
const roleInfo = roleInfoRaw && typeof roleInfoRaw === 'object' ? roleInfoRaw : null; const roleInfo = roleInfoRaw && typeof roleInfoRaw === 'object' ? roleInfoRaw : null;
@@ -619,9 +682,17 @@ export function InvitationScreen(): React.ReactElement {
key={`input-${idx}`} key={`input-${idx}`}
color={isUserInput ? colors.success : colors.text} color={isUserInput ? colors.success : colors.text}
> >
{/* Indicator for whether this is the user's input */}
{' '}{isUserInput ? '• ' : '○ '} {' '}{isUserInput ? '• ' : '○ '}
{/* TODO: Why doesnt this stuff work? It just cant resolve inputs? */}
{/* Input name */}
{inputTemplate?.name ?? input.inputIdentifier ?? `Input ${idx}`} {inputTemplate?.name ?? input.inputIdentifier ?? `Input ${idx}`}
{/* Input role */}
{input.roleIdentifier && ` (${input.roleIdentifier})`} {input.roleIdentifier && ` (${input.roleIdentifier})`}
{/* Input value */}
{inputSatoshis !== null && ` ${formatSatoshis(inputSatoshis)}${getFiatSuffix(inputSatoshis)}`} {inputSatoshis !== null && ` ${formatSatoshis(inputSatoshis)}${getFiatSuffix(inputSatoshis)}`}
</Text> </Text>
); );
@@ -645,8 +716,19 @@ export function InvitationScreen(): React.ReactElement {
key={`output-${idx}`} key={`output-${idx}`}
color={isUserOutput ? colors.success : colors.text} color={isUserOutput ? colors.success : colors.text}
> >
{/* Indicator for whether this is the user's output */}
{' '}{isUserOutput ? '• ' : '○ '} {' '}{isUserOutput ? '• ' : '○ '}
{/* Output name */}
{outputTemplate?.name ?? output.outputIdentifier ?? `Output ${idx}`} {outputTemplate?.name ?? output.outputIdentifier ?? `Output ${idx}`}
{/* Output description */}
{outputTemplate?.description && ' - ' + compileCashAssemblyString(outputTemplate?.description ?? '', variables.reduce((acc, variable) => {
acc[variable.variableIdentifier] = variable.value as XOInvitationVariableValue;
return acc;
}, {} as Record<string, XOInvitationVariableValue>))}
{/* Output value */}
{outputSatoshis !== null && ` (${formatSatoshis(outputSatoshis)}${getFiatSuffix(outputSatoshis)})`} {outputSatoshis !== null && ` (${formatSatoshis(outputSatoshis)}${getFiatSuffix(outputSatoshis)})`}
</Text> </Text>
); );

View File

@@ -17,15 +17,15 @@ import { StepIndicator, type Step } from '../../../components/ProgressBar.js';
import { FetchInvitationStep } from './steps/FetchInvitationStep.js'; import { FetchInvitationStep } from './steps/FetchInvitationStep.js';
import { PreviewInvitationStep } from './steps/PreviewInvitationStep.js'; import { PreviewInvitationStep } from './steps/PreviewInvitationStep.js';
import { RoleSelectStep } from './steps/RoleSelectStep.js'; import { RoleSelectStep } from './steps/RoleSelectStep.js';
import { VariablesStep } from './steps/VariablesStep.js';
import { InputsSelectStep } from './steps/InputsSelectStep.js'; import { InputsSelectStep } from './steps/InputsSelectStep.js';
import { ReviewStep } from './steps/ReviewStep.js'; import { ReviewStep } from './steps/ReviewStep.js';
import { IMPORT_STEPS, type ImportFlowProps, type SelectableUTXO } from './types.js'; import { IMPORT_STEPS, type ImportFlowProps, type ImportStepType, type ImportVariableInput, type SelectableUTXO } from './types.js';
import type { Invitation } from '../../../../services/invitation.js'; import type { Invitation } from '../../../../services/invitation.js';
import type { XOTemplate } from '@xo-cash/types'; import type { XOTemplate } from '@xo-cash/types';
import { DialogWrapper } from '../../../components/Dialog.js'; import { DialogWrapper } from '../../../components/Dialog.js';
import { useInputLayer, useLayeredInput } from '../../../hooks/useInputLayer.js'; import { useInputLayer, useLayeredInput } from '../../../hooks/useInputLayer.js';
import { InvitationBuilder } from '@xo-cash/engine';
import { hexToBin } from '@bitauth/libauth'; import { hexToBin } from '@bitauth/libauth';
/** Default fee estimate in satoshis. */ /** Default fee estimate in satoshis. */
@@ -34,6 +34,24 @@ const DEFAULT_FEE = 500n;
/** Dust threshold — outputs below this are unspendable. */ /** Dust threshold — outputs below this are unspendable. */
const DUST_THRESHOLD = 546n; const DUST_THRESHOLD = 546n;
/**
* Resolve the fixed index of a flow step from `IMPORT_STEPS`.
* We centralize this so step transitions do not rely on magic numbers.
*/
function getStepIndex(type: ImportStepType): number {
const index = IMPORT_STEPS.findIndex((step) => step.type === type);
if (index === -1) {
throw new Error(`Import step not found: ${type}`);
}
return index;
}
const PREVIEW_STEP_INDEX = getStepIndex('preview');
const ROLE_SELECT_STEP_INDEX = getStepIndex('role-select');
const VARIABLES_STEP_INDEX = getStepIndex('variables');
const INPUTS_SELECT_STEP_INDEX = getStepIndex('inputs-select');
const REVIEW_STEP_INDEX = getStepIndex('review');
export function InvitationImportFlow({ export function InvitationImportFlow({
invitationId, invitationId,
mode, mode,
@@ -46,10 +64,10 @@ export function InvitationImportFlow({
// ── Accumulated state ──────────────────────────────────────────────────── // ── Accumulated state ────────────────────────────────────────────────────
const [currentStep, setCurrentStep] = useState(0); const [currentStep, setCurrentStep] = useState(0);
const [invitation, setInvitation] = useState<Invitation | null>(null); const [invitation, setInvitation] = useState<Invitation | null>(null);
const [buildableInvitation, setBuildableInvitation] = useState<InvitationBuilder | null>(null);
const [template, setTemplate] = useState<XOTemplate | null>(null); const [template, setTemplate] = useState<XOTemplate | null>(null);
const [availableRoles, setAvailableRoles] = useState<string[]>([]); const [availableRoles, setAvailableRoles] = useState<string[]>([]);
const [selectedRole, setSelectedRole] = useState<string | null>(null); const [selectedRole, setSelectedRole] = useState<string | null>(null);
const [variableInputs, setVariableInputs] = useState<ImportVariableInput[]>([]);
const [selectedInputs, setSelectedInputs] = useState<SelectableUTXO[]>([]); const [selectedInputs, setSelectedInputs] = useState<SelectableUTXO[]>([]);
const [changeAmount, setChangeAmount] = useState(0n); const [changeAmount, setChangeAmount] = useState(0n);
const [requiredAmount, setRequiredAmount] = useState(0n); const [requiredAmount, setRequiredAmount] = useState(0n);
@@ -79,9 +97,6 @@ export function InvitationImportFlow({
setInvitation(inv); setInvitation(inv);
setTemplate(tmpl); setTemplate(tmpl);
const builder = InvitationBuilder.fromInvitation(inv.data);
setBuildableInvitation(builder);
try { try {
const roles = await inv.getAvailableRoles(); const roles = await inv.getAvailableRoles();
setAvailableRoles(roles); setAvailableRoles(roles);
@@ -89,20 +104,98 @@ export function InvitationImportFlow({
showError(`Failed to get roles: ${err instanceof Error ? err.message : String(err)}`); showError(`Failed to get roles: ${err instanceof Error ? err.message : String(err)}`);
} }
setCurrentStep(1); // → Preview setCurrentStep(PREVIEW_STEP_INDEX); // → Preview
}, [showError]); }, [showError]);
/** PreviewStep completed — user reviewed the invitation state and wants to proceed. */ /** PreviewStep completed — user reviewed the invitation state and wants to proceed. */
const handlePreviewComplete = useCallback(() => { const handlePreviewComplete = useCallback(() => {
setCurrentStep(2); // → Role Select setCurrentStep(ROLE_SELECT_STEP_INDEX); // → Role Select
}, []); }, []);
/** RoleSelectStep completed — user picked a role. */ /** RoleSelectStep completed — user picked a role. */
const handleRoleComplete = useCallback((role: string) => { const handleRoleComplete = useCallback((role: string) => {
setSelectedRole(role); setSelectedRole(role);
setCurrentStep(3); // → Inputs Select
const action = template?.actions?.[invitation?.data.actionIdentifier ?? ""];
const roleRequirements = action?.roles?.[role]?.requirements?.variables ?? [];
const hasRequiredVariables = roleRequirements.length > 0;
if (!hasRequiredVariables) {
setVariableInputs([]);
setCurrentStep(INPUTS_SELECT_STEP_INDEX); // → Inputs Select
return;
}
const initializedVariables: ImportVariableInput[] = roleRequirements.map((variableId) => {
const variableDefinition = template?.variables?.[variableId];
return {
id: variableId,
name: variableDefinition?.name ?? variableId,
type: variableDefinition?.type ?? 'string',
hint: variableDefinition?.hint,
value: '',
};
});
setVariableInputs(initializedVariables);
setCurrentStep(VARIABLES_STEP_INDEX); // → Variables
}, [template, invitation]);
/** VariablesStep edited a field value. */
const handleVariableUpdate = useCallback((index: number, value: string) => {
setVariableInputs((previous) => {
const updated = [...previous];
const current = updated[index];
if (current) {
updated[index] = { ...current, value };
}
return updated;
});
}, []); }, []);
/**
* Convert variable input value to its invitation payload representation.
* Numeric variables are persisted as bigint so they match action wizard behavior.
*/
const parseVariableValue = useCallback((variable: ImportVariableInput) => {
const variableHint = variable.hint?.toLowerCase();
const isNumeric =
['integer', 'number', 'satoshis'].includes(variable.type) ||
(variableHint !== undefined && ['satoshis', 'amount'].includes(variableHint));
if (!isNumeric) {
return variable.value;
}
return BigInt(variable.value || '0');
}, []);
/** VariablesStep completed — persist variables then continue to input selection. */
const handleVariablesComplete = useCallback(async () => {
if (!invitation || !selectedRole) return;
const emptyVariables = variableInputs.filter((variable) => variable.value.trim() === '');
if (emptyVariables.length > 0) {
showError(`Please enter values for: ${emptyVariables.map((variable) => variable.name).join(', ')}`);
return;
}
try {
await invitation.addVariables(
variableInputs.map((variable) => ({
variableIdentifier: variable.id,
roleIdentifier: selectedRole,
value: parseVariableValue(variable),
})),
);
setCurrentStep(INPUTS_SELECT_STEP_INDEX); // → Inputs Select
} catch (error) {
showError(
`Failed to add variables: ${error instanceof Error ? error.message : String(error)}`,
);
}
}, [invitation, selectedRole, variableInputs, parseVariableValue, showError]);
/** InputsSelectStep completed — user selected UTXOs. */ /** InputsSelectStep completed — user selected UTXOs. */
const handleInputsComplete = useCallback(async (inputs: SelectableUTXO[]) => { const handleInputsComplete = useCallback(async (inputs: SelectableUTXO[]) => {
setSelectedInputs(inputs); setSelectedInputs(inputs);
@@ -130,8 +223,8 @@ export function InvitationImportFlow({
}]); }]);
} }
setCurrentStep(4); // → Review setCurrentStep(REVIEW_STEP_INDEX); // → Review
}, [invitation, buildableInvitation, selectedInputs]); }, [invitation]);
/** ReviewStep completed — invitation import is done. */ /** ReviewStep completed — invitation import is done. */
const handleReviewComplete = useCallback(() => { const handleReviewComplete = useCallback(() => {
@@ -205,6 +298,17 @@ export function InvitationImportFlow({
/> />
); );
case 'variables':
return (
<VariablesStep
variables={variableInputs}
onUpdateVariable={handleVariableUpdate}
onComplete={handleVariablesComplete}
onCancel={handleCancel}
isActive={true}
/>
);
case 'inputs-select': case 'inputs-select':
if (!invitation || !selectedRole) return null; if (!invitation || !selectedRole) return null;
return ( return (

View File

@@ -33,7 +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 { formatSatoshisToFiat } = useSatoshisConversion();
const fee = DEFAULT_FEE; const fee = DEFAULT_FEE;

View File

@@ -42,7 +42,7 @@ export function PreviewInvitationStep({
onCancel, onCancel,
isActive, isActive,
}: PreviewStepProps): React.ReactElement { }: PreviewStepProps): React.ReactElement {
const { formatSatoshisToFiat } = useSatoshisConversion('USD'); const { formatSatoshisToFiat } = useSatoshisConversion();
useLayeredInput('import-flow', (_input, key) => { useLayeredInput('import-flow', (_input, key) => {
if (key.return) onComplete(); if (key.return) onComplete();

View File

@@ -33,7 +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 { formatSatoshisToFiat } = useSatoshisConversion();
const fee = DEFAULT_FEE; const fee = DEFAULT_FEE;
const action = template?.actions?.[invitation.data.actionIdentifier]; const action = template?.actions?.[invitation.data.actionIdentifier];

View File

@@ -0,0 +1,113 @@
/**
* VariablesStep — collects all required variable values for invitation import.
*
* This runs after role selection and before input selection so cashasm
* expressions can resolve required variables during `getSatsOut()`.
*/
import React, { useMemo, useState, useCallback } from "react";
import { Box, Text } from "ink";
import { colors } from "../../../../theme.js";
import { useLayeredInput } from "../../../../hooks/useInputLayer.js";
import { VariableInputField } from "../../../../components/VariableInputField.js";
import type { VariablesStepProps } from "../types.js";
/**
* Build a user-facing validation error for empty required fields.
*/
function validateVariables(
variables: VariablesStepProps["variables"],
): string | null {
const empty = variables.filter((v) => v.value.trim() === "");
if (empty.length === 0) return null;
return `Please enter values for: ${empty.map((v) => v.name).join(", ")}`;
}
export function VariablesStep({
variables,
onUpdateVariable,
onComplete,
onCancel,
isActive,
}: VariablesStepProps): React.ReactElement {
const [focusedInput, setFocusedInput] = useState(0);
const [validationError, setValidationError] = useState<string | null>(null);
const helpText = useMemo(() => {
if (variables.length === 0) {
return "No variables required for this role.";
}
return "Enter a value for each variable, then press Enter on the last field to continue.";
}, [variables.length]);
/**
* Move focus to next input, or finish the step if this is the last one.
*/
const handleInputSubmit = useCallback(() => {
if (variables.length === 0) {
onComplete();
return;
}
if (focusedInput < variables.length - 1) {
setFocusedInput((prev) => prev + 1);
return;
}
const validation = validateVariables(variables);
setValidationError(validation);
if (!validation) {
onComplete();
}
}, [variables, focusedInput, onComplete]);
// Keyboard navigation for non-text actions.
useLayeredInput(
"import-flow",
(input, key) => {
if (key.upArrow || input === "k") {
setFocusedInput((prev) => Math.max(0, prev - 1));
} else if (key.downArrow || input === "j") {
setFocusedInput((prev) => Math.min(variables.length - 1, prev + 1));
} else if (key.escape) {
onCancel();
}
},
{ isActive },
);
return (
<Box flexDirection="column">
<Text color={colors.primary} bold>
Required Variables
</Text>
<Box marginTop={1} flexDirection="column">
{variables.map((variable, index) => (
<VariableInputField
key={variable.id}
variable={variable}
index={index}
isFocused={focusedInput === index}
onChange={onUpdateVariable}
onSubmit={handleInputSubmit}
borderColor={colors.border as string}
focusColor={colors.primary as string}
/>
))}
</Box>
{validationError && (
<Box marginTop={1}>
<Text color={colors.error}>{validationError}</Text>
</Box>
)}
<Box marginTop={1}>
<Text color={colors.textMuted}>
{helpText} : Change field Esc: Cancel
</Text>
</Box>
</Box>
);
}

View File

@@ -16,6 +16,7 @@ export type ImportStepType =
| "fetch" | "fetch"
| "preview" | "preview"
| "role-select" | "role-select"
| "variables"
| "inputs-select" | "inputs-select"
| "review"; | "review";
@@ -30,6 +31,7 @@ export const IMPORT_STEPS: ImportStep[] = [
{ name: "Fetch", type: "fetch" }, { name: "Fetch", type: "fetch" },
{ name: "Preview", type: "preview" }, { name: "Preview", type: "preview" },
{ name: "Select Role", type: "role-select" }, { name: "Select Role", type: "role-select" },
{ name: "Variables", type: "variables" },
{ name: "Select Inputs", type: "inputs-select" }, { name: "Select Inputs", type: "inputs-select" },
{ name: "Review", type: "review" }, { name: "Review", type: "review" },
]; ];
@@ -81,6 +83,24 @@ export interface RoleSelectStepProps {
isActive: boolean; isActive: boolean;
} }
/** A single variable input required by the selected action role. */
export interface ImportVariableInput {
id: string;
name: string;
type: string;
hint?: string;
value: string;
}
/** Props for VariablesStep — collects required role/action variable values. */
export interface VariablesStepProps {
variables: ImportVariableInput[];
onUpdateVariable: (index: number, value: string) => void;
onComplete: () => void;
onCancel: () => void;
isActive: boolean;
}
/** Props for InputsSelectStep — lets user pick UTXOs to fund the invitation. */ /** Props for InputsSelectStep — lets user pick UTXOs to fund the invitation. */
export interface InputsSelectStepProps { export interface InputsSelectStepProps {
invitation: Invitation; invitation: Invitation;

View File

@@ -1,7 +1,8 @@
import type { import type {
HistoryItem, HistoryItem,
HistoryInvitationItem, WalletHistoryInput,
HistoryUtxoItem, WalletHistoryItem,
WalletHistoryOutput,
} from "../services/history.js"; } from "../services/history.js";
export type HistoryColorName = export type HistoryColorName =
@@ -13,10 +14,9 @@ export type HistoryColorName =
| "text"; | "text";
export type HistoryRowType = export type HistoryRowType =
| "invitation" | "history_item"
| "invitation_input" | "history_input"
| "invitation_output" | "history_output";
| "utxo";
export interface HistoryDisplayRow { export interface HistoryDisplayRow {
id: string; id: string;
@@ -25,8 +25,11 @@ export interface HistoryDisplayRow {
description?: string; description?: string;
timestamp?: number; timestamp?: number;
isNested: boolean; isNested: boolean;
utxo?: HistoryUtxoItem; valueSatoshis?: bigint;
invitation?: HistoryInvitationItem; reserved?: boolean;
input?: WalletHistoryInput;
output?: WalletHistoryOutput;
item?: WalletHistoryItem;
} }
export function formatHistoryDate(timestamp?: number): string | undefined { export function formatHistoryDate(timestamp?: number): string | undefined {
@@ -40,61 +43,68 @@ export function buildHistoryDisplayRows(
const rows: HistoryDisplayRow[] = []; const rows: HistoryDisplayRow[] = [];
for (const item of items) { for (const item of items) {
if (item.kind === "invitation") { const roles = item.roles.length > 0 ? item.roles.join(", ") : "unknown";
rows.push({ if (item.source === "utxo") {
id: item.id,
type: "invitation",
label: item.description,
timestamp: item.createdAtTimestamp,
isNested: false,
invitation: item,
});
for (const input of item.inputs) {
const satsPrefix =
input.valueSatoshis !== undefined
? `${input.valueSatoshis.toLocaleString()} sats `
: "";
rows.push({
id: `${item.id}-input-${input.id}`,
type: "invitation_input",
label: `${satsPrefix}${input.outpoint.txid}:${input.outpoint.index}`,
description: input.description,
isNested: true,
utxo: input,
invitation: item,
});
}
for (const output of item.outputs) { for (const output of item.outputs) {
rows.push({ rows.push({
id: `${item.id}-output-${output.id}`, id: `${item.id}-output-${output.id}`,
type: "invitation_output", type: "history_output",
label: label: output.outpoint
output.valueSatoshis !== undefined ? `${output.outpoint.txid}:${output.outpoint.index}`
? `${output.valueSatoshis.toLocaleString()} sats` : output.outputIdentifier ?? "Output",
: "Output", description: `${item.template} | ${roles} | ${output.description}`,
description: output.description, timestamp: item.createdAtTimestamp,
isNested: true, isNested: false,
utxo: output, valueSatoshis: output.valueSatoshis,
invitation: item, reserved: output.reserved,
output,
item,
}); });
} }
continue; continue;
} }
rows.push({ rows.push({
id: item.id, id: item.id,
type: "utxo", type: "history_item",
label: label: `${item.template} | ${roles} | ${item.description}`,
item.valueSatoshis !== undefined description: item.action,
? `${item.valueSatoshis.toLocaleString()} sats` timestamp: item.createdAtTimestamp,
: "UTXO",
description: item.description,
isNested: false, isNested: false,
utxo: item, valueSatoshis: item.valueSatoshis,
item,
}); });
if (item.source !== "invitation") continue;
for (const input of item.inputs) {
rows.push({
id: `${item.id}-input-${input.id}`,
type: "history_input",
label: `${input.outpoint.txid}:${input.outpoint.index}`,
description: input.description,
isNested: true,
valueSatoshis: input.valueSatoshis,
input,
item,
});
}
for (const output of item.outputs) {
rows.push({
id: `${item.id}-output-${output.id}`,
type: "history_output",
label: output.outpoint
? `${output.outpoint.txid}:${output.outpoint.index}`
: output.outputIdentifier ?? "Output",
description: output.description,
isNested: true,
valueSatoshis: output.valueSatoshis,
reserved: output.reserved,
output,
item,
});
}
} }
return rows; return rows;
@@ -106,14 +116,14 @@ export function getHistoryItemColorName(
): HistoryColorName { ): HistoryColorName {
if (isSelected) return "info"; if (isSelected) return "info";
switch (row.type) { switch (row.type) {
case "invitation": case "history_input":
return "text";
case "invitation_input":
return "error"; return "error";
case "invitation_output": case "history_output":
return "success"; return row.reserved ? "warning" : "success";
case "utxo": case "history_item":
return row.utxo?.reserved ? "warning" : "success"; if ((row.valueSatoshis ?? 0n) < 0n) return "error";
if ((row.valueSatoshis ?? 0n) > 0n) return "success";
return "text";
default: default:
return "text"; return "text";
} }

View File

@@ -10,6 +10,7 @@ export interface SelectableUtxoLike {
selected: boolean; selected: boolean;
} }
// TODO: Move to engine
export const hasMissingRequirements = (missingRequirements: { export const hasMissingRequirements = (missingRequirements: {
variables?: string[]; variables?: string[];
inputs?: string[]; inputs?: string[];
@@ -32,6 +33,7 @@ export const isInvitationRequirementsComplete = async (
return !hasMissingRequirements(missingRequirements); return !hasMissingRequirements(missingRequirements);
}; };
// TODO: Move to engine in templates.ts
export const resolveActionRoles = ( export const resolveActionRoles = (
template: XOTemplate | undefined, template: XOTemplate | undefined,
actionIdentifier: string | undefined, actionIdentifier: string | undefined,
@@ -51,6 +53,7 @@ export const resolveActionRoles = (
return [...new Set(roleIds)]; return [...new Set(roleIds)];
}; };
// TODO: Move to engine
export const roleRequiresInputs = ( export const roleRequiresInputs = (
template: XOTemplate | undefined, template: XOTemplate | undefined,
actionIdentifier: string | undefined, actionIdentifier: string | undefined,
@@ -75,6 +78,7 @@ export const roleRequiresInputs = (
return (roleInputs?.length ?? 0) > 0; return (roleInputs?.length ?? 0) > 0;
}; };
export const getTransactionOutputIdentifier = ( export const getTransactionOutputIdentifier = (
output: XOTemplateTransactionOutput, output: XOTemplateTransactionOutput,
): string | undefined => { ): string | undefined => {
@@ -121,6 +125,7 @@ export const tryCashAddressToLockingBytecodeHex = (
return binToHex(result.bytecode); return binToHex(result.bytecode);
}; };
// Replace with libauth compiler in the engine
export const resolveProvidedLockingBytecodeHex = ( export const resolveProvidedLockingBytecodeHex = (
template: XOTemplate, template: XOTemplate,
outputIdentifier: string, outputIdentifier: string,

View File

@@ -1,50 +0,0 @@
export class Logger {
constructor(
private readonly endpoint: string,
private readonly token: string,
private readonly path: string,
) {}
send(
level: "log" | "error" | "warn" | "info",
message: string,
...metadata: unknown[]
) {
const data = {
level,
message: `${this.path}: ${message}`,
metadata,
};
fetch(`${this.endpoint}`, {
method: "POST",
body: JSON.stringify(data),
headers: {
"Content-Type": "application/json",
"x-api-key": this.token,
},
}).catch((error) => {
console.error("Failed to send log to logger:", error);
});
}
log(message: string, ...metadata: unknown[]) {
this.send("log", message, ...metadata);
}
error(message: string, ...metadata: unknown[]) {
this.send("error", message, ...metadata);
}
warn(message: string, ...metadata: unknown[]) {
this.send("warn", message, ...metadata);
}
info(message: string, ...metadata: unknown[]) {
this.send("info", message, ...metadata);
}
child(path: string): Logger {
return new Logger(this.endpoint, this.token, `${this.path}.${path}`);
}
}

View File

@@ -35,10 +35,17 @@ export function getDataDir(): string {
} }
/** /**
* File storing the last-used mnemonic reference for `-m` omission. * File storing CLI settings (JSON), including the last-used mnemonic reference.
*/
export function getSettingsPath(): string {
return join(getConfigDir(), ".wallet");
}
/**
* @deprecated Prefer {@link getSettingsPath}.
*/ */
export function getWalletConfigPath(): string { export function getWalletConfigPath(): string {
return join(getConfigDir(), ".wallet"); return getSettingsPath();
} }
/** /**

View File

@@ -38,19 +38,34 @@ export abstract class BaseRates<
* @returns The formatted amount. * @returns The formatted amount.
*/ */
public formatCurrency(amount: number, targetCurrency: string): string { public formatCurrency(amount: number, targetCurrency: string): string {
const normalizedCurrency = targetCurrency.toUpperCase();
const minimumFractionDigitsMap: { [currency: string]: number } = { const minimumFractionDigitsMap: { [currency: string]: number } = {
AUD: 2, AUD: 2,
BCH: 8, BCH: 8,
USD: 2, USD: 2,
}; };
const minimumFractionDigits = minimumFractionDigitsMap[normalizedCurrency] ?? 2;
const maximumFractionDigits = Math.max(minimumFractionDigits, 8);
const formatter = new Intl.NumberFormat('en-US', { try {
style: 'currency', const formatter = new Intl.NumberFormat('en-US', {
currency: targetCurrency, style: 'currency',
currencyDisplay: 'narrowSymbol', currency: normalizedCurrency,
minimumFractionDigits: minimumFractionDigitsMap[targetCurrency] || 0, currencyDisplay: 'narrowSymbol',
}); minimumFractionDigits,
maximumFractionDigits,
});
return formatter.format(amount); return formatter.format(amount);
} catch {
// Some numerator symbols from oracle pairs (e.g. DOGE/BCH) are not ISO-4217
// fiat currency codes, so Intl currency formatting will throw a RangeError.
// In that case we still return a human-readable formatted value.
const numericFormatter = new Intl.NumberFormat('en-US', {
minimumFractionDigits,
maximumFractionDigits,
});
return `${numericFormatter.format(amount)} ${normalizedCurrency}`;
}
} }
} }

View File

@@ -6,6 +6,8 @@ import {
} from '@generalprotocols/oracle-client'; } from '@generalprotocols/oracle-client';
import { type RatesEventMap, BaseRates } from './base-rates.js'; import { type RatesEventMap, BaseRates } from './base-rates.js';
import { type OffCallback } from '../event-emitter.js';
import { SettingsService } from '../../services/settings.js';
// Add the Oracle Price Message to our Events for this Adapter. // Add the Oracle Price Message to our Events for this Adapter.
export type RatesOracleEventMap = RatesEventMap & { export type RatesOracleEventMap = RatesEventMap & {
@@ -22,22 +24,34 @@ export class RatesOracle extends BaseRates<RatesOracleEventMap> {
* @param client The underlying oracle client. If not provided, a new client will be created. * @param client The underlying oracle client. If not provided, a new client will be created.
* @returns The rates oracle. * @returns The rates oracle.
*/ */
static async from(client?: OracleClient) { static async from(
const ratesOracle = new RatesOracle(client ?? (await OracleClient.from())); client?: OracleClient,
settings: SettingsService = new SettingsService(),
) {
const ratesOracle = new RatesOracle(
client ?? (await OracleClient.from()),
settings,
);
return ratesOracle; return ratesOracle;
} }
private client: OracleClient; private client: OracleClient;
private settings: SettingsService;
private oracles: OracleMetadataMap; private oracles: OracleMetadataMap;
private started: boolean = false; private started: boolean = false;
private targetNumeratorUnitCode: string;
private targetDenominatorUnitCode: string = 'BCH';
private unsubscribeFromSettings: OffCallback | null = null;
private constructor(client: OracleClient) { private constructor(client: OracleClient, settings: SettingsService) {
super(); super();
this.client = client; this.client = client;
this.settings = settings;
this.oracles = {}; this.oracles = {};
this.targetNumeratorUnitCode = settings.getCurrency().toUpperCase();
} }
/** /**
@@ -48,6 +62,10 @@ export class RatesOracle extends BaseRates<RatesOracleEventMap> {
return; return;
} }
this.started = true; this.started = true;
this.unsubscribeFromSettings = this.settings.on(
'settings-updated',
this.handleSettingsUpdated.bind(this),
);
// Create event listeners for the client. // Create event listeners for the client.
this.client.setOnMetadataMessage(this.handleMetadataMessage.bind(this)); this.client.setOnMetadataMessage(this.handleMetadataMessage.bind(this));
@@ -71,6 +89,8 @@ export class RatesOracle extends BaseRates<RatesOracleEventMap> {
return; return;
} }
this.started = false; this.started = false;
this.unsubscribeFromSettings?.();
this.unsubscribeFromSettings = null;
// Remove event listeners by setting them to empty functions. // Remove event listeners by setting them to empty functions.
this.client.setOnMetadataMessage(() => {}); this.client.setOnMetadataMessage(() => {});
@@ -85,6 +105,12 @@ export class RatesOracle extends BaseRates<RatesOracleEventMap> {
* @returns A set of pairs. * @returns A set of pairs.
*/ */
async listPairs() { async listPairs() {
// If metadata has not arrived yet but the client is running, query once so
// callers (like the currency picker) can still discover available pairs.
if (Object.keys(this.oracles).length === 0 && this.started) {
this.oracles = await this.client.getMetadataMap();
}
return new Set( return new Set(
Object.values(this.oracles).map((oracle) => { Object.values(this.oracles).map((oracle) => {
return `${oracle.SOURCE_NUMERATOR_UNIT_CODE}/${oracle.SOURCE_DENOMINATOR_UNIT_CODE}`; return `${oracle.SOURCE_NUMERATOR_UNIT_CODE}/${oracle.SOURCE_DENOMINATOR_UNIT_CODE}`;
@@ -157,14 +183,48 @@ export class RatesOracle extends BaseRates<RatesOracleEventMap> {
return; return;
} }
const sourceNumeratorUnitCode = oracle.SOURCE_NUMERATOR_UNIT_CODE.toUpperCase();
const sourceDenominatorUnitCode = oracle.SOURCE_DENOMINATOR_UNIT_CODE.toUpperCase();
// Only emit the pair currently selected in settings.
if (
sourceNumeratorUnitCode !== this.targetNumeratorUnitCode ||
sourceDenominatorUnitCode !== this.targetDenominatorUnitCode
) {
return;
}
// Scale the price // Scale the price
const priceValue = message.priceValue / oracle.ATTESTATION_SCALING; const priceValue = message.priceValue / oracle.ATTESTATION_SCALING;
this.emit('rateUpdated', { this.emit('rateUpdated', {
numeratorUnitCode: oracle.SOURCE_NUMERATOR_UNIT_CODE, numeratorUnitCode: sourceNumeratorUnitCode,
denominatorUnitCode: oracle.SOURCE_DENOMINATOR_UNIT_CODE, denominatorUnitCode: sourceDenominatorUnitCode,
price: priceValue, price: priceValue,
oraclePriceMessage: message, oraclePriceMessage: message,
}); });
} }
/**
* Tracks updates to settings and switches the actively emitted fiat pair.
*/
private handleSettingsUpdated(
event: {
key: 'currency' | 'default-mnemonic';
value: string | undefined;
},
) {
if (event.key !== 'currency' || !event.value) {
return;
}
this.targetNumeratorUnitCode = event.value.toUpperCase();
// Refresh so listeners get the latest value for the new currency quickly.
if (this.started) {
this.refreshPrices().catch((error) => {
console.error('Error refreshing prices after currency update:', error);
});
}
}
} }

View File

@@ -0,0 +1,83 @@
/// <reference types="node" />
import { afterEach, beforeEach, describe, expect, test } from "vitest";
import { mkdtempSync, readFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { handleSettingsCommand } from "../../../src/cli/commands/settings";
import { CommandError } from "../../../src/cli/commands/types";
import { createMockIO, createMockPaths } from "../mocks/command";
describe("settings command", () => {
let tempDir: string;
beforeEach(() => {
tempDir = mkdtempSync(path.join(tmpdir(), "xo-cli-settings-command-test-"));
});
afterEach(() => {
rmSync(tempDir, { recursive: true, force: true });
});
test("shows default settings when .wallet does not exist", async () => {
const { io, capture } = createMockIO();
const paths = createMockPaths(tempDir);
const result = await handleSettingsCommand({ io, paths }, ["show"], {});
expect(result).toEqual({ currency: "USD" });
expect(capture.out.join("\n")).toContain("currency");
expect(capture.out.join("\n")).toContain("USD");
});
test("sets and gets currency", async () => {
const { io, capture } = createMockIO();
const paths = createMockPaths(tempDir);
await handleSettingsCommand({ io, paths }, ["set", "currency", "aud"], {});
const getResult = await handleSettingsCommand(
{ io, paths },
["get", "currency"],
{},
);
expect(getResult).toEqual({ key: "currency", value: "AUD" });
expect(capture.out).toContain("Updated currency: AUD");
expect(capture.out).toContain("AUD");
});
test("sets default-mnemonic and persists JSON .wallet", async () => {
const { io } = createMockIO();
const paths = createMockPaths(tempDir);
await handleSettingsCommand(
{ io, paths },
["set", "default-mnemonic", "mnemonic-primary"],
{},
);
const persisted = JSON.parse(readFileSync(paths.walletConfigPath, "utf8")) as {
currency: string;
"default-mnemonic"?: string;
};
expect(persisted).toEqual({
currency: "USD",
"default-mnemonic": "mnemonic-primary",
});
});
test("throws command error for unknown subcommand", async () => {
const { io } = createMockIO();
const paths = createMockPaths(tempDir);
try {
await handleSettingsCommand({ io, paths }, ["unknown"], {});
expect.fail("Expected settings command to throw");
} catch (error) {
expect(error).toBeInstanceOf(CommandError);
expect((error as CommandError).event).toBe("settings.subcommand.unknown");
}
});
});

View File

@@ -19,8 +19,7 @@ import {
import { BCHMnemonicURL } from "../../src/utils/bch-mnemonic-url"; import { BCHMnemonicURL } from "../../src/utils/bch-mnemonic-url";
const TEST_SEED = const TEST_SEED =
"page pencil stock planet limb cluster assault speak off joke private pioneer"; "oven crop same above under tower promote decrease vocal pretty require slow";
describe("mnemonic utilities", () => { describe("mnemonic utilities", () => {
let tempDir: string; let tempDir: string;
@@ -54,7 +53,7 @@ describe("mnemonic utilities", () => {
test("creates a mnemonic file with auto-generated name", () => { test("creates a mnemonic file with auto-generated name", () => {
const filename = createMnemonicFile(tempDir, TEST_SEED); const filename = createMnemonicFile(tempDir, TEST_SEED);
expect(filename).toMatch(/^mnemonic-page$/); expect(filename).toMatch(/^mnemonic-oven$/);
expect(existsSync(path.join(tempDir, filename))).toBe(true); expect(existsSync(path.join(tempDir, filename))).toBe(true);
}); });

View File

@@ -18,7 +18,7 @@ import { MockRatesService } from "./rates-service";
import { RatesService } from "../../../src/services/rates"; import { RatesService } from "../../../src/services/rates";
export const DEFAULT_SEED = export const DEFAULT_SEED =
"page pencil stock planet limb cluster assault speak off joke private pioneer"; "oven crop same above under tower promote decrease vocal pretty require slow";
/** /**
* Options for creating a fake resource (UTXO) in tests. * Options for creating a fake resource (UTXO) in tests.

View File

@@ -0,0 +1,40 @@
/// <reference types="node" />
import { describe, expect, test } from "vitest";
import { BaseRates } from "../../src/utils/rates/base-rates";
/**
* Minimal concrete adapter used only for testing BaseRates helpers.
*/
class TestRatesAdapter extends BaseRates {
public async start(): Promise<void> {
return;
}
public async stop(): Promise<void> {
return;
}
public async listPairs(): Promise<Set<string>> {
return new Set(["USD/BCH", "DOGE/BCH"]);
}
}
describe("BaseRates.formatCurrency", () => {
test("formats ISO currency codes with Intl currency style", () => {
const rates = new TestRatesAdapter();
const formatted = rates.formatCurrency(12.5, "USD");
expect(formatted).toContain("$");
expect(formatted).toContain("12.50");
});
test("formats non-ISO symbols without throwing", () => {
const rates = new TestRatesAdapter();
const formatted = rates.formatCurrency(12.3456789, "DOGE");
expect(formatted).toContain("DOGE");
expect(formatted).toContain("12.3456789");
});
});

View File

@@ -0,0 +1,96 @@
/// <reference types="node" />
import { beforeEach, afterEach, describe, expect, test } from "vitest";
import {
existsSync,
mkdtempSync,
readFileSync,
rmSync,
writeFileSync,
} from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { SettingsService } from "../../src/services/settings";
/**
* Tests for SettingsService persistence and migration behavior.
*/
describe("SettingsService", () => {
let testDir: string;
let settingsPath: string;
beforeEach(() => {
testDir = mkdtempSync(join(tmpdir(), "xo-cli-settings-test-"));
settingsPath = join(testDir, ".wallet");
});
afterEach(() => {
rmSync(testDir, { recursive: true, force: true });
});
test("returns defaults when settings file does not exist", () => {
const settings = new SettingsService(settingsPath);
expect(settings.getDefaultMnemonic()).toBeUndefined();
expect(settings.getCurrency()).toBe("USD");
expect(settings.getSettings()).toEqual({ currency: "USD" });
expect(existsSync(settingsPath)).toBe(false);
});
test("migrates legacy .wallet plaintext mnemonic to JSON", () => {
writeFileSync(settingsPath, "mnemonic-legacy", "utf8");
const settings = new SettingsService(settingsPath);
expect(settings.getDefaultMnemonic()).toBe("mnemonic-legacy");
expect(settings.getCurrency()).toBe("USD");
const persisted = JSON.parse(readFileSync(settingsPath, "utf8")) as {
"default-mnemonic"?: string;
currency: string;
};
expect(persisted).toEqual({
"default-mnemonic": "mnemonic-legacy",
currency: "USD",
});
});
test("normalizes and persists currency and default-mnemonic", () => {
const settings = new SettingsService(settingsPath);
settings.setDefaultMnemonic(" mnemonic-primary ");
settings.setCurrency("aud");
expect(settings.getSettings()).toEqual({
"default-mnemonic": "mnemonic-primary",
currency: "AUD",
});
const persisted = JSON.parse(readFileSync(settingsPath, "utf8")) as {
"default-mnemonic"?: string;
currency: string;
};
expect(persisted).toEqual({
"default-mnemonic": "mnemonic-primary",
currency: "AUD",
});
});
test("emits settings-updated events on setting changes", () => {
const settings = new SettingsService(settingsPath);
const events: Array<{ key: string; value: string | undefined }> = [];
settings.on("settings-updated", (event) => {
events.push({ key: event.key, value: event.value });
});
settings.setCurrency("cad");
settings.setDefaultMnemonic("mnemonic-blue");
expect(events).toEqual([
{ key: "currency", value: "CAD" },
{ key: "default-mnemonic", value: "mnemonic-blue" },
]);
});
});