Compare commits
2 Commits
b475b23beb
...
df4f438f6d
| Author | SHA1 | Date | |
|---|---|---|---|
| df4f438f6d | |||
| 55c75501d5 |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -10,3 +10,5 @@ dist/
|
||||
*.sqlite-journal
|
||||
resolvedTemplate.json
|
||||
mnemonic-*
|
||||
.xo-cli-wallet
|
||||
inv-*.json
|
||||
1
p2pkh-template.json
Normal file
1
p2pkh-template.json
Normal file
File diff suppressed because one or more lines are too long
1209
package-lock.json
generated
1209
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -7,7 +7,9 @@
|
||||
"dev": "SYNC_SERVER_URL=https://sync.xo.harvmaster.com tsx src/index.ts",
|
||||
"build": "tsc",
|
||||
"start": "SYNC_SERVER_URL=https://sync.xo.harvmaster.com node dist/index.js",
|
||||
"test": "echo \"Error: no test specified\" && exit 1",
|
||||
"test": "vitest --run",
|
||||
"test:watch": "vitest",
|
||||
"test:coverage": "vitest run --coverage",
|
||||
"nuke": "tsx scripts/rm-dbs.ts",
|
||||
"nuke:dry": "tsx scripts/rm-dbs.ts --dry",
|
||||
"format": "prettier --write \"**/*.{js,ts,md,json}\" --ignore-path .gitignore",
|
||||
@@ -43,6 +45,7 @@
|
||||
"@types/qrcode": "^1.5.6",
|
||||
"@types/react": "^19.2.14",
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "^5.9.3"
|
||||
"typescript": "^5.9.3",
|
||||
"vitest": "^4.1.2"
|
||||
}
|
||||
}
|
||||
|
||||
234
src/cli/README.md
Normal file
234
src/cli/README.md
Normal file
@@ -0,0 +1,234 @@
|
||||
# XO CLI
|
||||
|
||||
Command-line interface for the XO Engine. Create wallets, manage templates, build invitations, sign transactions, and broadcast them to the Bitcoin Cash network.
|
||||
|
||||
## Getting Started
|
||||
|
||||
```bash
|
||||
# Run any command via tsx from the cli/ directory
|
||||
npx tsx src/cli/index.ts <command> [options]
|
||||
```
|
||||
|
||||
### Wallet Setup
|
||||
|
||||
Before using most commands you need a mnemonic wallet file.
|
||||
|
||||
```bash
|
||||
# Generate a new mnemonic and save it to a file
|
||||
xo-cli mnemonic create
|
||||
|
||||
# Import an existing mnemonic seed phrase
|
||||
xo-cli mnemonic import page pencil stock planet limb cluster assault speak off joke private pioneer
|
||||
|
||||
# List available mnemonic files in the current directory
|
||||
xo-cli mnemonic list
|
||||
```
|
||||
|
||||
Mnemonic files are stored in the working directory with the prefix `mnemonic-`.
|
||||
|
||||
### Wallet Persistence
|
||||
|
||||
The first time you pass `-m <file>`, the choice is saved to `.xo-cli-wallet`. Subsequent commands will use that wallet automatically so you can omit `-m`.
|
||||
|
||||
```bash
|
||||
# First run — pass the wallet explicitly
|
||||
xo-cli resource list -m mnemonic-nuclear
|
||||
|
||||
# All future runs remember the wallet
|
||||
xo-cli resource list
|
||||
```
|
||||
|
||||
To switch wallets, pass `-m` again with a different file.
|
||||
|
||||
## Global Options
|
||||
|
||||
| Flag | Description |
|
||||
|---|---|
|
||||
| `-m`, `--mnemonic-file <file>` | Mnemonic file to use (persisted after first use) |
|
||||
| `-v`, `--verbose` | Show detailed debug output |
|
||||
| `-h`, `--help` | Show help message |
|
||||
|
||||
## Commands
|
||||
|
||||
### `mnemonic` — Manage Wallet Files
|
||||
|
||||
```bash
|
||||
xo-cli mnemonic create # Generate a new mnemonic
|
||||
xo-cli mnemonic import <seed words...> # Import a mnemonic from seed words
|
||||
xo-cli mnemonic list # List mnemonic files in cwd
|
||||
```
|
||||
|
||||
**Options:**
|
||||
- `-o <filename>` — Custom output filename for the mnemonic file.
|
||||
|
||||
### `template` — Manage Templates
|
||||
|
||||
```bash
|
||||
xo-cli template import <template-file> # Import a template
|
||||
xo-cli template list # List imported templates
|
||||
xo-cli template list <category> <template-id> # List items in a category
|
||||
xo-cli template inspect <category> <template-id> <field> # Inspect a specific field
|
||||
xo-cli template set-default <template-file> <output-id> <role> # Set default locking params
|
||||
```
|
||||
|
||||
**Categories:** `action`, `transaction`, `output`, `lockingscript`, `variable`
|
||||
|
||||
**Example — discover what a template offers:**
|
||||
|
||||
```bash
|
||||
xo-cli template import p2pkh-template.json
|
||||
xo-cli template list
|
||||
xo-cli template list action <template-identifier>
|
||||
```
|
||||
|
||||
### `resource` — Manage UTXOs
|
||||
|
||||
```bash
|
||||
xo-cli resource list # List unreserved (spendable) UTXOs
|
||||
xo-cli resource list reserved # List UTXOs reserved by invitations
|
||||
xo-cli resource list all # List all UTXOs (reserved + unreserved)
|
||||
xo-cli resource unreserve <txhash:vout> # Unreserve a specific UTXO
|
||||
xo-cli resource unreserve-all # Unreserve all reserved UTXOs
|
||||
```
|
||||
|
||||
Each UTXO is displayed as `<txhash>:<vout> <sats> <outputId> (height <n>)`.
|
||||
|
||||
### `receive` — Generate a Receiving Address
|
||||
|
||||
```bash
|
||||
xo-cli receive <template-file> <output-identifier> [role-identifier]
|
||||
```
|
||||
|
||||
Generates a single-use BCH cash address from a template. If the role is omitted, the first available role is used.
|
||||
|
||||
```bash
|
||||
xo-cli receive p2pkh-template.json receiveOutput receiver
|
||||
```
|
||||
|
||||
### `invitation` — Build, Sign & Broadcast Transactions
|
||||
|
||||
This is the core command for sending funds. An invitation goes through these stages: **create** → **sign** → **broadcast**.
|
||||
|
||||
```bash
|
||||
xo-cli invitation create <template-file> <action-id> [options]
|
||||
xo-cli invitation append <invitation-id> [options]
|
||||
xo-cli invitation sign <invitation-id>
|
||||
xo-cli invitation broadcast <invitation-id>
|
||||
xo-cli invitation requirements <invitation-id>
|
||||
xo-cli invitation import <invitation-file>
|
||||
xo-cli invitation list
|
||||
```
|
||||
|
||||
#### Create / Append Options
|
||||
|
||||
| Flag | Description |
|
||||
|---|---|
|
||||
| `-var-<name> <value>` | Set a template variable (kebab-case → camelCase) |
|
||||
| `--add-input <txhash:vout>` | Add UTXO input(s), comma-separated |
|
||||
| `--add-output <id>` | Override output(s) — **omit to auto-discover from template** |
|
||||
| `--auto-inputs` | Automatically select UTXOs to cover the required amount |
|
||||
| `-role <role>` | Role identifier for variable scoping and bytecode generation |
|
||||
| `--sign` | Auto-sign after all requirements are satisfied |
|
||||
| `--broadcast` | Auto-broadcast after signing (implies `--sign`) |
|
||||
|
||||
When inputs are provided, a **change output is automatically added** if the input total exceeds the required amount + fee (500 sats). Change below the dust threshold (546 sats) is donated as fee.
|
||||
|
||||
Outputs are **auto-discovered from the template** when `--add-output` is omitted, so you typically don't need to specify them.
|
||||
|
||||
#### Variable Naming
|
||||
|
||||
Variable flags use kebab-case which maps to the template's camelCase identifiers:
|
||||
|
||||
```
|
||||
-var-transferred-satoshis 4678 → transferredSatoshis = "4678"
|
||||
-var-recipient-lockingscript <addr> → recipientLockingscript = "<addr>"
|
||||
```
|
||||
|
||||
## Full Send Flow
|
||||
|
||||
### One-Command Send (Recommended)
|
||||
|
||||
With `--broadcast`, the entire create → sign → broadcast flow happens in a single invocation:
|
||||
|
||||
```bash
|
||||
# 1. List UTXOs to pick an input
|
||||
xo-cli resource list
|
||||
|
||||
# 2. Send in one shot
|
||||
xo-cli invitation create p2pkh-template.json sendSatoshis \
|
||||
-var-transferred-satoshis 4678 \
|
||||
-var-recipient-lockingscript "bitcoincash:qz..." \
|
||||
--add-input <txhash>:<vout> \
|
||||
-role sender \
|
||||
--broadcast
|
||||
```
|
||||
|
||||
Output:
|
||||
|
||||
```
|
||||
Invitation created: abc123.json (abc123)
|
||||
Invitation signed: abc123
|
||||
Transaction broadcast: <txid>
|
||||
```
|
||||
|
||||
### Step-by-Step Send
|
||||
|
||||
For multi-party transactions or debugging, you can run each step separately:
|
||||
|
||||
```bash
|
||||
# 1. Import template (only needed once)
|
||||
xo-cli template import p2pkh-template.json
|
||||
|
||||
# 2. Check available UTXOs
|
||||
xo-cli resource list
|
||||
|
||||
# 3. Create the invitation with variables and inputs
|
||||
xo-cli invitation create p2pkh-template.json sendSatoshis \
|
||||
-var-transferred-satoshis 4678 \
|
||||
-var-recipient-lockingscript "bitcoincash:qz..." \
|
||||
--add-input <txhash>:<vout> \
|
||||
-role sender
|
||||
|
||||
# 4. Sign
|
||||
xo-cli invitation sign <invitation-id>
|
||||
|
||||
# 5. Broadcast
|
||||
xo-cli invitation broadcast <invitation-id>
|
||||
```
|
||||
|
||||
### Using Auto-Inputs
|
||||
|
||||
Instead of manually selecting UTXOs, let the CLI pick them:
|
||||
|
||||
```bash
|
||||
xo-cli invitation create p2pkh-template.json sendSatoshis \
|
||||
-var-transferred-satoshis 4678 \
|
||||
-var-recipient-lockingscript "bitcoincash:qz..." \
|
||||
--auto-inputs \
|
||||
-role sender \
|
||||
--broadcast
|
||||
```
|
||||
|
||||
## Shell Completions
|
||||
|
||||
Tab-completion is available for bash, zsh, and fish:
|
||||
|
||||
```bash
|
||||
# Bash
|
||||
eval "$(xo-cli completions bash)"
|
||||
|
||||
# Zsh
|
||||
eval "$(xo-cli completions zsh)"
|
||||
|
||||
# Fish
|
||||
xo-cli completions fish | source
|
||||
```
|
||||
|
||||
## File Conventions
|
||||
|
||||
| File/Pattern | Purpose |
|
||||
|---|---|
|
||||
| `mnemonic-*` | Wallet mnemonic files |
|
||||
| `.xo-cli-wallet` | Persisted wallet selection |
|
||||
| `*.json` | Invitation files (saved by create/append) |
|
||||
| `*.db` | Engine database files |
|
||||
80
src/cli/arguments.ts
Normal file
80
src/cli/arguments.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* CLI Argument extraction and validation.
|
||||
*
|
||||
* Converts `-${key}` or `--${key}` to `key` in the args object.
|
||||
*/
|
||||
import { z } from "zod";
|
||||
|
||||
/**
|
||||
* Converts the CLI args to a key-value object and return the options object along with the other arguments still in the array.\
|
||||
* eg: `xo-cli mnemonic create page pencil stock planet limb cluster assault speak off joke private pioneer -v -o mnemonic.txt` will return:
|
||||
* {
|
||||
* args: ["mnemonic", "create", "page", "pencil", "stock", "planet", "limb", "cluster", "assault", "speak", "off", "joke", "private", "pioneer"],
|
||||
* options: {
|
||||
* output: "mnemonic.txt",
|
||||
* verbose: "true",
|
||||
* },
|
||||
* }
|
||||
*
|
||||
* @param args - The CLI args to convert.
|
||||
* @returns The key-value object.
|
||||
*/
|
||||
export function convertArgsToObject(args: string[]): { args: string[], options: Record<string, string> } {
|
||||
// Map of single-character short flags to their canonical long names
|
||||
const shortToFull: Record<string, string> = {
|
||||
'm': 'mnemonicFile',
|
||||
'o': 'output',
|
||||
'v': 'verbose',
|
||||
'h': 'help',
|
||||
};
|
||||
|
||||
// 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.
|
||||
const booleanFlags = new Set<string>([
|
||||
'verbose',
|
||||
'help',
|
||||
'autoInputs',
|
||||
'sign',
|
||||
'broadcast',
|
||||
]);
|
||||
|
||||
const positionalArgs: string[] = [];
|
||||
const optionsObject: Record<string, string> = {};
|
||||
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
const arg = args[i];
|
||||
|
||||
// Collect non-option arguments as positional args
|
||||
if (!arg || !arg.startsWith("-")) {
|
||||
if (arg) positionalArgs.push(arg);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Format the option key:
|
||||
// - Remove the leading `-`s
|
||||
// - Convert kebab-case to camelCase
|
||||
// - Expand known short forms to their full names
|
||||
let key = arg.replace(/^-+/, "").replace(/-([a-z])/g, (_, letter) => letter.toUpperCase());
|
||||
key = shortToFull[key] ?? key;
|
||||
|
||||
// Known boolean flags never take a value
|
||||
if (booleanFlags.has(key)) {
|
||||
optionsObject[key] = "true";
|
||||
continue;
|
||||
}
|
||||
|
||||
const nextArg = args[i + 1];
|
||||
|
||||
// If there's no next arg or it starts with `-`, treat this as a boolean flag
|
||||
if (!nextArg || nextArg.startsWith("-")) {
|
||||
optionsObject[key] = "true";
|
||||
continue;
|
||||
}
|
||||
|
||||
// Consume the next arg as the value and skip it in the next iteration
|
||||
optionsObject[key] = nextArg;
|
||||
i++;
|
||||
}
|
||||
|
||||
return { args: positionalArgs, options: optionsObject };
|
||||
}
|
||||
48
src/cli/cli-utils.ts
Normal file
48
src/cli/cli-utils.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
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
|
||||
});
|
||||
};
|
||||
|
||||
export const objectPrint = (obj: unknown) => {
|
||||
console.log(formatObject(obj));
|
||||
};
|
||||
7
src/cli/commands/index.ts
Normal file
7
src/cli/commands/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export type { CommandDependencies } from "./types.js";
|
||||
|
||||
export { handleMnemonicCommand, printMnemonicHelp } from "./mnemonic.js";
|
||||
export { handleTemplateCommand, printTemplateHelp } from "./template.js";
|
||||
export { handleInvitationCommand, printInvitationHelp } from "./invitation.js";
|
||||
export { handleReceiveCommand, printReceiveHelp } from "./receive.js";
|
||||
export { handleResourceCommand, printResourceHelp } from "./resource.js";
|
||||
562
src/cli/commands/invitation.ts
Normal file
562
src/cli/commands/invitation.ts
Normal file
@@ -0,0 +1,562 @@
|
||||
import { existsSync, readFileSync, writeFileSync } from "fs";
|
||||
import path from "path";
|
||||
import { generateTemplateIdentifier } from "@xo-cash/engine";
|
||||
import { binToHex, hexToBin } from "@bitauth/libauth";
|
||||
|
||||
import { bold, dim, formatObject } from "../cli-utils.js";
|
||||
import type { CommandDependencies } from "./types.js";
|
||||
import type { Invitation } from "../../services/invitation.js";
|
||||
import {
|
||||
resolveProvidedLockingBytecodeHex,
|
||||
mapUnspentOutputsToSelectable,
|
||||
autoSelectGreedyUtxos,
|
||||
} from "../../utils/invitation-flow.js";
|
||||
import { encodeExtendedJson } from "../../utils/ext-json.js";
|
||||
|
||||
const DEFAULT_FEE = 500n;
|
||||
const DUST_THRESHOLD = 546n;
|
||||
|
||||
/**
|
||||
* Result of parsing CLI options into inputs and outputs for an append call.
|
||||
* A `null` return signals a fatal error that was already logged to stderr.
|
||||
*/
|
||||
interface BuildAppendResult {
|
||||
inputs: { outpointTransactionHash: Uint8Array; outpointIndex: number }[];
|
||||
outputs: any[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts invitation variables from CLI option flags.
|
||||
* Keys starting with "var" are treated as variables — the prefix is stripped
|
||||
* and the next character is lowercased to reconstruct the camelCase identifier.
|
||||
*
|
||||
* When a `-role` flag is present the role identifier is attached to every
|
||||
* variable so the engine stores them under the correct role-level requirements.
|
||||
*
|
||||
* @example `-var-requested-satoshis 1000 -role sender` → `{ variableIdentifier: "requestedSatoshis", value: "1000", roleIdentifier: "sender" }`
|
||||
*/
|
||||
function parseVariablesFromOptions(
|
||||
options: Record<string, string>,
|
||||
): { variableIdentifier: string; value: string; roleIdentifier?: string }[] {
|
||||
const roleIdentifier = options["role"];
|
||||
|
||||
return Object.entries(options)
|
||||
.filter(([key]) => key.startsWith("var"))
|
||||
.map(([key, value]) => ({
|
||||
variableIdentifier: key.substring(3, 4).toLowerCase() + key.substring(4),
|
||||
value,
|
||||
...(roleIdentifier ? { roleIdentifier } : {}),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses CLI options into the inputs and outputs needed for an invitation
|
||||
* append call. Shared by both `create` and `append` so the same flags
|
||||
* (`--add-input`, `--add-output`, `--auto-inputs`, `-role`) work in either.
|
||||
*
|
||||
* Variables should already be committed to the invitation before calling this
|
||||
* so that `getSatsOut()` can resolve variable-dependent output values for the
|
||||
* automatic change calculation.
|
||||
*
|
||||
* @param deps - Command dependencies (engine, logger, etc.)
|
||||
* @param invitation - The invitation instance (variables should already be committed).
|
||||
* @param options - Parsed CLI option flags.
|
||||
* @returns The structured params, or `null` when a fatal error was printed.
|
||||
*/
|
||||
async function buildAppendParams(
|
||||
deps: CommandDependencies,
|
||||
invitation: Invitation,
|
||||
options: Record<string, string>,
|
||||
): Promise<BuildAppendResult | null> {
|
||||
// --- Inputs ---
|
||||
// Accepts comma-separated <txhash>:<vout> pairs via --add-input,
|
||||
// OR automatic selection via --auto-inputs.
|
||||
let inputs: { outpointTransactionHash: Uint8Array; outpointIndex: number }[] = [];
|
||||
|
||||
if (options["autoInputs"] === "true") {
|
||||
// Auto-select UTXOs using the greedy algorithm from invitation-flow.
|
||||
const suitableResources = await invitation.findSuitableResources();
|
||||
const selectable = mapUnspentOutputsToSelectable(suitableResources);
|
||||
|
||||
const requiredWithFee = (await invitation.getSatsOut().catch(() => 0n)) + DEFAULT_FEE;
|
||||
autoSelectGreedyUtxos(selectable, requiredWithFee);
|
||||
|
||||
inputs = selectable
|
||||
.filter((u) => u.selected)
|
||||
.map((u) => ({
|
||||
outpointTransactionHash: hexToBin(u.outpointTransactionHash),
|
||||
outpointIndex: u.outpointIndex,
|
||||
}));
|
||||
|
||||
if (inputs.length === 0) {
|
||||
console.error("No suitable UTXOs found for auto-input selection.");
|
||||
return null;
|
||||
}
|
||||
deps.verboseLogger(`Auto-selected ${inputs.length} input(s)`);
|
||||
} else if (options["addInput"]) {
|
||||
inputs = options["addInput"].split(",").map((entry) => {
|
||||
const separatorIndex = entry.lastIndexOf(":");
|
||||
if (separatorIndex === -1) {
|
||||
throw new Error(`Invalid input format "${entry}". Expected <txhash>:<vout> (e.g. abc123:0)`);
|
||||
}
|
||||
const txHash = entry.substring(0, separatorIndex);
|
||||
const vout = parseInt(entry.substring(separatorIndex + 1), 10);
|
||||
if (!txHash || isNaN(vout)) {
|
||||
throw new Error(`Invalid input format "${entry}". Expected <txhash>:<vout> (e.g. abc123:0)`);
|
||||
}
|
||||
return {
|
||||
outpointTransactionHash: hexToBin(txHash),
|
||||
outpointIndex: vout,
|
||||
};
|
||||
});
|
||||
}
|
||||
deps.verboseLogger(`Inputs: ${formatObject(inputs.map(i => ({ txHash: binToHex(i.outpointTransactionHash), vout: i.outpointIndex })))}`);
|
||||
|
||||
// --- Outputs ---
|
||||
// When --add-output is provided, use those identifiers explicitly.
|
||||
// Otherwise, auto-discover all required outputs from the template so the
|
||||
// user doesn't have to name them manually.
|
||||
const roleIdentifier = options["role"];
|
||||
let outputIdentifiers: string[] = [];
|
||||
|
||||
if (options["addOutput"]) {
|
||||
outputIdentifiers = options["addOutput"].split(",");
|
||||
} else {
|
||||
// Pull every output identifier the template requires (top-level + role-specific).
|
||||
const requirements = await invitation.getRequirements();
|
||||
const discovered = new Set<string>();
|
||||
for (const id of requirements.outputs ?? []) discovered.add(id);
|
||||
if (requirements.roles) {
|
||||
for (const role of Object.values(requirements.roles)) {
|
||||
for (const id of role.outputs ?? []) discovered.add(id);
|
||||
}
|
||||
}
|
||||
outputIdentifiers = [...discovered];
|
||||
if (outputIdentifiers.length > 0) {
|
||||
deps.verboseLogger(`Auto-discovered output(s) from template: ${outputIdentifiers.join(", ")}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Build a variable-values map from all committed variables so
|
||||
// resolveProvidedLockingBytecodeHex can resolve outputs whose locking
|
||||
// script depends on a variable (e.g. <recipientLockingscript>).
|
||||
const variableValuesByIdentifier: Record<string, string> = {};
|
||||
for (const commit of invitation.data.commits) {
|
||||
for (const v of commit.data?.variables ?? []) {
|
||||
if (v.variableIdentifier && typeof v.value === "string") {
|
||||
variableValuesByIdentifier[v.variableIdentifier] = v.value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const template = await deps.app.engine.getTemplate(invitation.data.templateIdentifier);
|
||||
|
||||
const outputs: any[] = await Promise.all(
|
||||
outputIdentifiers.map(async (outputId) => {
|
||||
// Try variable-based resolution first (e.g. sendSatoshis → recipientLockingscript)
|
||||
const providedHex = template
|
||||
? resolveProvidedLockingBytecodeHex(template, outputId, variableValuesByIdentifier)
|
||||
: undefined;
|
||||
|
||||
const lockingBytecodeHex = providedHex
|
||||
?? await invitation.generateLockingBytecode(outputId, roleIdentifier);
|
||||
|
||||
deps.verboseLogger(`Locking bytecode for output "${outputId}": ${lockingBytecodeHex}`);
|
||||
return {
|
||||
outputIdentifier: outputId,
|
||||
lockingBytecode: new Uint8Array(Buffer.from(lockingBytecodeHex, "hex")),
|
||||
};
|
||||
}),
|
||||
);
|
||||
deps.verboseLogger(`Outputs: ${formatObject(outputs.map(o => o.outputIdentifier))}`);
|
||||
|
||||
// --- Auto change output ---
|
||||
// When inputs are provided, look up each UTXO's value, compute the
|
||||
// required sats, and return the excess minus fees back to the user.
|
||||
if (inputs.length > 0) {
|
||||
const allUtxos = await deps.app.engine.listUnspentOutputsData();
|
||||
const utxoMap = new Map(allUtxos.map(u => [`${u.outpointTransactionHash}:${u.outpointIndex}`, u]));
|
||||
|
||||
let totalInputSats = 0n;
|
||||
for (const input of inputs) {
|
||||
const txHashHex = binToHex(input.outpointTransactionHash);
|
||||
const utxo = utxoMap.get(`${txHashHex}:${input.outpointIndex}`);
|
||||
if (!utxo) {
|
||||
console.error(`UTXO not found: ${txHashHex}:${input.outpointIndex}. Make sure it exists in your wallet.`);
|
||||
return null;
|
||||
}
|
||||
totalInputSats += BigInt(utxo.valueSatoshis);
|
||||
}
|
||||
deps.verboseLogger(`Total input value: ${totalInputSats} satoshis`);
|
||||
|
||||
const requiredSats = await invitation.getSatsOut();
|
||||
deps.verboseLogger(`Required output value: ${requiredSats} satoshis`);
|
||||
|
||||
const changeAmount = totalInputSats - requiredSats - DEFAULT_FEE;
|
||||
deps.verboseLogger(`Change amount: ${changeAmount} satoshis (fee: ${DEFAULT_FEE})`);
|
||||
|
||||
if (changeAmount < 0n) {
|
||||
console.error(`Insufficient funds. Inputs total ${totalInputSats} sats, but need ${requiredSats + DEFAULT_FEE} sats (${requiredSats} required + ${DEFAULT_FEE} fee).`);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (changeAmount >= DUST_THRESHOLD) {
|
||||
outputs.push({ valueSatoshis: changeAmount });
|
||||
console.log(`Auto-adding change output: ${changeAmount} satoshis`);
|
||||
} else if (changeAmount > 0n) {
|
||||
console.log(`Change ${changeAmount} sats is below dust threshold (${DUST_THRESHOLD} sats), donating to miners as fee.`);
|
||||
}
|
||||
}
|
||||
|
||||
return { inputs, outputs };
|
||||
}
|
||||
|
||||
/**
|
||||
* Prints the help message for the invitation command
|
||||
*/
|
||||
export const printInvitationHelp = () => {
|
||||
console.log(
|
||||
`
|
||||
${bold("Usage:")} xo-cli invitation <sub-command>
|
||||
|
||||
${bold("Sub-commands:")}
|
||||
- create <template-file> <action-id> ${dim("Create a new invitation")}
|
||||
- append <invitation-id> ${dim("Add variables/outputs to an invitation")}
|
||||
- sign <invitation-id> ${dim("Sign an invitation")}
|
||||
- broadcast <invitation-id> ${dim("Broadcast an invitation")}
|
||||
- requirements <invitation-id> ${dim("Show requirements for an invitation")}
|
||||
- import <invitation-file> ${dim("Import an invitation from a file")}
|
||||
- list ${dim("List all invitations")}
|
||||
|
||||
${bold("Create / Append options:")}
|
||||
-var-<name> <value> ${dim("Set a variable (e.g. -var-requested-satoshis 1000)")}
|
||||
--add-input <txhash:vout> ${dim("Add UTXO input(s), comma-separated (e.g. abc123:0,def456:1)")}
|
||||
--add-output <id> ${dim("Override output(s) — omit to auto-discover from template")}
|
||||
--auto-inputs ${dim("Automatically select UTXOs as inputs")}
|
||||
-role <role> ${dim("Role for output bytecode generation (fallback)")}
|
||||
--sign ${dim("Auto-sign after all requirements are satisfied")}
|
||||
--broadcast ${dim("Auto-broadcast after signing (implies --sign)")}
|
||||
|
||||
${dim("When inputs are provided, a change output is automatically added if the")}
|
||||
${dim("input total exceeds the required amount + fee.")}
|
||||
`);
|
||||
};
|
||||
|
||||
/**
|
||||
* Handles the invitation command.
|
||||
* @param deps - The command dependencies.
|
||||
* @param args - Positional args after the command name, e.g. ["create", "template.json", "action-id"].
|
||||
* @param options - Parsed option flags, e.g. { varRequestedSatohis: "1000", role: "receiver" }.
|
||||
*/
|
||||
export const handleInvitationCommand = async (deps: CommandDependencies, args: string[], options: Record<string, string>): Promise<void> => {
|
||||
const subCommand = args[0];
|
||||
deps.verboseLogger(`Invitation sub-command: ${subCommand}`);
|
||||
|
||||
if (!subCommand) {
|
||||
deps.verboseLogger("No sub-command provided");
|
||||
printInvitationHelp();
|
||||
return;
|
||||
}
|
||||
|
||||
switch (subCommand) {
|
||||
case "create": {
|
||||
const templateFile = args[1];
|
||||
const actionIdentifier = args[2];
|
||||
deps.verboseLogger(`Template file: ${templateFile}, action identifier: ${actionIdentifier}`);
|
||||
|
||||
if (!templateFile || !actionIdentifier) {
|
||||
deps.verboseLogger("No template file or action identifier provided");
|
||||
printInvitationHelp();
|
||||
return;
|
||||
}
|
||||
|
||||
// Resolve and validate the template file path
|
||||
const templatePath = path.resolve(`${process.cwd()}/${templateFile}`);
|
||||
deps.verboseLogger(`Template path: ${templatePath}`);
|
||||
|
||||
if (!existsSync(templatePath)) {
|
||||
console.error(`Template file does not exist: ${templatePath}`);
|
||||
printInvitationHelp();
|
||||
return;
|
||||
}
|
||||
|
||||
const template = await readFileSync(templatePath, "utf8");
|
||||
const templateIdentifier = generateTemplateIdentifier(JSON.parse(template));
|
||||
|
||||
// Create the base invitation via the engine
|
||||
const rawInvitation = await deps.app.engine.createInvitation({
|
||||
templateIdentifier: templateIdentifier,
|
||||
actionIdentifier: actionIdentifier,
|
||||
});
|
||||
deps.verboseLogger(`XOInvitation created: ${formatObject(rawInvitation)}`);
|
||||
|
||||
const invitationInstance = await deps.app.createInvitation(rawInvitation);
|
||||
deps.verboseLogger(`Invitation created: ${formatObject(invitationInstance.data)}`);
|
||||
|
||||
// Commit variables first so getSatsOut can resolve them for change calc
|
||||
// and resolveProvidedLockingBytecodeHex can read them from commits.
|
||||
const variables = parseVariablesFromOptions(options);
|
||||
deps.verboseLogger(`Variables: ${formatObject(variables)}`);
|
||||
if (variables.length > 0) {
|
||||
await invitationInstance.addVariables(variables);
|
||||
}
|
||||
|
||||
// Parse inputs/outputs and calculate change (variables are now committed)
|
||||
const params = await buildAppendParams(deps, invitationInstance, options);
|
||||
if (!params) return;
|
||||
|
||||
const { inputs, outputs } = params;
|
||||
|
||||
if (inputs.length > 0 || outputs.length > 0) {
|
||||
await invitationInstance.append({ inputs, outputs });
|
||||
}
|
||||
|
||||
// Save the invitation to a file
|
||||
const invitationFilePath = `${process.cwd()}/inv-${invitationInstance.data.invitationIdentifier}.json`;
|
||||
deps.verboseLogger(`Invitation file path: ${invitationFilePath}`);
|
||||
writeFileSync(invitationFilePath, encodeExtendedJson(invitationInstance.data, 2));
|
||||
console.log(`Invitation created: ${path.basename(invitationFilePath)} (${invitationInstance.data.invitationIdentifier})`);
|
||||
|
||||
// Check remaining requirements
|
||||
const missingRequirements = await invitationInstance.getMissingRequirements();
|
||||
const hasMissing =
|
||||
(missingRequirements.variables?.length ?? 0) > 0 ||
|
||||
(missingRequirements.inputs?.length ?? 0) > 0 ||
|
||||
(missingRequirements.outputs?.length ?? 0) > 0 ||
|
||||
(missingRequirements.roles !== undefined && Object.keys(missingRequirements.roles).length > 0);
|
||||
|
||||
if (hasMissing) {
|
||||
console.log(`\n${bold("Remaining requirements:")}`);
|
||||
console.log(formatObject(missingRequirements));
|
||||
} else {
|
||||
// --broadcast implies --sign
|
||||
const shouldSign = options["sign"] === "true" || options["broadcast"] === "true";
|
||||
const shouldBroadcast = options["broadcast"] === "true";
|
||||
|
||||
if (shouldSign) {
|
||||
await invitationInstance.sign();
|
||||
console.log(`Invitation signed: ${invitationInstance.data.invitationIdentifier}`);
|
||||
}
|
||||
|
||||
if (shouldBroadcast) {
|
||||
const txHash = await invitationInstance.broadcast();
|
||||
console.log(`Transaction broadcast: ${bold(txHash)}`);
|
||||
} else if (!shouldSign) {
|
||||
console.log(`\n${bold("All requirements satisfied.")} You can now sign with: xo-cli invitation sign ${invitationInstance.data.invitationIdentifier}`);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "append": {
|
||||
const invitationIdentifier = args[1];
|
||||
deps.verboseLogger(`Invitation identifier: ${invitationIdentifier}`);
|
||||
|
||||
if (!invitationIdentifier) {
|
||||
deps.verboseLogger("No invitation identifier provided");
|
||||
printInvitationHelp();
|
||||
return;
|
||||
}
|
||||
|
||||
// Find the invitation by identifier
|
||||
const invitation = deps.app.invitations.find(inv => inv.data.invitationIdentifier === invitationIdentifier);
|
||||
if (!invitation) {
|
||||
console.error(`Invitation not found: ${invitationIdentifier}`);
|
||||
return;
|
||||
}
|
||||
deps.verboseLogger(`Invitation: ${formatObject(invitation.data)}`);
|
||||
|
||||
// Commit variables first so getSatsOut can resolve them for change calc
|
||||
const variables = parseVariablesFromOptions(options);
|
||||
deps.verboseLogger(`Variables to append: ${formatObject(variables)}`);
|
||||
if (variables.length > 0) {
|
||||
await invitation.addVariables(variables);
|
||||
}
|
||||
|
||||
// Parse inputs/outputs and calculate change (variables are now committed)
|
||||
const params = await buildAppendParams(deps, invitation, options);
|
||||
if (!params) return;
|
||||
|
||||
const { inputs, outputs } = params;
|
||||
|
||||
if (variables.length === 0 && inputs.length === 0 && outputs.length === 0) {
|
||||
console.error("Nothing to append. Provide variables (-var-<name> <value>), inputs (--add-input <txhash>:<vout>), or outputs (--add-output <identifier>).");
|
||||
return;
|
||||
}
|
||||
|
||||
if (inputs.length > 0 || outputs.length > 0) {
|
||||
await invitation.append({ inputs, outputs });
|
||||
}
|
||||
deps.verboseLogger(`Invitation appended: ${formatObject(invitation.data)}`);
|
||||
console.log(`Invitation appended: ${invitationIdentifier}`);
|
||||
|
||||
// Save the updated invitation to a file
|
||||
const invitationFilePath = `${process.cwd()}/inv-${invitation.data.invitationIdentifier}.json`;
|
||||
writeFileSync(invitationFilePath, encodeExtendedJson(invitation.data, 2));
|
||||
console.log(`Invitation updated: ${path.basename(invitationFilePath)} (${invitation.data.invitationIdentifier})`);
|
||||
|
||||
// Check remaining requirements
|
||||
const missingRequirements = await invitation.getMissingRequirements();
|
||||
const hasMissing =
|
||||
(missingRequirements.variables?.length ?? 0) > 0 ||
|
||||
(missingRequirements.inputs?.length ?? 0) > 0 ||
|
||||
(missingRequirements.outputs?.length ?? 0) > 0 ||
|
||||
(missingRequirements.roles !== undefined && Object.keys(missingRequirements.roles).length > 0);
|
||||
|
||||
if (hasMissing) {
|
||||
console.log(`\n${bold("Remaining requirements:")}`);
|
||||
console.log(formatObject(missingRequirements));
|
||||
} else {
|
||||
const shouldSign = options["sign"] === "true" || options["broadcast"] === "true";
|
||||
const shouldBroadcast = options["broadcast"] === "true";
|
||||
|
||||
if (shouldSign) {
|
||||
await invitation.sign();
|
||||
console.log(`Invitation signed: ${invitationIdentifier}`);
|
||||
}
|
||||
|
||||
if (shouldBroadcast) {
|
||||
const txHash = await invitation.broadcast();
|
||||
console.log(`Transaction broadcast: ${bold(txHash)}`);
|
||||
} else if (!shouldSign) {
|
||||
console.log(`\n${bold("All requirements satisfied.")} You can now sign with: xo-cli invitation sign ${invitationIdentifier}`);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "sign": {
|
||||
const invitationIdentifier = args[1];
|
||||
deps.verboseLogger(`Invitation identifier: ${invitationIdentifier}`);
|
||||
|
||||
// Check if the invitation identifier is provided
|
||||
if (!invitationIdentifier) {
|
||||
deps.verboseLogger("No invitation identifier provided");
|
||||
printInvitationHelp();
|
||||
return;
|
||||
}
|
||||
|
||||
// Find the invitation by identifier
|
||||
const invitation = await deps.app.invitations.find(invitation => invitation.data.invitationIdentifier === invitationIdentifier);
|
||||
if (!invitation) {
|
||||
console.error(`Invitation not found: ${invitationIdentifier}`);
|
||||
return;
|
||||
}
|
||||
deps.verboseLogger(`Invitation: ${formatObject(invitation.data)}`);
|
||||
|
||||
// Sign the invitation
|
||||
await invitation.sign();
|
||||
deps.verboseLogger(`Invitation signed: ${formatObject(invitation.data)}`);
|
||||
console.log(`Invitation signed: ${invitationIdentifier}`);
|
||||
break;
|
||||
}
|
||||
|
||||
case "broadcast": {
|
||||
const invitationIdentifier = args[1];
|
||||
deps.verboseLogger(`Invitation identifier: ${invitationIdentifier}`);
|
||||
|
||||
// Check if the invitation identifier is provided
|
||||
if (!invitationIdentifier) {
|
||||
deps.verboseLogger("No invitation identifier provided");
|
||||
printInvitationHelp();
|
||||
return;
|
||||
}
|
||||
|
||||
// Find the invitation by identifier
|
||||
const invitation = await deps.app.invitations.find(invitation => invitation.data.invitationIdentifier === invitationIdentifier);
|
||||
if (!invitation) {
|
||||
console.error(`Invitation not found: ${invitationIdentifier}`);
|
||||
return;
|
||||
}
|
||||
deps.verboseLogger(`Invitation: ${formatObject(invitation.data)}`);
|
||||
|
||||
// Broadcast the invitation
|
||||
const txHash = await invitation.broadcast();
|
||||
deps.verboseLogger(`Invitation broadcasted: ${formatObject(invitation.data)}`);
|
||||
console.log(`Transaction broadcast: ${bold(txHash)}`);
|
||||
break;
|
||||
}
|
||||
|
||||
case "requirements": {
|
||||
const invitationIdentifier = args[1];
|
||||
deps.verboseLogger(`Invitation identifier: ${invitationIdentifier}`);
|
||||
|
||||
// Check if the invitation identifier is provided
|
||||
if (!invitationIdentifier) {
|
||||
deps.verboseLogger("No invitation identifier provided");
|
||||
printInvitationHelp();
|
||||
return;
|
||||
}
|
||||
|
||||
// Find the invitation by identifier
|
||||
const invitation = await deps.app.invitations.find(invitation => invitation.data.invitationIdentifier === invitationIdentifier);
|
||||
if (!invitation) {
|
||||
console.error(`Invitation not found: ${invitationIdentifier}`);
|
||||
return;
|
||||
}
|
||||
deps.verboseLogger(`Invitation: ${formatObject(invitation.data)}`);
|
||||
|
||||
// List the requirements for the invitation
|
||||
const requirements = await deps.app.engine.listRequirements(invitation.data);
|
||||
deps.verboseLogger(`Requirements: ${formatObject(requirements)}`);
|
||||
console.log(formatObject(requirements));
|
||||
break;
|
||||
}
|
||||
|
||||
case "import": {
|
||||
// Check if the invitation file path is provided
|
||||
const invitationFilePath = args[1];
|
||||
deps.verboseLogger(`Invitation file path: ${invitationFilePath}`);
|
||||
|
||||
// Check if the invitation file path is provided
|
||||
if (!invitationFilePath) {
|
||||
deps.verboseLogger("No invitation file provided");
|
||||
printInvitationHelp();
|
||||
return;
|
||||
}
|
||||
|
||||
// Read the invitation file
|
||||
const invitationFile = await readFileSync(invitationFilePath, "utf8");
|
||||
deps.verboseLogger(`Invitation file: ${invitationFile}`);
|
||||
|
||||
// Parse the invitation file
|
||||
const invitation = JSON.parse(invitationFile);
|
||||
deps.verboseLogger(`Invitation: ${formatObject(invitation)}`);
|
||||
|
||||
// Create the invitation (internal to XO Engine)
|
||||
const xoInvitation = await deps.app.engine.createInvitation(invitation);
|
||||
|
||||
// Create the invitation instance
|
||||
const invitationInstance = await deps.app.createInvitation(xoInvitation);
|
||||
deps.verboseLogger(`Invitation created: ${formatObject(invitationInstance.data)}`);
|
||||
break;
|
||||
}
|
||||
|
||||
case "list": {
|
||||
// List all invitations
|
||||
const invitations = await Promise.all(deps.app.invitations.map(async invitation => {
|
||||
// Get the template for the invitation so we can display the name of it
|
||||
const template = await deps.app.engine.getTemplate(invitation.data.templateIdentifier);
|
||||
return {
|
||||
invitationIdentifier: invitation.data.invitationIdentifier,
|
||||
templateIdentifier: invitation.data.templateIdentifier,
|
||||
actionIdentifier: invitation.data.actionIdentifier,
|
||||
templateName: template?.name ?? "Unknown",
|
||||
status: invitation.status,
|
||||
roleIdentifier: 'TODO: Get role identifier',
|
||||
};
|
||||
}));
|
||||
deps.verboseLogger(`Invitations: ${formatObject(invitations)}`);
|
||||
|
||||
// Format the invitations for display and print it
|
||||
const formattedInvitations = invitations.map(invitation => `${bold(invitation.templateName)} ${dim(invitation.status)} ${dim(invitation.invitationIdentifier)} ${dim(invitation.actionIdentifier)} (${dim(invitation.roleIdentifier)})`);
|
||||
console.log(formattedInvitations.join('\n'));
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
deps.verboseLogger(`Unknown invitation sub-command: ${subCommand}`);
|
||||
printInvitationHelp();
|
||||
return;
|
||||
}
|
||||
};
|
||||
73
src/cli/commands/mnemonic.ts
Normal file
73
src/cli/commands/mnemonic.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { bold, dim } from "../cli-utils.js";
|
||||
import { listMnemonicFiles, createMnemonicFile, createMnemonicSeed } from "../mnemonic.js";
|
||||
import type { CommandDependencies } from "./types.js";
|
||||
|
||||
/**
|
||||
* Prints the help message for the mnemonic command
|
||||
*/
|
||||
export const printMnemonicHelp = () => {
|
||||
console.log(
|
||||
`
|
||||
${bold("Usage:")} xo-cli mnemonic <sub-command>
|
||||
|
||||
${bold("Sub-commands:")}
|
||||
- create <mnemonic-seed> ${dim("Create a new mnemonic file")}
|
||||
- list ${dim("List all mnemonic files")}
|
||||
|
||||
${bold("Options:")}
|
||||
-o --output <output-filename> ${dim("Output filename for the mnemonic file")}
|
||||
-h --help ${dim("Show this help message")}
|
||||
`);
|
||||
};
|
||||
|
||||
/**
|
||||
* Handles the mnemonic command.
|
||||
* @param deps - The command dependencies.
|
||||
* @param args - Positional args after the command name, e.g. ["create"] or ["import", "page", "pencil", ...].
|
||||
* @param options - Parsed option flags, e.g. { output: "mnemonic.txt" }.
|
||||
*/
|
||||
export const handleMnemonicCommand = async (deps: Omit<CommandDependencies, "app">, args: string[], options: Record<string, string>): Promise<void> => {
|
||||
const subCommand = args[0];
|
||||
|
||||
if (!subCommand) {
|
||||
deps.verboseLogger("No sub-command provided");
|
||||
printMnemonicHelp();
|
||||
return;
|
||||
}
|
||||
|
||||
switch (subCommand) {
|
||||
case "create": {
|
||||
const mnemonicSeed = createMnemonicSeed();
|
||||
await createMnemonicFile(mnemonicSeed, options["output"]);
|
||||
|
||||
console.log(`Mnemonic file created: ${options["output"]} (${mnemonicSeed})`);
|
||||
break;
|
||||
}
|
||||
|
||||
case "import": {
|
||||
// The mnemonic seed words are all the positional args after the sub-command
|
||||
const mnemonicSeed = args.slice(1).join(" ");
|
||||
|
||||
if (!mnemonicSeed) {
|
||||
deps.verboseLogger("No mnemonic seed provided");
|
||||
printMnemonicHelp();
|
||||
return;
|
||||
}
|
||||
|
||||
deps.verboseLogger(`Mnemonic seed: ${mnemonicSeed}`);
|
||||
await createMnemonicFile(mnemonicSeed, options["output"]);
|
||||
break;
|
||||
}
|
||||
|
||||
case "list": {
|
||||
const mnemonicFiles = listMnemonicFiles();
|
||||
console.log(mnemonicFiles.join('\n'));
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
console.error(`Unknown sub-command: ${subCommand}`);
|
||||
printMnemonicHelp();
|
||||
return;
|
||||
}
|
||||
};
|
||||
80
src/cli/commands/receive.ts
Normal file
80
src/cli/commands/receive.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import { existsSync, readFileSync } from "fs";
|
||||
import path from "path";
|
||||
import { generateTemplateIdentifier } from "@xo-cash/engine";
|
||||
import { hexToBin, lockingBytecodeToCashAddress } from "@bitauth/libauth";
|
||||
|
||||
import { bold, dim } from "../cli-utils.js";
|
||||
import type { CommandDependencies } from "./types.js";
|
||||
|
||||
/**
|
||||
* Prints the help message for the receive command
|
||||
*/
|
||||
export const printReceiveHelp = () => {
|
||||
console.log(
|
||||
`
|
||||
${bold("Usage:")} xo-cli receive <template-file> <output-identifier> [role-identifier]
|
||||
|
||||
${bold("Description:")}
|
||||
Generate a single-use receiving address from a template.
|
||||
|
||||
${bold("Arguments:")}
|
||||
<template-file> ${dim("Path to the template JSON file")}
|
||||
<output-identifier> ${dim("The output identifier within the template (e.g. 'receiveOutput')")}
|
||||
[role-identifier] ${dim("The role identifier (e.g. 'receiver'). Auto-selects the first role if omitted.")}
|
||||
|
||||
${bold("Options:")}
|
||||
-h --help ${dim("Show this help message")}
|
||||
`);
|
||||
};
|
||||
|
||||
/**
|
||||
* Command which creates a single-use address/lockingScript for a given template and role.
|
||||
* @param deps - The command dependencies.
|
||||
* @param args - Positional args after the command name, e.g. ["template.json", "receiveOutput", "receiver"].
|
||||
* @param options - Parsed option flags.
|
||||
*/
|
||||
export const handleReceiveCommand = async (deps: CommandDependencies, args: string[], options: Record<string, string>): Promise<void> => {
|
||||
const templateFile = args[0];
|
||||
const outputIdentifier = args[1];
|
||||
const roleIdentifier = args[2];
|
||||
|
||||
deps.verboseLogger(`Receive args - template: ${templateFile}, output: ${outputIdentifier}, role: ${roleIdentifier}`);
|
||||
|
||||
if (!templateFile || !outputIdentifier) {
|
||||
deps.verboseLogger("Missing required arguments");
|
||||
printReceiveHelp();
|
||||
return;
|
||||
}
|
||||
|
||||
// Resolve and read the template file
|
||||
const templatePath = path.resolve(`${process.cwd()}/${templateFile}`);
|
||||
deps.verboseLogger(`Template path: ${templatePath}`);
|
||||
|
||||
if (!existsSync(templatePath)) {
|
||||
console.error(`Template file does not exist: ${templatePath}`);
|
||||
printReceiveHelp();
|
||||
return;
|
||||
}
|
||||
|
||||
const template = readFileSync(templatePath, "utf8");
|
||||
const templateIdentifier = generateTemplateIdentifier(JSON.parse(template));
|
||||
deps.verboseLogger(`Template identifier: ${templateIdentifier}`);
|
||||
|
||||
// Generate the locking bytecode (returned as a hex string)
|
||||
const lockingBytecodeHex = await deps.app.engine.generateLockingBytecode(
|
||||
templateIdentifier,
|
||||
outputIdentifier,
|
||||
roleIdentifier,
|
||||
);
|
||||
deps.verboseLogger(`Locking bytecode hex: ${lockingBytecodeHex}`);
|
||||
|
||||
// Convert the locking bytecode to a BCH cash address
|
||||
const result = lockingBytecodeToCashAddress({ bytecode: hexToBin(lockingBytecodeHex), prefix: 'bitcoincash' });
|
||||
|
||||
if (typeof result === 'string') {
|
||||
console.error(`Failed to encode address: ${result}`);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(result.address);
|
||||
};
|
||||
145
src/cli/commands/resource.ts
Normal file
145
src/cli/commands/resource.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
import { hexToBin } from "@bitauth/libauth";
|
||||
|
||||
import { bold, dim } from "../cli-utils.js";
|
||||
import type { CommandDependencies } from "./types.js";
|
||||
|
||||
/**
|
||||
* Prints the help message for the resource command.
|
||||
*/
|
||||
export const printResourceHelp = () => {
|
||||
console.log(
|
||||
`
|
||||
${bold("Usage:")} xo-cli resource <sub-command>
|
||||
|
||||
${bold("Sub-commands:")}
|
||||
- list ${dim("List all unreserved resources")}
|
||||
- list reserved ${dim("List reserved resources")}
|
||||
- list all ${dim("List all resources (reserved + unreserved)")}
|
||||
- unreserve <txhash:vout> ${dim("Unreserve a specific UTXO")}
|
||||
- unreserve-all ${dim("Unreserve all reserved UTXOs")}
|
||||
`);
|
||||
};
|
||||
|
||||
/**
|
||||
* Formats a single UTXO for display, optionally including reservation info.
|
||||
*/
|
||||
function formatResource(resource: { outpointTransactionHash: string; outpointIndex: number; valueSatoshis: number; outputIdentifier: string; minedAtHeight: number; reserved?: boolean; invitationIdentifier?: string }, showReserved = false): string {
|
||||
const outpoint = bold(`${resource.outpointTransactionHash}:${resource.outpointIndex}`);
|
||||
const value = dim(`${resource.valueSatoshis} sats`);
|
||||
const output = dim(resource.outputIdentifier);
|
||||
const height = dim(`(height ${resource.minedAtHeight})`);
|
||||
|
||||
if (showReserved && resource.reserved) {
|
||||
const inv = dim(`reserved for ${resource.invitationIdentifier}`);
|
||||
return `${outpoint} ${value} ${output} ${height} ${inv}`;
|
||||
}
|
||||
|
||||
return `${outpoint} ${value} ${output} ${height}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles the resource command.
|
||||
* @param deps - The command dependencies.
|
||||
* @param args - Positional args after the command name, e.g. ["list"].
|
||||
* @param options - Parsed option flags.
|
||||
*/
|
||||
export const handleResourceCommand = async (deps: CommandDependencies, args: string[], options: Record<string, string>): Promise<void> => {
|
||||
const subCommand = args[0];
|
||||
deps.verboseLogger(`Resource sub-command: ${subCommand}`);
|
||||
|
||||
if (!subCommand) {
|
||||
deps.verboseLogger("No sub-command provided");
|
||||
printResourceHelp();
|
||||
return;
|
||||
}
|
||||
|
||||
switch (subCommand) {
|
||||
case "list": {
|
||||
const qualifier = args[1]; // "reserved", "all", or undefined (defaults to unreserved)
|
||||
const allResources = await deps.app.engine.listUnspentOutputsData();
|
||||
|
||||
let filtered;
|
||||
if (qualifier === "reserved") {
|
||||
filtered = allResources.filter((r) => r.reserved);
|
||||
} else if (qualifier === "all") {
|
||||
filtered = allResources;
|
||||
} else {
|
||||
// Default: show only unreserved (selectable) resources
|
||||
filtered = allResources.filter((r) => !r.reserved);
|
||||
}
|
||||
|
||||
if (filtered.length === 0) {
|
||||
console.log(dim("No resources found."));
|
||||
return;
|
||||
}
|
||||
|
||||
const showReserved = qualifier === "all" || qualifier === "reserved";
|
||||
const formattedResources = filtered.map((r) => formatResource(r, showReserved));
|
||||
console.log(formattedResources.join("\n"));
|
||||
console.log(`Total satoshis: ${filtered.reduce((acc, r) => acc + r.valueSatoshis, 0)}`);
|
||||
console.log(`Total resources: ${filtered.length}`);
|
||||
break;
|
||||
}
|
||||
|
||||
case "unreserve": {
|
||||
const outpointArg = args[1];
|
||||
if (!outpointArg) {
|
||||
console.error("Please provide a UTXO in <txhash>:<vout> format.");
|
||||
printResourceHelp();
|
||||
return;
|
||||
}
|
||||
|
||||
const separatorIndex = outpointArg.lastIndexOf(":");
|
||||
if (separatorIndex === -1) {
|
||||
console.error(`Invalid format "${outpointArg}". Expected <txhash>:<vout>.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const txHash = outpointArg.substring(0, separatorIndex);
|
||||
const vout = parseInt(outpointArg.substring(separatorIndex + 1), 10);
|
||||
if (!txHash || isNaN(vout)) {
|
||||
console.error(`Invalid format "${outpointArg}". Expected <txhash>:<vout>.`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Look up the UTXO to get its invitation identifier (required by the engine).
|
||||
const allResources = await deps.app.engine.listUnspentOutputsData();
|
||||
const target = allResources.find(
|
||||
(r) => r.outpointTransactionHash === txHash && r.outpointIndex === vout,
|
||||
);
|
||||
|
||||
if (!target) {
|
||||
console.error(`UTXO not found: ${txHash}:${vout}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!target.reserved) {
|
||||
console.log(dim("UTXO is not reserved. Nothing to do."));
|
||||
return;
|
||||
}
|
||||
|
||||
await deps.app.engine.unreserveResources(
|
||||
[{ outpointTransactionHash: hexToBin(txHash), outpointIndex: vout }],
|
||||
target.invitationIdentifier,
|
||||
);
|
||||
console.log(`Unreserved ${bold(`${txHash}:${vout}`)} (was reserved for ${target.invitationIdentifier})`);
|
||||
break;
|
||||
}
|
||||
|
||||
case "unreserve-all": {
|
||||
const count = await deps.app.unreserveAllResources();
|
||||
if (count === 0) {
|
||||
console.log(dim("No reserved resources to unreserve."));
|
||||
} else {
|
||||
console.log(`Unreserved ${bold(String(count))} resource(s).`);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
default: {
|
||||
deps.verboseLogger(`Unknown resource sub-command: ${subCommand}`);
|
||||
printResourceHelp();
|
||||
return;
|
||||
}
|
||||
}
|
||||
};
|
||||
276
src/cli/commands/template.ts
Normal file
276
src/cli/commands/template.ts
Normal file
@@ -0,0 +1,276 @@
|
||||
import { existsSync, readFileSync } from "fs";
|
||||
import path from "path";
|
||||
import { generateTemplateIdentifier } from "@xo-cash/engine";
|
||||
import type { XOTemplate } from "@xo-cash/types";
|
||||
|
||||
import { bold, dim, formatObject, objectPrint } from "../cli-utils.js";
|
||||
import { resolveTemplateReferences } from "../../utils/templates.js";
|
||||
import type { CommandDependencies } from "./types.js";
|
||||
|
||||
/**
|
||||
* Prints the help message for the template command
|
||||
*/
|
||||
export const printTemplateHelp = () => {
|
||||
console.log(
|
||||
`
|
||||
${bold("Usage:")} xo-cli template <sub-command>
|
||||
|
||||
${bold("Sub-commands:")}
|
||||
- import <template-file> ${dim("Import a template from a file")}
|
||||
- list ${dim("List all templates")}
|
||||
- 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")}
|
||||
- set-default <template-file> <output-identifier> <role-identifier> ${dim("Set the default template")}
|
||||
`);
|
||||
};
|
||||
|
||||
/**
|
||||
* Handles the template list command.
|
||||
* @param deps - The command dependencies.
|
||||
* @param args - Positional args after the command name, e.g. ["list", "action"] or ["list", "action", "1234567890"].
|
||||
*/
|
||||
export const handleTemplateListCommand = async (deps: CommandDependencies, args: string[]): Promise<void> => {
|
||||
const templateCategory = args[0];
|
||||
deps.verboseLogger(`Template list category: ${templateCategory}`);
|
||||
|
||||
// If no category was provided to list, we assume its listing out the templates
|
||||
if (!templateCategory) {
|
||||
const templates = await deps.app.engine.listImportedTemplates();
|
||||
const formattedTemplates = templates.map((template: XOTemplate) => `${bold(generateTemplateIdentifier(template))} - ${dim(template.name)} ${dim(template.description)}`);
|
||||
console.log(formattedTemplates.join('\n'));
|
||||
return;
|
||||
}
|
||||
|
||||
// Extract the template identifier from the positional args
|
||||
const templateIdentifier = args[1];
|
||||
deps.verboseLogger(`Template identifier: ${templateIdentifier}`);
|
||||
|
||||
if (!templateIdentifier) {
|
||||
console.error("No template identifier provided");
|
||||
return;
|
||||
}
|
||||
|
||||
// Get the template from the engine
|
||||
const rawTemplate = await deps.app.engine.getTemplate(templateIdentifier);
|
||||
if (!rawTemplate) {
|
||||
console.error(`No template found: ${templateIdentifier}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Resolve the template references
|
||||
const template = await resolveTemplateReferences(rawTemplate);
|
||||
deps.verboseLogger(`Template: ${formatObject(template)}`);
|
||||
|
||||
// List the templates in the category
|
||||
switch (templateCategory) {
|
||||
case "action": {
|
||||
const actions = template.actions;
|
||||
const formattedActions = Object.entries(actions).map(([actionIdentifier, action]) => `${bold(actionIdentifier)} ${dim(action.name)} ${dim(action.description)}`);
|
||||
console.log(formattedActions.join('\n'));
|
||||
break;
|
||||
}
|
||||
case "transaction": {
|
||||
const transactions = template.transactions;
|
||||
const formattedTransactions = Object.entries(transactions).map(([transactionIdentifier, transaction]) => `${bold(transactionIdentifier)} ${dim(transaction.name)} ${dim(transaction.description)}`);
|
||||
console.log(formattedTransactions.join('\n'));
|
||||
break;
|
||||
}
|
||||
case "output": {
|
||||
const outputs = template.outputs;
|
||||
const formattedOutputs = Object.entries(outputs).map(([outputIdentifier, output]) => `${bold(outputIdentifier)} ${dim(output.name)} ${dim(output.description)}`);
|
||||
console.log(formattedOutputs.join('\n'));
|
||||
break;
|
||||
}
|
||||
case "lockingscript": {
|
||||
const lockingscripts = template.lockingScripts;
|
||||
const formattedLockingscripts = Object.entries(lockingscripts).map(([lockingScriptIdentifier, lockingScript]) => `${bold(lockingScriptIdentifier)} ${dim(lockingScript.name)} ${dim(lockingScript.description)}`);
|
||||
console.log(formattedLockingscripts.join('\n'));
|
||||
break;
|
||||
}
|
||||
case "variable": {
|
||||
const variables = template.variables || {};
|
||||
const formattedVariables = Object.entries(variables).map(([variableIdentifier, variable]) => `${bold(variableIdentifier)} ${dim(variable.name)} ${dim(variable.description)}`);
|
||||
console.log(formattedVariables.join('\n'));
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
deps.verboseLogger(`Unknown template category: ${templateCategory}`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Prints the help message for the template inspect command
|
||||
*/
|
||||
export const printTemplateInspectHelp = () => {
|
||||
console.log(
|
||||
`
|
||||
${bold("Usage:")} xo-cli template inspect <category> <identifier> <field>
|
||||
|
||||
${bold("Arguments:")}
|
||||
<category> ${dim("The category of the template to inspect")}
|
||||
<identifier> ${dim("The identifier of the template to inspect")}
|
||||
<field> ${dim("The field of the template to inspect")}
|
||||
|
||||
${bold("Categories:")}
|
||||
- action <action-identifier> ${dim("Inspect an action")}
|
||||
- transaction <transaction-identifier> ${dim("Inspect a transaction")}
|
||||
- output <output-identifier> ${dim("Inspect an output")}
|
||||
- lockingscript <lockingscript-identifier> ${dim("Inspect a lockingscript")}
|
||||
- variable <variable-identifier> ${dim("Inspect a variable")}
|
||||
`);
|
||||
};
|
||||
|
||||
/**
|
||||
* Handles the template inspect command.
|
||||
* @param deps - The command dependencies.
|
||||
* @param args - Positional args after the command name, e.g. ["inspect", "transaction", "1234567890"].
|
||||
*/
|
||||
export const handleTemplateInspectCommand = async (deps: CommandDependencies, args: string[]): Promise<void> => {
|
||||
const templateCategory = args[0];
|
||||
const templateIdentifier = args[1];
|
||||
const templateField = args[2];
|
||||
|
||||
deps.verboseLogger(`Template inspect args - category: ${templateCategory}, identifier: ${templateIdentifier}, field: ${templateField}`);
|
||||
|
||||
if (!templateCategory || !templateIdentifier || !templateField) {
|
||||
console.log("No template category, identifier, or field provided");
|
||||
printTemplateInspectHelp();
|
||||
return;
|
||||
}
|
||||
|
||||
// Get the template from the engine
|
||||
const rawTemplate = await deps.app.engine.getTemplate(templateIdentifier);
|
||||
if (!rawTemplate) {
|
||||
console.error(`No template found: ${templateIdentifier}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Resolve the template references
|
||||
const template = await resolveTemplateReferences(rawTemplate);
|
||||
deps.verboseLogger(`Template: ${formatObject(template)}`);
|
||||
|
||||
// Inspect the template in the category
|
||||
switch (templateCategory) {
|
||||
case "action": {
|
||||
const action = template.actions[templateField];
|
||||
if (!action) {
|
||||
console.error(`No action found: ${templateField}`);
|
||||
return;
|
||||
}
|
||||
objectPrint(action);
|
||||
break;
|
||||
}
|
||||
case "transaction": {
|
||||
const transaction = template.transactions[templateField];
|
||||
if (!transaction) {
|
||||
console.error(`No transaction found: ${templateField}`);
|
||||
return;
|
||||
}
|
||||
objectPrint(transaction);
|
||||
break;
|
||||
}
|
||||
case "output": {
|
||||
const output = template.outputs[templateField];
|
||||
if (!output) {
|
||||
console.error(`No output found: ${templateField}`);
|
||||
return;
|
||||
}
|
||||
objectPrint(output);
|
||||
break;
|
||||
}
|
||||
case "lockingscript": {
|
||||
const lockingscript = template.lockingScripts[templateField];
|
||||
if (!lockingscript) {
|
||||
console.error(`No lockingscript found: ${templateField}`);
|
||||
return;
|
||||
}
|
||||
objectPrint(lockingscript);
|
||||
break;
|
||||
}
|
||||
case "variable": {
|
||||
const variable = template.variables?.[templateField];
|
||||
if (!variable) {
|
||||
console.error(`No variable found: ${templateField}`);
|
||||
return;
|
||||
}
|
||||
objectPrint(variable);
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
deps.verboseLogger(`Unknown template category: ${templateCategory}`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles the template command.
|
||||
* @param deps - The command dependencies.
|
||||
* @param args - Positional args after the command name, e.g. ["import", "template.json"] or ["set-default", "tpl", "out", "role"].
|
||||
* @param options - Parsed option flags.
|
||||
*/
|
||||
export const handleTemplateCommand = async (deps: CommandDependencies, args: string[], options: Record<string, string>): Promise<void> => {
|
||||
const subCommand = args[0];
|
||||
|
||||
if (!subCommand) {
|
||||
deps.verboseLogger("No sub-command provided");
|
||||
printTemplateHelp();
|
||||
return;
|
||||
}
|
||||
|
||||
switch (subCommand) {
|
||||
case "import": {
|
||||
const templateFile = args[1];
|
||||
deps.verboseLogger(`Template file: ${templateFile}`);
|
||||
|
||||
if (!templateFile) {
|
||||
deps.verboseLogger("No template file provided");
|
||||
printTemplateHelp();
|
||||
return;
|
||||
}
|
||||
|
||||
const templatePath = path.resolve(`${process.cwd()}/${templateFile}`);
|
||||
deps.verboseLogger(`Template path: ${templatePath}`);
|
||||
|
||||
if (!existsSync(templatePath)) {
|
||||
console.error(`Template file does not exist: ${templatePath}`);
|
||||
printTemplateHelp();
|
||||
return;
|
||||
}
|
||||
|
||||
const template = await readFileSync(templatePath, "utf8");
|
||||
|
||||
deps.verboseLogger(`Importing template: ${templateFile}`);
|
||||
await deps.app.engine.importTemplate(template);
|
||||
deps.verboseLogger(`Template imported: ${templateFile}`);
|
||||
break;
|
||||
}
|
||||
case "list": {
|
||||
await handleTemplateListCommand(deps, args.slice(1));
|
||||
break;
|
||||
}
|
||||
case "inspect": {
|
||||
await handleTemplateInspectCommand(deps, args.slice(1));
|
||||
break;
|
||||
}
|
||||
case "set-default": {
|
||||
const templateFile = args[1];
|
||||
const outputIdentifier = args[2];
|
||||
const roleIdentifier = args[3];
|
||||
if (!templateFile || !outputIdentifier || !roleIdentifier) {
|
||||
deps.verboseLogger("No template file, output identifier, or role identifier provided");
|
||||
printTemplateHelp();
|
||||
return;
|
||||
}
|
||||
deps.verboseLogger(`Template file: ${templateFile}, output identifier: ${outputIdentifier}, role identifier: ${roleIdentifier}`);
|
||||
await deps.app.engine.setDefaultLockingParameters(templateFile, outputIdentifier, roleIdentifier);
|
||||
break;
|
||||
}
|
||||
default:
|
||||
deps.verboseLogger(`Unknown template sub-command: ${subCommand}`);
|
||||
printTemplateHelp();
|
||||
return;
|
||||
}
|
||||
};
|
||||
6
src/cli/commands/types.ts
Normal file
6
src/cli/commands/types.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import type { AppService } from "../../services/app.js";
|
||||
|
||||
export type CommandDependencies = {
|
||||
verboseLogger: (message: string) => void;
|
||||
app: AppService;
|
||||
};
|
||||
204
src/cli/completions.ts
Normal file
204
src/cli/completions.ts
Normal file
@@ -0,0 +1,204 @@
|
||||
/**
|
||||
* Shell completion script generation.
|
||||
*
|
||||
* Defines the CLI command tree in one place and generates
|
||||
* bash/zsh/fish completion scripts from it. Users source the output
|
||||
* in their shell profile for tab-completion support.
|
||||
*
|
||||
* Usage:
|
||||
* eval "$(xo-cli completions bash)"
|
||||
* eval "$(xo-cli completions zsh)"
|
||||
* xo-cli completions fish | source
|
||||
*/
|
||||
|
||||
/**
|
||||
* Single source of truth for the CLI command tree.
|
||||
* Each top-level key is a command, and its value is an array of sub-commands.
|
||||
*/
|
||||
export const COMMAND_TREE: Record<string, string[]> = {
|
||||
mnemonic: ["create", "import", "list"],
|
||||
template: ["import", "list", "set-default"],
|
||||
invitation: ["create", "import", "list"],
|
||||
receive: [],
|
||||
resource: ["list"],
|
||||
help: [],
|
||||
completions: ["bash", "zsh", "fish"],
|
||||
};
|
||||
|
||||
/** Global option flags available on every command. */
|
||||
const GLOBAL_OPTIONS = ["-h", "--help", "-v", "--verbose", "-m", "--mnemonic-file", "-o", "--output"];
|
||||
|
||||
/**
|
||||
* Generates a bash completion script.
|
||||
* @param binName - The name of the CLI binary (used in the `complete` registration).
|
||||
*/
|
||||
export function generateBashCompletions(binName: string): string {
|
||||
const commands = Object.keys(COMMAND_TREE).join(" ");
|
||||
const options = GLOBAL_OPTIONS.join(" ");
|
||||
|
||||
// Build the case arms for each command's sub-commands
|
||||
const caseArms = Object.entries(COMMAND_TREE)
|
||||
.filter(([, subs]) => subs.length > 0)
|
||||
.map(([cmd, subs]) => ` ${cmd})\n COMPREPLY=($(compgen -W "${subs.join(" ")}" -- "\${cur}"))\n return 0\n ;;`)
|
||||
.join("\n");
|
||||
|
||||
return `# bash completion for ${binName}
|
||||
# Add to ~/.bashrc: eval "$(${binName} completions bash)"
|
||||
_${binName.replace(/-/g, "_")}_completions() {
|
||||
local cur prev words cword
|
||||
_init_completion || return
|
||||
|
||||
# If the current word starts with "-", offer option flags
|
||||
if [[ "\${cur}" == -* ]]; then
|
||||
COMPREPLY=($(compgen -W "${options}" -- "\${cur}"))
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Find the command (first non-option positional arg after the binary)
|
||||
local cmd=""
|
||||
for ((i=1; i < cword; i++)); do
|
||||
if [[ "\${words[i]}" != -* ]]; then
|
||||
cmd="\${words[i]}"
|
||||
break
|
||||
fi
|
||||
done
|
||||
|
||||
# No command yet — offer the top-level commands
|
||||
if [[ -z "\${cmd}" ]]; then
|
||||
COMPREPLY=($(compgen -W "${commands}" -- "\${cur}"))
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Offer sub-commands for the matched command
|
||||
case "\${cmd}" in
|
||||
${caseArms}
|
||||
esac
|
||||
}
|
||||
|
||||
complete -F _${binName.replace(/-/g, "_")}_completions ${binName}
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a zsh completion script.
|
||||
* @param binName - The name of the CLI binary.
|
||||
*/
|
||||
export function generateZshCompletions(binName: string): string {
|
||||
const commands = Object.keys(COMMAND_TREE).join(" ");
|
||||
const options = GLOBAL_OPTIONS.join(" ");
|
||||
|
||||
const caseArms = Object.entries(COMMAND_TREE)
|
||||
.filter(([, subs]) => subs.length > 0)
|
||||
.map(([cmd, subs]) => ` ${cmd})\n compadd -- ${subs.join(" ")}\n ;;`)
|
||||
.join("\n");
|
||||
|
||||
return `# zsh completion for ${binName}
|
||||
# Add to ~/.zshrc: eval "$(${binName} completions zsh)"
|
||||
_${binName.replace(/-/g, "_")}_completions() {
|
||||
local -a commands
|
||||
commands=(${commands})
|
||||
|
||||
# If typing an option flag, complete options
|
||||
if [[ "\${words[\${CURRENT}]}" == -* ]]; then
|
||||
compadd -- ${options}
|
||||
return
|
||||
fi
|
||||
|
||||
# Find the command (first non-option positional arg)
|
||||
local cmd=""
|
||||
for ((i=2; i < CURRENT; i++)); do
|
||||
if [[ "\${words[i]}" != -* ]]; then
|
||||
cmd="\${words[i]}"
|
||||
break
|
||||
fi
|
||||
done
|
||||
|
||||
# No command yet — offer top-level commands
|
||||
if [[ -z "\${cmd}" ]]; then
|
||||
compadd -- \${commands[@]}
|
||||
return
|
||||
fi
|
||||
|
||||
# Offer sub-commands
|
||||
case "\${cmd}" in
|
||||
${caseArms}
|
||||
esac
|
||||
}
|
||||
|
||||
compdef _${binName.replace(/-/g, "_")}_completions ${binName}
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a fish completion script.
|
||||
* @param binName - The name of the CLI binary.
|
||||
*/
|
||||
export function generateFishCompletions(binName: string): string {
|
||||
const lines: string[] = [
|
||||
`# fish completion for ${binName}`,
|
||||
`# Add to fish config: ${binName} completions fish | source`,
|
||||
"",
|
||||
`# Disable file completions by default`,
|
||||
`complete -c ${binName} -f`,
|
||||
"",
|
||||
];
|
||||
|
||||
// Global options
|
||||
for (const opt of GLOBAL_OPTIONS) {
|
||||
const isShort = !opt.startsWith("--");
|
||||
const flag = opt.replace(/^-+/, "");
|
||||
if (isShort) {
|
||||
lines.push(`complete -c ${binName} -s ${flag} -d "Option flag"`);
|
||||
} else {
|
||||
lines.push(`complete -c ${binName} -l ${flag} -d "Option flag"`);
|
||||
}
|
||||
}
|
||||
lines.push("");
|
||||
|
||||
// Top-level commands (only when no sub-command is given yet)
|
||||
const commandNames = Object.keys(COMMAND_TREE);
|
||||
for (const cmd of commandNames) {
|
||||
lines.push(`complete -c ${binName} -n "__fish_use_subcommand" -a "${cmd}" -d "${cmd} command"`);
|
||||
}
|
||||
lines.push("");
|
||||
|
||||
// Sub-commands for each command
|
||||
for (const [cmd, subs] of Object.entries(COMMAND_TREE)) {
|
||||
for (const sub of subs) {
|
||||
lines.push(`complete -c ${binName} -n "__fish_seen_subcommand_from ${cmd}" -a "${sub}" -d "${cmd} ${sub}"`);
|
||||
}
|
||||
}
|
||||
|
||||
return lines.join("\n") + "\n";
|
||||
}
|
||||
|
||||
type ShellType = "bash" | "zsh" | "fish";
|
||||
|
||||
const generators: Record<ShellType, (binName: string) => string> = {
|
||||
bash: generateBashCompletions,
|
||||
zsh: generateZshCompletions,
|
||||
fish: generateFishCompletions,
|
||||
};
|
||||
|
||||
/**
|
||||
* Handles the `completions` command.
|
||||
* Prints the generated completion script for the given shell to stdout.
|
||||
* @param args - Positional args after "completions", e.g. ["bash"].
|
||||
* @param binName - The CLI binary name to use in the completion script.
|
||||
*/
|
||||
export function handleCompletionsCommand(args: string[], binName: string = "xo-cli"): void {
|
||||
const shell = args[0] as ShellType | undefined;
|
||||
|
||||
if (!shell || !generators[shell]) {
|
||||
const supported = Object.keys(generators).join(", ");
|
||||
console.error(`Usage: ${binName} completions <${supported}>`);
|
||||
console.error("");
|
||||
console.error("Examples:");
|
||||
console.error(` eval "$(${binName} completions bash)" # Add to ~/.bashrc`);
|
||||
console.error(` eval "$(${binName} completions zsh)" # Add to ~/.zshrc`);
|
||||
console.error(` ${binName} completions fish | source # Add to fish config`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
process.stdout.write(generators[shell](binName));
|
||||
}
|
||||
204
src/cli/index.ts
Normal file
204
src/cli/index.ts
Normal file
@@ -0,0 +1,204 @@
|
||||
/**
|
||||
* CLI entry point.
|
||||
*
|
||||
* TODO: Decide the best way to handle CLI arguments. We have the option of:
|
||||
* - Handling it in the `bin` folder
|
||||
* - Switch / if statements in here
|
||||
* - Dedicated command parser
|
||||
* - Separate files?
|
||||
*
|
||||
* What kind of commands do we want to support?
|
||||
* Worth noting that we shouldn't need to list invitations? Maybe we will though? If we do, then we will need to reuse the storage + xo-invitations.db file. I think this is fine to do though?
|
||||
* Nah, lets use the storage + xo-invitations.db file. Will allow us to persist invitations.
|
||||
* How do we want to import invitations though? Should we just take in the ID still? Probably makes more sense to allow for reading from a file though...
|
||||
* But thats an entirely different flow to what we have already. And how would we handle writing the invitation? Do we just overwrite the file? Probably... Just take in an -o option; default to overwrite?
|
||||
*
|
||||
* Commands:
|
||||
* xo-cli mnemonic create [mnemonic seed]
|
||||
* xo-cli mnemonic list
|
||||
*
|
||||
* xo-cli template import <template-file>
|
||||
* xo-cli template list
|
||||
* xo-cli template set-default <template-file> <output-identifier> <role-identifier>
|
||||
*
|
||||
* xo-cli invitation list
|
||||
* xo-cli invitation create <template-file> <action-id> [-o Output file, var-${action-variable-name}=${value}, role=${value}]
|
||||
* xo-cli invitation import <invitation-file>
|
||||
* xo-cli invitation sign <invitation-file>
|
||||
* xo-cli invitation broadcast <invitation-file>
|
||||
*
|
||||
* xo-cli resource list
|
||||
*
|
||||
* universal Args:
|
||||
* -h --help
|
||||
* -m --mnemonic-file <mnemonic-file>
|
||||
*/
|
||||
|
||||
import { existsSync, readFileSync, writeFileSync } from "fs";
|
||||
|
||||
import { AppService } from "../services/app.js";
|
||||
import { convertArgsToObject } from "./arguments.js";
|
||||
import { bold, dim, formatObject } from "./cli-utils.js";
|
||||
import { listMnemonicFiles, loadMnemonic } from "./mnemonic.js";
|
||||
|
||||
/** File that remembers the last-used mnemonic so `-m` can be omitted. */
|
||||
const WALLET_CONFIG_FILE = ".xo-cli-wallet";
|
||||
|
||||
import {
|
||||
type CommandDependencies,
|
||||
handleMnemonicCommand,
|
||||
handleTemplateCommand,
|
||||
handleInvitationCommand,
|
||||
handleReceiveCommand,
|
||||
handleResourceCommand,
|
||||
} from "./commands/index.js";
|
||||
|
||||
import { handleCompletionsCommand } from "./completions.js";
|
||||
|
||||
const createConditionalLogger = (verbose: boolean) => {
|
||||
return (message: string) => {
|
||||
if (verbose) {
|
||||
console.log(message);
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Main entry point.
|
||||
* We will:
|
||||
* - Initialize the app service?
|
||||
* - Extract the command being called
|
||||
* - Extract CLI Args (Depends on the command being called. Eww. But we can probably use Zod to validate the args in a decent way?)
|
||||
* - Execute the command
|
||||
* - Export if configured?
|
||||
* - Exit with the appropriate code
|
||||
*/
|
||||
async function main(): Promise<void> {
|
||||
// Initialize the app service
|
||||
// NOTE: We are going to assume that they are using a mnemonic file for now
|
||||
const { args, options } = convertArgsToObject(process.argv.slice(2));
|
||||
|
||||
// Create a verbose logger if the user set the verbose flag
|
||||
const verboseLogger = createConditionalLogger(options["verbose"] === "true");
|
||||
|
||||
// Log the parsed app args
|
||||
verboseLogger(`Parsed args: ${formatObject(args)}`);
|
||||
verboseLogger(`Parsed options: ${formatObject(options)}`);
|
||||
|
||||
// Handle the command
|
||||
const command = args[0];
|
||||
verboseLogger(`Command: ${command}`);
|
||||
if (!command) {
|
||||
// TODO: Print help, probably...
|
||||
console.error("No command provided");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Positional args after the command name (sub-command, files, etc.)
|
||||
const subArgs = args.slice(1);
|
||||
|
||||
// Early handling if we are calling the mnemonic command
|
||||
// TODO: This is ugly. I would like to find a nicer way of doing this.
|
||||
if (command === "completions") {
|
||||
handleCompletionsCommand(subArgs);
|
||||
return;
|
||||
}
|
||||
|
||||
if (command === "mnemonic") {
|
||||
await handleMnemonicCommand({ verboseLogger }, subArgs, options);
|
||||
return;
|
||||
}
|
||||
|
||||
// Resolve mnemonic file: explicit flag > persisted config > error.
|
||||
let mnemonicFile = options["mnemonicFile"];
|
||||
if (!mnemonicFile && existsSync(WALLET_CONFIG_FILE)) {
|
||||
mnemonicFile = readFileSync(WALLET_CONFIG_FILE, "utf8").trim();
|
||||
verboseLogger(`Using persisted wallet: ${mnemonicFile}`);
|
||||
}
|
||||
if (!mnemonicFile) {
|
||||
console.error("No mnemonic file provided");
|
||||
console.log(`You can create a mnemonic file with the following command: xo-cli mnemonic create <mnemonic-seed> or use one of the following files: \n${listMnemonicFiles().join("\n")}`);
|
||||
console.log(`\nTip: pass -m <file> once and it will be remembered in ${WALLET_CONFIG_FILE}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Persist the choice so subsequent commands can omit -m.
|
||||
writeFileSync(WALLET_CONFIG_FILE, mnemonicFile);
|
||||
|
||||
const mnemonic = await loadMnemonic(mnemonicFile);
|
||||
verboseLogger(`Loaded mnemonic from file: ${mnemonicFile} ${mnemonic}`);
|
||||
|
||||
// Create an App instance
|
||||
verboseLogger("Creating app instance...");
|
||||
const app = await AppService.create(mnemonic, {
|
||||
syncServerUrl: options["syncServerUrl"] ?? "http://localhost:3000",
|
||||
engineConfig: {
|
||||
databasePath: options["databasePath"] ?? "./",
|
||||
databaseFilename: options["databaseFilename"] ?? 'xo-wallet.db',
|
||||
},
|
||||
invitationStoragePath: options["invitationStoragePath"] ?? "./xo-invitations.db",
|
||||
});
|
||||
verboseLogger("App instance created");
|
||||
|
||||
// Start the app
|
||||
verboseLogger("Starting app...");
|
||||
await app.start();
|
||||
verboseLogger("App started");
|
||||
|
||||
const commandDependencies: CommandDependencies = {
|
||||
verboseLogger,
|
||||
app,
|
||||
};
|
||||
|
||||
// Handle the command
|
||||
switch (command) {
|
||||
case "template":
|
||||
await handleTemplateCommand(commandDependencies, subArgs, options);
|
||||
break;
|
||||
case "invitation":
|
||||
await handleInvitationCommand(commandDependencies, subArgs, options);
|
||||
break;
|
||||
case "receive":
|
||||
await handleReceiveCommand(commandDependencies, subArgs, options);
|
||||
break;
|
||||
case "resource":
|
||||
await handleResourceCommand(commandDependencies, subArgs, options);
|
||||
break;
|
||||
case "help":
|
||||
await handleHelpCommand(commandDependencies, subArgs, options);
|
||||
break;
|
||||
default:
|
||||
console.error(`Unknown command: ${command}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Exit the process
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const handleHelpCommand = async (deps: CommandDependencies, args: string[], options: Record<string, string>): Promise<void> => {
|
||||
// Im sorry about the formatting here. I'm not sure how to handle this better.
|
||||
console.log(
|
||||
`${bold("XO-CLI Help:")}
|
||||
|
||||
${bold("Usage:")} xo-cli <command> [options]
|
||||
|
||||
Commands:
|
||||
mnemonic ${dim("Manage mnemonic files")}
|
||||
template ${dim("Manage templates")}
|
||||
invitation ${dim("Manage invitations")}
|
||||
receive ${dim("Generate a single-use receiving address")}
|
||||
resource ${dim("Manage resources")}
|
||||
completions ${dim("Generate shell completion scripts (bash, zsh, fish)")}
|
||||
|
||||
Options:
|
||||
-h, --help ${dim("Show this help message")}
|
||||
-m, --mnemonic-file <mnemonic-file> ${dim("Use a specific mnemonic file")}
|
||||
-v, --verbose ${dim("Show verbose output")}`
|
||||
);
|
||||
};
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
});
|
||||
72
src/cli/mnemonic.ts
Normal file
72
src/cli/mnemonic.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import { readdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { BCHMnemonicURL, } from "../utils/bch-mnemonic-url.js";
|
||||
import { encodeBip39Mnemonic, generateBip39Mnemonic } from "@bitauth/libauth";
|
||||
|
||||
/**
|
||||
* Create a new mnemonic seed phrase
|
||||
*/
|
||||
export const createMnemonicSeed = (): string => {
|
||||
// Generate a new mnemonic seed
|
||||
const mnemonic = generateBip39Mnemonic();
|
||||
|
||||
// Return the mnemonic phrase
|
||||
return mnemonic;
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates a mnemonic file from a mnemonic seed
|
||||
* @param mnemonic - The mnemonic seed
|
||||
* @param outputFilename - The filename to write the mnemonic to. If not provided, the first word from the mnemonic will be used as the filename
|
||||
* @returns The filename of the created mnemonic file
|
||||
*/
|
||||
export const createMnemonicFile = (mnemonic: string, outputFilename?: string): string => {
|
||||
// Convert the mnemonic seed to a BCH Mnemonic URL
|
||||
const mnemonicUrl = BCHMnemonicURL.fromSeed(mnemonic);
|
||||
|
||||
// If no output filename is provided, use the first word from the mnemonic as the filename
|
||||
let fileName = outputFilename;
|
||||
if (!fileName) {
|
||||
const firstWord = mnemonic.at(0)?.toLowerCase();
|
||||
if (!firstWord) {
|
||||
throw new Error("Failed to create mnemonic file: Unable to extract first word from the mnemonic");
|
||||
}
|
||||
fileName = `mnemonic-${firstWord}`;
|
||||
}
|
||||
|
||||
// Write the mnemonic URL to a file
|
||||
// TODO: May need PWD or something to ensure we are writing to the correct directory
|
||||
writeFileSync(fileName, mnemonicUrl.toURL());
|
||||
|
||||
return fileName;
|
||||
};
|
||||
|
||||
/**
|
||||
* Loads a mnemonic from a mnemonic file
|
||||
* @param mnemonicFile - The filename of the mnemonic file
|
||||
* @returns The mnemonic seed
|
||||
*/
|
||||
export const loadMnemonic = (mnemonicFile: string): string => {
|
||||
const mnemonicUrl = BCHMnemonicURL.fromURL(readFileSync(mnemonicFile, "utf8"));
|
||||
const { entropy } = mnemonicUrl.toObject();
|
||||
|
||||
// Convert the entropy to a mnemonic seed
|
||||
const mnemonic = encodeBip39Mnemonic(entropy);
|
||||
|
||||
// If the conversion failed, throw an error
|
||||
if (typeof mnemonic === "string") {
|
||||
throw new Error(`Failed to convert entropy to mnemonic: ${mnemonic}`);
|
||||
}
|
||||
|
||||
// Return the mnemonic phrase
|
||||
return mnemonic.phrase;
|
||||
};
|
||||
|
||||
/**
|
||||
* Lists all mnemonic files in the current directory
|
||||
* @returns An array of mnemonic file names
|
||||
*/
|
||||
export const listMnemonicFiles = (): string[] => {
|
||||
const cwd = process.cwd();
|
||||
const filenames = readdirSync(cwd).filter((f: string) => f.startsWith('mnemonic-'));
|
||||
return filenames;
|
||||
};
|
||||
@@ -22,6 +22,14 @@ import { hexToBin } from "@bitauth/libauth";
|
||||
export type AppEventMap = {
|
||||
"invitation-added": Invitation;
|
||||
"invitation-removed": Invitation;
|
||||
"wallet-state-changed": {
|
||||
reason:
|
||||
| "invitation-added"
|
||||
| "invitation-removed"
|
||||
| "invitation-updated"
|
||||
| "invitation-status-changed";
|
||||
invitationIdentifier: string;
|
||||
};
|
||||
};
|
||||
|
||||
export interface AppConfig {
|
||||
@@ -40,6 +48,13 @@ export class AppService extends EventEmitter<AppEventMap> {
|
||||
public electrum: ElectrumService;
|
||||
|
||||
public invitations: Invitation[] = [];
|
||||
private invitationEventCleanup = new Map<
|
||||
string,
|
||||
{
|
||||
onUpdated: (invitation: XOInvitation) => void;
|
||||
onStatusChanged: (status: string) => void;
|
||||
}
|
||||
>();
|
||||
|
||||
static async create(seed: string, config: AppConfig): Promise<AppService> {
|
||||
// Because of a bug that lets wallets read the unspents of other wallets, we are going to manually namespace the storage paths for the app.
|
||||
@@ -57,9 +72,10 @@ export class AppService extends EventEmitter<AppEventMap> {
|
||||
|
||||
// TODO: We *technically* dont want this here, but we also need some initial templates for the wallet, so im doing it here
|
||||
// Import the default P2PKH template
|
||||
await engine.importTemplate(p2pkhTemplate);
|
||||
const { templateIdentifier } = await engine.importTemplate(p2pkhTemplate);
|
||||
|
||||
// console.log('p2pkhTemplate', JSON.stringify(p2pkhTemplate.transactions, null, 2));
|
||||
engine.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
|
||||
// To my knowledge, this doesnt generate any lockscript, so discovery of funds will not work automatically.
|
||||
@@ -80,32 +96,6 @@ export class AppService extends EventEmitter<AppEventMap> {
|
||||
applicationIdentifier: config.electrumApplicationIdentifier,
|
||||
});
|
||||
|
||||
// TEMP because testing is painful
|
||||
// Remove all reserved UTXOs on startup
|
||||
// First, get every unspent output
|
||||
const allUnspentOutputs = await engine.listUnspentOutputsData();
|
||||
|
||||
// Get a set of all the invitation identifiers
|
||||
const allInvitationIdentifiers = new Set(
|
||||
allUnspentOutputs.map((output) => output.invitationIdentifier),
|
||||
);
|
||||
|
||||
// Iterate over the invitation identifiers and unreserve the outputs
|
||||
for (const invitationIdentifier of allInvitationIdentifiers) {
|
||||
// Get the outputs for the invitation
|
||||
const outputs = allUnspentOutputs.filter(
|
||||
(output) => output.invitationIdentifier === invitationIdentifier,
|
||||
);
|
||||
// Unreserve the outputs
|
||||
await engine.unreserveResources(
|
||||
outputs.map((output) => ({
|
||||
outpointTransactionHash: hexToBin(output.outpointTransactionHash),
|
||||
outpointIndex: output.outpointIndex,
|
||||
})),
|
||||
invitationIdentifier,
|
||||
);
|
||||
}
|
||||
|
||||
return new AppService(engine, walletStorage, config, electrum);
|
||||
}
|
||||
|
||||
@@ -153,19 +143,108 @@ export class AppService extends EventEmitter<AppEventMap> {
|
||||
}
|
||||
|
||||
async addInvitation(invitation: Invitation): Promise<void> {
|
||||
this.attachInvitationListeners(invitation);
|
||||
|
||||
// Add the invitation to the invitations array
|
||||
this.invitations.push(invitation);
|
||||
|
||||
// Emit the invitation-added event
|
||||
this.emit("invitation-added", invitation);
|
||||
this.emit("wallet-state-changed", {
|
||||
reason: "invitation-added",
|
||||
invitationIdentifier: invitation.data.invitationIdentifier,
|
||||
});
|
||||
}
|
||||
|
||||
async removeInvitation(invitation: Invitation): Promise<void> {
|
||||
// Remove the invitation from the invitations array
|
||||
this.invitations = this.invitations.filter((i) => i !== invitation);
|
||||
const invitationIdentifier = invitation.data.invitationIdentifier;
|
||||
this.detachInvitationListeners(invitationIdentifier);
|
||||
|
||||
// Remove the invitation from the invitations array while preserving the array reference.
|
||||
const invitationIndex = this.invitations.indexOf(invitation);
|
||||
if (invitationIndex >= 0) {
|
||||
this.invitations.splice(invitationIndex, 1);
|
||||
}
|
||||
|
||||
// Emit the invitation-removed event
|
||||
this.emit("invitation-removed", invitation);
|
||||
this.emit("wallet-state-changed", {
|
||||
reason: "invitation-removed",
|
||||
invitationIdentifier,
|
||||
});
|
||||
}
|
||||
|
||||
private attachInvitationListeners(invitation: Invitation): void {
|
||||
const invitationIdentifier = invitation.data.invitationIdentifier;
|
||||
if (this.invitationEventCleanup.has(invitationIdentifier)) return;
|
||||
|
||||
const onUpdated = () => {
|
||||
this.emit("wallet-state-changed", {
|
||||
reason: "invitation-updated",
|
||||
invitationIdentifier,
|
||||
});
|
||||
};
|
||||
const onStatusChanged = () => {
|
||||
this.emit("wallet-state-changed", {
|
||||
reason: "invitation-status-changed",
|
||||
invitationIdentifier,
|
||||
});
|
||||
};
|
||||
|
||||
invitation.on("invitation-updated", onUpdated);
|
||||
invitation.on("invitation-status-changed", onStatusChanged);
|
||||
|
||||
this.invitationEventCleanup.set(invitationIdentifier, {
|
||||
onUpdated,
|
||||
onStatusChanged,
|
||||
});
|
||||
}
|
||||
|
||||
private detachInvitationListeners(invitationIdentifier: string): void {
|
||||
const trackedInvitation = this.invitations.find(
|
||||
(candidate) =>
|
||||
candidate.data.invitationIdentifier === invitationIdentifier,
|
||||
);
|
||||
const cleanup = this.invitationEventCleanup.get(invitationIdentifier);
|
||||
if (!trackedInvitation || !cleanup) return;
|
||||
|
||||
trackedInvitation.off("invitation-updated", cleanup.onUpdated);
|
||||
trackedInvitation.off(
|
||||
"invitation-status-changed",
|
||||
cleanup.onStatusChanged,
|
||||
);
|
||||
this.invitationEventCleanup.delete(invitationIdentifier);
|
||||
}
|
||||
|
||||
/**
|
||||
* Unreserves all reserved UTXOs across every invitation.
|
||||
* Useful when stale reservations from previous sessions block spending.
|
||||
*
|
||||
* @returns The number of UTXOs that were unreserved.
|
||||
*/
|
||||
async unreserveAllResources(): Promise<number> {
|
||||
const allUnspentOutputs = await this.engine.listUnspentOutputsData();
|
||||
const reserved = allUnspentOutputs.filter((o) => o.reserved);
|
||||
|
||||
// Group by invitation identifier so the engine can clear them properly.
|
||||
const byInvitation = new Map<string, typeof reserved>();
|
||||
for (const output of reserved) {
|
||||
const existing = byInvitation.get(output.invitationIdentifier) ?? [];
|
||||
existing.push(output);
|
||||
byInvitation.set(output.invitationIdentifier, existing);
|
||||
}
|
||||
|
||||
for (const [invitationIdentifier, outputs] of byInvitation) {
|
||||
await this.engine.unreserveResources(
|
||||
outputs.map((o) => ({
|
||||
outpointTransactionHash: hexToBin(o.outpointTransactionHash),
|
||||
outpointIndex: o.outpointIndex,
|
||||
})),
|
||||
invitationIdentifier,
|
||||
);
|
||||
}
|
||||
|
||||
return reserved.length;
|
||||
}
|
||||
|
||||
async start(): Promise<void> {
|
||||
@@ -180,7 +259,7 @@ export class AppService extends EventEmitter<AppEventMap> {
|
||||
|
||||
await Promise.all(
|
||||
invitations.map(async ({ key }) => {
|
||||
await this.createInvitation(key);
|
||||
await this.createInvitation(key).catch(err => console.error(`Error creating invitation ${key}: ${err}`));
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -34,6 +34,7 @@ import { compileCashAssemblyString } from "@xo-cash/engine";
|
||||
export type InvitationEventMap = {
|
||||
"invitation-updated": XOInvitation;
|
||||
"invitation-status-changed": string;
|
||||
"error": Error;
|
||||
};
|
||||
|
||||
export type InvitationDependencies = {
|
||||
@@ -146,32 +147,38 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
|
||||
* Start the invitation - Connect sync server and download latest invitation data.
|
||||
*/
|
||||
async start(): Promise<void> {
|
||||
// Connect to the sync server and get the invitation (in parallel)
|
||||
const [_, invitation] = await Promise.all([
|
||||
this.syncServer.connect(),
|
||||
this.syncServer.getInvitation(this.data.invitationIdentifier),
|
||||
]);
|
||||
try {
|
||||
// Connect to the sync server and get the invitation (in parallel)
|
||||
const [_, invitation] = await Promise.all([
|
||||
this.syncServer.connect(),
|
||||
this.syncServer.getInvitation(this.data.invitationIdentifier),
|
||||
]);
|
||||
|
||||
// There is a chance we get SSE messages before the invitation is returned, so we want to combine any commits
|
||||
const sseCommits = this.data.commits;
|
||||
// There is a chance we get SSE messages before the invitation is returned, so we want to combine any commits
|
||||
const sseCommits = this.data.commits;
|
||||
|
||||
// Merge the commits
|
||||
const combinedCommits = this.mergeCommits(
|
||||
sseCommits,
|
||||
invitation?.commits ?? [],
|
||||
);
|
||||
// Merge the commits
|
||||
const combinedCommits = this.mergeCommits(
|
||||
sseCommits,
|
||||
invitation?.commits ?? [],
|
||||
);
|
||||
|
||||
// Set the invitation data with the combined commits
|
||||
this.data = { ...this.data, ...invitation, commits: combinedCommits };
|
||||
// Set the invitation data with the combined commits
|
||||
this.data = { ...this.data, ...invitation, commits: combinedCommits };
|
||||
|
||||
// Store the invitation in the storage
|
||||
await this.storage.set(this.data.invitationIdentifier, this.data);
|
||||
// Store the invitation in the storage
|
||||
await this.storage.set(this.data.invitationIdentifier, this.data);
|
||||
|
||||
// Publish the invitation to the sync server
|
||||
this.syncServer.publishInvitation(this.data);
|
||||
// Publish the invitation to the sync server
|
||||
this.publishInvitation(this.data);
|
||||
|
||||
// Compute and emit initial status
|
||||
await this.updateStatus();
|
||||
// Compute and emit initial status
|
||||
await this.updateStatus();
|
||||
} catch (err) {
|
||||
// console.error(`Error starting invitation, could not connect to sync server or get invitation`, err);
|
||||
// Emit the error event. We might want to throw? but we need a better way of handling errors in the invitation system because we need the invitation to successfully initialize.
|
||||
this.emit("error", err instanceof Error ? err : new Error(String(err)));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -205,6 +212,18 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Publish the invitation to the sync server
|
||||
*/
|
||||
private async publishInvitation(invitation: XOInvitation = this.data): Promise<void> {
|
||||
try {
|
||||
await this.syncServer.publishInvitation(invitation);
|
||||
} catch (err) {
|
||||
// Emit the error event. We might want to throw? but we need a better way of handling errors in the invitation system because we need the invitation to successfully initialize.
|
||||
this.emit("error", err instanceof Error ? err : new Error(String(err)));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge the commits
|
||||
* @param initial - The initial commits
|
||||
@@ -359,7 +378,7 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
|
||||
this.data = await this.engine.acceptInvitation(this.data, acceptParams);
|
||||
|
||||
// Sync the invitation to the sync server
|
||||
this.syncServer.publishInvitation(this.data);
|
||||
this.publishInvitation(this.data);
|
||||
|
||||
// Update the status of the invitation
|
||||
await this.updateStatus();
|
||||
@@ -373,7 +392,7 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
|
||||
const signedInvitation = await this.engine.signInvitation(this.data);
|
||||
|
||||
// Publish the signed invitation to the sync server
|
||||
this.syncServer.publishInvitation(signedInvitation);
|
||||
this.publishInvitation(signedInvitation);
|
||||
|
||||
// Store the signed invitation in the storage
|
||||
await this.storage.set(this.data.invitationIdentifier, signedInvitation);
|
||||
@@ -385,16 +404,17 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
|
||||
}
|
||||
|
||||
/**
|
||||
* Broadcast the invitation
|
||||
* Broadcast the invitation.
|
||||
* @returns The transaction hash returned by the network after broadcast.
|
||||
*/
|
||||
async broadcast(): Promise<void> {
|
||||
// Broadcast the transaction (executeAction returns transaction hash when broadcastTransaction: true)
|
||||
await this.engine.executeAction(this.data, {
|
||||
async broadcast(): Promise<string> {
|
||||
const txHash = await this.engine.executeAction(this.data, {
|
||||
broadcastTransaction: true,
|
||||
});
|
||||
|
||||
// Update the status of the invitation
|
||||
await this.updateStatus();
|
||||
|
||||
return String(txHash);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
@@ -409,7 +429,7 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
|
||||
this.data = await this.engine.appendInvitation(this.data, data);
|
||||
|
||||
// Sync the invitation to the sync server
|
||||
await this.syncServer.publishInvitation(this.data);
|
||||
await this.publishInvitation(this.data);
|
||||
|
||||
// Store the invitation in the storage
|
||||
await this.storage.set(this.data.invitationIdentifier, this.data);
|
||||
@@ -426,7 +446,7 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
|
||||
await this.append({ inputs });
|
||||
|
||||
// Sync the invitation to the sync server
|
||||
await this.syncServer.publishInvitation(this.data);
|
||||
await this.publishInvitation(this.data);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -449,7 +469,7 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
|
||||
await this.append({ outputs });
|
||||
|
||||
// Sync the invitation to the sync server
|
||||
await this.syncServer.publishInvitation(this.data);
|
||||
await this.publishInvitation(this.data);
|
||||
}
|
||||
|
||||
async addVariables(variables: XOInvitationVariable[]): Promise<void> {
|
||||
@@ -457,7 +477,7 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
|
||||
await this.append({ variables });
|
||||
|
||||
// Sync the invitation to the sync server
|
||||
await this.syncServer.publishInvitation(this.data);
|
||||
await this.publishInvitation(this.data);
|
||||
}
|
||||
|
||||
async findSuitableResources(
|
||||
|
||||
@@ -17,7 +17,6 @@ import { useBlockableInput } from '../hooks/useInputLayer.js';
|
||||
import { useInvitation } from '../hooks/useInvitations.js';
|
||||
import { colors, logoSmall, formatSatoshis, formatHex } from '../theme.js';
|
||||
import { copyToClipboard } from '../utils/clipboard.js';
|
||||
import type { XOInvitation } from '@xo-cash/types';
|
||||
|
||||
/**
|
||||
* Action menu items.
|
||||
|
||||
@@ -58,6 +58,7 @@ const menuItems: ListItemData<string>[] = [
|
||||
{ key: 'import', label: 'Import Invitation', value: 'import' },
|
||||
{ key: 'invitations', label: 'View Invitations', value: 'invitations' },
|
||||
{ key: 'new-address', label: 'Generate New Address', value: 'new-address' },
|
||||
{ key: 'unreserve-all', label: 'Unreserve All Resources', value: 'unreserve-all' },
|
||||
{ key: 'refresh', label: 'Refresh', value: 'refresh' },
|
||||
];
|
||||
|
||||
@@ -160,6 +161,21 @@ export function WalletStateScreen(): React.ReactElement {
|
||||
refresh();
|
||||
}, [refresh]);
|
||||
|
||||
// Keep wallet state in sync with invitation lifecycle and updates.
|
||||
useEffect(() => {
|
||||
if (!appService) return;
|
||||
|
||||
const onWalletStateChanged = () => {
|
||||
void refresh();
|
||||
};
|
||||
|
||||
appService.on('wallet-state-changed', onWalletStateChanged);
|
||||
|
||||
return () => {
|
||||
appService.off('wallet-state-changed', onWalletStateChanged);
|
||||
};
|
||||
}, [appService, refresh]);
|
||||
|
||||
/**
|
||||
* Generates a new receiving address and displays it as a QR code.
|
||||
*/
|
||||
@@ -211,6 +227,25 @@ export function WalletStateScreen(): React.ReactElement {
|
||||
}
|
||||
}, [appService, setStatus, showError, refresh]);
|
||||
|
||||
/**
|
||||
* Unreserves all reserved UTXOs and refreshes the wallet state.
|
||||
*/
|
||||
const unreserveAll = useCallback(async () => {
|
||||
if (!appService) {
|
||||
showError('AppService not initialized');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setStatus('Unreserving all resources...');
|
||||
const count = await appService.unreserveAllResources();
|
||||
showInfo(`Unreserved ${count} resource(s)`);
|
||||
await refresh();
|
||||
} catch (error) {
|
||||
showError(`Failed to unreserve resources: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
}, [appService, setStatus, showError, showInfo, refresh]);
|
||||
|
||||
/**
|
||||
* Handles menu action.
|
||||
*/
|
||||
@@ -228,11 +263,14 @@ export function WalletStateScreen(): React.ReactElement {
|
||||
case 'new-address':
|
||||
generateNewAddress();
|
||||
break;
|
||||
case 'unreserve-all':
|
||||
unreserveAll();
|
||||
break;
|
||||
case 'refresh':
|
||||
refresh();
|
||||
break;
|
||||
}
|
||||
}, [navigate, generateNewAddress, refresh]);
|
||||
}, [navigate, generateNewAddress, unreserveAll, refresh]);
|
||||
|
||||
/**
|
||||
* Handle menu item activation.
|
||||
|
||||
@@ -142,7 +142,7 @@ export function InputsSelectStep({
|
||||
setFocusedIndex(prev => Math.max(0, prev - 1));
|
||||
} else if (key.downArrow || input === 'j') {
|
||||
setFocusedIndex(prev => Math.min(utxos.length - 1, prev + 1));
|
||||
} else if (input === ' ' || (key.return && utxos.length > 0)) {
|
||||
} else if (input === ' ') {
|
||||
if (utxos.length > 0) toggleSelection(focusedIndex);
|
||||
} else if (input === 'a') {
|
||||
setUtxos(prev => prev.map(u => ({ ...u, selected: true })));
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
* Pulled directly from the old stack package.
|
||||
*/
|
||||
import { z } from "zod";
|
||||
import { decodeBip39Mnemonic } from "@bitauth/libauth";
|
||||
|
||||
export type BCHMnemonicURLRaw = {
|
||||
entropy: Uint8Array;
|
||||
@@ -86,6 +87,18 @@ export class BCHMnemonicURL {
|
||||
return new BCHMnemonicURL(raw);
|
||||
}
|
||||
|
||||
static fromSeed(seed: string): BCHMnemonicURL {
|
||||
// Encode the seed to a Uint8Array
|
||||
const entropy = decodeBip39Mnemonic(seed);
|
||||
|
||||
// If the decode failed, throw an error
|
||||
if (typeof entropy === "string") {
|
||||
throw new Error(`Invalid seed: ${entropy}`);
|
||||
}
|
||||
|
||||
return BCHMnemonicURL.fromRaw({ entropy });
|
||||
}
|
||||
|
||||
constructor(protected raw: BCHMnemonicURLRaw) {}
|
||||
|
||||
toObject() {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { XOTemplate, XOTemplateTransactionOutput } from "@xo-cash/types";
|
||||
import type { Invitation } from "../services/invitation.js";
|
||||
import { cashAddressToLockingBytecode, binToHex } from "@bitauth/libauth";
|
||||
|
||||
export interface SelectableUtxoLike {
|
||||
outpointTransactionHash: string;
|
||||
@@ -97,6 +98,34 @@ export const getTransactionOutputIdentifier = (
|
||||
export const normalizeLockingBytecodeHex = (value: string): string =>
|
||||
value.trim().replace(/^0x/i, "");
|
||||
|
||||
/**
|
||||
* Checks whether a string looks like a CashAddress and, if so, converts it
|
||||
* to locking bytecode hex. Returns undefined when the value is not a
|
||||
* recognizable CashAddress (callers should fall through to treat it as raw hex).
|
||||
*/
|
||||
export const tryCashAddressToLockingBytecodeHex = (
|
||||
value: string,
|
||||
): string | undefined => {
|
||||
const trimmed = value.trim();
|
||||
|
||||
// Quick prefix check so we don't run the decoder on obvious hex strings.
|
||||
const looksLikeCashAddress =
|
||||
trimmed.startsWith("bitcoincash:") ||
|
||||
trimmed.startsWith("bchtest:") ||
|
||||
trimmed.startsWith("bchreg:") ||
|
||||
// Handle prefix-less addresses (e.g. "qp..." or "pp...")
|
||||
/^[qpQP][a-zA-Z0-9]{41,}$/.test(trimmed);
|
||||
|
||||
if (!looksLikeCashAddress) return undefined;
|
||||
|
||||
const result = cashAddressToLockingBytecode(trimmed);
|
||||
|
||||
// cashAddressToLockingBytecode returns a string on failure.
|
||||
if (typeof result === "string") return undefined;
|
||||
|
||||
return binToHex(result.bytecode);
|
||||
};
|
||||
|
||||
export const resolveProvidedLockingBytecodeHex = (
|
||||
template: XOTemplate,
|
||||
outputIdentifier: string,
|
||||
@@ -128,6 +157,10 @@ export const resolveProvidedLockingBytecodeHex = (
|
||||
const providedValue = variableValues[variableIdentifier];
|
||||
if (!providedValue) return undefined;
|
||||
|
||||
// If the user pasted a CashAddress, convert it to locking bytecode hex.
|
||||
const fromAddress = tryCashAddressToLockingBytecodeHex(providedValue);
|
||||
if (fromAddress) return fromAddress;
|
||||
|
||||
return normalizeLockingBytecodeHex(providedValue);
|
||||
};
|
||||
|
||||
|
||||
55
tests/cli/arguments.test.ts
Normal file
55
tests/cli/arguments.test.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { describe, it, expect } from "vitest"
|
||||
|
||||
import { convertArgsToObject } from "../../src/cli/arguments";
|
||||
|
||||
const testCases = [
|
||||
{
|
||||
input: ["-h", "--help", "-m", "--mnemonic-file", "mnemonic.txt"],
|
||||
expected: {
|
||||
args: [],
|
||||
options: { help: "true", mnemonicFile: "mnemonic.txt" },
|
||||
},
|
||||
},
|
||||
{
|
||||
input: ['-var-requested-satohis', '1000', '-role', 'receiver'],
|
||||
expected: {
|
||||
args: [],
|
||||
options: { "varRequestedSatohis": "1000", role: "receiver" },
|
||||
},
|
||||
},
|
||||
{
|
||||
input: ['-o', 'output.json', '-var-requested-satohis', '1000', '-role', 'receiver'],
|
||||
expected: {
|
||||
args: [],
|
||||
options: { output: "output.json", "varRequestedSatohis": "1000", role: "receiver" },
|
||||
},
|
||||
},
|
||||
{
|
||||
input: ['mnemonic', 'create', 'page', 'pencil', '-v', '-o', 'mnemonic.txt'],
|
||||
expected: {
|
||||
args: ['mnemonic', 'create', 'page', 'pencil'],
|
||||
options: { verbose: "true", output: "mnemonic.txt" },
|
||||
},
|
||||
},
|
||||
{
|
||||
input: ['-v', 'invitation', 'list', '-m', 'mnemonicFile'],
|
||||
expected: {
|
||||
args: ['invitation', 'list'],
|
||||
options: { verbose: "true", mnemonicFile: "mnemonicFile" },
|
||||
},
|
||||
},
|
||||
{
|
||||
input: ['--help', 'template', 'import', 'template.json'],
|
||||
expected: {
|
||||
args: ['template', 'import', 'template.json'],
|
||||
options: { help: "true" },
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
describe("convertArgsToObject", () => {
|
||||
it.each(testCases)("should split positional args from options", ({ input, expected }) => {
|
||||
const result = convertArgsToObject(input);
|
||||
expect(result).toEqual(expected);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user