269 lines
8.9 KiB
TypeScript
269 lines
8.9 KiB
TypeScript
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 <sub-command>
|
|
|
|
${bold("Sub-commands:")}
|
|
- list ${dim("List all unreserved resources")}
|
|
- list reserved ${dim("List reserved resources")}
|
|
- list all ${dim("List all resources (reserved + unreserved)")}
|
|
- unreserve <txhash:vout> ${dim("Unreserve a specific UTXO")}
|
|
- unreserve-all ${dim("Unreserve all reserved UTXOs")}
|
|
`,
|
|
);
|
|
};
|
|
|
|
/**
|
|
* Formats a single UTXO for display, optionally including reservation info.
|
|
*/
|
|
function formatResource(
|
|
resource: 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<string, string>,
|
|
): 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 <txhash>:<vout> format.");
|
|
printResourceHelp(deps.io);
|
|
throw new CommandError(
|
|
"resource.unreserve.outpoint_missing",
|
|
"Please provide a UTXO in <txhash>:<vout> 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 <txhash>:<vout>.`,
|
|
);
|
|
throw new CommandError(
|
|
"resource.unreserve.outpoint_invalid",
|
|
`Invalid format "${outpointArg}". Expected <txhash>:<vout>.`,
|
|
);
|
|
}
|
|
|
|
// 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 <txhash>:<vout>.`,
|
|
);
|
|
throw new CommandError(
|
|
"resource.unreserve.outpoint_invalid",
|
|
`Invalid format "${outpointArg}". Expected <txhash>:<vout>.`,
|
|
);
|
|
}
|
|
|
|
// 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}`,
|
|
);
|
|
}
|
|
}
|
|
};
|