From 3d6518e4657704c57beba9c572995b4b0eb75991 Mon Sep 17 00:00:00 2001 From: Harvey Zuccon Date: Fri, 22 May 2026 10:18:30 +0200 Subject: [PATCH 01/22] Completely broken update to latest versions --- src/services/app.ts | 12 ++--- src/services/history.ts | 17 +++---- src/services/invitation.ts | 45 +++++++++++++------ .../screens/invitations/InvitationScreen.tsx | 14 +++--- src/utils/invitation-flow.ts | 2 +- 5 files changed, 56 insertions(+), 34 deletions(-) diff --git a/src/services/app.ts b/src/services/app.ts index d404138..f8d8a67 100644 --- a/src/services/app.ts +++ b/src/services/app.ts @@ -105,7 +105,7 @@ export class AppService extends EventEmitter { templates.forEach(async (template) => { // engine.updateUnspentOutputsForTemplate(generateTemplateIdentifier(template)); - engine.subscribeToLockingBytecodesForTemplate(generateTemplateIdentifier(template)); + engine.subscribeToScriptHashForTemplate(generateTemplateIdentifier(template)); }); }; @@ -114,11 +114,11 @@ export class AppService extends EventEmitter { // Set default locking parameters for P2PKH // To my knowledge, this doesnt generate any lockscript, so discovery of funds will not work automatically. // TODO: Add discovery for funds in the first index? Or until we return 0 TXs? - await engine.setDefaultLockingParameters( - generateTemplateIdentifier(parseTemplate(p2pkhTemplate)), - "receiveOutput", - "receiver", - ); + // await engine.setDefaultLockingParameters( + // generateTemplateIdentifier(parseTemplate(p2pkhTemplate)), + // "receiveOutput", + // "receiver", + // ); // Create our own storage for the invitations const storage = await Storage.create(config.invitationStoragePath); diff --git a/src/services/history.ts b/src/services/history.ts index 959f798..4104c42 100644 --- a/src/services/history.ts +++ b/src/services/history.ts @@ -165,7 +165,7 @@ export class HistoryService { const templateIdentifiers = new Set(); for (const utxo of allUtxos) { - templateIdentifiers.add(utxo.templateIdentifier); + templateIdentifiers.add(utxo.scriptHash); } for (const invitation of this.invitations) { templateIdentifiers.add(invitation.data.templateIdentifier); @@ -186,7 +186,7 @@ export class HistoryService { metadataIndex: WalletMetadataIndex, ): Promise { const scriptHashData = metadataIndex.scriptHashDataByScriptHash.get(utxo.scriptHash); - const templateIdentifier = scriptHashData?.templateIdentifier ?? utxo.templateIdentifier; + const templateIdentifier = scriptHashData?.templateIdentifier!; const template = (await this.engine.getTemplate(templateIdentifier)) ?? null; return { @@ -299,7 +299,7 @@ export class HistoryService { if (!matchingContext) continue; const lockingBytecode = this.getOutputLockingBytecodeHex(output) ?? matchingContext.scriptHashData?.lockingBytecode; - const outputIdentifier = output.outputIdentifier ?? matchingContext.scriptHashData?.outputIdentifier ?? matchingContext.utxo.outputIdentifier; + const outputIdentifier = output.outputIdentifier ?? matchingContext.scriptHashData?.outputIdentifier; const role = output.roleIdentifier ?? this.getFirstEntityRole(entityRoles, commit.entityIdentifier) ?? @@ -380,20 +380,21 @@ export class HistoryService { if (usedUtxoIds.has(this.getUtxoId(context.utxo))) return false; if (scriptHash && context.utxo.scriptHash === scriptHash) return true; if (lockingBytecode && context.scriptHashData?.lockingBytecode === lockingBytecode) return true; - if (output.outputIdentifier && context.utxo.outputIdentifier === output.outputIdentifier) return true; + + if (output.outputIdentifier && context.scriptHashData?.outputIdentifier === output.outputIdentifier) return true; return false; }); } private projectStandaloneUtxo(context: UtxoContext): WalletHistoryItem { const output = this.projectUtxoOutput(context); - const templateIdentifier = context.scriptHashData?.templateIdentifier ?? context.utxo.templateIdentifier; + const templateIdentifier = context.scriptHashData?.templateIdentifier; const role = output.role; return { id: `utxo-${context.utxo.outpointTransactionHash}:${context.utxo.outpointIndex}`, source: "utxo", - templateIdentifier, + templateIdentifier: templateIdentifier ?? "", template: context.template?.name ?? "UnknownTemplate", roles: role ? [role] : ["unknown"], description: output.description, @@ -404,7 +405,7 @@ export class HistoryService { } private projectUtxoOutput(context: UtxoContext): WalletHistoryOutput { - const outputIdentifier = context.scriptHashData?.outputIdentifier ?? context.utxo.outputIdentifier; + const outputIdentifier = context.scriptHashData?.outputIdentifier; const role = context.scriptHashData?.roleIdentifier; return { @@ -557,7 +558,7 @@ export class HistoryService { variables: Record, ): string { try { - return compileCashAssemblyString(description, variables); + return compileCashAssemblyString({ cashAssemblyText: description, variables, evaluationDecodeMode: 'utf8' }); } catch { return this.interpolateSimpleCashAssemblyVariables(description, variables); } diff --git a/src/services/invitation.ts b/src/services/invitation.ts index a740853..6f7fac3 100644 --- a/src/services/invitation.ts +++ b/src/services/invitation.ts @@ -15,6 +15,10 @@ import type { } from "@xo-cash/types"; import type { UnspentOutputData } from "@xo-cash/state"; import { + bigIntToBinUint64LE, + bigIntToBinUintBE, + bigIntToBinUintLE, + bigIntToVmNumber, binToHex, encodeTransaction, generateTransaction, @@ -28,7 +32,7 @@ import type { BaseStorage } from "./storage.js"; import type { BlockchainService } from "./electrum.js"; import { EventEmitter } from "../utils/event-emitter.js"; -import { decodeExtendedJsonObject } from "../utils/ext-json.js"; +import { decodeExtendedJson, decodeExtendedJsonObject, encodeExtendedJson } from "../utils/ext-json.js"; import { compileCashAssemblyString } from "@xo-cash/engine"; export type InvitationEventMap = { @@ -279,7 +283,8 @@ export class Invitation extends EventEmitter { private async computeStatusInternal(): Promise { let missingReqs; try { - missingReqs = await this.engine.listMissingRequirements(this.data); + const missingRequirements = await this.engine.listMissingRequirements(this.data.invitationIdentifier); + missingReqs = missingRequirements.templateRequirements; } catch { return "unknown"; } @@ -394,7 +399,7 @@ export class Invitation extends EventEmitter { */ async sign(): Promise { // Sign the invitation - const signedInvitation = await this.engine.signInvitation(this.data); + const signedInvitation = await this.engine.signInvitation(this.data.invitationIdentifier); // Publish the signed invitation to the sync server this.publishInvitation(signedInvitation); @@ -413,7 +418,7 @@ export class Invitation extends EventEmitter { * @returns The transaction hash returned by the network after broadcast. */ async broadcast(): Promise { - const txHash = await this.engine.executeAction(this.data, { + const txHash = await this.engine.executeAction(this.data.invitationIdentifier, { broadcastTransaction: true, }); @@ -431,7 +436,7 @@ export class Invitation extends EventEmitter { */ async append(data: AppendInvitationParameters): Promise { // Append the commit to the invitation - this.data = await this.engine.appendInvitation(this.data, data); + this.data = await this.engine.appendInvitation(this.data.invitationIdentifier, data); // Sync the invitation to the sync server await this.publishInvitation(this.data); @@ -546,7 +551,7 @@ export class Invitation extends EventEmitter { * Get the missing requirements for the invitation */ async getMissingRequirements() { - return this.engine.listMissingRequirements(this.data); + return this.engine.listMissingRequirements(this.data.invitationIdentifier); } /** @@ -608,33 +613,41 @@ export class Invitation extends EventEmitter { ); } - const valueSatoshisIdentifier = output.valueSatoshis; - if (!valueSatoshisIdentifier) { + const valueSatoshisExpression = output.valueSatoshis; + if (!valueSatoshisExpression) { throw new Error( `Value satoshis identifier not found: ${outputIdentifier} in template: ${this.data.templateIdentifier}`, ); } + console.dir(this.data, { depth: null }); + // Create a list of all the variables from the commits const variables = this.data.commits.flatMap( (c) => c.data?.variables ?? [], ); + console.dir(variables, { depth: null }); // Create a dictionary of the variables const formattedVariables = variables.reduce( (acc, v) => { - acc[v.variableIdentifier ?? ""] = v.value; + const { variableIdentifier, value } = v; + console.log(typeof value); + acc[variableIdentifier ?? ""] = value; return acc; }, {} as Record, ); + console.dir(formattedVariables, { depth: null }); + // Compile the CashAssembly expression to get the value satoshis (It handles the variable replacement for us) - const valueSatoshis = await compileCashAssemblyString( - String(valueSatoshisIdentifier), - formattedVariables, + const valueSatoshis = compileCashAssemblyString( + { cashAssemblyText: String(valueSatoshisExpression), variables: formattedVariables, evaluationDecodeMode: 'bigint' }, ); + console.dir(valueSatoshis, { depth: null }); + // Return the value satoshis as a bigint // TODO: Check this of a vulnerability or crash - I assume there might be one if someone made the `valueSatoshis` a malicious expression return BigInt(valueSatoshis); @@ -688,9 +701,13 @@ export class Invitation extends EventEmitter { // Iterate through the outputs and sum the valueSatoshis for (const output of outputs) { if (typeof output === "string") { - totalSats += await this.getSatsOut(output); + const sats = await this.getSatsOut(output); + console.log(`Sats for output: ${output} is ${sats}`); + totalSats += sats } else { - totalSats += await this.getSatsOut(output.output); + const sats = await this.getSatsOut(output.output); + console.log(`Sats for output: ${output.output} is ${sats}`); + totalSats += sats; } } diff --git a/src/tui/screens/invitations/InvitationScreen.tsx b/src/tui/screens/invitations/InvitationScreen.tsx index 825efa6..c7c8bc4 100644 --- a/src/tui/screens/invitations/InvitationScreen.tsx +++ b/src/tui/screens/invitations/InvitationScreen.tsx @@ -221,7 +221,7 @@ export function InvitationScreen(): React.ReactElement { let isCurrent = true; - appService.engine.getOwnCommits(selectedInvitation.data) + appService.engine.findOwnCommits(selectedInvitation.data.invitationIdentifier) .then((ownCommits) => { if (!isCurrent) return; @@ -723,10 +723,14 @@ export function InvitationScreen(): React.ReactElement { {outputTemplate?.name ?? output.outputIdentifier ?? `Output ${idx}`} {/* Output description */} - {outputTemplate?.description && ' - ' + compileCashAssemblyString(outputTemplate?.description ?? '', variables.reduce((acc, variable) => { - acc[variable.variableIdentifier] = variable.value as XOInvitationVariableValue; - return acc; - }, {} as Record))} + {outputTemplate?.description && ' - ' + compileCashAssemblyString({ + cashAssemblyText: outputTemplate?.description, + variables: variables.reduce((acc, variable) => { + acc[variable.variableIdentifier] = variable.value as XOInvitationVariableValue; + + return acc; + }, {} as Record) + })} {/* Output value */} {outputSatoshis !== null && ` (${formatSatoshis(outputSatoshis)}${getFiatSuffix(outputSatoshis)})`} diff --git a/src/utils/invitation-flow.ts b/src/utils/invitation-flow.ts index 016a33d..65a35ac 100644 --- a/src/utils/invitation-flow.ts +++ b/src/utils/invitation-flow.ts @@ -30,7 +30,7 @@ export const isInvitationRequirementsComplete = async ( invitation: Invitation, ): Promise => { const missingRequirements = await invitation.getMissingRequirements(); - return !hasMissingRequirements(missingRequirements); + return !hasMissingRequirements(missingRequirements.templateRequirements); }; // TODO: Move to engine in templates.ts -- 2.49.1 From def261b56872a1b349b6f3cd5acd03f0538a169c Mon Sep 17 00:00:00 2001 From: Harvey Zuccon Date: Fri, 22 May 2026 14:11:07 +0200 Subject: [PATCH 02/22] Breaking-Change: Extremely rough update to work with Kioks wallet --- package-lock.json | 2 +- src/cli/autocomplete/completions.ts | 7 +- src/cli/autocomplete/offline-engine.ts | 29 +++--- src/cli/autocomplete/scripts/bash.sh | 2 +- src/cli/autocomplete/scripts/fish.fish | 1 + src/cli/autocomplete/scripts/zsh.zsh | 2 +- src/cli/commands/invitation.ts | 129 ++++++++++++++++++++----- src/cli/commands/resource.ts | 44 +++++++-- src/cli/commands/template.ts | 79 ++++++++++++++- src/services/app.ts | 29 ++---- src/services/history.ts | 34 +++++-- src/services/invitation.ts | 6 +- src/services/storage.ts | 22 ++--- src/tui/screens/TemplateList.tsx | 16 ++- src/utils/sync-server.ts | 10 +- src/utils/utxo-metadata.ts | 58 +++++++++++ tests/cli/commands/template.test.ts | 59 ++++++++++- 17 files changed, 422 insertions(+), 107 deletions(-) create mode 100644 src/utils/utxo-metadata.ts diff --git a/package-lock.json b/package-lock.json index af0656a..545500d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -51,7 +51,7 @@ "@electrum-cash/network": "^4.2.2", "@electrum-cash/protocol": "^2.3.1", "@electrum-cash/servers": "^3.1.0", - "@xo-cash/crypto": "file:../crypto", + "@xo-cash/crypto": "^0.0.1", "@xo-cash/primitives": "0.0.1", "@xo-cash/state": "0.0.2", "@xo-cash/templates": "0.0.1", diff --git a/src/cli/autocomplete/completions.ts b/src/cli/autocomplete/completions.ts index 01d1ebe..9b6b2c9 100644 --- a/src/cli/autocomplete/completions.ts +++ b/src/cli/autocomplete/completions.ts @@ -34,8 +34,8 @@ import { homedir } from "node:os"; * * IMPORTANT: Keep this in sync with actual switch statements in command handlers: * - mnemonic.ts: create, import, list, expose - * - template.ts: import, list, inspect, set-default - * - invitation.ts: create, append, sign, broadcast, requirements, import, inspect, list + * - template.ts: import, list, inspect, export, set-default + * - invitation.ts: create, append, sign, broadcast, requirements, import, export, inspect, list * - resource.ts: list, unreserve, unreserve-all * - settings.ts: show, get, set */ @@ -43,7 +43,7 @@ import { homedir } from "node:os"; /** Subcommands for the mnemonic command */ const MNEMONIC_SUBS = ["create", "import", "list", "expose"]; /** Subcommands for the template command */ -const TEMPLATE_SUBS = ["import", "list", "inspect", "set-default"]; +const TEMPLATE_SUBS = ["import", "list", "inspect", "export", "set-default"]; /** Subcommands for the invitation command */ const INVITATION_SUBS = [ "create", @@ -52,6 +52,7 @@ const INVITATION_SUBS = [ "broadcast", "requirements", "import", + "export", "inspect", "list", ]; diff --git a/src/cli/autocomplete/offline-engine.ts b/src/cli/autocomplete/offline-engine.ts index cd8b1d8..933bc12 100644 --- a/src/cli/autocomplete/offline-engine.ts +++ b/src/cli/autocomplete/offline-engine.ts @@ -8,11 +8,7 @@ * and instead constructs the engine directly with an in-memory blockchain provider. */ -import { - BlockchainMonitor, - Engine, - InMemoryBlockchainProvider, -} from "@xo-cash/engine"; +import { BlockchainMonitor, Engine } from "@xo-cash/engine"; import { createStorageAdapter, State, StorageType } from "@xo-cash/state"; import { convertMnemonicToSeedBytes } from "@xo-cash/crypto"; import { binToHex, hash256 } from "@bitauth/libauth"; @@ -67,18 +63,21 @@ export async function createOfflineEngine( // Create the state instance const state = new State(storageAdapter); - // Use in-memory blockchain provider (no network connections) - const blockchainProvider = new InMemoryBlockchainProvider(); - await blockchainProvider.initialize({ - applicationIdentifier: "xo-cli-completions", - electrumOptions: {}, - }); + // Create a minimal blockchain monitor (no electrum initialization) + const blockchainMonitor = new BlockchainMonitor(state); - // Create a minimal blockchain monitor - const blockchainMonitor = new BlockchainMonitor(state, blockchainProvider); + // Engine constructor is private; bypass for offline read-only completions. + type EngineConstructor = new ( + mnemonic: string, + state: State, + blockchainMonitor: BlockchainMonitor, + ) => Engine; - // Construct engine directly without state sync - const engine = new Engine(seed, state, blockchainMonitor, blockchainProvider); + const engine = new (Engine as unknown as EngineConstructor)( + seed, + state, + blockchainMonitor, + ); return engine; } diff --git a/src/cli/autocomplete/scripts/bash.sh b/src/cli/autocomplete/scripts/bash.sh index 45ad873..7089dae 100644 --- a/src/cli/autocomplete/scripts/bash.sh +++ b/src/cli/autocomplete/scripts/bash.sh @@ -161,7 +161,7 @@ _{{FUNC_NAME}}_completions() { fi fi ;; - append|sign|broadcast|requirements|inspect) + append|sign|broadcast|requirements|export|inspect) # These subcommands expect an invitation identifier as first arg. local pos=$((cword - subcmd_idx)) if [[ $pos -eq 1 ]]; then diff --git a/src/cli/autocomplete/scripts/fish.fish b/src/cli/autocomplete/scripts/fish.fish index b0c3bc6..5320c4f 100644 --- a/src/cli/autocomplete/scripts/fish.fish +++ b/src/cli/autocomplete/scripts/fish.fish @@ -70,6 +70,7 @@ complete -c {{BIN_NAME}} -n "__fish_seen_subcommand_from invitation; and __fish_ complete -c {{BIN_NAME}} -n "__fish_seen_subcommand_from invitation; and __fish_seen_subcommand_from sign; and test (count (commandline -opc)) -eq 3" -xa '(__{{FUNC_NAME}}_complete_dynamic invitations)' complete -c {{BIN_NAME}} -n "__fish_seen_subcommand_from invitation; and __fish_seen_subcommand_from broadcast; and test (count (commandline -opc)) -eq 3" -xa '(__{{FUNC_NAME}}_complete_dynamic invitations)' complete -c {{BIN_NAME}} -n "__fish_seen_subcommand_from invitation; and __fish_seen_subcommand_from requirements; and test (count (commandline -opc)) -eq 3" -xa '(__{{FUNC_NAME}}_complete_dynamic invitations)' +complete -c {{BIN_NAME}} -n "__fish_seen_subcommand_from invitation; and __fish_seen_subcommand_from export; and test (count (commandline -opc)) -eq 3" -xa '(__{{FUNC_NAME}}_complete_dynamic invitations)' complete -c {{BIN_NAME}} -n "__fish_seen_subcommand_from invitation; and __fish_seen_subcommand_from inspect; and test (count (commandline -opc)) -eq 3" -xa '(__{{FUNC_NAME}}_complete_dynamic invitations)' # invitation import diff --git a/src/cli/autocomplete/scripts/zsh.zsh b/src/cli/autocomplete/scripts/zsh.zsh index a11989f..1853a97 100644 --- a/src/cli/autocomplete/scripts/zsh.zsh +++ b/src/cli/autocomplete/scripts/zsh.zsh @@ -145,7 +145,7 @@ _{{FUNC_NAME}}_completions() { fi fi ;; - append|sign|broadcast|requirements|inspect) + append|sign|broadcast|requirements|export|inspect) # These subcommands take invitation ID as first argument. local pos=$((CURRENT - subcmd_idx)) if [[ $pos -eq 1 ]]; then diff --git a/src/cli/commands/invitation.ts b/src/cli/commands/invitation.ts index 4d41da9..f7c0a3a 100644 --- a/src/cli/commands/invitation.ts +++ b/src/cli/commands/invitation.ts @@ -11,13 +11,21 @@ import { resolveProvidedLockingBytecodeHex, mapUnspentOutputsToSelectable, autoSelectGreedyUtxos, + hasMissingRequirements, } from "../../utils/invitation-flow.js"; -import { encodeExtendedJson } from "../../utils/ext-json.js"; +import { deserializeInvitation, serializeInvitation } from "@xo-cash/engine"; +import type { XOInvitation } from "@xo-cash/types"; import { resolveTemplate } from "../utils.js"; const DEFAULT_FEE = 500n; const DUST_THRESHOLD = 546n; +/** + * Serializes an invitation to pretty-printed JSON for file export. + */ +const formatInvitationForFile = (invitation: XOInvitation, indent = 2): string => + JSON.stringify(JSON.parse(serializeInvitation(invitation)), null, indent); + /** * 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. @@ -286,9 +294,13 @@ ${bold("Sub-commands:")} - broadcast ${dim("Broadcast an invitation")} - requirements ${dim("Show requirements for an invitation")} - import ${dim("Import an invitation from a file")} + - export [output-file] ${dim("Export an invitation to stdout or a file")} - inspect ${dim("Inspect an invitation")} - list ${dim("List all invitations")} +${bold("Export options:")} + -o --output ${dim("Output filename for the exported invitation")} + ${bold("Create / Append options:")} -var- ${dim("Set a variable (e.g. -var-requested-satoshis 1000)")} --add-input ${dim("Add UTXO input(s), comma-separated (e.g. abc123:0,def456:1)")} @@ -311,6 +323,7 @@ export type InvitationCommandResult = { invitationIdentifier?: string; txHash?: string; count?: number; + outputFile?: string; templateName?: string; actionIdentifier?: string; status?: string; @@ -320,6 +333,66 @@ export type InvitationCommandResult = { variables?: unknown[]; }; +/** + * Handles the invitation export command. + * Throws CommandError on failure, returns result data on success. + * @param deps - The command dependencies. + * @param args - Positional args after "export", e.g. ["invitation-id"] or ["invitation-id", "invitation.json"]. + * @param options - Parsed option flags. + */ +export const handleInvitationExportCommand = async ( + deps: CommandDependencies, + args: string[], + options: Record, +): Promise<{ invitationIdentifier?: string; outputFile?: string }> => { + const invitationIdentifier = args[0]; + deps.io.verbose(`Invitation identifier: ${invitationIdentifier}`); + + if (!invitationIdentifier) { + deps.io.verbose("No invitation identifier provided"); + printInvitationHelp(deps.io); + throw new CommandError( + "invitation.export.identifier_missing", + "No invitation identifier provided", + ); + } + + const invitation = deps.app.invitations.find( + (candidate) => + candidate.data.invitationIdentifier === invitationIdentifier, + ); + + if (!invitation) { + deps.io.err(`Invitation not found: ${invitationIdentifier}`); + throw new CommandError( + "invitation.export.not_found", + `Invitation not found: ${invitationIdentifier}`, + ); + } + + const serializedInvitation = serializeInvitation(invitation.data); + + const outputFile = options["output"] ?? args[1]; + + if (!outputFile) { + deps.io.out(serializedInvitation); + return { invitationIdentifier }; + } + + const outputPath = path.resolve(process.cwd(), outputFile); + try { + writeFileSync(outputPath, serializedInvitation); + } catch (error) { + throw new CommandError( + "invitation.export.write_failed", + `Failed to export invitation to file: ${outputPath} (${error instanceof Error ? error.message : "unknown error"})`, + ); + } + + deps.io.out(`Invitation exported to: ${outputPath}`); + return { invitationIdentifier, outputFile: outputPath }; +}; + /** * Handles the invitation command. * Throws CommandError on failure, returns result data on success. @@ -411,7 +484,7 @@ export const handleInvitationCommand = async ( deps.io.verbose(`Invitation file path: ${invitationFilePath}`); writeFileSync( invitationFilePath, - encodeExtendedJson(invitationInstance.data, 2), + formatInvitationForFile(invitationInstance.data), ); deps.io.out( `Invitation created: ${path.basename(invitationFilePath)} (${invitationInstance.data.invitationIdentifier})`, @@ -421,11 +494,8 @@ export const handleInvitationCommand = async ( 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); + hasMissingRequirements(missingRequirements.templateRequirements) || + missingRequirements.inputsMissingSignatures.length > 0; // If there are missing requirements, print them out if (hasMissing) { @@ -532,7 +602,10 @@ export const handleInvitationCommand = async ( // Write the invitation to a file in the working directory // TODO: Support the -o flag to specify the output path const invitationFilePath = `${deps.paths.workingDir}/inv-${invitation.data.invitationIdentifier}.json`; - writeFileSync(invitationFilePath, encodeExtendedJson(invitation.data, 2)); + writeFileSync( + invitationFilePath, + formatInvitationForFile(invitation.data), + ); deps.io.out( `Invitation updated: ${path.basename(invitationFilePath)} (${invitation.data.invitationIdentifier})`, ); @@ -540,11 +613,8 @@ export const handleInvitationCommand = async ( // Get the missing requirements for the invitation. This will tell us if we are missing any variables, inputs, outputs, or roles. 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); + hasMissingRequirements(missingRequirements.templateRequirements) || + missingRequirements.inputsMissingSignatures.length > 0; // If there are missing requirements, print them out if (hasMissing) { @@ -721,11 +791,10 @@ export const handleInvitationCommand = async ( } // Read the invitation file (XOInvitation format, can be passed to the engine directly) - const invitationFile = await readFileSync(invitationFilePath, "utf8"); + const invitationFile = readFileSync(invitationFilePath, "utf8"); deps.io.verbose(`Invitation file: ${invitationFile}`); - // Parse the invitation file - const invitation = JSON.parse(invitationFile); + const invitation = deserializeInvitation(invitationFile); deps.io.verbose(`Invitation: ${formatObject(invitation)}`); // Create the invitation instance (NOTE: Not an engine compatible invitation. This is the wrapped format) @@ -839,19 +908,27 @@ export const handleInvitationCommand = async ( } // Read the invitation file (XOInvitation format, can be passed to the engine directly) - const invitationFile = await readFileSync(invitationFilePath, "utf8"); + const invitationFile = readFileSync(invitationFilePath, "utf8"); deps.io.verbose(`Invitation file: ${invitationFile}`); - // Parse the invitation file - const invitation = JSON.parse(invitationFile); + const invitation = deserializeInvitation(invitationFile); deps.io.verbose(`Invitation: ${formatObject(invitation)}`); - // "Creates" the invitiation in the engine. This method acts as both creation or import depending on the data that is being passed in - const xoInvitation = await deps.app.engine.createInvitation(invitation); - deps.io.verbose(`XOInvitation: ${formatObject(xoInvitation)}`); + const template = await deps.app.engine.getTemplate( + invitation.templateIdentifier, + ); + if (!template) { + throw new CommandError( + "invitation.import.template_not_found", + `Template not found: ${invitation.templateIdentifier}. ` + + `Import the matching template first with: xo-cli template import `, + ); + } - // Create the invitation instance (NOTE: Not an engine compatible invitation. This is the wrapped format) - const invitationInstance = await deps.app.createInvitation(xoInvitation); + // Accept and track the invitation. Invitation.create calls acceptInvitation + // internally — do not call engine.createInvitation here, that creates a new + // invitation and discards the imported commits. + const invitationInstance = await deps.app.createInvitation(invitation); deps.io.verbose( `Invitation created: ${formatObject(invitationInstance.data)}`, ); @@ -862,6 +939,10 @@ export const handleInvitationCommand = async ( }; } + case "export": { + return handleInvitationExportCommand(deps, args.slice(1), options); + } + case "list": { // List all the invitations const invitations = await Promise.all( diff --git a/src/cli/commands/resource.ts b/src/cli/commands/resource.ts index c812c89..0058a1f 100644 --- a/src/cli/commands/resource.ts +++ b/src/cli/commands/resource.ts @@ -2,8 +2,14 @@ import { hexToBin } from "@bitauth/libauth"; import { bold, dim } from "../utils.js"; import type { CommandDependencies, CommandIO } from "./types.js"; -import type { UnspentOutputData } from "@xo-cash/state"; import { CommandError } from "./types.js"; +import type { XOTemplate } from "@xo-cash/types"; +import { generateTemplateIdentifier } from "@xo-cash/engine"; +import { + buildScriptHashDataMap, + enrichUnspentOutput, + type UnspentOutputWithMetadata, +} from "../../utils/utxo-metadata.js"; /** * Prints the help message for the resource command. @@ -27,9 +33,12 @@ ${bold("Sub-commands:")} * Formats a single UTXO for display, optionally including reservation info. */ function formatResource( - resource: UnspentOutputData, + resource: UnspentOutputWithMetadata & { template?: XOTemplate }, showReserved = false, ): string { + // Format the template + const template = resource.template ? dim(`[${generateTemplateIdentifier(resource.template)}]`) : ""; + // Format the outpoint const outpoint = bold( `${resource.outpointTransactionHash}:${resource.outpointIndex}`, @@ -39,7 +48,9 @@ function formatResource( const value = dim(`${resource.valueSatoshis} sats`); // Format the output - const output = dim(resource.outputIdentifier); + const output = resource.outputIdentifier + ? dim(resource.outputIdentifier) + : ""; // Format the height const height = dim(`(height ${resource.minedAtHeight})`); @@ -47,11 +58,11 @@ function formatResource( // If the resource is reserved, format the reservation info if (showReserved && resource.reservedBy) { const inv = dim(`reserved for ${resource.reservedBy}`); - return `${outpoint} ${value} ${output} ${height} ${inv}`; + return `${template} ${outpoint} ${value} ${output} ${height} ${inv}`; } // Otherwise, format the resource without reservation info - return `${outpoint} ${value} ${output} ${height}`; + return `${template} ${outpoint} ${value} ${output} ${height}`; } /** @@ -108,9 +119,30 @@ export const handleResourceCommand = async ( return { count: 0 }; } + const scriptHashDataByScriptHash = await buildScriptHashDataMap( + deps.app.engine, + ); + + const resourcesWithTemplateInformation = await Promise.all( + filtered.map(async (resource) => { + const enriched = enrichUnspentOutput( + resource, + scriptHashDataByScriptHash, + ); + const template = enriched.templateIdentifier + ? await deps.app.engine.getTemplate(enriched.templateIdentifier) + : undefined; + + return { + ...enriched, + template, + }; + }), + ); + // Format the resources into a list of strings that we can display to the user const showReserved = qualifier === "all" || qualifier === "reserved"; - const formattedResources = filtered.map((r) => + const formattedResources = resourcesWithTemplateInformation.map((r) => formatResource(r, showReserved), ); diff --git a/src/cli/commands/template.ts b/src/cli/commands/template.ts index cf27898..ce0d4b4 100644 --- a/src/cli/commands/template.ts +++ b/src/cli/commands/template.ts @@ -1,4 +1,4 @@ -import { existsSync, readFileSync } from "fs"; +import { existsSync, readFileSync, writeFileSync } from "fs"; import path from "path"; import { generateTemplateIdentifier } from "@xo-cash/engine"; import type { XOTemplate } from "@xo-cash/types"; @@ -23,6 +23,10 @@ ${bold("Sub-commands:")} - list ${dim("List all options of the field type in a template")} - inspect ${dim("Inspect a field in a template")} - set-default ${dim("Set the default template")} + - export [output-file] ${dim("Export a template to stdout or a file")} + +${bold("Options:")} + -o --output ${dim("Output filename for the exported template")} `, ); }; @@ -338,6 +342,71 @@ export const handleTemplateInspectCommand = async ( } }; +/** + * Handles the template export command. + * Throws CommandError on failure, returns result data on success. + * @param deps - The command dependencies. + * @param args - Positional args after "export", e.g. ["template-id"] or ["template-id", "template.json"]. + * @param options - Parsed option flags. + */ +export const handleTemplateExportCommand = async ( + deps: CommandDependencies, + args: string[], + options: Record, +): Promise<{ outputFile?: string }> => { + // Get the template identifier from the arguments + const templateIdentifier = args[0]; + + // If no template identifier is provided, print a message and throw an error + if (!templateIdentifier) { + deps.io.err("No template identifier provided"); + printTemplateHelp(deps.io); + throw new CommandError( + "template.export.identifier_missing", + "No template identifier provided", + ); + } + + // Get the raw template from the engine. + // Do not resolve references or pretty-print the template. + const rawTemplate = await deps.app.engine.getTemplate(templateIdentifier); + + // If the raw template is not found, print a message and throw an error + if (!rawTemplate) { + deps.io.err(`No template found: ${templateIdentifier}`); + throw new CommandError( + "template.export.not_found", + `No template found: ${templateIdentifier}`, + ); + } + + // Serialize the template without indentation to preserve the engine output shape. + const serializedTemplate = JSON.stringify(rawTemplate); + + // Resolve output file from --output (or -o), then fallback to optional positional output file + const outputFile = options["output"] ?? args[1]; + + // If no output file is provided, print the template to stdout + if (!outputFile) { + deps.io.out(serializedTemplate); + return {}; + } + + // Resolve output file path and write the template to disk + const outputPath = path.resolve(process.cwd(), outputFile); + try { + writeFileSync(outputPath, serializedTemplate); + } catch (error) { + throw new CommandError( + "template.export.write_failed", + `Failed to export template to file: ${outputPath} (${error instanceof Error ? error.message : "unknown error"})`, + ); + } + + deps.io.out(`Template exported to: ${outputPath}`); + return { outputFile: outputPath }; +}; + /** * Handles the template command. * Throws CommandError on failure, returns result data on success. @@ -348,8 +417,8 @@ export const handleTemplateInspectCommand = async ( export const handleTemplateCommand = async ( deps: CommandDependencies, args: string[], - _options: Record, -): Promise<{ templateFile?: string; count?: number }> => { + options: Record, +): Promise<{ templateFile?: string; count?: number; outputFile?: string }> => { // Get the sub-command from the arguments const subCommand = args[0]; @@ -414,6 +483,10 @@ export const handleTemplateCommand = async ( // Handle the template inspect command, We offload here as it has lots of arguments and is quite long return handleTemplateInspectCommand(deps, args.slice(1)); } + case "export": { + // Handle the template export command + return handleTemplateExportCommand(deps, args.slice(1), options); + } case "set-default": { // Get the template file, output identifier, and role identifier from the arguments const templateFile = args[1]; diff --git a/src/services/app.ts b/src/services/app.ts index f8d8a67..62f2289 100644 --- a/src/services/app.ts +++ b/src/services/app.ts @@ -81,22 +81,7 @@ export class AppService extends EventEmitter { // 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 - const { templateIdentifier } = await engine.importTemplate(p2pkhTemplate); - - // 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}`, - // ), - // ); + await engine.importTemplate(p2pkhTemplate); // Update all the unspents for every template, and subscribe to the locking bytecodes for changes // TODO: Remove the above lines that do the same thing. Minimising changes for BLISS. @@ -104,7 +89,7 @@ export class AppService extends EventEmitter { const templates = await engine.listImportedTemplates(); templates.forEach(async (template) => { - // engine.updateUnspentOutputsForTemplate(generateTemplateIdentifier(template)); + engine.updateUnspentOutputsForTemplate(generateTemplateIdentifier(template)); engine.subscribeToScriptHashForTemplate(generateTemplateIdentifier(template)); }); }; @@ -114,11 +99,11 @@ export class AppService extends EventEmitter { // Set default locking parameters for P2PKH // To my knowledge, this doesnt generate any lockscript, so discovery of funds will not work automatically. // TODO: Add discovery for funds in the first index? Or until we return 0 TXs? - // await engine.setDefaultLockingParameters( - // generateTemplateIdentifier(parseTemplate(p2pkhTemplate)), - // "receiveOutput", - // "receiver", - // ); + await engine.setDefaultLockingParameters( + generateTemplateIdentifier(parseTemplate(p2pkhTemplate)), + "receiveOutput", + "receiver", + ); // Create our own storage for the invitations const storage = await Storage.create(config.invitationStoragePath); diff --git a/src/services/history.ts b/src/services/history.ts index 4104c42..f418ba2 100644 --- a/src/services/history.ts +++ b/src/services/history.ts @@ -1,6 +1,6 @@ import { binToHex, hexToBin, sha256 } from "@bitauth/libauth"; import { compileCashAssemblyString, type Engine } from "@xo-cash/engine"; -import type { ScriptHashData, UnspentOutputData } from "@xo-cash/state"; +import type { ScriptHashData, State, UnspentOutputData } from "@xo-cash/state"; import type { XOInvitation, XOInvitationCommit, @@ -146,8 +146,10 @@ export class HistoryService { const contexts = new Map(); for (const invitation of this.invitations) { - const template = - (await this.engine.getTemplate(invitation.data.templateIdentifier)) ?? null; + const templateIdentifier = invitation.data.templateIdentifier; + const template = templateIdentifier + ? (await this.engine.getTemplate(templateIdentifier)) ?? null + : null; contexts.set(invitation.data.invitationIdentifier, { invitation, template, @@ -164,11 +166,19 @@ export class HistoryService { const scriptHashDataByScriptHash = new Map(); const templateIdentifiers = new Set(); - for (const utxo of allUtxos) { - templateIdentifiers.add(utxo.scriptHash); - } for (const invitation of this.invitations) { - templateIdentifiers.add(invitation.data.templateIdentifier); + if (invitation.data.templateIdentifier) { + templateIdentifiers.add(invitation.data.templateIdentifier); + } + } + + const uniqueScriptHashes = new Set(allUtxos.map((utxo) => utxo.scriptHash)); + for (const scriptHash of uniqueScriptHashes) { + const scriptHashData = await this.getScriptHashData(scriptHash); + if (scriptHashData === undefined) continue; + + scriptHashDataByScriptHash.set(scriptHash, scriptHashData); + templateIdentifiers.add(scriptHashData.templateIdentifier); } for (const templateIdentifier of templateIdentifiers) { @@ -186,8 +196,10 @@ export class HistoryService { metadataIndex: WalletMetadataIndex, ): Promise { const scriptHashData = metadataIndex.scriptHashDataByScriptHash.get(utxo.scriptHash); - const templateIdentifier = scriptHashData?.templateIdentifier!; - const template = (await this.engine.getTemplate(templateIdentifier)) ?? null; + const templateIdentifier = scriptHashData?.templateIdentifier; + const template = templateIdentifier + ? (await this.engine.getTemplate(templateIdentifier)) ?? null + : null; return { utxo, @@ -592,6 +604,10 @@ export class HistoryService { : binToHex(output.lockingBytecode); } + private async getScriptHashData(scriptHash: string): Promise { + return (this.engine as unknown as { state: State }).state.getScriptHashData(scriptHash); + } + private getOutpointKey(txid: string, index: number): string { return `${txid}:${index}`; } diff --git a/src/services/invitation.ts b/src/services/invitation.ts index 6f7fac3..55f1ba6 100644 --- a/src/services/invitation.ts +++ b/src/services/invitation.ts @@ -32,7 +32,7 @@ import type { BaseStorage } from "./storage.js"; import type { BlockchainService } from "./electrum.js"; import { EventEmitter } from "../utils/event-emitter.js"; -import { decodeExtendedJson, decodeExtendedJsonObject, encodeExtendedJson } from "../utils/ext-json.js"; +import { decodeExtendedJsonObject } from "../utils/ext-json.js"; import { compileCashAssemblyString } from "@xo-cash/engine"; export type InvitationEventMap = { @@ -154,6 +154,10 @@ export class Invitation extends EventEmitter { * Start the invitation - Connect sync server and download latest invitation data. */ async start(): Promise { + // Persist immediately so imports survive sync-server outages and appear in the TUI + // after a CLI import or app restart. + await this.storage.set(this.data.invitationIdentifier, this.data); + try { // Connect to the sync server and get the invitation (in parallel) const [_, invitation] = await Promise.all([ diff --git a/src/services/storage.ts b/src/services/storage.ts index eb09c89..8e55947 100644 --- a/src/services/storage.ts +++ b/src/services/storage.ts @@ -1,5 +1,6 @@ import Database from "better-sqlite3"; -import { decodeExtendedJson, encodeExtendedJson } from "../utils/ext-json.js"; +import { deserializeInvitation, serializeInvitation } from "@xo-cash/engine"; +import type { XOInvitation } from "@xo-cash/types"; /** * This is not an actual storage adapter that we want to make use of. This storage adapter is a stop-gap while the engine is under development. @@ -56,9 +57,8 @@ export class Storage extends BaseStorage { return fullKey.startsWith(prefix) ? fullKey.slice(prefix.length) : fullKey; } - async set(key: string, value: any): Promise { - // Encode the extended json object - const encodedValue = encodeExtendedJson(value); + async set(key: string, value: XOInvitation): Promise { + const encodedValue = serializeInvitation(value); // Insert or replace the value into the database with full key (including basePath) const fullKey = this.getFullKey(key); @@ -93,10 +93,10 @@ export class Storage extends BaseStorage { return !strippedKey.includes("."); }); - // Decode the extended json objects and strip basePath from keys + // Deserialize invitations and strip basePath from keys return filteredRows.map((row) => ({ key: this.stripBasePath(row.key), - value: decodeExtendedJson(row.value), + value: deserializeInvitation(row.value), })); } @@ -111,7 +111,7 @@ export class Storage extends BaseStorage { if (!row) return null; // Decode the extended json object - return decodeExtendedJson(row.value); + return deserializeInvitation(row.value); } async remove(key: string): Promise { @@ -174,9 +174,9 @@ export class InMemoryStorage extends BaseStorage { return fullKey.startsWith(prefix) ? fullKey.slice(prefix.length) : fullKey; } - async set(key: string, value: any): Promise { + async set(key: string, value: XOInvitation): Promise { const fullKey = this.getFullKey(key); - const encodedValue = encodeExtendedJson(value); + const encodedValue = serializeInvitation(value); this.store.set(fullKey, encodedValue); } @@ -199,7 +199,7 @@ export class InMemoryStorage extends BaseStorage { return filteredRows.map((row) => ({ key: this.stripBasePath(row.key), - value: decodeExtendedJson(row.value), + value: deserializeInvitation(row.value), })); } @@ -208,7 +208,7 @@ export class InMemoryStorage extends BaseStorage { const encodedValue = this.store.get(fullKey); if (encodedValue === undefined) return null; - return decodeExtendedJson(encodedValue); + return deserializeInvitation(encodedValue); } async remove(key: string): Promise { diff --git a/src/tui/screens/TemplateList.tsx b/src/tui/screens/TemplateList.tsx index 2015d77..3d25e38 100644 --- a/src/tui/screens/TemplateList.tsx +++ b/src/tui/screens/TemplateList.tsx @@ -24,6 +24,7 @@ import { formatActionListItem, getTemplateRoles, } from '../../utils/template-utils.js'; +import { buildScriptHashDataMap } from '../../utils/utxo-metadata.js'; /** * Template item with metadata. @@ -83,12 +84,21 @@ export function TemplateListScreen(): React.ReactElement { const templateList = await appService.engine.listImportedTemplates(); const allUtxos = await appService.engine.listUnspentOutputsData(); + const scriptHashDataByScriptHash = + await buildScriptHashDataMap(appService.engine); const ownedOutputsByTemplate = new Map>(); for (const utxo of allUtxos) { - const existing = ownedOutputsByTemplate.get(utxo.templateIdentifier) ?? new Set(); - existing.add(utxo.outputIdentifier); - ownedOutputsByTemplate.set(utxo.templateIdentifier, existing); + const scriptRow = scriptHashDataByScriptHash.get(utxo.scriptHash); + if (scriptRow === undefined) { + continue; + } + + const existing = + ownedOutputsByTemplate.get(scriptRow.templateIdentifier) ?? + new Set(); + existing.add(scriptRow.outputIdentifier); + ownedOutputsByTemplate.set(scriptRow.templateIdentifier, existing); } const loadedTemplates = await Promise.all( diff --git a/src/utils/sync-server.ts b/src/utils/sync-server.ts index e8ec25c..03fe558 100644 --- a/src/utils/sync-server.ts +++ b/src/utils/sync-server.ts @@ -1,7 +1,7 @@ import type { XOInvitation } from "@xo-cash/types"; import { EventEmitter } from "./event-emitter.js"; import { SSESession, type SSEvent } from "./sse-client.js"; -import { decodeExtendedJson, encodeExtendedJson } from "./ext-json.js"; +import { deserializeInvitation, serializeInvitation } from "@xo-cash/engine"; export type SyncServerEventMap = { connected: void; @@ -81,9 +81,7 @@ export class SyncServer extends EventEmitter { throw new Error(`Failed to get invitation: ${response.statusText}`); } - const invitation = decodeExtendedJson(await response.text()) as - | XOInvitation - | undefined; + const invitation = deserializeInvitation(await response.text()); return invitation; } @@ -96,7 +94,7 @@ export class SyncServer extends EventEmitter { // Send a POST request to the sync server const response = await fetch(`${this.baseUrl}/invitations`, { method: "POST", - body: encodeExtendedJson(invitation), + body: serializeInvitation(invitation), headers: { "Content-Type": "application/json", }, @@ -109,7 +107,7 @@ export class SyncServer extends EventEmitter { // Read the returned JSON // TODO: This should use zod to verify the response - const data = decodeExtendedJson(await response.text()) as XOInvitation; + const data = deserializeInvitation(await response.text()); return data; } diff --git a/src/utils/utxo-metadata.ts b/src/utils/utxo-metadata.ts new file mode 100644 index 0000000..e3829f4 --- /dev/null +++ b/src/utils/utxo-metadata.ts @@ -0,0 +1,58 @@ +import type { Engine } from "@xo-cash/engine"; +import type { ScriptHashData, UnspentOutputData } from "@xo-cash/state"; + +/** + * Template and output identifiers resolved from script hash storage. + */ +export type UnspentOutputMetadata = { + templateIdentifier?: string; + outputIdentifier?: string; +}; + +export type UnspentOutputWithMetadata = UnspentOutputData & UnspentOutputMetadata; + +/** + * Builds a lookup map from script hash to its stored metadata. + */ +export const buildScriptHashDataMap = async ( + engine: Engine, +): Promise> => { + const scriptHashes = await engine.listScriptHashes(); + const scriptHashDataByScriptHash = new Map(); + + for (const scriptHashRow of scriptHashes) { + scriptHashDataByScriptHash.set(scriptHashRow.scriptHash, scriptHashRow); + } + + return scriptHashDataByScriptHash; +}; + +/** + * Resolves template/output metadata for a single UTXO via its script hash. + */ +export const getUnspentOutputMetadata = ( + utxo: UnspentOutputData, + scriptHashDataByScriptHash: Map, +): UnspentOutputMetadata => { + const scriptRow = scriptHashDataByScriptHash.get(utxo.scriptHash); + + if (scriptRow === undefined) { + return {}; + } + + return { + templateIdentifier: scriptRow.templateIdentifier, + outputIdentifier: scriptRow.outputIdentifier, + }; +}; + +/** + * Returns a UTXO enriched with template/output metadata from script hash storage. + */ +export const enrichUnspentOutput = ( + utxo: UnspentOutputData, + scriptHashDataByScriptHash: Map, +): UnspentOutputWithMetadata => ({ + ...utxo, + ...getUnspentOutputMetadata(utxo, scriptHashDataByScriptHash), +}); diff --git a/tests/cli/commands/template.test.ts b/tests/cli/commands/template.test.ts index 4301809..db3de1b 100644 --- a/tests/cli/commands/template.test.ts +++ b/tests/cli/commands/template.test.ts @@ -1,5 +1,5 @@ import { expect, test, describe, beforeEach, afterEach } from "vitest"; -import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; @@ -98,6 +98,13 @@ const testCases: TestCase[] = [ shouldThrow: false, expectedData: {}, }, + { + name: "export returns raw template json to stdout", + inputs: ["export", p2pkhTemplateIdentifier], + shouldThrow: false, + expectedData: {}, + logs: [{ out: "\"name\":\"Wallet (P2PKH)\"" }], + }, // Error cases - subcommand { name: "throws when no subcommand provided", @@ -124,6 +131,18 @@ const testCases: TestCase[] = [ shouldThrow: true, expectedEvent: "template.import.file_not_found", }, + { + name: "throws when export called without template identifier", + inputs: ["export"], + shouldThrow: true, + expectedEvent: "template.export.identifier_missing", + }, + { + name: "throws when export called with unknown template", + inputs: ["export", "unknown-template"], + shouldThrow: true, + expectedEvent: "template.export.not_found", + }, // Error cases - list category { name: "throws when list category called without template identifier", @@ -263,4 +282,42 @@ describe("template command", () => { process.chdir(originalCwd); } }); + + test("export prints exact engine template JSON to stdout", async () => { + const { io, capture } = createMockIO(); + const expectedTemplate = await engine.getTemplate(p2pkhTemplateIdentifier); + expect(expectedTemplate).toBeDefined(); + + await handleTemplateCommand( + createCommandDeps(app, io), + ["export", p2pkhTemplateIdentifier], + {}, + ); + + expect(capture.out[0]).toBe(JSON.stringify(expectedTemplate)); + }); + + test("export writes exact engine template JSON to file", async () => { + const outputFile = "exported-template.json"; + const outputPath = path.join(tempDir, outputFile); + const { io } = createMockIO(); + const expectedTemplate = await engine.getTemplate(p2pkhTemplateIdentifier); + expect(expectedTemplate).toBeDefined(); + + const originalCwd = process.cwd(); + process.chdir(tempDir); + try { + const result = await handleTemplateCommand( + createCommandDeps(app, io), + ["export", p2pkhTemplateIdentifier], + { output: outputFile }, + ); + + const exportedTemplate = readFileSync(outputPath, "utf8"); + expect(exportedTemplate).toBe(JSON.stringify(expectedTemplate)); + expect(result.outputFile).toBe(path.resolve(process.cwd(), outputFile)); + } finally { + process.chdir(originalCwd); + } + }); }); -- 2.49.1 From 85746c33067efec09d8dba9656a7a078e714c5f6 Mon Sep 17 00:00:00 2001 From: Harvey Zuccon Date: Sun, 24 May 2026 19:35:50 +0200 Subject: [PATCH 03/22] Use xo-cash/utils sse. Add vending machine template. Greatly improve startup times. --- package-lock.json | 48 +++++- package.json | 1 + src/services/app.ts | 18 +- src/services/history.ts | 1 - src/services/invitation.ts | 23 +-- src/services/rates.ts | 14 +- src/templates/vending-machine.ts | 277 +++++++++++++++++++++++++++++++ src/tui/hooks/useAppContext.tsx | 2 +- src/tui/screens/SeedInput.tsx | 4 +- src/utils/rates/rates-oracles.ts | 2 +- src/utils/sync-server.ts | 8 +- 11 files changed, 367 insertions(+), 31 deletions(-) create mode 100644 src/templates/vending-machine.ts diff --git a/package-lock.json b/package-lock.json index 545500d..324da2e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,6 +17,7 @@ "@xo-cash/state": "file:../state", "@xo-cash/templates": "file:../templates", "@xo-cash/types": "^0.0.1", + "@xo-cash/utils": "file:../utils", "better-sqlite3": "^12.6.2", "clipboardy": "^5.1.0", "ink": "^6.6.0", @@ -47,16 +48,16 @@ "license": "MIT", "dependencies": { "@bitauth/libauth": "^3.1.0-next.8", - "@electrum-cash/application": "^0.2.3-development.13424909069", + "@electrum-cash/application": "^0.2.3-development.13447192992", "@electrum-cash/network": "^4.2.2", "@electrum-cash/protocol": "^2.3.1", "@electrum-cash/servers": "^3.1.0", - "@xo-cash/crypto": "^0.0.1", - "@xo-cash/primitives": "0.0.1", - "@xo-cash/state": "0.0.2", + "@xo-cash/crypto": "0.0.1", + "@xo-cash/primitives": "file:../primitives", + "@xo-cash/state": "file:../state", "@xo-cash/templates": "0.0.1", - "@xo-cash/types": "0.0.1", - "@xo-cash/utils": "0.0.1", + "@xo-cash/types": "^0.0.1-development.14519184304", + "@xo-cash/utils": "^0.0.1-development.14519184505", "eventemitter3": "^5.0.1" }, "devDependencies": { @@ -140,6 +141,37 @@ "vitest": "^4.0.17" } }, + "../utils": { + "name": "@xo-cash/utils", + "version": "0.0.1", + "license": "MIT", + "dependencies": { + "@bitauth/libauth": "^3.1.0-next.8", + "@xo-cash/types": "0.0.1", + "zod": "^4.3.6" + }, + "devDependencies": { + "@chalp/eslint-airbnb": "^1.3.0", + "@generalprotocols/cspell-dictionary": "^1.0.1", + "@stylistic/eslint-plugin": "^5.7.0", + "@types/node": "^25.5.0", + "@typescript-eslint/eslint-plugin": "^8.53.1", + "@typescript-eslint/parser": "^8.53.1", + "@vitest/coverage-v8": "^4.0.17", + "@viz-kit/esbuild-analyzer": "^1.0.0", + "@xo-cash/eslint-config": "1.0.1", + "@xo-cash/templates": "0.0.1", + "cspell": "^9.6.0", + "eslint": "^9.39.2", + "prettier": "^3.6.2", + "tsdown": "^0.20.0-beta.4", + "typedoc": "^0.28.16", + "typedoc-plugin-coverage": "^4.0.2", + "typescript": "^5.3.2", + "typescript-eslint": "^8.53.1", + "vitest": "^4.0.17" + } + }, "node_modules/@alcalzone/ansi-tokenize": { "version": "0.2.4", "resolved": "https://registry.npmjs.org/@alcalzone/ansi-tokenize/-/ansi-tokenize-0.2.4.tgz", @@ -977,6 +1009,10 @@ "node": "^12.20.0 || ^14.13.1 || >=16.0.0" } }, + "node_modules/@xo-cash/utils": { + "resolved": "../utils", + "link": true + }, "node_modules/ansi-escapes": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.2.0.tgz", diff --git a/package.json b/package.json index 00e6ed2..a970123 100644 --- a/package.json +++ b/package.json @@ -41,6 +41,7 @@ "@xo-cash/state": "file:../state", "@xo-cash/templates": "file:../templates", "@xo-cash/types": "^0.0.1", + "@xo-cash/utils": "file:../utils", "better-sqlite3": "^12.6.2", "clipboardy": "^5.1.0", "ink": "^6.6.0", diff --git a/src/services/app.ts b/src/services/app.ts index 62f2289..bb5a885 100644 --- a/src/services/app.ts +++ b/src/services/app.ts @@ -18,10 +18,13 @@ import { EventEmitter } from "../utils/event-emitter.js"; // TODO: Remove this. Exists to hash the seed for database namespace. import { createHash } from "crypto"; -import { p2pkhTemplate } from "@xo-cash/templates"; import { hexToBin } from "@bitauth/libauth"; import { parseTemplate } from "@xo-cash/engine"; +import { p2pkhTemplate } from "@xo-cash/templates"; +import { vendingMachineTemplate } from "../templates/vending-machine.js"; +import { wrapBCHTemplate } from "../templates/wrap-template.js"; + export type AppEventMap = { "invitation-added": Invitation; "invitation-removed": Invitation; @@ -53,6 +56,12 @@ export class AppService extends EventEmitter { public settings: SettingsService; public invitations: Invitation[] = []; + /** + * Incremented whenever the invitation list or any invitation's data/status changes. + * Used by TUI hooks so useSyncExternalStore snapshots change on in-place mutations. + */ + public invitationsRevision = 0; + private invitationRevisions = new Map(); private invitationEventCleanup = new Map< string, { @@ -82,7 +91,9 @@ export class AppService extends EventEmitter { // 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); - + await engine.importTemplate(vendingMachineTemplate); + await engine.importTemplate(wrapBCHTemplate); + // Update all the unspents for every template, and subscribe to the locking bytecodes for changes // TODO: Remove the above lines that do the same thing. Minimising changes for BLISS. const updateTemplates = async () => { @@ -160,8 +171,9 @@ export class AppService extends EventEmitter { // Create the invitation const invitationInstance = await Invitation.create(invitation, deps); - // Add the invitation to the invitations array + // Attach listeners before SSE connects so updates are not missed. await this.addInvitation(invitationInstance); + await invitationInstance.start(); return invitationInstance; } diff --git a/src/services/history.ts b/src/services/history.ts index f418ba2..3cd09e4 100644 --- a/src/services/history.ts +++ b/src/services/history.ts @@ -3,7 +3,6 @@ import { compileCashAssemblyString, type Engine } from "@xo-cash/engine"; import type { ScriptHashData, State, UnspentOutputData } from "@xo-cash/state"; import type { XOInvitation, - XOInvitationCommit, XOInvitationInput, XOInvitationOutput, XOInvitationVariableValue, diff --git a/src/services/invitation.ts b/src/services/invitation.ts index 55f1ba6..490bcbe 100644 --- a/src/services/invitation.ts +++ b/src/services/invitation.ts @@ -1,10 +1,9 @@ import type { - AcceptInvitationParameters, - AppendInvitationParameters, + InvitationParameters, Engine, GetSpendableResourcesParameters, } from "@xo-cash/engine"; -import { generateTemplateIdentifier, hasInvitationExpired, mergeInvitationCommits } from "@xo-cash/engine"; +import { generateTemplateIdentifier, hasInvitationExpired, mergeInvitationCommits, serializeInvitation } from "@xo-cash/engine"; import type { XOInvitation, XOInvitationCommit, @@ -15,10 +14,6 @@ import type { } from "@xo-cash/types"; import type { UnspentOutputData } from "@xo-cash/state"; import { - bigIntToBinUint64LE, - bigIntToBinUintBE, - bigIntToBinUintLE, - bigIntToVmNumber, binToHex, encodeTransaction, generateTransaction, @@ -90,13 +85,13 @@ export class Invitation extends EventEmitter { } // engine invitation (I have no idea if this is required) - const engineInvitation = await dependencies.engine.acceptInvitation(invitation); + const engineInvitation = await dependencies.engine.importInvitation(serializeInvitation(invitation)); // Create the invitation const invitationInstance = new Invitation(engineInvitation, dependencies); // Start the invitation and its tracking - await invitationInstance.start(); + invitationInstance.start(); return invitationInstance; } @@ -387,7 +382,7 @@ export class Invitation extends EventEmitter { /** * Accept the invitation */ - async accept(acceptParams?: AcceptInvitationParameters): Promise { + async accept(acceptParams?: InvitationParameters): Promise { // Accept the invitation this.data = await this.engine.acceptInvitation(this.data, acceptParams); @@ -438,7 +433,13 @@ export class Invitation extends EventEmitter { /** * Append a commit to the invitation */ - async append(data: AppendInvitationParameters): Promise { + async append(data: InvitationParameters): Promise { + try { + await this.engine.acceptInvitation(this.data); + } catch (err) { + // Literally do nothing here. We are just trying to accept the invitation in case we haven't already + } + // Append the commit to the invitation this.data = await this.engine.appendInvitation(this.data.invitationIdentifier, data); diff --git a/src/services/rates.ts b/src/services/rates.ts index 2d4719a..a03e6ca 100644 --- a/src/services/rates.ts +++ b/src/services/rates.ts @@ -1,3 +1,4 @@ +import { OracleClient } from '@generalprotocols/oracle-client'; import { EventEmitter } from '../utils/event-emitter.js'; import { type RatesEventMap, @@ -73,8 +74,17 @@ export class RatesService extends EventEmitter { settings: SettingsService, adapter?: RatesAdapter, ): Promise { - const resolvedAdapter = adapter ?? (await RatesOracle.from(undefined, settings)); - return new RatesService(resolvedAdapter, settings); + if (adapter) { + return new RatesService(adapter, settings); + } + + const oracleClient = new OracleClient(); + oracleClient.start(); + + const ratesOracle = new RatesOracle(oracleClient, settings); + ratesOracle.start(); + + return new RatesService(ratesOracle, settings); } /** diff --git a/src/templates/vending-machine.ts b/src/templates/vending-machine.ts new file mode 100644 index 0000000..48c6600 --- /dev/null +++ b/src/templates/vending-machine.ts @@ -0,0 +1,277 @@ +import type { XOTemplate } from '@xo-cash/types'; + +/** + * Vending machine payment template. + * + * Merchant creates a purchaseItems invitation with receipt variables; + * customer funds and signs the composable transaction. + */ +export const vendingMachineTemplate: XOTemplate = { + $schema: 'https://libauth.org/schemas/wallet-template-v0.schema.json', + name: 'Vending Machine', + description: 'Purchase items from a vending machine with an itemized receipt.', + icon: 'wallet', + version: '1', + supported: ['BCH_2023_05', 'BCH_2024_05', 'BCH_2025_05', 'BCH_2026_05'], + + defaults: { + change: { + output: 'changeOutput', + role: 'merchant', + generate: ['merchantKey'], + }, + }, + + roles: { + merchant: { + name: 'Merchant', + description: 'The vending machine operator receiving payment.', + icon: 'owner', + }, + customer: { + name: 'Customer', + description: 'The customer paying for items.', + icon: 'sender', + }, + }, + + start: [ + { + action: 'purchaseItems', + role: 'merchant', + generate: ['merchantKey'], + }, + ], + + actions: { + purchaseItems: { + name: 'Purchase Items', + description: 'Purchase: $() for $() sats', + icon: 'request', + + roles: { + merchant: { + name: 'Sell Items', + description: 'Receive payment for $()', + icon: 'request', + requirements: { + secrets: ['merchantKey'], + variables: [ + 'totalSatoshis', + 'orderId', + 'merchantName', + 'receiptSummary', + 'lineItemsJson', + ], + }, + }, + customer: { + name: 'Pay', + description: 'Pay $() sats for $()', + icon: 'send', + requirements: {}, + }, + }, + + requirements: { + participants: [ + { role: 'merchant', slots: { min: 1, max: 1 } }, + { role: 'customer', slots: { min: 1 } }, + ], + }, + + transaction: 'purchaseItemsTransaction', + }, + }, + + transactions: { + purchaseItemsTransaction: { + name: 'Vending Purchase', + description: 'Order $(): $()', + icon: 'request', + + roles: { + merchant: { + name: 'Received Payment', + description: 'Received $() sats from $() sale', + icon: 'receive', + }, + customer: { + name: 'Sent Payment', + description: 'Paid $() sats for $()', + icon: 'send', + }, + }, + + inputs: [], + outputs: [{ output: 'purchaseOutput' }], + version: 2, + locktime: 0, + composable: true, + }, + }, + + /** No custom input templates — customer UTXOs are selected at funding time. */ + inputs: {}, + + outputs: { + changeOutput: { + name: 'Change', + description: 'Funds returned as change.', + icon: 'receive', + lockingScript: 'merchantReceivingLockingScript', + }, + purchaseOutput: { + name: 'Purchase Payment', + description: '$() sats to $()', + icon: 'request', + + roles: { + merchant: { + name: 'Payment Received', + description: 'Received $() sats for $()', + }, + customer: { + name: 'Payment Sent', + description: 'Sent $() sats for $()', + }, + }, + + lockingScript: 'merchantReceivingLockingScript', + valueSatoshis: '$()', + token: null, + }, + }, + + lockingScripts: { + merchantReceivingLockingScript: { + name: 'Merchant Receive', + description: 'Funds received by the vending machine merchant.', + icon: 'address', + lockingType: 'p2pkh', + lockingBytecode: 'lockMerchantP2PKH', + unlockingBytecode: 'unlockMerchantP2PKH', + actions: [], + state: { variables: [], secrets: [] }, + balance: {}, + roles: { + merchant: { + state: { + variables: [], + secrets: ['merchantKey'], + }, + actions: [], + balance: { + satoshis: true, + fungibleTokens: true, + nonfungibleTokens: true, + }, + selectable: true, + }, + }, + }, + }, + + scripts: { + lockMerchantP2PKH: + 'OP_DUP OP_HASH160 <$( OP_HASH160)> OP_EQUALVERIFY OP_CHECKSIG', + unlockMerchantP2PKH: + ' ', + }, + + constants: { + dustLimit: { + name: 'Dust Limit', + description: 'Minimum satoshis for P2PKH outputs.', + type: 'integer', + value: 546, + }, + }, + + variables: { + merchantKey: { + name: 'Merchant Private Key', + description: 'Private key for the vending machine merchant wallet.', + type: 'bytes', + hint: 'private_key', + }, + totalSatoshis: { + name: 'Total Price', + description: 'Total purchase price in satoshis', + type: 'integer', + hint: 'satoshis', + }, + orderId: { + name: 'Order ID', + description: 'Unique order identifier', + type: 'string', + }, + merchantName: { + name: 'Merchant Name', + description: 'Display name of the vending machine', + type: 'string', + }, + receiptSummary: { + name: 'Receipt Summary', + description: 'Human-readable list of purchased items', + type: 'string', + }, + lineItemsJson: { + name: 'Line Items', + description: 'JSON-encoded line items for the purchase', + type: 'string', + }, + }, + + icons: [ + { name: 'wallet', hash: '0000000000000000000000' }, + { name: 'owner', hash: '0000000000000000000000' }, + { name: 'sender', hash: '0000000000000000000000' }, + { name: 'request', hash: '0000000000000000000000' }, + { name: 'receive', hash: '0000000000000000000000' }, + { name: 'send', hash: '0000000000000000000000' }, + ], + + scenarios: [ + { + name: 'purchase items happy path', + description: 'Merchant requests payment for vending machine items.', + action: 'purchaseItems', + roles: [ + { + role: 'merchant', + values: { + generated: { + merchantKey: 'KyRQa5pEXuzVcDwnXRLpYAascjchQW5DoxVRMbj4DTxS83573mz8', + }, + variables: { + totalSatoshis: 3500, + orderId: 'order-demo-1', + merchantName: 'XO Snack Machine', + receiptSummary: '2× Cola, 1× Chips', + lineItemsJson: '[{"name":"Cola","qty":2},{"name":"Chips","qty":1}]', + }, + secrets: {}, + inputs: [], + outputs: [ + { + lockingBytecode: '76a91475c715ecb74178fe87933e57e947e5e92d904b8188ac', + valueSatoshis: 3500, + }, + ], + }, + }, + { + role: 'customer', + values: { + generated: {}, + variables: {}, + secrets: {}, + inputs: [], + outputs: [], + }, + }, + ], + }, + ], +}; diff --git a/src/tui/hooks/useAppContext.tsx b/src/tui/hooks/useAppContext.tsx index 619e3be..5b43c80 100644 --- a/src/tui/hooks/useAppContext.tsx +++ b/src/tui/hooks/useAppContext.tsx @@ -71,7 +71,7 @@ export function AppProvider({ }); // Start the AppService (loads existing invitations) - await service.start(); + service.start(); // Set the service and mark as initialized setAppService(service); diff --git a/src/tui/screens/SeedInput.tsx b/src/tui/screens/SeedInput.tsx index ecb1c81..c83755e 100644 --- a/src/tui/screens/SeedInput.tsx +++ b/src/tui/screens/SeedInput.tsx @@ -158,9 +158,7 @@ export function SeedInputScreen(): React.ReactElement { setSeedPhrase(''); setSaveMnemonicChecked(false); - setTimeout(() => { - navigate('wallet'); - }, 500); + navigate('wallet'); } catch (error) { const message = error instanceof Error ? error.message : 'Failed to initialize wallet'; diff --git a/src/utils/rates/rates-oracles.ts b/src/utils/rates/rates-oracles.ts index dc82dda..12fb468 100644 --- a/src/utils/rates/rates-oracles.ts +++ b/src/utils/rates/rates-oracles.ts @@ -45,7 +45,7 @@ export class RatesOracle extends BaseRates { private targetDenominatorUnitCode: string = 'BCH'; private unsubscribeFromSettings: OffCallback | null = null; - private constructor(client: OracleClient, settings: SettingsService) { + public constructor(client: OracleClient, settings: SettingsService) { super(); this.client = client; diff --git a/src/utils/sync-server.ts b/src/utils/sync-server.ts index 03fe558..d381271 100644 --- a/src/utils/sync-server.ts +++ b/src/utils/sync-server.ts @@ -1,6 +1,7 @@ import type { XOInvitation } from "@xo-cash/types"; import { EventEmitter } from "./event-emitter.js"; -import { SSESession, type SSEvent } from "./sse-client.js"; +// import { SSESession, type SSEvent } from "./sse-client.js"; +import { SSESession, type SSEvent } from "@xo-cash/utils"; import { deserializeInvitation, serializeInvitation } from "@xo-cash/engine"; export type SyncServerEventMap = { @@ -38,7 +39,6 @@ export class SyncServer extends EventEmitter { }, // Create our event bubblers - onMessage: (event: SSEvent) => this.emit("message", event), onError: (error: unknown) => this.emit( "error", @@ -48,6 +48,8 @@ export class SyncServer extends EventEmitter { onConnected: () => this.emit("connected", undefined), }, ); + + this.sse.on("message", (event: SSEvent) => this.emit("message", event)); } /** @@ -63,7 +65,7 @@ export class SyncServer extends EventEmitter { */ async disconnect(): Promise { // Disconnect from the SSE Session - this.sse.close(); + await this.sse.disconnect(); } /** -- 2.49.1 From 2f8dad7d8d3073f48dcc3a519e9be082c3433853 Mon Sep 17 00:00:00 2001 From: Harvey Zuccon Date: Fri, 29 May 2026 18:16:00 +0200 Subject: [PATCH 04/22] Add import template into tui. Fix tests that fail on macos. Fix some updates. --- package.json | 2 +- src/cli/commands/invitation.ts | 10 +- src/cli/commands/template.ts | 25 +- src/services/invitation.ts | 10 - src/templates/wrap-template.ts | 266 +++++++++++++++++++ src/tui/components/Dialog.tsx | 41 ++- src/tui/components/FilePicker.tsx | 273 ++++++++++++++++++++ src/tui/screens/TemplateList.tsx | 254 +++++++++++++++++- src/tui/utils/format-dialog-message.ts | 121 +++++++++ src/tui/utils/list-directory-entries.ts | 170 ++++++++++++ src/utils/load-template-from-file.ts | 194 ++++++++++++++ src/utils/pick-template-export.ts | 64 +++++ src/utils/template-module-loader.ts | 34 +++ tests/cli/mnemonic.test.ts | 9 +- tests/cli/mocks/engine.ts | 18 +- tests/cli/paths.test.ts | 10 +- tests/tui/format-dialog-message.test.ts | 44 ++++ tests/tui/list-directory-entries.test.ts | 114 ++++++++ tests/utils/load-template-from-file.test.ts | 78 ++++++ tests/utils/pick-template-export.test.ts | 57 ++++ 20 files changed, 1748 insertions(+), 46 deletions(-) create mode 100644 src/templates/wrap-template.ts create mode 100644 src/tui/components/FilePicker.tsx create mode 100644 src/tui/utils/format-dialog-message.ts create mode 100644 src/tui/utils/list-directory-entries.ts create mode 100644 src/utils/load-template-from-file.ts create mode 100644 src/utils/pick-template-export.ts create mode 100644 src/utils/template-module-loader.ts create mode 100644 tests/tui/format-dialog-message.test.ts create mode 100644 tests/tui/list-directory-entries.test.ts create mode 100644 tests/utils/load-template-from-file.test.ts create mode 100644 tests/utils/pick-template-export.test.ts diff --git a/package.json b/package.json index a970123..dca1675 100644 --- a/package.json +++ b/package.json @@ -48,6 +48,7 @@ "prettier": "^3.8.1", "qrcode": "^1.5.4", "react": "^19.2.4", + "tsx": "^4.21.0", "zod": "^4.3.6" }, "devDependencies": { @@ -56,7 +57,6 @@ "@types/qrcode": "^1.5.6", "@types/react": "^19.2.14", "@vitest/coverage-v8": "^4.1.2", - "tsx": "^4.21.0", "typescript": "^5.9.3", "vitest": "^4.1.2" } diff --git a/src/cli/commands/invitation.ts b/src/cli/commands/invitation.ts index f7c0a3a..48c9df1 100644 --- a/src/cli/commands/invitation.ts +++ b/src/cli/commands/invitation.ts @@ -453,7 +453,7 @@ export const handleInvitationCommand = async ( // Create our own invitation instance out of the raw XOInvitation. This will also initate the SSE Session const invitationInstance = await deps.app.createInvitation(rawInvitation); deps.io.verbose( - `Invitation created: ${formatObject(invitationInstance.data)}`, + `Invitation instance created: ${formatObject(invitationInstance.data)}`, ); // Read the variables that were passed in via `-var- ` @@ -474,6 +474,8 @@ export const handleInvitationCommand = async ( // Append the inputs and outputs to the invitation const { inputs, outputs } = params; + deps.io.verbose(`Inputs: ${formatObject(inputs)}`); + deps.io.verbose(`Outputs: ${formatObject(outputs)}`); if (inputs.length > 0 || outputs.length > 0) { await invitationInstance.append({ inputs, outputs }); } @@ -497,6 +499,9 @@ export const handleInvitationCommand = async ( hasMissingRequirements(missingRequirements.templateRequirements) || missingRequirements.inputsMissingSignatures.length > 0; + deps.io.verbose(`Missing requirements: ${formatObject(missingRequirements)}`); + deps.io.verbose(`Has missing requirements: ${hasMissing}`); + // If there are missing requirements, print them out if (hasMissing) { deps.io.out(`\n${bold("Remaining requirements:")}`); @@ -507,6 +512,9 @@ export const handleInvitationCommand = async ( options["sign"] === "true" || options["broadcast"] === "true"; const shouldBroadcast = options["broadcast"] === "true"; + deps.io.verbose(`Should sign: ${shouldSign}`); + deps.io.verbose(`Should broadcast: ${shouldBroadcast}`); + // Sign the invitation if the user has requested it if (shouldSign) { await invitationInstance.sign(); diff --git a/src/cli/commands/template.ts b/src/cli/commands/template.ts index ce0d4b4..964662d 100644 --- a/src/cli/commands/template.ts +++ b/src/cli/commands/template.ts @@ -1,9 +1,10 @@ -import { existsSync, readFileSync, writeFileSync } from "fs"; +import { existsSync, writeFileSync } from "fs"; import path from "path"; import { generateTemplateIdentifier } from "@xo-cash/engine"; import type { XOTemplate } from "@xo-cash/types"; import { bold, dim, formatObject } from "../utils.js"; +import { loadTemplateFromFile, TemplateLoadError } from "../../utils/load-template-from-file.js"; import { resolveTemplateReferences } from "../../utils/templates.js"; import type { CommandDependencies, CommandIO } from "./types.js"; import { CommandError } from "./types.js"; @@ -18,7 +19,7 @@ export const printTemplateHelp = (io: CommandIO): void => { ${bold("Usage:")} xo-cli template ${bold("Sub-commands:")} - - import ${dim("Import a template from a file")} + - import ${dim("Import a template from a JSON, JS, or TS file")} - list ${dim("List all templates")} - list ${dim("List all options of the field type in a template")} - inspect ${dim("Inspect a field in a template")} @@ -464,12 +465,26 @@ export const handleTemplateCommand = async ( ); } - // Read the template file - const template = await readFileSync(templatePath, "utf8"); + // Read and load the template file (JSON directly, TS/JS via child process). + let templateContents: string; + try { + templateContents = await loadTemplateFromFile(templatePath); + } catch (error) { + const message = + error instanceof TemplateLoadError + ? error.message + : error instanceof Error + ? error.message + : String(error); + deps.io.err(message); + printTemplateHelp(deps.io); + throw new CommandError("template.import.load_failed", message); + } + deps.io.verbose(`Importing template: ${templateFile}`); // Import the template - await deps.app.engine.importTemplate(template); + await deps.app.engine.importTemplate(templateContents); deps.io.verbose(`Template imported: ${templateFile}`); // Return the template file diff --git a/src/services/invitation.ts b/src/services/invitation.ts index 490bcbe..ddacfd6 100644 --- a/src/services/invitation.ts +++ b/src/services/invitation.ts @@ -625,34 +625,26 @@ export class Invitation extends EventEmitter { ); } - console.dir(this.data, { depth: null }); - // Create a list of all the variables from the commits const variables = this.data.commits.flatMap( (c) => c.data?.variables ?? [], ); - console.dir(variables, { depth: null }); // Create a dictionary of the variables const formattedVariables = variables.reduce( (acc, v) => { const { variableIdentifier, value } = v; - console.log(typeof value); acc[variableIdentifier ?? ""] = value; return acc; }, {} as Record, ); - console.dir(formattedVariables, { depth: null }); - // Compile the CashAssembly expression to get the value satoshis (It handles the variable replacement for us) const valueSatoshis = compileCashAssemblyString( { cashAssemblyText: String(valueSatoshisExpression), variables: formattedVariables, evaluationDecodeMode: 'bigint' }, ); - console.dir(valueSatoshis, { depth: null }); - // Return the value satoshis as a bigint // TODO: Check this of a vulnerability or crash - I assume there might be one if someone made the `valueSatoshis` a malicious expression return BigInt(valueSatoshis); @@ -707,11 +699,9 @@ export class Invitation extends EventEmitter { for (const output of outputs) { if (typeof output === "string") { const sats = await this.getSatsOut(output); - console.log(`Sats for output: ${output} is ${sats}`); totalSats += sats } else { const sats = await this.getSatsOut(output.output); - console.log(`Sats for output: ${output.output} is ${sats}`); totalSats += sats; } } diff --git a/src/templates/wrap-template.ts b/src/templates/wrap-template.ts new file mode 100644 index 0000000..dc1befd --- /dev/null +++ b/src/templates/wrap-template.ts @@ -0,0 +1,266 @@ +import type { XOTemplate } from '@xo-cash/types'; + +export const wrapBCHTemplate: XOTemplate = { + $schema: 'https://libauth.org/schemas/wallet-template-v0.schema.json', + + name: 'Wrapped BCH', + description: 'Convert between BCH and wBCH tokens.', + icon: 'wrap', + + version: '1', + supported: ['BCH_2023_05', 'BCH_2024_05', 'BCH_2025_05', 'BCH_2026_05'], + + roles: { + user: { + name: 'User', + description: 'The person wrapping or unwrapping BCH.', + icon: 'user', + }, + }, + + start: [ + { + action: 'wrap', + role: 'user', + }, + { + action: 'unwrap', + role: 'user', + }, + ], + + actions: { + wrap: { + name: 'Wrap BCH', + description: 'Convert BCH into wBCH tokens.', + icon: 'wrap', + + roles: { + user: { + requirements: { + variables: ['amountToWrap', 'recipientLockingScript'], + }, + }, + }, + + requirements: { + participants: [{ role: 'user', slots: { min: 1, max: 1 } }], + }, + + transaction: 'wrapTransaction', + }, + + unwrap: { + name: 'Unwrap wBCH', + description: 'Convert wBCH tokens back into BCH.', + icon: 'unwrap', + + roles: { + user: { + requirements: { + variables: ['amountToUnwrap', 'recipientLockingScript'], + }, + }, + }, + + requirements: { + participants: [{ role: 'user', slots: { min: 1, max: 1 } }], + }, + + transaction: 'unwrapTransaction', + }, + }, + + transactions: { + wrapTransaction: { + name: 'Wrapped BCH', + description: 'Wrapped $( OP_DIV).$( OP_MOD) BCH into wBCH tokens.', + icon: 'wrap', + + inputs: [ + { input: 'covenantInput', inputIndex: 0 }, + ], + outputs: [ + { output: 'covenantOutput', outputIndex: 0 }, + { output: 'wrappedTokensOutput', outputIndex: undefined }, + ], + + version: 2, + locktime: 0, + composable: true, + }, + + unwrapTransaction: { + name: 'Unwrapped wBCH', + description: 'Unwrapped $( OP_DIV).$( OP_MOD) wBCH tokens back into BCH.', + icon: 'unwrap', + + inputs: [ + { input: 'covenantInput', inputIndex: 0 }, + ], + outputs: [ + { output: 'covenantOutput', outputIndex: 0 }, + { output: 'unwrappedSatoshisOutput', outputIndex: undefined }, + ], + + version: 2, + locktime: 0, + composable: true, + }, + }, + + outputs: { + covenantOutput: { + name: 'wBCH Covenant', + description: 'Holds BCH and wBCH tokens that can be freely converted.', + icon: 'contract', + + lockingScript: 'wrapBCHLockingScript', + }, + + wrappedTokensOutput: { + name: 'Wrapped wBCH', + description: 'Wrapped $( OP_DIV).$( OP_MOD) wBCH tokens.', + icon: 'receive', + + valueSatoshis: '$()', + token: { + category: '$()', + amount: '$()', + nft: null, + }, + + roles: { + user: { + balance: { + satoshis: true, + fungibleTokens: true, + nonfungibleTokens: true, + }, + selectable: true, + }, + }, + + lockingScript: '$()', + }, + + unwrappedSatoshisOutput: { + name: 'Unwrapped BCH', + description: 'Unwrapped $( OP_DIV).$( OP_MOD) BCH.', + icon: 'receive', + + valueSatoshis: '$()', + token: null, + + roles: { + user: { + balance: { + satoshis: true, + fungibleTokens: true, + nonfungibleTokens: true, + }, + selectable: true, + }, + }, + + lockingScript: '$()', + }, + }, + + inputs: { + covenantInput: { + name: 'wBCH Covenant', + description: 'The covenant being updated.', + icon: 'contract', + + unlockingScript: 'unlockCovenant', + }, + }, + + lockingScripts: { + wrapBCHLockingScript: { + name: 'wBCH Covenant', + description: 'Holds BCH and wBCH tokens that can be freely converted.', + icon: 'contract', + + lockingType: 'p2sh', + lockingBytecode: 'wrapBCHLockingBytecode', + + actions: [ + { action: 'wrap', role: 'user' }, + { action: 'unwrap', role: 'user' }, + ], + + state: { + variables: [], + secrets: [], + }, + balance: { + satoshis: 0n, + fungibleTokens: 0n, + }, + selectable: false, + }, + }, + + scripts: { + enforceCovenantPersists: 'OP_INPUTINDEX OP_DUP OP_OUTPUTBYTECODE OP_SWAP OP_UTXOBYTECODE OP_EQUAL OP_VERIFY', + enforceTokenCategoryPreserved: 'OP_INPUTINDEX OP_DUP OP_OUTPUTTOKENCATEGORY OP_SWAP OP_UTXOTOKENCATEGORY OP_EQUAL OP_VERIFY', + enforceValueTokenSumConserved: 'OP_INPUTINDEX OP_UTXOVALUE OP_INPUTINDEX OP_UTXOTOKENAMOUNT OP_ADD OP_INPUTINDEX OP_OUTPUTVALUE OP_INPUTINDEX OP_OUTPUTTOKENAMOUNT OP_ADD OP_EQUAL OP_VERIFY', + + // Direct script references — introspection opcodes must not use $(...) evaluations + // because those are evaluated at compile time without transaction context. + wrapBCHLockingBytecode: 'enforceCovenantPersists enforceTokenCategoryPreserved enforceValueTokenSumConserved', + unlockCovenant: '', + }, + + constants: { + wbchTokenCategory: { + name: 'wBCH Token Category', + description: 'The official token category for Wrapped BCH.', + type: 'bytes', + value: 'ff4d6e4b90aa8158d39c5dc874fd9411af1ac3b5ed6f354755e8362a0d02c6b3', + }, + satoshisPerBCH: { + name: 'Satoshis per BCH', + description: 'Used to display amounts in BCH with decimals.', + type: 'integer', + value: 100000000, + }, + tokenDust: { + name: 'Token Dust Limit', + description: 'Minimal satoshis required for a token-bearing output.', + type: 'integer', + value: 1000, + }, + }, + + variables: { + amountToWrap: { + name: 'Amount to Wrap', + description: 'How much BCH to convert to wBCH (in satoshis).', + type: 'integer', + hint: 'satoshis', + }, + amountToUnwrap: { + name: 'Amount to Unwrap', + description: 'How much wBCH to convert back to BCH (in satoshis).', + type: 'integer', + hint: 'satoshis', + }, + recipientLockingScript: { + name: 'Destination', + description: 'Where to receive your BCH or wBCH tokens.', + type: 'bytes', + hint: 'lockingScript', + }, + }, + + icons: [ + { name: 'wrap', hash: '0000000000000000000000' }, + { name: 'unwrap', hash: '0000000000000000000000' }, + { name: 'user', hash: '0000000000000000000000' }, + { name: 'contract', hash: '0000000000000000000000' }, + { name: 'receive', hash: '0000000000000000000000' }, + ], +}; diff --git a/src/tui/components/Dialog.tsx b/src/tui/components/Dialog.tsx index 8a69845..f46e1d2 100644 --- a/src/tui/components/Dialog.tsx +++ b/src/tui/components/Dialog.tsx @@ -2,11 +2,17 @@ * Dialog components for modals, confirmations, and input dialogs. */ -import React, { useId, useRef, useState } from 'react'; -import { Box, Text, measureElement } from 'ink'; +import React, { useId, useMemo, useRef, useState } from 'react'; +import { Box, Text, measureElement, useStdout } from 'ink'; import TextInput from './TextInput.js'; import { colors } from '../theme.js'; import { useInputLayer, useLayeredInput } from '../hooks/useInputLayer.js'; +import { + formatDialogMessageLines, + getMessageContentWidth, + getMessageDialogWidth, + MAX_MESSAGE_DIALOG_LINES, +} from '../utils/format-dialog-message.js'; /** * Base dialog wrapper props. @@ -261,6 +267,23 @@ export function MessageDialog({ isActive = true, }: MessageDialogProps): React.ReactElement { const layerId = useId(); + const { stdout } = useStdout(); + const dialogWidth = getMessageDialogWidth(stdout.columns ?? 80); + const contentWidth = getMessageContentWidth(dialogWidth); + + const messageLines = useMemo(() => { + const formattedLines = formatDialogMessageLines(message, contentWidth); + + if (formattedLines.length <= MAX_MESSAGE_DIALOG_LINES) { + return formattedLines; + } + + const hiddenLineCount = formattedLines.length - MAX_MESSAGE_DIALOG_LINES; + return [ + ...formattedLines.slice(0, MAX_MESSAGE_DIALOG_LINES), + `... and ${hiddenLineCount} more line(s)`, + ]; + }, [contentWidth, message]); // Auto-capture input when this dialog is mounted. useInputLayer(layerId); @@ -269,7 +292,7 @@ export function MessageDialog({ if (key.return || key.escape) { onClose(); } - }); + }, { isActive }); const borderColor = type === 'error' ? colors.error : type === 'success' ? colors.success : @@ -280,8 +303,16 @@ export function MessageDialog({ 'ℹ'; return ( - - {message} + + + {messageLines.map((line, index) => ( + {line} + ))} + Press Enter or Esc to close diff --git a/src/tui/components/FilePicker.tsx b/src/tui/components/FilePicker.tsx new file mode 100644 index 0000000..f3e5059 --- /dev/null +++ b/src/tui/components/FilePicker.tsx @@ -0,0 +1,273 @@ +/** + * Terminal file picker for browsing directories and selecting files. + * + * This component does not include a dialog wrapper — consumers wrap it in + * {@link DialogWrapper} when needed. When used inside a dialog overlay, pass + * `layerId` so keyboard input is routed through the input-layer stack instead + * of conflicting with background {@link ScrollableList} handlers. + */ + +import React, { useCallback, useEffect, useMemo, useState } from "react"; +import { Box, Text } from "ink"; + +import { ScrollableList, type ListItemData } from "./List.js"; +import { useLayeredInput } from "../hooks/useInputLayer.js"; +import { colors } from "../theme.js"; +import { + listDirectoryEntries, + type DirectoryEntry, +} from "../utils/list-directory-entries.js"; + +/** + * Props for {@link FilePicker}. + */ +export interface FilePickerProps { + /** Starting directory. Defaults to `process.cwd()`. */ + initialDirectory?: string; + /** + * Allowed file extensions without a leading dot (e.g. `['json']`). + * Omit to show all files. Directories are always shown. + */ + extensions?: string[]; + /** + * Input-layer id for dialog use. When set, this component handles ↑↓/Enter + * via {@link useLayeredInput} and disables {@link ScrollableList} focus. + */ + layerId?: string; + /** Whether the list receives keyboard focus when `layerId` is not set. */ + focus?: boolean; + /** Maximum visible rows in the scroll window. */ + maxVisible?: number; + /** Called when the user confirms a file with Enter. */ + onSelectFile: (absolutePath: string) => void; + /** Optional callback whenever the browsed directory changes. */ + onDirectoryChange?: (absolutePath: string) => void; +} + +/** + * Truncates a long path for display, keeping the end visible. + */ +function formatDirectoryPath(directoryPath: string, maxLength = 56): string { + if (directoryPath.length <= maxLength) { + return directoryPath; + } + + return `...${directoryPath.slice(-(maxLength - 3))}`; +} + +/** + * Builds list row metadata for a directory entry. + */ +function toListItem(entry: DirectoryEntry): ListItemData { + if (entry.kind === "parent") { + return { + key: "__parent__", + label: "..", + description: "Parent directory", + value: entry, + }; + } + + if (entry.kind === "directory") { + return { + key: `dir:${entry.absolutePath}`, + label: entry.name, + description: "Directory", + value: entry, + }; + } + + return { + key: `file:${entry.absolutePath}`, + label: entry.name, + value: entry, + }; +} + +/** + * Generic terminal file picker with optional extension filtering. + */ +export function FilePicker({ + initialDirectory = process.cwd(), + extensions, + layerId, + focus = true, + maxVisible = 10, + onSelectFile, + onDirectoryChange, +}: FilePickerProps): React.ReactElement { + const [currentDirectory, setCurrentDirectory] = useState(() => + initialDirectory, + ); + const [selectedIndex, setSelectedIndex] = useState(0); + const [loadError, setLoadError] = useState(); + + const { entries, error } = useMemo( + () => listDirectoryEntries(currentDirectory, { extensions }), + [currentDirectory, extensions], + ); + + useEffect(() => { + setLoadError(error); + }, [error]); + + useEffect(() => { + setSelectedIndex(0); + }, [currentDirectory, extensions]); + + useEffect(() => { + if (entries.length === 0) { + setSelectedIndex(0); + return; + } + + if (selectedIndex >= entries.length) { + setSelectedIndex(entries.length - 1); + } + }, [entries, selectedIndex]); + + const listItems = useMemo( + (): ListItemData[] => entries.map(toListItem), + [entries], + ); + + /** + * Moves selection to the previous visible row, wrapping at the top. + */ + const selectPrevious = useCallback((): void => { + setSelectedIndex((previous) => + previous <= 0 ? Math.max(entries.length - 1, 0) : previous - 1, + ); + }, [entries.length]); + + /** + * Moves selection to the next visible row, wrapping at the bottom. + */ + const selectNext = useCallback((): void => { + setSelectedIndex((previous) => + entries.length === 0 + ? 0 + : previous >= entries.length - 1 + ? 0 + : previous + 1, + ); + }, [entries.length]); + + /** + * Applies the current row: navigate for parent/directory, select for files. + */ + const activateSelectedEntry = useCallback((): void => { + const entry = entries[selectedIndex]; + if (!entry) { + return; + } + + if (entry.kind === "parent" || entry.kind === "directory") { + setCurrentDirectory(entry.absolutePath); + onDirectoryChange?.(entry.absolutePath); + return; + } + + onSelectFile(entry.absolutePath); + }, [entries, onDirectoryChange, onSelectFile, selectedIndex]); + + /** + * Dialog overlays must pass `layerId` because ScrollableList uses raw ink + * `useInput`, which does not respect the input capture stack. + */ + useLayeredInput( + layerId ?? "file-picker-standalone", + (_input, key) => { + if (!layerId) { + return; + } + + if (key.upArrow) { + selectPrevious(); + return; + } + + if (key.downArrow) { + selectNext(); + return; + } + + if (key.return) { + activateSelectedEntry(); + } + }, + { isActive: Boolean(layerId) }, + ); + + const renderItem = useCallback( + ( + item: ListItemData, + isSelected: boolean, + isFocused: boolean, + ): React.ReactNode => { + const entry = item.value; + /** + * Inside dialogs, ScrollableList focus is disabled (input comes from layerId). + * Treat the selected row as highlighted so it matches other focused lists. + */ + const isHighlighted = layerId ? isSelected : isFocused; + const textColor = isHighlighted ? colors.focus : colors.text; + const indicator = isHighlighted ? "▸ " : " "; + + if (entry?.kind === "parent") { + return ( + + {indicator} + ⬆ .. + + ); + } + + if (entry?.kind === "directory") { + return ( + + {indicator} + 📁 {item.label}/ + + ); + } + + return ( + + {indicator} + {item.label} + + ); + }, + [layerId], + ); + + const listFocus = layerId ? false : focus; + + return ( + + + Directory: {formatDirectoryPath(currentDirectory)} + + + {loadError ? ( + + {loadError} + + ) : null} + + + activateSelectedEntry()} + focus={listFocus} + maxVisible={maxVisible} + emptyMessage="No matching files or folders" + renderItem={renderItem} + /> + + + ); +} diff --git a/src/tui/screens/TemplateList.tsx b/src/tui/screens/TemplateList.tsx index 3d25e38..5a5244d 100644 --- a/src/tui/screens/TemplateList.tsx +++ b/src/tui/screens/TemplateList.tsx @@ -8,10 +8,12 @@ import React, { useState, useEffect, useCallback, useMemo } from 'react'; import { Box, Text } from 'ink'; +import path from 'node:path'; import { ScrollableList, type ListItemData } from '../components/List.js'; +import { FilePicker } from '../components/FilePicker.js'; import { useNavigation } from '../hooks/useNavigation.js'; import { useAppContext, useStatus } from '../hooks/useAppContext.js'; -import { useBlockableInput } from '../hooks/useInputLayer.js'; +import { useBlockableInput, useInputLayer, useIsInputCaptured, useLayeredInput } from '../hooks/useInputLayer.js'; import { colors, logoSmall } from '../theme.js'; // XO Imports @@ -25,6 +27,8 @@ import { getTemplateRoles, } from '../../utils/template-utils.js'; import { buildScriptHashDataMap } from '../../utils/utxo-metadata.js'; +import { loadTemplateFromFile } from '../../utils/load-template-from-file.js'; +import { ConfirmDialog, DialogWrapper } from '../components/Dialog.js'; /** * Template item with metadata. @@ -53,6 +57,55 @@ interface TemplateActionItem { source: 'starting' | 'next' | 'starting+next'; } +/** List item key for the synthetic import row. */ +const IMPORT_TEMPLATE_KEY = 'import-template'; + +/** Input layer id shared by the import dialog and its file picker. */ +const IMPORT_TEMPLATE_DIALOG_LAYER_ID = 'import-template-dialog'; + +/** + * Import template dialog overlay. + * Captures keyboard input and wraps the generic {@link FilePicker}. + */ +function ImportTemplateDialogOverlay({ + onClose, + onSelectFile, +}: { + onClose: () => void; + onSelectFile: (filePath: string) => void; +}): React.ReactElement { + useInputLayer(IMPORT_TEMPLATE_DIALOG_LAYER_ID); + + useLayeredInput(IMPORT_TEMPLATE_DIALOG_LAYER_ID, (_input, key) => { + if (key.escape) { + onClose(); + } + }); + + return ( + + + Select a JSON, JavaScript, or TypeScript template file from disk. + + + + + + + + + ↑↓ navigate • Enter open/select • Esc cancel + + + + ); +} + /** * Template List Screen Component. * Displays templates and their starting actions. @@ -68,6 +121,10 @@ export function TemplateListScreen(): React.ReactElement { const [selectedActionIndex, setSelectedActionIndex] = useState(0); const [focusedPanel, setFocusedPanel] = useState<'templates' | 'actions'>('templates'); const [isLoading, setIsLoading] = useState(true); + const [isImportDialogOpen, setIsImportDialogOpen] = useState(false); + const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); + const [templateToDelete, setTemplateToDelete] = useState(null); + const isCaptured = useIsInputCaptured(); /** * Loads templates from the engine. @@ -196,15 +253,23 @@ export function TemplateListScreen(): React.ReactElement { loadTemplates(); }, [loadTemplates]); - // Get current template and its actions - const currentTemplate = templates[selectedTemplateIndex]; - const currentActions = currentTemplate?.availableActions ?? []; - /** * Build template list items for ScrollableList. */ const templateListItems = useMemo((): TemplateListItem[] => { - return templates.map((item, index) => { + const importTemplateItem: TemplateListItem = { + key: IMPORT_TEMPLATE_KEY, + label: 'Import Template', + description: 'Import a template from a file', + value: { + templateIdentifier: IMPORT_TEMPLATE_KEY, + template: {} as XOTemplate, + availableActions: [], + }, + hidden: false, + }; + + return [...templates.map((item, index) => { const formatted = formatTemplateListItem(item.template, index); return { key: item.templateIdentifier, @@ -213,9 +278,16 @@ export function TemplateListScreen(): React.ReactElement { value: item, hidden: !formatted.isValid, }; - }); + }), importTemplateItem]; }, [templates]); + const selectedTemplateListItem = templateListItems[selectedTemplateIndex]; + const isImportRowSelected = selectedTemplateListItem?.key === IMPORT_TEMPLATE_KEY; + const currentTemplate = isImportRowSelected + ? undefined + : selectedTemplateListItem?.value; + const currentActions = currentTemplate?.availableActions ?? []; + /** * Build action list items for ScrollableList. */ @@ -246,6 +318,86 @@ export function TemplateListScreen(): React.ReactElement { setSelectedActionIndex(0); // Reset action selection when template changes }, []); + /** + * Opens the import file picker. + */ + const openImportDialog = useCallback(() => { + setIsImportDialogOpen(true); + }, []); + + /** + * Opens the import file picker when the synthetic import row is activated. + */ + const handleTemplateActivate = useCallback((item: TemplateListItem) => { + if (item.key === IMPORT_TEMPLATE_KEY) { + openImportDialog(); + } + }, [openImportDialog]); + + /** + * Opens delete confirmation for the currently selected template. + */ + const openDeleteDialog = useCallback(() => { + if (!currentTemplate) { + return; + } + + setTemplateToDelete(currentTemplate); + setIsDeleteDialogOpen(true); + }, [currentTemplate]); + + /** + * Deletes the confirmed template from local storage. + */ + const handleConfirmDelete = useCallback(async () => { + if (!appService || !templateToDelete) { + return; + } + + const deletedName = + templateToDelete.template.name || templateToDelete.templateIdentifier; + + try { + setStatus('Deleting template...'); + await appService.engine.DANGEROUS_deleteImportedTemplate( + templateToDelete.templateIdentifier, + ); + setIsDeleteDialogOpen(false); + setTemplateToDelete(null); + await loadTemplates(); + setStatus(`Deleted ${deletedName}`); + } catch (error) { + setIsDeleteDialogOpen(false); + setTemplateToDelete(null); + showError( + `Failed to delete template: ${error instanceof Error ? error.message : String(error)}`, + ); + } + }, [appService, loadTemplates, setStatus, showError, templateToDelete]); + + /** + * Loads the selected template file and imports it through the engine. + */ + const handleImportFile = useCallback(async (filePath: string) => { + if (!appService) { + showError('AppService not initialized'); + return; + } + + try { + setStatus('Importing template...'); + const content = await loadTemplateFromFile(filePath); + await appService.engine.importTemplate(content); + await loadTemplates(); + setIsImportDialogOpen(false); + setStatus(`Imported ${path.basename(filePath)}`); + } catch (error) { + showError( + `Failed to import template: ${error instanceof Error ? error.message : String(error)}`, + ); + } + }, [appService, loadTemplates, setStatus, showError]); + /** * Handles action selection. * Navigates to the Action Wizard where the user will choose their role. @@ -264,12 +416,25 @@ export function TemplateListScreen(): React.ReactElement { }); }, [currentTemplate, navigate]); - // Handle keyboard navigation - useBlockableInput((_input, key) => { + // Handle keyboard navigation and template shortcuts + useBlockableInput((input, key) => { if (key.tab) { setFocusedPanel(prev => prev === 'templates' ? 'actions' : 'templates'); return; } + + if (isLoading || focusedPanel !== 'templates') { + return; + } + + if (input === 'a' || input === 'A') { + openImportDialog(); + return; + } + + if ((input === 'd' || input === 'D') && currentTemplate) { + openDeleteDialog(); + } }); /** @@ -338,7 +503,8 @@ export function TemplateListScreen(): React.ReactElement { items={templateListItems} selectedIndex={selectedTemplateIndex} onSelect={handleTemplateSelect} - focus={focusedPanel === 'templates'} + onActivate={handleTemplateActivate} + focus={focusedPanel === 'templates' && !isCaptured} emptyMessage="No templates imported" renderItem={renderTemplateItem} /> @@ -360,6 +526,12 @@ export function TemplateListScreen(): React.ReactElement { Loading... + ) : isImportRowSelected ? ( + + + Import a template to see available actions + + ) : !currentTemplate ? ( Select a template... @@ -370,7 +542,7 @@ export function TemplateListScreen(): React.ReactElement { selectedIndex={selectedActionIndex} onSelect={setSelectedActionIndex} onActivate={handleActionActivate} - focus={focusedPanel === 'actions'} + focus={focusedPanel === 'actions' && !isCaptured} emptyMessage="No actions available" renderItem={renderActionItem} /> @@ -392,7 +564,15 @@ export function TemplateListScreen(): React.ReactElement { Description {/* Show template description when templates panel is focused */} - {focusedPanel === 'templates' && currentTemplate ? ( + {focusedPanel === 'templates' && isImportRowSelected ? ( + + Import Template + + Import a template file (JSON, JavaScript, or TypeScript) from the directory where the TUI was launched. + Press Enter or a to open the file picker. + + + ) : focusedPanel === 'templates' && currentTemplate ? ( {currentTemplate.template.name || 'Unnamed Template'} @@ -471,9 +651,57 @@ export function TemplateListScreen(): React.ReactElement { {/* Help text */} - Tab: Switch list • Enter: Select action • ↑↓: Navigate • Esc: Back + {focusedPanel === 'templates' && isImportRowSelected + ? 'Tab: Switch list • a/Enter: Import • ↑↓: Navigate • Esc: Back' + : focusedPanel === 'templates' && currentTemplate + ? 'Tab: Switch list • a: Import • d: Delete • ↑↓: Navigate • Esc: Back' + : 'Tab: Switch list • Enter: Select action • ↑↓: Navigate • Esc: Back'} + + {/* Import template dialog overlay */} + {isImportDialogOpen && ( + + setIsImportDialogOpen(false)} + onSelectFile={handleImportFile} + /> + + )} + + {/* Delete template confirmation dialog */} + {isDeleteDialogOpen && templateToDelete && ( + + { + setIsDeleteDialogOpen(false); + setTemplateToDelete(null); + }} + /> + + )} ); } diff --git a/src/tui/utils/format-dialog-message.ts b/src/tui/utils/format-dialog-message.ts new file mode 100644 index 0000000..61d4d99 --- /dev/null +++ b/src/tui/utils/format-dialog-message.ts @@ -0,0 +1,121 @@ +/** + * Formats multi-line dialog messages for readable terminal display. + * + * Ink's `wrap="wrap"` breaks long lines mid-word, which looks broken for + * dot-separated template validation paths. We pre-split on newlines and break + * long lines at `.` segment boundaries instead. + */ + +/** + * Hard-wraps text when a single segment still exceeds the maximum width. + */ +function hardWrapLine(line: string, maxWidth: number): string[] { + if (line.length <= maxWidth) { + return [line]; + } + + const wrapped: string[] = []; + let remaining = line; + + while (remaining.length > maxWidth) { + wrapped.push(remaining.slice(0, maxWidth)); + remaining = ` ${remaining.slice(maxWidth)}`; + } + + if (remaining.length > 0) { + wrapped.push(remaining); + } + + return wrapped; +} + +/** + * Breaks a long line at dot-separated segments, indenting continuations. + */ +function breakLongLineAtDots(line: string, maxWidth: number): string[] { + const segments: string[] = []; + let segmentStart = 0; + + for (let index = 0; index < line.length; index += 1) { + if (line[index] === "." && index > 0) { + segments.push(line.slice(segmentStart, index + 1)); + segmentStart = index + 1; + } + } + + if (segmentStart < line.length) { + segments.push(line.slice(segmentStart)); + } + + if (segments.length === 0) { + return hardWrapLine(line, maxWidth); + } + + const lines: string[] = []; + let current = ""; + + for (const segment of segments) { + const candidate = current + segment; + + if (candidate.length > maxWidth && current.length > 0) { + lines.push(current); + current = ` ${segment}`; + continue; + } + + if (candidate.length > maxWidth) { + lines.push(...hardWrapLine(segment, maxWidth)); + current = ""; + continue; + } + + current = candidate; + } + + if (current.length > 0) { + lines.push(current); + } + + return lines; +} + +/** + * Splits a dialog message into display lines that fit the available width. + */ +export function formatDialogMessageLines( + message: string, + contentWidth: number, +): string[] { + const output: string[] = []; + + for (const rawLine of message.split("\n")) { + const line = rawLine.trimEnd(); + if (line.length === 0) { + continue; + } + + if (line.length <= contentWidth) { + output.push(line); + continue; + } + + output.push(...breakLongLineAtDots(line, contentWidth)); + } + + return output; +} + +/** + * Computes dialog width from the terminal size. + */ +export function getMessageDialogWidth(terminalColumns: number): number { + return Math.min(Math.max(terminalColumns - 4, 60), 100); +} + +/** Inner text width after dialog border and horizontal padding. */ +export function getMessageContentWidth(dialogWidth: number): number { + return Math.max(dialogWidth - 6, 40); +} + +/** Maximum number of body lines shown before truncating with a summary. */ +export const MAX_MESSAGE_DIALOG_LINES = 24; diff --git a/src/tui/utils/list-directory-entries.ts b/src/tui/utils/list-directory-entries.ts new file mode 100644 index 0000000..fa1bb1c --- /dev/null +++ b/src/tui/utils/list-directory-entries.ts @@ -0,0 +1,170 @@ +/** + * Directory listing helpers for terminal file pickers. + * + * Uses synchronous filesystem APIs to match other TUI screens (e.g. SeedInput). + */ + +import fs from "node:fs"; +import path from "node:path"; + +/** + * Kind of entry shown in a file picker list. + */ +export type DirectoryEntryKind = "parent" | "directory" | "file"; + +/** + * A single row in a directory listing. + */ +export interface DirectoryEntry { + /** Display name (e.g. ".." or "foo.json"). */ + name: string; + /** Absolute path on disk. */ + absolutePath: string; + /** Whether this row navigates up, into a folder, or selects a file. */ + kind: DirectoryEntryKind; +} + +/** + * Options for {@link listDirectoryEntries}. + */ +export interface ListDirectoryEntriesOptions { + /** + * Allowed file extensions without a leading dot (e.g. `['json']`). + * When omitted or empty, all non-hidden files are included. + * Directories are always included regardless of this filter. + */ + extensions?: string[]; +} + +/** + * Result of listing a directory for the file picker. + */ +export interface ListDirectoryEntriesResult { + entries: DirectoryEntry[]; + /** Set when the directory could not be read. */ + error?: string; +} + +/** + * Returns true when the file extension matches one of the allowed extensions. + * Comparison is case-insensitive; extensions may be passed with or without a dot. + */ +function matchesExtension( + filename: string, + extensions: string[] | undefined, +): boolean { + if (extensions === undefined || extensions.length === 0) { + return true; + } + + const fileExtension = path.extname(filename).slice(1).toLowerCase(); + if (fileExtension.length === 0) { + return false; + } + + return extensions.some((extension) => { + const normalized = extension.startsWith(".") + ? extension.slice(1) + : extension; + return normalized.toLowerCase() === fileExtension; + }); +} + +/** + * Lists files and folders in `directory` for display in a terminal file picker. + * + * - Prepends `..` when not at the filesystem root. + * - Always shows subdirectories (except `.` and `..` from readdir). + * - Filters files by optional `extensions`. + * - Sort order: parent link first, then directories A→Z, then files A→Z. + * - Returns an empty list and `error` instead of throwing on permission or missing paths. + */ +export function listDirectoryEntries( + directory: string, + options: ListDirectoryEntriesOptions = {}, +): ListDirectoryEntriesResult { + const resolvedDirectory = path.resolve(directory); + const { extensions } = options; + + try { + if (!fs.existsSync(resolvedDirectory)) { + return { + entries: [], + error: `Directory does not exist: ${resolvedDirectory}`, + }; + } + + const directoryStat = fs.statSync(resolvedDirectory); + if (!directoryStat.isDirectory()) { + return { + entries: [], + error: `Not a directory: ${resolvedDirectory}`, + }; + } + + const entries: DirectoryEntry[] = []; + const parentDirectory = path.dirname(resolvedDirectory); + + if (parentDirectory !== resolvedDirectory) { + entries.push({ + name: "..", + absolutePath: parentDirectory, + kind: "parent", + }); + } + + const childNames = fs.readdirSync(resolvedDirectory); + const directories: DirectoryEntry[] = []; + const files: DirectoryEntry[] = []; + + for (const name of childNames) { + if (name === "." || name === "..") { + continue; + } + + const absolutePath = path.join(resolvedDirectory, name); + + let childStat: fs.Stats; + try { + childStat = fs.statSync(absolutePath); + } catch { + // Skip broken symlinks or entries we cannot stat. + continue; + } + + if (childStat.isDirectory()) { + directories.push({ + name, + absolutePath, + kind: "directory", + }); + continue; + } + + if (childStat.isFile() && matchesExtension(name, extensions)) { + files.push({ + name, + absolutePath, + kind: "file", + }); + } + } + + const sortByName = (a: DirectoryEntry, b: DirectoryEntry): number => + a.name.localeCompare(b.name, undefined, { sensitivity: "base" }); + + directories.sort(sortByName); + files.sort(sortByName); + + return { + entries: [...entries, ...directories, ...files], + }; + } catch (error) { + const message = + error instanceof Error ? error.message : String(error); + return { + entries: [], + error: `Unable to read directory: ${message}`, + }; + } +} diff --git a/src/utils/load-template-from-file.ts b/src/utils/load-template-from-file.ts new file mode 100644 index 0000000..aa76a58 --- /dev/null +++ b/src/utils/load-template-from-file.ts @@ -0,0 +1,194 @@ +/** + * Loads template file contents for {@link Engine.importTemplate}. + * + * - `.json` files are read directly. + * - `.ts`, `.js`, `.mts`, `.cts`, `.mjs`, `.cjs` files are evaluated in a + * short-lived child process and serialized to Extended JSON on stdout. + * TypeScript templates (and the loader in dev) run via tsx; plain JS uses node. + */ + +import { spawn } from "node:child_process"; +import { createRequire } from "node:module"; +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +/** Extensions loaded via subprocess module evaluation. */ +const MODULE_TEMPLATE_EXTENSIONS = new Set([ + ".ts", + ".tsx", + ".mts", + ".cts", + ".js", + ".jsx", + ".mjs", + ".cjs", +]); + +/** Maximum time allowed for a template module child process. */ +const MODULE_LOAD_TIMEOUT_MS = 30_000; + +/** Maximum stdout size from the loader child (50 MiB). */ +const MODULE_LOAD_MAX_BUFFER_BYTES = 50 * 1024 * 1024; + +/** + * Thrown when a template file cannot be read or loaded. + */ +export class TemplateLoadError extends Error { + constructor(message: string) { + super(message); + this.name = "TemplateLoadError"; + } +} + +/** + * Resolves the tsx CLI binary shipped with this package. + */ +function resolveTsxCliPath(): string { + const require = createRequire(import.meta.url); + const tsxPackageJsonPath = require.resolve("tsx/package.json"); + return path.join(path.dirname(tsxPackageJsonPath), "dist/cli.mjs"); +} + +/** + * Resolves the loader script path for dev (.ts) and production (.js) layouts. + */ +function resolveTemplateModuleLoaderPath(): string { + const directory = path.dirname(fileURLToPath(import.meta.url)); + const compiledLoaderPath = path.join(directory, "template-module-loader.js"); + if (fs.existsSync(compiledLoaderPath)) { + return compiledLoaderPath; + } + + const sourceLoaderPath = path.join(directory, "template-module-loader.ts"); + if (fs.existsSync(sourceLoaderPath)) { + return sourceLoaderPath; + } + + throw new TemplateLoadError( + "Template module loader script was not found in the xo-cli package.", + ); +} + +/** TypeScript extensions that require tsx to evaluate the template module. */ +const TYPESCRIPT_TEMPLATE_EXTENSIONS = new Set([ + ".ts", + ".tsx", + ".mts", + ".cts", +]); + +/** + * Loads a TS/JS template module in an isolated child process. + * Returns Extended JSON suitable for {@link parseTemplate}. + */ +async function loadTemplateModuleViaChildProcess( + absolutePath: string, +): Promise { + const loaderPath = resolveTemplateModuleLoaderPath(); + const extension = path.extname(absolutePath).toLowerCase(); + const loaderIsTypeScript = loaderPath.endsWith(".ts"); + const useTsx = + TYPESCRIPT_TEMPLATE_EXTENSIONS.has(extension) || loaderIsTypeScript; + const executable = useTsx ? resolveTsxCliPath() : process.execPath; + const args = [loaderPath, absolutePath]; + + return new Promise((resolve, reject) => { + const child = spawn(executable, args, { + cwd: path.dirname(absolutePath), + stdio: ["ignore", "pipe", "pipe"], + env: process.env, + }); + + let stdout = ""; + let stderr = ""; + let stdoutBytes = 0; + + const timeout = setTimeout(() => { + child.kill("SIGTERM"); + reject( + new TemplateLoadError( + `Template module load timed out after ${MODULE_LOAD_TIMEOUT_MS / 1000}s`, + ), + ); + }, MODULE_LOAD_TIMEOUT_MS); + + child.stdout.on("data", (chunk: Buffer | string) => { + const text = typeof chunk === "string" ? chunk : chunk.toString("utf8"); + stdoutBytes += Buffer.byteLength(text, "utf8"); + if (stdoutBytes > MODULE_LOAD_MAX_BUFFER_BYTES) { + child.kill("SIGTERM"); + reject( + new TemplateLoadError( + "Template module output exceeded the maximum allowed size.", + ), + ); + return; + } + stdout += text; + }); + + child.stderr.on("data", (chunk: Buffer | string) => { + stderr += typeof chunk === "string" ? chunk : chunk.toString("utf8"); + }); + + child.on("error", (error) => { + clearTimeout(timeout); + reject( + new TemplateLoadError( + `Failed to start template module loader: ${error.message}`, + ), + ); + }); + + child.on("close", (code) => { + clearTimeout(timeout); + + if (code !== 0) { + reject( + new TemplateLoadError( + stderr.trim() || + `Template module loader exited with code ${code ?? "unknown"}`, + ), + ); + return; + } + + if (stdout.trim().length === 0) { + reject(new TemplateLoadError("Template module loader returned no output.")); + return; + } + + resolve(stdout); + }); + }); +} + +/** + * Loads template contents from disk. + * + * @param filePath - Absolute or relative path to a JSON or module template file. + * @returns Extended JSON string for {@link Engine.importTemplate}. + */ +export async function loadTemplateFromFile(filePath: string): Promise { + const absolutePath = path.resolve(filePath); + + if (!fs.existsSync(absolutePath)) { + throw new TemplateLoadError(`Template file does not exist: ${absolutePath}`); + } + + const extension = path.extname(absolutePath).toLowerCase(); + + if (extension === ".json") { + return fs.promises.readFile(absolutePath, "utf8"); + } + + if (MODULE_TEMPLATE_EXTENSIONS.has(extension)) { + return loadTemplateModuleViaChildProcess(absolutePath); + } + + throw new TemplateLoadError( + `Unsupported template file extension "${extension}". ` + + "Use .json or a JavaScript/TypeScript module that exports an XOTemplate.", + ); +} diff --git a/src/utils/pick-template-export.ts b/src/utils/pick-template-export.ts new file mode 100644 index 0000000..30bd144 --- /dev/null +++ b/src/utils/pick-template-export.ts @@ -0,0 +1,64 @@ +/** + * Helpers for finding an {@link XOTemplate} export in a loaded ES module. + */ + +/** + * Returns true when `value` looks like an XOTemplate object (pre-schema check). + * Used only to pick the correct export before {@link parseTemplate} validates fully. + */ +export function isTemplateLike(value: unknown): value is Record { + if (value === null || typeof value !== "object" || Array.isArray(value)) { + return false; + } + + const candidate = value as Record; + return ( + typeof candidate.$schema === "string" && + typeof candidate.name === "string" && + typeof candidate.roles === "object" && + candidate.roles !== null + ); +} + +/** + * Picks the single XOTemplate export from a dynamically loaded module. + * + * Resolution order: + * 1. `default` export, when template-like + * 2. Exactly one named template-like export + * + * @throws When no template export exists or multiple template exports are found. + */ +export function pickTemplateExport( + moduleExports: Record, +): Record { + const defaultExport = moduleExports.default; + if (isTemplateLike(defaultExport)) { + return defaultExport; + } + + const namedTemplateExports = Object.entries(moduleExports).filter( + ([exportName, exportValue]) => + exportName !== "default" && isTemplateLike(exportValue), + ); + + if (namedTemplateExports.length === 1) { + const [, exportValue] = namedTemplateExports[0]!; + if (!isTemplateLike(exportValue)) { + throw new Error("No XOTemplate export found."); + } + return exportValue; + } + + if (namedTemplateExports.length > 1) { + const exportNames = namedTemplateExports.map(([name]) => name).join(", "); + throw new Error( + `Multiple template exports found (${exportNames}). ` + + "Use a single named export or a default export.", + ); + } + + throw new Error( + "No XOTemplate export found. Export a template object as `default` or a named export.", + ); +} diff --git a/src/utils/template-module-loader.ts b/src/utils/template-module-loader.ts new file mode 100644 index 0000000..f5ba077 --- /dev/null +++ b/src/utils/template-module-loader.ts @@ -0,0 +1,34 @@ +/** + * Child-process entry point for loading a TS/JS template module. + * + * Usage (via tsx): `tsx template-module-loader.js ` + * + * Writes serialized Extended JSON to stdout. Errors go to stderr with exit code 1. + * Running in a subprocess isolates module evaluation from the wallet process. + */ + +import { pathToFileURL } from "node:url"; + +import { serializeTemplate } from "@xo-cash/utils"; +import type { XOTemplate } from "@xo-cash/types"; + +import { pickTemplateExport } from "./pick-template-export.js"; + +const templateFilePath = process.argv[2]; + +if (templateFilePath === undefined || templateFilePath.length === 0) { + console.error("Usage: template-module-loader "); + process.exit(1); +} + +try { + const moduleUrl = pathToFileURL(templateFilePath).href; + const loadedModule = (await import(moduleUrl)) as Record; + const template = pickTemplateExport(loadedModule); + process.stdout.write(serializeTemplate(template as XOTemplate)); +} catch (error) { + const message = + error instanceof Error ? error.message : String(error); + console.error(`Failed to load template module: ${message}`); + process.exit(1); +} diff --git a/tests/cli/mnemonic.test.ts b/tests/cli/mnemonic.test.ts index d0abfc0..baccac9 100644 --- a/tests/cli/mnemonic.test.ts +++ b/tests/cli/mnemonic.test.ts @@ -3,6 +3,7 @@ import { existsSync, mkdirSync, readFileSync, + realpathSync, rmSync, writeFileSync, } from "node:fs"; @@ -110,7 +111,13 @@ describe("mnemonic utilities", () => { "/nonexistent", "mnemonic-relative", ); - expect(resolved).toBe(path.join(tempDir, "mnemonic-relative")); + + // Due to some weird MacOS behavior we need to use realpathSync to get the correct path + // Basically, tmpDir() returns a symlink, but moving to that path puts us at `/private/${tmpDir()}` + const expectedPath = realpathSync(path.join(tempDir, "mnemonic-relative")); + + // Compare to the expected path + expect(resolved).toBe(expectedPath); } finally { process.chdir(originalCwd); } diff --git a/tests/cli/mocks/engine.ts b/tests/cli/mocks/engine.ts index 18ee48f..a8486a9 100644 --- a/tests/cli/mocks/engine.ts +++ b/tests/cli/mocks/engine.ts @@ -1,3 +1,6 @@ +// Node js tool for temp dir +import { tmpdir } from "node:os"; + import { BlockchainMonitor, Engine } from "@xo-cash/engine"; import { @@ -16,6 +19,7 @@ import { InMemoryStorage } from "../../../src/services/storage"; import { MockElectrumService } from "./electrum-service"; import { MockRatesService } from "./rates-service"; import { RatesService } from "../../../src/services/rates"; +import { SettingsService } from "../../../src/services/settings"; export const DEFAULT_SEED = "page pencil stock planet limb cluster assault speak off joke private pioneer"; @@ -67,8 +71,6 @@ export const addFakeResource = async ( status: UnspentOutputStatus.CONFIRMED, selectable: true, privacy: false, - templateIdentifier: options.templateIdentifier ?? "test-template", - outputIdentifier: options.outputIdentifier ?? "receiveOutput", outpointIndex: options.outpointIndex ?? 0, outpointTransactionHash: options.outpointTransactionHash ?? randomTxHash(), minedAtHeight: options.minedAtHeight ?? 800000, @@ -143,10 +145,7 @@ export const createMockEngine = async (seed: string) => { // Create the in-memory blockchain provider. const blockchainProvider = new InMemoryBlockchainProvider(); - await blockchainProvider.initialize({ - applicationIdentifier: "xo-cli-tests", - electrumOptions: {}, - }); + await blockchainProvider.initialize(); // Create the blockchain monitor instance. const blockchainMonitor = new BlockchainMonitor(state, blockchainProvider); @@ -160,10 +159,13 @@ export const createMockEngine = async (seed: string) => { }; export const createMockAppService = async (engine: Engine) => { + const settings = new SettingsService(`${tmpdir()}/xo-cli-tests-settings.json`); + settings.setCurrency("USD"); + const storage = await InMemoryStorage.create(); const mockRates = new MockRatesService(); - const rates = new RatesService(mockRates); + const rates = new RatesService(mockRates, settings); const mockElectrum = new MockElectrumService(); @@ -176,5 +178,5 @@ export const createMockAppService = async (engine: Engine) => { invitationStoragePath: "test-invitations.db", }; - return new AppService(engine, storage, config, mockElectrum, rates); + return new AppService(engine, storage, config, mockElectrum, rates, settings); }; diff --git a/tests/cli/paths.test.ts b/tests/cli/paths.test.ts index 5fb11b8..b63351c 100644 --- a/tests/cli/paths.test.ts +++ b/tests/cli/paths.test.ts @@ -1,5 +1,5 @@ import { expect, test, describe, beforeEach, afterEach } from "vitest"; -import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { existsSync, mkdirSync, rmSync, writeFileSync, realpathSync } from "node:fs"; import { homedir, tmpdir } from "node:os"; import path from "node:path"; @@ -93,7 +93,13 @@ describe("paths utilities", () => { try { writeFileSync(path.join(tempDir, "mnemonic-cwd-test"), "test"); const resolved = resolveMnemonicFilePath("mnemonic-cwd-test"); - expect(resolved).toBe(path.join(tempDir, "mnemonic-cwd-test")); + + // Due to some weird MacOS behavior we need to use realpathSync to get the correct path + // Basically, tmpDir() returns a symlink, but moving to that path puts us at `/private/${tmpDir()}` + const expectedPath = realpathSync(path.join(tempDir, "mnemonic-cwd-test")); + + // Compare to the expected path + expect(resolved).toBe(expectedPath); } finally { process.chdir(originalCwd); } diff --git a/tests/tui/format-dialog-message.test.ts b/tests/tui/format-dialog-message.test.ts new file mode 100644 index 0000000..ecb35a9 --- /dev/null +++ b/tests/tui/format-dialog-message.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, test } from "vitest"; + +import { + formatDialogMessageLines, + getMessageContentWidth, + getMessageDialogWidth, +} from "../../src/tui/utils/format-dialog-message.js"; + +describe("formatDialogMessageLines", () => { + test("drops empty lines from leading newlines", () => { + const lines = formatDialogMessageLines("\n- first\n- second", 80); + + expect(lines).toEqual(["- first", "- second"]); + }); + + test("keeps short lines unchanged", () => { + const lines = formatDialogMessageLines("- actions.receive: Invalid", 80); + + expect(lines).toEqual(["- actions.receive: Invalid"]); + }); + + test("breaks long dot-separated paths at segment boundaries", () => { + const line = + "- actions.requestFungibleTokens.roles.receiver.requirements: Unrecognized key: \"generate\""; + const lines = formatDialogMessageLines(line, 56); + + expect(lines.length).toBeGreaterThan(1); + expect(lines.join("\n")).toContain("actions.requestFungibleTokens."); + expect(lines.every((entry) => entry.length <= 58)).toBe(true); + expect(lines[1]?.startsWith(" ")).toBe(true); + }); +}); + +describe("dialog width helpers", () => { + test("getMessageDialogWidth respects terminal bounds", () => { + expect(getMessageDialogWidth(120)).toBe(100); + expect(getMessageDialogWidth(80)).toBe(76); + expect(getMessageDialogWidth(40)).toBe(60); + }); + + test("getMessageContentWidth subtracts border and padding", () => { + expect(getMessageContentWidth(76)).toBe(70); + }); +}); diff --git a/tests/tui/list-directory-entries.test.ts b/tests/tui/list-directory-entries.test.ts new file mode 100644 index 0000000..a565ef5 --- /dev/null +++ b/tests/tui/list-directory-entries.test.ts @@ -0,0 +1,114 @@ +import { afterEach, beforeEach, describe, expect, test } from "vitest"; +import { + existsSync, + mkdirSync, + mkdtempSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; + +import { listDirectoryEntries } from "../../src/tui/utils/list-directory-entries.js"; + +describe("listDirectoryEntries", () => { + let tempRoot: string; + + beforeEach(() => { + tempRoot = mkdtempSync(path.join(tmpdir(), "xo-file-picker-")); + }); + + afterEach(() => { + if (existsSync(tempRoot)) { + rmSync(tempRoot, { recursive: true, force: true }); + } + }); + + test("includes parent entry and sorts directories before files", () => { + mkdirSync(path.join(tempRoot, "beta-dir")); + mkdirSync(path.join(tempRoot, "alpha-dir")); + writeFileSync(path.join(tempRoot, "zebra.json"), "{}"); + writeFileSync(path.join(tempRoot, "apple.txt"), "x"); + + const childDir = path.join(tempRoot, "child"); + mkdirSync(childDir); + writeFileSync(path.join(childDir, "nested.json"), "{}"); + + const rootResult = listDirectoryEntries(tempRoot); + expect(rootResult.error).toBeUndefined(); + expect(rootResult.entries.map((entry) => entry.name)).toEqual([ + "..", + "alpha-dir", + "beta-dir", + "child", + "apple.txt", + "zebra.json", + ]); + + const childResult = listDirectoryEntries(childDir); + expect(childResult.entries[0]).toMatchObject({ + name: "..", + kind: "parent", + absolutePath: tempRoot, + }); + expect(childResult.entries.slice(1).map((entry) => entry.name)).toEqual([ + "nested.json", + ]); + }); + + test("filters files by extension when extensions are provided", () => { + writeFileSync(path.join(tempRoot, "template.json"), "{}"); + writeFileSync(path.join(tempRoot, "readme.md"), "# hi"); + writeFileSync(path.join(tempRoot, "UPPER.JSON"), "{}"); + + const result = listDirectoryEntries(tempRoot, { extensions: ["json"] }); + + expect(result.error).toBeUndefined(); + expect(result.entries.map((entry) => entry.name)).toEqual([ + "..", + "template.json", + "UPPER.JSON", + ]); + }); + + test("shows all files when extensions are omitted", () => { + writeFileSync(path.join(tempRoot, "a.json"), "{}"); + writeFileSync(path.join(tempRoot, "b.txt"), "x"); + + const result = listDirectoryEntries(tempRoot); + + expect(result.entries.map((entry) => entry.name)).toEqual([ + "..", + "a.json", + "b.txt", + ]); + }); + + test("omits parent entry at filesystem root", () => { + const rootResult = listDirectoryEntries(path.parse(tempRoot).root); + + expect(rootResult.error).toBeUndefined(); + expect(rootResult.entries.some((entry) => entry.kind === "parent")).toBe( + false, + ); + }); + + test("returns error for missing directory without throwing", () => { + const missingPath = path.join(tempRoot, "does-not-exist"); + + const result = listDirectoryEntries(missingPath); + + expect(result.entries).toEqual([]); + expect(result.error).toContain("Directory does not exist"); + }); + + test("returns error when path is a file", () => { + const filePath = path.join(tempRoot, "file.txt"); + writeFileSync(filePath, "hello"); + + const result = listDirectoryEntries(filePath); + + expect(result.entries).toEqual([]); + expect(result.error).toContain("Not a directory"); + }); +}); diff --git a/tests/utils/load-template-from-file.test.ts b/tests/utils/load-template-from-file.test.ts new file mode 100644 index 0000000..69756ae --- /dev/null +++ b/tests/utils/load-template-from-file.test.ts @@ -0,0 +1,78 @@ +import { existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, test } from "vitest"; + +import { parseTemplate } from "@xo-cash/utils"; + +import { + loadTemplateFromFile, + TemplateLoadError, +} from "../../src/utils/load-template-from-file.js"; +import { p2pkhTemplate } from "../cli/mocks/template-p2pkh.js"; + +describe("loadTemplateFromFile", () => { + let tempRoot: string; + + beforeEach(() => { + tempRoot = mkdtempSync(path.join(tmpdir(), "xo-load-template-")); + }); + + afterEach(() => { + if (existsSync(tempRoot)) { + rmSync(tempRoot, { recursive: true, force: true }); + } + }); + + test("loads JSON templates directly", async () => { + const jsonPath = path.join(tempRoot, "template.json"); + writeFileSync(jsonPath, JSON.stringify(p2pkhTemplate)); + + const contents = await loadTemplateFromFile(jsonPath); + const parsed = parseTemplate(contents); + + expect(parsed.name).toBe(p2pkhTemplate.name); + }); + + test("loads TypeScript templates via child process", async () => { + const tsTemplatePath = path.resolve( + process.cwd(), + "../templates/source/p2pkh.ts", + ); + expect(existsSync(tsTemplatePath)).toBe(true); + + const contents = await loadTemplateFromFile(tsTemplatePath); + const parsed = parseTemplate(contents); + + expect(parsed.name).toBe("Wallet (P2PKH)"); + }); + + test("loads JavaScript templates via child process", async () => { + const jsPath = path.join(tempRoot, "template.mjs"); + writeFileSync( + jsPath, + `export default ${JSON.stringify(p2pkhTemplate)};\n`, + "utf8", + ); + + const contents = await loadTemplateFromFile(jsPath); + const parsed = parseTemplate(contents); + + expect(parsed.name).toBe(p2pkhTemplate.name); + }); + + test("throws TemplateLoadError for missing files", async () => { + await expect( + loadTemplateFromFile(path.join(tempRoot, "missing.json")), + ).rejects.toBeInstanceOf(TemplateLoadError); + }); + + test("throws TemplateLoadError for unsupported extensions", async () => { + const txtPath = path.join(tempRoot, "template.txt"); + writeFileSync(txtPath, "hello"); + + await expect(loadTemplateFromFile(txtPath)).rejects.toThrow( + /Unsupported template file extension/, + ); + }); +}); diff --git a/tests/utils/pick-template-export.test.ts b/tests/utils/pick-template-export.test.ts new file mode 100644 index 0000000..0e190b1 --- /dev/null +++ b/tests/utils/pick-template-export.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, test } from "vitest"; + +import { + isTemplateLike, + pickTemplateExport, +} from "../../src/utils/pick-template-export.js"; + +const sampleTemplate = { + $schema: "https://libauth.org/schemas/wallet-template-v0.schema.json", + name: "Sample", + roles: { owner: { name: "Owner" } }, +}; + +describe("pickTemplateExport", () => { + test("isTemplateLike accepts objects with schema, name, and roles", () => { + expect(isTemplateLike(sampleTemplate)).toBe(true); + expect(isTemplateLike(null)).toBe(false); + expect(isTemplateLike({ name: "Missing schema" })).toBe(false); + }); + + test("prefers default export when template-like", () => { + const picked = pickTemplateExport({ + default: sampleTemplate, + otherTemplate: { + ...sampleTemplate, + name: "Other", + }, + }); + + expect(picked).toBe(sampleTemplate); + }); + + test("uses a single named export when no default export exists", () => { + const picked = pickTemplateExport({ + p2pkhTemplate: sampleTemplate, + }); + + expect(picked).toBe(sampleTemplate); + }); + + test("throws when multiple template exports exist", () => { + expect(() => + pickTemplateExport({ + firstTemplate: sampleTemplate, + secondTemplate: { ...sampleTemplate, name: "Second" }, + }), + ).toThrow(/Multiple template exports found/); + }); + + test("throws when no template export exists", () => { + expect(() => + pickTemplateExport({ + notATemplate: { foo: "bar" }, + }), + ).toThrow(/No XOTemplate export found/); + }); +}); -- 2.49.1 From a7f0ed69a255e0bd604ac9424b85f84cf2ab64a6 Mon Sep 17 00:00:00 2001 From: Harvey Zuccon Date: Fri, 29 May 2026 18:16:27 +0200 Subject: [PATCH 05/22] Update test mnemonic --- src/cli/README.md | 2 +- src/cli/arguments.ts | 4 ++-- tests/cli/mnemonic.test.ts | 5 ++--- tests/cli/mocks/engine.ts | 2 +- 4 files changed, 6 insertions(+), 7 deletions(-) diff --git a/src/cli/README.md b/src/cli/README.md index cfcaeb7..146805f 100644 --- a/src/cli/README.md +++ b/src/cli/README.md @@ -57,7 +57,7 @@ npx tsx src/index.ts # TUI 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 +xo-cli mnemonic import oven crop same above under tower promote decrease vocal pretty require slow # List mnemonic basenames (use with -m) xo-cli mnemonic list diff --git a/src/cli/arguments.ts b/src/cli/arguments.ts index 4558ab9..39d6fd2 100644 --- a/src/cli/arguments.ts +++ b/src/cli/arguments.ts @@ -7,9 +7,9 @@ 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: + * eg: `xo-cli mnemonic create oven crop same above under tower promote decrease vocal pretty require slow -v -o mnemonic.txt` will return: * { - * args: ["mnemonic", "create", "page", "pencil", "stock", "planet", "limb", "cluster", "assault", "speak", "off", "joke", "private", "pioneer"], + * args: ["mnemonic", "create", "oven", "crop", "same", "above", "under", "tower", "promote", "decrease", "vocal", "pretty", "require", "slow"], * options: { * output: "mnemonic.txt", * verbose: "true", diff --git a/tests/cli/mnemonic.test.ts b/tests/cli/mnemonic.test.ts index baccac9..03549c3 100644 --- a/tests/cli/mnemonic.test.ts +++ b/tests/cli/mnemonic.test.ts @@ -20,8 +20,7 @@ import { import { BCHMnemonicURL } from "../../src/utils/bch-mnemonic-url"; const TEST_SEED = - "page pencil stock planet limb cluster assault speak off joke private pioneer"; - + "oven crop same above under tower promote decrease vocal pretty require slow"; describe("mnemonic utilities", () => { let tempDir: string; @@ -55,7 +54,7 @@ describe("mnemonic utilities", () => { test("creates a mnemonic file with auto-generated name", () => { const filename = createMnemonicFile(tempDir, TEST_SEED); - expect(filename).toMatch(/^mnemonic-page$/); + expect(filename).toMatch(/^mnemonic-oven$/); expect(existsSync(path.join(tempDir, filename))).toBe(true); }); diff --git a/tests/cli/mocks/engine.ts b/tests/cli/mocks/engine.ts index a8486a9..bfdb849 100644 --- a/tests/cli/mocks/engine.ts +++ b/tests/cli/mocks/engine.ts @@ -22,7 +22,7 @@ import { RatesService } from "../../../src/services/rates"; import { SettingsService } from "../../../src/services/settings"; export const DEFAULT_SEED = - "page pencil stock planet limb cluster assault speak off joke private pioneer"; + "oven crop same above under tower promote decrease vocal pretty require slow"; /** * Options for creating a fake resource (UTXO) in tests. -- 2.49.1 From 14e74fab6cdbaca991c9cc3634cc7af5c3e1f0e8 Mon Sep 17 00:00:00 2001 From: Harvey Zuccon Date: Sat, 30 May 2026 12:13:05 +0200 Subject: [PATCH 06/22] Update readme to include pulling in primitives --- readme.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/readme.md b/readme.md index 17cf3a1..f284901 100644 --- a/readme.md +++ b/readme.md @@ -46,6 +46,22 @@ npm run build # Move back to the top level directory cd .. +# ----- Start Primitive Setup ----- +git clone git@gitlab.com:GeneralProtocols/xo/primitives.git + +cd primitives + +git checkout update/syncup-ui-requirements + +npm ci + +npm run build + +# ----- End Primitive Setup ----- + +# Move back to the top level directory +cd .. + # ----- Start Template Setup ---- # Clone the Template repo git clone https://gitlab.com/Harvmaster/templates.git -- 2.49.1 From 0acc70b613c3dbda02681c793f64dd3a29f2b68b Mon Sep 17 00:00:00 2001 From: Harvey Zuccon Date: Sat, 30 May 2026 12:25:05 +0200 Subject: [PATCH 07/22] Update readme to use utils and correct branches during install --- readme.md | 59 +++++++++++++++++++++++++++++++++++-------------------- 1 file changed, 38 insertions(+), 21 deletions(-) diff --git a/readme.md b/readme.md index f284901..83d43ff 100644 --- a/readme.md +++ b/readme.md @@ -7,26 +7,6 @@ # Create a new directory since we are going to be pulling in engine too mkdir xo-terminal && cd xo-terminal -# ----- Start Engine Setup ----- -# Clone the Engine Repo (Note, this uses harvey's fork of the engine repo to access the cli-test branch) -git clone https://gitlab.com/Harvmaster/engine.git - -# Move into teh engine directory -cd engine - -# Checkout the cli-test branch -git checkout cli-test - -# Install the dependencies -npm ci - -# Build the engine -npm run build -# ----- End Engine Setup ----- - -# Move back to the top level directory -cd .. - # ----- Start State Setup ----- # Clone the State Repo git clone https://gitlab.com/Harvmaster/state.git @@ -34,7 +14,7 @@ git clone https://gitlab.com/Harvmaster/state.git # Move into the state directory cd state -git checkout in-memory-adapter +git checkout cli-test # Install the dependencies npm ci @@ -62,6 +42,21 @@ npm run build # Move back to the top level directory cd .. +# ----- Start Utils Setup ----- +git clone git@gitlab.com:Harvmaster/xo-cash-utils.git utils + +cd utils + +git checkout sse-and-backoff + +npm ci + +npm run build +# ----- End Utils Setup + +# Move back to the top level directory +cd .. + # ----- Start Template Setup ---- # Clone the Template repo git clone https://gitlab.com/Harvmaster/templates.git @@ -79,6 +74,26 @@ npm run build # Move back to the top level directory cd .. +# ----- Start Engine Setup ----- +# Clone the Engine Repo (Note, this uses harvey's fork of the engine repo to access the cli-test branch) +git clone https://gitlab.com/Harvmaster/engine.git + +# Move into teh engine directory +cd engine + +# Checkout the cli-test branch +git checkout cli-test-update + +# Install the dependencies +npm ci + +# Build the engine +npm run build +# ----- End Engine Setup ----- + +# Move back to the top level directory +cd .. + # ----- Start CLI Setup ----- # Clone the CLI Repo git clone https://git.harvmaster.com/Harvmaster/xo-cli.git @@ -86,6 +101,8 @@ git clone https://git.harvmaster.com/Harvmaster/xo-cli.git # Move into the cli directory cd xo-cli +git checkout kiok-update + # Install the dependencies npm ci -- 2.49.1 From 1776fbbf61a2203651b6e3fb5a383c6917af57ff Mon Sep 17 00:00:00 2001 From: Harvey Zuccon Date: Sat, 30 May 2026 20:50:04 +0200 Subject: [PATCH 08/22] Add TSX as a core dep due to it being used in reading templates from .ts files --- package-lock.json | 39 ++++----------------------------------- 1 file changed, 4 insertions(+), 35 deletions(-) diff --git a/package-lock.json b/package-lock.json index 324da2e..5293dbd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -24,6 +24,7 @@ "prettier": "^3.8.1", "qrcode": "^1.5.4", "react": "^19.2.4", + "tsx": "^4.21.0", "zod": "^4.3.6" }, "bin": { @@ -37,7 +38,6 @@ "@types/qrcode": "^1.5.6", "@types/react": "^19.2.14", "@vitest/coverage-v8": "^4.1.2", - "tsx": "^4.21.0", "typescript": "^5.9.3", "vitest": "^4.1.2" } @@ -1686,7 +1686,6 @@ "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, @@ -1738,7 +1737,6 @@ "version": "4.13.0", "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.0.tgz", "integrity": "sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ==", - "dev": true, "license": "MIT", "dependencies": { "resolve-pkg-maps": "^1.0.0" @@ -2590,7 +2588,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", - "dev": true, "license": "MIT", "funding": { "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" @@ -3013,7 +3010,6 @@ "version": "4.21.0", "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", - "dev": true, "license": "MIT", "dependencies": { "esbuild": "~0.27.0", @@ -3036,7 +3032,6 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3053,7 +3048,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3070,7 +3064,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3087,7 +3080,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3104,7 +3096,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3121,7 +3112,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3138,7 +3128,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3155,7 +3144,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3172,7 +3160,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3189,7 +3176,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3206,7 +3192,6 @@ "cpu": [ "ia32" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3223,7 +3208,6 @@ "cpu": [ "loong64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3240,7 +3224,6 @@ "cpu": [ "mips64el" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3257,7 +3240,6 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3274,7 +3256,6 @@ "cpu": [ "riscv64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3291,7 +3272,6 @@ "cpu": [ "s390x" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3308,7 +3288,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3325,7 +3304,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3342,7 +3320,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3359,7 +3336,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3376,7 +3352,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3393,7 +3368,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3410,7 +3384,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3427,7 +3400,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3444,7 +3416,6 @@ "cpu": [ "ia32" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3461,7 +3432,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3475,7 +3445,6 @@ "version": "0.27.7", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==", - "dev": true, "hasInstallScript": true, "license": "MIT", "bin": { @@ -4293,9 +4262,9 @@ "license": "ISC" }, "node_modules/ws": { - "version": "8.19.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", - "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", "license": "MIT", "engines": { "node": ">=10.0.0" -- 2.49.1 From 0b848989a2e340a44294a35680ca2216c59bfdc4 Mon Sep 17 00:00:00 2001 From: Harvey Zuccon Date: Sat, 30 May 2026 20:50:26 +0200 Subject: [PATCH 09/22] Remove transaction screen from the menu in invitationScreen --- .../screens/invitations/InvitationScreen.tsx | 34 +++++++++++++++---- 1 file changed, 28 insertions(+), 6 deletions(-) diff --git a/src/tui/screens/invitations/InvitationScreen.tsx b/src/tui/screens/invitations/InvitationScreen.tsx index c7c8bc4..5ce111f 100644 --- a/src/tui/screens/invitations/InvitationScreen.tsx +++ b/src/tui/screens/invitations/InvitationScreen.tsx @@ -63,7 +63,7 @@ const actionItems: ListItemData[] = [ { key: 'accept', label: 'Accept & Join', value: 'accept' }, { key: 'fill', label: 'Fill Requirements', value: 'fill' }, { key: 'sign', label: 'Sign Transaction', value: 'sign' }, - { key: 'transaction', label: 'View Transaction', value: 'transaction' }, + { key: 'broadcast', label: 'Broadcast Transaction', value: 'broadcast' }, { key: 'copy', label: 'Copy Invitation ID', value: 'copy' }, ]; @@ -332,6 +332,30 @@ export function InvitationScreen(): React.ReactElement { } }, [selectedInvitation, showInfo, showError, setStatus]); + /** + * Broadcast transaction. + */ + const broadcastTransaction = useCallback(async () => { + if (!selectedInvitation) return; + + setIsLoading(true); + setStatus('Broadcasting transaction...'); + + try { + await selectedInvitation.broadcast(); + showInfo( + `Transaction Broadcast Successful!\n\n` + + `The transaction has been submitted to the network.` + ); + setStatus('Ready'); + } catch (error) { + showError(`Failed to broadcast: ${error instanceof Error ? error.message : String(error)}`); + } finally { + setIsLoading(false); + setStatus('Ready'); + } + }, [selectedInvitation, showInfo, showError, setStatus]); + const copyId = useCallback(async () => { if (!selectedInvitation) { showError('No invitation selected'); @@ -489,13 +513,11 @@ export function InvitationScreen(): React.ReactElement { case 'sign': signInvitation(); break; - case 'transaction': - if (selectedInvitation) { - navigate('transaction', { invitationId: selectedInvitation.data.invitationIdentifier }); - } + case 'broadcast': + broadcastTransaction(); break; } - }, [selectedInvitation, copyId, acceptInvitation, fillRequirements, signInvitation, navigate]); + }, [selectedInvitation, copyId, acceptInvitation, fillRequirements, signInvitation, broadcastTransaction, navigate]); const handleListItemActivate = useCallback((item: InvitationListItem, _index: number) => { if (item.key === 'import') { -- 2.49.1 From 17a41cf29aa4b17bf36f1ad18937d584f3975b32 Mon Sep 17 00:00:00 2001 From: Harvey Zuccon Date: Sat, 30 May 2026 21:21:34 +0200 Subject: [PATCH 10/22] Massive speed up during invitation creation at the expense of reliability. Document method for creating a reliability manager of some sort --- readme.md | 52 ++++++++++++++++++++++++++++++++++++++ src/services/app.ts | 2 +- src/services/invitation.ts | 19 +++++++------- 3 files changed, 63 insertions(+), 10 deletions(-) diff --git a/readme.md b/readme.md index 83d43ff..e8f884f 100644 --- a/readme.md +++ b/readme.md @@ -155,3 +155,55 @@ xo-tui # If not globally installed npm run dev ``` + +## TODO + +### Track invitation sync-server connectivity without blocking the UI + +Each `Invitation` currently owns a `SyncServer` instance for its invitation +identifier. The invitation uses that instance to open an SSE connection, fetch +remote state, and publish local changes. Publish requests are intentionally +fire-and-forget so that invitation actions and the TUI stay responsive when the +sync server is slow or unavailable. + +The tradeoff is that failed background requests and SSE connection changes are +not represented as application state. `SyncServer` already emits `connected`, +`disconnected`, and `error` events, and `Invitation` emits errors from failed +publishes, but there is no app-level owner that aggregates those events. The UI +therefore cannot reliably tell the user that an invitation may only be updated +locally and is not currently syncing with other participants. + +Implement an app-owned `InvitationConnectivityService` (or similarly named +invitation watcher) with the following responsibilities: + +- Register an invitation and its `SyncServer` when `AppService` creates or loads + it, and unregister it when the invitation is removed or stopped. +- Listen for each sync server's `connected`, `disconnected`, and `error` events, + plus invitation publish failures. +- Track connectivity separately from the invitation's business status + (`actionable`, `signed`, `ready`, and so on). Suggested transport states are + `connecting`, `online`, `offline`, and `degraded`, with the last error and + last successful connection timestamp available for diagnostics. +- Expose both per-invitation state and an aggregate app-level state such as + "one or more invitations are not syncing". +- Emit normalized connectivity-change events that the CLI can log and the TUI + can subscribe to without awaiting sync-server requests. + +Keep local persistence and local invitation actions independent from remote +sync health. Failed sync attempts should not freeze normal wallet interaction. +The service should provide a retry path, or observe retry events from the SSE +client, and clear the warning after connectivity recovers. If publish retries +are added, make the retry policy explicit and preserve commit idempotency. + +For UI integration, inject a small notification function or subscribe at the +app-context layer rather than having invitation instances render UI directly. +The first version can show an error dialog when the aggregate state becomes +unhealthy. A less intrusive version can expose the same state as a warning icon +or message in the TUI status bar and reserve dialogs for prolonged failures or +explicit user actions. + +While making this change, consolidate invitation startup ownership. Startup is +currently triggered during `Invitation.create()` and again by +`AppService.createInvitation()`. The watcher should have one clear lifecycle +point so connections, listeners, retries, and cleanup are registered exactly +once. diff --git a/src/services/app.ts b/src/services/app.ts index bb5a885..52c9127 100644 --- a/src/services/app.ts +++ b/src/services/app.ts @@ -173,7 +173,7 @@ export class AppService extends EventEmitter { // Attach listeners before SSE connects so updates are not missed. await this.addInvitation(invitationInstance); - await invitationInstance.start(); + invitationInstance.start(); return invitationInstance; } diff --git a/src/services/invitation.ts b/src/services/invitation.ts index ddacfd6..cafffba 100644 --- a/src/services/invitation.ts +++ b/src/services/invitation.ts @@ -224,12 +224,9 @@ export class Invitation extends EventEmitter { private async publishInvitation( invitation: XOInvitation = this.data, ): Promise { - 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))); - } + this.syncServer.publishInvitation(invitation).catch((error) => { + this.emit("error", error instanceof Error ? error : new Error(String(error))); + }); } /** @@ -374,9 +371,13 @@ export class Invitation extends EventEmitter { * Update the status of the invitation and emit the new single-word status. */ private async updateStatus(): Promise { - const status = await this.computeStatus(); - this.status = status; - this.emit("invitation-status-changed", status); + this.computeStatus().then(status => { + this.status = status; + this.emit("invitation-status-changed", status); + }).catch((error) => { + this.status = `error (${error instanceof Error ? error.message : String(error)})`; + this.emit("error", error instanceof Error ? error : new Error(String(error))); + }); } /** -- 2.49.1 From f1ac89ef91e2292ceb7eded1ebe4454eeeff4084 Mon Sep 17 00:00:00 2001 From: Harvey Zuccon Date: Sat, 30 May 2026 21:21:52 +0200 Subject: [PATCH 11/22] Remove reference to transaction screen --- src/tui/App.tsx | 3 - src/tui/screens/Transaction.tsx | 413 -------------------------------- 2 files changed, 416 deletions(-) delete mode 100644 src/tui/screens/Transaction.tsx diff --git a/src/tui/App.tsx b/src/tui/App.tsx index 1676398..000c258 100644 --- a/src/tui/App.tsx +++ b/src/tui/App.tsx @@ -17,7 +17,6 @@ import { WalletStateScreen } from './screens/WalletState.js'; import { TemplateListScreen } from './screens/TemplateList.js'; import { ActionWizardScreen } from './screens/action-wizard/ActionWizardScreen.js'; import { InvitationScreen } from './screens/invitations/InvitationScreen.js'; -import { TransactionScreen } from './screens/Transaction.js'; import { MessageDialog } from './components/Dialog.js'; @@ -45,8 +44,6 @@ function Router(): React.ReactElement { return ; case 'invitations': return ; - case 'transaction': - return ; default: return Unknown screen: {screen}; } diff --git a/src/tui/screens/Transaction.tsx b/src/tui/screens/Transaction.tsx deleted file mode 100644 index ec1bbfb..0000000 --- a/src/tui/screens/Transaction.tsx +++ /dev/null @@ -1,413 +0,0 @@ -/** - * Transaction Screen - Reviews and broadcasts transactions. - * - * Provides: - * - Transaction details review - * - Input/output inspection - * - Fee calculation display - * - Broadcast confirmation - */ - -import React, { useState, useEffect, useCallback } from 'react'; -import { Box, Text } from 'ink'; -import { ConfirmDialog } from '../components/Dialog.js'; -import { useNavigation } from '../hooks/useNavigation.js'; -import { useAppContext, useStatus } from '../hooks/useAppContext.js'; -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'; - -/** - * Action menu items. - */ -const actionItems = [ - { label: 'Broadcast Transaction', value: 'broadcast' }, - { label: 'Sign Transaction', value: 'sign' }, - { label: 'Copy Transaction Hex', value: 'copy' }, - { label: 'Back to Invitation', value: 'back' }, -]; - -/** - * Transaction Screen Component. - */ -export function TransactionScreen(): React.ReactElement { - const { navigate, goBack, data: navData } = useNavigation(); - const { showError, showInfo } = useAppContext(); - const { setStatus } = useStatus(); - - // Extract invitation ID from navigation data - const invitationId = navData.invitationId as string | undefined; - - // Use hook to get invitation reactively - const invitationInstance = useInvitation(invitationId ?? null); - - // State - const [focusedPanel, setFocusedPanel] = useState<'inputs' | 'outputs' | 'actions'>('actions'); - const [selectedActionIndex, setSelectedActionIndex] = useState(0); - const [isLoading, setIsLoading] = useState(false); - const [showBroadcastConfirm, setShowBroadcastConfirm] = useState(false); - - // Check if invitation exists - useEffect(() => { - if (!invitationId) { - showError('No invitation ID provided'); - goBack(); - return; - } - - if (invitationId && !invitationInstance) { - showError('Invitation not found'); - goBack(); - } - }, [invitationId, invitationInstance, showError, goBack]); - - const invitation = invitationInstance?.data ?? null; - - /** - * Broadcast transaction. - */ - const broadcastTransaction = useCallback(async () => { - if (!invitationInstance) return; - - setShowBroadcastConfirm(false); - setIsLoading(true); - setStatus('Broadcasting transaction...'); - - try { - await invitationInstance.broadcast(); - showInfo( - `Transaction Broadcast Successful!\n\n` + - `The transaction has been submitted to the network.` - ); - navigate('wallet'); - } catch (error) { - showError(`Failed to broadcast: ${error instanceof Error ? error.message : String(error)}`); - } finally { - setIsLoading(false); - setStatus('Ready'); - } - }, [invitationInstance, showInfo, showError, navigate, setStatus]); - - /** - * Sign transaction. - */ - const signTransaction = useCallback(async () => { - if (!invitationInstance) return; - - setIsLoading(true); - setStatus('Signing transaction...'); - - try { - await invitationInstance.sign(); - showInfo('Transaction signed successfully!'); - } catch (error) { - showError(`Failed to sign: ${error instanceof Error ? error.message : String(error)}`); - } finally { - setIsLoading(false); - setStatus('Ready'); - } - }, [invitationInstance, showInfo, showError, setStatus]); - - /** - * Copy transaction hex. - */ - const copyTransactionHex = useCallback(async () => { - if (!invitation) return; - - try { - await copyToClipboard(invitation.invitationIdentifier); - showInfo( - `Copied Invitation ID!\n\n` + - `ID: ${invitation.invitationIdentifier}\n` + - `Commits: ${invitation.commits.length}` - ); - } catch (error) { - showError(`Failed to copy: ${error instanceof Error ? error.message : String(error)}`); - } - }, [invitation, showInfo, showError]); - - /** - * Handle action selection. - */ - const handleAction = useCallback((action: string) => { - switch (action) { - case 'broadcast': - setShowBroadcastConfirm(true); - break; - case 'sign': - signTransaction(); - break; - case 'copy': - copyTransactionHex(); - break; - case 'back': - goBack(); - break; - } - }, [signTransaction, copyTransactionHex, goBack]); - - // Handle keyboard navigation — automatically blocked when the confirm dialog is open. - useBlockableInput((input, key) => { - // Tab to switch panels - if (key.tab) { - setFocusedPanel(prev => { - if (prev === 'inputs') return 'outputs'; - if (prev === 'outputs') return 'actions'; - return 'inputs'; - }); - return; - } - - // Up/Down in actions - if (focusedPanel === 'actions') { - if (key.upArrow || input === 'k') { - setSelectedActionIndex(prev => Math.max(0, prev - 1)); - } else if (key.downArrow || input === 'j') { - setSelectedActionIndex(prev => Math.min(actionItems.length - 1, prev + 1)); - } - } - - // Enter to select - if (key.return && focusedPanel === 'actions') { - const action = actionItems[selectedActionIndex]; - if (action) { - handleAction(action.value); - } - } - }); - - // Extract transaction data from invitation - const commits = invitation?.commits ?? []; - const inputs: Array<{ txid: string; index: number; value?: bigint; inputIdentifier?: string }> = []; - const outputs: Array<{ value?: bigint; lockingBytecode: string; outputIdentifier?: string; isTemplate: boolean }> = []; - const variables: Array<{ id: string; value: string }> = []; - - // Parse commits for inputs, outputs, and variables - for (const commit of commits) { - // Extract variables (to help understand output values) - if (commit.data?.variables) { - for (const variable of commit.data.variables) { - variables.push({ - id: variable.variableIdentifier, - value: String(variable.value), - }); - } - } - - if (commit.data?.inputs) { - for (const input of commit.data.inputs) { - // Convert Uint8Array to hex string if needed - const txidHex = input.outpointTransactionHash - ? typeof input.outpointTransactionHash === 'string' - ? input.outpointTransactionHash - : Buffer.from(input.outpointTransactionHash).toString('hex') - : undefined; - - // Skip inputs that are just placeholders (no txid) - if (txidHex) { - inputs.push({ - txid: txidHex, - index: input.outpointIndex ?? 0, - value: undefined, // Will be looked up from UTXO data - inputIdentifier: (input as any).inputIdentifier, - }); - } - } - } - if (commit.data?.outputs) { - for (const output of commit.data.outputs) { - // Convert Uint8Array to hex string if needed - const lockingBytecodeHex = output.lockingBytecode - ? typeof output.lockingBytecode === 'string' - ? output.lockingBytecode - : Buffer.from(output.lockingBytecode).toString('hex') - : undefined; - - // Check if this is a template-defined output (has outputIdentifier but no direct value) - const isTemplateOutput = !!(output as any).outputIdentifier && !output.valueSatoshis; - - outputs.push({ - value: output.valueSatoshis, - lockingBytecode: lockingBytecodeHex ?? '(pending)', - outputIdentifier: (output as any).outputIdentifier, - isTemplate: isTemplateOutput, - }); - } - } - } - - // Try to resolve template output values from variables - const resolvedOutputs = outputs.map(output => { - if (output.isTemplate && output.outputIdentifier) { - // Look for a matching variable (e.g., requestSatoshisOutput -> requestedSatoshis) - const satoshiVar = variables.find(v => - v.id.toLowerCase().includes('satoshi') || - v.id.toLowerCase().includes('amount') - ); - if (satoshiVar) { - return { - ...output, - value: BigInt(satoshiVar.value), - resolvedFrom: satoshiVar.id, - }; - } - } - return output; - }); - - // Calculate totals (only for resolved values) - const totalOut = resolvedOutputs.reduce((sum, o) => sum + (o.value ?? 0n), 0n); - // Note: We can't calculate totalIn without UTXO lookup, so fee is unknown - const hasUnresolvedOutputs = resolvedOutputs.some(o => o.value === undefined); - const hasUnresolvedInputs = inputs.length > 0; // Input values are always unknown from commit data - - return ( - - {/* Header */} - - {logoSmall} - Transaction Review - - - {/* Summary box */} - - Transaction Summary - {invitation ? ( - - Inputs: {inputs.length} | Outputs: {resolvedOutputs.length} | Commits: {commits.length} - {hasUnresolvedInputs && ( - Total In: (requires UTXO lookup) - )} - Total Out: {formatSatoshis(totalOut)}{hasUnresolvedOutputs ? ' (partial)' : ''} - {hasUnresolvedInputs ? ( - Fee: (calculated at broadcast) - ) : ( - Fee: {formatSatoshis(0n)} - )} - - ) : ( - Loading... - )} - - - {/* Inputs and Outputs */} - - {/* Inputs */} - - Inputs - - {inputs.length === 0 ? ( - No inputs - ) : ( - inputs.map((input, index) => ( - - - {index + 1}. {formatHex(input.txid, 12)}:{input.index} - - {input.value !== undefined && ( - {formatSatoshis(input.value)} - )} - - )) - )} - - - - {/* Outputs */} - - Outputs - - {resolvedOutputs.length === 0 ? ( - No outputs - ) : ( - resolvedOutputs.map((output, index) => ( - - - {index + 1}. {output.value !== undefined ? formatSatoshis(output.value) : '(pending)'} - {output.outputIdentifier && ( - [{output.outputIdentifier}] - )} - - {output.lockingBytecode !== '(pending)' ? formatHex(output.lockingBytecode, 20) : '(pending)'} - {(output as any).resolvedFrom && ( - (from ${(output as any).resolvedFrom}) - )} - - )) - )} - - - - - {/* Actions */} - - Actions - - {actionItems.map((item, index) => ( - - {index === selectedActionIndex && focusedPanel === 'actions' ? '▸ ' : ' '} - {item.label} - - ))} - - - - {/* Help text */} - - - Tab: Switch focus • Enter: Select • Esc: Back - - - - {/* Broadcast confirmation dialog */} - {showBroadcastConfirm && ( - - setShowBroadcastConfirm(false)} - /> - - )} - - ); -} -- 2.49.1 From 5bec49858f7207cfd66b134248648c09fc7f3f2c Mon Sep 17 00:00:00 2001 From: Harvey Zuccon Date: Sat, 30 May 2026 22:25:26 +0200 Subject: [PATCH 12/22] Set the cashASM evaluation encoding in the invitation details screen --- src/tui/screens/invitations/InvitationScreen.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/tui/screens/invitations/InvitationScreen.tsx b/src/tui/screens/invitations/InvitationScreen.tsx index 5ce111f..a137d42 100644 --- a/src/tui/screens/invitations/InvitationScreen.tsx +++ b/src/tui/screens/invitations/InvitationScreen.tsx @@ -751,7 +751,8 @@ export function InvitationScreen(): React.ReactElement { acc[variable.variableIdentifier] = variable.value as XOInvitationVariableValue; return acc; - }, {} as Record) + }, {} as Record), + evaluationDecodeMode: 'bigint' })} {/* Output value */} -- 2.49.1 From 5e9c6db412d329ea02c3c2cca169b27b8b941a56 Mon Sep 17 00:00:00 2001 From: Harvey Zuccon Date: Mon, 1 Jun 2026 11:28:18 +0200 Subject: [PATCH 13/22] Fix invitation syncing in realtime --- src/services/app.ts | 16 +++ src/services/invitation.ts | 194 +++++++++++++++++++++++-------- src/tui/hooks/useInvitations.tsx | 77 ++++++------ src/tui/screens/index.tsx | 1 - src/utils/sync-server.ts | 96 +++++++-------- 5 files changed, 242 insertions(+), 142 deletions(-) diff --git a/src/services/app.ts b/src/services/app.ts index 52c9127..febfc1f 100644 --- a/src/services/app.ts +++ b/src/services/app.ts @@ -183,6 +183,7 @@ export class AppService extends EventEmitter { // Add the invitation to the invitations array this.invitations.push(invitation); + this.bumpInvitationRevision(invitation.data.invitationIdentifier); // Emit the invitation-added event this.emit("invitation-added", invitation); @@ -201,6 +202,7 @@ export class AppService extends EventEmitter { if (invitationIndex >= 0) { this.invitations.splice(invitationIndex, 1); } + this.bumpInvitationRevision(invitationIdentifier); // Emit the invitation-removed event this.emit("invitation-removed", invitation); @@ -215,12 +217,14 @@ export class AppService extends EventEmitter { if (this.invitationEventCleanup.has(invitationIdentifier)) return; const onUpdated = () => { + this.bumpInvitationRevision(invitationIdentifier); this.emit("wallet-state-changed", { reason: "invitation-updated", invitationIdentifier, }); }; const onStatusChanged = () => { + this.bumpInvitationRevision(invitationIdentifier); this.emit("wallet-state-changed", { reason: "invitation-status-changed", invitationIdentifier, @@ -236,6 +240,18 @@ export class AppService extends EventEmitter { }); } + getInvitationRevision(invitationIdentifier: string): number { + return this.invitationRevisions.get(invitationIdentifier) ?? 0; + } + + private bumpInvitationRevision(invitationIdentifier: string): void { + this.invitationsRevision += 1; + this.invitationRevisions.set( + invitationIdentifier, + this.getInvitationRevision(invitationIdentifier) + 1, + ); + } + private detachInvitationListeners(invitationIdentifier: string): void { const trackedInvitation = this.invitations.find( (candidate) => diff --git a/src/services/invitation.ts b/src/services/invitation.ts index cafffba..b122ad9 100644 --- a/src/services/invitation.ts +++ b/src/services/invitation.ts @@ -3,7 +3,7 @@ import type { Engine, GetSpendableResourcesParameters, } from "@xo-cash/engine"; -import { generateTemplateIdentifier, hasInvitationExpired, mergeInvitationCommits, serializeInvitation } from "@xo-cash/engine"; +import { generateTemplateIdentifier, hasInvitationExpired, mergeInvitationCommits, serializeInvitation, deserializeInvitation } from "@xo-cash/engine"; import type { XOInvitation, XOInvitationCommit, @@ -43,6 +43,13 @@ export type InvitationDependencies = { electrum: BlockchainService; }; +function stripLocalInvitationMetadata(invitation: XOInvitation): XOInvitation { + const { entityIdentifier: _entityIdentifier, ...sharedInvitation } = + invitation as XOInvitation & { entityIdentifier?: string }; + + return sharedInvitation; +} + export class Invitation extends EventEmitter { /** * Create an invitation and start the SSE Session required for it. @@ -90,9 +97,6 @@ export class Invitation extends EventEmitter { // Create the invitation const invitationInstance = new Invitation(engineInvitation, dependencies); - // Start the invitation and its tracking - invitationInstance.start(); - return invitationInstance; } @@ -123,6 +127,7 @@ export class Invitation extends EventEmitter { */ private storage: BaseStorage; private electrum: BlockchainService; + private sseUpdateQueue: Promise = Promise.resolve(); /** * The status of the invitation (last emitted word: pending, actionable, signed, ready, complete, expired, unknown). @@ -141,8 +146,23 @@ export class Invitation extends EventEmitter { this.storage = dependencies.storage; this.electrum = dependencies.electrum; - // Create a listerner for the messages from the SSE Session (sync server) - this.syncServer.on("message", this.handleSSEMessage.bind(this)); + // Apply SSE updates serially so each engine update sees the latest history. + this.syncServer.on("message", (event) => { + this.enqueueSyncUpdate(() => this.handleSSEMessage(event)).catch( + (error) => { + this.emit( + "error", + error instanceof Error ? error : new Error(String(error)), + ); + }, + ); + }); + } + + private enqueueSyncUpdate(update: () => Promise): Promise { + const queuedUpdate = this.sseUpdateQueue.then(update); + this.sseUpdateQueue = queuedUpdate.catch(() => {}); + return queuedUpdate; } /** @@ -160,20 +180,32 @@ export class Invitation extends EventEmitter { 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; + await this.enqueueSyncUpdate(async () => { + // SSE messages can arrive before the GET request completes. + const combinedCommits = this.mergeCommits( + this.data.commits, + invitation?.commits ?? [], + ); - // Merge the commits - const combinedCommits = this.mergeCommits( - sseCommits, - invitation?.commits ?? [], - ); + try { + // Prefer keeping the engine's local invitation state in sync. + this.data = stripLocalInvitationMetadata( + await this.engine.updateInvitation({ + ...this.data, + ...invitation, + commits: combinedCommits, + }), + ); + } catch (error) { + this.emit( + "error", + error instanceof Error ? error : new Error(String(error)), + ); + this.data = { ...this.data, 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); + await this.storage.set(this.data.invitationIdentifier, this.data); + }); // Publish the invitation to the sync server this.publishInvitation(this.data); @@ -181,8 +213,6 @@ export class Invitation extends EventEmitter { // 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))); } } @@ -192,30 +222,83 @@ export class Invitation extends EventEmitter { * * TODO: Invitation should sync up the initial data (top level) then everything after that should be the commits. This makes it easier to merge as we go instead of just having to overwrite the entire invitation. */ - private handleSSEMessage(event: SSEvent): void { - const data = JSON.parse(event.data) as { topic?: string; data?: unknown }; - if (data.topic === "invitation-updated") { - const invitation = decodeExtendedJsonObject(data.data) as XOInvitation; - - if (invitation.invitationIdentifier !== this.data.invitationIdentifier) { - return; - } - - // Filter out commits that already exist (probably a faster way to do this. This is n^2) - const newCommits = this.mergeCommits( - this.data.commits, - invitation.commits, - ); - - // Set the new commits - this.data = { ...this.data, commits: newCommits }; - - // Calculate the new status of the invitation (fire-and-forget; handler is sync) - this.updateStatus().catch(() => {}); - - // Emit the updated event - this.emit("invitation-updated", this.data); + private async handleSSEMessage(event: SSEvent): Promise { + const invitation = this.parseInvitationFromSSEMessage(event); + if ( + !invitation || + invitation.invitationIdentifier !== this.data.invitationIdentifier + ) { + return; } + + // Filter out commits that already exist + const newCommits = this.mergeCommits(this.data.commits, invitation.commits); + + try { + this.data = stripLocalInvitationMetadata( + await this.engine.updateInvitation({ + ...this.data, + ...invitation, + commits: newCommits, + }), + ); + } catch (error) { + this.emit( + "error", + error instanceof Error ? error : new Error(String(error)), + ); + this.data = { ...this.data, commits: newCommits }; + } + + await this.storage.set(this.data.invitationIdentifier, this.data); + await this.updateStatus(); + this.emit("invitation-updated", this.data); + } + + private parseInvitationFromSSEMessage(event: SSEvent): XOInvitation | null { + try { + const parsed = JSON.parse(event.data) as unknown; + const payload = + event.event === "invitation-updated" + ? this.unwrapInvitationUpdatedPayload(parsed) + : this.unwrapLegacyInvitationUpdatedPayload(parsed); + + if (!payload) return null; + + const decoded = decodeExtendedJsonObject(payload) as XOInvitation; + return stripLocalInvitationMetadata( + deserializeInvitation(serializeInvitation(decoded)), + ); + } catch { + return null; + } + } + + private unwrapInvitationUpdatedPayload(payload: unknown): unknown | null { + if ( + payload && + typeof payload === "object" && + "topic" in payload && + "data" in payload + ) { + return this.unwrapLegacyInvitationUpdatedPayload(payload); + } + + return payload; + } + + private unwrapLegacyInvitationUpdatedPayload(payload: unknown): unknown | null { + if ( + payload && + typeof payload === "object" && + "topic" in payload && + "data" in payload && + payload.topic === "invitation-updated" + ) { + return payload.data; + } + + return null; } /** @@ -388,12 +471,29 @@ export class Invitation extends EventEmitter { this.data = await this.engine.acceptInvitation(this.data, acceptParams); // Sync the invitation to the sync server - this.publishInvitation(this.data); + await this.publishInvitation(this.data); + + // Store the accepted invitation and notify reactive consumers. + await this.storage.set(this.data.invitationIdentifier, this.data); + this.emit("invitation-updated", this.data); // Update the status of the invitation await this.updateStatus(); } + /** + * Accept the invitation once for this engine entity so future appends have a root commit. + */ + async ensureAccepted(): Promise { + const ownCommits = await this.engine.findOwnCommits( + this.data.invitationIdentifier, + ); + + if (ownCommits.length === 0) { + await this.accept(); + } + } + /** * Sign the invitation */ @@ -435,11 +535,7 @@ export class Invitation extends EventEmitter { * Append a commit to the invitation */ async append(data: InvitationParameters): Promise { - try { - await this.engine.acceptInvitation(this.data); - } catch (err) { - // Literally do nothing here. We are just trying to accept the invitation in case we haven't already - } + await this.ensureAccepted(); // Append the commit to the invitation this.data = await this.engine.appendInvitation(this.data.invitationIdentifier, data); diff --git a/src/tui/hooks/useInvitations.tsx b/src/tui/hooks/useInvitations.tsx index bedd00c..b307ce4 100644 --- a/src/tui/hooks/useInvitations.tsx +++ b/src/tui/hooks/useInvitations.tsx @@ -10,7 +10,7 @@ import { useAppContext } from './useAppContext.js'; /** * Get all invitations reactively. - * Re-renders when invitations are added or removed. + * Re-renders when invitations are added, removed, or updated. */ export function useInvitations(): Invitation[] { const { appService } = useAppContext(); @@ -21,26 +21,22 @@ export function useInvitations(): Invitation[] { return () => {}; } - // Subscribe to invitation list changes - const onAdded = () => callback(); - const onRemoved = () => callback(); - - appService.on('invitation-added', onAdded); - appService.on('invitation-removed', onRemoved); + appService.on('wallet-state-changed', callback); return () => { - appService.off('invitation-added', onAdded); - appService.off('invitation-removed', onRemoved); + appService.off('wallet-state-changed', callback); }; }, [appService] ); const getSnapshot = useCallback(() => { - return appService?.invitations ?? []; + return appService?.invitationsRevision ?? 0; }, [appService]); - return useSyncExternalStore(subscribe, getSnapshot, getSnapshot); + const revision = useSyncExternalStore(subscribe, getSnapshot, getSnapshot); + + return useMemo(() => [...(appService?.invitations ?? [])], [appService, revision]); } /** @@ -56,48 +52,41 @@ export function useInvitation(invitationId: string | null): Invitation | null { return () => {}; } - // Find the invitation instance - const invitation = appService.invitations.find( - (inv) => inv.data.invitationIdentifier === invitationId - ); - - if (!invitation) { - return () => {}; - } - - // Subscribe to this specific invitation's updates - const onUpdated = () => callback(); - const onStatusChanged = () => callback(); - - invitation.on('invitation-updated', onUpdated); - invitation.on('invitation-status-changed', onStatusChanged); - - // Also subscribe to list changes in case the invitation is removed - const onRemoved = () => callback(); - appService.on('invitation-removed', onRemoved); + const onWalletStateChanged = ({ + invitationIdentifier, + }: { + invitationIdentifier: string; + }) => { + if (invitationIdentifier === invitationId) { + callback(); + } + }; + appService.on('wallet-state-changed', onWalletStateChanged); return () => { - invitation.off('invitation-updated', onUpdated); - invitation.off('invitation-status-changed', onStatusChanged); - appService.off('invitation-removed', onRemoved); + appService.off('wallet-state-changed', onWalletStateChanged); }; }, [appService, invitationId] ); const getSnapshot = useCallback(() => { - if (!appService || !invitationId) { - return null; - } - - return ( - appService.invitations.find( - (inv) => inv.data.invitationIdentifier === invitationId - ) ?? null - ); + return appService && invitationId + ? appService.getInvitationRevision(invitationId) + : 0; }, [appService, invitationId]); - return useSyncExternalStore(subscribe, getSnapshot, getSnapshot); + useSyncExternalStore(subscribe, getSnapshot, getSnapshot); + + if (!appService || !invitationId) { + return null; + } + + return ( + appService.invitations.find( + (inv) => inv.data.invitationIdentifier === invitationId + ) ?? null + ); } /** @@ -109,7 +98,7 @@ export function useInvitationData(invitationId: string | null): XOInvitation | n return useMemo(() => { return invitation?.data ?? null; - }, [invitation?.data.invitationIdentifier, invitation?.data.commits?.length]); + }, [invitation?.data]); } /** diff --git a/src/tui/screens/index.tsx b/src/tui/screens/index.tsx index 98faa53..80c7b7a 100644 --- a/src/tui/screens/index.tsx +++ b/src/tui/screens/index.tsx @@ -7,4 +7,3 @@ export { SeedInputScreen } from './SeedInput.js'; export { WalletStateScreen } from './WalletState.js'; export { TemplateListScreen } from './TemplateList.js'; export { InvitationScreen } from './invitations/InvitationScreen.js'; -export { TransactionScreen } from './Transaction.js'; diff --git a/src/utils/sync-server.ts b/src/utils/sync-server.ts index d381271..46f925c 100644 --- a/src/utils/sync-server.ts +++ b/src/utils/sync-server.ts @@ -1,9 +1,15 @@ import type { XOInvitation } from "@xo-cash/types"; import { EventEmitter } from "./event-emitter.js"; -// import { SSESession, type SSEvent } from "./sse-client.js"; import { SSESession, type SSEvent } from "@xo-cash/utils"; import { deserializeInvitation, serializeInvitation } from "@xo-cash/engine"; +function stripLocalInvitationMetadata(invitation: XOInvitation): XOInvitation { + const { entityIdentifier: _entityIdentifier, ...sharedInvitation } = + invitation as XOInvitation & { entityIdentifier?: string }; + + return sharedInvitation; +} + export type SyncServerEventMap = { connected: void; disconnected: void; @@ -21,62 +27,66 @@ export class SyncServer extends EventEmitter { return server; } - private sse: SSESession; + private sse: SSESession | null = null; constructor( private readonly baseUrl: string, private readonly invitationIdentifier: string, ) { super(); + } - // Create an SSE Session - this.sse = new SSESession( - `${baseUrl}/invitations?invitationIdentifier=${invitationIdentifier}`, + async connect(): Promise { + if (this.sse) { + await this.sse.connect(); + return; + } + + await this.createSSESession(); + } + + async disconnect(): Promise { + await this.sse?.disconnect(); + this.sse = null; + } + + private async createSSESession(): Promise { + const sse = await SSESession.create( + `${this.baseUrl}/invitations?invitationIdentifier=${encodeURIComponent(this.invitationIdentifier)}`, { method: "GET", headers: { Accept: "text/event-stream", }, - - // Create our event bubblers - onError: (error: unknown) => + persistent: true, + onRequest: async (request) => { + const { body: _body, ...requestWithoutBody } = request; + return requestWithoutBody; + }, + onError: (error: unknown) => { this.emit( "error", error instanceof Error ? error : new Error(String(error)), - ), - onDisconnected: () => this.emit("disconnected", undefined), - onConnected: () => this.emit("connected", undefined), + ); + }, + onDisconnected: () => { + this.emit("disconnected", undefined); + }, + onConnected: () => { + this.emit("connected", undefined); + }, }, ); - this.sse.on("message", (event: SSEvent) => this.emit("message", event)); + this.sse = sse; + sse.on("message", (event: SSEvent) => { + this.emit("message", event); + }); } - /** - * Connect to the sync server. - */ - async connect(): Promise { - // Connect to the SSE Session - await this.sse.connect(); - } - - /** - * Disconnect from the sync server. - */ - async disconnect(): Promise { - // Disconnect from the SSE Session - await this.sse.disconnect(); - } - - /** - * Get the invitation by identifier. - * @param identifier - The invitation identifier. - * @returns The invitation. - */ async getInvitation(identifier: string): Promise { - // Send a GET request to the sync server const response = await fetch( - `${this.baseUrl}/invitations?invitationIdentifier=${identifier}`, + `${this.baseUrl}/invitations?invitationIdentifier=${encodeURIComponent(identifier)}`, ); if (!response.ok) { @@ -84,33 +94,23 @@ export class SyncServer extends EventEmitter { } const invitation = deserializeInvitation(await response.text()); - return invitation; + return stripLocalInvitationMetadata(invitation); } - /** - * Publish an invitation. - * @param invitation - The invitation to create. - * @returns The invitation. - */ async publishInvitation(invitation: XOInvitation): Promise { - // Send a POST request to the sync server const response = await fetch(`${this.baseUrl}/invitations`, { method: "POST", - body: serializeInvitation(invitation), + body: serializeInvitation(stripLocalInvitationMetadata(invitation)), headers: { "Content-Type": "application/json", }, }); - // Throw is there was an issue with the request if (!response.ok) { throw new Error(`Failed to publish invitation: ${response.statusText}`); } - // Read the returned JSON - // TODO: This should use zod to verify the response const data = deserializeInvitation(await response.text()); - - return data; + return stripLocalInvitationMetadata(data); } } -- 2.49.1 From b30243f67462d415f7eaf61ce4fb62c8a40afc7b Mon Sep 17 00:00:00 2001 From: Harvey Zuccon Date: Mon, 1 Jun 2026 11:49:23 +0200 Subject: [PATCH 14/22] Add custom path support for cli/tui in terminal config --- package.json | 1 + readme.md | 4 + src/cli/README.md | 39 +++---- src/cli/autocomplete/completions.ts | 48 ++++++--- src/cli/autocomplete/scripts/bash.sh | 17 +++- src/cli/autocomplete/scripts/fish.fish | 19 +++- src/cli/autocomplete/scripts/zsh.zsh | 15 ++- src/services/settings.ts | 4 +- src/tui/screens/SeedInput.tsx | 6 +- src/utils/paths.ts | 6 +- tests/cli/autocomplete-completions.test.ts | 112 +++++++++++++++++++++ tests/cli/paths.test.ts | 35 +++++++ 12 files changed, 264 insertions(+), 42 deletions(-) create mode 100644 tests/cli/autocomplete-completions.test.ts diff --git a/package.json b/package.json index dca1675..e3e971b 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "dev": "SYNC_SERVER_URL=https://sync.xo.harvmaster.com tsx src/index.ts", "build": "tsc && npm run build:copy-scripts", "build:copy-scripts": "cp -r src/cli/autocomplete/scripts dist/cli/autocomplete/", + "build:unsafe": "tsc --nocheck --noEmitOnError false || true && npm run build:copy-scripts", "start": "SYNC_SERVER_URL=https://sync.xo.harvmaster.com node dist/index.js", "test": "vitest --run --passWithNoTests", "test:watch": "vitest", diff --git a/readme.md b/readme.md index e8f884f..f122c03 100644 --- a/readme.md +++ b/readme.md @@ -126,6 +126,10 @@ npm install -g . ### Install autocomplete completions (From the xo-cli directory) +These commands add `XO_CONFIG_DIR` to your shell config with a default of +`~/.config/xo-cli`. Set it to an absolute path before installing, or edit the +generated assignment, to use a different wallet-state directory. + #### Install for bash ```bash npm run autocomplete:install:bash diff --git a/src/cli/README.md b/src/cli/README.md index 146805f..c320455 100644 --- a/src/cli/README.md +++ b/src/cli/README.md @@ -9,13 +9,13 @@ There are two global commands after install: ## Global config directory -Wallet state lives under **`~/.config/xo-cli/`** (XDG-style), so you can run commands from any directory: +Wallet state lives under **`${XO_CONFIG_DIR:-~/.config/xo-cli}`**, so you can run commands from any directory. Set `XO_CONFIG_DIR` to use a different wallet-state root. -| Path | Purpose | -| ----------------------------- | ----------------------------------------------------------------------- | -| `~/.config/xo-cli/mnemonics/` | Mnemonic files (`mnemonic-*`) | -| `~/.config/xo-cli/data/` | Engine DB (`xo-wallet.db`) and invitation storage (`xo-invitations.db`) | -| `~/.config/xo-cli/.wallet` | JSON settings (`default-mnemonic`, `currency`) | +| Path | Purpose | +| -------------------------- | ----------------------------------------------------------------------- | +| `$XO_CONFIG_DIR/mnemonics/` | Mnemonic files (`mnemonic-*`) | +| `$XO_CONFIG_DIR/data/` | Engine DB (`xo-wallet.db`) and invitation storage (`xo-invitations.db`) | +| `$XO_CONFIG_DIR/.wallet` | JSON settings (`default-mnemonic`, `currency`) | **Local to your shell’s current directory:** template JSON paths, invitation JSON you create/import, and any path you pass explicitly (e.g. `-m /abs/path/to/file`). @@ -39,21 +39,24 @@ npx tsx src/cli/index.ts [options] npx tsx src/index.ts # TUI ``` -### Environment variables (TUI / `xo-tui`) +### Environment variables | Variable | Default | | ------------------------- | ----------------------------------------- | +| `XO_CONFIG_DIR` | `~/.config/xo-cli` | | `SYNC_SERVER_URL` | `http://localhost:3000` | -| `DB_PATH` | `~/.config/xo-cli/data` | +| `DB_PATH` | `$XO_CONFIG_DIR/data` | | `DB_FILENAME` | `xo-wallet.db` | -| `INVITATION_STORAGE_PATH` | `~/.config/xo-cli/data/xo-invitations.db` | +| `INVITATION_STORAGE_PATH` | `$XO_CONFIG_DIR/data/xo-invitations.db` | + +Use an absolute path for a custom root. Setting `XO_CONFIG_DIR` does not copy state from the default directory. ## Getting Started ### Wallet Setup ```bash -# Generate a new mnemonic (saved under ~/.config/xo-cli/mnemonics/) +# Generate a new mnemonic (saved under $XO_CONFIG_DIR/mnemonics/) xo-cli mnemonic create # Import an existing mnemonic seed phrase @@ -68,7 +71,7 @@ xo-cli mnemonic list ### Wallet Persistence The first time you pass `-m `, that reference is saved as -`default-mnemonic` in `~/.config/xo-cli/.wallet`. Later runs can omit `-m`. +`default-mnemonic` in `$XO_CONFIG_DIR/.wallet`. Later runs can omit `-m`. `currency` controls the fiat unit used when showing BCH/sats conversions in the TUI. @@ -76,7 +79,7 @@ Mnemonic resolution order: 1. Absolute path, if the file exists 2. Path relative to the current working directory -3. `~/.config/xo-cli/mnemonics/` +3. `$XO_CONFIG_DIR/mnemonics/` ```bash xo-cli resource list -m mnemonic-nuclear @@ -93,7 +96,7 @@ xo-cli resource list | `-v`, `--verbose` | Verbose output | | `-h`, `--help` | Help | -Advanced: you can pass `--database-path`, `--database-filename`, and `--invitation-storage-path` to override the defaults under `~/.config/xo-cli/data/` (see `src/cli/index.ts`). +Advanced: you can pass `--database-path`, `--database-filename`, and `--invitation-storage-path` to override the defaults under `$XO_CONFIG_DIR/data/` (see `src/cli/index.ts`). ## Commands @@ -201,9 +204,11 @@ eval "$(xo-cli completions zsh)" xo-cli completions fish | source ``` +`xo-cli completions --install` adds a default `XO_CONFIG_DIR` assignment to the shell startup file if one is not already present. Mnemonic aliases are completed directly from `$XO_CONFIG_DIR/mnemonics/`; database-backed suggestions still use `xo-complete`. + ## File Conventions -| Location | Purpose | -| ------------------- | ------------------------------------------ | -| `~/.config/xo-cli/` | Global wallet state | -| `./` (cwd) | Templates, invitation JSON, explicit paths | +| Location | Purpose | +| ---------------- | ------------------------------------------ | +| `$XO_CONFIG_DIR` | Global wallet state | +| `./` (cwd) | Templates, invitation JSON, explicit paths | diff --git a/src/cli/autocomplete/completions.ts b/src/cli/autocomplete/completions.ts index 9b6b2c9..f71aa96 100644 --- a/src/cli/autocomplete/completions.ts +++ b/src/cli/autocomplete/completions.ts @@ -23,6 +23,7 @@ import { existsSync, readFileSync, appendFileSync, + mkdirSync, } from "node:fs"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; @@ -193,7 +194,7 @@ export function generateFishCompletions(binName: string): string { return loadAndProcessTemplate("fish.fish", binName); } -type ShellType = "bash" | "zsh" | "fish"; +export type ShellType = "bash" | "zsh" | "fish"; const generators: Record string> = { bash: generateBashCompletions, @@ -202,51 +203,74 @@ const generators: Record string> = { }; /** - * Shell config file paths and eval commands for each shell type. + * Shell config file paths and startup commands for each shell type. */ const shellConfigs: Record< ShellType, - { configFile: string; evalCommand: (binName: string) => string } + { + configFile: string; + configDirCommand: string; + configDirPattern: RegExp; + evalCommand: (binName: string) => string; + } > = { bash: { configFile: join(homedir(), ".bashrc"), + configDirCommand: 'export XO_CONFIG_DIR="${XO_CONFIG_DIR:-$HOME/.config/xo-cli}"', + configDirPattern: /^\s*(?:export\s+)?XO_CONFIG_DIR=/m, evalCommand: (binName) => `eval "$(${binName} completions bash)"`, }, zsh: { configFile: join(homedir(), ".zshrc"), + configDirCommand: 'export XO_CONFIG_DIR="${XO_CONFIG_DIR:-$HOME/.config/xo-cli}"', + configDirPattern: /^\s*(?:export\s+)?XO_CONFIG_DIR=/m, evalCommand: (binName) => `eval "$(${binName} completions zsh)"`, }, fish: { configFile: join(homedir(), ".config", "fish", "config.fish"), + configDirCommand: + 'set -q XO_CONFIG_DIR; or set -gx XO_CONFIG_DIR "$HOME/.config/xo-cli"', + configDirPattern: /^\s*set\b[^\n]*\bXO_CONFIG_DIR\b/m, evalCommand: (binName) => `${binName} completions fish | source`, }, }; /** * Installs completions to the user's shell config file. - * Adds the eval command if not already present. + * Adds a default config directory and the eval command if not already present. * @param shell - The shell type * @param binName - The CLI binary name * @returns true if installed, false if already present */ -function installCompletions(shell: ShellType, binName: string): boolean { - const config = shellConfigs[shell]; +export function installCompletions( + shell: ShellType, + binName: string, + configFile: string = shellConfigs[shell].configFile, +): boolean { + const config = { ...shellConfigs[shell], configFile }; const evalCommand = config.evalCommand(binName); - // Check if config file exists and already has the completion line let existingContent = ""; if (existsSync(config.configFile)) { existingContent = readFileSync(config.configFile, "utf8"); - if (existingContent.includes(evalCommand)) { - return false; // Already installed - } } - // Append the completion line + const commands: string[] = []; + if (!config.configDirPattern.test(existingContent)) { + commands.push(config.configDirCommand); + } + if (!existingContent.includes(evalCommand)) { + commands.push(evalCommand); + } + if (commands.length === 0) { + return false; + } + const newLine = existingContent.endsWith("\n") || existingContent === "" ? "" : "\n"; - const completionBlock = `${newLine}\n# ${binName} shell completions\n${evalCommand}\n`; + const completionBlock = `${newLine}\n# ${binName} shell completions\n${commands.join("\n")}\n`; + mkdirSync(dirname(config.configFile), { recursive: true }); appendFileSync(config.configFile, completionBlock); return true; } diff --git a/src/cli/autocomplete/scripts/bash.sh b/src/cli/autocomplete/scripts/bash.sh index 7089dae..5e7b960 100644 --- a/src/cli/autocomplete/scripts/bash.sh +++ b/src/cli/autocomplete/scripts/bash.sh @@ -26,6 +26,19 @@ __xo_complete() { [[ -n "${__xo_complete_bin}" ]] && "${__xo_complete_bin}" "$@" 2>/dev/null } +# @description +# Lists mnemonic aliases directly from the config directory without starting +# the dynamic Node helper. +__xo_complete_mnemonics() { + local config_dir="${XO_CONFIG_DIR:-${HOME}/.config/xo-cli}" + local file mnemonic + for file in "${config_dir}"/mnemonics/mnemonic-*; do + [[ -f "${file}" ]] || continue + mnemonic="${file##*/}" + [[ "${mnemonic}" == "$1"* ]] && printf '%s\n' "${mnemonic}" + done +} + # @description # Main completion dispatcher invoked by bash's `complete -F`. # It determines context (command/subcommand/argument position) and then mixes: @@ -39,10 +52,10 @@ _{{FUNC_NAME}}_completions() { _init_completion || return # If the previous token is `-m/--mnemonic-file`, this argument expects a - # mnemonic file alias/path. Ask the helper for mnemonic suggestions. + # mnemonic file alias/path. List mnemonic aliases directly from disk. if [[ "${prev}" == "-m" || "${prev}" == "--mnemonic-file" ]]; then local mnemonics - mnemonics=$(__xo_complete mnemonics "${cur}") + mnemonics=$(__xo_complete_mnemonics "${cur}") if [[ -n "${mnemonics}" ]]; then while IFS= read -r line; do COMPREPLY+=("$line") diff --git a/src/cli/autocomplete/scripts/fish.fish b/src/cli/autocomplete/scripts/fish.fish index 5320c4f..3375671 100644 --- a/src/cli/autocomplete/scripts/fish.fish +++ b/src/cli/autocomplete/scripts/fish.fish @@ -28,6 +28,21 @@ function __{{FUNC_NAME}}_complete_dynamic end end +# @description +# Lists mnemonic aliases directly from the config directory without starting +# the dynamic Node helper. +function __{{FUNC_NAME}}_complete_mnemonics + set -l config_dir "$XO_CONFIG_DIR" + if test -z "$config_dir" + set config_dir "$HOME/.config/xo-cli" + end + for file in $config_dir/mnemonics/mnemonic-* + if test -f "$file" + string replace -r '.*/' '' "$file" + end + end +end + # Global option flags available across top-level command contexts. complete -c {{BIN_NAME}} -s h -d "Show help" complete -c {{BIN_NAME}} -l help -d "Show help" @@ -37,8 +52,8 @@ complete -c {{BIN_NAME}} -s o -d "Output file" complete -c {{BIN_NAME}} -l output -d "Output file" complete -c {{BIN_NAME}} -l currency -d "Set fiat display currency" -# Dynamic completion for `-m/--mnemonic-file`. -complete -c {{BIN_NAME}} -s m -l mnemonic-file -xa '(__{{FUNC_NAME}}_complete_dynamic mnemonics)' +# Shell-native completion for `-m/--mnemonic-file`. +complete -c {{BIN_NAME}} -s m -l mnemonic-file -xa '(__{{FUNC_NAME}}_complete_mnemonics)' # Top-level command registrations inserted by template expansion. {{TOP_LEVEL_COMMANDS}} diff --git a/src/cli/autocomplete/scripts/zsh.zsh b/src/cli/autocomplete/scripts/zsh.zsh index 1853a97..0f659e7 100644 --- a/src/cli/autocomplete/scripts/zsh.zsh +++ b/src/cli/autocomplete/scripts/zsh.zsh @@ -25,6 +25,19 @@ __xo_complete() { [[ -n "${__xo_complete_bin}" ]] && "${__xo_complete_bin}" "$@" 2>/dev/null } +# @description +# Lists mnemonic aliases directly from the config directory without starting +# the dynamic Node helper. +__xo_complete_mnemonics() { + local config_dir="${XO_CONFIG_DIR:-${HOME}/.config/xo-cli}" + local file mnemonic + for file in "${config_dir}"/mnemonics/mnemonic-*(N); do + [[ -f "${file}" ]] || continue + mnemonic="${file:t}" + [[ "${mnemonic}" == "$1"* ]] && print -r -- "${mnemonic}" + done +} + # @description # Main zsh completion dispatcher registered via `compdef`. # It resolves command context from `$words`/`$CURRENT` and serves: @@ -38,7 +51,7 @@ _{{FUNC_NAME}}_completions() { # If previous token is `-m/--mnemonic-file`, complete mnemonic sources. if [[ "${words[CURRENT-1]}" == "-m" || "${words[CURRENT-1]}" == "--mnemonic-file" ]]; then local mnemonics - mnemonics=("${(@f)$(__xo_complete mnemonics "${words[CURRENT]}")}") + mnemonics=("${(@f)$(__xo_complete_mnemonics "${words[CURRENT]}")}") if [[ ${#mnemonics[@]} -gt 0 ]]; then compadd -- "${mnemonics[@]}" return diff --git a/src/services/settings.ts b/src/services/settings.ts index 19c3523..ed8fa5e 100644 --- a/src/services/settings.ts +++ b/src/services/settings.ts @@ -32,7 +32,7 @@ const DEFAULT_SETTINGS: SettingsData = { /** * Handles loading, migrating, and persisting wallet settings. * - * The backing file is `~/.config/xo-cli/.wallet`. Historically it stored a raw + * The backing file is `/.wallet`. Historically it stored a raw * mnemonic reference string. This service migrates that legacy format to JSON: * `{ "default-mnemonic": "", "currency": "USD" }`. */ @@ -191,4 +191,4 @@ export class SettingsService extends EventEmitter { } return normalizedCurrency; } -} \ No newline at end of file +} diff --git a/src/tui/screens/SeedInput.tsx b/src/tui/screens/SeedInput.tsx index c83755e..29a1575 100644 --- a/src/tui/screens/SeedInput.tsx +++ b/src/tui/screens/SeedInput.tsx @@ -44,7 +44,7 @@ interface MnemonicFileEntry { type FocusSection = 'files' | 'input' | 'saveCheckbox' | 'generateRandomSeed' | 'button'; /** - * Reads mnemonic-* files from ~/.config/xo-cli/mnemonics/ (same as xo-cli), + * Reads mnemonic-* files from the configured mnemonics directory (same as xo-cli), * then from cwd for legacy installs. Parses each as a BCHMnemonicURL. */ function loadMnemonicFiles(): MnemonicFileEntry[] { @@ -101,7 +101,7 @@ export function SeedInputScreen(): React.ReactElement { const [mnemonicFiles, setMnemonicFiles] = useState([]); const [selectedFileIndex, setSelectedFileIndex] = useState(0); - /** When set, manual seed is written to ~/.config/xo-cli/mnemonics/ after a successful unlock. */ + /** When set, manual seed is written to the configured mnemonics directory after a successful unlock. */ const [saveMnemonicChecked, setSaveMnemonicChecked] = useState(false); // Focus: when saved wallets exist default to the file list, otherwise the input. @@ -397,7 +397,7 @@ export function SeedInputScreen(): React.ReactElement { {saveMnemonicChecked ? '[x] ' : '[ ] '} Save this mnemonic - (~/.config/xo-cli/mnemonics/) + ({getMnemonicsDir()}/) {focusedSection === 'saveCheckbox' && ( diff --git a/src/utils/paths.ts b/src/utils/paths.ts index b5d25b4..549fee2 100644 --- a/src/utils/paths.ts +++ b/src/utils/paths.ts @@ -1,5 +1,5 @@ /** - * Global XO CLI config layout (XDG-style: ~/.config/xo-cli/). + * Global XO CLI config layout (`XO_CONFIG_DIR` or ~/.config/xo-cli/). * User-provided paths (templates, invitation JSON) stay relative to cwd. */ @@ -11,7 +11,7 @@ import { basename, isAbsolute, join, resolve } from "node:path"; * Base config directory. Created on first access. */ export function getConfigDir(): string { - const dir = join(homedir(), ".config", "xo-cli"); + const dir = process.env["XO_CONFIG_DIR"] || join(homedir(), ".config", "xo-cli"); mkdirSync(dir, { recursive: true }); return dir; } @@ -50,7 +50,7 @@ export function getWalletConfigPath(): string { /** * Resolves a mnemonic reference to an absolute path. - * Order: absolute path if it exists → path relative to cwd → ~/.config/xo-cli/mnemonics/. + * Order: absolute path if it exists → path relative to cwd → config mnemonics directory/. * * @param mnemonicRef - Path or basename (e.g. `mnemonic-nuclear`) * @returns Absolute path to the mnemonic file diff --git a/tests/cli/autocomplete-completions.test.ts b/tests/cli/autocomplete-completions.test.ts new file mode 100644 index 0000000..b74fe44 --- /dev/null +++ b/tests/cli/autocomplete-completions.test.ts @@ -0,0 +1,112 @@ +import { afterEach, describe, expect, test } from "vitest"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { + generateBashCompletions, + generateFishCompletions, + generateZshCompletions, + installCompletions, +} from "../../src/cli/autocomplete/completions"; + +describe("shell completions", () => { + const tempDirs: string[] = []; + + afterEach(() => { + for (const tempDir of tempDirs) { + rmSync(tempDir, { recursive: true, force: true }); + } + tempDirs.length = 0; + }); + + function createConfigFile(contents = ""): string { + const tempDir = mkdtempSync(join(tmpdir(), "xo-cli-completions-test-")); + tempDirs.push(tempDir); + const configFile = join(tempDir, "shellrc"); + writeFileSync(configFile, contents); + return configFile; + } + + test("uses shell-native mnemonic completion in bash", () => { + const completions = generateBashCompletions("xo-cli"); + + expect(completions).toContain( + 'local config_dir="${XO_CONFIG_DIR:-${HOME}/.config/xo-cli}"', + ); + expect(completions).toContain('__xo_complete_mnemonics "${cur}"'); + expect(completions).not.toContain('__xo_complete mnemonics "${cur}"'); + }); + + test("uses shell-native mnemonic completion in zsh", () => { + const completions = generateZshCompletions("xo-cli"); + + expect(completions).toContain( + 'local config_dir="${XO_CONFIG_DIR:-${HOME}/.config/xo-cli}"', + ); + expect(completions).toContain( + '__xo_complete_mnemonics "${words[CURRENT]}"', + ); + expect(completions).not.toContain( + '__xo_complete mnemonics "${words[CURRENT]}"', + ); + }); + + test("uses shell-native mnemonic completion in fish", () => { + const completions = generateFishCompletions("xo-cli"); + + expect(completions).toContain("set -l config_dir \"$XO_CONFIG_DIR\""); + expect(completions).toContain("(__xo_cli_complete_mnemonics)"); + expect(completions).not.toContain("(__xo_cli_complete_dynamic mnemonics)"); + }); + + test("installs the config default and completion loader once", () => { + const configFile = createConfigFile(); + + expect(installCompletions("bash", "xo-cli", configFile)).toBe(true); + expect(installCompletions("bash", "xo-cli", configFile)).toBe(false); + + const contents = readFileSync(configFile, "utf8"); + expect(contents.match(/XO_CONFIG_DIR/g)).toHaveLength(2); + expect(contents.match(/eval "\$\(xo-cli completions bash\)"/g)).toHaveLength( + 1, + ); + }); + + test("adds a missing default without duplicating an existing loader", () => { + const configFile = createConfigFile('eval "$(xo-cli completions bash)"\n'); + + expect(installCompletions("bash", "xo-cli", configFile)).toBe(true); + + const contents = readFileSync(configFile, "utf8"); + expect(contents.match(/eval "\$\(xo-cli completions bash\)"/g)).toHaveLength( + 1, + ); + expect(contents).toContain( + 'export XO_CONFIG_DIR="${XO_CONFIG_DIR:-$HOME/.config/xo-cli}"', + ); + }); + + test("preserves an existing custom config directory assignment", () => { + const configFile = createConfigFile("export XO_CONFIG_DIR=/tmp/custom-xo\n"); + + expect(installCompletions("zsh", "xo-cli", configFile)).toBe(true); + + const contents = readFileSync(configFile, "utf8"); + expect(contents).toContain("export XO_CONFIG_DIR=/tmp/custom-xo"); + expect(contents).not.toContain("${XO_CONFIG_DIR:-$HOME/.config/xo-cli}"); + expect(contents).toContain('eval "$(xo-cli completions zsh)"'); + }); + + test("uses fish syntax when installing fish completions", () => { + const configFile = createConfigFile(); + + expect(installCompletions("fish", "xo-cli", configFile)).toBe(true); + + const contents = readFileSync(configFile, "utf8"); + expect(contents).toContain( + 'set -q XO_CONFIG_DIR; or set -gx XO_CONFIG_DIR "$HOME/.config/xo-cli"', + ); + expect(contents).toContain("xo-cli completions fish | source"); + }); +}); diff --git a/tests/cli/paths.test.ts b/tests/cli/paths.test.ts index b63351c..848a9d7 100644 --- a/tests/cli/paths.test.ts +++ b/tests/cli/paths.test.ts @@ -12,6 +12,20 @@ import { } from "../../src/utils/paths"; describe("paths utilities", () => { + const originalConfigDir = process.env["XO_CONFIG_DIR"]; + + beforeEach(() => { + delete process.env["XO_CONFIG_DIR"]; + }); + + afterEach(() => { + if (originalConfigDir === undefined) { + delete process.env["XO_CONFIG_DIR"]; + } else { + process.env["XO_CONFIG_DIR"] = originalConfigDir; + } + }); + describe("getConfigDir", () => { test("returns path under ~/.config/xo-cli", () => { const configDir = getConfigDir(); @@ -24,6 +38,26 @@ describe("paths utilities", () => { expect(existsSync(configDir)).toBe(true); }); + + test("uses XO_CONFIG_DIR when configured", () => { + const customDir = path.join(tmpdir(), `xo-cli-config-test-${Date.now()}`); + process.env["XO_CONFIG_DIR"] = customDir; + + try { + expect(getConfigDir()).toBe(customDir); + expect(getMnemonicsDir()).toBe(path.join(customDir, "mnemonics")); + expect(getDataDir()).toBe(path.join(customDir, "data")); + expect(getWalletConfigPath()).toBe(path.join(customDir, ".wallet")); + } finally { + rmSync(customDir, { recursive: true, force: true }); + } + }); + + test("uses the default when XO_CONFIG_DIR is empty", () => { + process.env["XO_CONFIG_DIR"] = ""; + + expect(getConfigDir()).toBe(path.join(homedir(), ".config", "xo-cli")); + }); }); describe("getMnemonicsDir", () => { @@ -106,6 +140,7 @@ describe("paths utilities", () => { }); test("resolves from global mnemonics dir when file exists there", () => { + process.env["XO_CONFIG_DIR"] = tempDir; const mnemonicsDir = getMnemonicsDir(); const testFile = path.join(mnemonicsDir, "mnemonic-global-test"); -- 2.49.1 From c7e1d69e2d4cc2b8feeaac847fc16588fcb31064 Mon Sep 17 00:00:00 2001 From: Harvey Zuccon Date: Mon, 1 Jun 2026 12:36:55 +0200 Subject: [PATCH 15/22] Formatting --- readme.md | 6 + src/cli/README.md | 28 +- src/cli/autocomplete/completions.ts | 13 +- src/cli/commands/invitation.ts | 16 +- src/cli/commands/resource.ts | 8 +- src/cli/commands/settings.ts | 2 +- src/cli/commands/template.ts | 5 +- src/cli/index.ts | 22 +- src/services/app.ts | 23 +- src/services/history.ts | 178 +- src/services/invitation.ts | 94 +- src/services/rates.ts | 30 +- src/services/settings.ts | 4 +- src/services/storage.ts | 2 +- src/templates/vending-machine.ts | 514 ++-- src/templates/wrap-template.ts | 452 ++-- src/tui/utils/clipboard.ts | 38 +- src/tui/utils/list-directory-entries.ts | 3 +- src/utils/history-utils.ts | 4 +- src/utils/invitation-flow.ts | 18 +- src/utils/load-template-from-file.ts | 15 +- src/utils/paths.ts | 3 +- src/utils/pick-template-export.ts | 4 +- src/utils/rates/base-rates.ts | 13 +- src/utils/rates/rates-oracles.ts | 40 +- src/utils/template-module-loader.ts | 3 +- src/utils/template-utils.ts | 6 +- src/utils/utxo-metadata.ts | 3 +- tests/cli/autocomplete-completions.test.ts | 18 +- tests/cli/commands/settings.test.ts | 4 +- tests/cli/commands/template.test.ts | 2 +- tests/cli/mnemonic.test.ts | 4 +- tests/cli/mocks/engine.ts | 4 +- tests/cli/mocks/rates-service.ts | 7 +- tests/cli/mocks/template-p2pkh.ts | 2512 ++++++++++---------- tests/cli/paths.test.ts | 12 +- tests/tui/format-dialog-message.test.ts | 2 +- 37 files changed, 2187 insertions(+), 1925 deletions(-) diff --git a/readme.md b/readme.md index f122c03..52545e4 100644 --- a/readme.md +++ b/readme.md @@ -3,6 +3,7 @@ ## Installation ### Full Installation + ```bash # Create a new directory since we are going to be pulling in engine too mkdir xo-terminal && cd xo-terminal @@ -131,27 +132,32 @@ These commands add `XO_CONFIG_DIR` to your shell config with a default of generated assignment, to use a different wallet-state directory. #### Install for bash + ```bash npm run autocomplete:install:bash ``` #### Install for zsh + ```bash npm run autocomplete:install:zsh ``` #### Install for fish + ```bash npm run autocomplete:install:fish ``` ### Run the CLI + ```bash # If globally installed (Not really usable if not globally installed) xo-cli ``` ### Run the TUI + ```bash # If globally installed xo-tui diff --git a/src/cli/README.md b/src/cli/README.md index c320455..81abd1e 100644 --- a/src/cli/README.md +++ b/src/cli/README.md @@ -11,8 +11,8 @@ There are two global commands after install: Wallet state lives under **`${XO_CONFIG_DIR:-~/.config/xo-cli}`**, so you can run commands from any directory. Set `XO_CONFIG_DIR` to use a different wallet-state root. -| Path | Purpose | -| -------------------------- | ----------------------------------------------------------------------- | +| Path | Purpose | +| --------------------------- | ----------------------------------------------------------------------- | | `$XO_CONFIG_DIR/mnemonics/` | Mnemonic files (`mnemonic-*`) | | `$XO_CONFIG_DIR/data/` | Engine DB (`xo-wallet.db`) and invitation storage (`xo-invitations.db`) | | `$XO_CONFIG_DIR/.wallet` | JSON settings (`default-mnemonic`, `currency`) | @@ -41,13 +41,13 @@ npx tsx src/index.ts # TUI ### Environment variables -| Variable | Default | -| ------------------------- | ----------------------------------------- | -| `XO_CONFIG_DIR` | `~/.config/xo-cli` | -| `SYNC_SERVER_URL` | `http://localhost:3000` | -| `DB_PATH` | `$XO_CONFIG_DIR/data` | -| `DB_FILENAME` | `xo-wallet.db` | -| `INVITATION_STORAGE_PATH` | `$XO_CONFIG_DIR/data/xo-invitations.db` | +| Variable | Default | +| ------------------------- | --------------------------------------- | +| `XO_CONFIG_DIR` | `~/.config/xo-cli` | +| `SYNC_SERVER_URL` | `http://localhost:3000` | +| `DB_PATH` | `$XO_CONFIG_DIR/data` | +| `DB_FILENAME` | `xo-wallet.db` | +| `INVITATION_STORAGE_PATH` | `$XO_CONFIG_DIR/data/xo-invitations.db` | Use an absolute path for a custom root. Setting `XO_CONFIG_DIR` does not copy state from the default directory. @@ -88,13 +88,13 @@ xo-cli resource list ## Global Options (`xo-cli`) -| Flag | Description | -| ------------------------------ | --------------------------------------------------- | -| `-m`, `--mnemonic-file ` | Mnemonic file (basename, cwd-relative, or absolute) | +| Flag | Description | +| ------------------------------ | ---------------------------------------------------- | +| `-m`, `--mnemonic-file ` | Mnemonic file (basename, cwd-relative, or absolute) | | `--currency ` | Fiat display currency (e.g. `USD`, `AUD`) | | `-o`, `--output ` | Output filename (used by `mnemonic create`/`import`) | -| `-v`, `--verbose` | Verbose output | -| `-h`, `--help` | Help | +| `-v`, `--verbose` | Verbose output | +| `-h`, `--help` | Help | Advanced: you can pass `--database-path`, `--database-filename`, and `--invitation-storage-path` to override the defaults under `$XO_CONFIG_DIR/data/` (see `src/cli/index.ts`). diff --git a/src/cli/autocomplete/completions.ts b/src/cli/autocomplete/completions.ts index f71aa96..27e593d 100644 --- a/src/cli/autocomplete/completions.ts +++ b/src/cli/autocomplete/completions.ts @@ -19,12 +19,7 @@ * xo-cli completions fish --install */ -import { - existsSync, - readFileSync, - appendFileSync, - mkdirSync, -} from "node:fs"; +import { existsSync, readFileSync, appendFileSync, mkdirSync } from "node:fs"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; import { homedir } from "node:os"; @@ -216,13 +211,15 @@ const shellConfigs: Record< > = { bash: { configFile: join(homedir(), ".bashrc"), - configDirCommand: 'export XO_CONFIG_DIR="${XO_CONFIG_DIR:-$HOME/.config/xo-cli}"', + configDirCommand: + 'export XO_CONFIG_DIR="${XO_CONFIG_DIR:-$HOME/.config/xo-cli}"', configDirPattern: /^\s*(?:export\s+)?XO_CONFIG_DIR=/m, evalCommand: (binName) => `eval "$(${binName} completions bash)"`, }, zsh: { configFile: join(homedir(), ".zshrc"), - configDirCommand: 'export XO_CONFIG_DIR="${XO_CONFIG_DIR:-$HOME/.config/xo-cli}"', + configDirCommand: + 'export XO_CONFIG_DIR="${XO_CONFIG_DIR:-$HOME/.config/xo-cli}"', configDirPattern: /^\s*(?:export\s+)?XO_CONFIG_DIR=/m, evalCommand: (binName) => `eval "$(${binName} completions zsh)"`, }, diff --git a/src/cli/commands/invitation.ts b/src/cli/commands/invitation.ts index 48c9df1..8c21461 100644 --- a/src/cli/commands/invitation.ts +++ b/src/cli/commands/invitation.ts @@ -23,7 +23,10 @@ const DUST_THRESHOLD = 546n; /** * Serializes an invitation to pretty-printed JSON for file export. */ -const formatInvitationForFile = (invitation: XOInvitation, indent = 2): string => +const formatInvitationForFile = ( + invitation: XOInvitation, + indent = 2, +): string => JSON.stringify(JSON.parse(serializeInvitation(invitation)), null, indent); /** @@ -358,8 +361,7 @@ export const handleInvitationExportCommand = async ( } const invitation = deps.app.invitations.find( - (candidate) => - candidate.data.invitationIdentifier === invitationIdentifier, + (candidate) => candidate.data.invitationIdentifier === invitationIdentifier, ); if (!invitation) { @@ -499,7 +501,9 @@ export const handleInvitationCommand = async ( hasMissingRequirements(missingRequirements.templateRequirements) || missingRequirements.inputsMissingSignatures.length > 0; - deps.io.verbose(`Missing requirements: ${formatObject(missingRequirements)}`); + deps.io.verbose( + `Missing requirements: ${formatObject(missingRequirements)}`, + ); deps.io.verbose(`Has missing requirements: ${hasMissing}`); // If there are missing requirements, print them out @@ -693,7 +697,7 @@ export const handleInvitationCommand = async ( // Return the invitation identifier return { invitationIdentifier }; } - + case "broadcast": { // Get the invitation identifier from the arguments const invitationIdentifier = args[1]; @@ -940,7 +944,7 @@ export const handleInvitationCommand = async ( deps.io.verbose( `Invitation created: ${formatObject(invitationInstance.data)}`, ); - + // Return the invitation identifier return { invitationIdentifier: invitationInstance.data.invitationIdentifier, diff --git a/src/cli/commands/resource.ts b/src/cli/commands/resource.ts index 0058a1f..d6dfdbe 100644 --- a/src/cli/commands/resource.ts +++ b/src/cli/commands/resource.ts @@ -37,7 +37,9 @@ function formatResource( showReserved = false, ): string { // Format the template - const template = resource.template ? dim(`[${generateTemplateIdentifier(resource.template)}]`) : ""; + const template = resource.template + ? dim(`[${generateTemplateIdentifier(resource.template)}]`) + : ""; // Format the outpoint const outpoint = bold( @@ -51,7 +53,7 @@ function formatResource( const output = resource.outputIdentifier ? dim(resource.outputIdentifier) : ""; - + // Format the height const height = dim(`(height ${resource.minedAtHeight})`); @@ -233,7 +235,7 @@ export const handleResourceCommand = async ( deps.io.out( `Unreserved ${bold(`${txHash}:${vout}`)} (was reserved for ${target.reservedBy})`, ); - + // TODO: What do I want to return here? return {}; } diff --git a/src/cli/commands/settings.ts b/src/cli/commands/settings.ts index 0b4fbff..2ae0b12 100644 --- a/src/cli/commands/settings.ts +++ b/src/cli/commands/settings.ts @@ -83,7 +83,7 @@ export const handleSettingsCommand = async ( const value = key === "currency" ? settings.getCurrency() - : settings.getDefaultMnemonic() ?? ""; + : (settings.getDefaultMnemonic() ?? ""); deps.io.out(value); return { key, value }; } diff --git a/src/cli/commands/template.ts b/src/cli/commands/template.ts index 964662d..e57b61c 100644 --- a/src/cli/commands/template.ts +++ b/src/cli/commands/template.ts @@ -4,7 +4,10 @@ import { generateTemplateIdentifier } from "@xo-cash/engine"; import type { XOTemplate } from "@xo-cash/types"; import { bold, dim, formatObject } from "../utils.js"; -import { loadTemplateFromFile, TemplateLoadError } from "../../utils/load-template-from-file.js"; +import { + loadTemplateFromFile, + TemplateLoadError, +} from "../../utils/load-template-from-file.js"; import { resolveTemplateReferences } from "../../utils/templates.js"; import type { CommandDependencies, CommandIO } from "./types.js"; import { CommandError } from "./types.js"; diff --git a/src/cli/index.ts b/src/cli/index.ts index ce272ef..bef5b3f 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -181,16 +181,20 @@ async function main(): Promise { // Create an App instance io.verbose("Creating app instance..."); - const app = await AppService.create(mnemonic, { - syncServerUrl: options["syncServerUrl"] ?? "http://localhost:3000", - engineConfig: { - databasePath: options["databasePath"] ?? paths.dataDir, - databaseFilename: options["databaseFilename"] ?? "xo-wallet.db", + const app = await AppService.create( + mnemonic, + { + syncServerUrl: options["syncServerUrl"] ?? "http://localhost:3000", + engineConfig: { + databasePath: options["databasePath"] ?? paths.dataDir, + databaseFilename: options["databaseFilename"] ?? "xo-wallet.db", + }, + invitationStoragePath: + options["invitationStoragePath"] ?? + join(paths.dataDir, "xo-invitations.db"), }, - invitationStoragePath: - options["invitationStoragePath"] ?? - join(paths.dataDir, "xo-invitations.db"), - }, settings); + settings, + ); io.verbose("App instance created"); // Start the app diff --git a/src/services/app.ts b/src/services/app.ts index febfc1f..f175a1b 100644 --- a/src/services/app.ts +++ b/src/services/app.ts @@ -100,8 +100,12 @@ export class AppService extends EventEmitter { const templates = await engine.listImportedTemplates(); templates.forEach(async (template) => { - engine.updateUnspentOutputsForTemplate(generateTemplateIdentifier(template)); - engine.subscribeToScriptHashForTemplate(generateTemplateIdentifier(template)); + engine.updateUnspentOutputsForTemplate( + generateTemplateIdentifier(template), + ); + engine.subscribeToScriptHashForTemplate( + generateTemplateIdentifier(template), + ); }); }; @@ -127,7 +131,14 @@ export class AppService extends EventEmitter { }); const rates = await RatesService.create(settings); - return new AppService(engine, walletStorage, config, electrum, rates, settings); + return new AppService( + engine, + walletStorage, + config, + electrum, + rates, + settings, + ); } constructor( @@ -298,9 +309,9 @@ export class AppService extends EventEmitter { async start(): Promise { // Start rates in the background so BCH -> fiat conversions become reactive in the TUI. - this.rates.start().catch((err) => - console.error('Error starting rates service:', err), - ); + this.rates + .start() + .catch((err) => console.error("Error starting rates service:", err)); // Get the invitations db const invitationsDb = this.storage.child("invitations"); diff --git a/src/services/history.ts b/src/services/history.ts index 3cd09e4..10b3367 100644 --- a/src/services/history.ts +++ b/src/services/history.ts @@ -80,7 +80,7 @@ interface WalletMetadataIndex { * I've tried to fundamental approaches so far: * - UTXO first * - Invitation first - * + * * The issue is that neither of these end up being simple or effective * UTXO first makes tracking utxos across invitations extremely difficult. So if you receive a UTXO from an invitation and then spend it on another, you wont even see that old invitation. * Invitation first makes fitting UTXOs that dont have an invitation (say if someone sent directly to your address) extremely difficult. You end up having to run a UTXO first pass anyway, and then end up with conflicts around resolved roles. @@ -95,7 +95,6 @@ export class HistoryService { private invitations: Invitation[], ) {} - /** * I Might swap this over to invitation based history before the event to make it a bit more evident... Really not happy with the UTXO for demo purposes * But for the actual usage, UTXO is easier to follow - just not good for demo @@ -114,7 +113,10 @@ export class HistoryService { for (const context of utxoContexts) { const invitationIdentifier = context.utxo.reservedBy; - if (invitationIdentifier && invitationContexts.has(invitationIdentifier)) { + if ( + invitationIdentifier && + invitationContexts.has(invitationIdentifier) + ) { const group = reservedUtxosByInvitation.get(invitationIdentifier) ?? []; group.push(context); reservedUtxosByInvitation.set(invitationIdentifier, group); @@ -141,13 +143,15 @@ export class HistoryService { }); } - private async buildInvitationContextIndex(): Promise> { + private async buildInvitationContextIndex(): Promise< + Map + > { const contexts = new Map(); for (const invitation of this.invitations) { const templateIdentifier = invitation.data.templateIdentifier; const template = templateIdentifier - ? (await this.engine.getTemplate(templateIdentifier)) ?? null + ? ((await this.engine.getTemplate(templateIdentifier)) ?? null) : null; contexts.set(invitation.data.invitationIdentifier, { invitation, @@ -181,9 +185,13 @@ export class HistoryService { } for (const templateIdentifier of templateIdentifiers) { - const scriptHashDataList = await this.engine.listScriptHashesForTemplate(templateIdentifier); + const scriptHashDataList = + await this.engine.listScriptHashesForTemplate(templateIdentifier); for (const scriptHashData of scriptHashDataList) { - scriptHashDataByScriptHash.set(scriptHashData.scriptHash, scriptHashData); + scriptHashDataByScriptHash.set( + scriptHashData.scriptHash, + scriptHashData, + ); } } @@ -194,10 +202,12 @@ export class HistoryService { utxo: UnspentOutputData, metadataIndex: WalletMetadataIndex, ): Promise { - const scriptHashData = metadataIndex.scriptHashDataByScriptHash.get(utxo.scriptHash); + const scriptHashData = metadataIndex.scriptHashDataByScriptHash.get( + utxo.scriptHash, + ); const templateIdentifier = scriptHashData?.templateIdentifier; const template = templateIdentifier - ? (await this.engine.getTemplate(templateIdentifier)) ?? null + ? ((await this.engine.getTemplate(templateIdentifier)) ?? null) : null; return { @@ -213,8 +223,15 @@ export class HistoryService { ): WalletHistoryItem { const invitation = context.invitation.data; const entityRoles = this.deriveInvitationEntityRoles(context); - const inputs = this.projectInvitationInputs(context, reservedContexts, entityRoles); - const inputUtxoIds = this.listInvitationInputUtxoIds(context, reservedContexts); + const inputs = this.projectInvitationInputs( + context, + reservedContexts, + entityRoles, + ); + const inputUtxoIds = this.listInvitationInputUtxoIds( + context, + reservedContexts, + ); const outputs = this.projectInvitationOutputs( context, reservedContexts, @@ -263,7 +280,9 @@ export class HistoryService { const outpointIndex = input.outpointIndex; if (txid === undefined || outpointIndex === undefined) continue; - const utxoContext = reservedByOutpoint.get(this.getOutpointKey(txid, outpointIndex)); + const utxoContext = reservedByOutpoint.get( + this.getOutpointKey(txid, outpointIndex), + ); // TODO: Remove this reservation-based filter once Engine/library cleanup releases stale invitation reservations internally. if (!utxoContext) continue; @@ -309,15 +328,20 @@ export class HistoryService { // UTXO-first: committed outputs only matter here if they resolve to a wallet UTXO currently reserved by this invitation. if (!matchingContext) continue; - const lockingBytecode = this.getOutputLockingBytecodeHex(output) ?? matchingContext.scriptHashData?.lockingBytecode; - const outputIdentifier = output.outputIdentifier ?? matchingContext.scriptHashData?.outputIdentifier; + const lockingBytecode = + this.getOutputLockingBytecodeHex(output) ?? + matchingContext.scriptHashData?.lockingBytecode; + const outputIdentifier = + output.outputIdentifier ?? + matchingContext.scriptHashData?.outputIdentifier; const role = output.roleIdentifier ?? this.getFirstEntityRole(entityRoles, commit.entityIdentifier) ?? matchingContext.scriptHashData?.roleIdentifier; - const valueSatoshis = output.valueSatoshis !== undefined - ? BigInt(output.valueSatoshis) - : BigInt(matchingContext.utxo.valueSatoshis); + const valueSatoshis = + output.valueSatoshis !== undefined + ? BigInt(output.valueSatoshis) + : BigInt(matchingContext.utxo.valueSatoshis); usedUtxoIds.add(this.getUtxoId(matchingContext.utxo)); @@ -369,8 +393,11 @@ export class HistoryService { const outpointIndex = input.outpointIndex; if (txid === undefined || outpointIndex === undefined) continue; - const utxoContext = reservedByOutpoint.get(this.getOutpointKey(txid, outpointIndex)); - if (utxoContext) invitationInputUtxoIds.add(this.getUtxoId(utxoContext.utxo)); + const utxoContext = reservedByOutpoint.get( + this.getOutpointKey(txid, outpointIndex), + ); + if (utxoContext) + invitationInputUtxoIds.add(this.getUtxoId(utxoContext.utxo)); } } @@ -390,9 +417,17 @@ export class HistoryService { return reservedContexts.find((context) => { if (usedUtxoIds.has(this.getUtxoId(context.utxo))) return false; if (scriptHash && context.utxo.scriptHash === scriptHash) return true; - if (lockingBytecode && context.scriptHashData?.lockingBytecode === lockingBytecode) return true; + if ( + lockingBytecode && + context.scriptHashData?.lockingBytecode === lockingBytecode + ) + return true; - if (output.outputIdentifier && context.scriptHashData?.outputIdentifier === output.outputIdentifier) return true; + if ( + output.outputIdentifier && + context.scriptHashData?.outputIdentifier === output.outputIdentifier + ) + return true; return false; }); } @@ -423,7 +458,11 @@ export class HistoryService { id: this.getUtxoId(context.utxo), outputIdentifier, role, - description: this.describeOutputFromTemplate(outputIdentifier, context.template, {}), + description: this.describeOutputFromTemplate( + outputIdentifier, + context.template, + {}, + ), valueSatoshis: BigInt(context.utxo.valueSatoshis), outpoint: { txid: context.utxo.outpointTransactionHash, @@ -435,17 +474,22 @@ export class HistoryService { }; } - private deriveInvitationEntityRoles(context: InvitationContext): Map { + private deriveInvitationEntityRoles( + context: InvitationContext, + ): Map { const invitation = context.invitation.data; const rolesByEntity = new Map>(); - const allEntities = new Set(invitation.commits.map((commit) => commit.entityIdentifier)); + const allEntities = new Set( + invitation.commits.map((commit) => commit.entityIdentifier), + ); for (const entityIdentifier of allEntities) { rolesByEntity.set(entityIdentifier, new Set()); } for (const commit of invitation.commits) { - const roles = rolesByEntity.get(commit.entityIdentifier) ?? new Set(); + const roles = + rolesByEntity.get(commit.entityIdentifier) ?? new Set(); for (const input of commit.data.inputs ?? []) { if (input.roleIdentifier) roles.add(input.roleIdentifier); } @@ -459,9 +503,10 @@ export class HistoryService { } const action = context.template?.actions?.[invitation.actionIdentifier]; - const participantRoles = action?.requirements?.participants - ?.map((participant) => participant.role) - .filter((role): role is string => typeof role === "string") ?? []; + const participantRoles = + action?.requirements?.participants + ?.map((participant) => participant.role) + .filter((role): role is string => typeof role === "string") ?? []; const explicitlyFilledRoles = new Set(); for (const roles of rolesByEntity.values()) { for (const role of roles) explicitlyFilledRoles.add(role); @@ -473,7 +518,10 @@ export class HistoryService { .filter(([, roles]) => roles.size === 0) .map(([entityIdentifier]) => entityIdentifier); - if (unfilledParticipantRoles.length === 1 && entitiesWithoutRoles.length >= 1) { + if ( + unfilledParticipantRoles.length === 1 && + entitiesWithoutRoles.length >= 1 + ) { const inferredRole = unfilledParticipantRoles[0]; if (inferredRole !== undefined) { for (const entityIdentifier of entitiesWithoutRoles) { @@ -517,12 +565,21 @@ export class HistoryService { inputs: WalletHistoryInput[], outputs: WalletHistoryOutput[], ): bigint { - const inputTotal = inputs.reduce((total, input) => total + (input.valueSatoshis ?? 0n), 0n); - const outputTotal = outputs.reduce((total, output) => total + (output.valueSatoshis ?? 0n), 0n); + const inputTotal = inputs.reduce( + (total, input) => total + (input.valueSatoshis ?? 0n), + 0n, + ); + const outputTotal = outputs.reduce( + (total, output) => total + (output.valueSatoshis ?? 0n), + 0n, + ); return inputTotal + outputTotal; } - private describeInvitation(context: InvitationContext, role?: string): string { + private describeInvitation( + context: InvitationContext, + role?: string, + ): string { const invitation = context.invitation.data; const template = context.template; if (!template) return invitation.actionIdentifier; @@ -544,14 +601,27 @@ export class HistoryService { return this.compileDescription(descriptionTemplate, context.variables); } - private describeInput(inputIdentifier: string | undefined, context: InvitationContext): string { + private describeInput( + inputIdentifier: string | undefined, + context: InvitationContext, + ): string { if (!inputIdentifier) return "Input"; const input = context.template?.inputs?.[inputIdentifier]; - return this.compileDescription(input?.description ?? input?.name ?? inputIdentifier, context.variables); + return this.compileDescription( + input?.description ?? input?.name ?? inputIdentifier, + context.variables, + ); } - private describeOutput(outputIdentifier: string | undefined, context: InvitationContext): string { - return this.describeOutputFromTemplate(outputIdentifier, context.template, context.variables); + private describeOutput( + outputIdentifier: string | undefined, + context: InvitationContext, + ): string { + return this.describeOutputFromTemplate( + outputIdentifier, + context.template, + context.variables, + ); } private describeOutputFromTemplate( @@ -561,7 +631,10 @@ export class HistoryService { ): string { if (!outputIdentifier) return "Output"; const output = template?.outputs?.[outputIdentifier]; - return this.compileDescription(output?.description ?? output?.name ?? outputIdentifier, variables); + return this.compileDescription( + output?.description ?? output?.name ?? outputIdentifier, + variables, + ); } private compileDescription( @@ -569,16 +642,25 @@ export class HistoryService { variables: Record, ): string { try { - return compileCashAssemblyString({ cashAssemblyText: description, variables, evaluationDecodeMode: 'utf8' }); + return compileCashAssemblyString({ + cashAssemblyText: description, + variables, + evaluationDecodeMode: "utf8", + }); } catch { - return this.interpolateSimpleCashAssemblyVariables(description, variables); + return this.interpolateSimpleCashAssemblyVariables( + description, + variables, + ); } } private extractInvitationVariables( invitation: XOInvitation, ): Record { - const committedVariables = invitation.commits.flatMap((c) => c.data.variables ?? []); + const committedVariables = invitation.commits.flatMap( + (c) => c.data.variables ?? [], + ); return committedVariables.reduce( (acc, variable) => { if (!variable.variableIdentifier) return acc; @@ -596,15 +678,21 @@ export class HistoryService { : String(input.outpointTransactionHash); } - private getOutputLockingBytecodeHex(output: XOInvitationOutput): string | undefined { + private getOutputLockingBytecodeHex( + output: XOInvitationOutput, + ): string | undefined { if (output.lockingBytecode === undefined) return undefined; return typeof output.lockingBytecode === "string" ? output.lockingBytecode : binToHex(output.lockingBytecode); } - private async getScriptHashData(scriptHash: string): Promise { - return (this.engine as unknown as { state: State }).state.getScriptHashData(scriptHash); + private async getScriptHashData( + scriptHash: string, + ): Promise { + return (this.engine as unknown as { state: State }).state.getScriptHashData( + scriptHash, + ); } private getOutpointKey(txid: string, index: number): string { @@ -627,7 +715,9 @@ export class HistoryService { return text.replace( /\$\(<([^>]+)>\)/g, (match, variableIdentifier: string) => { - if (!Object.prototype.hasOwnProperty.call(variables, variableIdentifier)) { + if ( + !Object.prototype.hasOwnProperty.call(variables, variableIdentifier) + ) { return match; } return String(variables[variableIdentifier]); diff --git a/src/services/invitation.ts b/src/services/invitation.ts index b122ad9..73c22c3 100644 --- a/src/services/invitation.ts +++ b/src/services/invitation.ts @@ -3,7 +3,13 @@ import type { Engine, GetSpendableResourcesParameters, } from "@xo-cash/engine"; -import { generateTemplateIdentifier, hasInvitationExpired, mergeInvitationCommits, serializeInvitation, deserializeInvitation } from "@xo-cash/engine"; +import { + generateTemplateIdentifier, + hasInvitationExpired, + mergeInvitationCommits, + serializeInvitation, + deserializeInvitation, +} from "@xo-cash/engine"; import type { XOInvitation, XOInvitationCommit, @@ -92,7 +98,9 @@ export class Invitation extends EventEmitter { } // engine invitation (I have no idea if this is required) - const engineInvitation = await dependencies.engine.importInvitation(serializeInvitation(invitation)); + const engineInvitation = await dependencies.engine.importInvitation( + serializeInvitation(invitation), + ); // Create the invitation const invitationInstance = new Invitation(engineInvitation, dependencies); @@ -287,7 +295,9 @@ export class Invitation extends EventEmitter { return payload; } - private unwrapLegacyInvitationUpdatedPayload(payload: unknown): unknown | null { + private unwrapLegacyInvitationUpdatedPayload( + payload: unknown, + ): unknown | null { if ( payload && typeof payload === "object" && @@ -308,7 +318,10 @@ export class Invitation extends EventEmitter { invitation: XOInvitation = this.data, ): Promise { this.syncServer.publishInvitation(invitation).catch((error) => { - this.emit("error", error instanceof Error ? error : new Error(String(error))); + this.emit( + "error", + error instanceof Error ? error : new Error(String(error)), + ); }); } @@ -362,7 +375,9 @@ export class Invitation extends EventEmitter { private async computeStatusInternal(): Promise { let missingReqs; try { - const missingRequirements = await this.engine.listMissingRequirements(this.data.invitationIdentifier); + const missingRequirements = await this.engine.listMissingRequirements( + this.data.invitationIdentifier, + ); missingReqs = missingRequirements.templateRequirements; } catch { return "unknown"; @@ -454,13 +469,18 @@ export class Invitation extends EventEmitter { * Update the status of the invitation and emit the new single-word status. */ private async updateStatus(): Promise { - this.computeStatus().then(status => { - this.status = status; - this.emit("invitation-status-changed", status); - }).catch((error) => { - this.status = `error (${error instanceof Error ? error.message : String(error)})`; - this.emit("error", error instanceof Error ? error : new Error(String(error))); - }); + this.computeStatus() + .then((status) => { + this.status = status; + this.emit("invitation-status-changed", status); + }) + .catch((error) => { + this.status = `error (${error instanceof Error ? error.message : String(error)})`; + this.emit( + "error", + error instanceof Error ? error : new Error(String(error)), + ); + }); } /** @@ -499,7 +519,9 @@ export class Invitation extends EventEmitter { */ async sign(): Promise { // Sign the invitation - const signedInvitation = await this.engine.signInvitation(this.data.invitationIdentifier); + const signedInvitation = await this.engine.signInvitation( + this.data.invitationIdentifier, + ); // Publish the signed invitation to the sync server this.publishInvitation(signedInvitation); @@ -518,9 +540,12 @@ export class Invitation extends EventEmitter { * @returns The transaction hash returned by the network after broadcast. */ async broadcast(): Promise { - const txHash = await this.engine.executeAction(this.data.invitationIdentifier, { - broadcastTransaction: true, - }); + const txHash = await this.engine.executeAction( + this.data.invitationIdentifier, + { + broadcastTransaction: true, + }, + ); await this.updateStatus(); @@ -538,7 +563,10 @@ export class Invitation extends EventEmitter { await this.ensureAccepted(); // Append the commit to the invitation - this.data = await this.engine.appendInvitation(this.data.invitationIdentifier, data); + this.data = await this.engine.appendInvitation( + this.data.invitationIdentifier, + data, + ); // Sync the invitation to the sync server await this.publishInvitation(this.data); @@ -617,8 +645,8 @@ export class Invitation extends EventEmitter { const templates = await this.engine.listImportedTemplates(); // For each template, we need to create a 2d array of all the outputs - const outputs = templates.map(template => { - return Object.keys(template.outputs).map(output => { + const outputs = templates.map((template) => { + return Object.keys(template.outputs).map((output) => { const templateIdentifier = generateTemplateIdentifier(template); return { @@ -629,14 +657,18 @@ export class Invitation extends EventEmitter { }); // then, for each output, we need to get the spendable resources - const spendableResources = await Promise.all(outputs.flat().map(output => { - return this.engine.getSpendableResources(this.data, { - templateIdentifier: output.templateIdentifier, - outputIdentifier: output.outputIdentifier, - }); - })); + const spendableResources = await Promise.all( + outputs.flat().map((output) => { + return this.engine.getSpendableResources(this.data, { + templateIdentifier: output.templateIdentifier, + outputIdentifier: output.outputIdentifier, + }); + }), + ); - const unspentOutputs = spendableResources.flatMap(resource => resource.unspentOutputs); + const unspentOutputs = spendableResources.flatMap( + (resource) => resource.unspentOutputs, + ); // Update the status of the invitation await this.updateStatus(); @@ -738,9 +770,11 @@ export class Invitation extends EventEmitter { ); // Compile the CashAssembly expression to get the value satoshis (It handles the variable replacement for us) - const valueSatoshis = compileCashAssemblyString( - { cashAssemblyText: String(valueSatoshisExpression), variables: formattedVariables, evaluationDecodeMode: 'bigint' }, - ); + const valueSatoshis = compileCashAssemblyString({ + cashAssemblyText: String(valueSatoshisExpression), + variables: formattedVariables, + evaluationDecodeMode: "bigint", + }); // Return the value satoshis as a bigint // TODO: Check this of a vulnerability or crash - I assume there might be one if someone made the `valueSatoshis` a malicious expression @@ -796,7 +830,7 @@ export class Invitation extends EventEmitter { for (const output of outputs) { if (typeof output === "string") { const sats = await this.getSatsOut(output); - totalSats += sats + totalSats += sats; } else { const sats = await this.getSatsOut(output.output); totalSats += sats; diff --git a/src/services/rates.ts b/src/services/rates.ts index a03e6ca..fa35a7b 100644 --- a/src/services/rates.ts +++ b/src/services/rates.ts @@ -1,16 +1,14 @@ -import { OracleClient } from '@generalprotocols/oracle-client'; -import { EventEmitter } from '../utils/event-emitter.js'; -import { - type RatesEventMap, -} from '../utils/rates/base-rates.js'; -import { RatesOracle } from '../utils/rates/rates-oracles.js'; -import { SettingsService } from './settings.js'; +import { OracleClient } from "@generalprotocols/oracle-client"; +import { EventEmitter } from "../utils/event-emitter.js"; +import { type RatesEventMap } from "../utils/rates/base-rates.js"; +import { RatesOracle } from "../utils/rates/rates-oracles.js"; +import { SettingsService } from "./settings.js"; /** * Event map emitted by {@link RatesService}. */ export type RatesServiceEventMap = { - 'rate-updated': { + "rate-updated": { numeratorUnitCode: string; denominatorUnitCode: string; price: number; @@ -39,8 +37,8 @@ export interface RatesAdapter { listPairs(): Promise>; formatCurrency(amount: number, targetCurrency: string): string; on( - type: 'rateUpdated', - listener: (detail: RatesEventMap['rateUpdated']) => void, + type: "rateUpdated", + listener: (detail: RatesEventMap["rateUpdated"]) => void, ): () => void; } @@ -96,7 +94,7 @@ export class RatesService extends EventEmitter { } this.started = true; - this.unsubscribeFromAdapter = this.adapter.on('rateUpdated', (event) => { + this.unsubscribeFromAdapter = this.adapter.on("rateUpdated", (event) => { this.handleRateUpdated(event); }); @@ -145,9 +143,9 @@ export class RatesService extends EventEmitter { */ public convertBchToFiat( satoshis: bigint, - targetCurrency: string = 'USD', + targetCurrency: string = "USD", ): number | null { - const rate = this.getRate(targetCurrency, 'BCH'); + const rate = this.getRate(targetCurrency, "BCH"); if (rate === null) { return null; } @@ -161,7 +159,7 @@ export class RatesService extends EventEmitter { */ public formatBchToFiat( satoshis: bigint, - targetCurrency: string = 'USD', + targetCurrency: string = "USD", ): string | null { const normalizedCurrency = targetCurrency.toUpperCase(); const amount = this.convertBchToFiat(satoshis, normalizedCurrency); @@ -195,7 +193,7 @@ export class RatesService extends EventEmitter { /** * Handles normalized updates from the underlying adapter. */ - private handleRateUpdated(event: RatesEventMap['rateUpdated']): void { + private handleRateUpdated(event: RatesEventMap["rateUpdated"]): void { const numeratorUnitCode = event.numeratorUnitCode.toUpperCase(); const denominatorUnitCode = event.denominatorUnitCode.toUpperCase(); const pair = this.getPairKey(numeratorUnitCode, denominatorUnitCode); @@ -206,7 +204,7 @@ export class RatesService extends EventEmitter { updatedAt, }); - this.emit('rate-updated', { + this.emit("rate-updated", { numeratorUnitCode, denominatorUnitCode, price: event.price, diff --git a/src/services/settings.ts b/src/services/settings.ts index ed8fa5e..2cbaab8 100644 --- a/src/services/settings.ts +++ b/src/services/settings.ts @@ -168,7 +168,9 @@ export class SettingsService extends EventEmitter { return normalized; } - const maybeMnemonic = (input as Record)["default-mnemonic"]; + const maybeMnemonic = (input as Record)[ + "default-mnemonic" + ]; if (typeof maybeMnemonic === "string" && maybeMnemonic.trim().length > 0) { normalized["default-mnemonic"] = maybeMnemonic.trim(); } diff --git a/src/services/storage.ts b/src/services/storage.ts index 8e55947..3e62c5f 100644 --- a/src/services/storage.ts +++ b/src/services/storage.ts @@ -142,7 +142,7 @@ export class Storage extends BaseStorage { * * This adapter is useful for tests and short-lived sessions where persisted * SQLite state is not needed. - * + * * TODO: Move this somewhere else. There is no reason for this to be in the main codebase. We should put this stricly in the tests beacuse that were its actually being used. * Ideally, we would provide these kind of generic fills as part of our packages somewhere, but these interfaces dont fit our current design. */ diff --git a/src/templates/vending-machine.ts b/src/templates/vending-machine.ts index 48c6600..2511813 100644 --- a/src/templates/vending-machine.ts +++ b/src/templates/vending-machine.ts @@ -1,4 +1,4 @@ -import type { XOTemplate } from '@xo-cash/types'; +import type { XOTemplate } from "@xo-cash/types"; /** * Vending machine payment template. @@ -7,271 +7,277 @@ import type { XOTemplate } from '@xo-cash/types'; * customer funds and signs the composable transaction. */ export const vendingMachineTemplate: XOTemplate = { - $schema: 'https://libauth.org/schemas/wallet-template-v0.schema.json', - name: 'Vending Machine', - description: 'Purchase items from a vending machine with an itemized receipt.', - icon: 'wallet', - version: '1', - supported: ['BCH_2023_05', 'BCH_2024_05', 'BCH_2025_05', 'BCH_2026_05'], + $schema: "https://libauth.org/schemas/wallet-template-v0.schema.json", + name: "Vending Machine", + description: + "Purchase items from a vending machine with an itemized receipt.", + icon: "wallet", + version: "1", + supported: ["BCH_2023_05", "BCH_2024_05", "BCH_2025_05", "BCH_2026_05"], - defaults: { - change: { - output: 'changeOutput', - role: 'merchant', - generate: ['merchantKey'], - }, + defaults: { + change: { + output: "changeOutput", + role: "merchant", + generate: ["merchantKey"], }, + }, - roles: { + roles: { + merchant: { + name: "Merchant", + description: "The vending machine operator receiving payment.", + icon: "owner", + }, + customer: { + name: "Customer", + description: "The customer paying for items.", + icon: "sender", + }, + }, + + start: [ + { + action: "purchaseItems", + role: "merchant", + generate: ["merchantKey"], + }, + ], + + actions: { + purchaseItems: { + name: "Purchase Items", + description: "Purchase: $() for $() sats", + icon: "request", + + roles: { merchant: { - name: 'Merchant', - description: 'The vending machine operator receiving payment.', - icon: 'owner', + name: "Sell Items", + description: "Receive payment for $()", + icon: "request", + requirements: { + secrets: ["merchantKey"], + variables: [ + "totalSatoshis", + "orderId", + "merchantName", + "receiptSummary", + "lineItemsJson", + ], + }, }, customer: { - name: 'Customer', - description: 'The customer paying for items.', - icon: 'sender', + name: "Pay", + description: "Pay $() sats for $()", + icon: "send", + requirements: {}, }, - }, + }, - start: [ + requirements: { + participants: [ + { role: "merchant", slots: { min: 1, max: 1 } }, + { role: "customer", slots: { min: 1 } }, + ], + }, + + transaction: "purchaseItemsTransaction", + }, + }, + + transactions: { + purchaseItemsTransaction: { + name: "Vending Purchase", + description: "Order $(): $()", + icon: "request", + + roles: { + merchant: { + name: "Received Payment", + description: + "Received $() sats from $() sale", + icon: "receive", + }, + customer: { + name: "Sent Payment", + description: "Paid $() sats for $()", + icon: "send", + }, + }, + + inputs: [], + outputs: [{ output: "purchaseOutput" }], + version: 2, + locktime: 0, + composable: true, + }, + }, + + /** No custom input templates — customer UTXOs are selected at funding time. */ + inputs: {}, + + outputs: { + changeOutput: { + name: "Change", + description: "Funds returned as change.", + icon: "receive", + lockingScript: "merchantReceivingLockingScript", + }, + purchaseOutput: { + name: "Purchase Payment", + description: "$() sats to $()", + icon: "request", + + roles: { + merchant: { + name: "Payment Received", + description: + "Received $() sats for $()", + }, + customer: { + name: "Payment Sent", + description: "Sent $() sats for $()", + }, + }, + + lockingScript: "merchantReceivingLockingScript", + valueSatoshis: "$()", + token: null, + }, + }, + + lockingScripts: { + merchantReceivingLockingScript: { + name: "Merchant Receive", + description: "Funds received by the vending machine merchant.", + icon: "address", + lockingType: "p2pkh", + lockingBytecode: "lockMerchantP2PKH", + unlockingBytecode: "unlockMerchantP2PKH", + actions: [], + state: { variables: [], secrets: [] }, + balance: {}, + roles: { + merchant: { + state: { + variables: [], + secrets: ["merchantKey"], + }, + actions: [], + balance: { + satoshis: true, + fungibleTokens: true, + nonfungibleTokens: true, + }, + selectable: true, + }, + }, + }, + }, + + scripts: { + lockMerchantP2PKH: + "OP_DUP OP_HASH160 <$( OP_HASH160)> OP_EQUALVERIFY OP_CHECKSIG", + unlockMerchantP2PKH: + " ", + }, + + constants: { + dustLimit: { + name: "Dust Limit", + description: "Minimum satoshis for P2PKH outputs.", + type: "integer", + value: 546, + }, + }, + + variables: { + merchantKey: { + name: "Merchant Private Key", + description: "Private key for the vending machine merchant wallet.", + type: "bytes", + hint: "private_key", + }, + totalSatoshis: { + name: "Total Price", + description: "Total purchase price in satoshis", + type: "integer", + hint: "satoshis", + }, + orderId: { + name: "Order ID", + description: "Unique order identifier", + type: "string", + }, + merchantName: { + name: "Merchant Name", + description: "Display name of the vending machine", + type: "string", + }, + receiptSummary: { + name: "Receipt Summary", + description: "Human-readable list of purchased items", + type: "string", + }, + lineItemsJson: { + name: "Line Items", + description: "JSON-encoded line items for the purchase", + type: "string", + }, + }, + + icons: [ + { name: "wallet", hash: "0000000000000000000000" }, + { name: "owner", hash: "0000000000000000000000" }, + { name: "sender", hash: "0000000000000000000000" }, + { name: "request", hash: "0000000000000000000000" }, + { name: "receive", hash: "0000000000000000000000" }, + { name: "send", hash: "0000000000000000000000" }, + ], + + scenarios: [ + { + name: "purchase items happy path", + description: "Merchant requests payment for vending machine items.", + action: "purchaseItems", + roles: [ { - action: 'purchaseItems', - role: 'merchant', - generate: ['merchantKey'], - }, - ], - - actions: { - purchaseItems: { - name: 'Purchase Items', - description: 'Purchase: $() for $() sats', - icon: 'request', - - roles: { - merchant: { - name: 'Sell Items', - description: 'Receive payment for $()', - icon: 'request', - requirements: { - secrets: ['merchantKey'], - variables: [ - 'totalSatoshis', - 'orderId', - 'merchantName', - 'receiptSummary', - 'lineItemsJson', - ], - }, - }, - customer: { - name: 'Pay', - description: 'Pay $() sats for $()', - icon: 'send', - requirements: {}, - }, + role: "merchant", + values: { + generated: { + merchantKey: + "KyRQa5pEXuzVcDwnXRLpYAascjchQW5DoxVRMbj4DTxS83573mz8", }, - - requirements: { - participants: [ - { role: 'merchant', slots: { min: 1, max: 1 } }, - { role: 'customer', slots: { min: 1 } }, - ], + variables: { + totalSatoshis: 3500, + orderId: "order-demo-1", + merchantName: "XO Snack Machine", + receiptSummary: "2× Cola, 1× Chips", + lineItemsJson: + '[{"name":"Cola","qty":2},{"name":"Chips","qty":1}]', }, - - transaction: 'purchaseItemsTransaction', - }, - }, - - transactions: { - purchaseItemsTransaction: { - name: 'Vending Purchase', - description: 'Order $(): $()', - icon: 'request', - - roles: { - merchant: { - name: 'Received Payment', - description: 'Received $() sats from $() sale', - icon: 'receive', - }, - customer: { - name: 'Sent Payment', - description: 'Paid $() sats for $()', - icon: 'send', - }, - }, - + secrets: {}, inputs: [], - outputs: [{ output: 'purchaseOutput' }], - version: 2, - locktime: 0, - composable: true, - }, - }, - - /** No custom input templates — customer UTXOs are selected at funding time. */ - inputs: {}, - - outputs: { - changeOutput: { - name: 'Change', - description: 'Funds returned as change.', - icon: 'receive', - lockingScript: 'merchantReceivingLockingScript', - }, - purchaseOutput: { - name: 'Purchase Payment', - description: '$() sats to $()', - icon: 'request', - - roles: { - merchant: { - name: 'Payment Received', - description: 'Received $() sats for $()', - }, - customer: { - name: 'Payment Sent', - description: 'Sent $() sats for $()', - }, - }, - - lockingScript: 'merchantReceivingLockingScript', - valueSatoshis: '$()', - token: null, - }, - }, - - lockingScripts: { - merchantReceivingLockingScript: { - name: 'Merchant Receive', - description: 'Funds received by the vending machine merchant.', - icon: 'address', - lockingType: 'p2pkh', - lockingBytecode: 'lockMerchantP2PKH', - unlockingBytecode: 'unlockMerchantP2PKH', - actions: [], - state: { variables: [], secrets: [] }, - balance: {}, - roles: { - merchant: { - state: { - variables: [], - secrets: ['merchantKey'], - }, - actions: [], - balance: { - satoshis: true, - fungibleTokens: true, - nonfungibleTokens: true, - }, - selectable: true, - }, - }, - }, - }, - - scripts: { - lockMerchantP2PKH: - 'OP_DUP OP_HASH160 <$( OP_HASH160)> OP_EQUALVERIFY OP_CHECKSIG', - unlockMerchantP2PKH: - ' ', - }, - - constants: { - dustLimit: { - name: 'Dust Limit', - description: 'Minimum satoshis for P2PKH outputs.', - type: 'integer', - value: 546, - }, - }, - - variables: { - merchantKey: { - name: 'Merchant Private Key', - description: 'Private key for the vending machine merchant wallet.', - type: 'bytes', - hint: 'private_key', - }, - totalSatoshis: { - name: 'Total Price', - description: 'Total purchase price in satoshis', - type: 'integer', - hint: 'satoshis', - }, - orderId: { - name: 'Order ID', - description: 'Unique order identifier', - type: 'string', - }, - merchantName: { - name: 'Merchant Name', - description: 'Display name of the vending machine', - type: 'string', - }, - receiptSummary: { - name: 'Receipt Summary', - description: 'Human-readable list of purchased items', - type: 'string', - }, - lineItemsJson: { - name: 'Line Items', - description: 'JSON-encoded line items for the purchase', - type: 'string', - }, - }, - - icons: [ - { name: 'wallet', hash: '0000000000000000000000' }, - { name: 'owner', hash: '0000000000000000000000' }, - { name: 'sender', hash: '0000000000000000000000' }, - { name: 'request', hash: '0000000000000000000000' }, - { name: 'receive', hash: '0000000000000000000000' }, - { name: 'send', hash: '0000000000000000000000' }, - ], - - scenarios: [ - { - name: 'purchase items happy path', - description: 'Merchant requests payment for vending machine items.', - action: 'purchaseItems', - roles: [ - { - role: 'merchant', - values: { - generated: { - merchantKey: 'KyRQa5pEXuzVcDwnXRLpYAascjchQW5DoxVRMbj4DTxS83573mz8', - }, - variables: { - totalSatoshis: 3500, - orderId: 'order-demo-1', - merchantName: 'XO Snack Machine', - receiptSummary: '2× Cola, 1× Chips', - lineItemsJson: '[{"name":"Cola","qty":2},{"name":"Chips","qty":1}]', - }, - secrets: {}, - inputs: [], - outputs: [ - { - lockingBytecode: '76a91475c715ecb74178fe87933e57e947e5e92d904b8188ac', - valueSatoshis: 3500, - }, - ], - }, - }, - { - role: 'customer', - values: { - generated: {}, - variables: {}, - secrets: {}, - inputs: [], - outputs: [], - }, - }, + outputs: [ + { + lockingBytecode: + "76a91475c715ecb74178fe87933e57e947e5e92d904b8188ac", + valueSatoshis: 3500, + }, ], + }, }, - ], + { + role: "customer", + values: { + generated: {}, + variables: {}, + secrets: {}, + inputs: [], + outputs: [], + }, + }, + ], + }, + ], }; diff --git a/src/templates/wrap-template.ts b/src/templates/wrap-template.ts index dc1befd..23d14c2 100644 --- a/src/templates/wrap-template.ts +++ b/src/templates/wrap-template.ts @@ -1,266 +1,270 @@ -import type { XOTemplate } from '@xo-cash/types'; +import type { XOTemplate } from "@xo-cash/types"; export const wrapBCHTemplate: XOTemplate = { - $schema: 'https://libauth.org/schemas/wallet-template-v0.schema.json', + $schema: "https://libauth.org/schemas/wallet-template-v0.schema.json", - name: 'Wrapped BCH', - description: 'Convert between BCH and wBCH tokens.', - icon: 'wrap', + name: "Wrapped BCH", + description: "Convert between BCH and wBCH tokens.", + icon: "wrap", - version: '1', - supported: ['BCH_2023_05', 'BCH_2024_05', 'BCH_2025_05', 'BCH_2026_05'], + version: "1", + supported: ["BCH_2023_05", "BCH_2024_05", "BCH_2025_05", "BCH_2026_05"], - roles: { - user: { - name: 'User', - description: 'The person wrapping or unwrapping BCH.', - icon: 'user', - }, - }, + roles: { + user: { + name: "User", + description: "The person wrapping or unwrapping BCH.", + icon: "user", + }, + }, - start: [ - { - action: 'wrap', - role: 'user', - }, - { - action: 'unwrap', - role: 'user', - }, - ], + start: [ + { + action: "wrap", + role: "user", + }, + { + action: "unwrap", + role: "user", + }, + ], - actions: { - wrap: { - name: 'Wrap BCH', - description: 'Convert BCH into wBCH tokens.', - icon: 'wrap', + actions: { + wrap: { + name: "Wrap BCH", + description: "Convert BCH into wBCH tokens.", + icon: "wrap", - roles: { - user: { - requirements: { - variables: ['amountToWrap', 'recipientLockingScript'], - }, - }, - }, + roles: { + user: { + requirements: { + variables: ["amountToWrap", "recipientLockingScript"], + }, + }, + }, - requirements: { - participants: [{ role: 'user', slots: { min: 1, max: 1 } }], - }, + requirements: { + participants: [{ role: "user", slots: { min: 1, max: 1 } }], + }, - transaction: 'wrapTransaction', - }, + transaction: "wrapTransaction", + }, - unwrap: { - name: 'Unwrap wBCH', - description: 'Convert wBCH tokens back into BCH.', - icon: 'unwrap', + unwrap: { + name: "Unwrap wBCH", + description: "Convert wBCH tokens back into BCH.", + icon: "unwrap", - roles: { - user: { - requirements: { - variables: ['amountToUnwrap', 'recipientLockingScript'], - }, - }, - }, + roles: { + user: { + requirements: { + variables: ["amountToUnwrap", "recipientLockingScript"], + }, + }, + }, - requirements: { - participants: [{ role: 'user', slots: { min: 1, max: 1 } }], - }, + requirements: { + participants: [{ role: "user", slots: { min: 1, max: 1 } }], + }, - transaction: 'unwrapTransaction', - }, - }, + transaction: "unwrapTransaction", + }, + }, - transactions: { - wrapTransaction: { - name: 'Wrapped BCH', - description: 'Wrapped $( OP_DIV).$( OP_MOD) BCH into wBCH tokens.', - icon: 'wrap', + transactions: { + wrapTransaction: { + name: "Wrapped BCH", + description: + "Wrapped $( OP_DIV).$( OP_MOD) BCH into wBCH tokens.", + icon: "wrap", - inputs: [ - { input: 'covenantInput', inputIndex: 0 }, - ], - outputs: [ - { output: 'covenantOutput', outputIndex: 0 }, - { output: 'wrappedTokensOutput', outputIndex: undefined }, - ], + inputs: [{ input: "covenantInput", inputIndex: 0 }], + outputs: [ + { output: "covenantOutput", outputIndex: 0 }, + { output: "wrappedTokensOutput", outputIndex: undefined }, + ], - version: 2, - locktime: 0, - composable: true, - }, + version: 2, + locktime: 0, + composable: true, + }, - unwrapTransaction: { - name: 'Unwrapped wBCH', - description: 'Unwrapped $( OP_DIV).$( OP_MOD) wBCH tokens back into BCH.', - icon: 'unwrap', + unwrapTransaction: { + name: "Unwrapped wBCH", + description: + "Unwrapped $( OP_DIV).$( OP_MOD) wBCH tokens back into BCH.", + icon: "unwrap", - inputs: [ - { input: 'covenantInput', inputIndex: 0 }, - ], - outputs: [ - { output: 'covenantOutput', outputIndex: 0 }, - { output: 'unwrappedSatoshisOutput', outputIndex: undefined }, - ], + inputs: [{ input: "covenantInput", inputIndex: 0 }], + outputs: [ + { output: "covenantOutput", outputIndex: 0 }, + { output: "unwrappedSatoshisOutput", outputIndex: undefined }, + ], - version: 2, - locktime: 0, - composable: true, - }, - }, + version: 2, + locktime: 0, + composable: true, + }, + }, - outputs: { - covenantOutput: { - name: 'wBCH Covenant', - description: 'Holds BCH and wBCH tokens that can be freely converted.', - icon: 'contract', + outputs: { + covenantOutput: { + name: "wBCH Covenant", + description: "Holds BCH and wBCH tokens that can be freely converted.", + icon: "contract", - lockingScript: 'wrapBCHLockingScript', - }, + lockingScript: "wrapBCHLockingScript", + }, - wrappedTokensOutput: { - name: 'Wrapped wBCH', - description: 'Wrapped $( OP_DIV).$( OP_MOD) wBCH tokens.', - icon: 'receive', + wrappedTokensOutput: { + name: "Wrapped wBCH", + description: + "Wrapped $( OP_DIV).$( OP_MOD) wBCH tokens.", + icon: "receive", - valueSatoshis: '$()', - token: { - category: '$()', - amount: '$()', - nft: null, - }, + valueSatoshis: "$()", + token: { + category: "$()", + amount: "$()", + nft: null, + }, - roles: { - user: { - balance: { - satoshis: true, - fungibleTokens: true, - nonfungibleTokens: true, - }, - selectable: true, - }, - }, + roles: { + user: { + balance: { + satoshis: true, + fungibleTokens: true, + nonfungibleTokens: true, + }, + selectable: true, + }, + }, - lockingScript: '$()', - }, + lockingScript: "$()", + }, - unwrappedSatoshisOutput: { - name: 'Unwrapped BCH', - description: 'Unwrapped $( OP_DIV).$( OP_MOD) BCH.', - icon: 'receive', + unwrappedSatoshisOutput: { + name: "Unwrapped BCH", + description: + "Unwrapped $( OP_DIV).$( OP_MOD) BCH.", + icon: "receive", - valueSatoshis: '$()', - token: null, + valueSatoshis: "$()", + token: null, - roles: { - user: { - balance: { - satoshis: true, - fungibleTokens: true, - nonfungibleTokens: true, - }, - selectable: true, - }, - }, + roles: { + user: { + balance: { + satoshis: true, + fungibleTokens: true, + nonfungibleTokens: true, + }, + selectable: true, + }, + }, - lockingScript: '$()', - }, - }, + lockingScript: "$()", + }, + }, - inputs: { - covenantInput: { - name: 'wBCH Covenant', - description: 'The covenant being updated.', - icon: 'contract', + inputs: { + covenantInput: { + name: "wBCH Covenant", + description: "The covenant being updated.", + icon: "contract", - unlockingScript: 'unlockCovenant', - }, - }, + unlockingScript: "unlockCovenant", + }, + }, - lockingScripts: { - wrapBCHLockingScript: { - name: 'wBCH Covenant', - description: 'Holds BCH and wBCH tokens that can be freely converted.', - icon: 'contract', + lockingScripts: { + wrapBCHLockingScript: { + name: "wBCH Covenant", + description: "Holds BCH and wBCH tokens that can be freely converted.", + icon: "contract", - lockingType: 'p2sh', - lockingBytecode: 'wrapBCHLockingBytecode', + lockingType: "p2sh", + lockingBytecode: "wrapBCHLockingBytecode", - actions: [ - { action: 'wrap', role: 'user' }, - { action: 'unwrap', role: 'user' }, - ], + actions: [ + { action: "wrap", role: "user" }, + { action: "unwrap", role: "user" }, + ], - state: { - variables: [], - secrets: [], - }, - balance: { - satoshis: 0n, - fungibleTokens: 0n, - }, - selectable: false, - }, - }, + state: { + variables: [], + secrets: [], + }, + balance: { + satoshis: 0n, + fungibleTokens: 0n, + }, + selectable: false, + }, + }, - scripts: { - enforceCovenantPersists: 'OP_INPUTINDEX OP_DUP OP_OUTPUTBYTECODE OP_SWAP OP_UTXOBYTECODE OP_EQUAL OP_VERIFY', - enforceTokenCategoryPreserved: 'OP_INPUTINDEX OP_DUP OP_OUTPUTTOKENCATEGORY OP_SWAP OP_UTXOTOKENCATEGORY OP_EQUAL OP_VERIFY', - enforceValueTokenSumConserved: 'OP_INPUTINDEX OP_UTXOVALUE OP_INPUTINDEX OP_UTXOTOKENAMOUNT OP_ADD OP_INPUTINDEX OP_OUTPUTVALUE OP_INPUTINDEX OP_OUTPUTTOKENAMOUNT OP_ADD OP_EQUAL OP_VERIFY', + scripts: { + enforceCovenantPersists: + "OP_INPUTINDEX OP_DUP OP_OUTPUTBYTECODE OP_SWAP OP_UTXOBYTECODE OP_EQUAL OP_VERIFY", + enforceTokenCategoryPreserved: + "OP_INPUTINDEX OP_DUP OP_OUTPUTTOKENCATEGORY OP_SWAP OP_UTXOTOKENCATEGORY OP_EQUAL OP_VERIFY", + enforceValueTokenSumConserved: + "OP_INPUTINDEX OP_UTXOVALUE OP_INPUTINDEX OP_UTXOTOKENAMOUNT OP_ADD OP_INPUTINDEX OP_OUTPUTVALUE OP_INPUTINDEX OP_OUTPUTTOKENAMOUNT OP_ADD OP_EQUAL OP_VERIFY", - // Direct script references — introspection opcodes must not use $(...) evaluations - // because those are evaluated at compile time without transaction context. - wrapBCHLockingBytecode: 'enforceCovenantPersists enforceTokenCategoryPreserved enforceValueTokenSumConserved', - unlockCovenant: '', - }, + // Direct script references — introspection opcodes must not use $(...) evaluations + // because those are evaluated at compile time without transaction context. + wrapBCHLockingBytecode: + "enforceCovenantPersists enforceTokenCategoryPreserved enforceValueTokenSumConserved", + unlockCovenant: "", + }, - constants: { - wbchTokenCategory: { - name: 'wBCH Token Category', - description: 'The official token category for Wrapped BCH.', - type: 'bytes', - value: 'ff4d6e4b90aa8158d39c5dc874fd9411af1ac3b5ed6f354755e8362a0d02c6b3', - }, - satoshisPerBCH: { - name: 'Satoshis per BCH', - description: 'Used to display amounts in BCH with decimals.', - type: 'integer', - value: 100000000, - }, - tokenDust: { - name: 'Token Dust Limit', - description: 'Minimal satoshis required for a token-bearing output.', - type: 'integer', - value: 1000, - }, - }, + constants: { + wbchTokenCategory: { + name: "wBCH Token Category", + description: "The official token category for Wrapped BCH.", + type: "bytes", + value: "ff4d6e4b90aa8158d39c5dc874fd9411af1ac3b5ed6f354755e8362a0d02c6b3", + }, + satoshisPerBCH: { + name: "Satoshis per BCH", + description: "Used to display amounts in BCH with decimals.", + type: "integer", + value: 100000000, + }, + tokenDust: { + name: "Token Dust Limit", + description: "Minimal satoshis required for a token-bearing output.", + type: "integer", + value: 1000, + }, + }, - variables: { - amountToWrap: { - name: 'Amount to Wrap', - description: 'How much BCH to convert to wBCH (in satoshis).', - type: 'integer', - hint: 'satoshis', - }, - amountToUnwrap: { - name: 'Amount to Unwrap', - description: 'How much wBCH to convert back to BCH (in satoshis).', - type: 'integer', - hint: 'satoshis', - }, - recipientLockingScript: { - name: 'Destination', - description: 'Where to receive your BCH or wBCH tokens.', - type: 'bytes', - hint: 'lockingScript', - }, - }, + variables: { + amountToWrap: { + name: "Amount to Wrap", + description: "How much BCH to convert to wBCH (in satoshis).", + type: "integer", + hint: "satoshis", + }, + amountToUnwrap: { + name: "Amount to Unwrap", + description: "How much wBCH to convert back to BCH (in satoshis).", + type: "integer", + hint: "satoshis", + }, + recipientLockingScript: { + name: "Destination", + description: "Where to receive your BCH or wBCH tokens.", + type: "bytes", + hint: "lockingScript", + }, + }, - icons: [ - { name: 'wrap', hash: '0000000000000000000000' }, - { name: 'unwrap', hash: '0000000000000000000000' }, - { name: 'user', hash: '0000000000000000000000' }, - { name: 'contract', hash: '0000000000000000000000' }, - { name: 'receive', hash: '0000000000000000000000' }, - ], + icons: [ + { name: "wrap", hash: "0000000000000000000000" }, + { name: "unwrap", hash: "0000000000000000000000" }, + { name: "user", hash: "0000000000000000000000" }, + { name: "contract", hash: "0000000000000000000000" }, + { name: "receive", hash: "0000000000000000000000" }, + ], }; diff --git a/src/tui/utils/clipboard.ts b/src/tui/utils/clipboard.ts index 53bf7fd..6c54c4b 100644 --- a/src/tui/utils/clipboard.ts +++ b/src/tui/utils/clipboard.ts @@ -13,30 +13,36 @@ const execAsync = promisify(exec); // The command is a function that returns a promise that resolves to the result of the command. const clipboardMethods = { pbCopy: { - platform: (platform: string) => platform === 'darwin', - command: async (text: string) => execAsync(`printf '%s' '${text}' | pbcopy`), + platform: (platform: string) => platform === "darwin", + command: async (text: string) => + execAsync(`printf '%s' '${text}' | pbcopy`), }, xclip: { - platform: (platform: string) => platform === 'linux', - command: async (text: string) => execAsync(`printf '%s' '${text}' | xclip -selection clipboard`), + platform: (platform: string) => platform === "linux", + command: async (text: string) => + execAsync(`printf '%s' '${text}' | xclip -selection clipboard`), }, xsel: { - platform: (platform: string) => platform === 'linux', - command: async (text: string) => execAsync(`printf '%s' '${text}' | xsel --clipboard --input`), + platform: (platform: string) => platform === "linux", + command: async (text: string) => + execAsync(`printf '%s' '${text}' | xsel --clipboard --input`), }, ssh: { - platform: (platform: string) => platform === 'linux', - command: async (text: string) => process.stdout.write(`\x1b]52;c;${Buffer.from(text, 'utf-8').toString('base64')}\x07`), + platform: (platform: string) => platform === "linux", + command: async (text: string) => + process.stdout.write( + `\x1b]52;c;${Buffer.from(text, "utf-8").toString("base64")}\x07`, + ), }, clip: { - platform: (platform: string) => platform === 'windows', + platform: (platform: string) => platform === "windows", command: async (text: string) => execAsync(`echo|set /p="${text}" | clip`), }, clipboardy: { - platform: (platform: string) => platform === 'windows', + platform: (platform: string) => platform === "windows", command: async (text: string) => clipboardy.writeSync(text), }, -} +}; /** * Attempts to copy text to clipboard using multiple methods. @@ -51,7 +57,9 @@ export async function copyToClipboard(text: string): Promise { // Escape the text for shell commands const escapedText = text.replace(/'/g, "'\\''"); - const availableMethods = Object.values(clipboardMethods).filter(method => method.platform(platform)); + const availableMethods = Object.values(clipboardMethods).filter((method) => + method.platform(platform), + ); const errors: Error[] = []; @@ -63,7 +71,7 @@ export async function copyToClipboard(text: string): Promise { continue; } return; - } catch(error) { + } catch (error) { if (error instanceof Error) { errors.push(error); } @@ -71,5 +79,7 @@ export async function copyToClipboard(text: string): Promise { } // All methods failed - throw new Error(`Clipboard not available. ${errors.map(error => error.message).join('\n')}`); + throw new Error( + `Clipboard not available. ${errors.map((error) => error.message).join("\n")}`, + ); } diff --git a/src/tui/utils/list-directory-entries.ts b/src/tui/utils/list-directory-entries.ts index fa1bb1c..fc4649c 100644 --- a/src/tui/utils/list-directory-entries.ts +++ b/src/tui/utils/list-directory-entries.ts @@ -160,8 +160,7 @@ export function listDirectoryEntries( entries: [...entries, ...directories, ...files], }; } catch (error) { - const message = - error instanceof Error ? error.message : String(error); + const message = error instanceof Error ? error.message : String(error); return { entries: [], error: `Unable to read directory: ${message}`, diff --git a/src/utils/history-utils.ts b/src/utils/history-utils.ts index 2b727cc..c289609 100644 --- a/src/utils/history-utils.ts +++ b/src/utils/history-utils.ts @@ -51,7 +51,7 @@ export function buildHistoryDisplayRows( type: "history_output", label: output.outpoint ? `${output.outpoint.txid}:${output.outpoint.index}` - : output.outputIdentifier ?? "Output", + : (output.outputIdentifier ?? "Output"), description: `${item.template} | ${roles} | ${output.description}`, timestamp: item.createdAtTimestamp, isNested: false, @@ -96,7 +96,7 @@ export function buildHistoryDisplayRows( type: "history_output", label: output.outpoint ? `${output.outpoint.txid}:${output.outpoint.index}` - : output.outputIdentifier ?? "Output", + : (output.outputIdentifier ?? "Output"), description: output.description, isNested: true, valueSatoshis: output.valueSatoshis, diff --git a/src/utils/invitation-flow.ts b/src/utils/invitation-flow.ts index 65a35ac..7782e66 100644 --- a/src/utils/invitation-flow.ts +++ b/src/utils/invitation-flow.ts @@ -65,8 +65,18 @@ export const roleRequiresInputs = ( const actionRole = action.roles?.[roleIdentifier]; const actionRequirements = action.requirements; - const actionRoleRequirements = actionRole && actionRequirements && actionRequirements.participants?.find((participant) => participant.role === roleIdentifier); - const roleSlotsMin = actionRoleRequirements && actionRoleRequirements.slots && actionRoleRequirements.slots.min > 0 ? actionRoleRequirements.slots.min : 0; + const actionRoleRequirements = + actionRole && + actionRequirements && + actionRequirements.participants?.find( + (participant) => participant.role === roleIdentifier, + ); + const roleSlotsMin = + actionRoleRequirements && + actionRoleRequirements.slots && + actionRoleRequirements.slots.min > 0 + ? actionRoleRequirements.slots.min + : 0; if (roleSlotsMin > 0) return true; const transactionIdentifier = action.transaction; @@ -78,7 +88,6 @@ export const roleRequiresInputs = ( return (roleInputs?.length ?? 0) > 0; }; - export const getTransactionOutputIdentifier = ( output: XOTemplateTransactionOutput, ): string | undefined => { @@ -136,7 +145,8 @@ export const resolveProvidedLockingBytecodeHex = ( return undefined; } - const lockingScriptDefinition = template.lockingScripts?.[outputDefinition.lockingScript]; + const lockingScriptDefinition = + template.lockingScripts?.[outputDefinition.lockingScript]; const scriptIdentifier = lockingScriptDefinition?.lockingBytecode; if (!scriptIdentifier) return undefined; diff --git a/src/utils/load-template-from-file.ts b/src/utils/load-template-from-file.ts index aa76a58..33edc14 100644 --- a/src/utils/load-template-from-file.ts +++ b/src/utils/load-template-from-file.ts @@ -71,12 +71,7 @@ function resolveTemplateModuleLoaderPath(): string { } /** TypeScript extensions that require tsx to evaluate the template module. */ -const TYPESCRIPT_TEMPLATE_EXTENSIONS = new Set([ - ".ts", - ".tsx", - ".mts", - ".cts", -]); +const TYPESCRIPT_TEMPLATE_EXTENSIONS = new Set([".ts", ".tsx", ".mts", ".cts"]); /** * Loads a TS/JS template module in an isolated child process. @@ -155,7 +150,9 @@ async function loadTemplateModuleViaChildProcess( } if (stdout.trim().length === 0) { - reject(new TemplateLoadError("Template module loader returned no output.")); + reject( + new TemplateLoadError("Template module loader returned no output."), + ); return; } @@ -174,7 +171,9 @@ export async function loadTemplateFromFile(filePath: string): Promise { const absolutePath = path.resolve(filePath); if (!fs.existsSync(absolutePath)) { - throw new TemplateLoadError(`Template file does not exist: ${absolutePath}`); + throw new TemplateLoadError( + `Template file does not exist: ${absolutePath}`, + ); } const extension = path.extname(absolutePath).toLowerCase(); diff --git a/src/utils/paths.ts b/src/utils/paths.ts index 549fee2..eb004af 100644 --- a/src/utils/paths.ts +++ b/src/utils/paths.ts @@ -11,7 +11,8 @@ import { basename, isAbsolute, join, resolve } from "node:path"; * Base config directory. Created on first access. */ export function getConfigDir(): string { - const dir = process.env["XO_CONFIG_DIR"] || join(homedir(), ".config", "xo-cli"); + const dir = + process.env["XO_CONFIG_DIR"] || join(homedir(), ".config", "xo-cli"); mkdirSync(dir, { recursive: true }); return dir; } diff --git a/src/utils/pick-template-export.ts b/src/utils/pick-template-export.ts index 30bd144..88f1af5 100644 --- a/src/utils/pick-template-export.ts +++ b/src/utils/pick-template-export.ts @@ -6,7 +6,9 @@ * Returns true when `value` looks like an XOTemplate object (pre-schema check). * Used only to pick the correct export before {@link parseTemplate} validates fully. */ -export function isTemplateLike(value: unknown): value is Record { +export function isTemplateLike( + value: unknown, +): value is Record { if (value === null || typeof value !== "object" || Array.isArray(value)) { return false; } diff --git a/src/utils/rates/base-rates.ts b/src/utils/rates/base-rates.ts index 739278b..f3366d0 100644 --- a/src/utils/rates/base-rates.ts +++ b/src/utils/rates/base-rates.ts @@ -1,4 +1,4 @@ -import { EventEmitter } from '../event-emitter.js'; +import { EventEmitter } from "../event-emitter.js"; /** * Events emitted by our Rates Adapters @@ -44,14 +44,15 @@ export abstract class BaseRates< BCH: 8, USD: 2, }; - const minimumFractionDigits = minimumFractionDigitsMap[normalizedCurrency] ?? 2; + const minimumFractionDigits = + minimumFractionDigitsMap[normalizedCurrency] ?? 2; const maximumFractionDigits = Math.max(minimumFractionDigits, 8); try { - const formatter = new Intl.NumberFormat('en-US', { - style: 'currency', + const formatter = new Intl.NumberFormat("en-US", { + style: "currency", currency: normalizedCurrency, - currencyDisplay: 'narrowSymbol', + currencyDisplay: "narrowSymbol", minimumFractionDigits, maximumFractionDigits, }); @@ -61,7 +62,7 @@ export abstract class BaseRates< // Some numerator symbols from oracle pairs (e.g. DOGE/BCH) are not ISO-4217 // fiat currency codes, so Intl currency formatting will throw a RangeError. // In that case we still return a human-readable formatted value. - const numericFormatter = new Intl.NumberFormat('en-US', { + const numericFormatter = new Intl.NumberFormat("en-US", { minimumFractionDigits, maximumFractionDigits, }); diff --git a/src/utils/rates/rates-oracles.ts b/src/utils/rates/rates-oracles.ts index 12fb468..230701b 100644 --- a/src/utils/rates/rates-oracles.ts +++ b/src/utils/rates/rates-oracles.ts @@ -3,11 +3,11 @@ import { OracleMetadataMessage, OraclePriceMessage, type OracleMetadataMap, -} from '@generalprotocols/oracle-client'; +} from "@generalprotocols/oracle-client"; -import { type RatesEventMap, BaseRates } from './base-rates.js'; -import { type OffCallback } from '../event-emitter.js'; -import { SettingsService } from '../../services/settings.js'; +import { type RatesEventMap, BaseRates } from "./base-rates.js"; +import { type OffCallback } from "../event-emitter.js"; +import { SettingsService } from "../../services/settings.js"; // Add the Oracle Price Message to our Events for this Adapter. export type RatesOracleEventMap = RatesEventMap & { @@ -42,7 +42,7 @@ export class RatesOracle extends BaseRates { private started: boolean = false; private targetNumeratorUnitCode: string; - private targetDenominatorUnitCode: string = 'BCH'; + private targetDenominatorUnitCode: string = "BCH"; private unsubscribeFromSettings: OffCallback | null = null; public constructor(client: OracleClient, settings: SettingsService) { @@ -63,7 +63,7 @@ export class RatesOracle extends BaseRates { } this.started = true; this.unsubscribeFromSettings = this.settings.on( - 'settings-updated', + "settings-updated", this.handleSettingsUpdated.bind(this), ); @@ -150,7 +150,11 @@ export class RatesOracle extends BaseRates { this.handlePriceMessage(message); } } catch (error) { - console.error('Error refreshing prices for oracle:', oracle.publicKey, error); + console.error( + "Error refreshing prices for oracle:", + oracle.publicKey, + error, + ); } }), ); @@ -183,8 +187,10 @@ export class RatesOracle extends BaseRates { return; } - const sourceNumeratorUnitCode = oracle.SOURCE_NUMERATOR_UNIT_CODE.toUpperCase(); - const sourceDenominatorUnitCode = oracle.SOURCE_DENOMINATOR_UNIT_CODE.toUpperCase(); + const sourceNumeratorUnitCode = + oracle.SOURCE_NUMERATOR_UNIT_CODE.toUpperCase(); + const sourceDenominatorUnitCode = + oracle.SOURCE_DENOMINATOR_UNIT_CODE.toUpperCase(); // Only emit the pair currently selected in settings. if ( @@ -197,7 +203,7 @@ export class RatesOracle extends BaseRates { // Scale the price const priceValue = message.priceValue / oracle.ATTESTATION_SCALING; - this.emit('rateUpdated', { + this.emit("rateUpdated", { numeratorUnitCode: sourceNumeratorUnitCode, denominatorUnitCode: sourceDenominatorUnitCode, price: priceValue, @@ -208,13 +214,11 @@ export class RatesOracle extends BaseRates { /** * Tracks updates to settings and switches the actively emitted fiat pair. */ - private handleSettingsUpdated( - event: { - key: 'currency' | 'default-mnemonic'; - value: string | undefined; - }, - ) { - if (event.key !== 'currency' || !event.value) { + private handleSettingsUpdated(event: { + key: "currency" | "default-mnemonic"; + value: string | undefined; + }) { + if (event.key !== "currency" || !event.value) { return; } @@ -223,7 +227,7 @@ export class RatesOracle extends BaseRates { // Refresh so listeners get the latest value for the new currency quickly. if (this.started) { this.refreshPrices().catch((error) => { - console.error('Error refreshing prices after currency update:', error); + console.error("Error refreshing prices after currency update:", error); }); } } diff --git a/src/utils/template-module-loader.ts b/src/utils/template-module-loader.ts index f5ba077..649835d 100644 --- a/src/utils/template-module-loader.ts +++ b/src/utils/template-module-loader.ts @@ -27,8 +27,7 @@ try { const template = pickTemplateExport(loadedModule); process.stdout.write(serializeTemplate(template as XOTemplate)); } catch (error) { - const message = - error instanceof Error ? error.message : String(error); + const message = error instanceof Error ? error.message : String(error); console.error(`Failed to load template module: ${message}`); process.exit(1); } diff --git a/src/utils/template-utils.ts b/src/utils/template-utils.ts index 2d18a01..9aadf60 100644 --- a/src/utils/template-utils.ts +++ b/src/utils/template-utils.ts @@ -188,13 +188,13 @@ export function getRolesForAction( ); return startEntries.map((entry) => { - const roleDef = template.roles?.[entry.role || '']; + const roleDef = template.roles?.[entry.role || ""]; const roleObj = typeof roleDef === "object" ? roleDef : null; // TODO: This is ugly. Lot of conditionals. Need to take a much closer look at this. return { - roleId: entry.role || '', - name: roleObj?.name || entry.role || '', + roleId: entry.role || "", + name: roleObj?.name || entry.role || "", description: roleObj?.description, }; }); diff --git a/src/utils/utxo-metadata.ts b/src/utils/utxo-metadata.ts index e3829f4..636a9b6 100644 --- a/src/utils/utxo-metadata.ts +++ b/src/utils/utxo-metadata.ts @@ -9,7 +9,8 @@ export type UnspentOutputMetadata = { outputIdentifier?: string; }; -export type UnspentOutputWithMetadata = UnspentOutputData & UnspentOutputMetadata; +export type UnspentOutputWithMetadata = UnspentOutputData & + UnspentOutputMetadata; /** * Builds a lookup map from script hash to its stored metadata. diff --git a/tests/cli/autocomplete-completions.test.ts b/tests/cli/autocomplete-completions.test.ts index b74fe44..a786c6c 100644 --- a/tests/cli/autocomplete-completions.test.ts +++ b/tests/cli/autocomplete-completions.test.ts @@ -55,7 +55,7 @@ describe("shell completions", () => { test("uses shell-native mnemonic completion in fish", () => { const completions = generateFishCompletions("xo-cli"); - expect(completions).toContain("set -l config_dir \"$XO_CONFIG_DIR\""); + expect(completions).toContain('set -l config_dir "$XO_CONFIG_DIR"'); expect(completions).toContain("(__xo_cli_complete_mnemonics)"); expect(completions).not.toContain("(__xo_cli_complete_dynamic mnemonics)"); }); @@ -68,9 +68,9 @@ describe("shell completions", () => { const contents = readFileSync(configFile, "utf8"); expect(contents.match(/XO_CONFIG_DIR/g)).toHaveLength(2); - expect(contents.match(/eval "\$\(xo-cli completions bash\)"/g)).toHaveLength( - 1, - ); + expect( + contents.match(/eval "\$\(xo-cli completions bash\)"/g), + ).toHaveLength(1); }); test("adds a missing default without duplicating an existing loader", () => { @@ -79,16 +79,18 @@ describe("shell completions", () => { expect(installCompletions("bash", "xo-cli", configFile)).toBe(true); const contents = readFileSync(configFile, "utf8"); - expect(contents.match(/eval "\$\(xo-cli completions bash\)"/g)).toHaveLength( - 1, - ); + expect( + contents.match(/eval "\$\(xo-cli completions bash\)"/g), + ).toHaveLength(1); expect(contents).toContain( 'export XO_CONFIG_DIR="${XO_CONFIG_DIR:-$HOME/.config/xo-cli}"', ); }); test("preserves an existing custom config directory assignment", () => { - const configFile = createConfigFile("export XO_CONFIG_DIR=/tmp/custom-xo\n"); + const configFile = createConfigFile( + "export XO_CONFIG_DIR=/tmp/custom-xo\n", + ); expect(installCompletions("zsh", "xo-cli", configFile)).toBe(true); diff --git a/tests/cli/commands/settings.test.ts b/tests/cli/commands/settings.test.ts index 60a5925..366a23a 100644 --- a/tests/cli/commands/settings.test.ts +++ b/tests/cli/commands/settings.test.ts @@ -57,7 +57,9 @@ describe("settings command", () => { {}, ); - const persisted = JSON.parse(readFileSync(paths.walletConfigPath, "utf8")) as { + const persisted = JSON.parse( + readFileSync(paths.walletConfigPath, "utf8"), + ) as { currency: string; "default-mnemonic"?: string; }; diff --git a/tests/cli/commands/template.test.ts b/tests/cli/commands/template.test.ts index db3de1b..0031636 100644 --- a/tests/cli/commands/template.test.ts +++ b/tests/cli/commands/template.test.ts @@ -103,7 +103,7 @@ const testCases: TestCase[] = [ inputs: ["export", p2pkhTemplateIdentifier], shouldThrow: false, expectedData: {}, - logs: [{ out: "\"name\":\"Wallet (P2PKH)\"" }], + logs: [{ out: '"name":"Wallet (P2PKH)"' }], }, // Error cases - subcommand { diff --git a/tests/cli/mnemonic.test.ts b/tests/cli/mnemonic.test.ts index 03549c3..75c3b41 100644 --- a/tests/cli/mnemonic.test.ts +++ b/tests/cli/mnemonic.test.ts @@ -113,7 +113,9 @@ describe("mnemonic utilities", () => { // Due to some weird MacOS behavior we need to use realpathSync to get the correct path // Basically, tmpDir() returns a symlink, but moving to that path puts us at `/private/${tmpDir()}` - const expectedPath = realpathSync(path.join(tempDir, "mnemonic-relative")); + const expectedPath = realpathSync( + path.join(tempDir, "mnemonic-relative"), + ); // Compare to the expected path expect(resolved).toBe(expectedPath); diff --git a/tests/cli/mocks/engine.ts b/tests/cli/mocks/engine.ts index bfdb849..568d515 100644 --- a/tests/cli/mocks/engine.ts +++ b/tests/cli/mocks/engine.ts @@ -159,7 +159,9 @@ export const createMockEngine = async (seed: string) => { }; export const createMockAppService = async (engine: Engine) => { - const settings = new SettingsService(`${tmpdir()}/xo-cli-tests-settings.json`); + const settings = new SettingsService( + `${tmpdir()}/xo-cli-tests-settings.json`, + ); settings.setCurrency("USD"); const storage = await InMemoryStorage.create(); diff --git a/tests/cli/mocks/rates-service.ts b/tests/cli/mocks/rates-service.ts index 1237d71..81a04dc 100644 --- a/tests/cli/mocks/rates-service.ts +++ b/tests/cli/mocks/rates-service.ts @@ -5,7 +5,10 @@ export class MockRatesService extends BaseRates { super(); } - async getRate(numeratorUnitCode: string, denominatorUnitCode: string): Promise { + async getRate( + numeratorUnitCode: string, + denominatorUnitCode: string, + ): Promise { return 1; } @@ -20,4 +23,4 @@ export class MockRatesService extends BaseRates { async listPairs(): Promise> { return new Set(); } -} \ No newline at end of file +} diff --git a/tests/cli/mocks/template-p2pkh.ts b/tests/cli/mocks/template-p2pkh.ts index 0cb3033..ebc4df7 100644 --- a/tests/cli/mocks/template-p2pkh.ts +++ b/tests/cli/mocks/template-p2pkh.ts @@ -2,36 +2,37 @@ import type { XOTemplate } from "@xo-cash/types"; import { generateTemplateIdentifier, parseTemplate } from "@xo-cash/engine"; export const p2pkhTemplate: XOTemplate = { - $schema: 'https://libauth.org/schemas/wallet-template-v0.schema.json', + $schema: "https://libauth.org/schemas/wallet-template-v0.schema.json", // Name for this template. - name: 'Wallet (P2PKH)', + name: "Wallet (P2PKH)", // Description for this template. - description: 'A standard single-factor wallet template that uses Pay-to-Public-Key-Hash (P2PKH) locking scripts.', + description: + "A standard single-factor wallet template that uses Pay-to-Public-Key-Hash (P2PKH) locking scripts.", // Icon for this template. - icon: 'wallet', + icon: "wallet", // Version number for this template. - version: '1', + version: "1", // List of VM versions that can be used to run this template. - supported: [ 'BCH_2023_05', 'BCH_2024_05', 'BCH_2025_05', 'BCH_2026_05' ], + supported: ["BCH_2023_05", "BCH_2024_05", "BCH_2025_05", "BCH_2026_05"], // Sets optional default values to be used with this template. defaults: { - // Configures a default intent structure for creating change outputs and locking scripts. - // NOTE: This is used when the engine needs to make change for an output created by - // this template and there is no other policy provided elsewhere that takes precedence. - // NOTE: It is recommended that templates that create outputs with the 'selectable' property - // either provides a change policy here, or a comment that explain that they - // intentionally omit the change policy and why. - change: { - output: 'changeOutput', - role: 'receiver', - generate: [ 'ownerKey' ], - }, + // Configures a default intent structure for creating change outputs and locking scripts. + // NOTE: This is used when the engine needs to make change for an output created by + // this template and there is no other policy provided elsewhere that takes precedence. + // NOTE: It is recommended that templates that create outputs with the 'selectable' property + // either provides a change policy here, or a comment that explain that they + // intentionally omit the change policy and why. + change: { + output: "changeOutput", + role: "receiver", + generate: ["ownerKey"], + }, }, // Describe a list of roles that are used in this template. @@ -39,1402 +40,1457 @@ export const p2pkhTemplate: XOTemplate = { // For example, the same entity that acts as 'receiver' when creating/receiving an output // can later act as 'sender' (or 'owner') when performing a follow-up action. roles: { - owner: { - name: 'Wallet Owner', - description: 'The party who can spend from this wallet.', - icon: 'owner', - }, - receiver: { - name: 'Receiver', - description: 'A party that is receiving value.', - icon: 'receiver', - }, - sender: { - name: 'Sender', - description: 'A party that is sending value.', - icon: 'sender', - }, + owner: { + name: "Wallet Owner", + description: "The party who can spend from this wallet.", + icon: "owner", + }, + receiver: { + name: "Receiver", + description: "A party that is receiving value.", + icon: "receiver", + }, + sender: { + name: "Sender", + description: "A party that is sending value.", + icon: "sender", + }, }, // Define a list of entrypoints supported by this template. start: [ - { - action: 'receive', - role: 'receiver', - generate: [ 'ownerKey' ], - }, - { - action: 'requestSatoshis', - role: 'receiver', - generate: [ 'ownerKey' ], - }, - { - action: 'requestFungibleTokens', - role: 'receiver', - generate: [ 'ownerKey' ], - }, - { - action: 'requestNonfungibleTokens', - role: 'receiver', - generate: [ 'ownerKey' ], - }, + { + action: "receive", + role: "receiver", + generate: ["ownerKey"], + }, + { + action: "requestSatoshis", + role: "receiver", + generate: ["ownerKey"], + }, + { + action: "requestFungibleTokens", + role: "receiver", + generate: ["ownerKey"], + }, + { + action: "requestNonfungibleTokens", + role: "receiver", + generate: ["ownerKey"], + }, ], // Define a list of actions that can be taken by this template. // NOTE: There is no action to generate an address, but a wallet can create an invitation to a receive action and // extract the generated lockscript as needed as the engine will track all lockscripts it generates. actions: { - receive: { - // TODO: Consider rewriting to be generic/role-less. - name: 'Receive', - description: 'Receive an unspecified amount of cash and/or tokens from one or more senders.', - icon: 'receive', + receive: { + // TODO: Consider rewriting to be generic/role-less. + name: "Receive", + description: + "Receive an unspecified amount of cash and/or tokens from one or more senders.", + icon: "receive", - roles: { - receiver: { - name: 'Receive', - description: 'Receive an unspecified amount of cash and/or tokens from one or more senders.', - icon: 'receive', - - requirements: { - secrets: [ 'ownerKey' ], - }, - }, - sender: { - name: 'Send', - description: 'Send an unspecified amount of cash and/or tokens to the provided receiver.', - icon: 'send', - - // The sender only need to provide blockchain-level requirements. - // NOTE: This field is not required when empty, but shown here for illustrative purposes. - requirements: {}, - }, - }, + roles: { + receiver: { + name: "Receive", + description: + "Receive an unspecified amount of cash and/or tokens from one or more senders.", + icon: "receive", requirements: { - participants: [ - { - role: 'receiver', - slots: { min: 1, max: 1 }, - }, - { - role: 'sender', - slots: { min: 1, max: undefined }, - }, - ], - // variables: [ 'requestedSatoshis' ], + secrets: ["ownerKey"], }, + }, + sender: { + name: "Send", + description: + "Send an unspecified amount of cash and/or tokens to the provided receiver.", + icon: "send", - transaction: 'receiveTransaction', + // The sender only need to provide blockchain-level requirements. + // NOTE: This field is not required when empty, but shown here for illustrative purposes. + requirements: {}, + }, }, - requestSatoshis: { - // TODO: Consider rewriting to be generic/role-less. - name: 'Request Satoshis', - description: 'Requests a specific amount of Bitcoin Cash from one or more senders.', - icon: 'request', - roles: { - receiver: { - name: 'Request Satoshis', - description: 'Requests a specific amount of Bitcoin Cash from one or more senders.', - icon: 'request', - - requirements: { - secrets: [ 'ownerKey' ], - variables: [ 'requestedSatoshis' ], - }, - }, - sender: { - name: 'Send', - description: 'Send a specific amount of Bitcoin Cash to the provided receiver.', - icon: 'send', - - // The sender only need to provide blockchain-level requirements. - // NOTE: This field is not required when empty, but shown here for illustrative purposes. - requirements: {}, - }, + requirements: { + participants: [ + { + role: "receiver", + slots: { min: 1, max: 1 }, }, + { + role: "sender", + slots: { min: 1, max: undefined }, + }, + ], + // variables: [ 'requestedSatoshis' ], + }, + + transaction: "receiveTransaction", + }, + requestSatoshis: { + // TODO: Consider rewriting to be generic/role-less. + name: "Request Satoshis", + description: + "Requests a specific amount of Bitcoin Cash from one or more senders.", + icon: "request", + + roles: { + receiver: { + name: "Request Satoshis", + description: + "Requests a specific amount of Bitcoin Cash from one or more senders.", + icon: "request", requirements: { - participants: [ - { - role: 'receiver', - slots: { min: 1, max: 1 }, - }, - { - role: 'sender', - slots: { min: 1, max: undefined }, - }, - ], + secrets: ["ownerKey"], + variables: ["requestedSatoshis"], }, + }, + sender: { + name: "Send", + description: + "Send a specific amount of Bitcoin Cash to the provided receiver.", + icon: "send", - transaction: 'requestSatoshisTransaction', + // The sender only need to provide blockchain-level requirements. + // NOTE: This field is not required when empty, but shown here for illustrative purposes. + requirements: {}, + }, }, - requestFungibleTokens: { - // TODO: Consider rewriting to be generic/role-less. - name: 'Request Fungible Tokens', - description: 'Requests a specific amount of a fungible tokens from one or more senders.', - icon: 'request', - roles: { - receiver: { - name: 'Request Fungible Tokens', - description: 'Requests a specific amount of a fungible tokens from one or more senders.', - icon: 'request', - - requirements: { - secrets: [ 'ownerKey' ], - variables: [ 'requestedTokenCategory', 'requestedTokenAmount' ], - }, - }, - sender: { - name: 'Send', - description: 'Send a specific amount of fungible tokens to the provided receiver.', - icon: 'send', - - // The sender only need to provide blockchain-level requirements. - // NOTE: This field is not required when empty, but shown here for illustrative purposes. - requirements: {}, - }, + requirements: { + participants: [ + { + role: "receiver", + slots: { min: 1, max: 1 }, }, + { + role: "sender", + slots: { min: 1, max: undefined }, + }, + ], + }, + + transaction: "requestSatoshisTransaction", + }, + requestFungibleTokens: { + // TODO: Consider rewriting to be generic/role-less. + name: "Request Fungible Tokens", + description: + "Requests a specific amount of a fungible tokens from one or more senders.", + icon: "request", + + roles: { + receiver: { + name: "Request Fungible Tokens", + description: + "Requests a specific amount of a fungible tokens from one or more senders.", + icon: "request", requirements: { - participants: [ - { - role: 'receiver', - slots: { min: 1, max: 1 }, - }, - { - role: 'sender', - slots: { min: 1, max: undefined }, - }, - ], + secrets: ["ownerKey"], + variables: ["requestedTokenCategory", "requestedTokenAmount"], }, + }, + sender: { + name: "Send", + description: + "Send a specific amount of fungible tokens to the provided receiver.", + icon: "send", - transaction: 'requestFungibleTokensTransaction', + // The sender only need to provide blockchain-level requirements. + // NOTE: This field is not required when empty, but shown here for illustrative purposes. + requirements: {}, + }, }, - requestNonfungibleTokens: { - // TODO: Consider rewriting to be generic/role-less. - name: 'Request a Non-fungible Token', - description: 'Requests a non-fungible token from one or more senders.', - icon: 'request', - roles: { - receiver: { - name: 'Request a Non-fungible Token', - description: 'Requests a non-fungible token from one or more senders.', - icon: 'request', - - requirements: { - secrets: [ 'ownerKey' ], - variables: [ 'requestedTokenCategory', 'requestedTokenCapability', 'requestedTokenCommitment' ], - }, - }, - sender: { - name: 'Send', - description: 'Send a non-fungible token to the provided receiver.', - icon: 'send', - - // The sender only need to provide blockchain-level requirements. - // NOTE: This field is not required when empty, but shown here for illustrative purposes. - requirements: {}, - }, + requirements: { + participants: [ + { + role: "receiver", + slots: { min: 1, max: 1 }, }, + { + role: "sender", + slots: { min: 1, max: undefined }, + }, + ], + }, + + transaction: "requestFungibleTokensTransaction", + }, + requestNonfungibleTokens: { + // TODO: Consider rewriting to be generic/role-less. + name: "Request a Non-fungible Token", + description: "Requests a non-fungible token from one or more senders.", + icon: "request", + + roles: { + receiver: { + name: "Request a Non-fungible Token", + description: + "Requests a non-fungible token from one or more senders.", + icon: "request", requirements: { - participants: [ - { - role: 'receiver', - slots: { min: 1, max: 1 }, - }, - { - role: 'sender', - slots: { min: 1, max: undefined }, - }, - ], + secrets: ["ownerKey"], + variables: [ + "requestedTokenCategory", + "requestedTokenCapability", + "requestedTokenCommitment", + ], }, + }, + sender: { + name: "Send", + description: "Send a non-fungible token to the provided receiver.", + icon: "send", - transaction: 'requestNonfungibleTokensTransaction', + // The sender only need to provide blockchain-level requirements. + // NOTE: This field is not required when empty, but shown here for illustrative purposes. + requirements: {}, + }, }, - // NOTE: Sending value can be done without explicit template support. - // NOTE: This feature is explicitly defined in this template to demonstrate how versatile templates can be, and - // to ensure the feature is discoverable by the user from outputs that hold value. - sendSatoshis: { - name: 'Send Satoshis', - description: 'Sends a specific amount of Bitcoin Cash to a given recipient.', - icon: 'send', - - roles: { - sender: { - requirements: { - variables: [ 'transferredSatoshis', 'recipientLockingscript' ], - secrets: [ 'ownerKey' ], - }, - }, + requirements: { + participants: [ + { + role: "receiver", + slots: { min: 1, max: 1 }, }, + { + role: "sender", + slots: { min: 1, max: undefined }, + }, + ], + }, + transaction: "requestNonfungibleTokensTransaction", + }, + + // NOTE: Sending value can be done without explicit template support. + // NOTE: This feature is explicitly defined in this template to demonstrate how versatile templates can be, and + // to ensure the feature is discoverable by the user from outputs that hold value. + sendSatoshis: { + name: "Send Satoshis", + description: + "Sends a specific amount of Bitcoin Cash to a given recipient.", + icon: "send", + + roles: { + sender: { requirements: { - participants: [ - { - role: 'sender', - slots: { min: 1, max: 1 }, - }, - ], + variables: ["transferredSatoshis", "recipientLockingscript"], + secrets: ["ownerKey"], }, - - // Sending is only available for outputs that have sufficient satoshis on them. - // NOTE: Dust is enforced here according to standardness rules. - conditions: [ '$(OP_INPUTINDEX OP_UTXOVALUE OP_GREATERTHAN)' ], - - transaction: 'transferSatoshisTransaction', + }, }, - sendFungibleTokens: { - name: 'Send Fungible Tokens', - description: 'Send a specific amount of a fungible token to a given recipient.', - icon: 'send', - roles: { - sender: { - requirements: { - variables: [ 'transferredTokenCategory', 'transferredTokenAmount', 'recipientLockingscript' ], - secrets: [ 'ownerKey' ], - }, - }, + requirements: { + participants: [ + { + role: "sender", + slots: { min: 1, max: 1 }, }, + ], + }, + // Sending is only available for outputs that have sufficient satoshis on them. + // NOTE: Dust is enforced here according to standardness rules. + conditions: ["$(OP_INPUTINDEX OP_UTXOVALUE OP_GREATERTHAN)"], + + transaction: "transferSatoshisTransaction", + }, + sendFungibleTokens: { + name: "Send Fungible Tokens", + description: + "Send a specific amount of a fungible token to a given recipient.", + icon: "send", + + roles: { + sender: { requirements: { - participants: [ - { - role: 'sender', - slots: { min: 1, max: 1 }, - }, - ], + variables: [ + "transferredTokenCategory", + "transferredTokenAmount", + "recipientLockingscript", + ], + secrets: ["ownerKey"], }, - - // Sending is only available for outputs that have fungible tokens on them. - conditions: [ '$(OP_INPUTINDEX OP_UTXOTOKENAMOUNT <0> OP_GREATERTHAN)' ], - - transaction: 'transferFungibleTokensTransaction', + }, }, - sendNonfungibleTokens: { - name: 'Send a Non-fungible Token', - description: 'Send a non-fungible token to a given recipient.', - icon: 'send', - roles: { - sender: { - requirements: { - variables: [ 'transferredTokenCategory', 'transferredTokenCapability', 'transferredTokenCommitment', 'recipientLockingscript' ], - secrets: [ 'ownerKey' ], - }, - }, + requirements: { + participants: [ + { + role: "sender", + slots: { min: 1, max: 1 }, }, + ], + }, + // Sending is only available for outputs that have fungible tokens on them. + conditions: ["$(OP_INPUTINDEX OP_UTXOTOKENAMOUNT <0> OP_GREATERTHAN)"], + + transaction: "transferFungibleTokensTransaction", + }, + sendNonfungibleTokens: { + name: "Send a Non-fungible Token", + description: "Send a non-fungible token to a given recipient.", + icon: "send", + + roles: { + sender: { requirements: { - participants: [ - { - role: 'sender', - slots: { min: 1, max: 1 }, - }, - ], + variables: [ + "transferredTokenCategory", + "transferredTokenCapability", + "transferredTokenCommitment", + "recipientLockingscript", + ], + secrets: ["ownerKey"], }, - - // Sending is only available for outputs that have a non-fungible token on them. - conditions: [ '$(OP_INPUTINDEX OP_UTXOTOKENCATEGORY OP_SIZE OP_NIP <32> OP_GREATERTHAN)' ], - - transaction: 'transferNonfungibleTokensTransaction', + }, }, - // NOTE: Burning tokens can be done without explicit template support. - // NOTE: This feature is explicitly defined in this template to demonstrate how versatile templates can be, and - // to ensure the feature is discoverable by the user from outputs that hold tokens. - burnFungibleTokens: { - name: 'Delete Fungible Tokens', - description: 'Permanently and irreversibly deletes one or more fungible tokens.', - icon: 'burn', - - roles: { - owner: { - requirements: { - variables: [ 'burnedTokenCategory', 'burnedTokenAmount' ], - secrets: [ 'ownerKey' ], - }, - }, + requirements: { + participants: [ + { + role: "sender", + slots: { min: 1, max: 1 }, }, + ], + }, + // Sending is only available for outputs that have a non-fungible token on them. + conditions: [ + "$(OP_INPUTINDEX OP_UTXOTOKENCATEGORY OP_SIZE OP_NIP <32> OP_GREATERTHAN)", + ], + + transaction: "transferNonfungibleTokensTransaction", + }, + + // NOTE: Burning tokens can be done without explicit template support. + // NOTE: This feature is explicitly defined in this template to demonstrate how versatile templates can be, and + // to ensure the feature is discoverable by the user from outputs that hold tokens. + burnFungibleTokens: { + name: "Delete Fungible Tokens", + description: + "Permanently and irreversibly deletes one or more fungible tokens.", + icon: "burn", + + roles: { + owner: { requirements: { - participants: [ - { - role: 'owner', - slots: { min: 1, max: 1 }, - }, - ], + variables: ["burnedTokenCategory", "burnedTokenAmount"], + secrets: ["ownerKey"], }, - - // Burning is only available for outputs that have fungible tokens on them. - conditions: [ '$(OP_INPUTINDEX OP_UTXOTOKENAMOUNT <0> OP_GREATERTHAN)' ], - - transaction: 'burnFungibleTokensTransaction', + }, }, - burnNonfungibleTokens: { - name: 'Delete a Non-fungible Token', - description: 'Permanently and irreversibly deletes one non-fungible token.', - icon: 'burn', - roles: { - owner: { - requirements: { - variables: [ 'burnedTokenCategory', 'burnedTokenCapability', 'burnedTokenCommitment' ], - secrets: [ 'ownerKey' ], - }, - }, + requirements: { + participants: [ + { + role: "owner", + slots: { min: 1, max: 1 }, }, + ], + }, + // Burning is only available for outputs that have fungible tokens on them. + conditions: ["$(OP_INPUTINDEX OP_UTXOTOKENAMOUNT <0> OP_GREATERTHAN)"], + + transaction: "burnFungibleTokensTransaction", + }, + burnNonfungibleTokens: { + name: "Delete a Non-fungible Token", + description: + "Permanently and irreversibly deletes one non-fungible token.", + icon: "burn", + + roles: { + owner: { requirements: { - participants: [ - { - role: 'owner', - slots: { min: 1, max: 1 }, - }, - ], + variables: [ + "burnedTokenCategory", + "burnedTokenCapability", + "burnedTokenCommitment", + ], + secrets: ["ownerKey"], }, - - // Burning is only available for outputs that have non-fungible tokens on them. - conditions: [ '$(OP_INPUTINDEX OP_UTXOTOKENCATEGORY OP_SIZE OP_NIP <32> OP_GREATERTHAN)' ], - - transaction: 'burnNonfungibleTokenTransaction', + }, }, - sign: { - name: 'Sign Message', - description: 'Signs a provided message using the Bitcoin message signing protocol.', - icon: 'sign', - - roles: { - owner: { - requirements: { - variables: [ 'messageToSign' ], - secrets: [ 'ownerKey' ], - }, - }, + requirements: { + participants: [ + { + role: "owner", + slots: { min: 1, max: 1 }, }, + ], + }, + // Burning is only available for outputs that have non-fungible tokens on them. + conditions: [ + "$(OP_INPUTINDEX OP_UTXOTOKENCATEGORY OP_SIZE OP_NIP <32> OP_GREATERTHAN)", + ], + + transaction: "burnNonfungibleTokenTransaction", + }, + + sign: { + name: "Sign Message", + description: + "Signs a provided message using the Bitcoin message signing protocol.", + icon: "sign", + + roles: { + owner: { requirements: { - participants: [ - { - role: 'owner', - slots: { min: 1, max: 1 }, - }, - ], + variables: ["messageToSign"], + secrets: ["ownerKey"], }, - - data: 'messageSignature', + }, }, - verify: { - name: 'Verify Message Signature', - description: 'Verifies a provided message signature according to the Bitcoin message signing protocol.', - icon: 'verify', - roles: { - owner: { - requirements: { - variables: [ 'messageSignature', 'messageToVerify' ], - secrets: [ 'ownerKey' ], - }, - }, + requirements: { + participants: [ + { + role: "owner", + slots: { min: 1, max: 1 }, }, + ], + }, + data: "messageSignature", + }, + verify: { + name: "Verify Message Signature", + description: + "Verifies a provided message signature according to the Bitcoin message signing protocol.", + icon: "verify", + + roles: { + owner: { requirements: { - participants: [ - { - role: 'owner', - slots: { min: 1, max: 1 }, - }, - ], + variables: ["messageSignature", "messageToVerify"], + secrets: ["ownerKey"], }, - - data: 'messageSignatureValidity', + }, }, + + requirements: { + participants: [ + { + role: "owner", + slots: { min: 1, max: 1 }, + }, + ], + }, + + data: "messageSignatureValidity", + }, }, // Define a set of data that can be used in this template. data: { - messageSignature: { - // Evaluate CashASM expression to get the signature needed. - // NOTE: Pushes the prefix and message, then concatenates them together to form the data to sign. - // NOTE: In libauth today, it seems that this is done by defining the signature as a variable, and tying it to the key and message, - // and so this is different - // TODO: Check with Jason and see if there is any reason why this cannot be done like this. - value: '$( OP_CAT )', - type: 'bytes', - hint: 'signature', - }, - messageSignatureValidity: { - // Evaluate the validity of the message with the owners public key. - value: '$( OP_CAT OP_CHECKDATASIG)', - type: 'integer', - hint: 'script_boolean', - }, + messageSignature: { + // Evaluate CashASM expression to get the signature needed. + // NOTE: Pushes the prefix and message, then concatenates them together to form the data to sign. + // NOTE: In libauth today, it seems that this is done by defining the signature as a variable, and tying it to the key and message, + // and so this is different + // TODO: Check with Jason and see if there is any reason why this cannot be done like this. + value: + "$( OP_CAT )", + type: "bytes", + hint: "signature", + }, + messageSignatureValidity: { + // Evaluate the validity of the message with the owners public key. + value: + "$( OP_CAT OP_CHECKDATASIG)", + type: "integer", + hint: "script_boolean", + }, }, // Define a set of transactions that can be used in this template. transactions: { - receiveTransaction: { - name: 'Transfer Completed', - description: 'Transferred an unspecified amount of cash and/or tokens.', - icon: 'request', + receiveTransaction: { + name: "Transfer Completed", + description: "Transferred an unspecified amount of cash and/or tokens.", + icon: "request", - roles: { - receiver: { - name: 'Received', - description: 'Received an unspecified amount of cash and/or tokens.', - icon: 'receive', - }, - sender: { - name: 'Sent', - description: 'Sent an unspecified amount of cash and/or tokens.', - icon: 'send', - }, - }, - - // Inputs and outputs that must exist in the transaction. - // NOTE: There is no inputs required, but the engine should detect that there is not sufficient input value to - // match the output and thus generate an invitation to participate in this action. - // When the invitation is shared, the other parties can add as many inputs and change outputs as needed. - inputs: [], - outputs: [ - { - output: 'receiveOutput', - outputIndex: undefined, - }, - ], - - // Standard transaction without a locktime. - version: 2, - locktime: 0, - - // ... - composable: true, + roles: { + receiver: { + name: "Received", + description: "Received an unspecified amount of cash and/or tokens.", + icon: "receive", + }, + sender: { + name: "Sent", + description: "Sent an unspecified amount of cash and/or tokens.", + icon: "send", + }, }, - requestSatoshisTransaction: { - name: 'Satoshis Transferred', - description: 'Transferred $() satoshis.', - icon: 'request', - roles: { - receiver: { - name: 'Received', - description: 'Received $() satoshis.', - icon: 'receive', - }, - sender: { - name: 'Sent', - description: 'Sent $() satoshis.', - icon: 'send', - }, - }, + // Inputs and outputs that must exist in the transaction. + // NOTE: There is no inputs required, but the engine should detect that there is not sufficient input value to + // match the output and thus generate an invitation to participate in this action. + // When the invitation is shared, the other parties can add as many inputs and change outputs as needed. + inputs: [], + outputs: [ + { + output: "receiveOutput", + outputIndex: undefined, + }, + ], - // Inputs and outputs that must exist in the transaction. - // NOTE: There is no inputs required, but the engine should detect that there is not sufficient input value to - // match the output and thus generate an invitation to participate in this action. - // When the invitation is shared, the other party can add as many inputs and outputs as needed since this transaction is composable. - inputs: [], - outputs: [ - { - output: 'requestSatoshisOutput', - outputIndex: undefined, - }, - ], + // Standard transaction without a locktime. + version: 2, + locktime: 0, - // Standard transaction without a locktime. - version: 2, - locktime: 0, + // ... + composable: true, + }, + requestSatoshisTransaction: { + name: "Satoshis Transferred", + description: "Transferred $() satoshis.", + icon: "request", - // ... - composable: true, + roles: { + receiver: { + name: "Received", + description: "Received $() satoshis.", + icon: "receive", + }, + sender: { + name: "Sent", + description: "Sent $() satoshis.", + icon: "send", + }, }, - requestFungibleTokensTransaction: { - name: 'Fungible Tokens Transferred', + + // Inputs and outputs that must exist in the transaction. + // NOTE: There is no inputs required, but the engine should detect that there is not sufficient input value to + // match the output and thus generate an invitation to participate in this action. + // When the invitation is shared, the other party can add as many inputs and outputs as needed since this transaction is composable. + inputs: [], + outputs: [ + { + output: "requestSatoshisOutput", + outputIndex: undefined, + }, + ], + + // Standard transaction without a locktime. + version: 2, + locktime: 0, + + // ... + composable: true, + }, + requestFungibleTokensTransaction: { + name: "Fungible Tokens Transferred", + description: + "Transferred $( OP_DIV).$( OP_MOD) $() tokens.", + icon: "request", + + roles: { + receiver: { + name: "Received", description: - 'Transferred $( OP_DIV).$( OP_MOD) $() tokens.', - icon: 'request', - - roles: { - receiver: { - name: 'Received', - description: - 'Received $( OP_DIV).$( OP_MOD) $() tokens.', - icon: 'receive', - }, - sender: { - name: 'Sent', - description: - 'Sent $( OP_DIV).$( OP_MOD) $() tokens.', - icon: 'send', - }, - }, - - // Inputs and outputs that must exist in the transaction. - // NOTE: There is no inputs required, but the engine should detect that there is not sufficient input value to - // match the output and thus generate an invitation to participate in this action. - // When the invitation is shared, the other party can add as many inputs and outputs as needed since this transaction is composable. - inputs: [], - outputs: [ - { - output: 'requestFungibleTokensOutput', - outputIndex: undefined, - }, - ], - - // Standard transaction without a locktime. - version: 2, - locktime: 0, - - // ... - composable: true, - }, - requestNonfungibleTokensTransaction: { - name: 'Non-Fungible Token Transferred', + "Received $( OP_DIV).$( OP_MOD) $() tokens.", + icon: "receive", + }, + sender: { + name: "Sent", description: - 'Transferred one non-fungible $( <0x02> OP_EQUAL OP_IF <"minting"> OP_ELSE <0x01> OP_EQUAL OP_IF <"mutable"> OP_ELSE <"immutable"> OP_ENDIF OP_ENDIF) () token, with $() commitment.', - icon: 'request', - - roles: { - receiver: { - name: 'Received', - description: - 'Received one non-fungible $( <0x02> OP_EQUAL OP_IF <"minting"> OP_ELSE <0x01> OP_EQUAL OP_IF <"mutable"> OP_ELSE <"immutable"> OP_ENDIF OP_ENDIF) () token, with $() commitment.', - icon: 'receive', - }, - sender: { - name: 'Sent', - description: - 'Sent the requested non-fungible $( <0x02> OP_EQUAL OP_IF <"minting"> OP_ELSE <0x01> OP_EQUAL OP_IF <"mutable"> OP_ELSE <"immutable"> OP_ENDIF OP_ENDIF) () token, with $() commitment.', - icon: 'send', - }, - }, - - // Inputs and outputs that must exist in the transaction. - // NOTE: There is no inputs required, but the engine should detect that there is not sufficient input value to - // match the output and thus generate an invitation to participate in this action. - // When the invitation is shared, the other party can add as many inputs and outputs as needed since this transaction is composable. - inputs: [], - outputs: [ - { - output: 'requestNonfungibleTokensOutput', - outputIndex: undefined, - }, - ], - - // Standard transaction without a locktime. - version: 2, - locktime: 0, - - // ... - composable: true, + "Sent $( OP_DIV).$( OP_MOD) $() tokens.", + icon: "send", + }, }, - transferSatoshisTransaction: { - name: 'Satoshis Transferred', - description: '$() satoshis were transferred to a recipient.', - icon: 'send', + // Inputs and outputs that must exist in the transaction. + // NOTE: There is no inputs required, but the engine should detect that there is not sufficient input value to + // match the output and thus generate an invitation to participate in this action. + // When the invitation is shared, the other party can add as many inputs and outputs as needed since this transaction is composable. + inputs: [], + outputs: [ + { + output: "requestFungibleTokensOutput", + outputIndex: undefined, + }, + ], - roles: { - receiver: { - name: 'Received', - description: 'Received $() satoshis.', - icon: 'receive', - }, - sender: { - name: 'Sent', - description: 'Sent $() satoshis.', - icon: 'send', - }, - }, + // Standard transaction without a locktime. + version: 2, + locktime: 0, - // Enforce the inputs and outputs required by the transaction. - // NOTE: The input is provided from the action since it is only available on outputs with satoshis. - inputs: [], - outputs: [ - { - output: 'transferSatoshisOutput', - outputIndex: undefined, - }, - ], + // ... + composable: true, + }, + requestNonfungibleTokensTransaction: { + name: "Non-Fungible Token Transferred", + description: + 'Transferred one non-fungible $( <0x02> OP_EQUAL OP_IF <"minting"> OP_ELSE <0x01> OP_EQUAL OP_IF <"mutable"> OP_ELSE <"immutable"> OP_ENDIF OP_ENDIF) () token, with $() commitment.', + icon: "request", - // Standard transaction without a locktime. - version: 2, - locktime: 0, - - // ... - composable: true, - }, - transferFungibleTokensTransaction: { - name: 'Fungible Tokens Transferred', + roles: { + receiver: { + name: "Received", description: - '$( OP_DIV).$( OP_MOD) $() tokens were transferred to a recipient.', - icon: 'send', - - roles: { - receiver: { - name: 'Received', - description: - 'Received $( OP_DIV).$( OP_MOD) $() tokens.', - icon: 'receive', - }, - sender: { - name: 'Sent', - description: - 'Sent $( OP_DIV).$( OP_MOD) $() tokens.', - icon: 'send', - }, - }, - - // Enforce the inputs and outputs required by the transaction. - // NOTE: The input is provided from the action since it is only available on outputs with fungible tokens. - inputs: [], - outputs: [ - { - output: 'transferFungibleTokensOutput', - outputIndex: undefined, - }, - ], - - // Standard transaction without a locktime. - version: 2, - locktime: 0, - - // ... - composable: true, - }, - transferNonfungibleTokensTransaction: { - name: 'Non-fungible Token Transferred', + 'Received one non-fungible $( <0x02> OP_EQUAL OP_IF <"minting"> OP_ELSE <0x01> OP_EQUAL OP_IF <"mutable"> OP_ELSE <"immutable"> OP_ENDIF OP_ENDIF) () token, with $() commitment.', + icon: "receive", + }, + sender: { + name: "Sent", description: - 'One non-fungible $( <0x02> OP_EQUAL OP_IF <"minting"> OP_ELSE <0x01> OP_EQUAL OP_IF <"mutable"> OP_ELSE <"immutable"> OP_ENDIF OP_ENDIF) $() token was transferred to a recipient, with $() commitment.', - icon: 'send', - - roles: { - receiver: { - name: 'Received', - description: - 'Received one non-fungible $( <0x02> OP_EQUAL OP_IF <"minting"> OP_ELSE <0x01> OP_EQUAL OP_IF <"mutable"> OP_ELSE <"immutable"> OP_ENDIF OP_ENDIF) () token, with $() commitment.', - icon: 'receive', - }, - sender: { - name: 'Sent', - description: - 'Sent one non-fungible $( <0x02> OP_EQUAL OP_IF <"minting"> OP_ELSE <0x01> OP_EQUAL OP_IF <"mutable"> OP_ELSE <"immutable"> OP_ENDIF OP_ENDIF) () token, with $() commitment.', - icon: 'send', - }, - }, - - // Enforce the inputs and outputs required by the transaction. - // NOTE: The input is provided from the action since it is only available on outputs with a non-fungible token. - inputs: [], - outputs: [ - { - output: 'transferNonfungibleTokenOutput', - outputIndex: undefined, - }, - ], - - // Standard transaction without a locktime. - version: 2, - locktime: 0, - - // ... - composable: true, + 'Sent the requested non-fungible $( <0x02> OP_EQUAL OP_IF <"minting"> OP_ELSE <0x01> OP_EQUAL OP_IF <"mutable"> OP_ELSE <"immutable"> OP_ENDIF OP_ENDIF) () token, with $() commitment.', + icon: "send", + }, }, - burnFungibleTokensTransaction: { - name: 'Deleted fungible tokens', + // Inputs and outputs that must exist in the transaction. + // NOTE: There is no inputs required, but the engine should detect that there is not sufficient input value to + // match the output and thus generate an invitation to participate in this action. + // When the invitation is shared, the other party can add as many inputs and outputs as needed since this transaction is composable. + inputs: [], + outputs: [ + { + output: "requestNonfungibleTokensOutput", + outputIndex: undefined, + }, + ], + + // Standard transaction without a locktime. + version: 2, + locktime: 0, + + // ... + composable: true, + }, + + transferSatoshisTransaction: { + name: "Satoshis Transferred", + description: + "$() satoshis were transferred to a recipient.", + icon: "send", + + roles: { + receiver: { + name: "Received", + description: "Received $() satoshis.", + icon: "receive", + }, + sender: { + name: "Sent", + description: "Sent $() satoshis.", + icon: "send", + }, + }, + + // Enforce the inputs and outputs required by the transaction. + // NOTE: The input is provided from the action since it is only available on outputs with satoshis. + inputs: [], + outputs: [ + { + output: "transferSatoshisOutput", + outputIndex: undefined, + }, + ], + + // Standard transaction without a locktime. + version: 2, + locktime: 0, + + // ... + composable: true, + }, + transferFungibleTokensTransaction: { + name: "Fungible Tokens Transferred", + description: + "$( OP_DIV).$( OP_MOD) $() tokens were transferred to a recipient.", + icon: "send", + + roles: { + receiver: { + name: "Received", description: - 'Permanently and irreversibly deleted $( OP_DIV).$( OP_MOD) $() tokens.', - icon: 'burn', - - // Inputs and outputs that must exist in the transaction. - // NOTE: There is no defined outputs as any non-burned value is automatically returned as change. - inputs: [ - { - input: 'burnFungibleTokensInput', - inputIndex: undefined, - }, - ], - - outputs: [], - - // Standard transaction without a locktime. - version: 2, - locktime: 0, - - // ... - composable: true, - }, - burnNonfungibleTokenTransaction: { - name: 'Deleted non fungible token', + "Received $( OP_DIV).$( OP_MOD) $() tokens.", + icon: "receive", + }, + sender: { + name: "Sent", description: - 'Permanently and irreversibly deleted a non-fungible $( <0x02> OP_EQUAL OP_IF <"minting"> OP_ELSE <0x01> OP_EQUAL OP_IF <"mutable"> OP_ELSE <"immutable"> OP_ENDIF OP_ENDIF) () token, with $() commitment.', - icon: 'burn', - - // Inputs and outputs that must exist in the transaction. - // NOTE: There is no defined outputs as any non-burned value is automatically returned as change. - inputs: [ - { - input: 'burnNonfungibleTokenInput', - inputIndex: undefined, - }, - ], - outputs: [], - - // Standard transaction without a locktime. - version: 2, - locktime: 0, - - // ... - composable: true, + "Sent $( OP_DIV).$( OP_MOD) $() tokens.", + icon: "send", + }, }, + + // Enforce the inputs and outputs required by the transaction. + // NOTE: The input is provided from the action since it is only available on outputs with fungible tokens. + inputs: [], + outputs: [ + { + output: "transferFungibleTokensOutput", + outputIndex: undefined, + }, + ], + + // Standard transaction without a locktime. + version: 2, + locktime: 0, + + // ... + composable: true, + }, + transferNonfungibleTokensTransaction: { + name: "Non-fungible Token Transferred", + description: + 'One non-fungible $( <0x02> OP_EQUAL OP_IF <"minting"> OP_ELSE <0x01> OP_EQUAL OP_IF <"mutable"> OP_ELSE <"immutable"> OP_ENDIF OP_ENDIF) $() token was transferred to a recipient, with $() commitment.', + icon: "send", + + roles: { + receiver: { + name: "Received", + description: + 'Received one non-fungible $( <0x02> OP_EQUAL OP_IF <"minting"> OP_ELSE <0x01> OP_EQUAL OP_IF <"mutable"> OP_ELSE <"immutable"> OP_ENDIF OP_ENDIF) () token, with $() commitment.', + icon: "receive", + }, + sender: { + name: "Sent", + description: + 'Sent one non-fungible $( <0x02> OP_EQUAL OP_IF <"minting"> OP_ELSE <0x01> OP_EQUAL OP_IF <"mutable"> OP_ELSE <"immutable"> OP_ENDIF OP_ENDIF) () token, with $() commitment.', + icon: "send", + }, + }, + + // Enforce the inputs and outputs required by the transaction. + // NOTE: The input is provided from the action since it is only available on outputs with a non-fungible token. + inputs: [], + outputs: [ + { + output: "transferNonfungibleTokenOutput", + outputIndex: undefined, + }, + ], + + // Standard transaction without a locktime. + version: 2, + locktime: 0, + + // ... + composable: true, + }, + + burnFungibleTokensTransaction: { + name: "Deleted fungible tokens", + description: + "Permanently and irreversibly deleted $( OP_DIV).$( OP_MOD) $() tokens.", + icon: "burn", + + // Inputs and outputs that must exist in the transaction. + // NOTE: There is no defined outputs as any non-burned value is automatically returned as change. + inputs: [ + { + input: "burnFungibleTokensInput", + inputIndex: undefined, + }, + ], + + outputs: [], + + // Standard transaction without a locktime. + version: 2, + locktime: 0, + + // ... + composable: true, + }, + burnNonfungibleTokenTransaction: { + name: "Deleted non fungible token", + description: + 'Permanently and irreversibly deleted a non-fungible $( <0x02> OP_EQUAL OP_IF <"minting"> OP_ELSE <0x01> OP_EQUAL OP_IF <"mutable"> OP_ELSE <"immutable"> OP_ENDIF OP_ENDIF) () token, with $() commitment.', + icon: "burn", + + // Inputs and outputs that must exist in the transaction. + // NOTE: There is no defined outputs as any non-burned value is automatically returned as change. + inputs: [ + { + input: "burnNonfungibleTokenInput", + inputIndex: undefined, + }, + ], + outputs: [], + + // Standard transaction without a locktime. + version: 2, + locktime: 0, + + // ... + composable: true, + }, }, // Define a set of outputs that can be used within transactions in this template. outputs: { - changeOutput: { - name: 'Change', - description: 'Funds returned as change.', - icon: 'receive', + changeOutput: { + name: "Change", + description: "Funds returned as change.", + icon: "receive", - // Defines how the requested funds should be locked. - lockingScript: 'receivingLockingScript', + // Defines how the requested funds should be locked. + lockingScript: "receivingLockingScript", + }, + receiveOutput: { + name: "Recipient output", + description: + "Transferred an unspecified amount of cash and/or tokens to a recipient.", + icon: "receive", + + roles: { + receiver: { + name: "Received", + description: "Received an unspecified amount of cash and/or tokens.", + }, + sender: { + name: "Sent", + description: "Sent an unspecified amount of cash and/or tokens.", + }, }, - receiveOutput: { - name: 'Recipient output', - description: 'Transferred an unspecified amount of cash and/or tokens to a recipient.', - icon: 'receive', - roles: { - receiver: { - name: 'Received', - description: 'Received an unspecified amount of cash and/or tokens.', - }, - sender: { - name: 'Sent', - description: 'Sent an unspecified amount of cash and/or tokens.', - }, - }, + // Defines how the requested funds should be locked. + lockingScript: "receivingLockingScript", + }, + requestSatoshisOutput: { + name: "Satoshis", + description: "$() satoshis.", + icon: "request", - // Defines how the requested funds should be locked. - lockingScript: 'receivingLockingScript', + roles: { + receiver: { + name: "Satoshis Received", + description: "Received $() satoshis.", + }, + sender: { + name: "Satoshis Sent", + description: "Sent $() satoshis.", + }, }, - requestSatoshisOutput: { - name: 'Satoshis', - description: '$() satoshis.', - icon: 'request', - roles: { - receiver: { - name: 'Satoshis Received', - description: 'Received $() satoshis.', - }, - sender: { - name: 'Satoshis Sent', - description: 'Sent $() satoshis.', - }, - }, + // Defines how the requested funds should be locked. + lockingScript: "receivingLockingScript", - // Defines how the requested funds should be locked. - lockingScript: 'receivingLockingScript', + // Require the specified number of satoshis and no tokens. + valueSatoshis: "$()", + token: null, + }, + requestFungibleTokensOutput: { + name: "Fungible $() Tokens", + description: + "$( OP_DIV).$( OP_MOD) $() tokens.", + icon: "request", - // Require the specified number of satoshis and no tokens. - valueSatoshis: '$()', - token: null, - }, - requestFungibleTokensOutput: { - name: 'Fungible $() Tokens', + roles: { + receiver: { + name: "Fungible $() Tokens Received", description: - '$( OP_DIV).$( OP_MOD) $() tokens.', - icon: 'request', - - roles: { - receiver: { - name: 'Fungible $() Tokens Received', - description: - 'Received $( OP_DIV).$( OP_MOD) $() tokens.', - }, - sender: { - name: 'Fungible $() Tokens Sent', - description: - 'Sent $( OP_DIV).$( OP_MOD) $() tokens.', - }, - }, - - // Defines how the requested funds should be locked. - lockingScript: 'receivingLockingScript', - - // Require a flat 1000 satoshis to ensure the fungible tokens remains transferrable. - valueSatoshis: '1000', - - // Require only the specified amount and type of fungible tokens. - // NOTE: This can be composed with a request for a non-fungible token, but will always result in two separate outputs. - token: { - category: '$()', - amount: '$()', - nft: null, - }, - }, - requestNonfungibleTokensOutput: { - name: 'Non-fungible $() Token', + "Received $( OP_DIV).$( OP_MOD) $() tokens.", + }, + sender: { + name: "Fungible $() Tokens Sent", description: - 'Transferred one non-fungible $( <0x02> OP_EQUAL OP_IF <"minting"> OP_ELSE <0x01> OP_EQUAL OP_IF <"mutable"> OP_ELSE <"immutable"> OP_ENDIF OP_ENDIF) $() token to a recipient, with $() commitment.', - icon: 'request', - - roles: { - receiver: { - name: 'Non-fungible $() Token Received', - description: - 'Received one non-fungible $( <0x02> OP_EQUAL OP_IF <"minting"> OP_ELSE <0x01> OP_EQUAL OP_IF <"mutable"> OP_ELSE <"immutable"> OP_ENDIF OP_ENDIF) $() token, with $() commitment.', - }, - sender: { - name: 'Non-fungible $() Token Sent', - description: - 'Sent the requested non-fungible $( <0x02> OP_EQUAL OP_IF <"minting"> OP_ELSE <0x01> OP_EQUAL OP_IF <"mutable"> OP_ELSE <"immutable"> OP_ENDIF OP_ENDIF) $() token, with $() commitment.', - }, - }, - - // Defines how the requested funds should be locked. - lockingScript: 'receivingLockingScript', - - // Require a flat 1000 satoshis to ensure the non-fungible token remains transferrable. - valueSatoshis: '1000', - - // Require only an NFT with specified category, capability and commitment. - // NOTE: This can be composed with a request for fungible token amounts, but will always result in two separate outputs. - token: { - category: '$()', - amount: null, - nft: { - capability: '$()', - commitment: '$()', - }, - }, + "Sent $( OP_DIV).$( OP_MOD) $() tokens.", + }, }, - transferSatoshisOutput: { - name: 'Recipient output', - description: 'Transferred $() satoshis to a recipient.', - icon: 'send', + // Defines how the requested funds should be locked. + lockingScript: "receivingLockingScript", - roles: { - receiver: { - name: 'Received', - description: 'Received $() satoshis.', - }, - sender: { - name: 'Sent', - description: 'Sent $() satoshis.', - }, - }, + // Require a flat 1000 satoshis to ensure the fungible tokens remains transferrable. + valueSatoshis: "1000", - // Use the recipients lockscript. - lockingScript: 'sendingLockingscript', - - // Set the amount of satoshis to transfer. - valueSatoshis: '$()', + // Require only the specified amount and type of fungible tokens. + // NOTE: This can be composed with a request for a non-fungible token, but will always result in two separate outputs. + token: { + category: "$()", + amount: "$()", + nft: null, }, - transferFungibleTokensOutput: { - name: 'Recipient output', + }, + requestNonfungibleTokensOutput: { + name: "Non-fungible $() Token", + description: + 'Transferred one non-fungible $( <0x02> OP_EQUAL OP_IF <"minting"> OP_ELSE <0x01> OP_EQUAL OP_IF <"mutable"> OP_ELSE <"immutable"> OP_ENDIF OP_ENDIF) $() token to a recipient, with $() commitment.', + icon: "request", + + roles: { + receiver: { + name: "Non-fungible $() Token Received", description: - 'Transferred $( OP_DIV).$( OP_MOD) $() tokens to a recipient.', - icon: 'send', - - roles: { - receiver: { - name: 'Received', - description: - 'Received $( OP_DIV).$( OP_MOD) $() tokens.', - }, - sender: { - name: 'Sent', - description: - 'Sent $( OP_DIV).$( OP_MOD) $() tokens.', - }, - }, - - // Use the recipients lockscript. - lockingScript: 'sendingLockingscript', - - // Set the amount of fungible tokens to transfer. - token: { - category: '$()', - amount: '$()', - }, - }, - transferNonfungibleTokenOutput: { - name: 'Recipient output', + 'Received one non-fungible $( <0x02> OP_EQUAL OP_IF <"minting"> OP_ELSE <0x01> OP_EQUAL OP_IF <"mutable"> OP_ELSE <"immutable"> OP_ENDIF OP_ENDIF) $() token, with $() commitment.', + }, + sender: { + name: "Non-fungible $() Token Sent", description: - 'Transferred one non-fungible $( <0x02> OP_EQUAL OP_IF <"minting"> OP_ELSE <0x01> OP_EQUAL OP_IF <"mutable"> OP_ELSE <"immutable"> OP_ENDIF OP_ENDIF) $() token to a recipient, with $() commitment.', - icon: 'send', - - roles: { - receiver: { - name: 'Received', - description: - 'Received one non-fungible $( <0x02> OP_EQUAL OP_IF <"minting"> OP_ELSE <0x01> OP_EQUAL OP_IF <"mutable"> OP_ELSE <"immutable"> OP_ENDIF OP_ENDIF) $() token, with $() commitment.', - }, - sender: { - name: 'Sent', - description: - 'Sent one non-fungible $( <0x02> OP_EQUAL OP_IF <"minting"> OP_ELSE <0x01> OP_EQUAL OP_IF <"mutable"> OP_ELSE <"immutable"> OP_ENDIF OP_ENDIF) $() token, with $() commitment.', - }, - }, - - // Use the recipients lockscript. - lockingScript: 'sendingLockingscript', - - // Set the non-fungible token to transfer. - token: { - category: '$()', - nft: { - capability: '$()', - commitment: '$()', - }, - }, + 'Sent the requested non-fungible $( <0x02> OP_EQUAL OP_IF <"minting"> OP_ELSE <0x01> OP_EQUAL OP_IF <"mutable"> OP_ELSE <"immutable"> OP_ENDIF OP_ENDIF) $() token, with $() commitment.', + }, }, + + // Defines how the requested funds should be locked. + lockingScript: "receivingLockingScript", + + // Require a flat 1000 satoshis to ensure the non-fungible token remains transferrable. + valueSatoshis: "1000", + + // Require only an NFT with specified category, capability and commitment. + // NOTE: This can be composed with a request for fungible token amounts, but will always result in two separate outputs. + token: { + category: "$()", + amount: null, + nft: { + capability: "$()", + commitment: "$()", + }, + }, + }, + + transferSatoshisOutput: { + name: "Recipient output", + description: + "Transferred $() satoshis to a recipient.", + icon: "send", + + roles: { + receiver: { + name: "Received", + description: "Received $() satoshis.", + }, + sender: { + name: "Sent", + description: "Sent $() satoshis.", + }, + }, + + // Use the recipients lockscript. + lockingScript: "sendingLockingscript", + + // Set the amount of satoshis to transfer. + valueSatoshis: "$()", + }, + transferFungibleTokensOutput: { + name: "Recipient output", + description: + "Transferred $( OP_DIV).$( OP_MOD) $() tokens to a recipient.", + icon: "send", + + roles: { + receiver: { + name: "Received", + description: + "Received $( OP_DIV).$( OP_MOD) $() tokens.", + }, + sender: { + name: "Sent", + description: + "Sent $( OP_DIV).$( OP_MOD) $() tokens.", + }, + }, + + // Use the recipients lockscript. + lockingScript: "sendingLockingscript", + + // Set the amount of fungible tokens to transfer. + token: { + category: "$()", + amount: "$()", + }, + }, + transferNonfungibleTokenOutput: { + name: "Recipient output", + description: + 'Transferred one non-fungible $( <0x02> OP_EQUAL OP_IF <"minting"> OP_ELSE <0x01> OP_EQUAL OP_IF <"mutable"> OP_ELSE <"immutable"> OP_ENDIF OP_ENDIF) $() token to a recipient, with $() commitment.', + icon: "send", + + roles: { + receiver: { + name: "Received", + description: + 'Received one non-fungible $( <0x02> OP_EQUAL OP_IF <"minting"> OP_ELSE <0x01> OP_EQUAL OP_IF <"mutable"> OP_ELSE <"immutable"> OP_ENDIF OP_ENDIF) $() token, with $() commitment.', + }, + sender: { + name: "Sent", + description: + 'Sent one non-fungible $( <0x02> OP_EQUAL OP_IF <"minting"> OP_ELSE <0x01> OP_EQUAL OP_IF <"mutable"> OP_ELSE <"immutable"> OP_ENDIF OP_ENDIF) $() token, with $() commitment.', + }, + }, + + // Use the recipients lockscript. + lockingScript: "sendingLockingscript", + + // Set the non-fungible token to transfer. + token: { + category: "$()", + nft: { + capability: "$()", + commitment: "$()", + }, + }, + }, }, inputs: { - burnFungibleTokensInput: { - name: 'Deleted fungible tokens', - description: 'Permanently and irreversibly deleted $() $().', - icon: 'burn', + burnFungibleTokensInput: { + name: "Deleted fungible tokens", + description: + "Permanently and irreversibly deleted $() $().", + icon: "burn", - // Define which unlocking script unlocks this input. - unlockingScript: 'unlockP2PKH', + // Define which unlocking script unlocks this input. + unlockingScript: "unlockP2PKH", - // Require a fungible token of the requested token category, with an amount larger than or equal to the requested amount to burn. - token: { - category: '$()', - amount: '$( OP_GREATERTHANOREQUAL OP_IF OP_ENDIF)', - }, - - // Ignore the burned token amount when determining change for this output. - // NOTE: The engine must check that this does not exceed the input value. - omitChangeAmounts: { - fungibleTokens: '${}', - }, + // Require a fungible token of the requested token category, with an amount larger than or equal to the requested amount to burn. + token: { + category: "$()", + amount: + "$( OP_GREATERTHANOREQUAL OP_IF OP_ENDIF)", }, - burnNonfungibleTokenInput: { - name: 'Deleted non-fungible token', - description: - 'Permanently and irreversibly burned one non-fungible $( <0x02> OP_EQUAL OP_IF <"minting"> OP_ELSE <0x01> OP_EQUAL OP_IF <"mutable"> OP_ELSE <"immutable"> OP_ENDIF OP_ENDIF) token of category $(), with a $() commitment.', - icon: 'burn', - // Define which unlocking script unlocks this input. - unlockingScript: 'unlockP2PKH', - - // Require a non-fungible token of the specified category, capability and commitment to burn. - token: { - category: '$()', - nft: { - capability: '$()', - commitment: '$()', - }, - }, - - // Ignore the burned token when determining change for this output. - // NOTE: The engine must check that this does not exceed the input value. - omitChangeAmounts: { - nonfungibleTokens: 1, - }, + // Ignore the burned token amount when determining change for this output. + // NOTE: The engine must check that this does not exceed the input value. + omitChangeAmounts: { + fungibleTokens: "${}", }, + }, + burnNonfungibleTokenInput: { + name: "Deleted non-fungible token", + description: + 'Permanently and irreversibly burned one non-fungible $( <0x02> OP_EQUAL OP_IF <"minting"> OP_ELSE <0x01> OP_EQUAL OP_IF <"mutable"> OP_ELSE <"immutable"> OP_ENDIF OP_ENDIF) token of category $(), with a $() commitment.', + icon: "burn", + + // Define which unlocking script unlocks this input. + unlockingScript: "unlockP2PKH", + + // Require a non-fungible token of the specified category, capability and commitment to burn. + token: { + category: "$()", + nft: { + capability: "$()", + commitment: "$()", + }, + }, + + // Ignore the burned token when determining change for this output. + // NOTE: The engine must check that this does not exceed the input value. + omitChangeAmounts: { + nonfungibleTokens: 1, + }, + }, }, // Define locking scripts used by this template. // NOTE: Template supported wallets should automatically track all generated lockscripts for on-chain events. lockingScripts: { - sendingLockingscript: { - // TODO: This currently describes outputs locked to this script, but all actions creating such outputs already have descriptions. - // This can either be dropped, or maybe more importantly, should be disambiguated so that lockscripts can provide separate - // descriptions for their script and the outputs that are locked to them. Leaving as a TODO for now and will address later. - name: 'Sent', - description: 'Funds sent to an external recipient', - icon: 'address', + sendingLockingscript: { + // TODO: This currently describes outputs locked to this script, but all actions creating such outputs already have descriptions. + // This can either be dropped, or maybe more importantly, should be disambiguated so that lockscripts can provide separate + // descriptions for their script and the outputs that are locked to them. Leaving as a TODO for now and will address later. + name: "Sent", + description: "Funds sent to an external recipient", + icon: "address", - // ... - lockingType: 'p2pkh', - lockingBytecode: 'lockToRecipient', + // ... + lockingType: "p2pkh", + lockingBytecode: "lockToRecipient", - // Indicate that the sent output does not belong to the initiating user. - // NOTE: These values default to false/empty, but added here for additional clarity. - actions: [], - state: { variables: [], secrets: [] }, - balance: {}, - selectable: false, - }, - receivingLockingScript: { - // NOTE: Outputs to this lockscript by external actors defaults to this description when detected on-chain. - name: 'Received', - description: 'Funds received without wallet coordination.', - icon: 'address', + // Indicate that the sent output does not belong to the initiating user. + // NOTE: These values default to false/empty, but added here for additional clarity. + actions: [], + state: { variables: [], secrets: [] }, + balance: {}, + selectable: false, + }, + receivingLockingScript: { + // NOTE: Outputs to this lockscript by external actors defaults to this description when detected on-chain. + name: "Received", + description: "Funds received without wallet coordination.", + icon: "address", - // Defines how spending future received funds should be locked. - lockingType: 'p2pkh', - lockingBytecode: 'lockP2PKH', + // Defines how spending future received funds should be locked. + lockingType: "p2pkh", + lockingBytecode: "lockP2PKH", - // Define a default unlocking script to be used when no action provided script is present. - unlockingBytecode: 'unlockP2PKH', + // Define a default unlocking script to be used when no action provided script is present. + unlockingBytecode: "unlockP2PKH", - // Participants without a role or observers cannot take any further actions. - // NOTE: This is not required but shown here for illustrative purposes. - actions: [], + // Participants without a role or observers cannot take any further actions. + // NOTE: This is not required but shown here for illustrative purposes. + actions: [], - roles: { - receiver: { - // The only state that is required to be persisted when receiving funds is the owners private key. - // NOTE: This is defined as a secret to not leak when creating invitations for others to participate in request or send actions. - state: { - variables: [], - secrets: [ 'ownerKey' ], - }, - - // List actions that can be taken with the ownerKey for each address/lockscript. - actions: [ - { - action: 'sign', - role: 'owner', - secrets: [{ ownerKey: null }], - }, - { - action: 'verify', - role: 'owner', - secrets: [{ ownerKey: null }], - }, - { - action: 'sendSatoshis', - role: 'sender', - secrets: [{ ownerKey: null }], - }, - { - action: 'sendFungibleTokens', - role: 'sender', - secrets: [{ ownerKey: null }], - }, - { - action: 'sendNonfungibleTokens', - role: 'sender', - secrets: [{ ownerKey: null }], - }, - { - action: 'burnFungibleTokens', - role: 'sender', - secrets: [{ ownerKey: null }], - }, - { - action: 'burnNonfungibleTokens', - role: 'sender', - secrets: [{ ownerKey: null }], - }, - ], - - // Indicates how much of received funds should be part of a wallets total balance. - // NOTE: Evaluates in the context of the source transaction, with the current outputs technical data available under this.output.* - // NOTE: A CashASM expression of '1' means include 100% of this asset in the balance. - // NOTE: Since we know that the owner controls all assets, we short-cut the evaluations by setting 1 directly. - balance: { - satoshis: true, - fungibleTokens: true, - nonfungibleTokens: true, - }, - - // Indicate when received funds should be considered in automatic coin selection to meet input requirements of other transactions. - // NOTE: Evaluates in the context of the source transaction, with the current outputs technical data available under this.output.* - // NOTE: This evaluates to a boolean. If non-true, the output should not be considered for automatic coin selection. - // NOTE: Since we know that the secret key exist for the owner, this template short-cuts the evaluation by setting true directly. - selectable: true, - }, + roles: { + receiver: { + // The only state that is required to be persisted when receiving funds is the owners private key. + // NOTE: This is defined as a secret to not leak when creating invitations for others to participate in request or send actions. + state: { + variables: [], + secrets: ["ownerKey"], }, + + // List actions that can be taken with the ownerKey for each address/lockscript. + actions: [ + { + action: "sign", + role: "owner", + secrets: [{ ownerKey: null }], + }, + { + action: "verify", + role: "owner", + secrets: [{ ownerKey: null }], + }, + { + action: "sendSatoshis", + role: "sender", + secrets: [{ ownerKey: null }], + }, + { + action: "sendFungibleTokens", + role: "sender", + secrets: [{ ownerKey: null }], + }, + { + action: "sendNonfungibleTokens", + role: "sender", + secrets: [{ ownerKey: null }], + }, + { + action: "burnFungibleTokens", + role: "sender", + secrets: [{ ownerKey: null }], + }, + { + action: "burnNonfungibleTokens", + role: "sender", + secrets: [{ ownerKey: null }], + }, + ], + + // Indicates how much of received funds should be part of a wallets total balance. + // NOTE: Evaluates in the context of the source transaction, with the current outputs technical data available under this.output.* + // NOTE: A CashASM expression of '1' means include 100% of this asset in the balance. + // NOTE: Since we know that the owner controls all assets, we short-cut the evaluations by setting 1 directly. + balance: { + satoshis: true, + fungibleTokens: true, + nonfungibleTokens: true, + }, + + // Indicate when received funds should be considered in automatic coin selection to meet input requirements of other transactions. + // NOTE: Evaluates in the context of the source transaction, with the current outputs technical data available under this.output.* + // NOTE: This evaluates to a boolean. If non-true, the output should not be considered for automatic coin selection. + // NOTE: Since we know that the secret key exist for the owner, this template short-cuts the evaluation by setting true directly. + selectable: true, + }, }, + }, }, // Define a set of scripts that can be used in this template. scripts: { - lockP2PKH: 'OP_DUP OP_HASH160 <$( OP_HASH160)> OP_EQUALVERIFY OP_CHECKSIG', - unlockP2PKH: ' ', - lockToRecipient: '', + lockP2PKH: + "OP_DUP OP_HASH160 <$( OP_HASH160)> OP_EQUALVERIFY OP_CHECKSIG", + unlockP2PKH: + " ", + lockToRecipient: "", }, // TODO: Add icons constants: { - dustLimit: { - name: 'Dust Limit', - description: 'Standard required minimum satoshis for Pay to Public Key Hash outputs.', - type: 'integer', - value: 546, - }, + dustLimit: { + name: "Dust Limit", + description: + "Standard required minimum satoshis for Pay to Public Key Hash outputs.", + type: "integer", + value: 546, + }, - // Define a message prefix for use in arbitrary message signing. - messagePrefix: { - name: 'Message Prefix', - description: 'Standard message prefix used in the bitcoin signed message protocol.', - type: 'bytes', + // Define a message prefix for use in arbitrary message signing. + messagePrefix: { + name: "Message Prefix", + description: + "Standard message prefix used in the bitcoin signed message protocol.", + type: "bytes", - // Value is enforced to the bitcoin signing magic string: - // "\x18Bitcoin Signed Message:\n" - value: '0x18426974636f696e205369676e6564204d6573736167653a0a', - }, + // Value is enforced to the bitcoin signing magic string: + // "\x18Bitcoin Signed Message:\n" + value: "0x18426974636f696e205369676e6564204d6573736167653a0a", + }, }, // TODO: Add icons variables: { - // Describe the secret private key. - ownerKey: { - name: 'Owners Private Key', - description: 'The private key used to authorize spending of received funds.', - type: 'bytes', - hint: 'private_key', - }, + // Describe the secret private key. + ownerKey: { + name: "Owners Private Key", + description: + "The private key used to authorize spending of received funds.", + type: "bytes", + hint: "private_key", + }, - messageToSign: { - name: 'Message', - description: 'The text message to sign.', - type: 'string', - }, - messageToVerify: { - name: 'Message', - description: 'The text message to verify.', - type: 'string', - }, - messageSignature: { - name: 'Message Signature', - description: 'The signature for the message.', - type: 'bytes', - hint: 'signature', - }, + messageToSign: { + name: "Message", + description: "The text message to sign.", + type: "string", + }, + messageToVerify: { + name: "Message", + description: "The text message to verify.", + type: "string", + }, + messageSignature: { + name: "Message Signature", + description: "The signature for the message.", + type: "bytes", + hint: "signature", + }, - // Describe the parameters used when requesting value. - requestedSatoshis: { - name: 'Requested Amount', - description: 'The Bitcoin Cash amount requested', - type: 'integer', - hint: 'satoshis', - }, - requestedTokenCategory: { - name: 'Requested Token Category', - description: 'The token category requested', - type: 'bytes', - hint: 'token_category', - }, - requestedTokenAmount: { - name: 'Requested Token Amount', - description: 'The fungible token amount requested', - type: 'integer', - hint: 'token_amount', - }, - requestedTokenCapability: { - name: 'Requested Token Capability', - description: 'The non-fungible token capability requested', - type: 'bytes', - hint: 'token_capability', - }, - requestedTokenCommitment: { - name: 'Requested Token Commitment', - description: 'The non-fungible token commitment requested', - type: 'bytes', - hint: 'token_commitment', - }, - transferredTokenCategory: { - name: 'Sending Token Category', - description: 'The token category of the token(s) to send', - type: 'bytes', - hint: 'token_category', - }, - transferredTokenAmount: { - name: 'Sending Token Amount', - description: 'The fungible token amount to send', - type: 'integer', - hint: 'token_amount', - }, - transferredTokenCapability: { - name: 'Sending Token Capability', - description: 'The token capability for the non-fungible token to send', - type: 'bytes', - hint: 'token_capability', - }, - transferredTokenCommitment: { - name: 'Sending Token Commitment', - description: 'The token commitment for the non-fungible token to send', - type: 'bytes', - hint: 'token_commitment', - }, - burnedTokenCategory: { - name: 'Deleted Token Category', - description: 'The token category of the token(s) to delete', - type: 'bytes', - hint: 'token_category', - }, - burnedTokenAmount: { - name: 'Deleted Token Amount', - description: 'The fungible token amount to delete', - type: 'integer', - hint: 'token_amount', - }, - burnedTokenCapability: { - name: 'Deleted Token Capability', - description: 'The token capability for the non-fungible token to delete', - type: 'bytes', - hint: 'token_capability', - }, - burnedTokenCommitment: { - name: 'Deleted Token Commitment', - description: 'The token commitment for the non-fungible token to delete', - type: 'bytes', - hint: 'token_commitment', - }, + // Describe the parameters used when requesting value. + requestedSatoshis: { + name: "Requested Amount", + description: "The Bitcoin Cash amount requested", + type: "integer", + hint: "satoshis", + }, + requestedTokenCategory: { + name: "Requested Token Category", + description: "The token category requested", + type: "bytes", + hint: "token_category", + }, + requestedTokenAmount: { + name: "Requested Token Amount", + description: "The fungible token amount requested", + type: "integer", + hint: "token_amount", + }, + requestedTokenCapability: { + name: "Requested Token Capability", + description: "The non-fungible token capability requested", + type: "bytes", + hint: "token_capability", + }, + requestedTokenCommitment: { + name: "Requested Token Commitment", + description: "The non-fungible token commitment requested", + type: "bytes", + hint: "token_commitment", + }, + transferredTokenCategory: { + name: "Sending Token Category", + description: "The token category of the token(s) to send", + type: "bytes", + hint: "token_category", + }, + transferredTokenAmount: { + name: "Sending Token Amount", + description: "The fungible token amount to send", + type: "integer", + hint: "token_amount", + }, + transferredTokenCapability: { + name: "Sending Token Capability", + description: "The token capability for the non-fungible token to send", + type: "bytes", + hint: "token_capability", + }, + transferredTokenCommitment: { + name: "Sending Token Commitment", + description: "The token commitment for the non-fungible token to send", + type: "bytes", + hint: "token_commitment", + }, + burnedTokenCategory: { + name: "Deleted Token Category", + description: "The token category of the token(s) to delete", + type: "bytes", + hint: "token_category", + }, + burnedTokenAmount: { + name: "Deleted Token Amount", + description: "The fungible token amount to delete", + type: "integer", + hint: "token_amount", + }, + burnedTokenCapability: { + name: "Deleted Token Capability", + description: "The token capability for the non-fungible token to delete", + type: "bytes", + hint: "token_capability", + }, + burnedTokenCommitment: { + name: "Deleted Token Commitment", + description: "The token commitment for the non-fungible token to delete", + type: "bytes", + hint: "token_commitment", + }, }, // Define a list of re-usable icons that can be used as part of metadata. // NOTE: the actual icons are not embedded in the template but only referenced by hashes here, // and can be distributed either as an asset pack or looked up through something like IPFS. icons: [ - { - name: 'wallet', - hash: '0000000000000000000000', - }, - { - name: 'owner', - hash: '0000000000000000000000', - }, - { - name: 'sender', - hash: '0000000000000000000000', - }, - { - name: 'address', - hash: '0000000000000000000000', - }, - { - name: 'receive', - hash: '0000000000000000000000', - }, - { - name: 'request', - hash: '0000000000000000000000', - }, - { - name: 'send', - hash: '0000000000000000000000', - }, - { - name: 'burn', - hash: '0000000000000000000000', - }, - { - name: 'sign', - hash: '0000000000000000000000', - }, - { - name: 'verify', - hash: '0000000000000000000000', - }, + { + name: "wallet", + hash: "0000000000000000000000", + }, + { + name: "owner", + hash: "0000000000000000000000", + }, + { + name: "sender", + hash: "0000000000000000000000", + }, + { + name: "address", + hash: "0000000000000000000000", + }, + { + name: "receive", + hash: "0000000000000000000000", + }, + { + name: "request", + hash: "0000000000000000000000", + }, + { + name: "send", + hash: "0000000000000000000000", + }, + { + name: "burn", + hash: "0000000000000000000000", + }, + { + name: "sign", + hash: "0000000000000000000000", + }, + { + name: "verify", + hash: "0000000000000000000000", + }, ], scenarios: [ - { - name: 'requesting satoshis', - description: 'happy-path evaluation for requesting satoshis.', + { + name: "requesting satoshis", + description: "happy-path evaluation for requesting satoshis.", - // The action being run in the scenario. - action: 'requestSatoshis', + // The action being run in the scenario. + action: "requestSatoshis", - // List of roles taken in this scenario, and the resources they provided. - roles: [ - { - role: 'receiver', - values: { - generated: { - ownerKey: 'KyRQa5pEXuzVcDwnXRLpYAascjchQW5DoxVRMbj4DTxS83573mz8', - }, - variables: { - requestedSatoshis: 2000, - }, - secrets: { - // This scenario does not carry any secrets. - }, - inputs: [], - outputs: [ - { - lockingBytecode: '76a91475c715ecb74178fe87933e57e947e5e92d904b8188ac', - valueSatoshis: 2000, - }, - ], - }, - }, - { - role: 'sender', - values: { - // The sender provides no raw data to the action. - generated: {}, - variables: {}, - secrets: {}, - - // The sender does provide input and change. - inputs: [ - { - outpointTransactionHash: '4ef28553a31a266719e66ba97fee3aeecd6d1788f7ff6ab12f8ebceda49660c0', - outpointIndex: 0, - sequenceNumber: 0, - unlockingBytecode: - '41226b2be7c2890c8bbde2f79e79640e56d866843f2e822ec51c469019d13db04a422c9ee49f5eefd26fee24e91910edbbb032b90cc54c34da80a61e69b0ee3d22412103e7ab26c36a7c7f45b2c26f33c08b0fa43a633268700f47216646d4cb37ae5696', - }, - ], - outputs: [ - { - lockingBytecode: '76a91475c715ecb74178fe87933e57e947e5e92d904b8188ac', - valueSatoshis: 2000, - }, - ], - }, - }, - ], - - // List of resources provided outside the context of a role. + // List of roles taken in this scenario, and the resources they provided. + roles: [ + { + role: "receiver", values: { - generated: { - // This scenario does not have any non-role generated values. - }, - variables: { - // This scenario does not have any non-role variables. - }, - secrets: { - // This scenario does not have any non-role secrets. + generated: { + ownerKey: "KyRQa5pEXuzVcDwnXRLpYAascjchQW5DoxVRMbj4DTxS83573mz8", + }, + variables: { + requestedSatoshis: 2000, + }, + secrets: { + // This scenario does not carry any secrets. + }, + inputs: [], + outputs: [ + { + lockingBytecode: + "76a91475c715ecb74178fe87933e57e947e5e92d904b8188ac", + valueSatoshis: 2000, }, + ], }, + }, + { + role: "sender", + values: { + // The sender provides no raw data to the action. + generated: {}, + variables: {}, + secrets: {}, - // Outcomes provides the set of resulting values created from the action. - outcome: { - roles: { - receiver: { - name: 'Request', - description: 'Requested a specific amount of satoshis from one or more senders.', - icon: 'request', - }, - sender: { - name: 'Send', - description: 'Sent a specific amount of satoshis to the provided receiver.', - icon: 'send', - }, + // The sender does provide input and change. + inputs: [ + { + outpointTransactionHash: + "4ef28553a31a266719e66ba97fee3aeecd6d1788f7ff6ab12f8ebceda49660c0", + outpointIndex: 0, + sequenceNumber: 0, + unlockingBytecode: + "41226b2be7c2890c8bbde2f79e79640e56d866843f2e822ec51c469019d13db04a422c9ee49f5eefd26fee24e91910edbbb032b90cc54c34da80a61e69b0ee3d22412103e7ab26c36a7c7f45b2c26f33c08b0fa43a633268700f47216646d4cb37ae5696", }, - - transactions: [ - { - transaction: - '0200000001c06096a4edbc8e2fb16afff788176dcdee3aee7fa96be61967261aa35385f24e000000006441226b2be7c2890c8bbde2f79e79640e56d866843f2e822ec51c469019d13db04a422c9ee49f5eefd26fee24e91910edbbb032b90cc54c34da80a61e69b0ee3d22412103e7ab26c36a7c7f45b2c26f33c08b0fa43a633268700f47216646d4cb37ae5696000000000267530300000000001976a91475c715ecb74178fe87933e57e947e5e92d904b8188acd0070000000000001976a91475c715ecb74178fe87933e57e947e5e92d904b8188ac00000000', - value: '', - }, - ], + ], + outputs: [ + { + lockingBytecode: + "76a91475c715ecb74178fe87933e57e947e5e92d904b8188ac", + valueSatoshis: 2000, + }, + ], }, + }, + ], + + // List of resources provided outside the context of a role. + values: { + generated: { + // This scenario does not have any non-role generated values. + }, + variables: { + // This scenario does not have any non-role variables. + }, + secrets: { + // This scenario does not have any non-role secrets. + }, }, + + // Outcomes provides the set of resulting values created from the action. + outcome: { + roles: { + receiver: { + name: "Request", + description: + "Requested a specific amount of satoshis from one or more senders.", + icon: "request", + }, + sender: { + name: "Send", + description: + "Sent a specific amount of satoshis to the provided receiver.", + icon: "send", + }, + }, + + transactions: [ + { + transaction: + "0200000001c06096a4edbc8e2fb16afff788176dcdee3aee7fa96be61967261aa35385f24e000000006441226b2be7c2890c8bbde2f79e79640e56d866843f2e822ec51c469019d13db04a422c9ee49f5eefd26fee24e91910edbbb032b90cc54c34da80a61e69b0ee3d22412103e7ab26c36a7c7f45b2c26f33c08b0fa43a633268700f47216646d4cb37ae5696000000000267530300000000001976a91475c715ecb74178fe87933e57e947e5e92d904b8188acd0070000000000001976a91475c715ecb74178fe87933e57e947e5e92d904b8188ac00000000", + value: "", + }, + ], + }, + }, ], }; -export const p2pkhTemplateIdentifier = - generateTemplateIdentifier(parseTemplate(p2pkhTemplate)); +export const p2pkhTemplateIdentifier = generateTemplateIdentifier( + parseTemplate(p2pkhTemplate), +); diff --git a/tests/cli/paths.test.ts b/tests/cli/paths.test.ts index 848a9d7..7824bcf 100644 --- a/tests/cli/paths.test.ts +++ b/tests/cli/paths.test.ts @@ -1,5 +1,11 @@ import { expect, test, describe, beforeEach, afterEach } from "vitest"; -import { existsSync, mkdirSync, rmSync, writeFileSync, realpathSync } from "node:fs"; +import { + existsSync, + mkdirSync, + rmSync, + writeFileSync, + realpathSync, +} from "node:fs"; import { homedir, tmpdir } from "node:os"; import path from "node:path"; @@ -130,7 +136,9 @@ describe("paths utilities", () => { // Due to some weird MacOS behavior we need to use realpathSync to get the correct path // Basically, tmpDir() returns a symlink, but moving to that path puts us at `/private/${tmpDir()}` - const expectedPath = realpathSync(path.join(tempDir, "mnemonic-cwd-test")); + const expectedPath = realpathSync( + path.join(tempDir, "mnemonic-cwd-test"), + ); // Compare to the expected path expect(resolved).toBe(expectedPath); diff --git a/tests/tui/format-dialog-message.test.ts b/tests/tui/format-dialog-message.test.ts index ecb35a9..6a229c9 100644 --- a/tests/tui/format-dialog-message.test.ts +++ b/tests/tui/format-dialog-message.test.ts @@ -21,7 +21,7 @@ describe("formatDialogMessageLines", () => { test("breaks long dot-separated paths at segment boundaries", () => { const line = - "- actions.requestFungibleTokens.roles.receiver.requirements: Unrecognized key: \"generate\""; + '- actions.requestFungibleTokens.roles.receiver.requirements: Unrecognized key: "generate"'; const lines = formatDialogMessageLines(line, 56); expect(lines.length).toBeGreaterThan(1); -- 2.49.1 From 69adee180a921bf861d65ca85a25ded326a40cb1 Mon Sep 17 00:00:00 2001 From: Harvey Zuccon Date: Mon, 8 Jun 2026 13:09:38 +0200 Subject: [PATCH 16/22] Add resolveCommitReferences method --- src/services/invitation.ts | 94 ++++-- .../screens/invitations/InvitationScreen.tsx | 92 +++--- .../steps/PreviewInvitationStep.tsx | 55 ++-- src/utils/resolve-invitation-data.ts | 295 ++++++++++++++++++ tests/utils/resolve-invitation-data.test.ts | 240 ++++++++++++++ 5 files changed, 683 insertions(+), 93 deletions(-) create mode 100644 src/utils/resolve-invitation-data.ts create mode 100644 tests/utils/resolve-invitation-data.test.ts diff --git a/src/services/invitation.ts b/src/services/invitation.ts index 73c22c3..ebe6630 100644 --- a/src/services/invitation.ts +++ b/src/services/invitation.ts @@ -17,6 +17,7 @@ import type { XOInvitationOutput, XOInvitationVariable, XOInvitationVariableValue, + XOTemplate, } from "@xo-cash/types"; import type { UnspentOutputData } from "@xo-cash/state"; import { @@ -34,8 +35,14 @@ import type { BlockchainService } from "./electrum.js"; import { EventEmitter } from "../utils/event-emitter.js"; import { decodeExtendedJsonObject } from "../utils/ext-json.js"; +import { + resolveCommitReferences, + type ResolvedInvitationData, +} from "../utils/resolve-invitation-data.js"; import { compileCashAssemblyString } from "@xo-cash/engine"; +export type { ResolvedInvitationData } from "../utils/resolve-invitation-data.js"; + export type InvitationEventMap = { "invitation-updated": XOInvitation; "invitation-status-changed": string; @@ -103,11 +110,33 @@ export class Invitation extends EventEmitter { ); // Create the invitation - const invitationInstance = new Invitation(engineInvitation, dependencies); + const invitationInstance = new Invitation( + engineInvitation, + dependencies, + template, + ); return invitationInstance; } + /** + * Flattened, template-enriched view of {@link Invitation.data}. + * Updated automatically whenever invitation data changes. + */ + public resolvedData: ResolvedInvitationData = { + invitationIdentifier: "", + templateIdentifier: "", + actionIdentifier: "", + variables: [], + inputs: [], + outputs: [], + }; + + /** + * The template used to enrich {@link resolvedData}. + */ + private template: XOTemplate; + /** * The invitation data. */ @@ -145,14 +174,19 @@ export class Invitation extends EventEmitter { /** * Create an invitation and start the SSE Session required for it. */ - constructor(invitation: XOInvitation, dependencies: InvitationDependencies) { + constructor( + invitation: XOInvitation, + dependencies: InvitationDependencies, + template: XOTemplate, + ) { super(); - this.data = invitation; + this.template = template; this.engine = dependencies.engine; this.syncServer = dependencies.syncServer; this.storage = dependencies.storage; this.electrum = dependencies.electrum; + this.updateInvitationData(invitation); // Apply SSE updates serially so each engine update sees the latest history. this.syncServer.on("message", (event) => { @@ -167,6 +201,14 @@ export class Invitation extends EventEmitter { }); } + /** + * Updates raw invitation data and recomputes {@link resolvedData}. + */ + private updateInvitationData(invitation: XOInvitation): void { + this.data = invitation; + this.resolvedData = resolveCommitReferences(invitation, this.template); + } + private enqueueSyncUpdate(update: () => Promise): Promise { const queuedUpdate = this.sseUpdateQueue.then(update); this.sseUpdateQueue = queuedUpdate.catch(() => {}); @@ -197,19 +239,21 @@ export class Invitation extends EventEmitter { try { // Prefer keeping the engine's local invitation state in sync. - this.data = stripLocalInvitationMetadata( - await this.engine.updateInvitation({ - ...this.data, - ...invitation, - commits: combinedCommits, - }), + this.updateInvitationData( + stripLocalInvitationMetadata( + await this.engine.updateInvitation({ + ...this.data, + ...invitation, + commits: combinedCommits, + }), + ), ); } catch (error) { this.emit( "error", error instanceof Error ? error : new Error(String(error)), ); - this.data = { ...this.data, commits: combinedCommits }; + this.updateInvitationData({ ...this.data, commits: combinedCommits }); } await this.storage.set(this.data.invitationIdentifier, this.data); @@ -243,19 +287,21 @@ export class Invitation extends EventEmitter { const newCommits = this.mergeCommits(this.data.commits, invitation.commits); try { - this.data = stripLocalInvitationMetadata( - await this.engine.updateInvitation({ - ...this.data, - ...invitation, - commits: newCommits, - }), + this.updateInvitationData( + stripLocalInvitationMetadata( + await this.engine.updateInvitation({ + ...this.data, + ...invitation, + commits: newCommits, + }), + ), ); } catch (error) { this.emit( "error", error instanceof Error ? error : new Error(String(error)), ); - this.data = { ...this.data, commits: newCommits }; + this.updateInvitationData({ ...this.data, commits: newCommits }); } await this.storage.set(this.data.invitationIdentifier, this.data); @@ -488,7 +534,9 @@ export class Invitation extends EventEmitter { */ async accept(acceptParams?: InvitationParameters): Promise { // Accept the invitation - this.data = await this.engine.acceptInvitation(this.data, acceptParams); + this.updateInvitationData( + await this.engine.acceptInvitation(this.data, acceptParams), + ); // Sync the invitation to the sync server await this.publishInvitation(this.data); @@ -529,7 +577,7 @@ export class Invitation extends EventEmitter { // Store the signed invitation in the storage await this.storage.set(this.data.invitationIdentifier, signedInvitation); - this.data = signedInvitation; + this.updateInvitationData(signedInvitation); // Update the status of the invitation await this.updateStatus(); @@ -563,9 +611,11 @@ export class Invitation extends EventEmitter { await this.ensureAccepted(); // Append the commit to the invitation - this.data = await this.engine.appendInvitation( - this.data.invitationIdentifier, - data, + this.updateInvitationData( + await this.engine.appendInvitation( + this.data.invitationIdentifier, + data, + ), ); // Sync the invitation to the sync server diff --git a/src/tui/screens/invitations/InvitationScreen.tsx b/src/tui/screens/invitations/InvitationScreen.tsx index a137d42..2a84517 100644 --- a/src/tui/screens/invitations/InvitationScreen.tsx +++ b/src/tui/screens/invitations/InvitationScreen.tsx @@ -26,12 +26,10 @@ import type { XOInvitationCommit, XOInvitationVariableValue, XOTemplate } from ' import { getInvitationState, getStateColorName, - getInvitationInputs, - getInvitationOutputs, - getInvitationVariables, formatInvitationListItem, formatInvitationId, } from '../../../utils/invitation-utils.js'; +import type { ResolvedInvitationVariable } from '../../../utils/resolve-invitation-data.js'; import { InvitationImportFlow } from './invitation-import/InvitationImportFlow.js'; import { compileCashAssemblyString } from '@xo-cash/engine'; @@ -401,16 +399,11 @@ export function InvitationScreen(): React.ReactElement { setStatus('Analyzing invitation...'); let requiredAmount = 0n; - const commits = selectedInvitation.data.commits || []; - for (const commit of commits) { - const variables = commit.data?.variables || []; - for (const variable of variables) { - if (variable.variableIdentifier?.toLowerCase().includes('satoshi')) { - requiredAmount = BigInt(variable.value?.toString() || '0'); - break; - } + for (const variable of selectedInvitation.resolvedData.variables) { + if (variable.variableIdentifier.toLowerCase().includes('satoshi')) { + requiredAmount = BigInt(variable.value?.toString() || '0'); + break; } - if (requiredAmount > 0n) break; } const fee = 500n; @@ -595,14 +588,17 @@ export function InvitationScreen(): React.ReactElement { const state = getInvitationState(selectedInvitation); const action = selectedTemplate?.actions?.[selectedInvitation.data.actionIdentifier]; - const inputs = getInvitationInputs(selectedInvitation); - const outputs = getInvitationOutputs(selectedInvitation); - const variables = getInvitationVariables(selectedInvitation); + const { inputs, outputs, variables } = selectedInvitation.resolvedData; const userEntityId = ownInvitationContext.entityIdentifier; const userRole = ownInvitationContext.roleIdentifier; const roleInfoRaw = userRole && selectedTemplate?.roles?.[userRole]; const roleInfo = roleInfoRaw && typeof roleInfoRaw === 'object' ? roleInfoRaw : null; + const variableValues = variables.reduce((acc, variable) => { + acc[variable.variableIdentifier] = variable.value as XOInvitationVariableValue; + return acc; + }, {} as Record); + const getFiatSuffix = (satoshis: bigint): string => { const fiatValue = formatSatoshisToFiat(satoshis); return fiatValue ? ` (~${fiatValue})` : ''; @@ -625,11 +621,10 @@ export function InvitationScreen(): React.ReactElement { } }; - const isSatoshisVariable = (variableIdentifier: string): boolean => { - const templateVariable = selectedTemplate?.variables?.[variableIdentifier]; - const templateType = templateVariable?.type?.toLowerCase(); - const templateHint = templateVariable?.hint?.toLowerCase(); - const identifier = variableIdentifier.toLowerCase(); + const isSatoshisVariable = (variable: ResolvedInvitationVariable): boolean => { + const templateHint = variable.hint?.toLowerCase(); + const templateType = variable.type?.toLowerCase(); + const identifier = variable.variableIdentifier.toLowerCase(); if (templateHint?.includes('satoshi')) { return true; @@ -641,6 +636,20 @@ export function InvitationScreen(): React.ReactElement { ); }; + const compileResolvedDescription = (description?: string): string | null => { + if (!description) return null; + + try { + return compileCashAssemblyString({ + cashAssemblyText: description, + variables: variableValues, + evaluationDecodeMode: 'bigint', + }); + } catch { + return description; + } + }; + return ( {/* Type & Status */} @@ -693,28 +702,21 @@ export function InvitationScreen(): React.ReactElement { ) : ( inputs.map((input, idx) => { const isUserInput = input.entityIdentifier === userEntityId; - const inputTemplate = selectedTemplate?.inputs?.[input.inputIdentifier ?? '']; const inputSatoshis = ( 'valueSatoshis' in input && input.valueSatoshis !== undefined ) ? parseNumberishToBigInt(input.valueSatoshis) : null; + const inputDescription = compileResolvedDescription(input.description); return ( - {/* Indicator for whether this is the user's input */} {' '}{isUserInput ? '• ' : '○ '} - - {/* TODO: Why doesnt this stuff work? It just cant resolve inputs? */} - {/* Input name */} - {inputTemplate?.name ?? input.inputIdentifier ?? `Input ${idx}`} - - {/* Input role */} + {input.name ?? input.inputIdentifier ?? `Input ${idx}`} {input.roleIdentifier && ` (${input.roleIdentifier})`} - - {/* Input value */} + {inputDescription && ` - ${inputDescription}`} {inputSatoshis !== null && ` ${formatSatoshis(inputSatoshis)}${getFiatSuffix(inputSatoshis)}`} ); @@ -729,33 +731,18 @@ export function InvitationScreen(): React.ReactElement { ) : ( outputs.map((output, idx) => { const isUserOutput = output.entityIdentifier === userEntityId; - const outputTemplate = selectedTemplate?.outputs?.[output.outputIdentifier ?? '']; const outputSatoshis = output.valueSatoshis !== undefined ? parseNumberishToBigInt(output.valueSatoshis) : null; + const outputDescription = compileResolvedDescription(output.description); return ( - {/* Indicator for whether this is the user's output */} {' '}{isUserOutput ? '• ' : '○ '} - - {/* Output name */} - {outputTemplate?.name ?? output.outputIdentifier ?? `Output ${idx}`} - - {/* Output description */} - {outputTemplate?.description && ' - ' + compileCashAssemblyString({ - cashAssemblyText: outputTemplate?.description, - variables: variables.reduce((acc, variable) => { - acc[variable.variableIdentifier] = variable.value as XOInvitationVariableValue; - - return acc; - }, {} as Record), - evaluationDecodeMode: 'bigint' - })} - - {/* Output value */} + {output.name ?? output.outputIdentifier ?? `Output ${idx}`} + {outputDescription && ` - ${outputDescription}`} {outputSatoshis !== null && ` (${formatSatoshis(outputSatoshis)}${getFiatSuffix(outputSatoshis)})`} ); @@ -772,11 +759,10 @@ export function InvitationScreen(): React.ReactElement { ) : ( variables.map((variable, idx) => { const isUserVariable = variable.entityIdentifier === userEntityId; - const varTemplate = selectedTemplate?.variables?.[variable.variableIdentifier]; const displayValue = typeof variable.value === 'bigint' ? variable.value.toString() : String(variable.value); - const parsedVariableSatoshis = isSatoshisVariable(variable.variableIdentifier) + const parsedVariableSatoshis = isSatoshisVariable(variable) ? parseNumberishToBigInt(variable.value) : null; return ( @@ -785,11 +771,11 @@ export function InvitationScreen(): React.ReactElement { color={isUserVariable ? colors.success : colors.text} > {' '}{isUserVariable ? '• ' : '○ '} - {varTemplate?.name ?? variable.variableIdentifier}: {displayValue} + {variable.name ?? variable.variableIdentifier}: {displayValue} {parsedVariableSatoshis !== null && ` (${formatSatoshis(parsedVariableSatoshis)}${getFiatSuffix(parsedVariableSatoshis)})`} - {varTemplate?.description && ( - - {varTemplate.description} + {variable.description && ( + - {variable.description} )} ); diff --git a/src/tui/screens/invitations/invitation-import/steps/PreviewInvitationStep.tsx b/src/tui/screens/invitations/invitation-import/steps/PreviewInvitationStep.tsx index 2740a07..1bda2c4 100644 --- a/src/tui/screens/invitations/invitation-import/steps/PreviewInvitationStep.tsx +++ b/src/tui/screens/invitations/invitation-import/steps/PreviewInvitationStep.tsx @@ -14,12 +14,29 @@ import { useLayeredInput } from '../../../../hooks/useInputLayer.js'; import { getInvitationState, getStateColorName, - getInvitationInputs, - getInvitationOutputs, - getInvitationVariables, } from '../../../../../utils/invitation-utils.js'; import type { PreviewStepProps } from '../types.js'; +/** + * Map a semantic color name to an actual theme color value. + */ +function parseNumberishToBigInt(value: unknown): bigint | null { + if (typeof value === 'bigint') { + return value; + } + + const asString = String(value).trim(); + if (!/^[-]?\d+$/.test(asString)) { + return null; + } + + try { + return BigInt(asString); + } catch { + return null; + } +} + /** * Map a semantic color name to an actual theme color value. */ @@ -51,16 +68,18 @@ export function PreviewInvitationStep({ const state = getInvitationState(invitation); const action = template?.actions?.[invitation.data.actionIdentifier]; - const inputs = getInvitationInputs(invitation); - const outputs = getInvitationOutputs(invitation); - const variables = getInvitationVariables(invitation); + const { inputs, outputs, variables } = invitation.resolvedData; - // Collect role identifiers that appear across all commits + // Collect role identifiers that appear across resolved invitation data const filledRoles = new Set(); - for (const commit of invitation.data.commits ?? []) { - for (const input of commit.data?.inputs ?? []) { - if (input.roleIdentifier) filledRoles.add(input.roleIdentifier); - } + for (const input of inputs) { + if (input.roleIdentifier) filledRoles.add(input.roleIdentifier); + } + for (const output of outputs) { + if (output.roleIdentifier) filledRoles.add(output.roleIdentifier); + } + for (const variable of variables) { + if (variable.roleIdentifier) filledRoles.add(variable.roleIdentifier); } return ( @@ -143,11 +162,10 @@ export function PreviewInvitationStep({ ) : ( inputs.map((input, idx) => { - const inputTemplate = template?.inputs?.[input.inputIdentifier ?? '']; return ( - {' '}• {inputTemplate?.name ?? input.inputIdentifier ?? `Input ${idx}`} + {' '}• {input.name ?? input.inputIdentifier ?? `Input ${idx}`} {input.roleIdentifier && ` (${input.roleIdentifier})`} @@ -170,15 +188,17 @@ export function PreviewInvitationStep({ ) : ( outputs.map((output, idx) => { - const outputTemplate = template?.outputs?.[output.outputIdentifier ?? '']; const fiatValue = output.valueSatoshis !== undefined ? formatSatoshisToFiat(output.valueSatoshis) : null; + const outputSatoshis = output.valueSatoshis !== undefined + ? parseNumberishToBigInt(output.valueSatoshis) + : null; return ( - {' '}• {outputTemplate?.name ?? output.outputIdentifier ?? `Output ${idx}`} - {output.valueSatoshis !== undefined && ` (${formatSatoshis(output.valueSatoshis)})`} + {' '}• {output.name ?? output.outputIdentifier ?? `Output ${idx}`} + {outputSatoshis !== null && ` (${formatSatoshis(outputSatoshis)})`} {fiatValue && ` (~${fiatValue})`} @@ -201,14 +221,13 @@ export function PreviewInvitationStep({ ) : ( variables.map((variable, idx) => { - const varTemplate = template?.variables?.[variable.variableIdentifier]; const displayValue = typeof variable.value === 'bigint' ? variable.value.toString() : String(variable.value); return ( - {' '}• {varTemplate?.name ?? variable.variableIdentifier}: {displayValue} + {' '}• {variable.name ?? variable.variableIdentifier}: {displayValue} ); diff --git a/src/utils/resolve-invitation-data.ts b/src/utils/resolve-invitation-data.ts new file mode 100644 index 0000000..9dd57b6 --- /dev/null +++ b/src/utils/resolve-invitation-data.ts @@ -0,0 +1,295 @@ +/** + * Transforms a raw XO invitation into a flattened, template-enriched structure + * suitable for UI display without manually resolving template references. + * + * The original invitation format is unchanged in storage and transport; this + * function produces a read model that merges commit data with template metadata + * (names, descriptions, icons, roles, etc.). + */ + +import type { + XOInvitation, + XOInvitationInput, + XOInvitationOutput, + XOInvitationVariable, + XOInvitationVariableValue, + XOTemplate, + XOTemplateInput, + XOTemplateOutput, + XOTemplateVariable, +} from "@xo-cash/types"; + +/** + * View metadata copied from a template definition onto a resolved invitation item. + */ +interface TemplateViewMetadata { + name?: string; + description?: string; + icon?: string; +} + +/** + * Role-specific view metadata from a template output definition. + */ +export interface ResolvedInvitationOutputRoleMetadata { + name?: string; + description?: string; + icon?: string; +} + +/** + * A variable from invitation commits enriched with its template definition. + */ +export interface ResolvedInvitationVariable { + entityIdentifier: string; + variableIdentifier: string; + roleIdentifier?: string; + value: XOInvitationVariableValue; + name?: string; + description?: string; + type?: string; + hint?: string; +} + +/** + * A transaction input from invitation commits enriched with its template definition. + */ +export type ResolvedInvitationInput = XOInvitationInput & { + entityIdentifier: string; + name?: string; + description?: string; + icon?: string; + unlockingScript?: string; + omitChangeAmounts?: XOTemplateInput["omitChangeAmounts"]; +}; + +/** + * A transaction output from invitation commits enriched with its template definition. + */ +export type ResolvedInvitationOutput = XOInvitationOutput & { + entityIdentifier: string; + name?: string; + description?: string; + icon?: string; + roles?: Record; + lockingScript?: string; +}; + +/** + * Flattened, template-enriched invitation data for UI consumption. + */ +export interface ResolvedInvitationData { + invitationIdentifier: string; + templateIdentifier: string; + actionIdentifier: string; + variables: ResolvedInvitationVariable[]; + inputs: ResolvedInvitationInput[]; + outputs: ResolvedInvitationOutput[]; +} + +/** + * Picks human-readable view fields from a template definition. + */ +function pickTemplateViewMetadata( + definition: TemplateViewMetadata | undefined, +): TemplateViewMetadata { + if (!definition) return {}; + + return { + ...(definition.name !== undefined && { name: definition.name }), + ...(definition.description !== undefined && { + description: definition.description, + }), + ...(definition.icon !== undefined && { icon: definition.icon }), + }; +} + +/** + * Picks variable metadata from a template variable definition. + */ +function pickTemplateVariableMetadata( + definition: XOTemplateVariable | undefined, +): Pick { + if (!definition) return {}; + + return { + ...pickTemplateViewMetadata(definition), + ...(definition.type !== undefined && { type: definition.type }), + ...(definition.hint !== undefined && { hint: definition.hint }), + }; +} + +/** + * Picks input metadata from a template input definition. + */ +function pickTemplateInputMetadata( + definition: XOTemplateInput | undefined, +): Pick< + ResolvedInvitationInput, + "name" | "description" | "icon" | "unlockingScript" | "omitChangeAmounts" +> { + if (!definition) return {}; + + return { + ...pickTemplateViewMetadata(definition), + ...(definition.unlockingScript !== undefined && { + unlockingScript: definition.unlockingScript, + }), + ...(definition.omitChangeAmounts !== undefined && { + omitChangeAmounts: definition.omitChangeAmounts, + }), + }; +} + +/** + * Template display metadata layered onto a committed output. + */ +interface TemplateOutputMetadata { + name?: string; + description?: string; + icon?: string; + roles?: Record; + lockingScript?: string; + valueSatoshis?: bigint | string; + token?: XOTemplateOutput["token"]; +} + +/** + * Picks output metadata from a template output definition. + * + * Committed output values (e.g. lockingBytecode) take precedence over template + * defaults; display-oriented fields like name, description, and template + * valueSatoshis expressions are layered on for UI rendering. + */ +function pickTemplateOutputMetadata( + definition: XOTemplateOutput | undefined, +): TemplateOutputMetadata { + if (!definition) return {}; + + const roles = definition.roles + ? Object.fromEntries( + Object.entries(definition.roles).map(([roleId, roleDefinition]) => [ + roleId, + pickTemplateViewMetadata(roleDefinition), + ]), + ) + : undefined; + + return { + ...pickTemplateViewMetadata(definition), + ...(roles !== undefined && Object.keys(roles).length > 0 && { roles }), + ...(definition.lockingScript !== undefined && { + lockingScript: definition.lockingScript, + }), + ...(definition.valueSatoshis !== undefined && { + valueSatoshis: definition.valueSatoshis, + }), + ...(definition.token !== undefined && { token: definition.token }), + }; +} + +/** + * Enriches a committed variable with its template definition. + */ +function resolveVariable( + variable: XOInvitationVariable, + entityIdentifier: string, + template: XOTemplate, +): ResolvedInvitationVariable { + const definition = template.variables?.[variable.variableIdentifier]; + + return { + entityIdentifier, + variableIdentifier: variable.variableIdentifier, + ...(variable.roleIdentifier !== undefined && { + roleIdentifier: variable.roleIdentifier, + }), + value: variable.value, + ...pickTemplateVariableMetadata(definition), + }; +} + +/** + * Enriches a committed input with its template definition when an identifier is present. + */ +function resolveInput( + input: XOInvitationInput, + entityIdentifier: string, + template: XOTemplate, +): ResolvedInvitationInput { + const definition = input.inputIdentifier + ? template.inputs?.[input.inputIdentifier] + : undefined; + + return { + entityIdentifier, + ...input, + ...pickTemplateInputMetadata(definition), + }; +} + +/** + * Enriches a committed output with its template definition when an identifier is present. + */ +function resolveOutput( + output: XOInvitationOutput, + entityIdentifier: string, + template: XOTemplate, +): ResolvedInvitationOutput { + const definition = output.outputIdentifier + ? template.outputs?.[output.outputIdentifier] + : undefined; + const templateMetadata = pickTemplateOutputMetadata(definition); + + return { + entityIdentifier, + ...output, + ...templateMetadata, + } as ResolvedInvitationOutput; +} + +/** + * Returns flattened, template-enriched invitation data for UI display. + * + * Commits are walked in order; variables, inputs, and outputs are collected + * into top-level arrays with `entityIdentifier` and template metadata attached. + * Items without a template identifier (e.g. ad-hoc change outputs) keep only + * their committed fields. + * + * @param invitation - The raw invitation in standard XO format. + * @param template - The template referenced by the invitation. + * @returns Resolved invitation data ready for display. + */ +export function resolveCommitReferences( + invitation: XOInvitation, + template: XOTemplate, +): ResolvedInvitationData { + const variables: ResolvedInvitationVariable[] = []; + const inputs: ResolvedInvitationInput[] = []; + const outputs: ResolvedInvitationOutput[] = []; + + for (const commit of invitation.commits ?? []) { + for (const variable of commit.data?.variables ?? []) { + variables.push( + resolveVariable(variable, commit.entityIdentifier, template), + ); + } + + for (const input of commit.data?.inputs ?? []) { + inputs.push(resolveInput(input, commit.entityIdentifier, template)); + } + + for (const output of commit.data?.outputs ?? []) { + outputs.push(resolveOutput(output, commit.entityIdentifier, template)); + } + } + + return { + invitationIdentifier: invitation.invitationIdentifier, + templateIdentifier: invitation.templateIdentifier, + actionIdentifier: invitation.actionIdentifier, + variables, + inputs, + outputs, + }; +} diff --git a/tests/utils/resolve-invitation-data.test.ts b/tests/utils/resolve-invitation-data.test.ts new file mode 100644 index 0000000..39f2dda --- /dev/null +++ b/tests/utils/resolve-invitation-data.test.ts @@ -0,0 +1,240 @@ +import { describe, expect, it } from "vitest"; +import type { XOInvitation } from "@xo-cash/types"; + +import { vendingMachineTemplate } from "../../src/templates/vending-machine.js"; +import { resolveCommitReferences } from "../../src/utils/resolve-invitation-data.js"; + +const MERCHANT_ENTITY = + "xpub6EUk69HMQk83Ay3QEFWhYgvqLvT6tGTnzWK33fao2fvnDyzhbBeoSc6JbQkvnKq33bH7HjqQmZ9H29hsesC53ZgxQfGBadBZL5jmSa7kbTD"; +const CUSTOMER_ENTITY = + "xpub6FHRsCb1ma6VFGZpRYZL8A3X1Gwwc8JjRcaDJR2vgirrttmdvJX5VNYceA84RDVjy1c2a2oYEwuayLDZ9gssDgU52UXDGFTDa19z5ceXfFh"; + +/** + * Minimal reproduction of OriginalInvitation.json for the vending machine flow. + */ +const originalInvitation: XOInvitation = { + invitationIdentifier: "c57b1f8f8534df28b359e323c5fbd5ba", + createdAtTimestamp: 1779488689379, + templateIdentifier: + "feadd05c6566c5eded68f321efe7150cb765fda070d027c89f285e5b42a00652", + actionIdentifier: "purchaseItems", + commits: [ + { + commitIdentifier: "76b935a35ca45f1065f9c66769d1a957", + previousCommitIdentifier: undefined, + entityIdentifier: MERCHANT_ENTITY, + data: {}, + signature: "5f487c045657f3939ecfeaaacf239a7cfd44b485c2be591f5280bf0cc3a6e5fe304e8ea23311d82b2afa4f0ad7e0a6d07ec1e0b1aaee9c44097613694390966b", + expiresAtTimestamp: 1779506689379, + }, + { + commitIdentifier: "cbf2d6242144f6761d0efc3bbbbf6660", + previousCommitIdentifier: "76b935a35ca45f1065f9c66769d1a957", + entityIdentifier: MERCHANT_ENTITY, + data: { + variables: [ + { + variableIdentifier: "totalSatoshis", + roleIdentifier: "merchant", + value: 3000, + }, + { + variableIdentifier: "orderId", + roleIdentifier: "merchant", + value: "eb5a30b3-ec8c-4b81-89dd-c53371f55a0e", + }, + { + variableIdentifier: "merchantName", + roleIdentifier: "merchant", + value: "XO Snack Machine", + }, + { + variableIdentifier: "receiptSummary", + roleIdentifier: "merchant", + value: "2× Chips", + }, + { + variableIdentifier: "lineItemsJson", + roleIdentifier: "merchant", + value: + '[{"id":"225e37f4-14f2-4b33-86fd-763018bbfd7c","name":"Chips","quantity":2,"price":1500}]', + }, + ], + }, + signature: "7cfc53860ec81403a79a03521a7674ee8d2a11365ee031e4f7f2e36a045bd6e2999510264b29045582a74e1190f0176950a855361f02bc67ff7877fabcf794f4", + expiresAtTimestamp: 1779506689390, + }, + { + commitIdentifier: "583208aa304c0aa9841d1400efe6b6aa", + previousCommitIdentifier: "cbf2d6242144f6761d0efc3bbbbf6660", + entityIdentifier: MERCHANT_ENTITY, + data: { + outputs: [ + { + outputIdentifier: "purchaseOutput", + lockingBytecode: + "76a9146a4715fe1cc1ce228336502f1711b06045ef361088ac", + }, + ], + }, + signature: "d9bdd3b24fef6afd13f12da92e832672c6c1b83fb372506faeb7fa4ea0e39e3a32ad74493fbe7a393aed58bc18226431dabae09948ce371ad3f77b0219cb3831", + expiresAtTimestamp: 1779506689412, + }, + { + commitIdentifier: "4f3f9a3361c8070ab589cc44248a6a80", + previousCommitIdentifier: "583208aa304c0aa9841d1400efe6b6aa", + entityIdentifier: CUSTOMER_ENTITY, + data: {}, + signature: "63be8af81622da4fccc7eb6b81c6174879fe6aa113b8dae794bd42d4d5c87ae550a18be1e6cb5edf231e774bdc7883eb5a78bd02188579dce58da0d449c43865", + expiresAtTimestamp: 1779506979194, + }, + { + commitIdentifier: "d18b9a0caa638eaa1d0711f333e9c114", + previousCommitIdentifier: "4f3f9a3361c8070ab589cc44248a6a80", + entityIdentifier: CUSTOMER_ENTITY, + data: { + inputs: [ + { + outpointTransactionHash: + "b1e8f77cdc60efac19f668fc5c7177ace42a46e2532f230979559c7190c3c80a", + outpointIndex: 1, + }, + ], + }, + signature: "e36942eb5f147e620659d20b7059630da871944e74fe5ffb3c4ff0298a5aedb101bc7468b19750114cbcfa56b99bd4a080453a31084f18173adcd9442fca4303", + expiresAtTimestamp: 1779507006272, + }, + { + commitIdentifier: "7823f7ae7a365f87f6acdfee8896f508", + previousCommitIdentifier: "d18b9a0caa638eaa1d0711f333e9c114", + entityIdentifier: CUSTOMER_ENTITY, + data: { + outputs: [ + { + valueSatoshis: 74881n, + lockingBytecode: + "76a9141730ca066d4b9c8d542f8c9bdce645f77697d46088ac", + }, + ], + }, + signature: "2c1d1ed1259a2e4b1bc7187b93029e99e590a4e92ff9c39031319766b7fbcdabab9c3dc20b3d27d05eee198cbc717b9aedfbef92bd3e519c62c60e4731bd936a", + expiresAtTimestamp: 1779507008169, + }, + ], +}; + +describe("resolveCommitReferences", () => { + it("flattens commits and enriches items with template metadata", () => { + const resolved = resolveCommitReferences( + originalInvitation, + vendingMachineTemplate, + ); + + expect(resolved).toEqual({ + invitationIdentifier: "c57b1f8f8534df28b359e323c5fbd5ba", + templateIdentifier: + "feadd05c6566c5eded68f321efe7150cb765fda070d027c89f285e5b42a00652", + actionIdentifier: "purchaseItems", + variables: [ + { + entityIdentifier: MERCHANT_ENTITY, + name: "Total Price", + description: "Total purchase price in satoshis", + type: "integer", + hint: "satoshis", + variableIdentifier: "totalSatoshis", + roleIdentifier: "merchant", + value: 3000, + }, + { + entityIdentifier: MERCHANT_ENTITY, + name: "Order ID", + description: "Unique order identifier", + type: "string", + variableIdentifier: "orderId", + roleIdentifier: "merchant", + value: "eb5a30b3-ec8c-4b81-89dd-c53371f55a0e", + }, + { + entityIdentifier: MERCHANT_ENTITY, + name: "Merchant Name", + description: "Display name of the vending machine", + type: "string", + variableIdentifier: "merchantName", + roleIdentifier: "merchant", + value: "XO Snack Machine", + }, + { + entityIdentifier: MERCHANT_ENTITY, + name: "Receipt Summary", + description: "Human-readable list of purchased items", + type: "string", + variableIdentifier: "receiptSummary", + roleIdentifier: "merchant", + value: "2× Chips", + }, + { + entityIdentifier: MERCHANT_ENTITY, + name: "Line Items", + description: "JSON-encoded line items for the purchase", + type: "string", + variableIdentifier: "lineItemsJson", + roleIdentifier: "merchant", + value: + '[{"id":"225e37f4-14f2-4b33-86fd-763018bbfd7c","name":"Chips","quantity":2,"price":1500}]', + }, + ], + inputs: [ + { + entityIdentifier: CUSTOMER_ENTITY, + outpointTransactionHash: + "b1e8f77cdc60efac19f668fc5c7177ace42a46e2532f230979559c7190c3c80a", + outpointIndex: 1, + }, + ], + outputs: [ + { + entityIdentifier: MERCHANT_ENTITY, + outputIdentifier: "purchaseOutput", + lockingBytecode: + "76a9146a4715fe1cc1ce228336502f1711b06045ef361088ac", + name: "Purchase Payment", + description: "$() sats to $()", + icon: "request", + roles: { + merchant: { + name: "Payment Received", + description: + "Received $() sats for $()", + }, + customer: { + name: "Payment Sent", + description: + "Sent $() sats for $()", + }, + }, + lockingScript: "merchantReceivingLockingScript", + valueSatoshis: "$()", + token: null, + }, + { + entityIdentifier: CUSTOMER_ENTITY, + valueSatoshis: 74881n, + lockingBytecode: + "76a9141730ca066d4b9c8d542f8c9bdce645f77697d46088ac", + }, + ], + }); + }); + + it("leaves unidentified inputs and outputs without template metadata", () => { + const resolved = resolveCommitReferences( + originalInvitation, + vendingMachineTemplate, + ); + + expect(resolved.inputs[0]).not.toHaveProperty("name"); + expect(resolved.outputs[1]).not.toHaveProperty("name"); + expect(resolved.outputs[1]).not.toHaveProperty("outputIdentifier"); + }); +}); -- 2.49.1 From bca736dab4b897148fcb70f90108f9d61cd92c7a Mon Sep 17 00:00:00 2001 From: Harvey Zuccon Date: Mon, 8 Jun 2026 13:22:13 +0200 Subject: [PATCH 17/22] Add removeInvitation to the invitation screen --- src/services/app.ts | 13 ++++++++++ src/services/invitation.ts | 18 +++++++++++++ .../screens/invitations/InvitationScreen.tsx | 26 +++++++++++++++++++ 3 files changed, 57 insertions(+) diff --git a/src/services/app.ts b/src/services/app.ts index f175a1b..e1b71df 100644 --- a/src/services/app.ts +++ b/src/services/app.ts @@ -67,6 +67,7 @@ export class AppService extends EventEmitter { { onUpdated: (invitation: XOInvitation) => void; onStatusChanged: (status: string) => void; + onRemoved: () => void; } >(); @@ -241,13 +242,25 @@ export class AppService extends EventEmitter { invitationIdentifier, }); }; + const onRemoved = () => { + this.detachInvitationListeners(invitationIdentifier); + this.invitations.splice(this.invitations.indexOf(invitation), 1); + this.bumpInvitationRevision(invitationIdentifier); + this.emit("invitation-removed", invitation); + this.emit("wallet-state-changed", { + reason: "invitation-removed", + invitationIdentifier: invitationIdentifier, + }); + }; invitation.on("invitation-updated", onUpdated); invitation.on("invitation-status-changed", onStatusChanged); + invitation.on("invitation-removed", onRemoved); this.invitationEventCleanup.set(invitationIdentifier, { onUpdated, onStatusChanged, + onRemoved, }); } diff --git a/src/services/invitation.ts b/src/services/invitation.ts index ebe6630..7a08d7c 100644 --- a/src/services/invitation.ts +++ b/src/services/invitation.ts @@ -46,6 +46,7 @@ export type { ResolvedInvitationData } from "../utils/resolve-invitation-data.js export type InvitationEventMap = { "invitation-updated": XOInvitation; "invitation-status-changed": string; + "invitation-removed": void; error: Error; }; @@ -889,4 +890,21 @@ export class Invitation extends EventEmitter { return totalSats; } + + /** + * Removes the invitation from the Local SQLite db as well as the Engine's internal DB + * NOTE: This uses methods that are marked "DANGEROUSLY" inside the engine and behaviour may change + */ + public async delete() { + // Remove the invitation from our local db + this.storage.remove(this.data.invitationIdentifier); + + // Remove the invitation from the engine's internal db + await this.engine.DANGEROUS_deleteStoredInvitation(this.data.invitationIdentifier); + + this.emit("invitation-removed", this.data.invitationIdentifier); + + // Update the status of the invitation + await this.updateStatus(); + } } diff --git a/src/tui/screens/invitations/InvitationScreen.tsx b/src/tui/screens/invitations/InvitationScreen.tsx index 2a84517..28e3390 100644 --- a/src/tui/screens/invitations/InvitationScreen.tsx +++ b/src/tui/screens/invitations/InvitationScreen.tsx @@ -63,6 +63,7 @@ const actionItems: ListItemData[] = [ { key: 'sign', label: 'Sign Transaction', value: 'sign' }, { key: 'broadcast', label: 'Broadcast Transaction', value: 'broadcast' }, { key: 'copy', label: 'Copy Invitation ID', value: 'copy' }, + { key: 'delete', label: 'Delete Invitation', value: 'delete' }, ]; /** @@ -354,6 +355,28 @@ export function InvitationScreen(): React.ReactElement { } }, [selectedInvitation, showInfo, showError, setStatus]); + /** + * Delete the selected invitation from both our SQLite db and the engine's db + * NOTE: This uses methods marked "DANGEROUSLY" internally, and may change in the future. + */ + const deleteInvitation = useCallback(async () => { + if (!selectedInvitation) return; + + setIsLoading(true) + setStatus('Removing invitation...') + + try { + await selectedInvitation.delete(); + showInfo('Invitation successfully deleted') + setStatus('Ready') + } catch (error) { + showError(`Failed to delete invitation: ${error instanceof Error ? error.message : String(error)}`) + } finally { + setIsLoading(false) + setStatus('Ready') + } + }) + const copyId = useCallback(async () => { if (!selectedInvitation) { showError('No invitation selected'); @@ -509,6 +532,9 @@ export function InvitationScreen(): React.ReactElement { case 'broadcast': broadcastTransaction(); break; + case 'delete': + deleteInvitation(); + break; } }, [selectedInvitation, copyId, acceptInvitation, fillRequirements, signInvitation, broadcastTransaction, navigate]); -- 2.49.1 From d2c37fd95793e7ab7560bd89fbb40e0e914c2bc9 Mon Sep 17 00:00:00 2001 From: Harvey Zuccon Date: Mon, 8 Jun 2026 13:26:41 +0200 Subject: [PATCH 18/22] Add invitation delete to cli --- src/cli/autocomplete/scripts/bash.sh | 2 +- src/cli/commands/invitation.ts | 42 ++++++++++++++++++++++++++++ 2 files changed, 43 insertions(+), 1 deletion(-) diff --git a/src/cli/autocomplete/scripts/bash.sh b/src/cli/autocomplete/scripts/bash.sh index 5e7b960..4310f48 100644 --- a/src/cli/autocomplete/scripts/bash.sh +++ b/src/cli/autocomplete/scripts/bash.sh @@ -174,7 +174,7 @@ _{{FUNC_NAME}}_completions() { fi fi ;; - append|sign|broadcast|requirements|export|inspect) + append|sign|broadcast|requirements|export|inspect|delete) # These subcommands expect an invitation identifier as first arg. local pos=$((cword - subcmd_idx)) if [[ $pos -eq 1 ]]; then diff --git a/src/cli/commands/invitation.ts b/src/cli/commands/invitation.ts index 8c21461..70d0dc1 100644 --- a/src/cli/commands/invitation.ts +++ b/src/cli/commands/invitation.ts @@ -298,6 +298,7 @@ ${bold("Sub-commands:")} - requirements ${dim("Show requirements for an invitation")} - import ${dim("Import an invitation from a file")} - export [output-file] ${dim("Export an invitation to stdout or a file")} + - delete ${dim("Delete an invitation")} - inspect ${dim("Inspect an invitation")} - list ${dim("List all invitations")} @@ -955,6 +956,47 @@ export const handleInvitationCommand = async ( return handleInvitationExportCommand(deps, args.slice(1), options); } + case "delete": { + // Get the invitation identifier from the arguments + const invitationIdentifier = args[1]; + deps.io.verbose(`Invitation identifier: ${invitationIdentifier}`); + + // If they didnt provide us with an invitation identifier, print the help message and throw an error + // TODO: Should probably print a specific help message for this command? + if (!invitationIdentifier) { + deps.io.verbose("No invitation identifier provided"); + printInvitationHelp(deps.io); + throw new CommandError( + "invitation.delete.identifier_missing", + "No invitation identifier provided", + ); + } + + // Find the invitation instance in our list of invitations + const invitation = deps.app.invitations.find( + (candidate) => + candidate.data.invitationIdentifier === invitationIdentifier, + ); + + // If the invitation is not found, print an error and throw an error + if (!invitation) { + deps.io.err(`Invitation not found: ${invitationIdentifier}`); + throw new CommandError( + "invitation.delete.not_found", + `Invitation not found: ${invitationIdentifier}`, + ); + } + deps.io.verbose(`Invitation: ${formatObject(invitation.data)}`); + + // Delete the invitation + await invitation.delete(); + deps.io.verbose(`Invitation deleted: ${formatObject(invitation.data)}`); + deps.io.out(`Invitation deleted: ${invitationIdentifier}`); + + // Return the invitation identifier + return { invitationIdentifier }; + } + case "list": { // List all the invitations const invitations = await Promise.all( -- 2.49.1 From 771968dfbb20ab48e8353e9179eb2defad8c198d Mon Sep 17 00:00:00 2001 From: Harvey Zuccon Date: Mon, 15 Jun 2026 18:36:55 +1000 Subject: [PATCH 19/22] Use mergeInvitationCommits in resolveCommitReferences for correct commit merging. Delegate input/output merging to the engine so mergesWith extensions and transaction indices resolve correctly instead of flattening raw commits. --- src/utils/resolve-invitation-data.ts | 223 ++++++++++++++++++-- tests/utils/resolve-invitation-data.test.ts | 47 +++++ 2 files changed, 249 insertions(+), 21 deletions(-) diff --git a/src/utils/resolve-invitation-data.ts b/src/utils/resolve-invitation-data.ts index 9dd57b6..5927ea4 100644 --- a/src/utils/resolve-invitation-data.ts +++ b/src/utils/resolve-invitation-data.ts @@ -7,8 +7,11 @@ * (names, descriptions, icons, roles, etc.). */ +import { mergeInvitationCommits } from "@xo-cash/engine"; +import { binToHex } from "@bitauth/libauth"; import type { XOInvitation, + XOInvitationCommit, XOInvitationInput, XOInvitationOutput, XOInvitationVariable, @@ -249,12 +252,149 @@ function resolveOutput( } /** - * Returns flattened, template-enriched invitation data for UI display. + * Converts hex or binary invitation bytecode fields to hex strings for display. + */ +function hexOrBinToHex( + value: string | Uint8Array | undefined, +): string | undefined { + if (value === undefined) { + return undefined; + } + + return typeof value === "string" ? value : binToHex(value); +} + +/** + * Normalizes a merged input row for UI display (hex strings, no encoding placeholders). + */ +function normalizeMergedInputForDisplay(input: XOInvitationInput): XOInvitationInput { + const normalized: XOInvitationInput = { ...input }; + + if (input.outpointTransactionHash !== undefined) { + normalized.outpointTransactionHash = hexOrBinToHex( + input.outpointTransactionHash, + ) as XOInvitationInput["outpointTransactionHash"]; + } + + if (input.unlockingBytecode !== undefined) { + const isPlaceholder = + input.unlockingBytecode instanceof Uint8Array && + input.unlockingBytecode.length === 0; + + if (isPlaceholder) { + delete normalized.unlockingBytecode; + } else { + normalized.unlockingBytecode = hexOrBinToHex( + input.unlockingBytecode, + ) as XOInvitationInput["unlockingBytecode"]; + } + } + + if (normalized.sequenceNumber === 0) { + delete normalized.sequenceNumber; + } + + return normalized; +} + +/** + * Normalizes a merged output row for UI display (hex strings). + */ +function normalizeMergedOutputForDisplay( + output: XOInvitationOutput, +): XOInvitationOutput { + const normalized: XOInvitationOutput = { ...output }; + + if (output.lockingBytecode !== undefined) { + normalized.lockingBytecode = hexOrBinToHex( + output.lockingBytecode, + ) as XOInvitationOutput["lockingBytecode"]; + } + + return normalized; +} + +/** + * Recovers `outputIdentifier` from the source commit because the merger strips it + * after template resolution. + */ +function findOutputIdentifierForMergedOutput( + commit: XOInvitationCommit | undefined, + mergedOutput: XOInvitationOutput, +): string | undefined { + const outputs = commit?.data?.outputs ?? []; + const mergedBytecodeHex = hexOrBinToHex(mergedOutput.lockingBytecode); + + for (const commitOutput of outputs) { + if (commitOutput.outputIdentifier === undefined) { + continue; + } + + const commitBytecodeHex = hexOrBinToHex(commitOutput.lockingBytecode); + + if ( + mergedBytecodeHex !== undefined && + commitBytecodeHex !== undefined && + mergedBytecodeHex === commitBytecodeHex + ) { + return commitOutput.outputIdentifier; + } + } + + const outputsWithIdentifier = outputs.filter( + (commitOutput) => commitOutput.outputIdentifier !== undefined, + ); + + if (outputsWithIdentifier.length === 1) { + const soleIdentifiedOutput = outputsWithIdentifier[0]; + return soleIdentifiedOutput?.outputIdentifier; + } + + return undefined; +} + +/** + * Whether two invitation variable rows refer to the same template variable slot. + */ +function matchesInvitationVariable( + left: XOInvitationVariable, + right: XOInvitationVariable, +): boolean { + return ( + left.variableIdentifier === right.variableIdentifier && + left.roleIdentifier === right.roleIdentifier + ); +} + +/** + * Finds the entity that authored a merged variable by scanning invitation commits. + * Last matching commit in array order wins. Best-effort until the engine orders + * commits internally or exposes source attribution on merged variables. + */ +function findVariableEntityIdentifier( + variable: XOInvitationVariable, + commits: XOInvitationCommit[], +): string { + let entityIdentifier = ""; + + for (const commit of commits) { + for (const commitVariable of commit.data?.variables ?? []) { + if (matchesInvitationVariable(commitVariable, variable)) { + entityIdentifier = commit.entityIdentifier; + } + } + } + + return entityIdentifier; +} + +/** + * Returns template-enriched invitation data for UI display. * - * Commits are walked in order; variables, inputs, and outputs are collected - * into top-level arrays with `entityIdentifier` and template metadata attached. - * Items without a template identifier (e.g. ad-hoc change outputs) keep only - * their committed fields. + * Uses {@link mergeInvitationCommits} for inputs and outputs so `mergesWith` + * extensions and transaction indices are resolved. Variables come from the merged + * result and are enriched with template metadata. Commit ordering is delegated to + * the engine merger. * * @param invitation - The raw invitation in standard XO format. * @param template - The template referenced by the invitation. @@ -264,26 +404,67 @@ export function resolveCommitReferences( invitation: XOInvitation, template: XOTemplate, ): ResolvedInvitationData { - const variables: ResolvedInvitationVariable[] = []; - const inputs: ResolvedInvitationInput[] = []; - const outputs: ResolvedInvitationOutput[] = []; + const commits = invitation.commits ?? []; + const commitsMap = new Map( + commits.map((commit) => [commit.commitIdentifier, commit]), + ); - for (const commit of invitation.commits ?? []) { - for (const variable of commit.data?.variables ?? []) { - variables.push( - resolveVariable(variable, commit.entityIdentifier, template), - ); - } + const merged = mergeInvitationCommits( + invitation as Parameters[0], + template, + ); - for (const input of commit.data?.inputs ?? []) { - inputs.push(resolveInput(input, commit.entityIdentifier, template)); - } - - for (const output of commit.data?.outputs ?? []) { - outputs.push(resolveOutput(output, commit.entityIdentifier, template)); - } + if (merged === null) { + return { + invitationIdentifier: invitation.invitationIdentifier, + templateIdentifier: invitation.templateIdentifier, + actionIdentifier: invitation.actionIdentifier, + variables: [], + inputs: [], + outputs: [], + }; } + const variables = merged.variables.map((variable) => + resolveVariable( + variable, + findVariableEntityIdentifier(variable, commits), + template, + ), + ); + + const inputs = merged.inputs.map((mergedInput) => { + const commit = commitsMap.get(mergedInput.sourceCommitIdentifier); + const entityIdentifier = commit?.entityIdentifier ?? ""; + const { + sourceCommitIdentifier: _sourceCommitIdentifier, + mergesWith: _mergesWith, + ...input + } = mergedInput; + + return resolveInput( + normalizeMergedInputForDisplay(input), + entityIdentifier, + template, + ); + }); + + const outputs = merged.outputs.map((mergedOutput) => { + const commit = commitsMap.get(mergedOutput.sourceCommitIdentifier); + const entityIdentifier = commit?.entityIdentifier ?? ""; + const { + sourceCommitIdentifier: _sourceCommitIdentifier, + mergesWith: _mergesWith, + ...output + } = mergedOutput; + const outputIdentifier = findOutputIdentifierForMergedOutput(commit, output); + const outputForDisplay = normalizeMergedOutputForDisplay( + outputIdentifier !== undefined ? { ...output, outputIdentifier } : output, + ); + + return resolveOutput(outputForDisplay, entityIdentifier, template); + }); + return { invitationIdentifier: invitation.invitationIdentifier, templateIdentifier: invitation.templateIdentifier, diff --git a/tests/utils/resolve-invitation-data.test.ts b/tests/utils/resolve-invitation-data.test.ts index 39f2dda..bffecba 100644 --- a/tests/utils/resolve-invitation-data.test.ts +++ b/tests/utils/resolve-invitation-data.test.ts @@ -123,6 +123,36 @@ const originalInvitation: XOInvitation = { ], }; +/** + * Customer input commit extended with unlocking bytecode via mergesWith (signing flow). + */ +const invitationWithSignedInput: XOInvitation = { + ...originalInvitation, + commits: [ + ...originalInvitation.commits.slice(0, 5), + { + commitIdentifier: "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6", + previousCommitIdentifier: "d18b9a0caa638eaa1d0711f333e9c114", + entityIdentifier: CUSTOMER_ENTITY, + data: { + inputs: [ + { + mergesWith: { + commitIdentifier: "d18b9a0caa638eaa1d0711f333e9c114", + index: 0, + }, + unlockingBytecode: + "41226b2be7c2890c8bbde2f79e79640e56d866843f2e822ec51c469019d13db0", + }, + ], + }, + signature: + "3045022001a1b2c3d4e5f6789012345678901234567890abcdef1234567890abcdef123456789022100fedcba0987654321fedcba0987654321fedcba0987654321fedcba0987654321", + expiresAtTimestamp: 1779507008000, + }, + ], +}; + describe("resolveCommitReferences", () => { it("flattens commits and enriches items with template metadata", () => { const resolved = resolveCommitReferences( @@ -237,4 +267,21 @@ describe("resolveCommitReferences", () => { expect(resolved.outputs[1]).not.toHaveProperty("name"); expect(resolved.outputs[1]).not.toHaveProperty("outputIdentifier"); }); + + it("merges input extension commits via mergesWith into a single input", () => { + const resolved = resolveCommitReferences( + invitationWithSignedInput, + vendingMachineTemplate, + ); + + expect(resolved.inputs).toHaveLength(1); + expect(resolved.inputs[0]).toMatchObject({ + entityIdentifier: CUSTOMER_ENTITY, + outpointTransactionHash: + "b1e8f77cdc60efac19f668fc5c7177ace42a46e2532f230979559c7190c3c80a", + outpointIndex: 1, + unlockingBytecode: + "41226b2be7c2890c8bbde2f79e79640e56d866843f2e822ec51c469019d13db0", + }); + }); }); -- 2.49.1 From d089e909f8357f4ba8b5780173e1c30bab675253 Mon Sep 17 00:00:00 2001 From: Harvey Zuccon Date: Mon, 15 Jun 2026 20:00:40 +1000 Subject: [PATCH 20/22] Use arrow function syntax. Export all methods. --- src/utils/resolve-invitation-data.ts | 112 ++++++++++++++------------- 1 file changed, 57 insertions(+), 55 deletions(-) diff --git a/src/utils/resolve-invitation-data.ts b/src/utils/resolve-invitation-data.ts index 5927ea4..949fd4d 100644 --- a/src/utils/resolve-invitation-data.ts +++ b/src/utils/resolve-invitation-data.ts @@ -25,7 +25,7 @@ import type { /** * View metadata copied from a template definition onto a resolved invitation item. */ -interface TemplateViewMetadata { +export interface TemplateViewMetadata { name?: string; description?: string; icon?: string; @@ -90,12 +90,25 @@ export interface ResolvedInvitationData { outputs: ResolvedInvitationOutput[]; } +/** + * Template display metadata layered onto a committed output. + */ +export interface TemplateOutputMetadata { + name?: string; + description?: string; + icon?: string; + roles?: Record; + lockingScript?: string; + valueSatoshis?: bigint | string; + token?: XOTemplateOutput["token"]; +} + /** * Picks human-readable view fields from a template definition. */ -function pickTemplateViewMetadata( +export const pickTemplateViewMetadata = ( definition: TemplateViewMetadata | undefined, -): TemplateViewMetadata { +): TemplateViewMetadata => { if (!definition) return {}; return { @@ -105,14 +118,14 @@ function pickTemplateViewMetadata( }), ...(definition.icon !== undefined && { icon: definition.icon }), }; -} +}; /** * Picks variable metadata from a template variable definition. */ -function pickTemplateVariableMetadata( +export const pickTemplateVariableMetadata = ( definition: XOTemplateVariable | undefined, -): Pick { +): Pick => { if (!definition) return {}; return { @@ -120,17 +133,17 @@ function pickTemplateVariableMetadata( ...(definition.type !== undefined && { type: definition.type }), ...(definition.hint !== undefined && { hint: definition.hint }), }; -} +}; /** * Picks input metadata from a template input definition. */ -function pickTemplateInputMetadata( +export const pickTemplateInputMetadata = ( definition: XOTemplateInput | undefined, ): Pick< ResolvedInvitationInput, "name" | "description" | "icon" | "unlockingScript" | "omitChangeAmounts" -> { +> => { if (!definition) return {}; return { @@ -142,20 +155,7 @@ function pickTemplateInputMetadata( omitChangeAmounts: definition.omitChangeAmounts, }), }; -} - -/** - * Template display metadata layered onto a committed output. - */ -interface TemplateOutputMetadata { - name?: string; - description?: string; - icon?: string; - roles?: Record; - lockingScript?: string; - valueSatoshis?: bigint | string; - token?: XOTemplateOutput["token"]; -} +}; /** * Picks output metadata from a template output definition. @@ -164,9 +164,9 @@ interface TemplateOutputMetadata { * defaults; display-oriented fields like name, description, and template * valueSatoshis expressions are layered on for UI rendering. */ -function pickTemplateOutputMetadata( +export const pickTemplateOutputMetadata = ( definition: XOTemplateOutput | undefined, -): TemplateOutputMetadata { +): TemplateOutputMetadata => { if (!definition) return {}; const roles = definition.roles @@ -189,16 +189,16 @@ function pickTemplateOutputMetadata( }), ...(definition.token !== undefined && { token: definition.token }), }; -} +}; /** * Enriches a committed variable with its template definition. */ -function resolveVariable( +export const resolveVariable = ( variable: XOInvitationVariable, entityIdentifier: string, template: XOTemplate, -): ResolvedInvitationVariable { +): ResolvedInvitationVariable => { const definition = template.variables?.[variable.variableIdentifier]; return { @@ -210,16 +210,16 @@ function resolveVariable( value: variable.value, ...pickTemplateVariableMetadata(definition), }; -} +}; /** * Enriches a committed input with its template definition when an identifier is present. */ -function resolveInput( +export const resolveInput = ( input: XOInvitationInput, entityIdentifier: string, template: XOTemplate, -): ResolvedInvitationInput { +): ResolvedInvitationInput => { const definition = input.inputIdentifier ? template.inputs?.[input.inputIdentifier] : undefined; @@ -229,16 +229,16 @@ function resolveInput( ...input, ...pickTemplateInputMetadata(definition), }; -} +}; /** * Enriches a committed output with its template definition when an identifier is present. */ -function resolveOutput( +export const resolveOutput = ( output: XOInvitationOutput, entityIdentifier: string, template: XOTemplate, -): ResolvedInvitationOutput { +): ResolvedInvitationOutput => { const definition = output.outputIdentifier ? template.outputs?.[output.outputIdentifier] : undefined; @@ -249,25 +249,27 @@ function resolveOutput( ...output, ...templateMetadata, } as ResolvedInvitationOutput; -} +}; /** * Converts hex or binary invitation bytecode fields to hex strings for display. */ -function hexOrBinToHex( +export const hexOrBinToHex = ( value: string | Uint8Array | undefined, -): string | undefined { +): string | undefined => { if (value === undefined) { return undefined; } return typeof value === "string" ? value : binToHex(value); -} +}; /** * Normalizes a merged input row for UI display (hex strings, no encoding placeholders). */ -function normalizeMergedInputForDisplay(input: XOInvitationInput): XOInvitationInput { +export const normalizeMergedInputForDisplay = ( + input: XOInvitationInput, +): XOInvitationInput => { const normalized: XOInvitationInput = { ...input }; if (input.outpointTransactionHash !== undefined) { @@ -295,14 +297,14 @@ function normalizeMergedInputForDisplay(input: XOInvitationInput): XOInvitationI } return normalized; -} +}; /** * Normalizes a merged output row for UI display (hex strings). */ -function normalizeMergedOutputForDisplay( +export const normalizeMergedOutputForDisplay = ( output: XOInvitationOutput, -): XOInvitationOutput { +): XOInvitationOutput => { const normalized: XOInvitationOutput = { ...output }; if (output.lockingBytecode !== undefined) { @@ -312,16 +314,16 @@ function normalizeMergedOutputForDisplay( } return normalized; -} +}; /** * Recovers `outputIdentifier` from the source commit because the merger strips it * after template resolution. */ -function findOutputIdentifierForMergedOutput( +export const findOutputIdentifierForMergedOutput = ( commit: XOInvitationCommit | undefined, mergedOutput: XOInvitationOutput, -): string | undefined { +): string | undefined => { const outputs = commit?.data?.outputs ?? []; const mergedBytecodeHex = hexOrBinToHex(mergedOutput.lockingBytecode); @@ -351,30 +353,30 @@ function findOutputIdentifierForMergedOutput( } return undefined; -} +}; /** * Whether two invitation variable rows refer to the same template variable slot. */ -function matchesInvitationVariable( +export const matchesInvitationVariable = ( left: XOInvitationVariable, right: XOInvitationVariable, -): boolean { +): boolean => { return ( left.variableIdentifier === right.variableIdentifier && left.roleIdentifier === right.roleIdentifier ); -} +}; /** * Finds the entity that authored a merged variable by scanning invitation commits. * Last matching commit in array order wins. Best-effort until the engine orders * commits internally or exposes source attribution on merged variables. */ -function findVariableEntityIdentifier( +export const findVariableEntityIdentifier = ( variable: XOInvitationVariable, commits: XOInvitationCommit[], -): string { +): string => { let entityIdentifier = ""; for (const commit of commits) { @@ -386,7 +388,7 @@ function findVariableEntityIdentifier( } return entityIdentifier; -} +}; /** * Returns template-enriched invitation data for UI display. @@ -400,10 +402,10 @@ function findVariableEntityIdentifier( * @param template - The template referenced by the invitation. * @returns Resolved invitation data ready for display. */ -export function resolveCommitReferences( +export const resolveCommitReferences = ( invitation: XOInvitation, template: XOTemplate, -): ResolvedInvitationData { +): ResolvedInvitationData => { const commits = invitation.commits ?? []; const commitsMap = new Map( commits.map((commit) => [commit.commitIdentifier, commit]), @@ -473,4 +475,4 @@ export function resolveCommitReferences( inputs, outputs, }; -} +}; -- 2.49.1 From 3ee2d5376633e3bb0e2faf4d23dc38d42ab3f7bf Mon Sep 17 00:00:00 2001 From: Harvey Zuccon Date: Mon, 15 Jun 2026 20:04:51 +1000 Subject: [PATCH 21/22] Formatting and reduce explicit typing --- src/utils/resolve-invitation-data.ts | 163 +++++++------------- tests/utils/resolve-invitation-data.test.ts | 24 +-- 2 files changed, 71 insertions(+), 116 deletions(-) diff --git a/src/utils/resolve-invitation-data.ts b/src/utils/resolve-invitation-data.ts index 949fd4d..b3d0232 100644 --- a/src/utils/resolve-invitation-data.ts +++ b/src/utils/resolve-invitation-data.ts @@ -22,24 +22,6 @@ import type { XOTemplateVariable, } from "@xo-cash/types"; -/** - * View metadata copied from a template definition onto a resolved invitation item. - */ -export interface TemplateViewMetadata { - name?: string; - description?: string; - icon?: string; -} - -/** - * Role-specific view metadata from a template output definition. - */ -export interface ResolvedInvitationOutputRoleMetadata { - name?: string; - description?: string; - icon?: string; -} - /** * A variable from invitation commits enriched with its template definition. */ @@ -74,7 +56,10 @@ export type ResolvedInvitationOutput = XOInvitationOutput & { name?: string; description?: string; icon?: string; - roles?: Record; + roles?: Record< + string, + { name?: string; description?: string; icon?: string } + >; lockingScript?: string; }; @@ -91,24 +76,13 @@ export interface ResolvedInvitationData { } /** - * Template display metadata layered onto a committed output. + * Picks human-readable view fields from a template definition. */ -export interface TemplateOutputMetadata { +export const pickTemplateViewMetadata = (definition?: { name?: string; description?: string; icon?: string; - roles?: Record; - lockingScript?: string; - valueSatoshis?: bigint | string; - token?: XOTemplateOutput["token"]; -} - -/** - * Picks human-readable view fields from a template definition. - */ -export const pickTemplateViewMetadata = ( - definition: TemplateViewMetadata | undefined, -): TemplateViewMetadata => { +}) => { if (!definition) return {}; return { @@ -124,8 +98,8 @@ export const pickTemplateViewMetadata = ( * Picks variable metadata from a template variable definition. */ export const pickTemplateVariableMetadata = ( - definition: XOTemplateVariable | undefined, -): Pick => { + definition?: XOTemplateVariable, +) => { if (!definition) return {}; return { @@ -138,12 +112,7 @@ export const pickTemplateVariableMetadata = ( /** * Picks input metadata from a template input definition. */ -export const pickTemplateInputMetadata = ( - definition: XOTemplateInput | undefined, -): Pick< - ResolvedInvitationInput, - "name" | "description" | "icon" | "unlockingScript" | "omitChangeAmounts" -> => { +export const pickTemplateInputMetadata = (definition?: XOTemplateInput) => { if (!definition) return {}; return { @@ -164,9 +133,7 @@ export const pickTemplateInputMetadata = ( * defaults; display-oriented fields like name, description, and template * valueSatoshis expressions are layered on for UI rendering. */ -export const pickTemplateOutputMetadata = ( - definition: XOTemplateOutput | undefined, -): TemplateOutputMetadata => { +export const pickTemplateOutputMetadata = (definition?: XOTemplateOutput) => { if (!definition) return {}; const roles = definition.roles @@ -198,19 +165,17 @@ export const resolveVariable = ( variable: XOInvitationVariable, entityIdentifier: string, template: XOTemplate, -): ResolvedInvitationVariable => { - const definition = template.variables?.[variable.variableIdentifier]; - - return { - entityIdentifier, - variableIdentifier: variable.variableIdentifier, - ...(variable.roleIdentifier !== undefined && { - roleIdentifier: variable.roleIdentifier, - }), - value: variable.value, - ...pickTemplateVariableMetadata(definition), - }; -}; +): ResolvedInvitationVariable => ({ + entityIdentifier, + variableIdentifier: variable.variableIdentifier, + ...(variable.roleIdentifier !== undefined && { + roleIdentifier: variable.roleIdentifier, + }), + value: variable.value, + ...pickTemplateVariableMetadata( + template.variables?.[variable.variableIdentifier], + ), +}); /** * Enriches a committed input with its template definition when an identifier is present. @@ -219,17 +184,15 @@ export const resolveInput = ( input: XOInvitationInput, entityIdentifier: string, template: XOTemplate, -): ResolvedInvitationInput => { - const definition = input.inputIdentifier - ? template.inputs?.[input.inputIdentifier] - : undefined; - - return { - entityIdentifier, - ...input, - ...pickTemplateInputMetadata(definition), - }; -}; +): ResolvedInvitationInput => ({ + entityIdentifier, + ...input, + ...pickTemplateInputMetadata( + input.inputIdentifier + ? template.inputs?.[input.inputIdentifier] + : undefined, + ), +}); /** * Enriches a committed output with its template definition when an identifier is present. @@ -238,25 +201,21 @@ export const resolveOutput = ( output: XOInvitationOutput, entityIdentifier: string, template: XOTemplate, -): ResolvedInvitationOutput => { - const definition = output.outputIdentifier - ? template.outputs?.[output.outputIdentifier] - : undefined; - const templateMetadata = pickTemplateOutputMetadata(definition); - - return { +): ResolvedInvitationOutput => + ({ entityIdentifier, ...output, - ...templateMetadata, - } as ResolvedInvitationOutput; -}; + ...pickTemplateOutputMetadata( + output.outputIdentifier + ? template.outputs?.[output.outputIdentifier] + : undefined, + ), + }) as ResolvedInvitationOutput; /** * Converts hex or binary invitation bytecode fields to hex strings for display. */ -export const hexOrBinToHex = ( - value: string | Uint8Array | undefined, -): string | undefined => { +export const hexOrBinToHex = (value?: string | Uint8Array) => { if (value === undefined) { return undefined; } @@ -267,10 +226,8 @@ export const hexOrBinToHex = ( /** * Normalizes a merged input row for UI display (hex strings, no encoding placeholders). */ -export const normalizeMergedInputForDisplay = ( - input: XOInvitationInput, -): XOInvitationInput => { - const normalized: XOInvitationInput = { ...input }; +export const normalizeMergedInputForDisplay = (input: XOInvitationInput) => { + const normalized = { ...input }; if (input.outpointTransactionHash !== undefined) { normalized.outpointTransactionHash = hexOrBinToHex( @@ -302,10 +259,8 @@ export const normalizeMergedInputForDisplay = ( /** * Normalizes a merged output row for UI display (hex strings). */ -export const normalizeMergedOutputForDisplay = ( - output: XOInvitationOutput, -): XOInvitationOutput => { - const normalized: XOInvitationOutput = { ...output }; +export const normalizeMergedOutputForDisplay = (output: XOInvitationOutput) => { + const normalized = { ...output }; if (output.lockingBytecode !== undefined) { normalized.lockingBytecode = hexOrBinToHex( @@ -323,7 +278,7 @@ export const normalizeMergedOutputForDisplay = ( export const findOutputIdentifierForMergedOutput = ( commit: XOInvitationCommit | undefined, mergedOutput: XOInvitationOutput, -): string | undefined => { +) => { const outputs = commit?.data?.outputs ?? []; const mergedBytecodeHex = hexOrBinToHex(mergedOutput.lockingBytecode); @@ -348,8 +303,7 @@ export const findOutputIdentifierForMergedOutput = ( ); if (outputsWithIdentifier.length === 1) { - const soleIdentifiedOutput = outputsWithIdentifier[0]; - return soleIdentifiedOutput?.outputIdentifier; + return outputsWithIdentifier[0]?.outputIdentifier; } return undefined; @@ -361,12 +315,9 @@ export const findOutputIdentifierForMergedOutput = ( export const matchesInvitationVariable = ( left: XOInvitationVariable, right: XOInvitationVariable, -): boolean => { - return ( - left.variableIdentifier === right.variableIdentifier && - left.roleIdentifier === right.roleIdentifier - ); -}; +) => + left.variableIdentifier === right.variableIdentifier && + left.roleIdentifier === right.roleIdentifier; /** * Finds the entity that authored a merged variable by scanning invitation commits. @@ -376,7 +327,7 @@ export const matchesInvitationVariable = ( export const findVariableEntityIdentifier = ( variable: XOInvitationVariable, commits: XOInvitationCommit[], -): string => { +) => { let entityIdentifier = ""; for (const commit of commits) { @@ -397,10 +348,6 @@ export const findVariableEntityIdentifier = ( * extensions and transaction indices are resolved. Variables come from the merged * result and are enriched with template metadata. Commit ordering is delegated to * the engine merger. - * - * @param invitation - The raw invitation in standard XO format. - * @param template - The template referenced by the invitation. - * @returns Resolved invitation data ready for display. */ export const resolveCommitReferences = ( invitation: XOInvitation, @@ -436,8 +383,9 @@ export const resolveCommitReferences = ( ); const inputs = merged.inputs.map((mergedInput) => { - const commit = commitsMap.get(mergedInput.sourceCommitIdentifier); - const entityIdentifier = commit?.entityIdentifier ?? ""; + const entityIdentifier = + commitsMap.get(mergedInput.sourceCommitIdentifier)?.entityIdentifier ?? + ""; const { sourceCommitIdentifier: _sourceCommitIdentifier, mergesWith: _mergesWith, @@ -459,7 +407,10 @@ export const resolveCommitReferences = ( mergesWith: _mergesWith, ...output } = mergedOutput; - const outputIdentifier = findOutputIdentifierForMergedOutput(commit, output); + const outputIdentifier = findOutputIdentifierForMergedOutput( + commit, + output, + ); const outputForDisplay = normalizeMergedOutputForDisplay( outputIdentifier !== undefined ? { ...output, outputIdentifier } : output, ); diff --git a/tests/utils/resolve-invitation-data.test.ts b/tests/utils/resolve-invitation-data.test.ts index bffecba..030a734 100644 --- a/tests/utils/resolve-invitation-data.test.ts +++ b/tests/utils/resolve-invitation-data.test.ts @@ -24,7 +24,8 @@ const originalInvitation: XOInvitation = { previousCommitIdentifier: undefined, entityIdentifier: MERCHANT_ENTITY, data: {}, - signature: "5f487c045657f3939ecfeaaacf239a7cfd44b485c2be591f5280bf0cc3a6e5fe304e8ea23311d82b2afa4f0ad7e0a6d07ec1e0b1aaee9c44097613694390966b", + signature: + "5f487c045657f3939ecfeaaacf239a7cfd44b485c2be591f5280bf0cc3a6e5fe304e8ea23311d82b2afa4f0ad7e0a6d07ec1e0b1aaee9c44097613694390966b", expiresAtTimestamp: 1779506689379, }, { @@ -61,7 +62,8 @@ const originalInvitation: XOInvitation = { }, ], }, - signature: "7cfc53860ec81403a79a03521a7674ee8d2a11365ee031e4f7f2e36a045bd6e2999510264b29045582a74e1190f0176950a855361f02bc67ff7877fabcf794f4", + signature: + "7cfc53860ec81403a79a03521a7674ee8d2a11365ee031e4f7f2e36a045bd6e2999510264b29045582a74e1190f0176950a855361f02bc67ff7877fabcf794f4", expiresAtTimestamp: 1779506689390, }, { @@ -77,7 +79,8 @@ const originalInvitation: XOInvitation = { }, ], }, - signature: "d9bdd3b24fef6afd13f12da92e832672c6c1b83fb372506faeb7fa4ea0e39e3a32ad74493fbe7a393aed58bc18226431dabae09948ce371ad3f77b0219cb3831", + signature: + "d9bdd3b24fef6afd13f12da92e832672c6c1b83fb372506faeb7fa4ea0e39e3a32ad74493fbe7a393aed58bc18226431dabae09948ce371ad3f77b0219cb3831", expiresAtTimestamp: 1779506689412, }, { @@ -85,7 +88,8 @@ const originalInvitation: XOInvitation = { previousCommitIdentifier: "583208aa304c0aa9841d1400efe6b6aa", entityIdentifier: CUSTOMER_ENTITY, data: {}, - signature: "63be8af81622da4fccc7eb6b81c6174879fe6aa113b8dae794bd42d4d5c87ae550a18be1e6cb5edf231e774bdc7883eb5a78bd02188579dce58da0d449c43865", + signature: + "63be8af81622da4fccc7eb6b81c6174879fe6aa113b8dae794bd42d4d5c87ae550a18be1e6cb5edf231e774bdc7883eb5a78bd02188579dce58da0d449c43865", expiresAtTimestamp: 1779506979194, }, { @@ -101,7 +105,8 @@ const originalInvitation: XOInvitation = { }, ], }, - signature: "e36942eb5f147e620659d20b7059630da871944e74fe5ffb3c4ff0298a5aedb101bc7468b19750114cbcfa56b99bd4a080453a31084f18173adcd9442fca4303", + signature: + "e36942eb5f147e620659d20b7059630da871944e74fe5ffb3c4ff0298a5aedb101bc7468b19750114cbcfa56b99bd4a080453a31084f18173adcd9442fca4303", expiresAtTimestamp: 1779507006272, }, { @@ -117,7 +122,8 @@ const originalInvitation: XOInvitation = { }, ], }, - signature: "2c1d1ed1259a2e4b1bc7187b93029e99e590a4e92ff9c39031319766b7fbcdabab9c3dc20b3d27d05eee198cbc717b9aedfbef92bd3e519c62c60e4731bd936a", + signature: + "2c1d1ed1259a2e4b1bc7187b93029e99e590a4e92ff9c39031319766b7fbcdabab9c3dc20b3d27d05eee198cbc717b9aedfbef92bd3e519c62c60e4731bd936a", expiresAtTimestamp: 1779507008169, }, ], @@ -226,8 +232,7 @@ describe("resolveCommitReferences", () => { { entityIdentifier: MERCHANT_ENTITY, outputIdentifier: "purchaseOutput", - lockingBytecode: - "76a9146a4715fe1cc1ce228336502f1711b06045ef361088ac", + lockingBytecode: "76a9146a4715fe1cc1ce228336502f1711b06045ef361088ac", name: "Purchase Payment", description: "$() sats to $()", icon: "request", @@ -250,8 +255,7 @@ describe("resolveCommitReferences", () => { { entityIdentifier: CUSTOMER_ENTITY, valueSatoshis: 74881n, - lockingBytecode: - "76a9141730ca066d4b9c8d542f8c9bdce645f77697d46088ac", + lockingBytecode: "76a9141730ca066d4b9c8d542f8c9bdce645f77697d46088ac", }, ], }); -- 2.49.1 From cfcba02bb36a98061e1cefcdbe5a521dbe7f9f5c Mon Sep 17 00:00:00 2001 From: Harvey Zuccon Date: Mon, 15 Jun 2026 20:07:31 +1000 Subject: [PATCH 22/22] Document methods in resolveInvitationData --- src/utils/resolve-invitation-data.ts | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/src/utils/resolve-invitation-data.ts b/src/utils/resolve-invitation-data.ts index b3d0232..7c31936 100644 --- a/src/utils/resolve-invitation-data.ts +++ b/src/utils/resolve-invitation-data.ts @@ -85,6 +85,8 @@ export const pickTemplateViewMetadata = (definition?: { }) => { if (!definition) return {}; + // Only copy fields that are present so absent template metadata does not + // overwrite committed values when this object is spread onto a commit row. return { ...(definition.name !== undefined && { name: definition.name }), ...(definition.description !== undefined && { @@ -151,6 +153,8 @@ export const pickTemplateOutputMetadata = (definition?: XOTemplateOutput) => { ...(definition.lockingScript !== undefined && { lockingScript: definition.lockingScript, }), + // Keep CashAssembly expressions (e.g. "$()") for UI compilation; + // committed bigint values on the output row take precedence when spread later. ...(definition.valueSatoshis !== undefined && { valueSatoshis: definition.valueSatoshis, }), @@ -196,6 +200,10 @@ export const resolveInput = ( /** * Enriches a committed output with its template definition when an identifier is present. + * + * Template metadata is spread after commit fields so display expressions (e.g. + * `valueSatoshis: "$()"`) layer on for the UI even when the merger + * already resolved a bigint for transaction encoding. */ export const resolveOutput = ( output: XOInvitationOutput, @@ -210,6 +218,8 @@ export const resolveOutput = ( ? template.outputs?.[output.outputIdentifier] : undefined, ), + // Template valueSatoshis may be a CashAssembly string while XOInvitationOutput + // expects bigint — the read model intentionally allows both for display. }) as ResolvedInvitationOutput; /** @@ -225,6 +235,9 @@ export const hexOrBinToHex = (value?: string | Uint8Array) => { /** * Normalizes a merged input row for UI display (hex strings, no encoding placeholders). + * + * The engine merger returns libauth-ready binary fields and fills in encoding + * defaults (empty unlocking bytecode, sequence 0) that are not useful in the TUI. */ export const normalizeMergedInputForDisplay = (input: XOInvitationInput) => { const normalized = { ...input }; @@ -236,6 +249,7 @@ export const normalizeMergedInputForDisplay = (input: XOInvitationInput) => { } if (input.unlockingBytecode !== undefined) { + // Engine uses an empty Uint8Array as a placeholder until the input is signed. const isPlaceholder = input.unlockingBytecode instanceof Uint8Array && input.unlockingBytecode.length === 0; @@ -249,6 +263,7 @@ export const normalizeMergedInputForDisplay = (input: XOInvitationInput) => { } } + // Default sequence from the merger is not meaningful for display. if (normalized.sequenceNumber === 0) { delete normalized.sequenceNumber; } @@ -289,6 +304,7 @@ export const findOutputIdentifierForMergedOutput = ( const commitBytecodeHex = hexOrBinToHex(commitOutput.lockingBytecode); + // Match merged binary bytecode back to the committed row that carried the identifier. if ( mergedBytecodeHex !== undefined && commitBytecodeHex !== undefined && @@ -298,6 +314,7 @@ export const findOutputIdentifierForMergedOutput = ( } } + // Fall back when the commit has a single identified output (common case). const outputsWithIdentifier = outputs.filter( (commitOutput) => commitOutput.outputIdentifier !== undefined, ); @@ -330,6 +347,8 @@ export const findVariableEntityIdentifier = ( ) => { let entityIdentifier = ""; + // Merged variables do not carry sourceCommitIdentifier today; walk commits and + // let the last array match win (ordering deferred to the engine merger). for (const commit of commits) { for (const commitVariable of commit.data?.variables ?? []) { if (matchesInvitationVariable(commitVariable, variable)) { @@ -358,6 +377,8 @@ export const resolveCommitReferences = ( commits.map((commit) => [commit.commitIdentifier, commit]), ); + // Merge rather than flatten so mergesWith input extensions and transactionIndex + // ordering are handled by the engine (see signing flow in engine.append/sign). const merged = mergeInvitationCommits( invitation as Parameters[0], template, @@ -386,6 +407,7 @@ export const resolveCommitReferences = ( const entityIdentifier = commitsMap.get(mergedInput.sourceCommitIdentifier)?.entityIdentifier ?? ""; + // Strip merger-only fields before normalization and template enrichment. const { sourceCommitIdentifier: _sourceCommitIdentifier, mergesWith: _mergesWith, @@ -411,6 +433,7 @@ export const resolveCommitReferences = ( commit, output, ); + // Re-attach outputIdentifier so pickTemplateOutputMetadata can resolve names/roles. const outputForDisplay = normalizeMergedOutputForDisplay( outputIdentifier !== undefined ? { ...output, outputIdentifier } : output, ); -- 2.49.1