From def261b56872a1b349b6f3cd5acd03f0538a169c Mon Sep 17 00:00:00 2001 From: Harvey Zuccon Date: Fri, 22 May 2026 14:11:07 +0200 Subject: [PATCH] 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); + } + }); });