Breaking Change: Update to latest XO-Engine #2
2
package-lock.json
generated
2
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
];
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 <path>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 <invitation-id> ${dim("Broadcast an invitation")}
|
||||
- requirements <invitation-id> ${dim("Show requirements for an invitation")}
|
||||
- import <invitation-file> ${dim("Import an invitation from a file")}
|
||||
- export <invitation-id> [output-file] ${dim("Export an invitation to stdout or a file")}
|
||||
- inspect <invitation-id | invitation-file> ${dim("Inspect an invitation")}
|
||||
- list ${dim("List all invitations")}
|
||||
|
||||
${bold("Export options:")}
|
||||
-o --output <output-filename> ${dim("Output filename for the exported invitation")}
|
||||
|
||||
${bold("Create / Append options:")}
|
||||
-var-<name> <value> ${dim("Set a variable (e.g. -var-requested-satoshis 1000)")}
|
||||
--add-input <txhash:vout> ${dim("Add UTXO input(s), comma-separated (e.g. abc123:0,def456:1)")}
|
||||
@@ -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<string, string>,
|
||||
): 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 <template-file>`,
|
||||
);
|
||||
}
|
||||
|
||||
// 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(
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
|
||||
|
||||
@@ -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 <category> <identifier> ${dim("List all options of the field type in a template")}
|
||||
- inspect <category> <identifier> <field> ${dim("Inspect a field in a template")}
|
||||
- set-default <template-file> <output-identifier> <role-identifier> ${dim("Set the default template")}
|
||||
- export <template-identifier> [output-file] ${dim("Export a template to stdout or a file")}
|
||||
|
||||
${bold("Options:")}
|
||||
-o --output <output-filename> ${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<string, string>,
|
||||
): 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<string, string>,
|
||||
): Promise<{ templateFile?: string; count?: number }> => {
|
||||
options: Record<string, string>,
|
||||
): 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];
|
||||
|
||||
@@ -81,22 +81,7 @@ export class AppService extends EventEmitter<AppEventMap> {
|
||||
|
||||
// TODO: We *technically* dont want this here, but we also need some initial templates for the wallet, so im doing it here
|
||||
// Import the default P2PKH template
|
||||
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<AppEventMap> {
|
||||
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<AppEventMap> {
|
||||
// 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);
|
||||
|
||||
@@ -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<string, InvitationContext>();
|
||||
|
||||
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<string, ScriptHashData>();
|
||||
const templateIdentifiers = new Set<string>();
|
||||
|
||||
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<UtxoContext> {
|
||||
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<ScriptHashData | undefined> {
|
||||
return (this.engine as unknown as { state: State }).state.getScriptHashData(scriptHash);
|
||||
}
|
||||
|
||||
private getOutpointKey(txid: string, index: number): string {
|
||||
return `${txid}:${index}`;
|
||||
}
|
||||
|
||||
@@ -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<InvitationEventMap> {
|
||||
* Start the invitation - Connect sync server and download latest invitation data.
|
||||
*/
|
||||
async start(): Promise<void> {
|
||||
// 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([
|
||||
|
||||
@@ -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<void> {
|
||||
// Encode the extended json object
|
||||
const encodedValue = encodeExtendedJson(value);
|
||||
async set(key: string, value: XOInvitation): Promise<void> {
|
||||
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<void> {
|
||||
@@ -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<void> {
|
||||
async set(key: string, value: XOInvitation): Promise<void> {
|
||||
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<void> {
|
||||
|
||||
@@ -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<string, Set<string>>();
|
||||
for (const utxo of allUtxos) {
|
||||
const existing = ownedOutputsByTemplate.get(utxo.templateIdentifier) ?? new Set<string>();
|
||||
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<string>();
|
||||
existing.add(scriptRow.outputIdentifier);
|
||||
ownedOutputsByTemplate.set(scriptRow.templateIdentifier, existing);
|
||||
}
|
||||
|
||||
const loadedTemplates = await Promise.all(
|
||||
|
||||
@@ -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<SyncServerEventMap> {
|
||||
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<SyncServerEventMap> {
|
||||
// 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<SyncServerEventMap> {
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
58
src/utils/utxo-metadata.ts
Normal file
58
src/utils/utxo-metadata.ts
Normal file
@@ -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<Map<string, ScriptHashData>> => {
|
||||
const scriptHashes = await engine.listScriptHashes();
|
||||
const scriptHashDataByScriptHash = new Map<string, ScriptHashData>();
|
||||
|
||||
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<string, ScriptHashData>,
|
||||
): 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<string, ScriptHashData>,
|
||||
): UnspentOutputWithMetadata => ({
|
||||
...utxo,
|
||||
...getUnspentOutputMetadata(utxo, scriptHashDataByScriptHash),
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user