Compare commits

..

9 Commits

Author SHA1 Message Date
e73fb24422 Add installation instruction as readme 2026-04-27 12:35:54 +00:00
b282bbf5d6 Update readme for cli and command parsing 2026-04-27 09:48:10 +00:00
bd1ae909b5 Fix tests 2026-04-27 09:45:38 +00:00
e97054fa34 Fix help docs 2026-04-27 09:45:07 +00:00
a43a45831c Missed the utils file during previous commit 2026-04-27 09:44:42 +00:00
1bbc21c742 Remove [next] from template actions 2026-04-27 09:14:44 +00:00
9fa87d01b3 Combine cli-utils with utils 2026-04-27 09:14:30 +00:00
7ad17a7c0e Add oracle rates 2026-04-27 08:42:51 +00:00
dbfb2c68d2 Formatting 2026-04-20 12:26:35 +00:00
51 changed files with 4641 additions and 1942 deletions

File diff suppressed because one or more lines are too long

13
package-lock.json generated
View File

@@ -11,6 +11,7 @@
"dependencies": { "dependencies": {
"@bitauth/libauth": "^3.0.0", "@bitauth/libauth": "^3.0.0",
"@electrum-cash/protocol": "^2.3.1", "@electrum-cash/protocol": "^2.3.1",
"@generalprotocols/oracle-client": "^0.0.1-development.11945476152",
"@xo-cash/crypto": "file:../crypto", "@xo-cash/crypto": "file:../crypto",
"@xo-cash/engine": "file:../engine", "@xo-cash/engine": "file:../engine",
"@xo-cash/state": "file:../state", "@xo-cash/state": "file:../state",
@@ -118,7 +119,7 @@
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@bitauth/libauth": "^3.1.0-next.8", "@bitauth/libauth": "^3.1.0-next.8",
"@xo-cash/types": "0.0.1-development.13730885533", "@xo-cash/types": "0.0.1",
"@xo-cash/utils": "0.0.1", "@xo-cash/utils": "0.0.1",
"better-sqlite3": "^12.5.0", "better-sqlite3": "^12.5.0",
"idb": "^8.0.3", "idb": "^8.0.3",
@@ -330,6 +331,16 @@
"ws": "^8.13.0" "ws": "^8.13.0"
} }
}, },
"node_modules/@generalprotocols/oracle-client": {
"version": "0.0.1-development.11945476152",
"resolved": "https://registry.npmjs.org/@generalprotocols/oracle-client/-/oracle-client-0.0.1-development.11945476152.tgz",
"integrity": "sha512-1Q43NfacrVfSbatCREzIX7U3DgACBUegNjV977y+pql+Fve03bOyTiUQClevymCi7M3T6mCyMzSEGT8zA6EZtQ==",
"license": "MIT",
"dependencies": {
"@bitauth/libauth": "^3.0.0",
"zod": "^4.1.12"
}
},
"node_modules/@jridgewell/resolve-uri": { "node_modules/@jridgewell/resolve-uri": {
"version": "3.1.2", "version": "3.1.2",
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",

View File

@@ -33,6 +33,7 @@
"dependencies": { "dependencies": {
"@bitauth/libauth": "^3.0.0", "@bitauth/libauth": "^3.0.0",
"@electrum-cash/protocol": "^2.3.1", "@electrum-cash/protocol": "^2.3.1",
"@generalprotocols/oracle-client": "^0.0.1-development.11945476152",
"@xo-cash/crypto": "file:../crypto", "@xo-cash/crypto": "file:../crypto",
"@xo-cash/engine": "file:../engine", "@xo-cash/engine": "file:../engine",
"@xo-cash/state": "file:../state", "@xo-cash/state": "file:../state",

58
readme.md Normal file
View File

@@ -0,0 +1,58 @@
# XO-CLI & XO-TUI
## Installation
### Full Installation
```bash
# Create a new directory since we are going to be pulling in engine too
mdkir xo-terminal && cd xo-terminal
# Clone the Engine Repo
git clone git@gitlab.com:GeneralProtocols/xo/engine.git
# Move into teh engine directory
cd engine
# Install the dependencies
npm ci
# Build the engine
npm run build
# Move back to the top level directory
cd ..
# Clone the CLI Repo
git clone git@git.harvmaster.com:Harvmaster/xo-cli.git
# Move into the cli directory
cd xo-cli
# Install the dependencies
npm ci
# Build the cli
npm run build
```
### Install globally
```bash
# (From the xo-cli directory)
npm install -g .
```
### Run the CLI
```bash
# If globally installed (Not really usable if not globally installed)
xo-cli
```
### Run the TUI
```bash
# If globally installed
xo-tui
# If not globally installed
npm run dev
```

View File

@@ -69,7 +69,9 @@ function resolveExportedValue(
} }
if (keys.length === 0) { if (keys.length === 0) {
console.error("No suitable exports found (need default or a non-function export)."); console.error(
"No suitable exports found (need default or a non-function export).",
);
} else { } else {
console.error( console.error(
`Multiple data exports found; pass exportName. Candidates: ${keys.join(", ")}`, `Multiple data exports found; pass exportName. Candidates: ${keys.join(", ")}`,

View File

@@ -11,11 +11,11 @@ There are two global commands after install:
Wallet state lives under **`~/.config/xo-cli/`** (XDG-style), so you can run commands from any directory: Wallet state lives under **`~/.config/xo-cli/`** (XDG-style), so you can run commands from any directory:
| Path | Purpose | | Path | Purpose |
|------|---------| | ----------------------------- | ----------------------------------------------------------------------- |
| `~/.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` | Last-used mnemonic reference (so `-m` can be omitted) |
**Local to your shells current directory:** template JSON paths, invitation JSON you create/import, and any path you pass explicitly (e.g. `-m /abs/path/to/file`). **Local to your shells current directory:** template JSON paths, invitation JSON you create/import, and any path you pass explicitly (e.g. `-m /abs/path/to/file`).
@@ -41,11 +41,11 @@ npx tsx src/index.ts # TUI
### Environment variables (TUI / `xo-tui`) ### Environment variables (TUI / `xo-tui`)
| Variable | Default | | Variable | Default |
|----------|---------| | ------------------------- | ----------------------------------------- |
| `SYNC_SERVER_URL` | `http://localhost:3000` | | `SYNC_SERVER_URL` | `http://localhost:3000` |
| `DB_PATH` | `~/.config/xo-cli/data` | | `DB_PATH` | `~/.config/xo-cli/data` |
| `DB_FILENAME` | `xo-wallet.db` | | `DB_FILENAME` | `xo-wallet.db` |
| `INVITATION_STORAGE_PATH` | `~/.config/xo-cli/data/xo-invitations.db` | | `INVITATION_STORAGE_PATH` | `~/.config/xo-cli/data/xo-invitations.db` |
## Getting Started ## Getting Started
@@ -82,11 +82,12 @@ xo-cli resource list
## Global Options (`xo-cli`) ## Global Options (`xo-cli`)
| 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) |
| `-v`, `--verbose` | Verbose output | | `-o`, `--output <filename>` | Output filename (used by `mnemonic create`/`import`) |
| `-h`, `--help` | Help | | `-v`, `--verbose` | Verbose output |
| `-h`, `--help` | Help |
Advanced: you can pass `--database-path`, `--database-filename`, and `--invitation-storage-path` to override the defaults under `~/.config/xo-cli/data/` (see `src/cli/index.ts`). Advanced: you can pass `--database-path`, `--database-filename`, and `--invitation-storage-path` to override the defaults under `~/.config/xo-cli/data/` (see `src/cli/index.ts`).
@@ -98,6 +99,7 @@ Advanced: you can pass `--database-path`, `--database-filename`, and `--invitati
xo-cli mnemonic create xo-cli mnemonic create
xo-cli mnemonic import <seed words...> xo-cli mnemonic import <seed words...>
xo-cli mnemonic list xo-cli mnemonic list
xo-cli mnemonic expose <mnemonic-file>
``` ```
### `template` — Manage Templates ### `template` — Manage Templates
@@ -139,20 +141,21 @@ xo-cli invitation sign <invitation-id>
xo-cli invitation broadcast <invitation-id> xo-cli invitation broadcast <invitation-id>
xo-cli invitation requirements <invitation-id> xo-cli invitation requirements <invitation-id>
xo-cli invitation import <invitation-file> xo-cli invitation import <invitation-file>
xo-cli invitation inspect <invitation-file>
xo-cli invitation list xo-cli invitation list
``` ```
**Create / append options:** **Create / append options:**
| Flag | Description | | Flag | Description |
|------|-------------| | --------------------------- | ---------------------------------------- |
| `-var-<name> <value>` | Template variable | | `-var-<name> <value>` | Template variable |
| `--add-input <txhash:vout>` | Inputs (comma-separated) | | `--add-input <txhash:vout>` | Inputs (comma-separated) |
| `--add-output <id>` | Override outputs (omit to auto-discover) | | `--add-output <id>` | Override outputs (omit to auto-discover) |
| `--auto-inputs` | Auto-select UTXOs | | `--auto-inputs` | Auto-select UTXOs |
| `-role <role>` | Role for variables / bytecode | | `-role <role>` | Role for variables / bytecode |
| `--sign` | Auto-sign when complete | | `--sign` | Auto-sign when complete |
| `--broadcast` | Auto-broadcast (implies `--sign`) | | `--broadcast` | Auto-broadcast (implies `--sign`) |
Invitation JSON files from `create` / `append` are written to the **current working directory**. Invitation JSON files from `create` / `append` are written to the **current working directory**.
@@ -186,7 +189,7 @@ xo-cli completions fish | source
## File Conventions ## File Conventions
| Location | Purpose | | Location | Purpose |
|----------|---------| | ------------------- | ------------------------------------------ |
| `~/.config/xo-cli/` | Global wallet state | | `~/.config/xo-cli/` | Global wallet state |
| `./` (cwd) | Templates, invitation JSON, explicit paths | | `./` (cwd) | Templates, invitation JSON, explicit paths |

View File

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

View File

@@ -27,7 +27,11 @@ import { existsSync, readdirSync, readFileSync } from "node:fs";
import { join } from "node:path"; import { join } from "node:path";
import { createHash } from "node:crypto"; import { createHash } from "node:crypto";
import { getDataDir, getMnemonicsDir, getWalletConfigPath } from "../../utils/paths.js"; import {
getDataDir,
getMnemonicsDir,
getWalletConfigPath,
} 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 { COMMAND_TREE } from "./completions.js"; import { COMMAND_TREE } from "./completions.js";
@@ -56,7 +60,9 @@ async function getEngineModule() {
*/ */
function outputCompletions(items: readonly string[], prefix?: string): void { function outputCompletions(items: readonly string[], prefix?: string): void {
const filtered = prefix const filtered = prefix
? items.filter((item) => item.toLowerCase().startsWith(prefix.toLowerCase())) ? items.filter((item) =>
item.toLowerCase().startsWith(prefix.toLowerCase()),
)
: items; : items;
for (const item of filtered) { for (const item of filtered) {
@@ -71,7 +77,9 @@ function outputCompletions(items: readonly string[], prefix?: string): void {
function listMnemonics(prefix?: string): void { function listMnemonics(prefix?: string): void {
try { try {
const mnemonicsDir = getMnemonicsDir(); const mnemonicsDir = getMnemonicsDir();
const files = readdirSync(mnemonicsDir).filter((f) => f.startsWith("mnemonic-")); const files = readdirSync(mnemonicsDir).filter((f) =>
f.startsWith("mnemonic-"),
);
outputCompletions(files, prefix); outputCompletions(files, prefix);
} catch { } catch {
// Silently fail - no completions available // Silently fail - no completions available
@@ -155,7 +163,13 @@ async function listTemplates(prefix?: string): Promise<void> {
* Resolves a template by name or ID. * Resolves a template by name or ID.
*/ */
async function resolveTemplate( async function resolveTemplate(
engine: Awaited<ReturnType<Awaited<ReturnType<typeof getOfflineEngineModule>>["tryCreateOfflineEngine"]>>, engine: Awaited<
ReturnType<
Awaited<
ReturnType<typeof getOfflineEngineModule>
>["tryCreateOfflineEngine"]
>
>,
templateQuery: string, templateQuery: string,
) { ) {
if (!engine) return null; if (!engine) return null;
@@ -165,7 +179,9 @@ async function resolveTemplate(
// Try exact match on name or ID // Try exact match on name or ID
let template = templates.find( let template = templates.find(
(t) => t.name === templateQuery || generateTemplateIdentifier(t) === templateQuery, (t) =>
t.name === templateQuery ||
generateTemplateIdentifier(t) === templateQuery,
); );
// Try partial match on name // Try partial match on name
@@ -181,7 +197,10 @@ async function resolveTemplate(
/** /**
* Lists actions for a specific template. * Lists actions for a specific template.
*/ */
async function listActions(templateQuery: string, prefix?: string): Promise<void> { async function listActions(
templateQuery: string,
prefix?: string,
): Promise<void> {
const mnemonic = getCurrentMnemonic(); const mnemonic = getCurrentMnemonic();
if (!mnemonic) return; if (!mnemonic) return;
@@ -210,7 +229,11 @@ async function listActions(templateQuery: string, prefix?: string): Promise<void
* Lists fields (actions, transactions, outputs, etc.) for a specific template category. * Lists fields (actions, transactions, outputs, etc.) for a specific template category.
* Used for completing the 3rd argument of `template inspect <category> <template> <field>`. * Used for completing the 3rd argument of `template inspect <category> <template> <field>`.
*/ */
async function listFields(category: string, templateQuery: string, prefix?: string): Promise<void> { async function listFields(
category: string,
templateQuery: string,
prefix?: string,
): Promise<void> {
const mnemonic = getCurrentMnemonic(); const mnemonic = getCurrentMnemonic();
if (!mnemonic) return; if (!mnemonic) return;
@@ -300,7 +323,9 @@ async function listResources(prefix?: string): Promise<void> {
try { try {
const utxos = await engine.listUnspentOutputsData(); const utxos = await engine.listUnspentOutputsData();
const outpoints = utxos.map((u) => `${u.outpointTransactionHash}:${u.outpointIndex}`); const outpoints = utxos.map(
(u) => `${u.outpointTransactionHash}:${u.outpointIndex}`,
);
outputCompletions(outpoints, prefix); outputCompletions(outpoints, prefix);
} finally { } finally {
await engine.stop(); await engine.stop();

View File

@@ -19,7 +19,12 @@
* xo-cli completions fish --install * xo-cli completions fish --install
*/ */
import { existsSync, readFileSync, appendFileSync, writeFileSync } from "node:fs"; import {
existsSync,
readFileSync,
appendFileSync,
writeFileSync,
} from "node:fs";
import { dirname, join } from "node:path"; import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import { homedir } from "node:os"; import { homedir } from "node:os";
@@ -40,7 +45,16 @@ const MNEMONIC_SUBS = ["create", "import", "list", "expose"];
/** Subcommands for the template command */ /** Subcommands for the template command */
const TEMPLATE_SUBS = ["import", "list", "inspect", "set-default"]; const TEMPLATE_SUBS = ["import", "list", "inspect", "set-default"];
/** Subcommands for the invitation command */ /** Subcommands for the invitation command */
const INVITATION_SUBS = ["create", "append", "sign", "broadcast", "requirements", "import", "inspect", "list"]; const INVITATION_SUBS = [
"create",
"append",
"sign",
"broadcast",
"requirements",
"import",
"inspect",
"list",
];
/** 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 completions command */ /** Subcommands for the completions command */
@@ -57,7 +71,16 @@ export const COMMAND_TREE = {
} as const; } as const;
/** Global option flags available on every command. */ /** Global option flags available on every command. */
const GLOBAL_OPTIONS = ["-h", "--help", "-v", "--verbose", "-m", "--mnemonic-file", "-o", "--output"]; const GLOBAL_OPTIONS = [
"-h",
"--help",
"-v",
"--verbose",
"-m",
"--mnemonic-file",
"-o",
"--output",
];
/** /**
* Gets the path to the scripts directory containing shell templates. * Gets the path to the scripts directory containing shell templates.
@@ -92,13 +115,22 @@ function loadAndProcessTemplate(templateName: string, binName: string): string {
content = content.replace(/\{\{OPTIONS\}\}/g, options); content = content.replace(/\{\{OPTIONS\}\}/g, options);
content = content.replace(/\{\{MNEMONIC_SUBS\}\}/g, MNEMONIC_SUBS.join(" ")); content = content.replace(/\{\{MNEMONIC_SUBS\}\}/g, MNEMONIC_SUBS.join(" "));
content = content.replace(/\{\{TEMPLATE_SUBS\}\}/g, TEMPLATE_SUBS.join(" ")); content = content.replace(/\{\{TEMPLATE_SUBS\}\}/g, TEMPLATE_SUBS.join(" "));
content = content.replace(/\{\{INVITATION_SUBS\}\}/g, INVITATION_SUBS.join(" ")); content = content.replace(
/\{\{INVITATION_SUBS\}\}/g,
INVITATION_SUBS.join(" "),
);
content = content.replace(/\{\{RESOURCE_SUBS\}\}/g, RESOURCE_SUBS.join(" ")); content = content.replace(/\{\{RESOURCE_SUBS\}\}/g, RESOURCE_SUBS.join(" "));
// Fish-specific placeholders // Fish-specific placeholders
if (templateName.endsWith(".fish")) { if (templateName.endsWith(".fish")) {
content = content.replace(/\{\{TOP_LEVEL_COMMANDS\}\}/g, generateFishTopLevelCommands(binName)); content = content.replace(
content = content.replace(/\{\{STATIC_SUBCOMMANDS\}\}/g, generateFishStaticSubcommands(binName)); /\{\{TOP_LEVEL_COMMANDS\}\}/g,
generateFishTopLevelCommands(binName),
);
content = content.replace(
/\{\{STATIC_SUBCOMMANDS\}\}/g,
generateFishStaticSubcommands(binName),
);
} }
return content; return content;
@@ -110,7 +142,9 @@ function loadAndProcessTemplate(templateName: string, binName: string): string {
function generateFishTopLevelCommands(binName: string): string { function generateFishTopLevelCommands(binName: string): string {
const lines: string[] = []; const lines: string[] = [];
for (const cmd of Object.keys(COMMAND_TREE)) { for (const cmd of Object.keys(COMMAND_TREE)) {
lines.push(`complete -c ${binName} -n "__fish_use_subcommand" -a "${cmd}" -d "${cmd} command"`); lines.push(
`complete -c ${binName} -n "__fish_use_subcommand" -a "${cmd}" -d "${cmd} command"`,
);
} }
return lines.join("\n"); return lines.join("\n");
} }
@@ -122,7 +156,9 @@ function generateFishStaticSubcommands(binName: string): string {
const lines: string[] = []; const lines: string[] = [];
for (const [cmd, subs] of Object.entries(COMMAND_TREE)) { for (const [cmd, subs] of Object.entries(COMMAND_TREE)) {
for (const sub of subs) { for (const sub of subs) {
lines.push(`complete -c ${binName} -n "__fish_seen_subcommand_from ${cmd}; and not __fish_seen_subcommand_from ${subs.join(" ")}" -a "${sub}" -d "${cmd} ${sub}"`); lines.push(
`complete -c ${binName} -n "__fish_seen_subcommand_from ${cmd}; and not __fish_seen_subcommand_from ${subs.join(" ")}" -a "${sub}" -d "${cmd} ${sub}"`,
);
} }
} }
return lines.join("\n"); return lines.join("\n");
@@ -163,7 +199,10 @@ const generators: Record<ShellType, (binName: string) => string> = {
/** /**
* Shell config file paths and eval commands for each shell type. * Shell config file paths and eval commands for each shell type.
*/ */
const shellConfigs: Record<ShellType, { configFile: string; evalCommand: (binName: string) => string }> = { const shellConfigs: Record<
ShellType,
{ configFile: string; evalCommand: (binName: string) => string }
> = {
bash: { bash: {
configFile: join(homedir(), ".bashrc"), configFile: join(homedir(), ".bashrc"),
evalCommand: (binName) => `eval "$(${binName} completions bash)"`, evalCommand: (binName) => `eval "$(${binName} completions bash)"`,
@@ -199,7 +238,8 @@ function installCompletions(shell: ShellType, binName: string): boolean {
} }
// Append the completion line // Append the completion line
const newLine = existingContent.endsWith("\n") || existingContent === "" ? "" : "\n"; const newLine =
existingContent.endsWith("\n") || existingContent === "" ? "" : "\n";
const completionBlock = `${newLine}\n# ${binName} shell completions\n${evalCommand}\n`; const completionBlock = `${newLine}\n# ${binName} shell completions\n${evalCommand}\n`;
appendFileSync(config.configFile, completionBlock); appendFileSync(config.configFile, completionBlock);
@@ -227,14 +267,26 @@ export function handleCompletionsCommand(
console.error(`Usage: ${binName} completions <${supported}> [--install]`); console.error(`Usage: ${binName} completions <${supported}> [--install]`);
console.error(""); console.error("");
console.error("Examples:"); console.error("Examples:");
console.error(` eval "$(${binName} completions bash)" # Output to stdout (add to ~/.bashrc)`); console.error(
console.error(` eval "$(${binName} completions zsh)" # Output to stdout (add to ~/.zshrc)`); ` eval "$(${binName} completions bash)" # Output to stdout (add to ~/.bashrc)`,
console.error(` ${binName} completions fish | source # Output to stdout (add to fish config)`); );
console.error(
` eval "$(${binName} completions zsh)" # Output to stdout (add to ~/.zshrc)`,
);
console.error(
` ${binName} completions fish | source # Output to stdout (add to fish config)`,
);
console.error(""); console.error("");
console.error("Install directly to shell config:"); console.error("Install directly to shell config:");
console.error(` ${binName} completions bash --install # Appends to ~/.bashrc`); console.error(
console.error(` ${binName} completions zsh --install # Appends to ~/.zshrc`); ` ${binName} completions bash --install # Appends to ~/.bashrc`,
console.error(` ${binName} completions fish --install # Appends to ~/.config/fish/config.fish`); );
console.error(
` ${binName} completions zsh --install # Appends to ~/.zshrc`,
);
console.error(
` ${binName} completions fish --install # Appends to ~/.config/fish/config.fish`,
);
process.exit(1); process.exit(1);
} }

View File

@@ -8,7 +8,11 @@
* and instead constructs the engine directly with an in-memory blockchain provider. * and instead constructs the engine directly with an in-memory blockchain provider.
*/ */
import { BlockchainMonitor, Engine, InMemoryBlockchainProvider } from "@xo-cash/engine"; import {
BlockchainMonitor,
Engine,
InMemoryBlockchainProvider,
} from "@xo-cash/engine";
import { createStorageAdapter, State, StorageType } from "@xo-cash/state"; import { createStorageAdapter, State, StorageType } from "@xo-cash/state";
import { convertMnemonicToSeedBytes } from "@xo-cash/crypto"; import { convertMnemonicToSeedBytes } from "@xo-cash/crypto";
import { binToHex, hash256 } from "@bitauth/libauth"; import { binToHex, hash256 } from "@bitauth/libauth";

View File

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

View File

@@ -3,7 +3,7 @@ import path from "path";
import { generateTemplateIdentifier } from "@xo-cash/engine"; import { generateTemplateIdentifier } from "@xo-cash/engine";
import { binToHex, hexToBin } from "@bitauth/libauth"; import { binToHex, hexToBin } from "@bitauth/libauth";
import { bold, dim, formatObject } from "../cli-utils.js"; import { bold, dim, formatObject } from "../utils.js";
import type { CommandDependencies, CommandIO } from "./types.js"; import type { CommandDependencies, CommandIO } from "./types.js";
import { CommandError } from "./types.js"; import { CommandError } from "./types.js";
import type { Invitation } from "../../services/invitation.js"; import type { Invitation } from "../../services/invitation.js";
@@ -73,14 +73,16 @@ async function buildAppendParams(
// --- Inputs --- // --- Inputs ---
// Accepts comma-separated <txhash>:<vout> pairs via --add-input, // Accepts comma-separated <txhash>:<vout> pairs via --add-input,
// OR automatic selection via --auto-inputs. // OR automatic selection via --auto-inputs.
let inputs: { outpointTransactionHash: Uint8Array; outpointIndex: number }[] = []; let inputs: { outpointTransactionHash: Uint8Array; outpointIndex: number }[] =
[];
if (options["autoInputs"] === "true") { if (options["autoInputs"] === "true") {
// Auto-select UTXOs using the greedy algorithm from invitation-flow. // Auto-select UTXOs using the greedy algorithm from invitation-flow.
const suitableResources = await invitation.findSuitableResources(); const suitableResources = await invitation.findSuitableResources();
const selectable = mapUnspentOutputsToSelectable(suitableResources); const selectable = mapUnspentOutputsToSelectable(suitableResources);
const requiredWithFee = (await invitation.getSatsOut().catch(() => 0n)) + DEFAULT_FEE; const requiredWithFee =
(await invitation.getSatsOut().catch(() => 0n)) + DEFAULT_FEE;
autoSelectGreedyUtxos(selectable, requiredWithFee); autoSelectGreedyUtxos(selectable, requiredWithFee);
inputs = selectable inputs = selectable
@@ -99,12 +101,16 @@ async function buildAppendParams(
inputs = options["addInput"].split(",").map((entry) => { inputs = options["addInput"].split(",").map((entry) => {
const separatorIndex = entry.lastIndexOf(":"); const separatorIndex = entry.lastIndexOf(":");
if (separatorIndex === -1) { if (separatorIndex === -1) {
throw new Error(`Invalid input format "${entry}". Expected <txhash>:<vout> (e.g. abc123:0)`); throw new Error(
`Invalid input format "${entry}". Expected <txhash>:<vout> (e.g. abc123:0)`,
);
} }
const txHash = entry.substring(0, separatorIndex); const txHash = entry.substring(0, separatorIndex);
const vout = parseInt(entry.substring(separatorIndex + 1), 10); const vout = parseInt(entry.substring(separatorIndex + 1), 10);
if (!txHash || isNaN(vout)) { if (!txHash || isNaN(vout)) {
throw new Error(`Invalid input format "${entry}". Expected <txhash>:<vout> (e.g. abc123:0)`); throw new Error(
`Invalid input format "${entry}". Expected <txhash>:<vout> (e.g. abc123:0)`,
);
} }
return { return {
outpointTransactionHash: hexToBin(txHash), outpointTransactionHash: hexToBin(txHash),
@@ -112,7 +118,9 @@ async function buildAppendParams(
}; };
}); });
} }
deps.io.verbose(`Inputs: ${formatObject(inputs.map(i => ({ txHash: binToHex(i.outpointTransactionHash), vout: i.outpointIndex })))}`); deps.io.verbose(
`Inputs: ${formatObject(inputs.map((i) => ({ txHash: binToHex(i.outpointTransactionHash), vout: i.outpointIndex })))}`,
);
// --- Outputs --- // --- Outputs ---
// When --add-output is provided, use those identifiers explicitly. // When --add-output is provided, use those identifiers explicitly.
@@ -135,7 +143,9 @@ async function buildAppendParams(
} }
outputIdentifiers = [...discovered]; outputIdentifiers = [...discovered];
if (outputIdentifiers.length > 0) { if (outputIdentifiers.length > 0) {
deps.io.verbose(`Auto-discovered output(s) from template: ${outputIdentifiers.join(", ")}`); deps.io.verbose(
`Auto-discovered output(s) from template: ${outputIdentifiers.join(", ")}`,
);
} }
} }
@@ -151,40 +161,58 @@ async function buildAppendParams(
} }
} }
const template = await deps.app.engine.getTemplate(invitation.data.templateIdentifier); const template = await deps.app.engine.getTemplate(
invitation.data.templateIdentifier,
);
const outputs: any[] = await Promise.all( const outputs: any[] = await Promise.all(
outputIdentifiers.map(async (outputId) => { outputIdentifiers.map(async (outputId) => {
// Try variable-based resolution first (e.g. sendSatoshis → recipientLockingscript) // Try variable-based resolution first (e.g. sendSatoshis → recipientLockingscript)
const providedHex = template const providedHex = template
? resolveProvidedLockingBytecodeHex(template, outputId, variableValuesByIdentifier) ? resolveProvidedLockingBytecodeHex(
template,
outputId,
variableValuesByIdentifier,
)
: undefined; : undefined;
const lockingBytecodeHex = providedHex const lockingBytecodeHex =
?? await invitation.generateLockingBytecode(outputId, roleIdentifier); providedHex ??
(await invitation.generateLockingBytecode(outputId, roleIdentifier));
deps.io.verbose(`Locking bytecode for output "${outputId}": ${lockingBytecodeHex}`); deps.io.verbose(
`Locking bytecode for output "${outputId}": ${lockingBytecodeHex}`,
);
return { return {
outputIdentifier: outputId, outputIdentifier: outputId,
lockingBytecode: new Uint8Array(Buffer.from(lockingBytecodeHex, "hex")), lockingBytecode: new Uint8Array(Buffer.from(lockingBytecodeHex, "hex")),
}; };
}), }),
); );
deps.io.verbose(`Outputs: ${formatObject(outputs.map(o => o.outputIdentifier))}`); deps.io.verbose(
`Outputs: ${formatObject(outputs.map((o) => o.outputIdentifier))}`,
);
// --- Auto change output --- // --- Auto change output ---
// When inputs are provided, look up each UTXO's value, compute the // When inputs are provided, look up each UTXO's value, compute the
// required sats, and return the excess minus fees back to the user. // required sats, and return the excess minus fees back to the user.
if (inputs.length > 0) { if (inputs.length > 0) {
const allUtxos = await deps.app.engine.listUnspentOutputsData(); const allUtxos = await deps.app.engine.listUnspentOutputsData();
const utxoMap = new Map(allUtxos.map(u => [`${u.outpointTransactionHash}:${u.outpointIndex}`, u])); const utxoMap = new Map(
allUtxos.map((u) => [
`${u.outpointTransactionHash}:${u.outpointIndex}`,
u,
]),
);
let totalInputSats = 0n; let totalInputSats = 0n;
for (const input of inputs) { for (const input of inputs) {
const txHashHex = binToHex(input.outpointTransactionHash); const txHashHex = binToHex(input.outpointTransactionHash);
const utxo = utxoMap.get(`${txHashHex}:${input.outpointIndex}`); const utxo = utxoMap.get(`${txHashHex}:${input.outpointIndex}`);
if (!utxo) { if (!utxo) {
deps.io.err(`UTXO not found: ${txHashHex}:${input.outpointIndex}. Make sure it exists in your wallet.`); deps.io.err(
`UTXO not found: ${txHashHex}:${input.outpointIndex}. Make sure it exists in your wallet.`,
);
return null; return null;
} }
totalInputSats += BigInt(utxo.valueSatoshis); totalInputSats += BigInt(utxo.valueSatoshis);
@@ -195,10 +223,14 @@ async function buildAppendParams(
deps.io.verbose(`Required output value: ${requiredSats} satoshis`); deps.io.verbose(`Required output value: ${requiredSats} satoshis`);
const changeAmount = totalInputSats - requiredSats - DEFAULT_FEE; const changeAmount = totalInputSats - requiredSats - DEFAULT_FEE;
deps.io.verbose(`Change amount: ${changeAmount} satoshis (fee: ${DEFAULT_FEE})`); deps.io.verbose(
`Change amount: ${changeAmount} satoshis (fee: ${DEFAULT_FEE})`,
);
if (changeAmount < 0n) { if (changeAmount < 0n) {
deps.io.err(`Insufficient funds. Inputs total ${totalInputSats} sats, but need ${requiredSats + DEFAULT_FEE} sats (${requiredSats} required + ${DEFAULT_FEE} fee).`); deps.io.err(
`Insufficient funds. Inputs total ${totalInputSats} sats, but need ${requiredSats + DEFAULT_FEE} sats (${requiredSats} required + ${DEFAULT_FEE} fee).`,
);
return null; return null;
} }
@@ -206,7 +238,9 @@ async function buildAppendParams(
outputs.push({ valueSatoshis: changeAmount }); outputs.push({ valueSatoshis: changeAmount });
deps.io.out(`Auto-adding change output: ${changeAmount} satoshis`); deps.io.out(`Auto-adding change output: ${changeAmount} satoshis`);
} else if (changeAmount > 0n) { } else if (changeAmount > 0n) {
deps.io.out(`Change ${changeAmount} sats is below dust threshold (${DUST_THRESHOLD} sats), donating to miners as fee.`); deps.io.out(
`Change ${changeAmount} sats is below dust threshold (${DUST_THRESHOLD} sats), donating to miners as fee.`,
);
} }
} }
@@ -218,7 +252,7 @@ async function buildAppendParams(
*/ */
export const printInvitationHelp = (io: CommandIO): void => { export const printInvitationHelp = (io: CommandIO): void => {
io.out( io.out(
` `
${bold("Usage:")} xo-cli invitation <sub-command> ${bold("Usage:")} xo-cli invitation <sub-command>
${bold("Sub-commands:")} ${bold("Sub-commands:")}
@@ -228,6 +262,7 @@ ${bold("Sub-commands:")}
- broadcast <invitation-id> ${dim("Broadcast an invitation")} - broadcast <invitation-id> ${dim("Broadcast an invitation")}
- requirements <invitation-id> ${dim("Show requirements for an invitation")} - requirements <invitation-id> ${dim("Show requirements for an invitation")}
- import <invitation-file> ${dim("Import an invitation from a file")} - import <invitation-file> ${dim("Import an invitation from a file")}
- inspect <invitation-id | invitation-file> ${dim("Inspect an invitation")}
- list ${dim("List all invitations")} - list ${dim("List all invitations")}
${bold("Create / Append options:")} ${bold("Create / Append options:")}
@@ -241,7 +276,8 @@ ${bold("Create / Append options:")}
${dim("When inputs are provided, a change output is automatically added if the")} ${dim("When inputs are provided, a change output is automatically added if the")}
${dim("input total exceeds the required amount + fee.")} ${dim("input total exceeds the required amount + fee.")}
`); `,
);
}; };
/** /**
@@ -278,19 +314,27 @@ export const handleInvitationCommand = async (
if (!subCommand) { if (!subCommand) {
deps.io.verbose("No sub-command provided"); deps.io.verbose("No sub-command provided");
printInvitationHelp(deps.io); printInvitationHelp(deps.io);
throw new CommandError("invitation.subcommand.missing", "No sub-command provided"); throw new CommandError(
"invitation.subcommand.missing",
"No sub-command provided",
);
} }
switch (subCommand) { switch (subCommand) {
case "create": { case "create": {
const templateQuery = args[1]; const templateQuery = args[1];
const actionIdentifier = args[2]; const actionIdentifier = args[2];
deps.io.verbose(`Template query: ${templateQuery}, action identifier: ${actionIdentifier}`); deps.io.verbose(
`Template query: ${templateQuery}, action identifier: ${actionIdentifier}`,
);
if (!templateQuery || !actionIdentifier) { if (!templateQuery || !actionIdentifier) {
deps.io.verbose("No template file or action identifier provided"); deps.io.verbose("No template file or action identifier provided");
printInvitationHelp(deps.io); printInvitationHelp(deps.io);
throw new CommandError("invitation.create.arguments_missing", "No template file or action identifier provided"); throw new CommandError(
"invitation.create.arguments_missing",
"No template file or action identifier provided",
);
} }
const template = await resolveTemplate(deps, templateQuery); const template = await resolveTemplate(deps, templateQuery);
@@ -302,7 +346,9 @@ export const handleInvitationCommand = async (
deps.io.verbose(`XOInvitation created: ${formatObject(rawInvitation)}`); deps.io.verbose(`XOInvitation created: ${formatObject(rawInvitation)}`);
const invitationInstance = await deps.app.createInvitation(rawInvitation); const invitationInstance = await deps.app.createInvitation(rawInvitation);
deps.io.verbose(`Invitation created: ${formatObject(invitationInstance.data)}`); deps.io.verbose(
`Invitation created: ${formatObject(invitationInstance.data)}`,
);
const variables = parseVariablesFromOptions(options); const variables = parseVariablesFromOptions(options);
deps.io.verbose(`Variables: ${formatObject(variables)}`); deps.io.verbose(`Variables: ${formatObject(variables)}`);
@@ -312,7 +358,10 @@ export const handleInvitationCommand = async (
const params = await buildAppendParams(deps, invitationInstance, options); const params = await buildAppendParams(deps, invitationInstance, options);
if (!params) { if (!params) {
throw new CommandError("invitation.create.append_params_failed", "Failed to build append parameters"); throw new CommandError(
"invitation.create.append_params_failed",
"Failed to build append parameters",
);
} }
const { inputs, outputs } = params; const { inputs, outputs } = params;
@@ -322,36 +371,50 @@ export const handleInvitationCommand = async (
const invitationFilePath = `${deps.paths.workingDir}/inv-${invitationInstance.data.invitationIdentifier}.json`; const invitationFilePath = `${deps.paths.workingDir}/inv-${invitationInstance.data.invitationIdentifier}.json`;
deps.io.verbose(`Invitation file path: ${invitationFilePath}`); deps.io.verbose(`Invitation file path: ${invitationFilePath}`);
writeFileSync(invitationFilePath, encodeExtendedJson(invitationInstance.data, 2)); writeFileSync(
deps.io.out(`Invitation created: ${path.basename(invitationFilePath)} (${invitationInstance.data.invitationIdentifier})`); invitationFilePath,
encodeExtendedJson(invitationInstance.data, 2),
);
deps.io.out(
`Invitation created: ${path.basename(invitationFilePath)} (${invitationInstance.data.invitationIdentifier})`,
);
const missingRequirements = await invitationInstance.getMissingRequirements(); const missingRequirements =
await invitationInstance.getMissingRequirements();
const hasMissing = const hasMissing =
(missingRequirements.variables?.length ?? 0) > 0 || (missingRequirements.variables?.length ?? 0) > 0 ||
(missingRequirements.inputs?.length ?? 0) > 0 || (missingRequirements.inputs?.length ?? 0) > 0 ||
(missingRequirements.outputs?.length ?? 0) > 0 || (missingRequirements.outputs?.length ?? 0) > 0 ||
(missingRequirements.roles !== undefined && Object.keys(missingRequirements.roles).length > 0); (missingRequirements.roles !== undefined &&
Object.keys(missingRequirements.roles).length > 0);
if (hasMissing) { if (hasMissing) {
deps.io.out(`\n${bold("Remaining requirements:")}`); deps.io.out(`\n${bold("Remaining requirements:")}`);
deps.io.out(formatObject(missingRequirements)); deps.io.out(formatObject(missingRequirements));
} else { } else {
const shouldSign = options["sign"] === "true" || options["broadcast"] === "true"; const shouldSign =
options["sign"] === "true" || options["broadcast"] === "true";
const shouldBroadcast = options["broadcast"] === "true"; const shouldBroadcast = options["broadcast"] === "true";
if (shouldSign) { if (shouldSign) {
await invitationInstance.sign(); await invitationInstance.sign();
deps.io.out(`Invitation signed: ${invitationInstance.data.invitationIdentifier}`); deps.io.out(
`Invitation signed: ${invitationInstance.data.invitationIdentifier}`,
);
} }
if (shouldBroadcast) { if (shouldBroadcast) {
const txHash = await invitationInstance.broadcast(); const txHash = await invitationInstance.broadcast();
deps.io.out(`Transaction broadcast: ${bold(txHash)}`); deps.io.out(`Transaction broadcast: ${bold(txHash)}`);
} else if (!shouldSign) { } else if (!shouldSign) {
deps.io.out(`\n${bold("All requirements satisfied.")} You can now sign with: xo-cli invitation sign ${invitationInstance.data.invitationIdentifier}`); deps.io.out(
`\n${bold("All requirements satisfied.")} You can now sign with: xo-cli invitation sign ${invitationInstance.data.invitationIdentifier}`,
);
} }
} }
return { invitationIdentifier: invitationInstance.data.invitationIdentifier }; return {
invitationIdentifier: invitationInstance.data.invitationIdentifier,
};
} }
case "append": { case "append": {
@@ -361,7 +424,10 @@ export const handleInvitationCommand = async (
if (!invitationIdentifier) { if (!invitationIdentifier) {
deps.io.verbose("No invitation identifier provided"); deps.io.verbose("No invitation identifier provided");
printInvitationHelp(deps.io); printInvitationHelp(deps.io);
throw new CommandError("invitation.append.identifier_missing", "No invitation identifier provided"); throw new CommandError(
"invitation.append.identifier_missing",
"No invitation identifier provided",
);
} }
const invitation = deps.app.invitations.find( const invitation = deps.app.invitations.find(
@@ -369,7 +435,10 @@ export const handleInvitationCommand = async (
); );
if (!invitation) { if (!invitation) {
deps.io.err(`Invitation not found: ${invitationIdentifier}`); deps.io.err(`Invitation not found: ${invitationIdentifier}`);
throw new CommandError("invitation.append.not_found", `Invitation not found: ${invitationIdentifier}`); throw new CommandError(
"invitation.append.not_found",
`Invitation not found: ${invitationIdentifier}`,
);
} }
deps.io.verbose(`Invitation: ${formatObject(invitation.data)}`); deps.io.verbose(`Invitation: ${formatObject(invitation.data)}`);
@@ -381,12 +450,20 @@ export const handleInvitationCommand = async (
const params = await buildAppendParams(deps, invitation, options); const params = await buildAppendParams(deps, invitation, options);
if (!params) { if (!params) {
throw new CommandError("invitation.append.params_failed", "Failed to build append parameters"); throw new CommandError(
"invitation.append.params_failed",
"Failed to build append parameters",
);
} }
const { inputs, outputs } = params; const { inputs, outputs } = params;
if (variables.length === 0 && inputs.length === 0 && outputs.length === 0) { if (
const error = "Nothing to append. Provide variables (-var-<name> <value>), inputs (--add-input <txhash>:<vout>), or outputs (--add-output <identifier>)."; variables.length === 0 &&
inputs.length === 0 &&
outputs.length === 0
) {
const error =
"Nothing to append. Provide variables (-var-<name> <value>), inputs (--add-input <txhash>:<vout>), or outputs (--add-output <identifier>).";
deps.io.err(error); deps.io.err(error);
throw new CommandError("invitation.append.empty", error); throw new CommandError("invitation.append.empty", error);
} }
@@ -399,20 +476,24 @@ export const handleInvitationCommand = async (
const invitationFilePath = `${deps.paths.workingDir}/inv-${invitation.data.invitationIdentifier}.json`; const invitationFilePath = `${deps.paths.workingDir}/inv-${invitation.data.invitationIdentifier}.json`;
writeFileSync(invitationFilePath, encodeExtendedJson(invitation.data, 2)); writeFileSync(invitationFilePath, encodeExtendedJson(invitation.data, 2));
deps.io.out(`Invitation updated: ${path.basename(invitationFilePath)} (${invitation.data.invitationIdentifier})`); deps.io.out(
`Invitation updated: ${path.basename(invitationFilePath)} (${invitation.data.invitationIdentifier})`,
);
const missingRequirements = await invitation.getMissingRequirements(); const missingRequirements = await invitation.getMissingRequirements();
const hasMissing = const hasMissing =
(missingRequirements.variables?.length ?? 0) > 0 || (missingRequirements.variables?.length ?? 0) > 0 ||
(missingRequirements.inputs?.length ?? 0) > 0 || (missingRequirements.inputs?.length ?? 0) > 0 ||
(missingRequirements.outputs?.length ?? 0) > 0 || (missingRequirements.outputs?.length ?? 0) > 0 ||
(missingRequirements.roles !== undefined && Object.keys(missingRequirements.roles).length > 0); (missingRequirements.roles !== undefined &&
Object.keys(missingRequirements.roles).length > 0);
if (hasMissing) { if (hasMissing) {
deps.io.out(`\n${bold("Remaining requirements:")}`); deps.io.out(`\n${bold("Remaining requirements:")}`);
deps.io.out(formatObject(missingRequirements)); deps.io.out(formatObject(missingRequirements));
} else { } else {
const shouldSign = options["sign"] === "true" || options["broadcast"] === "true"; const shouldSign =
options["sign"] === "true" || options["broadcast"] === "true";
const shouldBroadcast = options["broadcast"] === "true"; const shouldBroadcast = options["broadcast"] === "true";
if (shouldSign) { if (shouldSign) {
@@ -424,7 +505,9 @@ export const handleInvitationCommand = async (
const txHash = await invitation.broadcast(); const txHash = await invitation.broadcast();
deps.io.out(`Transaction broadcast: ${bold(txHash)}`); deps.io.out(`Transaction broadcast: ${bold(txHash)}`);
} else if (!shouldSign) { } else if (!shouldSign) {
deps.io.out(`\n${bold("All requirements satisfied.")} You can now sign with: xo-cli invitation sign ${invitationIdentifier}`); deps.io.out(
`\n${bold("All requirements satisfied.")} You can now sign with: xo-cli invitation sign ${invitationIdentifier}`,
);
} }
} }
return { invitationIdentifier }; return { invitationIdentifier };
@@ -436,15 +519,22 @@ export const handleInvitationCommand = async (
if (!invitationIdentifier) { if (!invitationIdentifier) {
deps.io.verbose("No invitation identifier provided"); deps.io.verbose("No invitation identifier provided");
printInvitationHelp(deps.io); printInvitationHelp(deps.io);
throw new CommandError("invitation.sign.identifier_missing", "No invitation identifier provided"); throw new CommandError(
"invitation.sign.identifier_missing",
"No invitation identifier provided",
);
} }
const invitation = deps.app.invitations.find( const invitation = deps.app.invitations.find(
(candidate) => candidate.data.invitationIdentifier === invitationIdentifier, (candidate) =>
candidate.data.invitationIdentifier === invitationIdentifier,
); );
if (!invitation) { if (!invitation) {
deps.io.err(`Invitation not found: ${invitationIdentifier}`); deps.io.err(`Invitation not found: ${invitationIdentifier}`);
throw new CommandError("invitation.sign.not_found", `Invitation not found: ${invitationIdentifier}`); throw new CommandError(
"invitation.sign.not_found",
`Invitation not found: ${invitationIdentifier}`,
);
} }
deps.io.verbose(`Invitation: ${formatObject(invitation.data)}`); deps.io.verbose(`Invitation: ${formatObject(invitation.data)}`);
@@ -460,20 +550,29 @@ export const handleInvitationCommand = async (
if (!invitationIdentifier) { if (!invitationIdentifier) {
deps.io.verbose("No invitation identifier provided"); deps.io.verbose("No invitation identifier provided");
printInvitationHelp(deps.io); printInvitationHelp(deps.io);
throw new CommandError("invitation.broadcast.identifier_missing", "No invitation identifier provided"); throw new CommandError(
"invitation.broadcast.identifier_missing",
"No invitation identifier provided",
);
} }
const invitation = deps.app.invitations.find( const invitation = deps.app.invitations.find(
(candidate) => candidate.data.invitationIdentifier === invitationIdentifier, (candidate) =>
candidate.data.invitationIdentifier === invitationIdentifier,
); );
if (!invitation) { if (!invitation) {
deps.io.err(`Invitation not found: ${invitationIdentifier}`); deps.io.err(`Invitation not found: ${invitationIdentifier}`);
throw new CommandError("invitation.broadcast.not_found", `Invitation not found: ${invitationIdentifier}`); throw new CommandError(
"invitation.broadcast.not_found",
`Invitation not found: ${invitationIdentifier}`,
);
} }
deps.io.verbose(`Invitation: ${formatObject(invitation.data)}`); deps.io.verbose(`Invitation: ${formatObject(invitation.data)}`);
const txHash = await invitation.broadcast(); const txHash = await invitation.broadcast();
deps.io.verbose(`Invitation broadcasted: ${formatObject(invitation.data)}`); deps.io.verbose(
`Invitation broadcasted: ${formatObject(invitation.data)}`,
);
deps.io.out(`Transaction broadcast: ${bold(txHash)}`); deps.io.out(`Transaction broadcast: ${bold(txHash)}`);
return { invitationIdentifier, txHash }; return { invitationIdentifier, txHash };
} }
@@ -484,19 +583,28 @@ export const handleInvitationCommand = async (
if (!invitationIdentifier) { if (!invitationIdentifier) {
deps.io.verbose("No invitation identifier provided"); deps.io.verbose("No invitation identifier provided");
printInvitationHelp(deps.io); printInvitationHelp(deps.io);
throw new CommandError("invitation.requirements.identifier_missing", "No invitation identifier provided"); throw new CommandError(
"invitation.requirements.identifier_missing",
"No invitation identifier provided",
);
} }
const invitation = deps.app.invitations.find( const invitation = deps.app.invitations.find(
(candidate) => candidate.data.invitationIdentifier === invitationIdentifier, (candidate) =>
candidate.data.invitationIdentifier === invitationIdentifier,
); );
if (!invitation) { if (!invitation) {
deps.io.err(`Invitation not found: ${invitationIdentifier}`); deps.io.err(`Invitation not found: ${invitationIdentifier}`);
throw new CommandError("invitation.requirements.not_found", `Invitation not found: ${invitationIdentifier}`); throw new CommandError(
"invitation.requirements.not_found",
`Invitation not found: ${invitationIdentifier}`,
);
} }
deps.io.verbose(`Invitation: ${formatObject(invitation.data)}`); deps.io.verbose(`Invitation: ${formatObject(invitation.data)}`);
const requirements = await deps.app.engine.listRequirements(invitation.data); const requirements = await deps.app.engine.listRequirements(
invitation.data,
);
deps.io.verbose(`Requirements: ${formatObject(requirements)}`); deps.io.verbose(`Requirements: ${formatObject(requirements)}`);
deps.io.out(formatObject(requirements)); deps.io.out(formatObject(requirements));
return { invitationIdentifier }; return { invitationIdentifier };
@@ -509,7 +617,10 @@ export const handleInvitationCommand = async (
if (!invitationFilePath) { if (!invitationFilePath) {
deps.io.verbose("No invitation file provided"); deps.io.verbose("No invitation file provided");
printInvitationHelp(deps.io); printInvitationHelp(deps.io);
throw new CommandError("invitation.inspect.file_missing", "No invitation file provided"); throw new CommandError(
"invitation.inspect.file_missing",
"No invitation file provided",
);
} }
const invitationFile = await readFileSync(invitationFilePath, "utf8"); const invitationFile = await readFileSync(invitationFilePath, "utf8");
@@ -519,43 +630,74 @@ export const handleInvitationCommand = async (
deps.io.verbose(`Invitation: ${formatObject(invitation)}`); deps.io.verbose(`Invitation: ${formatObject(invitation)}`);
const invitationInstance = await deps.app.createInvitation(invitation); const invitationInstance = await deps.app.createInvitation(invitation);
deps.io.verbose(`Invitation created: ${formatObject(invitationInstance.data)}`); deps.io.verbose(
`Invitation created: ${formatObject(invitationInstance.data)}`,
);
const template = await deps.app.engine.getTemplate(invitationInstance.data.templateIdentifier); const template = await deps.app.engine.getTemplate(
invitationInstance.data.templateIdentifier,
);
const action = template?.actions[invitationInstance.data.actionIdentifier]; const action =
template?.actions[invitationInstance.data.actionIdentifier];
deps.io.verbose(`Action: ${formatObject(action)}`); deps.io.verbose(`Action: ${formatObject(action)}`);
if (!action) { if (!action) {
deps.io.err(`Action not found: ${invitationInstance.data.actionIdentifier}`); deps.io.err(
throw new CommandError("invitation.inspect.action_not_found", `Action not found: ${invitationInstance.data.actionIdentifier}`); `Action not found: ${invitationInstance.data.actionIdentifier}`,
);
throw new CommandError(
"invitation.inspect.action_not_found",
`Action not found: ${invitationInstance.data.actionIdentifier}`,
);
} }
const status = invitationInstance.status; const status = invitationInstance.status;
deps.io.verbose(`Status: ${status}`); deps.io.verbose(`Status: ${status}`);
const entities = Array.from(new Set(invitationInstance.data.commits.map((commit) => commit.entityIdentifier))); const entities = Array.from(
new Set(
invitationInstance.data.commits.map(
(commit) => commit.entityIdentifier,
),
),
);
deps.io.verbose(`Entities: ${formatObject(entities)}`); deps.io.verbose(`Entities: ${formatObject(entities)}`);
const entitiesWithRoles = entities.map((entity) => { const entitiesWithRoles = entities.map((entity) => {
return { return {
entityIdentifier: entity, entityIdentifier: entity,
roles: invitationInstance.data.commits.filter((commit) => commit.entityIdentifier === entity).map((commit) => { roles: invitationInstance.data.commits
return [ .filter((commit) => commit.entityIdentifier === entity)
...(commit.data.inputs?.map((input) => input.roleIdentifier) ?? []), .map((commit) => {
...(commit.data.outputs?.map((output) => output.roleIdentifier) ?? []), return [
...(commit.data.variables?.map((variable) => variable.roleIdentifier) ?? []), ...(commit.data.inputs?.map((input) => input.roleIdentifier) ??
]; []),
}).flat().filter((role) => role !== undefined) ...(commit.data.outputs?.map(
(output) => output.roleIdentifier,
) ?? []),
...(commit.data.variables?.map(
(variable) => variable.roleIdentifier,
) ?? []),
];
})
.flat()
.filter((role) => role !== undefined),
}; };
}); });
const inputs = invitationInstance.data.commits.flatMap((commit) => commit.data.inputs ?? []); const inputs = invitationInstance.data.commits.flatMap(
(commit) => commit.data.inputs ?? [],
);
deps.io.verbose(`Inputs: ${formatObject(inputs)}`); deps.io.verbose(`Inputs: ${formatObject(inputs)}`);
const outputs = invitationInstance.data.commits.flatMap((commit) => commit.data.outputs ?? []); const outputs = invitationInstance.data.commits.flatMap(
(commit) => commit.data.outputs ?? [],
);
deps.io.verbose(`Outputs: ${formatObject(outputs)}`); deps.io.verbose(`Outputs: ${formatObject(outputs)}`);
const variables = invitationInstance.data.commits.flatMap((commit) => commit.data.variables ?? []); const variables = invitationInstance.data.commits.flatMap(
(commit) => commit.data.variables ?? [],
);
deps.io.verbose(`Variables: ${formatObject(variables)}`); deps.io.verbose(`Variables: ${formatObject(variables)}`);
return { return {
@@ -576,7 +718,10 @@ export const handleInvitationCommand = async (
if (!invitationFilePath) { if (!invitationFilePath) {
deps.io.verbose("No invitation file provided"); deps.io.verbose("No invitation file provided");
printInvitationHelp(deps.io); printInvitationHelp(deps.io);
throw new CommandError("invitation.import.file_missing", "No invitation file provided"); throw new CommandError(
"invitation.import.file_missing",
"No invitation file provided",
);
} }
const invitationFile = await readFileSync(invitationFilePath, "utf8"); const invitationFile = await readFileSync(invitationFilePath, "utf8");
@@ -585,14 +730,20 @@ export const handleInvitationCommand = async (
deps.io.verbose(`Invitation: ${formatObject(invitation)}`); deps.io.verbose(`Invitation: ${formatObject(invitation)}`);
const xoInvitation = await deps.app.engine.createInvitation(invitation); const xoInvitation = await deps.app.engine.createInvitation(invitation);
const invitationInstance = await deps.app.createInvitation(xoInvitation); const invitationInstance = await deps.app.createInvitation(xoInvitation);
deps.io.verbose(`Invitation created: ${formatObject(invitationInstance.data)}`); deps.io.verbose(
return { invitationIdentifier: invitationInstance.data.invitationIdentifier }; `Invitation created: ${formatObject(invitationInstance.data)}`,
);
return {
invitationIdentifier: invitationInstance.data.invitationIdentifier,
};
} }
case "list": { case "list": {
const invitations = await Promise.all( const invitations = await Promise.all(
deps.app.invitations.map(async (invitation) => { deps.app.invitations.map(async (invitation) => {
const template = await deps.app.engine.getTemplate(invitation.data.templateIdentifier); const template = await deps.app.engine.getTemplate(
invitation.data.templateIdentifier,
);
return { return {
invitationIdentifier: invitation.data.invitationIdentifier, invitationIdentifier: invitation.data.invitationIdentifier,
templateIdentifier: invitation.data.templateIdentifier, templateIdentifier: invitation.data.templateIdentifier,
@@ -615,6 +766,9 @@ export const handleInvitationCommand = async (
default: default:
deps.io.verbose(`Unknown invitation sub-command: ${subCommand}`); deps.io.verbose(`Unknown invitation sub-command: ${subCommand}`);
printInvitationHelp(deps.io); printInvitationHelp(deps.io);
throw new CommandError("invitation.subcommand.unknown", `Unknown invitation sub-command: ${subCommand}`); throw new CommandError(
"invitation.subcommand.unknown",
`Unknown invitation sub-command: ${subCommand}`,
);
} }
}; };

View File

@@ -1,5 +1,10 @@
import { bold, dim } from "../cli-utils.js"; import { bold, dim } from "../utils.js";
import { listMnemonicFiles, createMnemonicFile, createMnemonicSeed, loadMnemonic } from "../mnemonic.js"; import {
listMnemonicFiles,
createMnemonicFile,
createMnemonicSeed,
loadMnemonic,
} from "../mnemonic.js";
import type { BaseCommandDependencies, CommandIO } from "./types.js"; import type { BaseCommandDependencies, CommandIO } from "./types.js";
import { CommandError } from "./types.js"; import { CommandError } from "./types.js";
@@ -8,17 +13,20 @@ import { CommandError } from "./types.js";
*/ */
export const printMnemonicHelp = (io: CommandIO): void => { export const printMnemonicHelp = (io: CommandIO): void => {
io.out( io.out(
` `
${bold("Usage:")} xo-cli mnemonic <sub-command> ${bold("Usage:")} xo-cli mnemonic <sub-command>
${bold("Sub-commands:")} ${bold("Sub-commands:")}
- create <mnemonic-seed> ${dim("Create a new mnemonic file")} - create <mnemonic-seed> ${dim("Create a new mnemonic file")}
- list ${dim("List all mnemonic files")} - list ${dim("List all mnemonic files")}
- import <mnemonic-seed> ${dim("Import a mnemonic seed from a file")}
- expose <mnemonic-file> ${dim("Expose a mnemonic file")}
${bold("Options:")} ${bold("Options:")}
-o --output <output-filename> ${dim("Output filename for the mnemonic file")} -o --output <output-filename> ${dim("Output filename for the mnemonic file")}
-h --help ${dim("Show this help message")} -h --help ${dim("Show this help message")}
`); `,
);
}; };
/** /**
@@ -39,13 +47,20 @@ export const handleMnemonicCommand = async (
if (!subCommand) { if (!subCommand) {
deps.io.verbose("No sub-command provided"); deps.io.verbose("No sub-command provided");
printMnemonicHelp(deps.io); printMnemonicHelp(deps.io);
throw new CommandError("mnemonic.subcommand.missing", "No sub-command provided"); throw new CommandError(
"mnemonic.subcommand.missing",
"No sub-command provided",
);
} }
switch (subCommand) { switch (subCommand) {
case "create": { case "create": {
const mnemonicSeed = createMnemonicSeed(); const mnemonicSeed = createMnemonicSeed();
const savedAs = createMnemonicFile(mnemonicsDir, mnemonicSeed, options["output"]); const savedAs = createMnemonicFile(
mnemonicsDir,
mnemonicSeed,
options["output"],
);
deps.io.out(`Mnemonic file created: ${savedAs} (${mnemonicSeed})`); deps.io.out(`Mnemonic file created: ${savedAs} (${mnemonicSeed})`);
return { savedAs }; return { savedAs };
@@ -57,18 +72,25 @@ export const handleMnemonicCommand = async (
if (!mnemonicSeed) { if (!mnemonicSeed) {
deps.io.verbose("No mnemonic seed provided"); deps.io.verbose("No mnemonic seed provided");
printMnemonicHelp(deps.io); printMnemonicHelp(deps.io);
throw new CommandError("mnemonic.import.seed_missing", "No mnemonic seed provided"); throw new CommandError(
"mnemonic.import.seed_missing",
"No mnemonic seed provided",
);
} }
deps.io.verbose(`Mnemonic seed: ${mnemonicSeed}`); deps.io.verbose(`Mnemonic seed: ${mnemonicSeed}`);
const savedAs = createMnemonicFile(mnemonicsDir, mnemonicSeed, options["output"]); const savedAs = createMnemonicFile(
mnemonicsDir,
mnemonicSeed,
options["output"],
);
deps.io.out(`Mnemonic file created: ${savedAs}`); deps.io.out(`Mnemonic file created: ${savedAs}`);
return { savedAs }; return { savedAs };
} }
case "list": { case "list": {
const mnemonicFiles = listMnemonicFiles(mnemonicsDir); const mnemonicFiles = listMnemonicFiles(mnemonicsDir);
deps.io.out(mnemonicFiles.join('\n')); deps.io.out(mnemonicFiles.join("\n"));
return { count: mnemonicFiles.length }; return { count: mnemonicFiles.length };
} }
@@ -78,7 +100,10 @@ export const handleMnemonicCommand = async (
if (!mnemonicFile) { if (!mnemonicFile) {
deps.io.verbose("No mnemonic file provided"); deps.io.verbose("No mnemonic file provided");
printMnemonicHelp(deps.io); printMnemonicHelp(deps.io);
throw new CommandError("mnemonic.expose.file_missing", "No mnemonic file provided"); throw new CommandError(
"mnemonic.expose.file_missing",
"No mnemonic file provided",
);
} }
try { try {
@@ -96,6 +121,9 @@ export const handleMnemonicCommand = async (
default: default:
deps.io.err(`Unknown sub-command: ${subCommand}`); deps.io.err(`Unknown sub-command: ${subCommand}`);
printMnemonicHelp(deps.io); printMnemonicHelp(deps.io);
throw new CommandError("mnemonic.subcommand.unknown", `Unknown sub-command: ${subCommand}`); throw new CommandError(
"mnemonic.subcommand.unknown",
`Unknown sub-command: ${subCommand}`,
);
} }
}; };

View File

@@ -1,7 +1,7 @@
import { generateTemplateIdentifier } from "@xo-cash/engine"; import { generateTemplateIdentifier } from "@xo-cash/engine";
import { hexToBin, lockingBytecodeToCashAddress } from "@bitauth/libauth"; import { hexToBin, lockingBytecodeToCashAddress } from "@bitauth/libauth";
import { bold, dim } from "../cli-utils.js"; import { bold, dim } from "../utils.js";
import type { CommandDependencies, CommandIO } from "./types.js"; import type { CommandDependencies, CommandIO } from "./types.js";
import { CommandError } from "./types.js"; import { CommandError } from "./types.js";
@@ -12,7 +12,7 @@ import { resolveTemplate } from "../utils.js";
*/ */
export const printReceiveHelp = (io: CommandIO): void => { export const printReceiveHelp = (io: CommandIO): void => {
io.out( io.out(
` `
${bold("Usage:")} xo-cli receive <template-file> <output-identifier> [role-identifier] ${bold("Usage:")} xo-cli receive <template-file> <output-identifier> [role-identifier]
${bold("Description:")} ${bold("Description:")}
@@ -25,7 +25,8 @@ ${bold("Arguments:")}
${bold("Options:")} ${bold("Options:")}
-h --help ${dim("Show this help message")} -h --help ${dim("Show this help message")}
`); `,
);
}; };
/** /**
@@ -47,12 +48,17 @@ export const handleReceiveCommand = async (
const outputIdentifier = args[1]; const outputIdentifier = args[1];
const roleIdentifier = args[2]; const roleIdentifier = args[2];
deps.io.verbose(`Receive args - template: ${templateQuery}, output: ${outputIdentifier}, role: ${roleIdentifier}`); deps.io.verbose(
`Receive args - template: ${templateQuery}, output: ${outputIdentifier}, role: ${roleIdentifier}`,
);
if (!templateQuery || !outputIdentifier) { if (!templateQuery || !outputIdentifier) {
deps.io.verbose("Missing required arguments"); deps.io.verbose("Missing required arguments");
printReceiveHelp(deps.io); printReceiveHelp(deps.io);
throw new CommandError("receive.arguments.missing", "Missing required arguments"); throw new CommandError(
"receive.arguments.missing",
"Missing required arguments",
);
} }
// Resolve and read the template file // Resolve and read the template file
@@ -69,11 +75,17 @@ export const handleReceiveCommand = async (
deps.io.verbose(`Locking bytecode hex: ${lockingBytecodeHex}`); deps.io.verbose(`Locking bytecode hex: ${lockingBytecodeHex}`);
// Convert the locking bytecode to a BCH cash address // Convert the locking bytecode to a BCH cash address
const result = lockingBytecodeToCashAddress({ bytecode: hexToBin(lockingBytecodeHex), prefix: 'bitcoincash' }); const result = lockingBytecodeToCashAddress({
bytecode: hexToBin(lockingBytecodeHex),
prefix: "bitcoincash",
});
if (typeof result === 'string') { if (typeof result === "string") {
deps.io.err(`Failed to encode address: ${result}`); deps.io.err(`Failed to encode address: ${result}`);
throw new CommandError("receive.address.encode_failed", `Failed to encode address: ${result}`); throw new CommandError(
"receive.address.encode_failed",
`Failed to encode address: ${result}`,
);
} }
deps.io.out(result.address); deps.io.out(result.address);

View File

@@ -1,6 +1,6 @@
import { hexToBin } from "@bitauth/libauth"; import { hexToBin } from "@bitauth/libauth";
import { bold, dim } from "../cli-utils.js"; import { bold, dim } from "../utils.js";
import type { CommandDependencies, CommandIO } from "./types.js"; import type { CommandDependencies, CommandIO } from "./types.js";
import type { UnspentOutputData } from "@xo-cash/state"; import type { UnspentOutputData } from "@xo-cash/state";
import { CommandError } from "./types.js"; import { CommandError } from "./types.js";
@@ -10,7 +10,7 @@ import { CommandError } from "./types.js";
*/ */
export const printResourceHelp = (io: CommandIO): void => { export const printResourceHelp = (io: CommandIO): void => {
io.out( io.out(
` `
${bold("Usage:")} xo-cli resource <sub-command> ${bold("Usage:")} xo-cli resource <sub-command>
${bold("Sub-commands:")} ${bold("Sub-commands:")}
@@ -19,14 +19,20 @@ ${bold("Sub-commands:")}
- list all ${dim("List all resources (reserved + unreserved)")} - list all ${dim("List all resources (reserved + unreserved)")}
- unreserve <txhash:vout> ${dim("Unreserve a specific UTXO")} - unreserve <txhash:vout> ${dim("Unreserve a specific UTXO")}
- unreserve-all ${dim("Unreserve all reserved UTXOs")} - unreserve-all ${dim("Unreserve all reserved UTXOs")}
`); `,
);
}; };
/** /**
* Formats a single UTXO for display, optionally including reservation info. * Formats a single UTXO for display, optionally including reservation info.
*/ */
function formatResource(resource: UnspentOutputData, showReserved = false): string { function formatResource(
const outpoint = bold(`${resource.outpointTransactionHash}:${resource.outpointIndex}`); resource: UnspentOutputData,
showReserved = false,
): string {
const outpoint = bold(
`${resource.outpointTransactionHash}:${resource.outpointIndex}`,
);
const value = dim(`${resource.valueSatoshis} sats`); const value = dim(`${resource.valueSatoshis} sats`);
const output = dim(resource.outputIdentifier); const output = dim(resource.outputIdentifier);
const height = dim(`(height ${resource.minedAtHeight})`); const height = dim(`(height ${resource.minedAtHeight})`);
@@ -57,7 +63,10 @@ export const handleResourceCommand = async (
if (!subCommand) { if (!subCommand) {
deps.io.verbose("No sub-command provided"); deps.io.verbose("No sub-command provided");
printResourceHelp(deps.io); printResourceHelp(deps.io);
throw new CommandError("resource.subcommand.missing", "No sub-command provided"); throw new CommandError(
"resource.subcommand.missing",
"No sub-command provided",
);
} }
switch (subCommand) { switch (subCommand) {
@@ -80,9 +89,13 @@ export const handleResourceCommand = async (
} }
const showReserved = qualifier === "all" || qualifier === "reserved"; const showReserved = qualifier === "all" || qualifier === "reserved";
const formattedResources = filtered.map((r) => formatResource(r, showReserved)); const formattedResources = filtered.map((r) =>
formatResource(r, showReserved),
);
deps.io.out(formattedResources.join("\n")); deps.io.out(formattedResources.join("\n"));
deps.io.out(`Total satoshis: ${filtered.reduce((acc, r) => acc + r.valueSatoshis, 0)}`); deps.io.out(
`Total satoshis: ${filtered.reduce((acc, r) => acc + r.valueSatoshis, 0)}`,
);
deps.io.out(`Total resources: ${filtered.length}`); deps.io.out(`Total resources: ${filtered.length}`);
return { count: filtered.length }; return { count: filtered.length };
} }
@@ -92,20 +105,33 @@ export const handleResourceCommand = async (
if (!outpointArg) { if (!outpointArg) {
deps.io.err("Please provide a UTXO in <txhash>:<vout> format."); deps.io.err("Please provide a UTXO in <txhash>:<vout> format.");
printResourceHelp(deps.io); printResourceHelp(deps.io);
throw new CommandError("resource.unreserve.outpoint_missing", "Please provide a UTXO in <txhash>:<vout> format."); throw new CommandError(
"resource.unreserve.outpoint_missing",
"Please provide a UTXO in <txhash>:<vout> format.",
);
} }
const separatorIndex = outpointArg.lastIndexOf(":"); const separatorIndex = outpointArg.lastIndexOf(":");
if (separatorIndex === -1) { if (separatorIndex === -1) {
deps.io.err(`Invalid format "${outpointArg}". Expected <txhash>:<vout>.`); deps.io.err(
throw new CommandError("resource.unreserve.outpoint_invalid", `Invalid format "${outpointArg}". Expected <txhash>:<vout>.`); `Invalid format "${outpointArg}". Expected <txhash>:<vout>.`,
);
throw new CommandError(
"resource.unreserve.outpoint_invalid",
`Invalid format "${outpointArg}". Expected <txhash>:<vout>.`,
);
} }
const txHash = outpointArg.substring(0, separatorIndex); const txHash = outpointArg.substring(0, separatorIndex);
const vout = parseInt(outpointArg.substring(separatorIndex + 1), 10); const vout = parseInt(outpointArg.substring(separatorIndex + 1), 10);
if (!txHash || isNaN(vout)) { if (!txHash || isNaN(vout)) {
deps.io.err(`Invalid format "${outpointArg}". Expected <txhash>:<vout>.`); deps.io.err(
throw new CommandError("resource.unreserve.outpoint_invalid", `Invalid format "${outpointArg}". Expected <txhash>:<vout>.`); `Invalid format "${outpointArg}". Expected <txhash>:<vout>.`,
);
throw new CommandError(
"resource.unreserve.outpoint_invalid",
`Invalid format "${outpointArg}". Expected <txhash>:<vout>.`,
);
} }
const allResources = await deps.app.engine.listUnspentOutputsData(); const allResources = await deps.app.engine.listUnspentOutputsData();
@@ -115,7 +141,10 @@ export const handleResourceCommand = async (
if (!target) { if (!target) {
deps.io.err(`UTXO not found: ${txHash}:${vout}`); deps.io.err(`UTXO not found: ${txHash}:${vout}`);
throw new CommandError("resource.unreserve.utxo_missing", `UTXO not found: ${txHash}:${vout}`); throw new CommandError(
"resource.unreserve.utxo_missing",
`UTXO not found: ${txHash}:${vout}`,
);
} }
if (!target.reservedBy) { if (!target.reservedBy) {
@@ -125,9 +154,11 @@ export const handleResourceCommand = async (
await deps.app.engine.unreserveResources( await deps.app.engine.unreserveResources(
[{ outpointTransactionHash: hexToBin(txHash), outpointIndex: vout }], [{ outpointTransactionHash: hexToBin(txHash), outpointIndex: vout }],
target.reservedBy , target.reservedBy,
);
deps.io.out(
`Unreserved ${bold(`${txHash}:${vout}`)} (was reserved for ${target.reservedBy})`,
); );
deps.io.out(`Unreserved ${bold(`${txHash}:${vout}`)} (was reserved for ${target.reservedBy})`);
return {}; return {};
} }
@@ -144,7 +175,10 @@ export const handleResourceCommand = async (
default: { default: {
deps.io.verbose(`Unknown resource sub-command: ${subCommand}`); deps.io.verbose(`Unknown resource sub-command: ${subCommand}`);
printResourceHelp(deps.io); printResourceHelp(deps.io);
throw new CommandError("resource.subcommand.unknown", `Unknown resource sub-command: ${subCommand}`); throw new CommandError(
"resource.subcommand.unknown",
`Unknown resource sub-command: ${subCommand}`,
);
} }
} }
}; };

View File

@@ -3,7 +3,7 @@ import path from "path";
import { generateTemplateIdentifier } from "@xo-cash/engine"; import { generateTemplateIdentifier } from "@xo-cash/engine";
import type { XOTemplate } from "@xo-cash/types"; import type { XOTemplate } from "@xo-cash/types";
import { bold, dim, formatObject } from "../cli-utils.js"; import { bold, dim, formatObject } from "../utils.js";
import { resolveTemplateReferences } from "../../utils/templates.js"; import { resolveTemplateReferences } from "../../utils/templates.js";
import type { CommandDependencies, CommandIO } from "./types.js"; import type { CommandDependencies, CommandIO } from "./types.js";
import { CommandError } from "./types.js"; import { CommandError } from "./types.js";
@@ -14,7 +14,7 @@ import { resolveTemplate } from "../utils.js";
*/ */
export const printTemplateHelp = (io: CommandIO): void => { export const printTemplateHelp = (io: CommandIO): void => {
io.out( io.out(
` `
${bold("Usage:")} xo-cli template <sub-command> ${bold("Usage:")} xo-cli template <sub-command>
${bold("Sub-commands:")} ${bold("Sub-commands:")}
@@ -23,7 +23,8 @@ ${bold("Sub-commands:")}
- list <category> <identifier> ${dim("List all options of the field type in a template")} - list <category> <identifier> ${dim("List all options of the field type in a template")}
- inspect <category> <identifier> <field> ${dim("Inspect a field in a template")} - inspect <category> <identifier> <field> ${dim("Inspect a field in a template")}
- set-default <template-file> <output-identifier> <role-identifier> ${dim("Set the default template")} - set-default <template-file> <output-identifier> <role-identifier> ${dim("Set the default template")}
`); `,
);
}; };
/** /**
@@ -41,8 +42,11 @@ export const handleTemplateListCommand = async (
if (!templateCategory) { if (!templateCategory) {
const templates = await deps.app.engine.listImportedTemplates(); const templates = await deps.app.engine.listImportedTemplates();
const formattedTemplates = templates.map((template: XOTemplate) => `${bold(generateTemplateIdentifier(template))} - ${dim(template.name)} ${dim(template.description)}`); const formattedTemplates = templates.map(
deps.io.out(formattedTemplates.join('\n')); (template: XOTemplate) =>
`${bold(generateTemplateIdentifier(template))} - ${dim(template.name)} ${dim(template.description)}`,
);
deps.io.out(formattedTemplates.join("\n"));
return { count: templates.length }; return { count: templates.length };
} }
@@ -51,13 +55,19 @@ export const handleTemplateListCommand = async (
if (!templateIdentifier) { if (!templateIdentifier) {
deps.io.err("No template identifier provided"); deps.io.err("No template identifier provided");
throw new CommandError("template.list.identifier_missing", "No template identifier provided"); throw new CommandError(
"template.list.identifier_missing",
"No template identifier provided",
);
} }
const rawTemplate = await deps.app.engine.getTemplate(templateIdentifier); const rawTemplate = await deps.app.engine.getTemplate(templateIdentifier);
if (!rawTemplate) { if (!rawTemplate) {
deps.io.err(`No template found: ${templateIdentifier}`); deps.io.err(`No template found: ${templateIdentifier}`);
throw new CommandError("template.list.not_found", `No template found: ${templateIdentifier}`); throw new CommandError(
"template.list.not_found",
`No template found: ${templateIdentifier}`,
);
} }
const template = await resolveTemplateReferences(rawTemplate); const template = await resolveTemplateReferences(rawTemplate);
@@ -66,47 +76,65 @@ export const handleTemplateListCommand = async (
switch (templateCategory) { switch (templateCategory) {
case "action": { case "action": {
const actions = template.actions; const actions = template.actions;
const formattedActions = Object.entries(actions).map(([actionIdentifier, action]) => `${bold(actionIdentifier)} ${dim(action.name)} ${dim(action.description)}`); const formattedActions = Object.entries(actions).map(
deps.io.out(formattedActions.join('\n')); ([actionIdentifier, action]) =>
`${bold(actionIdentifier)} ${dim(action.name)} ${dim(action.description)}`,
);
deps.io.out(formattedActions.join("\n"));
return {}; return {};
} }
case "transaction": { case "transaction": {
const transactions = template.transactions; const transactions = template.transactions;
const formattedTransactions = Object.entries(transactions).map(([transactionIdentifier, transaction]) => `${bold(transactionIdentifier)} ${dim(transaction.name)} ${dim(transaction.description)}`); const formattedTransactions = Object.entries(transactions).map(
deps.io.out(formattedTransactions.join('\n')); ([transactionIdentifier, transaction]) =>
`${bold(transactionIdentifier)} ${dim(transaction.name)} ${dim(transaction.description)}`,
);
deps.io.out(formattedTransactions.join("\n"));
return {}; return {};
} }
case "output": { case "output": {
const outputs = template.outputs; const outputs = template.outputs;
const formattedOutputs = Object.entries(outputs).map(([outputIdentifier, output]) => `${bold(outputIdentifier)} ${dim(output.name)} ${dim(output.description)}`); const formattedOutputs = Object.entries(outputs).map(
deps.io.out(formattedOutputs.join('\n')); ([outputIdentifier, output]) =>
`${bold(outputIdentifier)} ${dim(output.name)} ${dim(output.description)}`,
);
deps.io.out(formattedOutputs.join("\n"));
return {}; return {};
} }
case "lockingscript": { case "lockingscript": {
const lockingscripts = template.lockingScripts; const lockingscripts = template.lockingScripts;
const formattedLockingscripts = Object.entries(lockingscripts).map(([lockingScriptIdentifier, lockingScript]) => `${bold(lockingScriptIdentifier)} ${dim(lockingScript.name)} ${dim(lockingScript.description)}`); const formattedLockingscripts = Object.entries(lockingscripts).map(
deps.io.out(formattedLockingscripts.join('\n')); ([lockingScriptIdentifier, lockingScript]) =>
`${bold(lockingScriptIdentifier)} ${dim(lockingScript.name)} ${dim(lockingScript.description)}`,
);
deps.io.out(formattedLockingscripts.join("\n"));
return {}; return {};
} }
case "variable": { case "variable": {
const variables = template.variables || {}; const variables = template.variables || {};
const formattedVariables = Object.entries(variables).map(([variableIdentifier, variable]) => `${bold(variableIdentifier)} ${dim(variable.name)} ${dim(variable.description)}`); const formattedVariables = Object.entries(variables).map(
deps.io.out(formattedVariables.join('\n')); ([variableIdentifier, variable]) =>
`${bold(variableIdentifier)} ${dim(variable.name)} ${dim(variable.description)}`,
);
deps.io.out(formattedVariables.join("\n"));
return {}; return {};
} }
default: { default: {
deps.io.verbose(`Unknown template category: ${templateCategory}`); deps.io.verbose(`Unknown template category: ${templateCategory}`);
throw new CommandError("template.list.category_unknown", `Unknown template category: ${templateCategory}`); throw new CommandError(
"template.list.category_unknown",
`Unknown template category: ${templateCategory}`,
);
} }
} }
} };
/** /**
* Prints the help message for the template inspect command * Prints the help message for the template inspect command
*/ */
export const printTemplateInspectHelp = (io: CommandIO): void => { export const printTemplateInspectHelp = (io: CommandIO): void => {
io.out( io.out(
` `
${bold("Usage:")} xo-cli template inspect <category> <identifier> <field> ${bold("Usage:")} xo-cli template inspect <category> <identifier> <field>
${bold("Arguments:")} ${bold("Arguments:")}
@@ -120,7 +148,8 @@ ${bold("Categories:")}
- output <output-identifier> ${dim("Inspect an output")} - output <output-identifier> ${dim("Inspect an output")}
- lockingscript <lockingscript-identifier> ${dim("Inspect a lockingscript")} - lockingscript <lockingscript-identifier> ${dim("Inspect a lockingscript")}
- variable <variable-identifier> ${dim("Inspect a variable")} - variable <variable-identifier> ${dim("Inspect a variable")}
`); `,
);
}; };
/** /**
@@ -137,12 +166,17 @@ export const handleTemplateInspectCommand = async (
const templateQuery = args[1]; const templateQuery = args[1];
const templateField = args[2]; const templateField = args[2];
deps.io.verbose(`Template inspect args - category: ${templateCategory}, identifier: ${templateQuery}, field: ${templateField}`); deps.io.verbose(
`Template inspect args - category: ${templateCategory}, identifier: ${templateQuery}, field: ${templateField}`,
);
if (!templateCategory || !templateQuery || !templateField) { if (!templateCategory || !templateQuery || !templateField) {
deps.io.err("No template category, identifier, or field provided"); deps.io.err("No template category, identifier, or field provided");
printTemplateInspectHelp(deps.io); printTemplateInspectHelp(deps.io);
throw new CommandError("template.inspect.arguments_missing", "No template category, identifier, or field provided"); throw new CommandError(
"template.inspect.arguments_missing",
"No template category, identifier, or field provided",
);
} }
const originalTemplate = await resolveTemplate(deps, templateQuery); const originalTemplate = await resolveTemplate(deps, templateQuery);
@@ -156,7 +190,10 @@ export const handleTemplateInspectCommand = async (
const action = template.actions[templateField]; const action = template.actions[templateField];
if (!action) { if (!action) {
deps.io.err(`No action found: ${templateField}`); deps.io.err(`No action found: ${templateField}`);
throw new CommandError("template.inspect.action_missing", `No action found: ${templateField}`); throw new CommandError(
"template.inspect.action_missing",
`No action found: ${templateField}`,
);
} }
deps.io.out(formatObject(action)); deps.io.out(formatObject(action));
return {}; return {};
@@ -165,7 +202,10 @@ export const handleTemplateInspectCommand = async (
const transaction = template.transactions?.[templateField]; const transaction = template.transactions?.[templateField];
if (!transaction) { if (!transaction) {
deps.io.err(`No transaction found: ${templateField}`); deps.io.err(`No transaction found: ${templateField}`);
throw new CommandError("template.inspect.transaction_missing", `No transaction found: ${templateField}`); throw new CommandError(
"template.inspect.transaction_missing",
`No transaction found: ${templateField}`,
);
} }
deps.io.out(formatObject(transaction)); deps.io.out(formatObject(transaction));
return {}; return {};
@@ -174,7 +214,10 @@ export const handleTemplateInspectCommand = async (
const output = template.outputs[templateField]; const output = template.outputs[templateField];
if (!output) { if (!output) {
deps.io.err(`No output found: ${templateField}`); deps.io.err(`No output found: ${templateField}`);
throw new CommandError("template.inspect.output_missing", `No output found: ${templateField}`); throw new CommandError(
"template.inspect.output_missing",
`No output found: ${templateField}`,
);
} }
deps.io.out(formatObject(output)); deps.io.out(formatObject(output));
return {}; return {};
@@ -183,7 +226,10 @@ export const handleTemplateInspectCommand = async (
const lockingscript = template.lockingScripts[templateField]; const lockingscript = template.lockingScripts[templateField];
if (!lockingscript) { if (!lockingscript) {
deps.io.err(`No lockingscript found: ${templateField}`); deps.io.err(`No lockingscript found: ${templateField}`);
throw new CommandError("template.inspect.lockingscript_missing", `No lockingscript found: ${templateField}`); throw new CommandError(
"template.inspect.lockingscript_missing",
`No lockingscript found: ${templateField}`,
);
} }
deps.io.out(formatObject(lockingscript)); deps.io.out(formatObject(lockingscript));
return {}; return {};
@@ -192,17 +238,23 @@ export const handleTemplateInspectCommand = async (
const variable = template.variables?.[templateField]; const variable = template.variables?.[templateField];
if (!variable) { if (!variable) {
deps.io.err(`No variable found: ${templateField}`); deps.io.err(`No variable found: ${templateField}`);
throw new CommandError("template.inspect.variable_missing", `No variable found: ${templateField}`); throw new CommandError(
"template.inspect.variable_missing",
`No variable found: ${templateField}`,
);
} }
deps.io.out(formatObject(variable)); deps.io.out(formatObject(variable));
return {}; return {};
} }
default: { default: {
deps.io.verbose(`Unknown template category: ${templateCategory}`); deps.io.verbose(`Unknown template category: ${templateCategory}`);
throw new CommandError("template.inspect.category_unknown", `Unknown template category: ${templateCategory}`); throw new CommandError(
"template.inspect.category_unknown",
`Unknown template category: ${templateCategory}`,
);
} }
} }
} };
/** /**
* Handles the template command. * Handles the template command.
@@ -221,7 +273,10 @@ export const handleTemplateCommand = async (
if (!subCommand) { if (!subCommand) {
deps.io.verbose("No sub-command provided"); deps.io.verbose("No sub-command provided");
printTemplateHelp(deps.io); printTemplateHelp(deps.io);
throw new CommandError("template.subcommand.missing", "No sub-command provided"); throw new CommandError(
"template.subcommand.missing",
"No sub-command provided",
);
} }
switch (subCommand) { switch (subCommand) {
@@ -232,7 +287,10 @@ export const handleTemplateCommand = async (
if (!templateFile) { if (!templateFile) {
deps.io.verbose("No template file provided"); deps.io.verbose("No template file provided");
printTemplateHelp(deps.io); printTemplateHelp(deps.io);
throw new CommandError("template.import.file_missing", "No template file provided"); throw new CommandError(
"template.import.file_missing",
"No template file provided",
);
} }
const templatePath = path.resolve(`${process.cwd()}/${templateFile}`); const templatePath = path.resolve(`${process.cwd()}/${templateFile}`);
@@ -241,7 +299,10 @@ export const handleTemplateCommand = async (
if (!existsSync(templatePath)) { if (!existsSync(templatePath)) {
deps.io.err(`Template file does not exist: ${templatePath}`); deps.io.err(`Template file does not exist: ${templatePath}`);
printTemplateHelp(deps.io); printTemplateHelp(deps.io);
throw new CommandError("template.import.file_not_found", `Template file does not exist: ${templatePath}`); throw new CommandError(
"template.import.file_not_found",
`Template file does not exist: ${templatePath}`,
);
} }
const template = await readFileSync(templatePath, "utf8"); const template = await readFileSync(templatePath, "utf8");
@@ -262,17 +323,31 @@ export const handleTemplateCommand = async (
const outputIdentifier = args[2]; const outputIdentifier = args[2];
const roleIdentifier = args[3]; const roleIdentifier = args[3];
if (!templateFile || !outputIdentifier || !roleIdentifier) { if (!templateFile || !outputIdentifier || !roleIdentifier) {
deps.io.verbose("No template file, output identifier, or role identifier provided"); deps.io.verbose(
"No template file, output identifier, or role identifier provided",
);
printTemplateHelp(deps.io); printTemplateHelp(deps.io);
throw new CommandError("template.default.arguments_missing", "No template file, output identifier, or role identifier provided"); throw new CommandError(
"template.default.arguments_missing",
"No template file, output identifier, or role identifier provided",
);
} }
deps.io.verbose(`Template file: ${templateFile}, output identifier: ${outputIdentifier}, role identifier: ${roleIdentifier}`); deps.io.verbose(
await deps.app.engine.setDefaultLockingParameters(templateFile, outputIdentifier, roleIdentifier); `Template file: ${templateFile}, output identifier: ${outputIdentifier}, role identifier: ${roleIdentifier}`,
);
await deps.app.engine.setDefaultLockingParameters(
templateFile,
outputIdentifier,
roleIdentifier,
);
return {}; return {};
} }
default: default:
deps.io.verbose(`Unknown template sub-command: ${subCommand}`); deps.io.verbose(`Unknown template sub-command: ${subCommand}`);
printTemplateHelp(deps.io); printTemplateHelp(deps.io);
throw new CommandError("template.subcommand.unknown", `Unknown template sub-command: ${subCommand}`); throw new CommandError(
"template.subcommand.unknown",
`Unknown template sub-command: ${subCommand}`,
);
} }
}; };

View File

@@ -40,9 +40,13 @@ import { join } from "path";
import { AppService } from "../services/app.js"; import { AppService } from "../services/app.js";
import { convertArgsToObject } from "./arguments.js"; import { convertArgsToObject } from "./arguments.js";
import { bold, dim, formatObject } from "./cli-utils.js"; import { bold, dim, formatObject } from "./utils.js";
import { listGlobalMnemonicFiles, loadMnemonic } from "./mnemonic.js"; import { listGlobalMnemonicFiles, loadMnemonic } from "./mnemonic.js";
import { getDataDir, getMnemonicsDir, getWalletConfigPath } from "../utils/paths.js"; import {
getDataDir,
getMnemonicsDir,
getWalletConfigPath,
} from "../utils/paths.js";
import { import {
type CommandDependencies, type CommandDependencies,
@@ -138,8 +142,12 @@ async function main(): Promise<void> {
} }
if (!mnemonicFile) { if (!mnemonicFile) {
io.err("No mnemonic file provided"); io.err("No mnemonic file provided");
io.out(`You can create a mnemonic file with the following command: xo-cli mnemonic create <mnemonic-seed> or use one of the following files: \n${listGlobalMnemonicFiles().join("\n")}`); io.out(
io.out(`\nTip: pass -m <file> once and it will be remembered in ${paths.walletConfigPath}`); `You can create a mnemonic file with the following command: xo-cli mnemonic create <mnemonic-seed> or use one of the following files: \n${listGlobalMnemonicFiles().join("\n")}`,
);
io.out(
`\nTip: pass -m <file> once and it will be remembered in ${paths.walletConfigPath}`,
);
process.exit(1); process.exit(1);
} }
@@ -158,7 +166,8 @@ async function main(): Promise<void> {
databaseFilename: options["databaseFilename"] ?? "xo-wallet.db", databaseFilename: options["databaseFilename"] ?? "xo-wallet.db",
}, },
invitationStoragePath: invitationStoragePath:
options["invitationStoragePath"] ?? join(paths.dataDir, "xo-invitations.db"), options["invitationStoragePath"] ??
join(paths.dataDir, "xo-invitations.db"),
}); });
io.verbose("App instance created"); io.verbose("App instance created");
@@ -179,23 +188,42 @@ async function main(): Promise<void> {
let result: unknown; let result: unknown;
switch (command) { switch (command) {
case "template": case "template":
result = await handleTemplateCommand(commandDependencies, subArgs, options); result = await handleTemplateCommand(
commandDependencies,
subArgs,
options,
);
break; break;
case "invitation": case "invitation":
result = await handleInvitationCommand(commandDependencies, subArgs, options); result = await handleInvitationCommand(
commandDependencies,
subArgs,
options,
);
break; break;
case "receive": case "receive":
result = await handleReceiveCommand(commandDependencies, subArgs, options); result = await handleReceiveCommand(
commandDependencies,
subArgs,
options,
);
break; break;
case "resource": case "resource":
result = await handleResourceCommand(commandDependencies, subArgs, options); result = await handleResourceCommand(
commandDependencies,
subArgs,
options,
);
break; break;
case "help": case "help":
result = await handleHelpCommand(commandDependencies, subArgs, options); result = await handleHelpCommand(commandDependencies, subArgs, options);
break; break;
default: default:
io.err(`Unknown command: ${command}`); io.err(`Unknown command: ${command}`);
throw new CommandError("cli.command.unknown", `Unknown command: ${command}`); throw new CommandError(
"cli.command.unknown",
`Unknown command: ${command}`,
);
} }
// console.log(result); // console.log(result);
@@ -217,7 +245,7 @@ const handleHelpCommand = async (
_options: Record<string, string>, _options: Record<string, string>,
): Promise<Record<string, never>> => { ): Promise<Record<string, never>> => {
deps.io.out( deps.io.out(
`${bold("XO-CLI Help:")} `${bold("XO-CLI Help:")}
${bold("Usage:")} xo-cli <command> [options] ${bold("Usage:")} xo-cli <command> [options]
@@ -228,11 +256,12 @@ Commands:
receive ${dim("Generate a single-use receiving address")} receive ${dim("Generate a single-use receiving address")}
resource ${dim("Manage resources")} resource ${dim("Manage resources")}
completions ${dim("Generate shell completion scripts (bash, zsh, fish)")} completions ${dim("Generate shell completion scripts (bash, zsh, fish)")}
help ${dim("Show this help message")}
Options: Options:
-h, --help ${dim("Show this help message")} -h, --help ${dim("Show this help message")}
-m, --mnemonic-file <mnemonic-file> ${dim("Use a specific mnemonic file")} -m, --mnemonic-file <mnemonic-file> ${dim("Use a specific mnemonic file")}
-v, --verbose ${dim("Show verbose output")}` -v, --verbose ${dim("Show verbose output")}`,
); );
return {}; return {};
}; };

View File

@@ -28,9 +28,11 @@ export const createMnemonicFile = (
let fileName = outputFilename; let fileName = outputFilename;
if (!fileName) { if (!fileName) {
const firstWord = mnemonic.split(' ')[0]?.toLowerCase(); const firstWord = mnemonic.split(" ")[0]?.toLowerCase();
if (!firstWord) { if (!firstWord) {
throw new Error("Failed to create mnemonic file: Unable to extract first word from the mnemonic"); throw new Error(
"Failed to create mnemonic file: Unable to extract first word from the mnemonic",
);
} }
fileName = `mnemonic-${firstWord}`; fileName = `mnemonic-${firstWord}`;
} }
@@ -80,9 +82,14 @@ export const resolveMnemonicFilePath = (
* @param mnemonicFile - The filename of the mnemonic file * @param mnemonicFile - The filename of the mnemonic file
* @returns The mnemonic seed * @returns The mnemonic seed
*/ */
export const loadMnemonic = (mnemonicsDir: string, mnemonicFile: string): string => { export const loadMnemonic = (
mnemonicsDir: string,
mnemonicFile: string,
): string => {
const resolvedPath = resolveMnemonicFilePath(mnemonicsDir, mnemonicFile); const resolvedPath = resolveMnemonicFilePath(mnemonicsDir, mnemonicFile);
const mnemonicUrl = BCHMnemonicURL.fromURL(readFileSync(resolvedPath, "utf8")); const mnemonicUrl = BCHMnemonicURL.fromURL(
readFileSync(resolvedPath, "utf8"),
);
const { entropy } = mnemonicUrl.toObject(); const { entropy } = mnemonicUrl.toObject();
const mnemonic = encodeBip39Mnemonic(entropy); const mnemonic = encodeBip39Mnemonic(entropy);

View File

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

View File

@@ -11,6 +11,7 @@ import { BaseStorage, Storage } from "./storage.js";
import { SyncServer } from "../utils/sync-server.js"; import { SyncServer } from "../utils/sync-server.js";
import { HistoryService } from "./history.js"; import { HistoryService } from "./history.js";
import { type BlockchainService, ElectrumService } from "./electrum.js"; import { type BlockchainService, ElectrumService } from "./electrum.js";
import { RatesService } from "./rates.js";
import { EventEmitter } from "../utils/event-emitter.js"; import { EventEmitter } from "../utils/event-emitter.js";
@@ -46,6 +47,7 @@ export class AppService extends EventEmitter<AppEventMap> {
public config: AppConfig; public config: AppConfig;
public history: HistoryService; public history: HistoryService;
public electrum: BlockchainService; public electrum: BlockchainService;
public rates: RatesService;
public invitations: Invitation[] = []; public invitations: Invitation[] = [];
private invitationEventCleanup = new Map< private invitationEventCleanup = new Map<
@@ -74,8 +76,20 @@ export class AppService extends EventEmitter<AppEventMap> {
// Import the default P2PKH template // Import the default P2PKH template
const { templateIdentifier } = await engine.importTemplate(p2pkhTemplate); const { templateIdentifier } = await engine.importTemplate(p2pkhTemplate);
engine.subscribeToLockingBytecodesForTemplate(templateIdentifier).catch(err => console.error(`Error subscribing to locking bytecodes for template ${templateIdentifier}: ${err}`)); engine
engine.updateUnspentOutputsForTemplate(templateIdentifier).catch(err => console.error(`Error updating unspent outputs for template ${templateIdentifier}: ${err}`)); .subscribeToLockingBytecodesForTemplate(templateIdentifier)
.catch((err) =>
console.error(
`Error subscribing to locking bytecodes for template ${templateIdentifier}: ${err}`,
),
);
engine
.updateUnspentOutputsForTemplate(templateIdentifier)
.catch((err) =>
console.error(
`Error updating unspent outputs for template ${templateIdentifier}: ${err}`,
),
);
// Set default locking parameters for P2PKH // Set default locking parameters for P2PKH
// To my knowledge, this doesnt generate any lockscript, so discovery of funds will not work automatically. // To my knowledge, this doesnt generate any lockscript, so discovery of funds will not work automatically.
@@ -95,8 +109,9 @@ export class AppService extends EventEmitter<AppEventMap> {
host: config.electrumHost, host: config.electrumHost,
applicationIdentifier: config.electrumApplicationIdentifier, applicationIdentifier: config.electrumApplicationIdentifier,
}); });
const rates = await RatesService.create();
return new AppService(engine, walletStorage, config, electrum); return new AppService(engine, walletStorage, config, electrum, rates);
} }
constructor( constructor(
@@ -104,6 +119,7 @@ export class AppService extends EventEmitter<AppEventMap> {
storage: BaseStorage, storage: BaseStorage,
config: AppConfig, config: AppConfig,
electrum: BlockchainService, electrum: BlockchainService,
rates: RatesService,
) { ) {
super(); super();
@@ -111,6 +127,7 @@ export class AppService extends EventEmitter<AppEventMap> {
this.storage = storage; this.storage = storage;
this.config = config; this.config = config;
this.electrum = electrum; this.electrum = electrum;
this.rates = rates;
this.history = new HistoryService(engine, this.invitations); this.history = new HistoryService(engine, this.invitations);
} }
@@ -209,10 +226,7 @@ export class AppService extends EventEmitter<AppEventMap> {
if (!trackedInvitation || !cleanup) return; if (!trackedInvitation || !cleanup) return;
trackedInvitation.off("invitation-updated", cleanup.onUpdated); trackedInvitation.off("invitation-updated", cleanup.onUpdated);
trackedInvitation.off( trackedInvitation.off("invitation-status-changed", cleanup.onStatusChanged);
"invitation-status-changed",
cleanup.onStatusChanged,
);
this.invitationEventCleanup.delete(invitationIdentifier); this.invitationEventCleanup.delete(invitationIdentifier);
} }
@@ -248,6 +262,11 @@ export class AppService extends EventEmitter<AppEventMap> {
} }
async start(): Promise<void> { async start(): Promise<void> {
// Start rates in the background so BCH -> fiat conversions become reactive in the TUI.
this.rates.start().catch((err) =>
console.error('Error starting rates service:', err),
);
// Get the invitations db // Get the invitations db
const invitationsDb = this.storage.child("invitations"); const invitationsDb = this.storage.child("invitations");
@@ -259,7 +278,9 @@ export class AppService extends EventEmitter<AppEventMap> {
await Promise.all( await Promise.all(
invitations.map(async ({ key }) => { invitations.map(async ({ key }) => {
await this.createInvitation(key).catch(err => console.error(`Error creating invitation ${key}: ${err}`)); await this.createInvitation(key).catch((err) =>
console.error(`Error creating invitation ${key}: ${err}`),
);
}), }),
); );
} }

View File

@@ -34,7 +34,7 @@ import { compileCashAssemblyString } from "@xo-cash/engine";
export type InvitationEventMap = { export type InvitationEventMap = {
"invitation-updated": XOInvitation; "invitation-updated": XOInvitation;
"invitation-status-changed": string; "invitation-status-changed": string;
"error": Error; error: Error;
}; };
export type InvitationDependencies = { export type InvitationDependencies = {
@@ -215,7 +215,9 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
/** /**
* Publish the invitation to the sync server * Publish the invitation to the sync server
*/ */
private async publishInvitation(invitation: XOInvitation = this.data): Promise<void> { private async publishInvitation(
invitation: XOInvitation = this.data,
): Promise<void> {
try { try {
await this.syncServer.publishInvitation(invitation); await this.syncServer.publishInvitation(invitation);
} catch (err) { } catch (err) {

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

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -217,14 +217,10 @@ export function TemplateListScreen(): React.ReactElement {
action.roles.length, action.roles.length,
index index
); );
const sourceSuffix = action.source === 'next'
? ' [next]'
: action.source === 'starting+next'
? ' [start+next]'
: '';
return { return {
key: action.actionIdentifier, key: action.actionIdentifier,
label: `${formatted.label}${sourceSuffix}`, label: `${formatted.label}`,
description: formatted.description, description: formatted.description,
value: action, value: action,
hidden: !formatted.isValid, hidden: !formatted.isValid,

View File

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

View File

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

View File

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

View File

@@ -17,6 +17,7 @@ import { useNavigation } from '../../hooks/useNavigation.js';
import { useAppContext, useStatus } from '../../hooks/useAppContext.js'; import { useAppContext, useStatus } from '../../hooks/useAppContext.js';
import { useBlockableInput, useIsInputCaptured } from '../../hooks/useInputLayer.js'; import { useBlockableInput, useIsInputCaptured } from '../../hooks/useInputLayer.js';
import { useInvitations } from '../../hooks/useInvitations.js'; import { useInvitations } from '../../hooks/useInvitations.js';
import { useSatoshisConversion } from '../../hooks/useSatoshisConversion.js';
import { colors, logoSmall, formatSatoshis } from '../../theme.js'; import { colors, logoSmall, formatSatoshis } from '../../theme.js';
import { copyToClipboard } from '../../utils/clipboard.js'; import { copyToClipboard } from '../../utils/clipboard.js';
import type { Invitation } from '../../../services/invitation.js'; import type { Invitation } from '../../../services/invitation.js';
@@ -88,6 +89,8 @@ export function InvitationScreen(): React.ReactElement {
const { setStatus } = useStatus(); const { setStatus } = useStatus();
const invitations = useInvitations(); const invitations = useInvitations();
const { currencyCode, formattedFiatPerBchRate, formatSatoshisToFiat } =
useSatoshisConversion('USD');
// ── UI state ───────────────────────────────────────────────────────────── // ── UI state ─────────────────────────────────────────────────────────────
const [selectedIndex, setSelectedIndex] = useState(0); const [selectedIndex, setSelectedIndex] = useState(0);
@@ -494,6 +497,44 @@ export function InvitationScreen(): React.ReactElement {
const roleInfoRaw = userRole && selectedTemplate?.roles?.[userRole]; const roleInfoRaw = userRole && selectedTemplate?.roles?.[userRole];
const roleInfo = roleInfoRaw && typeof roleInfoRaw === 'object' ? roleInfoRaw : null; const roleInfo = roleInfoRaw && typeof roleInfoRaw === 'object' ? roleInfoRaw : null;
const getFiatSuffix = (satoshis: bigint): string => {
const fiatValue = formatSatoshisToFiat(satoshis);
return fiatValue ? ` (~${fiatValue})` : '';
};
const parseNumberishToBigInt = (value: unknown): bigint | null => {
if (typeof value === 'bigint') {
return value;
}
const asString = String(value).trim();
if (!/^[-]?\d+$/.test(asString)) {
return null;
}
try {
return BigInt(asString);
} catch {
return null;
}
};
const isSatoshisVariable = (variableIdentifier: string): boolean => {
const templateVariable = selectedTemplate?.variables?.[variableIdentifier];
const templateType = templateVariable?.type?.toLowerCase();
const templateHint = templateVariable?.hint?.toLowerCase();
const identifier = variableIdentifier.toLowerCase();
if (templateHint?.includes('satoshi')) {
return true;
}
return (
templateType === 'integer' &&
(identifier.includes('satoshi') || identifier.includes('amount'))
);
};
return ( return (
<Box flexDirection="column"> <Box flexDirection="column">
{/* Type & Status */} {/* Type & Status */}
@@ -514,6 +555,11 @@ export function InvitationScreen(): React.ReactElement {
<Text color={colors.textMuted}> <Text color={colors.textMuted}>
Action: {action?.name ?? selectedInvitation.data.actionIdentifier} Action: {action?.name ?? selectedInvitation.data.actionIdentifier}
</Text> </Text>
{formattedFiatPerBchRate && (
<Text color={colors.textMuted}>
1 BCH = {formattedFiatPerBchRate}
</Text>
)}
{action?.description && ( {action?.description && (
<Text color={colors.textMuted} dimColor>{action.description}</Text> <Text color={colors.textMuted} dimColor>{action.description}</Text>
)} )}
@@ -542,6 +588,11 @@ export function InvitationScreen(): React.ReactElement {
inputs.map((input, idx) => { inputs.map((input, idx) => {
const isUserInput = input.entityIdentifier === userEntityId; const isUserInput = input.entityIdentifier === userEntityId;
const inputTemplate = selectedTemplate?.inputs?.[input.inputIdentifier ?? '']; const inputTemplate = selectedTemplate?.inputs?.[input.inputIdentifier ?? ''];
const inputSatoshis = (
'valueSatoshis' in input && input.valueSatoshis !== undefined
)
? parseNumberishToBigInt(input.valueSatoshis)
: null;
return ( return (
<Text <Text
key={`input-${idx}`} key={`input-${idx}`}
@@ -550,6 +601,7 @@ export function InvitationScreen(): React.ReactElement {
{' '}{isUserInput ? '• ' : '○ '} {' '}{isUserInput ? '• ' : '○ '}
{inputTemplate?.name ?? input.inputIdentifier ?? `Input ${idx}`} {inputTemplate?.name ?? input.inputIdentifier ?? `Input ${idx}`}
{input.roleIdentifier && ` (${input.roleIdentifier})`} {input.roleIdentifier && ` (${input.roleIdentifier})`}
{inputSatoshis !== null && ` ${formatSatoshis(inputSatoshis)}${getFiatSuffix(inputSatoshis)}`}
</Text> </Text>
); );
}) })
@@ -564,6 +616,9 @@ export function InvitationScreen(): React.ReactElement {
outputs.map((output, idx) => { outputs.map((output, idx) => {
const isUserOutput = output.entityIdentifier === userEntityId; const isUserOutput = output.entityIdentifier === userEntityId;
const outputTemplate = selectedTemplate?.outputs?.[output.outputIdentifier ?? '']; const outputTemplate = selectedTemplate?.outputs?.[output.outputIdentifier ?? ''];
const outputSatoshis = output.valueSatoshis !== undefined
? parseNumberishToBigInt(output.valueSatoshis)
: null;
return ( return (
<Text <Text
key={`output-${idx}`} key={`output-${idx}`}
@@ -571,7 +626,7 @@ export function InvitationScreen(): React.ReactElement {
> >
{' '}{isUserOutput ? '• ' : '○ '} {' '}{isUserOutput ? '• ' : '○ '}
{outputTemplate?.name ?? output.outputIdentifier ?? `Output ${idx}`} {outputTemplate?.name ?? output.outputIdentifier ?? `Output ${idx}`}
{output.valueSatoshis !== undefined && ` (${formatSatoshis(output.valueSatoshis)})`} {outputSatoshis !== null && ` (${formatSatoshis(outputSatoshis)}${getFiatSuffix(outputSatoshis)})`}
</Text> </Text>
); );
}) })
@@ -591,6 +646,9 @@ export function InvitationScreen(): React.ReactElement {
const displayValue = typeof variable.value === 'bigint' const displayValue = typeof variable.value === 'bigint'
? variable.value.toString() ? variable.value.toString()
: String(variable.value); : String(variable.value);
const parsedVariableSatoshis = isSatoshisVariable(variable.variableIdentifier)
? parseNumberishToBigInt(variable.value)
: null;
return ( return (
<Text <Text
key={`var-${idx}`} key={`var-${idx}`}
@@ -598,6 +656,8 @@ export function InvitationScreen(): React.ReactElement {
> >
{' '}{isUserVariable ? '• ' : '○ '} {' '}{isUserVariable ? '• ' : '○ '}
{varTemplate?.name ?? variable.variableIdentifier}: {displayValue} {varTemplate?.name ?? variable.variableIdentifier}: {displayValue}
{parsedVariableSatoshis !== null &&
` (${formatSatoshis(parsedVariableSatoshis)}${getFiatSuffix(parsedVariableSatoshis)})`}
{varTemplate?.description && ( {varTemplate?.description && (
<Text color={colors.textMuted} dimColor> - {varTemplate.description}</Text> <Text color={colors.textMuted} dimColor> - {varTemplate.description}</Text>
)} )}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -73,11 +73,19 @@ describe("command handler contracts", () => {
const { io } = createMockIO(); const { io } = createMockIO();
await expect( await expect(
handleResourceCommand(createCommandDeps(fakeApp, io), ["does-not-exist"], {}), handleResourceCommand(
createCommandDeps(fakeApp, io),
["does-not-exist"],
{},
),
).rejects.toThrow(CommandError); ).rejects.toThrow(CommandError);
try { try {
await handleResourceCommand(createCommandDeps(fakeApp, io), ["does-not-exist"], {}); await handleResourceCommand(
createCommandDeps(fakeApp, io),
["does-not-exist"],
{},
);
} catch (error) { } catch (error) {
expect(error).toBeInstanceOf(CommandError); expect(error).toBeInstanceOf(CommandError);
expect((error as CommandError).event).toBe("resource.subcommand.unknown"); expect((error as CommandError).event).toBe("resource.subcommand.unknown");

View File

@@ -11,18 +11,41 @@
*/ */
import { expect, test, describe, beforeEach, afterEach } from "vitest"; import { expect, test, describe, beforeEach, afterEach } from "vitest";
import { existsSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs"; import {
existsSync,
mkdtempSync,
readFileSync,
readdirSync,
rmSync,
writeFileSync,
} from "node:fs";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import path from "node:path"; import path from "node:path";
import { addFakeResource, createMockAppService, createMockEngine, DEFAULT_SEED, randomTxHash } from "../mocks/engine"; import {
addFakeResource,
createMockAppService,
createMockEngine,
DEFAULT_SEED,
randomTxHash,
} from "../mocks/engine";
import { type Engine } from "@xo-cash/engine"; import { type Engine } from "@xo-cash/engine";
import { p2pkhTemplate, p2pkhTemplateIdentifier } from "../mocks/template-p2pkh"; import {
p2pkhTemplate,
p2pkhTemplateIdentifier,
} from "../mocks/template-p2pkh";
import { AppService } from "../../../src/services/app"; import { AppService } from "../../../src/services/app";
import { handleInvitationCommand } from "../../../src/cli/commands/invitation"; import { handleInvitationCommand } from "../../../src/cli/commands/invitation";
import { CommandError, CommandPaths } from "../../../src/cli/commands/types"; import { CommandError, CommandPaths } from "../../../src/cli/commands/types";
import { createCommandDeps, createMockIO, createMockPaths, expectLogs, type LogExpectation } from "../mocks/command"; import {
createCommandDeps,
createMockIO,
createMockPaths,
expectLogs,
type LogExpectation,
} from "../mocks/command";
import { State } from "@xo-cash/state";
// ============================================================================ // ============================================================================
// Error Cases - Validate argument parsing and error handling // Error Cases - Validate argument parsing and error handling
@@ -134,7 +157,8 @@ describe("invitation command - error cases", () => {
let paths: CommandPaths; let paths: CommandPaths;
beforeEach(async () => { beforeEach(async () => {
engine = await createMockEngine(DEFAULT_SEED); const mockEngine = await createMockEngine(DEFAULT_SEED);
engine = mockEngine.engine;
await engine.importTemplate(p2pkhTemplate); await engine.importTemplate(p2pkhTemplate);
app = await createMockAppService(engine); app = await createMockAppService(engine);
tempDir = mkdtempSync(path.join(tmpdir(), "xo-cli-invitation-errors-")); tempDir = mkdtempSync(path.join(tmpdir(), "xo-cli-invitation-errors-"));
@@ -150,7 +174,11 @@ describe("invitation command - error cases", () => {
const { io } = createMockIO(); const { io } = createMockIO();
try { try {
await handleInvitationCommand(createCommandDeps(app, io, paths), inputs, {}); await handleInvitationCommand(
createCommandDeps(app, io, paths),
inputs,
{},
);
expect.fail("Expected command to throw"); expect.fail("Expected command to throw");
} catch (error) { } catch (error) {
expect(error).toBeInstanceOf(CommandError); expect(error).toBeInstanceOf(CommandError);
@@ -170,7 +198,8 @@ describe("invitation command - receive flow", () => {
let paths: CommandPaths; let paths: CommandPaths;
beforeEach(async () => { beforeEach(async () => {
engine = await createMockEngine(DEFAULT_SEED); const mockEngine = await createMockEngine(DEFAULT_SEED);
engine = mockEngine.engine;
await engine.importTemplate(p2pkhTemplate); await engine.importTemplate(p2pkhTemplate);
app = await createMockAppService(engine); app = await createMockAppService(engine);
tempDir = mkdtempSync(path.join(tmpdir(), "xo-cli-invitation-receive-")); tempDir = mkdtempSync(path.join(tmpdir(), "xo-cli-invitation-receive-"));
@@ -211,7 +240,10 @@ describe("invitation command - receive flow", () => {
{}, {},
); );
const expectedFile = path.join(tempDir, `inv-${result.invitationIdentifier}.json`); const expectedFile = path.join(
tempDir,
`inv-${result.invitationIdentifier}.json`,
);
expect(existsSync(expectedFile)).toBe(true); expect(existsSync(expectedFile)).toBe(true);
}); });
@@ -279,7 +311,8 @@ describe("invitation command - request satoshis flow", () => {
let paths: CommandPaths; let paths: CommandPaths;
beforeEach(async () => { beforeEach(async () => {
engine = await createMockEngine(DEFAULT_SEED); const mockEngine = await createMockEngine(DEFAULT_SEED);
engine = mockEngine.engine;
await engine.importTemplate(p2pkhTemplate); await engine.importTemplate(p2pkhTemplate);
app = await createMockAppService(engine); app = await createMockAppService(engine);
tempDir = mkdtempSync(path.join(tmpdir(), "xo-cli-invitation-request-")); tempDir = mkdtempSync(path.join(tmpdir(), "xo-cli-invitation-request-"));
@@ -325,8 +358,12 @@ describe("invitation command - request satoshis flow", () => {
); );
expect(invitation).toBeDefined(); expect(invitation).toBeDefined();
const variables = invitation?.data.commits.flatMap((c) => c.data.variables ?? []); const variables = invitation?.data.commits.flatMap(
const requestedSatoshis = variables?.find((v) => v.variableIdentifier === "requestedSatoshis"); (c) => c.data.variables ?? [],
);
const requestedSatoshis = variables?.find(
(v) => v.variableIdentifier === "requestedSatoshis",
);
expect(requestedSatoshis).toBeDefined(); expect(requestedSatoshis).toBeDefined();
expect(requestedSatoshis?.value).toBe("10000"); expect(requestedSatoshis?.value).toBe("10000");
}); });
@@ -347,8 +384,12 @@ describe("invitation command - request satoshis flow", () => {
(inv) => inv.data.invitationIdentifier === result.invitationIdentifier, (inv) => inv.data.invitationIdentifier === result.invitationIdentifier,
); );
const variables = invitation?.data.commits.flatMap((c) => c.data.variables ?? []); const variables = invitation?.data.commits.flatMap(
const requestedSatoshis = variables?.find((v) => v.variableIdentifier === "requestedSatoshis"); (c) => c.data.variables ?? [],
);
const requestedSatoshis = variables?.find(
(v) => v.variableIdentifier === "requestedSatoshis",
);
expect(requestedSatoshis?.roleIdentifier).toBe("receiver"); expect(requestedSatoshis?.roleIdentifier).toBe("receiver");
}); });
}); });
@@ -359,12 +400,15 @@ describe("invitation command - request satoshis flow", () => {
describe("invitation command - send flow with resources", () => { describe("invitation command - send flow with resources", () => {
let engine: Engine; let engine: Engine;
let state: State;
let app: AppService; let app: AppService;
let tempDir: string; let tempDir: string;
let paths: CommandPaths; let paths: CommandPaths;
beforeEach(async () => { beforeEach(async () => {
engine = await createMockEngine(DEFAULT_SEED); const mockEngine = await createMockEngine(DEFAULT_SEED);
engine = mockEngine.engine;
state = mockEngine.state;
await engine.importTemplate(p2pkhTemplate); await engine.importTemplate(p2pkhTemplate);
app = await createMockAppService(engine); app = await createMockAppService(engine);
tempDir = mkdtempSync(path.join(tmpdir(), "xo-cli-invitation-send-")); tempDir = mkdtempSync(path.join(tmpdir(), "xo-cli-invitation-send-"));
@@ -388,7 +432,8 @@ describe("invitation command - send flow with resources", () => {
["create", "Wallet (P2PKH)", "sendSatoshis"], ["create", "Wallet (P2PKH)", "sendSatoshis"],
{ {
varTransferredSatoshis: "10000", varTransferredSatoshis: "10000",
varRecipientLockingscript: "76a91489abcdefabbaabbaabbaabbaabbaabbaabbaabba88ac", varRecipientLockingscript:
"76a91489abcdefabbaabbaabbaabbaabbaabbaabbaabba88ac",
role: "sender", role: "sender",
}, },
); );
@@ -408,7 +453,8 @@ describe("invitation command - send flow with resources", () => {
["create", "Wallet (P2PKH)", "sendSatoshis"], ["create", "Wallet (P2PKH)", "sendSatoshis"],
{ {
varTransferredSatoshis: "10000", varTransferredSatoshis: "10000",
varRecipientLockingscript: "76a91489abcdefabbaabbaabbaabbaabbaabbaabbaabba88ac", varRecipientLockingscript:
"76a91489abcdefabbaabbaabbaabbaabbaabbaabbaabba88ac",
role: "sender", role: "sender",
}, },
); );
@@ -418,8 +464,12 @@ describe("invitation command - send flow with resources", () => {
); );
expect(invitation).toBeDefined(); expect(invitation).toBeDefined();
const variables = invitation?.data.commits.flatMap((c) => c.data.variables ?? []); const variables = invitation?.data.commits.flatMap(
const transferredSatoshis = variables?.find((v) => v.variableIdentifier === "transferredSatoshis"); (c) => c.data.variables ?? [],
);
const transferredSatoshis = variables?.find(
(v) => v.variableIdentifier === "transferredSatoshis",
);
expect(transferredSatoshis).toBeDefined(); expect(transferredSatoshis).toBeDefined();
expect(transferredSatoshis?.value).toBe("10000"); expect(transferredSatoshis?.value).toBe("10000");
}); });
@@ -436,8 +486,10 @@ describe("invitation command - send flow with resources", () => {
["create", "Wallet (P2PKH)", "sendSatoshis"], ["create", "Wallet (P2PKH)", "sendSatoshis"],
{ {
varTransferredSatoshis: "10000", varTransferredSatoshis: "10000",
varRecipientLockingscript: "76a91489abcdefabbaabbaabbaabbaabbaabbaabbaabba88ac", varRecipientLockingscript:
addInput: "0000000000000000000000000000000000000000000000000000000000000000:0", "76a91489abcdefabbaabbaabbaabbaabbaabbaabbaabba88ac",
addInput:
"0000000000000000000000000000000000000000000000000000000000000000:0",
role: "sender", role: "sender",
}, },
); );
@@ -452,7 +504,7 @@ describe("invitation command - send flow with resources", () => {
* This validates our test infrastructure works correctly. * This validates our test infrastructure works correctly.
*/ */
test("fake resources are accessible via engine", async () => { test("fake resources are accessible via engine", async () => {
const resource = await addFakeResource(engine, { const resource = await addFakeResource(state!, {
valueSatoshis: 50000, valueSatoshis: 50000,
templateIdentifier: p2pkhTemplateIdentifier, templateIdentifier: p2pkhTemplateIdentifier,
outputIdentifier: "receiveOutput", outputIdentifier: "receiveOutput",
@@ -481,7 +533,8 @@ describe("invitation command - multi-step append", () => {
let paths: CommandPaths; let paths: CommandPaths;
beforeEach(async () => { beforeEach(async () => {
engine = await createMockEngine(DEFAULT_SEED); const mockEngine = await createMockEngine(DEFAULT_SEED);
engine = mockEngine.engine;
await engine.importTemplate(p2pkhTemplate); await engine.importTemplate(p2pkhTemplate);
app = await createMockAppService(engine); app = await createMockAppService(engine);
tempDir = mkdtempSync(path.join(tmpdir(), "xo-cli-invitation-append-")); tempDir = mkdtempSync(path.join(tmpdir(), "xo-cli-invitation-append-"));
@@ -516,10 +569,15 @@ describe("invitation command - multi-step append", () => {
expectLogs(spies, [{ out: "Invitation appended" }]); expectLogs(spies, [{ out: "Invitation appended" }]);
const invitation = app.invitations.find( const invitation = app.invitations.find(
(inv) => inv.data.invitationIdentifier === createResult.invitationIdentifier, (inv) =>
inv.data.invitationIdentifier === createResult.invitationIdentifier,
);
const variables = invitation?.data.commits.flatMap(
(c) => c.data.variables ?? [],
);
const requestedSatoshis = variables?.find(
(v) => v.variableIdentifier === "requestedSatoshis",
); );
const variables = invitation?.data.commits.flatMap((c) => c.data.variables ?? []);
const requestedSatoshis = variables?.find((v) => v.variableIdentifier === "requestedSatoshis");
expect(requestedSatoshis?.value).toBe("25000"); expect(requestedSatoshis?.value).toBe("25000");
}); });
@@ -569,7 +627,10 @@ describe("invitation command - multi-step append", () => {
expectLogs(spies, [{ out: "Invitation updated" }]); expectLogs(spies, [{ out: "Invitation updated" }]);
const expectedFile = path.join(tempDir, `inv-${createResult.invitationIdentifier}.json`); const expectedFile = path.join(
tempDir,
`inv-${createResult.invitationIdentifier}.json`,
);
expect(existsSync(expectedFile)).toBe(true); expect(existsSync(expectedFile)).toBe(true);
}); });
@@ -594,12 +655,17 @@ describe("invitation command - multi-step append", () => {
); );
const invitation = app.invitations.find( const invitation = app.invitations.find(
(inv) => inv.data.invitationIdentifier === createResult.invitationIdentifier, (inv) =>
inv.data.invitationIdentifier === createResult.invitationIdentifier,
); );
expect(invitation?.data.commits.length).toBeGreaterThan(1); expect(invitation?.data.commits.length).toBeGreaterThan(1);
const variables = invitation?.data.commits.flatMap((c) => c.data.variables ?? []); const variables = invitation?.data.commits.flatMap(
const requestedSatoshis = variables?.find((v) => v.variableIdentifier === "requestedSatoshis"); (c) => c.data.variables ?? [],
);
const requestedSatoshis = variables?.find(
(v) => v.variableIdentifier === "requestedSatoshis",
);
expect(requestedSatoshis?.value).toBe("10000"); expect(requestedSatoshis?.value).toBe("10000");
}); });
}); });
@@ -615,7 +681,8 @@ describe("invitation command - list and inspect", () => {
let paths: CommandPaths; let paths: CommandPaths;
beforeEach(async () => { beforeEach(async () => {
engine = await createMockEngine(DEFAULT_SEED); const mockEngine = await createMockEngine(DEFAULT_SEED);
engine = mockEngine.engine;
await engine.importTemplate(p2pkhTemplate); await engine.importTemplate(p2pkhTemplate);
app = await createMockAppService(engine); app = await createMockAppService(engine);
tempDir = mkdtempSync(path.join(tmpdir(), "xo-cli-invitation-list-")); tempDir = mkdtempSync(path.join(tmpdir(), "xo-cli-invitation-list-"));
@@ -724,7 +791,10 @@ describe("invitation command - list and inspect", () => {
{}, {},
); );
const invitationFilePath = path.join(tempDir, `inv-${createResult.invitationIdentifier}.json`); const invitationFilePath = path.join(
tempDir,
`inv-${createResult.invitationIdentifier}.json`,
);
const { io: inspectIO } = createMockIO(); const { io: inspectIO } = createMockIO();
@@ -780,7 +850,8 @@ describe("invitation command - sign flow", () => {
let paths: CommandPaths; let paths: CommandPaths;
beforeEach(async () => { beforeEach(async () => {
engine = await createMockEngine(DEFAULT_SEED); const mockEngine = await createMockEngine(DEFAULT_SEED);
engine = mockEngine.engine;
await engine.importTemplate(p2pkhTemplate); await engine.importTemplate(p2pkhTemplate);
app = await createMockAppService(engine); app = await createMockAppService(engine);
tempDir = mkdtempSync(path.join(tmpdir(), "xo-cli-invitation-sign-")); tempDir = mkdtempSync(path.join(tmpdir(), "xo-cli-invitation-sign-"));
@@ -813,7 +884,9 @@ describe("invitation command - sign flow", () => {
{}, {},
); );
expect(signResult.invitationIdentifier).toBe(createResult.invitationIdentifier); expect(signResult.invitationIdentifier).toBe(
createResult.invitationIdentifier,
);
expectLogs(spies, [{ out: "Invitation signed" }]); expectLogs(spies, [{ out: "Invitation signed" }]);
}); });
@@ -840,7 +913,8 @@ describe("invitation command - sign flow", () => {
); );
const invitation = app.invitations.find( const invitation = app.invitations.find(
(inv) => inv.data.invitationIdentifier === createResult.invitationIdentifier, (inv) =>
inv.data.invitationIdentifier === createResult.invitationIdentifier,
); );
expect(invitation).toBeDefined(); expect(invitation).toBeDefined();
@@ -897,7 +971,8 @@ describe("invitation command - import flow", () => {
let paths: CommandPaths; let paths: CommandPaths;
beforeEach(async () => { beforeEach(async () => {
engine = await createMockEngine(DEFAULT_SEED); const mockEngine = await createMockEngine(DEFAULT_SEED);
engine = mockEngine.engine;
await engine.importTemplate(p2pkhTemplate); await engine.importTemplate(p2pkhTemplate);
app = await createMockAppService(engine); app = await createMockAppService(engine);
tempDir = mkdtempSync(path.join(tmpdir(), "xo-cli-invitation-import-")); tempDir = mkdtempSync(path.join(tmpdir(), "xo-cli-invitation-import-"));
@@ -921,7 +996,10 @@ describe("invitation command - import flow", () => {
{}, {},
); );
const invitationFilePath = path.join(tempDir, `inv-${createResult.invitationIdentifier}.json`); const invitationFilePath = path.join(
tempDir,
`inv-${createResult.invitationIdentifier}.json`,
);
const secondApp = await createMockAppService(engine); const secondApp = await createMockAppService(engine);
const { io: importIO } = createMockIO(); const { io: importIO } = createMockIO();
@@ -965,7 +1043,10 @@ describe("invitation command - import flow", () => {
{}, {},
); );
const invitationFilePath = path.join(tempDir, `inv-${createResult.invitationIdentifier}.json`); const invitationFilePath = path.join(
tempDir,
`inv-${createResult.invitationIdentifier}.json`,
);
const secondApp = await createMockAppService(engine); const secondApp = await createMockAppService(engine);
const { io: importIO } = createMockIO(); const { io: importIO } = createMockIO();
@@ -991,7 +1072,10 @@ describe("invitation command - import flow", () => {
{}, {},
); );
const invitationFilePath = path.join(tempDir, `inv-${createResult.invitationIdentifier}.json`); const invitationFilePath = path.join(
tempDir,
`inv-${createResult.invitationIdentifier}.json`,
);
const secondApp = await createMockAppService(engine); const secondApp = await createMockAppService(engine);
const { io: importIO } = createMockIO(); const { io: importIO } = createMockIO();
@@ -1019,7 +1103,8 @@ describe("invitation command - auto-inputs flow", () => {
let paths: CommandPaths; let paths: CommandPaths;
beforeEach(async () => { beforeEach(async () => {
engine = await createMockEngine(DEFAULT_SEED); const mockEngine = await createMockEngine(DEFAULT_SEED);
engine = mockEngine.engine;
await engine.importTemplate(p2pkhTemplate); await engine.importTemplate(p2pkhTemplate);
app = await createMockAppService(engine); app = await createMockAppService(engine);
tempDir = mkdtempSync(path.join(tmpdir(), "xo-cli-invitation-autoinputs-")); tempDir = mkdtempSync(path.join(tmpdir(), "xo-cli-invitation-autoinputs-"));
@@ -1044,7 +1129,8 @@ describe("invitation command - auto-inputs flow", () => {
["create", "Wallet (P2PKH)", "sendSatoshis"], ["create", "Wallet (P2PKH)", "sendSatoshis"],
{ {
varTransferredSatoshis: "10000", varTransferredSatoshis: "10000",
varRecipientLockingscript: "76a91489abcdefabbaabbaabbaabbaabbaabbaabbaabba88ac", varRecipientLockingscript:
"76a91489abcdefabbaabbaabbaabbaabbaabbaabbaabba88ac",
role: "sender", role: "sender",
autoInputs: "true", autoInputs: "true",
}, },
@@ -1052,7 +1138,9 @@ describe("invitation command - auto-inputs flow", () => {
expect.fail("Expected command to throw"); expect.fail("Expected command to throw");
} catch (error) { } catch (error) {
expect(error).toBeInstanceOf(CommandError); expect(error).toBeInstanceOf(CommandError);
expect((error as CommandError).event).toBe("invitation.create.append_params_failed"); expect((error as CommandError).event).toBe(
"invitation.create.append_params_failed",
);
} }
}); });
@@ -1068,7 +1156,8 @@ describe("invitation command - auto-inputs flow", () => {
["create", "Wallet (P2PKH)", "sendSatoshis"], ["create", "Wallet (P2PKH)", "sendSatoshis"],
{ {
varTransferredSatoshis: "10000", varTransferredSatoshis: "10000",
varRecipientLockingscript: "76a91489abcdefabbaabbaabbaabbaabbaabbaabbaabba88ac", varRecipientLockingscript:
"76a91489abcdefabbaabbaabbaabbaabbaabbaabbaabba88ac",
role: "sender", role: "sender",
autoInputs: "true", autoInputs: "true",
}, },
@@ -1090,7 +1179,8 @@ describe("invitation command - broadcast flow", () => {
let paths: CommandPaths; let paths: CommandPaths;
beforeEach(async () => { beforeEach(async () => {
engine = await createMockEngine(DEFAULT_SEED); const mockEngine = await createMockEngine(DEFAULT_SEED);
engine = mockEngine.engine;
await engine.importTemplate(p2pkhTemplate); await engine.importTemplate(p2pkhTemplate);
app = await createMockAppService(engine); app = await createMockAppService(engine);
tempDir = mkdtempSync(path.join(tmpdir(), "xo-cli-invitation-broadcast-")); tempDir = mkdtempSync(path.join(tmpdir(), "xo-cli-invitation-broadcast-"));
@@ -1117,7 +1207,9 @@ describe("invitation command - broadcast flow", () => {
expect.fail("Expected command to throw"); expect.fail("Expected command to throw");
} catch (error) { } catch (error) {
expect(error).toBeInstanceOf(CommandError); expect(error).toBeInstanceOf(CommandError);
expect((error as CommandError).event).toBe("invitation.broadcast.not_found"); expect((error as CommandError).event).toBe(
"invitation.broadcast.not_found",
);
} }
}); });
@@ -1152,7 +1244,8 @@ describe("invitation command - full lifecycle", () => {
let paths: CommandPaths; let paths: CommandPaths;
beforeEach(async () => { beforeEach(async () => {
engine = await createMockEngine(DEFAULT_SEED); const mockEngine = await createMockEngine(DEFAULT_SEED);
engine = mockEngine.engine;
await engine.importTemplate(p2pkhTemplate); await engine.importTemplate(p2pkhTemplate);
app = await createMockAppService(engine); app = await createMockAppService(engine);
tempDir = mkdtempSync(path.join(tmpdir(), "xo-cli-invitation-lifecycle-")); tempDir = mkdtempSync(path.join(tmpdir(), "xo-cli-invitation-lifecycle-"));
@@ -1188,11 +1281,14 @@ describe("invitation command - full lifecycle", () => {
{}, {},
); );
expect(signResult.invitationIdentifier).toBe(createResult.invitationIdentifier); expect(signResult.invitationIdentifier).toBe(
createResult.invitationIdentifier,
);
expectLogs(signSpies, [{ out: "Invitation signed" }]); expectLogs(signSpies, [{ out: "Invitation signed" }]);
const invitation = app.invitations.find( const invitation = app.invitations.find(
(inv) => inv.data.invitationIdentifier === createResult.invitationIdentifier, (inv) =>
inv.data.invitationIdentifier === createResult.invitationIdentifier,
); );
expect(invitation).toBeDefined(); expect(invitation).toBeDefined();
expect(invitation?.data.commits.length).toBeGreaterThan(0); expect(invitation?.data.commits.length).toBeGreaterThan(0);
@@ -1210,7 +1306,10 @@ describe("invitation command - full lifecycle", () => {
{}, {},
); );
const invitationFilePath = path.join(tempDir, `inv-${createResult.invitationIdentifier}.json`); const invitationFilePath = path.join(
tempDir,
`inv-${createResult.invitationIdentifier}.json`,
);
expect(existsSync(invitationFilePath)).toBe(true); expect(existsSync(invitationFilePath)).toBe(true);
const { io: inspectIO } = createMockIO(); const { io: inspectIO } = createMockIO();
@@ -1254,7 +1353,9 @@ describe("invitation command - full lifecycle", () => {
{}, {},
); );
expect(signResult.invitationIdentifier).toBe(createResult.invitationIdentifier); expect(signResult.invitationIdentifier).toBe(
createResult.invitationIdentifier,
);
expectLogs(signSpies, [{ out: "Invitation signed" }]); expectLogs(signSpies, [{ out: "Invitation signed" }]);
}); });
@@ -1270,7 +1371,10 @@ describe("invitation command - full lifecycle", () => {
{}, {},
); );
const invitationFilePath = path.join(tempDir, `inv-${createResult.invitationIdentifier}.json`); const invitationFilePath = path.join(
tempDir,
`inv-${createResult.invitationIdentifier}.json`,
);
const afterCreate = JSON.parse(readFileSync(invitationFilePath, "utf-8")); const afterCreate = JSON.parse(readFileSync(invitationFilePath, "utf-8"));
const createCommitCount = afterCreate.commits?.length ?? 0; const createCommitCount = afterCreate.commits?.length ?? 0;
@@ -1335,7 +1439,9 @@ describe("invitation command - full lifecycle", () => {
{}, {},
); );
expect(reqResult.invitationIdentifier).toBe(createResult.invitationIdentifier); expect(reqResult.invitationIdentifier).toBe(
createResult.invitationIdentifier,
);
expect(spies.out).toHaveBeenCalled(); expect(spies.out).toHaveBeenCalled();
}); });
}); });

View File

@@ -7,7 +7,12 @@ import { DEFAULT_SEED } from "../mocks/engine";
import { handleMnemonicCommand } from "../../../src/cli/commands/mnemonic"; import { handleMnemonicCommand } from "../../../src/cli/commands/mnemonic";
import { CommandError } from "../../../src/cli/commands/types"; import { CommandError } from "../../../src/cli/commands/types";
import { createMockIO, createMockPaths, expectLogs, type LogExpectation } from "../mocks/command"; import {
createMockIO,
createMockPaths,
expectLogs,
type LogExpectation,
} from "../mocks/command";
import { BCHMnemonicURL } from "../../../src/utils/bch-mnemonic-url"; import { BCHMnemonicURL } from "../../../src/utils/bch-mnemonic-url";
type TestCase = { type TestCase = {
@@ -116,31 +121,45 @@ describe("mnemonic commands", () => {
rmSync(tempDir, { recursive: true, force: true }); rmSync(tempDir, { recursive: true, force: true });
}); });
test.each(testCases)("mnemonic command: $inputs", async ({ inputs, options, shouldThrow, expectedEvent, expectedData, logs }) => { test.each(testCases)(
const { io, spies } = createMockIO(); "mnemonic command: $inputs",
const paths = createMockPaths(tempDir); async ({
inputs,
options,
shouldThrow,
expectedEvent,
expectedData,
logs,
}) => {
const { io, spies } = createMockIO();
const paths = createMockPaths(tempDir);
if (shouldThrow) { if (shouldThrow) {
try { try {
await handleMnemonicCommand({ io, paths }, inputs, options ?? {}); await handleMnemonicCommand({ io, paths }, inputs, options ?? {});
expect.fail("Expected command to throw"); expect.fail("Expected command to throw");
} catch (error) { } catch (error) {
if (expectedEvent) { if (expectedEvent) {
expect(error).toBeInstanceOf(CommandError); expect(error).toBeInstanceOf(CommandError);
expect((error as CommandError).event).toBe(expectedEvent); expect((error as CommandError).event).toBe(expectedEvent);
}
}
} else {
const result = await handleMnemonicCommand(
{ io, paths },
inputs,
options ?? {},
);
if (expectedData) {
Object.entries(expectedData).forEach(([key, value]) => {
expect(result[key as keyof typeof result]).toEqual(value);
});
} }
} }
} else {
const result = await handleMnemonicCommand({ io, paths }, inputs, options ?? {});
if (expectedData) {
Object.entries(expectedData).forEach(([key, value]) => {
expect(result[key as keyof typeof result]).toEqual(value);
});
}
}
if (logs) { if (logs) {
expectLogs(spies, logs); expectLogs(spies, logs);
} }
}); },
);
}); });

View File

@@ -3,14 +3,23 @@ import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import path from "node:path"; import path from "node:path";
import { createMockAppService, createMockEngine, DEFAULT_SEED } from "../mocks/engine"; import {
createMockAppService,
createMockEngine,
DEFAULT_SEED,
} from "../mocks/engine";
import { type Engine } from "@xo-cash/engine"; import { type Engine } from "@xo-cash/engine";
import { p2pkhTemplate } from "../mocks/template-p2pkh"; import { p2pkhTemplate } from "../mocks/template-p2pkh";
import { AppService } from "../../../src/services/app"; import { AppService } from "../../../src/services/app";
import { handleReceiveCommand } from "../../../src/cli/commands/receive"; import { handleReceiveCommand } from "../../../src/cli/commands/receive";
import { CommandError } from "../../../src/cli/commands/types"; import { CommandError } from "../../../src/cli/commands/types";
import { createCommandDeps, createMockIO, expectLogs, type LogExpectation } from "../mocks/command"; import {
createCommandDeps,
createMockIO,
expectLogs,
type LogExpectation,
} from "../mocks/command";
type TestCase = { type TestCase = {
name: string; name: string;
@@ -72,7 +81,8 @@ describe("receive command", () => {
let tempDir: string; let tempDir: string;
beforeEach(async () => { beforeEach(async () => {
engine = await createMockEngine(DEFAULT_SEED); const mockEngine = await createMockEngine(DEFAULT_SEED);
engine = mockEngine.engine;
await engine.importTemplate(p2pkhTemplate); await engine.importTemplate(p2pkhTemplate);
app = await createMockAppService(engine); app = await createMockAppService(engine);
@@ -85,30 +95,48 @@ describe("receive command", () => {
rmSync(tempDir, { recursive: true, force: true }); rmSync(tempDir, { recursive: true, force: true });
}); });
test.each(testCases)("$name", async ({ inputs, options, shouldThrow, expectedEvent, expectedData, logs }) => { test.each(testCases)(
const { io, spies } = createMockIO(); "$name",
async ({
inputs,
options,
shouldThrow,
expectedEvent,
expectedData,
logs,
}) => {
const { io, spies } = createMockIO();
if (shouldThrow) { if (shouldThrow) {
try { try {
await handleReceiveCommand(createCommandDeps(app, io), inputs, options ?? {}); await handleReceiveCommand(
expect.fail("Expected command to throw"); createCommandDeps(app, io),
} catch (error) { inputs,
if (expectedEvent) { options ?? {},
expect(error).toBeInstanceOf(CommandError); );
expect((error as CommandError).event).toBe(expectedEvent); expect.fail("Expected command to throw");
} catch (error) {
if (expectedEvent) {
expect(error).toBeInstanceOf(CommandError);
expect((error as CommandError).event).toBe(expectedEvent);
}
}
} else {
const result = await handleReceiveCommand(
createCommandDeps(app, io),
inputs,
options ?? {},
);
if (expectedData) {
Object.entries(expectedData).forEach(([key, value]) => {
expect(result[key as keyof typeof result]).toEqual(value);
});
} }
} }
} else {
const result = await handleReceiveCommand(createCommandDeps(app, io), inputs, options ?? {});
if (expectedData) {
Object.entries(expectedData).forEach(([key, value]) => {
expect(result[key as keyof typeof result]).toEqual(value);
});
}
}
if (logs) { if (logs) {
expectLogs(spies, logs); expectLogs(spies, logs);
} }
}); },
);
}); });

View File

@@ -3,14 +3,26 @@ import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import path from "node:path"; import path from "node:path";
import { addFakeResource, createMockAppService, createMockEngine, DEFAULT_SEED, reserveResource } from "../mocks/engine"; import {
addFakeResource,
createMockAppService,
createMockEngine,
DEFAULT_SEED,
reserveResource,
} from "../mocks/engine";
import { type Engine } from "@xo-cash/engine"; import { type Engine } from "@xo-cash/engine";
import { p2pkhTemplate } from "../mocks/template-p2pkh"; import { p2pkhTemplate } from "../mocks/template-p2pkh";
import { AppService } from "../../../src/services/app"; import { AppService } from "../../../src/services/app";
import { handleResourceCommand } from "../../../src/cli/commands/resource"; import { handleResourceCommand } from "../../../src/cli/commands/resource";
import { CommandError } from "../../../src/cli/commands/types"; import { CommandError } from "../../../src/cli/commands/types";
import { createCommandDeps, createMockIO, expectLogs, type LogExpectation } from "../mocks/command"; import {
createCommandDeps,
createMockIO,
expectLogs,
type LogExpectation,
} from "../mocks/command";
import { State } from "@xo-cash/state";
type TestCase = { type TestCase = {
name: string; name: string;
@@ -94,7 +106,10 @@ const testCases: TestCase[] = [
}, },
{ {
name: "throws when unreserve called with non-existent UTXO", name: "throws when unreserve called with non-existent UTXO",
inputs: ["unreserve", "0000000000000000000000000000000000000000000000000000000000000000:0"], inputs: [
"unreserve",
"0000000000000000000000000000000000000000000000000000000000000000:0",
],
shouldThrow: true, shouldThrow: true,
expectedEvent: "resource.unreserve.utxo_missing", expectedEvent: "resource.unreserve.utxo_missing",
}, },
@@ -106,7 +121,8 @@ describe("resource command", () => {
let tempDir: string; let tempDir: string;
beforeEach(async () => { beforeEach(async () => {
engine = await createMockEngine(DEFAULT_SEED); const mockEngine = await createMockEngine(DEFAULT_SEED);
engine = mockEngine.engine;
await engine.importTemplate(p2pkhTemplate); await engine.importTemplate(p2pkhTemplate);
app = await createMockAppService(engine); app = await createMockAppService(engine);
@@ -119,41 +135,62 @@ describe("resource command", () => {
rmSync(tempDir, { recursive: true, force: true }); rmSync(tempDir, { recursive: true, force: true });
}); });
test.each(testCases)("$name", async ({ inputs, options, shouldThrow, expectedEvent, expectedData, logs }) => { test.each(testCases)(
const { io, spies } = createMockIO(); "$name",
async ({
inputs,
options,
shouldThrow,
expectedEvent,
expectedData,
logs,
}) => {
const { io, spies } = createMockIO();
if (shouldThrow) { if (shouldThrow) {
try { try {
await handleResourceCommand(createCommandDeps(app, io), inputs, options ?? {}); await handleResourceCommand(
expect.fail("Expected command to throw"); createCommandDeps(app, io),
} catch (error) { inputs,
if (expectedEvent) { options ?? {},
expect(error).toBeInstanceOf(CommandError); );
expect((error as CommandError).event).toBe(expectedEvent); expect.fail("Expected command to throw");
} catch (error) {
if (expectedEvent) {
expect(error).toBeInstanceOf(CommandError);
expect((error as CommandError).event).toBe(expectedEvent);
}
}
} else {
const result = await handleResourceCommand(
createCommandDeps(app, io),
inputs,
options ?? {},
);
if (expectedData) {
Object.entries(expectedData).forEach(([key, value]) => {
expect(result[key as keyof typeof result]).toEqual(value);
});
} }
} }
} else {
const result = await handleResourceCommand(createCommandDeps(app, io), inputs, options ?? {});
if (expectedData) {
Object.entries(expectedData).forEach(([key, value]) => {
expect(result[key as keyof typeof result]).toEqual(value);
});
}
}
if (logs) { if (logs) {
expectLogs(spies, logs); expectLogs(spies, logs);
} }
}); },
);
}); });
describe("resource command with populated data", () => { describe("resource command with populated data", () => {
let engine: Engine; let engine: Engine;
let state: State;
let app: AppService; let app: AppService;
let tempDir: string; let tempDir: string;
beforeEach(async () => { beforeEach(async () => {
engine = await createMockEngine(DEFAULT_SEED); const mockEngine = await createMockEngine(DEFAULT_SEED);
engine = mockEngine.engine;
state = mockEngine.state;
await engine.importTemplate(p2pkhTemplate); await engine.importTemplate(p2pkhTemplate);
app = await createMockAppService(engine); app = await createMockAppService(engine);
tempDir = mkdtempSync(path.join(tmpdir(), "xo-cli-resource-tests-")); tempDir = mkdtempSync(path.join(tmpdir(), "xo-cli-resource-tests-"));
@@ -165,19 +202,23 @@ describe("resource command with populated data", () => {
}); });
test("list returns count when resources exist", async () => { test("list returns count when resources exist", async () => {
await addFakeResource(engine, { valueSatoshis: 50000 }); await addFakeResource(state, { valueSatoshis: 50000 });
await addFakeResource(engine, { valueSatoshis: 25000 }); await addFakeResource(state, { valueSatoshis: 25000 });
const { io, spies } = createMockIO(); const { io, spies } = createMockIO();
const result = await handleResourceCommand(createCommandDeps(app, io), ["list"], {}); const result = await handleResourceCommand(
createCommandDeps(app, io),
["list"],
{},
);
expect(result.count).toBe(2); expect(result.count).toBe(2);
expectLogs(spies, [{ out: "Total resources: 2" }]); expectLogs(spies, [{ out: "Total resources: 2" }]);
}); });
test("list shows total satoshis", async () => { test("list shows total satoshis", async () => {
await addFakeResource(engine, { valueSatoshis: 50000 }); await addFakeResource(state, { valueSatoshis: 50000 });
await addFakeResource(engine, { valueSatoshis: 25000 }); await addFakeResource(state, { valueSatoshis: 25000 });
const { io, spies } = createMockIO(); const { io, spies } = createMockIO();
await handleResourceCommand(createCommandDeps(app, io), ["list"], {}); await handleResourceCommand(createCommandDeps(app, io), ["list"], {});
@@ -186,63 +227,99 @@ describe("resource command with populated data", () => {
}); });
test("list excludes reserved resources by default", async () => { test("list excludes reserved resources by default", async () => {
await addFakeResource(engine, { valueSatoshis: 50000 }); await addFakeResource(state, { valueSatoshis: 50000 });
await addFakeResource(engine, { valueSatoshis: 25000, reservedBy: "inv-123" }); await addFakeResource(state, {
valueSatoshis: 25000,
reservedBy: "inv-123",
});
const { io } = createMockIO(); const { io } = createMockIO();
const result = await handleResourceCommand(createCommandDeps(app, io), ["list"], {}); const result = await handleResourceCommand(
createCommandDeps(app, io),
["list"],
{},
);
expect(result.count).toBe(1); expect(result.count).toBe(1);
}); });
test("list reserved shows only reserved resources", async () => { test("list reserved shows only reserved resources", async () => {
await addFakeResource(engine, { valueSatoshis: 50000 }); await addFakeResource(state, { valueSatoshis: 50000 });
await addFakeResource(engine, { valueSatoshis: 25000, reservedBy: "inv-123" }); await addFakeResource(state, {
await addFakeResource(engine, { valueSatoshis: 10000, reservedBy: "inv-456" }); valueSatoshis: 25000,
reservedBy: "inv-123",
});
await addFakeResource(state, {
valueSatoshis: 10000,
reservedBy: "inv-456",
});
const { io, spies } = createMockIO(); const { io, spies } = createMockIO();
const result = await handleResourceCommand(createCommandDeps(app, io), ["list", "reserved"], {}); const result = await handleResourceCommand(
createCommandDeps(app, io),
["list", "reserved"],
{},
);
expect(result.count).toBe(2); expect(result.count).toBe(2);
expectLogs(spies, [{ out: "reserved for inv-123" }]); expectLogs(spies, [{ out: "reserved for inv-123" }]);
}); });
test("list all shows both reserved and unreserved", async () => { test("list all shows both reserved and unreserved", async () => {
await addFakeResource(engine, { valueSatoshis: 50000 }); await addFakeResource(state, { valueSatoshis: 50000 });
await addFakeResource(engine, { valueSatoshis: 25000, reservedBy: "inv-123" }); await addFakeResource(state, {
valueSatoshis: 25000,
reservedBy: "inv-123",
});
const { io } = createMockIO(); const { io } = createMockIO();
const result = await handleResourceCommand(createCommandDeps(app, io), ["list", "all"], {}); const result = await handleResourceCommand(
createCommandDeps(app, io),
["list", "all"],
{},
);
expect(result.count).toBe(2); expect(result.count).toBe(2);
}); });
test("unreserve releases a reserved UTXO", async () => { test("unreserve releases a reserved UTXO", async () => {
const resource = await addFakeResource(engine, { valueSatoshis: 25000, reservedBy: "inv-123" }); const resource = await addFakeResource(state, {
valueSatoshis: 25000,
reservedBy: "inv-123",
});
const { io, spies } = createMockIO(); const { io, spies } = createMockIO();
await handleResourceCommand( await handleResourceCommand(
createCommandDeps(app, io), createCommandDeps(app, io),
["unreserve", `${resource.outpointTransactionHash}:${resource.outpointIndex}`], [
"unreserve",
`${resource.outpointTransactionHash}:${resource.outpointIndex}`,
],
{}, {},
); );
expectLogs(spies, [{ out: "Unreserved" }, { out: "was reserved for inv-123" }]); expectLogs(spies, [
{ out: "Unreserved" },
{ out: "was reserved for inv-123" },
]);
const resources = await engine.listUnspentOutputsData(); const resources = await engine.listUnspentOutputsData();
const target = resources.find( const target = resources.find(
r => r.outpointTransactionHash === resource.outpointTransactionHash, (r) => r.outpointTransactionHash === resource.outpointTransactionHash,
); );
expect(target?.reservedBy).toBeUndefined(); expect(target?.reservedBy).toBeUndefined();
}); });
test("unreserve reports when UTXO is not reserved", async () => { test("unreserve reports when UTXO is not reserved", async () => {
const resource = await addFakeResource(engine, { valueSatoshis: 25000 }); const resource = await addFakeResource(state, { valueSatoshis: 25000 });
const { io, spies } = createMockIO(); const { io, spies } = createMockIO();
await handleResourceCommand( await handleResourceCommand(
createCommandDeps(app, io), createCommandDeps(app, io),
["unreserve", `${resource.outpointTransactionHash}:${resource.outpointIndex}`], [
"unreserve",
`${resource.outpointTransactionHash}:${resource.outpointIndex}`,
],
{}, {},
); );
@@ -250,23 +327,33 @@ describe("resource command with populated data", () => {
}); });
test("unreserve-all releases all reserved UTXOs", async () => { test("unreserve-all releases all reserved UTXOs", async () => {
await addFakeResource(engine, { valueSatoshis: 50000 }); await addFakeResource(state, { valueSatoshis: 50000 });
await addFakeResource(engine, { valueSatoshis: 25000, reservedBy: "inv-123" }); await addFakeResource(state, {
await addFakeResource(engine, { valueSatoshis: 10000, reservedBy: "inv-456" }); valueSatoshis: 25000,
reservedBy: "inv-123",
});
await addFakeResource(state, {
valueSatoshis: 10000,
reservedBy: "inv-456",
});
const { io, spies } = createMockIO(); const { io, spies } = createMockIO();
const result = await handleResourceCommand(createCommandDeps(app, io), ["unreserve-all"], {}); const result = await handleResourceCommand(
createCommandDeps(app, io),
["unreserve-all"],
{},
);
expect(result.count).toBe(2); expect(result.count).toBe(2);
expectLogs(spies, [{ out: "Unreserved" }, { out: "2" }]); expectLogs(spies, [{ out: "Unreserved" }, { out: "2" }]);
const resources = await engine.listUnspentOutputsData(); const resources = await engine.listUnspentOutputsData();
const reserved = resources.filter(r => r.reservedBy); const reserved = resources.filter((r) => r.reservedBy);
expect(reserved).toHaveLength(0); expect(reserved).toHaveLength(0);
}); });
test("list displays outpoint information", async () => { test("list displays outpoint information", async () => {
const resource = await addFakeResource(engine, { valueSatoshis: 12345 }); const resource = await addFakeResource(state, { valueSatoshis: 12345 });
const { io, spies } = createMockIO(); const { io, spies } = createMockIO();
await handleResourceCommand(createCommandDeps(app, io), ["list"], {}); await handleResourceCommand(createCommandDeps(app, io), ["list"], {});

View File

@@ -3,14 +3,26 @@ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import path from "node:path"; import path from "node:path";
import { createMockAppService, createMockEngine, DEFAULT_SEED } from "../mocks/engine"; import {
createMockAppService,
createMockEngine,
DEFAULT_SEED,
} from "../mocks/engine";
import { type Engine } from "@xo-cash/engine"; import { type Engine } from "@xo-cash/engine";
import { p2pkhTemplate, p2pkhTemplateIdentifier } from "../mocks/template-p2pkh"; import {
p2pkhTemplate,
p2pkhTemplateIdentifier,
} from "../mocks/template-p2pkh";
import { AppService } from "../../../src/services/app"; import { AppService } from "../../../src/services/app";
import { handleTemplateCommand } from "../../../src/cli/commands/template"; import { handleTemplateCommand } from "../../../src/cli/commands/template";
import { CommandError } from "../../../src/cli/commands/types"; import { CommandError } from "../../../src/cli/commands/types";
import { createCommandDeps, createMockIO, expectLogs, type LogExpectation } from "../mocks/command"; import {
createCommandDeps,
createMockIO,
expectLogs,
type LogExpectation,
} from "../mocks/command";
type TestCase = { type TestCase = {
name: string; name: string;
@@ -171,7 +183,8 @@ describe("template command", () => {
let tempDir: string; let tempDir: string;
beforeEach(async () => { beforeEach(async () => {
engine = await createMockEngine(DEFAULT_SEED); const mockEngine = await createMockEngine(DEFAULT_SEED);
engine = mockEngine.engine;
await engine.importTemplate(p2pkhTemplate); await engine.importTemplate(p2pkhTemplate);
app = await createMockAppService(engine); app = await createMockAppService(engine);
@@ -184,32 +197,50 @@ describe("template command", () => {
rmSync(tempDir, { recursive: true, force: true }); rmSync(tempDir, { recursive: true, force: true });
}); });
test.each(testCases)("$name", async ({ inputs, options, shouldThrow, expectedEvent, expectedData, logs }) => { test.each(testCases)(
const { io, spies } = createMockIO(); "$name",
async ({
inputs,
options,
shouldThrow,
expectedEvent,
expectedData,
logs,
}) => {
const { io, spies } = createMockIO();
if (shouldThrow) { if (shouldThrow) {
try { try {
await handleTemplateCommand(createCommandDeps(app, io), inputs, options ?? {}); await handleTemplateCommand(
expect.fail("Expected command to throw"); createCommandDeps(app, io),
} catch (error) { inputs,
if (expectedEvent) { options ?? {},
expect(error).toBeInstanceOf(CommandError); );
expect((error as CommandError).event).toBe(expectedEvent); expect.fail("Expected command to throw");
} catch (error) {
if (expectedEvent) {
expect(error).toBeInstanceOf(CommandError);
expect((error as CommandError).event).toBe(expectedEvent);
}
}
} else {
const result = await handleTemplateCommand(
createCommandDeps(app, io),
inputs,
options ?? {},
);
if (expectedData) {
Object.entries(expectedData).forEach(([key, value]) => {
expect(result[key as keyof typeof result]).toEqual(value);
});
} }
} }
} else {
const result = await handleTemplateCommand(createCommandDeps(app, io), inputs, options ?? {});
if (expectedData) {
Object.entries(expectedData).forEach(([key, value]) => {
expect(result[key as keyof typeof result]).toEqual(value);
});
}
}
if (logs) { if (logs) {
expectLogs(spies, logs); expectLogs(spies, logs);
} }
}); },
);
test("import imports template from file", async () => { test("import imports template from file", async () => {
const templatePath = path.join(tempDir, "test-template.json"); const templatePath = path.join(tempDir, "test-template.json");

View File

@@ -1,5 +1,11 @@
import { expect, test, describe, beforeEach, afterEach } from "vitest"; import { expect, test, describe, beforeEach, afterEach } from "vitest";
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import {
existsSync,
mkdirSync,
readFileSync,
rmSync,
writeFileSync,
} from "node:fs";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import path from "node:path"; import path from "node:path";
@@ -12,7 +18,8 @@ import {
} from "../../src/cli/mnemonic"; } from "../../src/cli/mnemonic";
import { BCHMnemonicURL } from "../../src/utils/bch-mnemonic-url"; import { BCHMnemonicURL } from "../../src/utils/bch-mnemonic-url";
const TEST_SEED = "page pencil stock planet limb cluster assault speak off joke private pioneer"; const TEST_SEED =
"page pencil stock planet limb cluster assault speak off joke private pioneer";
describe("mnemonic utilities", () => { describe("mnemonic utilities", () => {
let tempDir: string; let tempDir: string;
@@ -68,7 +75,11 @@ describe("mnemonic utilities", () => {
}); });
test("sanitizes filename to basename only", () => { test("sanitizes filename to basename only", () => {
const filename = createMnemonicFile(tempDir, TEST_SEED, "../../../evil-path"); const filename = createMnemonicFile(
tempDir,
TEST_SEED,
"../../../evil-path",
);
expect(filename).toBe("evil-path"); expect(filename).toBe("evil-path");
expect(existsSync(path.join(tempDir, "evil-path"))).toBe(true); expect(existsSync(path.join(tempDir, "evil-path"))).toBe(true);
@@ -95,7 +106,10 @@ describe("mnemonic utilities", () => {
try { try {
writeFileSync(path.join(tempDir, "mnemonic-relative"), "test"); writeFileSync(path.join(tempDir, "mnemonic-relative"), "test");
const resolved = resolveMnemonicFilePath("/nonexistent", "mnemonic-relative"); const resolved = resolveMnemonicFilePath(
"/nonexistent",
"mnemonic-relative",
);
expect(resolved).toBe(path.join(tempDir, "mnemonic-relative")); expect(resolved).toBe(path.join(tempDir, "mnemonic-relative"));
} finally { } finally {
process.chdir(originalCwd); process.chdir(originalCwd);
@@ -110,15 +124,18 @@ describe("mnemonic utilities", () => {
}); });
test("throws when file not found anywhere", () => { test("throws when file not found anywhere", () => {
expect(() => resolveMnemonicFilePath(tempDir, "nonexistent-file")).toThrow( expect(() =>
/Mnemonic file not found/, resolveMnemonicFilePath(tempDir, "nonexistent-file"),
); ).toThrow(/Mnemonic file not found/);
}); });
test("strips path components and looks up basename in mnemonicsDir", () => { test("strips path components and looks up basename in mnemonicsDir", () => {
writeFileSync(path.join(tempDir, "mnemonic-basename"), "test"); writeFileSync(path.join(tempDir, "mnemonic-basename"), "test");
const resolved = resolveMnemonicFilePath(tempDir, "some/path/mnemonic-basename"); const resolved = resolveMnemonicFilePath(
tempDir,
"some/path/mnemonic-basename",
);
expect(resolved).toBe(path.join(tempDir, "mnemonic-basename")); expect(resolved).toBe(path.join(tempDir, "mnemonic-basename"));
}); });
}); });
@@ -140,11 +157,16 @@ describe("mnemonic utilities", () => {
}); });
test("throws when file not found", () => { test("throws when file not found", () => {
expect(() => loadMnemonic(tempDir, "nonexistent")).toThrow(/Mnemonic file not found/); expect(() => loadMnemonic(tempDir, "nonexistent")).toThrow(
/Mnemonic file not found/,
);
}); });
test("throws when file contains invalid data", () => { test("throws when file contains invalid data", () => {
writeFileSync(path.join(tempDir, "mnemonic-invalid"), "not a valid mnemonic url"); writeFileSync(
path.join(tempDir, "mnemonic-invalid"),
"not a valid mnemonic url",
);
expect(() => loadMnemonic(tempDir, "mnemonic-invalid")).toThrow(); expect(() => loadMnemonic(tempDir, "mnemonic-invalid")).toThrow();
}); });

View File

@@ -92,27 +92,36 @@ export const createMockIO = (): MockIO => {
* @param spies - The mock IO spies from createMockIO * @param spies - The mock IO spies from createMockIO
* @param logs - Array of log expectations to validate * @param logs - Array of log expectations to validate
*/ */
export const expectLogs = (spies: MockIOSpies, logs: LogExpectation[]): void => { export const expectLogs = (
spies: MockIOSpies,
logs: LogExpectation[],
): void => {
for (const log of logs) { for (const log of logs) {
if (log.out !== undefined) { if (log.out !== undefined) {
if (log.exact) { if (log.exact) {
expect(spies.out).toHaveBeenCalledWith(log.out); expect(spies.out).toHaveBeenCalledWith(log.out);
} else { } else {
expect(spies.out).toHaveBeenCalledWith(expect.stringContaining(log.out)); expect(spies.out).toHaveBeenCalledWith(
expect.stringContaining(log.out),
);
} }
} }
if (log.err !== undefined) { if (log.err !== undefined) {
if (log.exact) { if (log.exact) {
expect(spies.err).toHaveBeenCalledWith(log.err); expect(spies.err).toHaveBeenCalledWith(log.err);
} else { } else {
expect(spies.err).toHaveBeenCalledWith(expect.stringContaining(log.err)); expect(spies.err).toHaveBeenCalledWith(
expect.stringContaining(log.err),
);
} }
} }
if (log.verbose !== undefined) { if (log.verbose !== undefined) {
if (log.exact) { if (log.exact) {
expect(spies.verbose).toHaveBeenCalledWith(log.verbose); expect(spies.verbose).toHaveBeenCalledWith(log.verbose);
} else { } else {
expect(spies.verbose).toHaveBeenCalledWith(expect.stringContaining(log.verbose)); expect(spies.verbose).toHaveBeenCalledWith(
expect.stringContaining(log.verbose),
);
} }
} }
} }

View File

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

View File

@@ -1,6 +1,12 @@
import { BlockchainMonitor, Engine } from "@xo-cash/engine"; import { BlockchainMonitor, Engine } from "@xo-cash/engine";
import { createStorageAdapter, State, StorageType, type UnspentOutputData } from "@xo-cash/state"; import {
createStorageAdapter,
State,
StorageType,
UnspentOutputStatus,
type UnspentOutputData,
} from "@xo-cash/state";
import { InMemoryBlockchainProvider } from "@xo-cash/engine"; import { InMemoryBlockchainProvider } from "@xo-cash/engine";
import { convertMnemonicToSeedBytes } from "@xo-cash/crypto"; import { convertMnemonicToSeedBytes } from "@xo-cash/crypto";
@@ -8,8 +14,11 @@ import { binToHex, sha256 } from "@bitauth/libauth";
import { AppService } from "../../../src/services/app"; import { AppService } from "../../../src/services/app";
import { InMemoryStorage } from "../../../src/services/storage"; import { InMemoryStorage } from "../../../src/services/storage";
import { MockElectrumService } from "./electrum-service"; import { MockElectrumService } from "./electrum-service";
import { MockRatesService } from "./rates-service";
import { RatesService } from "../../../src/services/rates";
export const DEFAULT_SEED = "page pencil stock planet limb cluster assault speak off joke private pioneer"; export const DEFAULT_SEED =
"page pencil stock planet limb cluster assault speak off joke private pioneer";
/** /**
* Options for creating a fake resource (UTXO) in tests. * Options for creating a fake resource (UTXO) in tests.
@@ -39,7 +48,9 @@ export type FakeResourceOptions = {
export const randomTxHash = (): string => { export const randomTxHash = (): string => {
const bytes = new Uint8Array(32); const bytes = new Uint8Array(32);
crypto.getRandomValues(bytes); crypto.getRandomValues(bytes);
return Array.from(bytes).map(b => b.toString(16).padStart(2, "0")).join(""); return Array.from(bytes)
.map((b) => b.toString(16).padStart(2, "0"))
.join("");
}; };
/** /**
@@ -49,11 +60,11 @@ export const randomTxHash = (): string => {
* @returns The created UnspentOutputData object. * @returns The created UnspentOutputData object.
*/ */
export const addFakeResource = async ( export const addFakeResource = async (
engine: Engine, state: State,
options: FakeResourceOptions = {}, options: FakeResourceOptions = {},
): Promise<UnspentOutputData> => { ): Promise<UnspentOutputData> => {
const resource: UnspentOutputData = { const resource: UnspentOutputData = {
status: "confirmed", status: UnspentOutputStatus.CONFIRMED,
selectable: true, selectable: true,
privacy: false, privacy: false,
templateIdentifier: options.templateIdentifier ?? "test-template", templateIdentifier: options.templateIdentifier ?? "test-template",
@@ -62,11 +73,13 @@ export const addFakeResource = async (
outpointTransactionHash: options.outpointTransactionHash ?? randomTxHash(), outpointTransactionHash: options.outpointTransactionHash ?? randomTxHash(),
minedAtHeight: options.minedAtHeight ?? 800000, minedAtHeight: options.minedAtHeight ?? 800000,
valueSatoshis: options.valueSatoshis ?? 10000, valueSatoshis: options.valueSatoshis ?? 10000,
lockingBytecode: options.lockingBytecode ?? "76a914000000000000000000000000000000000000000088ac", lockingBytecode:
options.lockingBytecode ??
"76a914000000000000000000000000000000000000000088ac",
reservedBy: options.reservedBy, reservedBy: options.reservedBy,
}; };
await engine.state.storeUnspentOutputData(resource); await state.storeUnspentOutputData(resource);
return resource; return resource;
}; };
@@ -78,12 +91,12 @@ export const addFakeResource = async (
* @param invitationIdentifier - The invitation identifier to reserve for. * @param invitationIdentifier - The invitation identifier to reserve for.
*/ */
export const reserveResource = async ( export const reserveResource = async (
engine: Engine, state: State,
outpointTransactionHash: string, outpointTransactionHash: string,
outpointIndex: number, outpointIndex: number,
invitationIdentifier: string, invitationIdentifier: string,
): Promise<void> => { ): Promise<void> => {
await engine.state.executeBulkUnspentOutputReservation( await state.executeBulkUnspentOutputReservation(
[{ outpointTransactionHash, outpointIndex }], [{ outpointTransactionHash, outpointIndex }],
true, true,
invitationIdentifier, invitationIdentifier,
@@ -98,12 +111,12 @@ export const reserveResource = async (
* @param invitationIdentifier - The invitation identifier to unreserve from. * @param invitationIdentifier - The invitation identifier to unreserve from.
*/ */
export const unreserveResource = async ( export const unreserveResource = async (
engine: Engine, state: State,
outpointTransactionHash: string, outpointTransactionHash: string,
outpointIndex: number, outpointIndex: number,
invitationIdentifier: string, invitationIdentifier: string,
): Promise<void> => { ): Promise<void> => {
await engine.state.executeBulkUnspentOutputReservation( await state.executeBulkUnspentOutputReservation(
[{ outpointTransactionHash, outpointIndex }], [{ outpointTransactionHash, outpointIndex }],
false, false,
invitationIdentifier, invitationIdentifier,
@@ -143,13 +156,14 @@ export const createMockEngine = async (seed: string) => {
const engine = new Engine(seed, state, blockchainMonitor, blockchainProvider); const engine = new Engine(seed, state, blockchainMonitor, blockchainProvider);
await engine.initializeStateSync(); await engine.initializeStateSync();
return engine; return { engine, state, blockchainMonitor, blockchainProvider };
}; };
export const createMockAppService = async (engine: Engine) => { export const createMockAppService = async (engine: Engine) => {
const storage = await InMemoryStorage.create(); const storage = await InMemoryStorage.create();
const electrum = new MockElectrumService(); const mockRates = new MockRatesService();
const rates = new RatesService(mockRates);
const config = { const config = {
syncServerUrl: "http://localhost:3000", syncServerUrl: "http://localhost:3000",
@@ -160,5 +174,5 @@ export const createMockAppService = async (engine: Engine) => {
invitationStoragePath: "test-invitations.db", invitationStoragePath: "test-invitations.db",
}; };
return new AppService(engine, storage, config, electrum); return new AppService(engine, storage, config, rates);
}; };

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -30,7 +30,9 @@ describe("paths utilities", () => {
test("returns path under config dir", () => { test("returns path under config dir", () => {
const mnemonicsDir = getMnemonicsDir(); const mnemonicsDir = getMnemonicsDir();
expect(mnemonicsDir).toBe(path.join(homedir(), ".config", "xo-cli", "mnemonics")); expect(mnemonicsDir).toBe(
path.join(homedir(), ".config", "xo-cli", "mnemonics"),
);
}); });
test("creates the directory if it does not exist", () => { test("creates the directory if it does not exist", () => {
@@ -58,7 +60,9 @@ describe("paths utilities", () => {
test("returns .wallet file path under config dir", () => { test("returns .wallet file path under config dir", () => {
const walletConfigPath = getWalletConfigPath(); const walletConfigPath = getWalletConfigPath();
expect(walletConfigPath).toBe(path.join(homedir(), ".config", "xo-cli", ".wallet")); expect(walletConfigPath).toBe(
path.join(homedir(), ".config", "xo-cli", ".wallet"),
);
}); });
}); });
@@ -111,9 +115,9 @@ describe("paths utilities", () => {
}); });
test("throws when file not found anywhere", () => { test("throws when file not found anywhere", () => {
expect(() => resolveMnemonicFilePath("nonexistent-mnemonic-file-xyz")).toThrow( expect(() =>
/Mnemonic file not found/, resolveMnemonicFilePath("nonexistent-mnemonic-file-xyz"),
); ).toThrow(/Mnemonic file not found/);
}); });
test("does not resolve absolute path if file does not exist", () => { test("does not resolve absolute path if file does not exist", () => {