4 Commits

36 changed files with 1478 additions and 99 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

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

@@ -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`).
@@ -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

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

@@ -37,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 */
@@ -56,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"];
@@ -65,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;
@@ -77,6 +81,7 @@ const GLOBAL_OPTIONS = [
"--verbose", "--verbose",
"-m", "-m",
"--mnemonic-file", "--mnemonic-file",
"--currency",
"-o", "-o",
"--output", "--output",
]; ];

View File

@@ -201,6 +201,17 @@ _{{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] # receive <template> [output]
# Template is the first positional argument after `receive`. # Template is the first positional argument after `receive`.

View File

@@ -35,6 +35,7 @@ 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 completion for `-m/--mnemonic-file`. # 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)'

View File

@@ -180,6 +180,17 @@ _{{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] # receive <template> [output]
local pos=$((CURRENT - cmd_idx)) local pos=$((CURRENT - cmd_idx))

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

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

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

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

View File

@@ -96,6 +96,12 @@ export class HistoryService {
private invitations: Invitation[], private invitations: Invitation[],
) {} ) {}
/**
* I Might swap this over to invitation based history before the event to make it a bit more evident... Really not happy with the UTXO for demo purposes
* But for the actual usage, UTXO is easier to follow - just not good for demo
* Long term, this is intended to be in the Engine, so we will just be a consumer of history state.
*/
async getHistory(): Promise<WalletHistoryItem[]> { async getHistory(): Promise<WalletHistoryItem[]> {
const allUtxos = await this.engine.listUnspentOutputsData(); const allUtxos = await this.engine.listUnspentOutputsData();
const metadataIndex = await this.buildWalletMetadataIndex(allUtxos); const metadataIndex = await this.buildWalletMetadataIndex(allUtxos);

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

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

@@ -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';
@@ -60,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' },
]; ];
@@ -121,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);
@@ -133,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.
@@ -260,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.
*/ */
@@ -277,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;
@@ -284,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.
@@ -543,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

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

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

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

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