Add currency settings, Settings service, and dialog to select fiat currency. Add support for non Official currencies like DOGE when using rates.
This commit is contained in:
@@ -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");
|
||||||
@@ -110,9 +116,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 +127,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 +136,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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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(() => {
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import React, { useState, useEffect, useCallback, useMemo } from 'react';
|
|||||||
import { Box, Text } from 'ink';
|
import { Box, Text } from 'ink';
|
||||||
import { ScrollableList, type ListItemData } from '../components/List.js';
|
import { ScrollableList, type ListItemData } from '../components/List.js';
|
||||||
import { QRCode } from '../components/QRCode.js';
|
import { QRCode } from '../components/QRCode.js';
|
||||||
|
import { CurrencySelectionDialog } from '../components/CurrencySelectionDialog.js';
|
||||||
import { useNavigation } from '../hooks/useNavigation.js';
|
import { useNavigation } from '../hooks/useNavigation.js';
|
||||||
import { useAppContext, useStatus } from '../hooks/useAppContext.js';
|
import { useAppContext, useStatus } from '../hooks/useAppContext.js';
|
||||||
import { useSatoshisConversion } from '../hooks/useSatoshisConversion.js';
|
import { useSatoshisConversion } from '../hooks/useSatoshisConversion.js';
|
||||||
@@ -60,6 +61,7 @@ const menuItems: ListItemData<string>[] = [
|
|||||||
{ key: 'import', label: 'Import Invitation', value: 'import' },
|
{ key: 'import', label: 'Import Invitation', value: 'import' },
|
||||||
{ key: 'invitations', label: 'View Invitations', value: 'invitations' },
|
{ key: 'invitations', label: 'View Invitations', value: 'invitations' },
|
||||||
{ key: 'new-address', label: 'Generate New Address', value: 'new-address' },
|
{ key: 'new-address', label: 'Generate New Address', value: 'new-address' },
|
||||||
|
{ key: 'set-currency', label: 'Set Fiat Currency', value: 'set-currency' },
|
||||||
{ key: 'unreserve-all', label: 'Unreserve All Resources', value: 'unreserve-all' },
|
{ key: 'unreserve-all', label: 'Unreserve All Resources', value: 'unreserve-all' },
|
||||||
{ key: 'refresh', label: 'Refresh', value: 'refresh' },
|
{ key: 'refresh', label: 'Refresh', value: 'refresh' },
|
||||||
];
|
];
|
||||||
@@ -121,7 +123,7 @@ export function WalletStateScreen(): React.ReactElement {
|
|||||||
fiatPerBchRate,
|
fiatPerBchRate,
|
||||||
formattedFiatPerBchRate,
|
formattedFiatPerBchRate,
|
||||||
formatSatoshisToFiat,
|
formatSatoshisToFiat,
|
||||||
} = useSatoshisConversion('USD');
|
} = useSatoshisConversion();
|
||||||
|
|
||||||
// State
|
// State
|
||||||
const [balance, setBalance] = useState<{ totalSatoshis: bigint; utxoCount: number } | null>(null);
|
const [balance, setBalance] = useState<{ totalSatoshis: bigint; utxoCount: number } | null>(null);
|
||||||
@@ -133,6 +135,14 @@ export function WalletStateScreen(): React.ReactElement {
|
|||||||
|
|
||||||
/** Cash address to display in the QR code dialog (null when dialog is hidden). */
|
/** Cash address to display in the QR code dialog (null when dialog is hidden). */
|
||||||
const [qrAddress, setQrAddress] = useState<string | null>(null);
|
const [qrAddress, setQrAddress] = useState<string | null>(null);
|
||||||
|
/** Whether the fiat currency selection dialog is open. */
|
||||||
|
const [isCurrencyDialogOpen, setCurrencyDialogOpen] = useState(false);
|
||||||
|
/** Loading state for rates pair discovery. */
|
||||||
|
const [isLoadingCurrencyPairs, setLoadingCurrencyPairs] = useState(false);
|
||||||
|
/** Optional error message shown in the currency dialog. */
|
||||||
|
const [currencyPairsError, setCurrencyPairsError] = useState<string | null>(null);
|
||||||
|
/** Available fiat currencies derived from rates pairs in X/BCH format. */
|
||||||
|
const [availableCurrencies, setAvailableCurrencies] = useState<string[]>([]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Refreshes wallet state.
|
* Refreshes wallet state.
|
||||||
@@ -260,6 +270,89 @@ export function WalletStateScreen(): React.ReactElement {
|
|||||||
}
|
}
|
||||||
}, [appService, setStatus, showError, showInfo, refresh]);
|
}, [appService, setStatus, showError, showInfo, refresh]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loads all available rates pairs, then extracts fiat numerator symbols from
|
||||||
|
* pairs shaped like X/BCH.
|
||||||
|
*
|
||||||
|
* We retry briefly because rates startup is asynchronous and metadata can take
|
||||||
|
* a moment to hydrate right after wallet initialization.
|
||||||
|
*/
|
||||||
|
const loadAvailableCurrencies = useCallback(async (): Promise<void> => {
|
||||||
|
if (!appService) {
|
||||||
|
setCurrencyPairsError("AppService not initialized");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoadingCurrencyPairs(true);
|
||||||
|
setCurrencyPairsError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
let pairs = new Set<string>();
|
||||||
|
|
||||||
|
// Retry a few times so we can catch late metadata initialization.
|
||||||
|
for (let attempt = 0; attempt < 4; attempt += 1) {
|
||||||
|
pairs = await appService.rates.listPairs();
|
||||||
|
if (pairs.size > 0) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||||
|
}
|
||||||
|
|
||||||
|
const currencies = Array.from(pairs)
|
||||||
|
.map((pair) => pair.toUpperCase())
|
||||||
|
.filter((pair) => pair.endsWith("/BCH"))
|
||||||
|
.map((pair) => pair.split("/")[0] ?? "")
|
||||||
|
.filter((currency) => currency.length > 0)
|
||||||
|
.sort((a, b) => a.localeCompare(b));
|
||||||
|
|
||||||
|
const uniqueCurrencies = Array.from(new Set(currencies));
|
||||||
|
setAvailableCurrencies(uniqueCurrencies);
|
||||||
|
|
||||||
|
if (uniqueCurrencies.length === 0) {
|
||||||
|
setCurrencyPairsError(
|
||||||
|
"No X/BCH rates are currently available. Try again in a moment.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
setCurrencyPairsError(
|
||||||
|
`Failed to load currency pairs: ${error instanceof Error ? error.message : String(error)}`,
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setLoadingCurrencyPairs(false);
|
||||||
|
}
|
||||||
|
}, [appService]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Opens the fiat currency dialog and triggers pair discovery.
|
||||||
|
*/
|
||||||
|
const openCurrencyDialog = useCallback(() => {
|
||||||
|
setCurrencyDialogOpen(true);
|
||||||
|
void loadAvailableCurrencies();
|
||||||
|
}, [loadAvailableCurrencies]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Applies the selected fiat currency to persisted settings.
|
||||||
|
*/
|
||||||
|
const applyCurrencySelection = useCallback(
|
||||||
|
(currencyCode: string) => {
|
||||||
|
if (!appService) {
|
||||||
|
showError("AppService not initialized");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
appService.settings.setCurrency(currencyCode);
|
||||||
|
setStatus(`Fiat currency updated to ${currencyCode}`);
|
||||||
|
setCurrencyDialogOpen(false);
|
||||||
|
} catch (error) {
|
||||||
|
showError(
|
||||||
|
`Failed to update currency: ${error instanceof Error ? error.message : String(error)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[appService, setStatus, showError],
|
||||||
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handles menu action.
|
* Handles menu action.
|
||||||
*/
|
*/
|
||||||
@@ -277,6 +370,9 @@ export function WalletStateScreen(): React.ReactElement {
|
|||||||
case 'new-address':
|
case 'new-address':
|
||||||
generateNewAddress();
|
generateNewAddress();
|
||||||
break;
|
break;
|
||||||
|
case 'set-currency':
|
||||||
|
openCurrencyDialog();
|
||||||
|
break;
|
||||||
case 'unreserve-all':
|
case 'unreserve-all':
|
||||||
unreserveAll();
|
unreserveAll();
|
||||||
break;
|
break;
|
||||||
@@ -284,7 +380,7 @@ export function WalletStateScreen(): React.ReactElement {
|
|||||||
refresh();
|
refresh();
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}, [navigate, generateNewAddress, unreserveAll, refresh]);
|
}, [navigate, generateNewAddress, openCurrencyDialog, unreserveAll, refresh]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handle menu item activation.
|
* Handle menu item activation.
|
||||||
@@ -543,6 +639,27 @@ export function WalletStateScreen(): React.ReactElement {
|
|||||||
onClose={() => setQrAddress(null)}
|
onClose={() => setQrAddress(null)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Fiat currency selection dialog overlay */}
|
||||||
|
{isCurrencyDialogOpen && (
|
||||||
|
<Box
|
||||||
|
position="absolute"
|
||||||
|
flexDirection="column"
|
||||||
|
alignItems="center"
|
||||||
|
justifyContent="center"
|
||||||
|
width="100%"
|
||||||
|
height="100%"
|
||||||
|
>
|
||||||
|
<CurrencySelectionDialog
|
||||||
|
currentCurrency={currencyCode}
|
||||||
|
currencies={availableCurrencies}
|
||||||
|
isLoading={isLoadingCurrencyPairs}
|
||||||
|
errorMessage={currencyPairsError}
|
||||||
|
onSelectCurrency={applyCurrencySelection}
|
||||||
|
onCancel={() => setCurrencyDialogOpen(false)}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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];
|
||||||
|
|||||||
@@ -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