import { hexToBin } from "@bitauth/libauth"; import { bold, dim } from "../utils.js"; import type { CommandDependencies, CommandIO } from "./types.js"; 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. */ export const printResourceHelp = (io: CommandIO): void => { io.out( ` ${bold("Usage:")} xo-cli resource ${bold("Sub-commands:")} - list ${dim("List all unreserved resources")} - list reserved ${dim("List reserved resources")} - list all ${dim("List all resources (reserved + unreserved)")} - unreserve ${dim("Unreserve a specific UTXO")} - unreserve-all ${dim("Unreserve all reserved UTXOs")} `, ); }; /** * Formats a single UTXO for display, optionally including reservation info. */ function formatResource( resource: 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}`, ); // Format the value const value = dim(`${resource.valueSatoshis} sats`); // Format the output const output = resource.outputIdentifier ? dim(resource.outputIdentifier) : ""; // Format the height const height = dim(`(height ${resource.minedAtHeight})`); // If the resource is reserved, format the reservation info if (showReserved && resource.reservedBy) { const inv = dim(`reserved for ${resource.reservedBy}`); return `${template} ${outpoint} ${value} ${output} ${height} ${inv}`; } // Otherwise, format the resource without reservation info return `${template} ${outpoint} ${value} ${output} ${height}`; } /** * Handles the resource command. * Throws CommandError on failure, returns result data on success. * @param deps - The command dependencies. * @param args - Positional args after the command name, e.g. ["list"]. * @param options - Parsed option flags. */ export const handleResourceCommand = async ( deps: CommandDependencies, args: string[], _options: Record, ): Promise<{ count?: number }> => { const subCommand = args[0]; deps.io.verbose(`Resource sub-command: ${subCommand}`); // If no sub-command is provided, print the help message and throw an error if (!subCommand) { deps.io.verbose("No sub-command provided"); printResourceHelp(deps.io); throw new CommandError( "resource.subcommand.missing", "No sub-command provided", ); } // Handle the sub-command switch (subCommand) { case "list": { // Get the qualifier from the arguments - This could be "reserved", "all", or omitted (which defaults to "unreserved") const qualifier = args[1]; // List all the unspent outputs data const allResources = await deps.app.engine.listUnspentOutputsData(); let filtered; // If the qualifier is "reserved", return only the reserved resources if (qualifier === "reserved") { filtered = allResources.filter((r) => r.reservedBy); } // If the qualifier is "all", return all the resources else if (qualifier === "all") { filtered = allResources; } // If the qualifier is not "reserved" or "all", return only the unreserved resources else { filtered = allResources.filter((r) => !r.reservedBy); } // If no resources are found, print a message and return 0 if (filtered.length === 0) { deps.io.out(dim("No resources found.")); 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 = resourcesWithTemplateInformation.map((r) => formatResource(r, showReserved), ); // Display the resources to the user deps.io.out(formattedResources.join("\n")); // Display the total satoshis deps.io.out( `Total satoshis: ${filtered.reduce((acc, r) => acc + r.valueSatoshis, 0)}`, ); // Display the total resources deps.io.out(`Total resources: ${filtered.length}`); return { count: filtered.length }; } case "unreserve": { // Get the outpoint from the arguments const outpointArg = args[1]; // If no outpoint is provided, print a message and throw an error if (!outpointArg) { deps.io.err("Please provide a UTXO in : format."); printResourceHelp(deps.io); throw new CommandError( "resource.unreserve.outpoint_missing", "Please provide a UTXO in : format.", ); } // Get the separator index const separatorIndex = outpointArg.lastIndexOf(":"); if (separatorIndex === -1) { // If the separator index is -1 (not found), print a message and throw an error deps.io.err( `Invalid format "${outpointArg}". Expected :.`, ); throw new CommandError( "resource.unreserve.outpoint_invalid", `Invalid format "${outpointArg}". Expected :.`, ); } // Get the tx hash and vout const txHash = outpointArg.substring(0, separatorIndex); const vout = parseInt(outpointArg.substring(separatorIndex + 1), 10); // If the tx hash or vout is not a string or isNaN, print a message and throw an error if (!txHash || isNaN(vout)) { deps.io.err( `Invalid format "${outpointArg}". Expected :.`, ); throw new CommandError( "resource.unreserve.outpoint_invalid", `Invalid format "${outpointArg}". Expected :.`, ); } // Gather all of our resources const allResources = await deps.app.engine.listUnspentOutputsData(); // Find the target resource const target = allResources.find( (r) => r.outpointTransactionHash === txHash && r.outpointIndex === vout, ); // If the target resource is not found, print a message and throw an error if (!target) { deps.io.err(`UTXO not found: ${txHash}:${vout}`); throw new CommandError( "resource.unreserve.utxo_missing", `UTXO not found: ${txHash}:${vout}`, ); } // If the target resource is not reserved, print a message and return if (!target.reservedBy) { deps.io.out(dim("UTXO is not reserved. Nothing to do.")); return {}; } // Unreserve the resources await deps.app.engine.archiveInvitation(target.reservedBy); // TODO: This should ideally not archive the invitation, but instead just release the resource so the user can add a different resource to the same invitation. // Maybe make this method only available on invitations that haven't been synced yet? That way the user has some time to undo it without scrapping the whole invitation, // but is still safe from the other participants from spending the resource? deps.io.out( `Archived invitation ${bold(target.reservedBy)} and released its resources`, ); // TODO: What do I want to return here? return {}; } case "unreserve-all": { // Unreserve all the resources const count = await deps.app.unreserveAllResources(); // If no resources are reserved, print a message and return if (count === 0) { deps.io.out(dim("No reserved resources to unreserve.")); } // If some resources were unreserved, print a message and return the count else { deps.io.out(`Unreserved ${bold(String(count))} resource(s).`); } return { count }; } default: { deps.io.verbose(`Unknown resource sub-command: ${subCommand}`); printResourceHelp(deps.io); throw new CommandError( "resource.subcommand.unknown", `Unknown resource sub-command: ${subCommand}`, ); } } };