Huge commit. Multiple fixes. Refactored commands. Invitations, resources, template inspection, mnemonic stuff, cli utils, pretty printing, remove unreserve on start, fix connectino requirement for invitations, format cashAddress to lockingBytecode on send, lots and lots of other stuff.

This commit is contained in:
2026-04-06 11:56:09 +00:00
parent b475b23beb
commit 55c75501d5
24 changed files with 3284 additions and 77 deletions

2
.gitignore vendored
View File

@@ -10,3 +10,5 @@ dist/
*.sqlite-journal
resolvedTemplate.json
mnemonic-*
.xo-cli-wallet
inv-*.json

1
p2pkh-template.json Normal file

File diff suppressed because one or more lines are too long

1209
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

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

80
src/cli/arguments.ts Normal file
View 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
View 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));
};

View 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";

View 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;
}
};

View 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;
}
};

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

View 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;
}
}
};

View 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;
}
};

View 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
View 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
View 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
View 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;
};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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() {

View File

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

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