Compare commits
7 Commits
c2334b2cdd
...
use-flatma
| Author | SHA1 | Date | |
|---|---|---|---|
|
941414b3ee
|
|||
|
e9bc6186b9
|
|||
|
b2ccff5b19
|
|||
| b4d82b8b1f | |||
| a0d9775015 | |||
| 6c01ac1c1b | |||
| ebe1d8acda |
40
package-lock.json
generated
40
package-lock.json
generated
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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 shell’s current directory:** template JSON paths, invitation JSON you create/import, and any path you pass explicitly (e.g. `-m /abs/path/to/file`).
|
**Local to your shell’s current directory:** template JSON paths, invitation JSON you create/import, and any path you pass explicitly (e.g. `-m /abs/path/to/file`).
|
||||||
|
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -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`.
|
||||||
|
|||||||
@@ -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)'
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
131
src/cli/commands/settings.ts
Normal file
131
src/cli/commands/settings.ts
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
import { SettingsService } from "../../services/settings.js";
|
||||||
|
import { formatObject } from "../utils.js";
|
||||||
|
import type { BaseCommandDependencies, CommandIO } from "./types.js";
|
||||||
|
import { CommandError } from "./types.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prints help text for the settings command.
|
||||||
|
*/
|
||||||
|
export const printSettingsHelp = (io: CommandIO): void => {
|
||||||
|
io.out(`Settings Command Help:
|
||||||
|
Commands:
|
||||||
|
settings show
|
||||||
|
settings get <currency|default-mnemonic>
|
||||||
|
settings set <currency|default-mnemonic> <value>
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
xo-cli settings show
|
||||||
|
xo-cli settings get currency
|
||||||
|
xo-cli settings set currency AUD
|
||||||
|
xo-cli settings set default-mnemonic mnemonic-main`);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Supported settings keys exposed on the CLI.
|
||||||
|
*/
|
||||||
|
type SettingsKey = "currency" | "default-mnemonic";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalizes user input to one of the supported settings keys.
|
||||||
|
*/
|
||||||
|
function parseSettingsKey(input: string | undefined): SettingsKey | null {
|
||||||
|
if (!input) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalized = input.trim().toLowerCase();
|
||||||
|
if (normalized === "currency") {
|
||||||
|
return "currency";
|
||||||
|
}
|
||||||
|
if (normalized === "default-mnemonic" || normalized === "defaultMnemonic") {
|
||||||
|
return "default-mnemonic";
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles `xo-cli settings` commands.
|
||||||
|
*
|
||||||
|
* This command intentionally does not require wallet initialization so users can
|
||||||
|
* configure currency/default mnemonic without passing `-m`.
|
||||||
|
*/
|
||||||
|
export const handleSettingsCommand = async (
|
||||||
|
deps: BaseCommandDependencies,
|
||||||
|
args: string[],
|
||||||
|
options: Record<string, string>,
|
||||||
|
): Promise<Record<string, unknown>> => {
|
||||||
|
const settings = new SettingsService(deps.paths.walletConfigPath);
|
||||||
|
|
||||||
|
// settings show (default if no subcommand)
|
||||||
|
const subCommand = args[0] ?? "show";
|
||||||
|
if (subCommand === "help" || options["help"] === "true") {
|
||||||
|
printSettingsHelp(deps.io);
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (subCommand) {
|
||||||
|
case "show": {
|
||||||
|
const snapshot = settings.getSettings();
|
||||||
|
deps.io.out(formatObject(snapshot));
|
||||||
|
return snapshot;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "get": {
|
||||||
|
const key = parseSettingsKey(args[1]);
|
||||||
|
if (!key) {
|
||||||
|
printSettingsHelp(deps.io);
|
||||||
|
throw new CommandError(
|
||||||
|
"settings.get.key_missing",
|
||||||
|
'Missing or invalid key. Expected "currency" or "default-mnemonic".',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const value =
|
||||||
|
key === "currency"
|
||||||
|
? settings.getCurrency()
|
||||||
|
: settings.getDefaultMnemonic() ?? "";
|
||||||
|
deps.io.out(value);
|
||||||
|
return { key, value };
|
||||||
|
}
|
||||||
|
|
||||||
|
case "set": {
|
||||||
|
const key = parseSettingsKey(args[1]);
|
||||||
|
if (!key) {
|
||||||
|
printSettingsHelp(deps.io);
|
||||||
|
throw new CommandError(
|
||||||
|
"settings.set.key_missing",
|
||||||
|
'Missing or invalid key. Expected "currency" or "default-mnemonic".',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawValue = args.slice(2).join(" ").trim();
|
||||||
|
if (!rawValue) {
|
||||||
|
printSettingsHelp(deps.io);
|
||||||
|
throw new CommandError(
|
||||||
|
"settings.set.value_missing",
|
||||||
|
"Missing value for settings set command.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (key === "currency") {
|
||||||
|
settings.setCurrency(rawValue);
|
||||||
|
const currency = settings.getCurrency();
|
||||||
|
deps.io.out(`Updated currency: ${currency}`);
|
||||||
|
return { key, value: currency };
|
||||||
|
}
|
||||||
|
|
||||||
|
settings.setDefaultMnemonic(rawValue);
|
||||||
|
const defaultMnemonic = settings.getDefaultMnemonic() ?? "";
|
||||||
|
deps.io.out(`Updated default-mnemonic: ${defaultMnemonic}`);
|
||||||
|
return { key, value: defaultMnemonic };
|
||||||
|
}
|
||||||
|
|
||||||
|
default: {
|
||||||
|
printSettingsHelp(deps.io);
|
||||||
|
throw new CommandError(
|
||||||
|
"settings.subcommand.unknown",
|
||||||
|
`Unknown settings command: ${subCommand}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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 {};
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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.flatMap(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.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();
|
||||||
|
|||||||
@@ -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
194
src/services/settings.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
188
src/tui/components/CurrencySelectionDialog.tsx
Normal file
188
src/tui/components/CurrencySelectionDialog.tsx
Normal file
@@ -0,0 +1,188 @@
|
|||||||
|
import React, { useEffect, useId, useMemo, useState } from "react";
|
||||||
|
import { Box, Text } from "ink";
|
||||||
|
|
||||||
|
import { ScrollableList, type ListItemData } from "./List.js";
|
||||||
|
import TextInput from "./TextInput.js";
|
||||||
|
import { DialogWrapper } from "./Dialog.js";
|
||||||
|
import { useInputLayer, useLayeredInput } from "../hooks/useInputLayer.js";
|
||||||
|
import { colors } from "../theme.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Props for the currency selection dialog.
|
||||||
|
*/
|
||||||
|
interface CurrencySelectionDialogProps {
|
||||||
|
/** Current wallet currency from persisted settings. */
|
||||||
|
currentCurrency: string;
|
||||||
|
/** Available fiat numerator symbols that can be paired with BCH. */
|
||||||
|
currencies: string[];
|
||||||
|
/** True while the dialog is loading available pairs. */
|
||||||
|
isLoading: boolean;
|
||||||
|
/** Optional loading/error message for pair discovery. */
|
||||||
|
errorMessage: string | null;
|
||||||
|
/** Called when the user chooses a currency and confirms. */
|
||||||
|
onSelectCurrency: (currencyCode: string) => void;
|
||||||
|
/** Called when the dialog should close without applying changes. */
|
||||||
|
onCancel: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Currency picker dialog.
|
||||||
|
*
|
||||||
|
* UX requirements:
|
||||||
|
* - Arrow keys move the highlighted item.
|
||||||
|
* - Typing immediately filters results.
|
||||||
|
* - Enter applies current selection.
|
||||||
|
* - Escape closes without saving.
|
||||||
|
*/
|
||||||
|
export function CurrencySelectionDialog({
|
||||||
|
currentCurrency,
|
||||||
|
currencies,
|
||||||
|
isLoading,
|
||||||
|
errorMessage,
|
||||||
|
onSelectCurrency,
|
||||||
|
onCancel,
|
||||||
|
}: CurrencySelectionDialogProps): React.ReactElement {
|
||||||
|
const layerId = useId();
|
||||||
|
const [filterText, setFilterText] = useState("");
|
||||||
|
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||||
|
|
||||||
|
// Mount this as a capturing input layer so background screens stop handling keys.
|
||||||
|
useInputLayer(layerId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Applies the currently selected filtered result.
|
||||||
|
*/
|
||||||
|
const applySelection = (): void => {
|
||||||
|
const selectedCurrency = filteredCurrencies[selectedIndex];
|
||||||
|
if (!selectedCurrency) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onSelectCurrency(selectedCurrency);
|
||||||
|
};
|
||||||
|
|
||||||
|
useLayeredInput(layerId, (_input, key) => {
|
||||||
|
if (key.escape) {
|
||||||
|
onCancel();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (key.upArrow) {
|
||||||
|
setSelectedIndex((prev) =>
|
||||||
|
prev <= 0 ? Math.max(filteredCurrencies.length - 1, 0) : prev - 1,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (key.downArrow) {
|
||||||
|
setSelectedIndex((prev) =>
|
||||||
|
filteredCurrencies.length === 0
|
||||||
|
? 0
|
||||||
|
: prev >= filteredCurrencies.length - 1
|
||||||
|
? 0
|
||||||
|
: prev + 1,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filter currencies as the user types.
|
||||||
|
*/
|
||||||
|
const filteredCurrencies = useMemo(() => {
|
||||||
|
const normalizedFilter = filterText.trim().toUpperCase();
|
||||||
|
if (!normalizedFilter) {
|
||||||
|
return currencies;
|
||||||
|
}
|
||||||
|
return currencies.filter((currencyCode) =>
|
||||||
|
currencyCode.toUpperCase().includes(normalizedFilter),
|
||||||
|
);
|
||||||
|
}, [currencies, filterText]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keep selected index valid whenever filtering shrinks the result set.
|
||||||
|
*/
|
||||||
|
useEffect(() => {
|
||||||
|
if (filteredCurrencies.length === 0) {
|
||||||
|
setSelectedIndex(0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedIndex >= filteredCurrencies.length) {
|
||||||
|
setSelectedIndex(filteredCurrencies.length - 1);
|
||||||
|
}
|
||||||
|
}, [filteredCurrencies, selectedIndex]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* When the dialog opens or the list updates, default to current currency.
|
||||||
|
*/
|
||||||
|
useEffect(() => {
|
||||||
|
if (filterText.trim().length > 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentIndex = filteredCurrencies.findIndex(
|
||||||
|
(currencyCode) => currencyCode.toUpperCase() === currentCurrency.toUpperCase(),
|
||||||
|
);
|
||||||
|
if (currentIndex >= 0) {
|
||||||
|
setSelectedIndex(currentIndex);
|
||||||
|
}
|
||||||
|
}, [filteredCurrencies, currentCurrency, filterText]);
|
||||||
|
|
||||||
|
const listItems: ListItemData<string>[] = filteredCurrencies.map(
|
||||||
|
(currencyCode) => ({
|
||||||
|
key: currencyCode,
|
||||||
|
label: currencyCode,
|
||||||
|
description:
|
||||||
|
currencyCode.toUpperCase() === currentCurrency.toUpperCase()
|
||||||
|
? "(current)"
|
||||||
|
: undefined,
|
||||||
|
value: currencyCode,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DialogWrapper title="Select Fiat Currency" borderColor={colors.info} width={64}>
|
||||||
|
<Text color={colors.textMuted}>
|
||||||
|
Available BCH quote pairs are loaded from the live rates adapter.
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Box marginTop={1}>
|
||||||
|
<Text color={colors.primary}>Filter:</Text>
|
||||||
|
</Box>
|
||||||
|
<Box borderStyle="single" borderColor={colors.focus} paddingX={1}>
|
||||||
|
<TextInput
|
||||||
|
value={filterText}
|
||||||
|
onChange={setFilterText}
|
||||||
|
onSubmit={() => applySelection()}
|
||||||
|
placeholder="Type currency code (e.g. USD, AUD)..."
|
||||||
|
focus
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box marginTop={1} flexDirection="column">
|
||||||
|
{isLoading ? (
|
||||||
|
<Text color={colors.textMuted}>Loading available pairs...</Text>
|
||||||
|
) : errorMessage ? (
|
||||||
|
<Text color={colors.error}>{errorMessage}</Text>
|
||||||
|
) : (
|
||||||
|
<ScrollableList
|
||||||
|
items={listItems}
|
||||||
|
selectedIndex={selectedIndex}
|
||||||
|
onSelect={setSelectedIndex}
|
||||||
|
onActivate={() => applySelection()}
|
||||||
|
focus={false}
|
||||||
|
maxVisible={8}
|
||||||
|
emptyMessage="No BCH quote pairs match this filter."
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box marginTop={1}>
|
||||||
|
<Text color={colors.textMuted}>
|
||||||
|
Type to filter • ↑↓ navigate • Enter apply • Esc cancel
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
</DialogWrapper>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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],
|
||||||
|
|||||||
@@ -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(() => {
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -536,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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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];
|
||||||
|
|||||||
@@ -0,0 +1,113 @@
|
|||||||
|
/**
|
||||||
|
* VariablesStep — collects all required variable values for invitation import.
|
||||||
|
*
|
||||||
|
* This runs after role selection and before input selection so cashasm
|
||||||
|
* expressions can resolve required variables during `getSatsOut()`.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useMemo, useState, useCallback } from "react";
|
||||||
|
import { Box, Text } from "ink";
|
||||||
|
import { colors } from "../../../../theme.js";
|
||||||
|
import { useLayeredInput } from "../../../../hooks/useInputLayer.js";
|
||||||
|
import { VariableInputField } from "../../../../components/VariableInputField.js";
|
||||||
|
import type { VariablesStepProps } from "../types.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a user-facing validation error for empty required fields.
|
||||||
|
*/
|
||||||
|
function validateVariables(
|
||||||
|
variables: VariablesStepProps["variables"],
|
||||||
|
): string | null {
|
||||||
|
const empty = variables.filter((v) => v.value.trim() === "");
|
||||||
|
if (empty.length === 0) return null;
|
||||||
|
return `Please enter values for: ${empty.map((v) => v.name).join(", ")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function VariablesStep({
|
||||||
|
variables,
|
||||||
|
onUpdateVariable,
|
||||||
|
onComplete,
|
||||||
|
onCancel,
|
||||||
|
isActive,
|
||||||
|
}: VariablesStepProps): React.ReactElement {
|
||||||
|
const [focusedInput, setFocusedInput] = useState(0);
|
||||||
|
const [validationError, setValidationError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const helpText = useMemo(() => {
|
||||||
|
if (variables.length === 0) {
|
||||||
|
return "No variables required for this role.";
|
||||||
|
}
|
||||||
|
return "Enter a value for each variable, then press Enter on the last field to continue.";
|
||||||
|
}, [variables.length]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Move focus to next input, or finish the step if this is the last one.
|
||||||
|
*/
|
||||||
|
const handleInputSubmit = useCallback(() => {
|
||||||
|
if (variables.length === 0) {
|
||||||
|
onComplete();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (focusedInput < variables.length - 1) {
|
||||||
|
setFocusedInput((prev) => prev + 1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const validation = validateVariables(variables);
|
||||||
|
setValidationError(validation);
|
||||||
|
if (!validation) {
|
||||||
|
onComplete();
|
||||||
|
}
|
||||||
|
}, [variables, focusedInput, onComplete]);
|
||||||
|
|
||||||
|
// Keyboard navigation for non-text actions.
|
||||||
|
useLayeredInput(
|
||||||
|
"import-flow",
|
||||||
|
(input, key) => {
|
||||||
|
if (key.upArrow || input === "k") {
|
||||||
|
setFocusedInput((prev) => Math.max(0, prev - 1));
|
||||||
|
} else if (key.downArrow || input === "j") {
|
||||||
|
setFocusedInput((prev) => Math.min(variables.length - 1, prev + 1));
|
||||||
|
} else if (key.escape) {
|
||||||
|
onCancel();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ isActive },
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box flexDirection="column">
|
||||||
|
<Text color={colors.primary} bold>
|
||||||
|
Required Variables
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Box marginTop={1} flexDirection="column">
|
||||||
|
{variables.map((variable, index) => (
|
||||||
|
<VariableInputField
|
||||||
|
key={variable.id}
|
||||||
|
variable={variable}
|
||||||
|
index={index}
|
||||||
|
isFocused={focusedInput === index}
|
||||||
|
onChange={onUpdateVariable}
|
||||||
|
onSubmit={handleInputSubmit}
|
||||||
|
borderColor={colors.border as string}
|
||||||
|
focusColor={colors.primary as string}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{validationError && (
|
||||||
|
<Box marginTop={1}>
|
||||||
|
<Text color={colors.error}>{validationError}</Text>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Box marginTop={1}>
|
||||||
|
<Text color={colors.textMuted}>
|
||||||
|
{helpText} ↑↓: Change field • Esc: Cancel
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -16,6 +16,7 @@ export type ImportStepType =
|
|||||||
| "fetch"
|
| "fetch"
|
||||||
| "preview"
|
| "preview"
|
||||||
| "role-select"
|
| "role-select"
|
||||||
|
| "variables"
|
||||||
| "inputs-select"
|
| "inputs-select"
|
||||||
| "review";
|
| "review";
|
||||||
|
|
||||||
@@ -30,6 +31,7 @@ export const IMPORT_STEPS: ImportStep[] = [
|
|||||||
{ name: "Fetch", type: "fetch" },
|
{ name: "Fetch", type: "fetch" },
|
||||||
{ name: "Preview", type: "preview" },
|
{ name: "Preview", type: "preview" },
|
||||||
{ name: "Select Role", type: "role-select" },
|
{ name: "Select Role", type: "role-select" },
|
||||||
|
{ name: "Variables", type: "variables" },
|
||||||
{ name: "Select Inputs", type: "inputs-select" },
|
{ name: "Select Inputs", type: "inputs-select" },
|
||||||
{ name: "Review", type: "review" },
|
{ name: "Review", type: "review" },
|
||||||
];
|
];
|
||||||
@@ -81,6 +83,24 @@ export interface RoleSelectStepProps {
|
|||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A single variable input required by the selected action role. */
|
||||||
|
export interface ImportVariableInput {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
type: string;
|
||||||
|
hint?: string;
|
||||||
|
value: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Props for VariablesStep — collects required role/action variable values. */
|
||||||
|
export interface VariablesStepProps {
|
||||||
|
variables: ImportVariableInput[];
|
||||||
|
onUpdateVariable: (index: number, value: string) => void;
|
||||||
|
onComplete: () => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
isActive: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
/** Props for InputsSelectStep — lets user pick UTXOs to fund the invitation. */
|
/** Props for InputsSelectStep — lets user pick UTXOs to fund the invitation. */
|
||||||
export interface InputsSelectStepProps {
|
export interface InputsSelectStepProps {
|
||||||
invitation: Invitation;
|
invitation: Invitation;
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import type { Invitation } from "../services/invitation.js";
|
import type { Invitation } from "../services/invitation.js";
|
||||||
import type { XOTemplate } from "@xo-cash/types";
|
import type { XOInvitationCommit, XOTemplate } from "@xo-cash/types";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Color names for invitation states.
|
* Color names for invitation states.
|
||||||
@@ -249,9 +249,9 @@ export function formatInvitationId(id: string, maxLength: number = 16): string {
|
|||||||
* @param invitation - The invitation to check
|
* @param invitation - The invitation to check
|
||||||
* @returns Array of unique entity identifiers
|
* @returns Array of unique entity identifiers
|
||||||
*/
|
*/
|
||||||
export function getInvitationParticipants(invitation: Invitation): string[] {
|
export function getInvitationParticipants(commits: Array<XOInvitationCommit>): string[] {
|
||||||
const participants = new Set<string>();
|
const participants = new Set<string>();
|
||||||
for (const commit of invitation.data.commits || []) {
|
for (const commit of commits) {
|
||||||
if (commit.entityIdentifier) {
|
if (commit.entityIdentifier) {
|
||||||
participants.add(commit.entityIdentifier);
|
participants.add(commit.entityIdentifier);
|
||||||
}
|
}
|
||||||
@@ -267,9 +267,14 @@ export function getInvitationParticipants(invitation: Invitation): string[] {
|
|||||||
* @returns True if the user has made at least one commit
|
* @returns True if the user has made at least one commit
|
||||||
*/
|
*/
|
||||||
export function isUserParticipant(
|
export function isUserParticipant(
|
||||||
invitation: Invitation,
|
invitation: Invitation | Array<XOInvitationCommit>,
|
||||||
userEntityId: string | null,
|
userEntityId: string | null,
|
||||||
): boolean {
|
): boolean {
|
||||||
if (!userEntityId) return false;
|
if (!userEntityId) return false;
|
||||||
return getInvitationParticipants(invitation).includes(userEntityId);
|
|
||||||
|
if (Array.isArray(invitation)) {
|
||||||
|
return invitation.some(commit => commit.entityIdentifier === userEntityId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return getInvitationParticipants(invitation.data.commits).includes(userEntityId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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}`;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
83
tests/cli/commands/settings.test.ts
Normal file
83
tests/cli/commands/settings.test.ts
Normal 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");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
40
tests/cli/rates-format.test.ts
Normal file
40
tests/cli/rates-format.test.ts
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
/// <reference types="node" />
|
||||||
|
|
||||||
|
import { describe, expect, test } from "vitest";
|
||||||
|
|
||||||
|
import { BaseRates } from "../../src/utils/rates/base-rates";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Minimal concrete adapter used only for testing BaseRates helpers.
|
||||||
|
*/
|
||||||
|
class TestRatesAdapter extends BaseRates {
|
||||||
|
public async start(): Promise<void> {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async stop(): Promise<void> {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async listPairs(): Promise<Set<string>> {
|
||||||
|
return new Set(["USD/BCH", "DOGE/BCH"]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("BaseRates.formatCurrency", () => {
|
||||||
|
test("formats ISO currency codes with Intl currency style", () => {
|
||||||
|
const rates = new TestRatesAdapter();
|
||||||
|
const formatted = rates.formatCurrency(12.5, "USD");
|
||||||
|
|
||||||
|
expect(formatted).toContain("$");
|
||||||
|
expect(formatted).toContain("12.50");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("formats non-ISO symbols without throwing", () => {
|
||||||
|
const rates = new TestRatesAdapter();
|
||||||
|
const formatted = rates.formatCurrency(12.3456789, "DOGE");
|
||||||
|
|
||||||
|
expect(formatted).toContain("DOGE");
|
||||||
|
expect(formatted).toContain("12.3456789");
|
||||||
|
});
|
||||||
|
});
|
||||||
96
tests/cli/settings.test.ts
Normal file
96
tests/cli/settings.test.ts
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
/// <reference types="node" />
|
||||||
|
|
||||||
|
import { beforeEach, afterEach, describe, expect, test } from "vitest";
|
||||||
|
import {
|
||||||
|
existsSync,
|
||||||
|
mkdtempSync,
|
||||||
|
readFileSync,
|
||||||
|
rmSync,
|
||||||
|
writeFileSync,
|
||||||
|
} from "node:fs";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
|
||||||
|
import { SettingsService } from "../../src/services/settings";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests for SettingsService persistence and migration behavior.
|
||||||
|
*/
|
||||||
|
describe("SettingsService", () => {
|
||||||
|
let testDir: string;
|
||||||
|
let settingsPath: string;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
testDir = mkdtempSync(join(tmpdir(), "xo-cli-settings-test-"));
|
||||||
|
settingsPath = join(testDir, ".wallet");
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
rmSync(testDir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns defaults when settings file does not exist", () => {
|
||||||
|
const settings = new SettingsService(settingsPath);
|
||||||
|
|
||||||
|
expect(settings.getDefaultMnemonic()).toBeUndefined();
|
||||||
|
expect(settings.getCurrency()).toBe("USD");
|
||||||
|
expect(settings.getSettings()).toEqual({ currency: "USD" });
|
||||||
|
expect(existsSync(settingsPath)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("migrates legacy .wallet plaintext mnemonic to JSON", () => {
|
||||||
|
writeFileSync(settingsPath, "mnemonic-legacy", "utf8");
|
||||||
|
|
||||||
|
const settings = new SettingsService(settingsPath);
|
||||||
|
|
||||||
|
expect(settings.getDefaultMnemonic()).toBe("mnemonic-legacy");
|
||||||
|
expect(settings.getCurrency()).toBe("USD");
|
||||||
|
|
||||||
|
const persisted = JSON.parse(readFileSync(settingsPath, "utf8")) as {
|
||||||
|
"default-mnemonic"?: string;
|
||||||
|
currency: string;
|
||||||
|
};
|
||||||
|
expect(persisted).toEqual({
|
||||||
|
"default-mnemonic": "mnemonic-legacy",
|
||||||
|
currency: "USD",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("normalizes and persists currency and default-mnemonic", () => {
|
||||||
|
const settings = new SettingsService(settingsPath);
|
||||||
|
|
||||||
|
settings.setDefaultMnemonic(" mnemonic-primary ");
|
||||||
|
settings.setCurrency("aud");
|
||||||
|
|
||||||
|
expect(settings.getSettings()).toEqual({
|
||||||
|
"default-mnemonic": "mnemonic-primary",
|
||||||
|
currency: "AUD",
|
||||||
|
});
|
||||||
|
|
||||||
|
const persisted = JSON.parse(readFileSync(settingsPath, "utf8")) as {
|
||||||
|
"default-mnemonic"?: string;
|
||||||
|
currency: string;
|
||||||
|
};
|
||||||
|
expect(persisted).toEqual({
|
||||||
|
"default-mnemonic": "mnemonic-primary",
|
||||||
|
currency: "AUD",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("emits settings-updated events on setting changes", () => {
|
||||||
|
const settings = new SettingsService(settingsPath);
|
||||||
|
const events: Array<{ key: string; value: string | undefined }> = [];
|
||||||
|
|
||||||
|
settings.on("settings-updated", (event) => {
|
||||||
|
events.push({ key: event.key, value: event.value });
|
||||||
|
});
|
||||||
|
|
||||||
|
settings.setCurrency("cad");
|
||||||
|
settings.setDefaultMnemonic("mnemonic-blue");
|
||||||
|
|
||||||
|
expect(events).toEqual([
|
||||||
|
{ key: "currency", value: "CAD" },
|
||||||
|
{ key: "default-mnemonic", value: "mnemonic-blue" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user