2 Commits

21 changed files with 142 additions and 491 deletions

2
package-lock.json generated
View File

@@ -51,7 +51,7 @@
"@electrum-cash/network": "^4.2.2", "@electrum-cash/network": "^4.2.2",
"@electrum-cash/protocol": "^2.3.1", "@electrum-cash/protocol": "^2.3.1",
"@electrum-cash/servers": "^3.1.0", "@electrum-cash/servers": "^3.1.0",
"@xo-cash/crypto": "^0.0.1", "@xo-cash/crypto": "file:../crypto",
"@xo-cash/primitives": "0.0.1", "@xo-cash/primitives": "0.0.1",
"@xo-cash/state": "0.0.2", "@xo-cash/state": "0.0.2",
"@xo-cash/templates": "0.0.1", "@xo-cash/templates": "0.0.1",

View File

@@ -9,7 +9,7 @@ mkdir xo-terminal && cd xo-terminal
# ----- Start Engine Setup ----- # ----- Start Engine Setup -----
# Clone the Engine Repo (Note, this uses harvey's fork of the engine repo to access the cli-test branch) # Clone the Engine Repo (Note, this uses harvey's fork of the engine repo to access the cli-test branch)
git clone https://gitlab.com/Harvmaster/engine.git git clone git@gitlab.com:Harvmaster/engine.git
# Move into teh engine directory # Move into teh engine directory
cd engine cd engine
@@ -29,7 +29,7 @@ cd ..
# ----- Start State Setup ----- # ----- Start State Setup -----
# Clone the State Repo # Clone the State Repo
git clone https://gitlab.com/Harvmaster/state.git git clone git@gitlab.com:Harvmaster/state.git
# Move into the state directory # Move into the state directory
cd state cd state
@@ -46,26 +46,9 @@ npm run build
# Move back to the top level directory # Move back to the top level directory
cd .. cd ..
# ----- Start Template Setup ----
# Clone the Template repo
git clone https://gitlab.com/Harvmaster/templates.git
# Move into themplates directory
cd templates
# Install deps
npm ci
#build the templates
npm run build
# ----- End Templates Setup ----
# Move back to the top level directory
cd ..
# ----- Start CLI Setup ----- # ----- Start CLI Setup -----
# Clone the CLI Repo # Clone the CLI Repo
git clone https://git.harvmaster.com/Harvmaster/xo-cli.git git clone git@git.harvmaster.com:Harvmaster/xo-cli.git
# Move into the cli directory # Move into the cli directory
cd xo-cli cd xo-cli

View File

@@ -34,8 +34,8 @@ import { homedir } from "node:os";
* *
* IMPORTANT: Keep this in sync with actual switch statements in command handlers: * IMPORTANT: Keep this in sync with actual switch statements in command handlers:
* - mnemonic.ts: create, import, list, expose * - mnemonic.ts: create, import, list, expose
* - template.ts: import, list, inspect, export, set-default * - template.ts: import, list, inspect, set-default
* - invitation.ts: create, append, sign, broadcast, requirements, import, export, inspect, list * - invitation.ts: create, append, sign, broadcast, requirements, import, inspect, list
* - resource.ts: list, unreserve, unreserve-all * - resource.ts: list, unreserve, unreserve-all
* - settings.ts: show, get, set * - settings.ts: show, get, set
*/ */
@@ -43,7 +43,7 @@ import { homedir } from "node:os";
/** Subcommands for the mnemonic command */ /** Subcommands for the mnemonic command */
const MNEMONIC_SUBS = ["create", "import", "list", "expose"]; const MNEMONIC_SUBS = ["create", "import", "list", "expose"];
/** Subcommands for the template command */ /** Subcommands for the template command */
const TEMPLATE_SUBS = ["import", "list", "inspect", "export", "set-default"]; const TEMPLATE_SUBS = ["import", "list", "inspect", "set-default"];
/** Subcommands for the invitation command */ /** Subcommands for the invitation command */
const INVITATION_SUBS = [ const INVITATION_SUBS = [
"create", "create",
@@ -52,7 +52,6 @@ const INVITATION_SUBS = [
"broadcast", "broadcast",
"requirements", "requirements",
"import", "import",
"export",
"inspect", "inspect",
"list", "list",
]; ];

View File

@@ -8,7 +8,11 @@
* and instead constructs the engine directly with an in-memory blockchain provider. * and instead constructs the engine directly with an in-memory blockchain provider.
*/ */
import { BlockchainMonitor, Engine } from "@xo-cash/engine"; import {
BlockchainMonitor,
Engine,
InMemoryBlockchainProvider,
} from "@xo-cash/engine";
import { createStorageAdapter, State, StorageType } from "@xo-cash/state"; import { createStorageAdapter, State, StorageType } from "@xo-cash/state";
import { convertMnemonicToSeedBytes } from "@xo-cash/crypto"; import { convertMnemonicToSeedBytes } from "@xo-cash/crypto";
import { binToHex, hash256 } from "@bitauth/libauth"; import { binToHex, hash256 } from "@bitauth/libauth";
@@ -63,21 +67,18 @@ export async function createOfflineEngine(
// Create the state instance // Create the state instance
const state = new State(storageAdapter); const state = new State(storageAdapter);
// Create a minimal blockchain monitor (no electrum initialization) // Use in-memory blockchain provider (no network connections)
const blockchainMonitor = new BlockchainMonitor(state); const blockchainProvider = new InMemoryBlockchainProvider();
await blockchainProvider.initialize({
applicationIdentifier: "xo-cli-completions",
electrumOptions: {},
});
// Engine constructor is private; bypass for offline read-only completions. // Create a minimal blockchain monitor
type EngineConstructor = new ( const blockchainMonitor = new BlockchainMonitor(state, blockchainProvider);
mnemonic: string,
state: State,
blockchainMonitor: BlockchainMonitor,
) => Engine;
const engine = new (Engine as unknown as EngineConstructor)( // Construct engine directly without state sync
seed, const engine = new Engine(seed, state, blockchainMonitor, blockchainProvider);
state,
blockchainMonitor,
);
return engine; return engine;
} }

View File

@@ -161,7 +161,7 @@ _{{FUNC_NAME}}_completions() {
fi fi
fi fi
;; ;;
append|sign|broadcast|requirements|export|inspect) append|sign|broadcast|requirements|inspect)
# These subcommands expect an invitation identifier as first arg. # These subcommands expect an invitation identifier as first arg.
local pos=$((cword - subcmd_idx)) local pos=$((cword - subcmd_idx))
if [[ $pos -eq 1 ]]; then if [[ $pos -eq 1 ]]; then

View File

@@ -70,7 +70,6 @@ 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 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 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 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)' 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> # invitation import <path>

View File

@@ -145,7 +145,7 @@ _{{FUNC_NAME}}_completions() {
fi fi
fi fi
;; ;;
append|sign|broadcast|requirements|export|inspect) append|sign|broadcast|requirements|inspect)
# These subcommands take invitation ID as first argument. # These subcommands take invitation ID as first argument.
local pos=$((CURRENT - subcmd_idx)) local pos=$((CURRENT - subcmd_idx))
if [[ $pos -eq 1 ]]; then if [[ $pos -eq 1 ]]; then

View File

@@ -11,21 +11,13 @@ import {
resolveProvidedLockingBytecodeHex, resolveProvidedLockingBytecodeHex,
mapUnspentOutputsToSelectable, mapUnspentOutputsToSelectable,
autoSelectGreedyUtxos, autoSelectGreedyUtxos,
hasMissingRequirements,
} from "../../utils/invitation-flow.js"; } from "../../utils/invitation-flow.js";
import { deserializeInvitation, serializeInvitation } from "@xo-cash/engine"; import { encodeExtendedJson } from "../../utils/ext-json.js";
import type { XOInvitation } from "@xo-cash/types";
import { resolveTemplate } from "../utils.js"; import { resolveTemplate } from "../utils.js";
const DEFAULT_FEE = 500n; const DEFAULT_FEE = 500n;
const DUST_THRESHOLD = 546n; 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. * 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. * A `null` return signals a fatal error that was already logged to stderr.
@@ -294,13 +286,9 @@ ${bold("Sub-commands:")}
- broadcast <invitation-id> ${dim("Broadcast an invitation")} - broadcast <invitation-id> ${dim("Broadcast an invitation")}
- requirements <invitation-id> ${dim("Show requirements for an invitation")} - requirements <invitation-id> ${dim("Show requirements for an invitation")}
- import <invitation-file> ${dim("Import an invitation from a file")} - 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")} - inspect <invitation-id | invitation-file> ${dim("Inspect an invitation")}
- list ${dim("List all invitations")} - list ${dim("List all invitations")}
${bold("Export options:")}
-o --output <output-filename> ${dim("Output filename for the exported invitation")}
${bold("Create / Append options:")} ${bold("Create / Append options:")}
-var-<name> <value> ${dim("Set a variable (e.g. -var-requested-satoshis 1000)")} -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)")} --add-input <txhash:vout> ${dim("Add UTXO input(s), comma-separated (e.g. abc123:0,def456:1)")}
@@ -323,7 +311,6 @@ export type InvitationCommandResult = {
invitationIdentifier?: string; invitationIdentifier?: string;
txHash?: string; txHash?: string;
count?: number; count?: number;
outputFile?: string;
templateName?: string; templateName?: string;
actionIdentifier?: string; actionIdentifier?: string;
status?: string; status?: string;
@@ -333,66 +320,6 @@ export type InvitationCommandResult = {
variables?: unknown[]; 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. * Handles the invitation command.
* Throws CommandError on failure, returns result data on success. * Throws CommandError on failure, returns result data on success.
@@ -484,7 +411,7 @@ export const handleInvitationCommand = async (
deps.io.verbose(`Invitation file path: ${invitationFilePath}`); deps.io.verbose(`Invitation file path: ${invitationFilePath}`);
writeFileSync( writeFileSync(
invitationFilePath, invitationFilePath,
formatInvitationForFile(invitationInstance.data), encodeExtendedJson(invitationInstance.data, 2),
); );
deps.io.out( deps.io.out(
`Invitation created: ${path.basename(invitationFilePath)} (${invitationInstance.data.invitationIdentifier})`, `Invitation created: ${path.basename(invitationFilePath)} (${invitationInstance.data.invitationIdentifier})`,
@@ -494,8 +421,11 @@ export const handleInvitationCommand = async (
const missingRequirements = const missingRequirements =
await invitationInstance.getMissingRequirements(); await invitationInstance.getMissingRequirements();
const hasMissing = const hasMissing =
hasMissingRequirements(missingRequirements.templateRequirements) || (missingRequirements.variables?.length ?? 0) > 0 ||
missingRequirements.inputsMissingSignatures.length > 0; (missingRequirements.inputs?.length ?? 0) > 0 ||
(missingRequirements.outputs?.length ?? 0) > 0 ||
(missingRequirements.roles !== undefined &&
Object.keys(missingRequirements.roles).length > 0);
// If there are missing requirements, print them out // If there are missing requirements, print them out
if (hasMissing) { if (hasMissing) {
@@ -602,10 +532,7 @@ export const handleInvitationCommand = async (
// Write the invitation to a file in the working directory // Write the invitation to a file in the working directory
// TODO: Support the -o flag to specify the output path // TODO: Support the -o flag to specify the output path
const invitationFilePath = `${deps.paths.workingDir}/inv-${invitation.data.invitationIdentifier}.json`; const invitationFilePath = `${deps.paths.workingDir}/inv-${invitation.data.invitationIdentifier}.json`;
writeFileSync( writeFileSync(invitationFilePath, encodeExtendedJson(invitation.data, 2));
invitationFilePath,
formatInvitationForFile(invitation.data),
);
deps.io.out( deps.io.out(
`Invitation updated: ${path.basename(invitationFilePath)} (${invitation.data.invitationIdentifier})`, `Invitation updated: ${path.basename(invitationFilePath)} (${invitation.data.invitationIdentifier})`,
); );
@@ -613,8 +540,11 @@ 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. // 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 missingRequirements = await invitation.getMissingRequirements();
const hasMissing = const hasMissing =
hasMissingRequirements(missingRequirements.templateRequirements) || (missingRequirements.variables?.length ?? 0) > 0 ||
missingRequirements.inputsMissingSignatures.length > 0; (missingRequirements.inputs?.length ?? 0) > 0 ||
(missingRequirements.outputs?.length ?? 0) > 0 ||
(missingRequirements.roles !== undefined &&
Object.keys(missingRequirements.roles).length > 0);
// If there are missing requirements, print them out // If there are missing requirements, print them out
if (hasMissing) { if (hasMissing) {
@@ -791,10 +721,11 @@ export const handleInvitationCommand = async (
} }
// Read the invitation file (XOInvitation format, can be passed to the engine directly) // Read the invitation file (XOInvitation format, can be passed to the engine directly)
const invitationFile = readFileSync(invitationFilePath, "utf8"); const invitationFile = await readFileSync(invitationFilePath, "utf8");
deps.io.verbose(`Invitation file: ${invitationFile}`); deps.io.verbose(`Invitation file: ${invitationFile}`);
const invitation = deserializeInvitation(invitationFile); // Parse the invitation file
const invitation = JSON.parse(invitationFile);
deps.io.verbose(`Invitation: ${formatObject(invitation)}`); deps.io.verbose(`Invitation: ${formatObject(invitation)}`);
// Create the invitation instance (NOTE: Not an engine compatible invitation. This is the wrapped format) // Create the invitation instance (NOTE: Not an engine compatible invitation. This is the wrapped format)
@@ -908,27 +839,19 @@ export const handleInvitationCommand = async (
} }
// Read the invitation file (XOInvitation format, can be passed to the engine directly) // Read the invitation file (XOInvitation format, can be passed to the engine directly)
const invitationFile = readFileSync(invitationFilePath, "utf8"); const invitationFile = await readFileSync(invitationFilePath, "utf8");
deps.io.verbose(`Invitation file: ${invitationFile}`); deps.io.verbose(`Invitation file: ${invitationFile}`);
const invitation = deserializeInvitation(invitationFile); // Parse the invitation file
const invitation = JSON.parse(invitationFile);
deps.io.verbose(`Invitation: ${formatObject(invitation)}`); deps.io.verbose(`Invitation: ${formatObject(invitation)}`);
const template = await deps.app.engine.getTemplate( // "Creates" the invitiation in the engine. This method acts as both creation or import depending on the data that is being passed in
invitation.templateIdentifier, const xoInvitation = await deps.app.engine.createInvitation(invitation);
); deps.io.verbose(`XOInvitation: ${formatObject(xoInvitation)}`);
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>`,
);
}
// Accept and track the invitation. Invitation.create calls acceptInvitation // Create the invitation instance (NOTE: Not an engine compatible invitation. This is the wrapped format)
// internally — do not call engine.createInvitation here, that creates a new const invitationInstance = await deps.app.createInvitation(xoInvitation);
// invitation and discards the imported commits.
const invitationInstance = await deps.app.createInvitation(invitation);
deps.io.verbose( deps.io.verbose(
`Invitation created: ${formatObject(invitationInstance.data)}`, `Invitation created: ${formatObject(invitationInstance.data)}`,
); );
@@ -939,10 +862,6 @@ export const handleInvitationCommand = async (
}; };
} }
case "export": {
return handleInvitationExportCommand(deps, args.slice(1), options);
}
case "list": { case "list": {
// List all the invitations // List all the invitations
const invitations = await Promise.all( const invitations = await Promise.all(

View File

@@ -2,14 +2,8 @@ import { hexToBin } from "@bitauth/libauth";
import { bold, dim } from "../utils.js"; import { bold, dim } from "../utils.js";
import type { CommandDependencies, CommandIO } from "./types.js"; import type { CommandDependencies, CommandIO } from "./types.js";
import type { UnspentOutputData } from "@xo-cash/state";
import { CommandError } 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. * Prints the help message for the resource command.
@@ -33,12 +27,9 @@ ${bold("Sub-commands:")}
* Formats a single UTXO for display, optionally including reservation info. * Formats a single UTXO for display, optionally including reservation info.
*/ */
function formatResource( function formatResource(
resource: UnspentOutputWithMetadata & { template?: XOTemplate }, resource: UnspentOutputData,
showReserved = false, showReserved = false,
): string { ): string {
// Format the template
const template = resource.template ? dim(`[${generateTemplateIdentifier(resource.template)}]`) : "";
// Format the outpoint // Format the outpoint
const outpoint = bold( const outpoint = bold(
`${resource.outpointTransactionHash}:${resource.outpointIndex}`, `${resource.outpointTransactionHash}:${resource.outpointIndex}`,
@@ -48,9 +39,7 @@ function formatResource(
const value = dim(`${resource.valueSatoshis} sats`); const value = dim(`${resource.valueSatoshis} sats`);
// Format the output // Format the output
const output = resource.outputIdentifier const output = dim(resource.outputIdentifier);
? dim(resource.outputIdentifier)
: "";
// Format the height // Format the height
const height = dim(`(height ${resource.minedAtHeight})`); const height = dim(`(height ${resource.minedAtHeight})`);
@@ -58,11 +47,11 @@ function formatResource(
// If the resource is reserved, format the reservation info // If the resource is reserved, format the reservation info
if (showReserved && resource.reservedBy) { if (showReserved && resource.reservedBy) {
const inv = dim(`reserved for ${resource.reservedBy}`); const inv = dim(`reserved for ${resource.reservedBy}`);
return `${template} ${outpoint} ${value} ${output} ${height} ${inv}`; return `${outpoint} ${value} ${output} ${height} ${inv}`;
} }
// Otherwise, format the resource without reservation info // Otherwise, format the resource without reservation info
return `${template} ${outpoint} ${value} ${output} ${height}`; return `${outpoint} ${value} ${output} ${height}`;
} }
/** /**
@@ -119,30 +108,9 @@ export const handleResourceCommand = async (
return { count: 0 }; 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 // Format the resources into a list of strings that we can display to the user
const showReserved = qualifier === "all" || qualifier === "reserved"; const showReserved = qualifier === "all" || qualifier === "reserved";
const formattedResources = resourcesWithTemplateInformation.map((r) => const formattedResources = filtered.map((r) =>
formatResource(r, showReserved), formatResource(r, showReserved),
); );

View File

@@ -1,4 +1,4 @@
import { existsSync, readFileSync, writeFileSync } from "fs"; import { existsSync, readFileSync } from "fs";
import path from "path"; import path from "path";
import { generateTemplateIdentifier } from "@xo-cash/engine"; import { generateTemplateIdentifier } from "@xo-cash/engine";
import type { XOTemplate } from "@xo-cash/types"; import type { XOTemplate } from "@xo-cash/types";
@@ -23,10 +23,6 @@ ${bold("Sub-commands:")}
- list <category> <identifier> ${dim("List all options of the field type in a template")} - 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")} - inspect <category> <identifier> <field> ${dim("Inspect a field in a template")}
- set-default <template-file> <output-identifier> <role-identifier> ${dim("Set the default 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")}
`, `,
); );
}; };
@@ -342,71 +338,6 @@ 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. * Handles the template command.
* Throws CommandError on failure, returns result data on success. * Throws CommandError on failure, returns result data on success.
@@ -417,8 +348,8 @@ export const handleTemplateExportCommand = async (
export const handleTemplateCommand = async ( export const handleTemplateCommand = async (
deps: CommandDependencies, deps: CommandDependencies,
args: string[], args: string[],
options: Record<string, string>, _options: Record<string, string>,
): Promise<{ templateFile?: string; count?: number; outputFile?: string }> => { ): Promise<{ templateFile?: string; count?: number }> => {
// Get the sub-command from the arguments // Get the sub-command from the arguments
const subCommand = args[0]; const subCommand = args[0];
@@ -483,10 +414,6 @@ export const handleTemplateCommand = async (
// Handle the template inspect command, We offload here as it has lots of arguments and is quite long // Handle the template inspect command, We offload here as it has lots of arguments and is quite long
return handleTemplateInspectCommand(deps, args.slice(1)); return handleTemplateInspectCommand(deps, args.slice(1));
} }
case "export": {
// Handle the template export command
return handleTemplateExportCommand(deps, args.slice(1), options);
}
case "set-default": { case "set-default": {
// Get the template file, output identifier, and role identifier from the arguments // Get the template file, output identifier, and role identifier from the arguments
const templateFile = args[1]; const templateFile = args[1];

View File

@@ -81,7 +81,22 @@ 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 // 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 // Import the default P2PKH template
await engine.importTemplate(p2pkhTemplate); 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}`,
// ),
// );
// Update all the unspents for every template, and subscribe to the locking bytecodes for changes // 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. // TODO: Remove the above lines that do the same thing. Minimising changes for BLISS.
@@ -89,8 +104,8 @@ export class AppService extends EventEmitter<AppEventMap> {
const templates = await engine.listImportedTemplates(); const templates = await engine.listImportedTemplates();
templates.forEach(async (template) => { templates.forEach(async (template) => {
engine.updateUnspentOutputsForTemplate(generateTemplateIdentifier(template)); // engine.updateUnspentOutputsForTemplate(generateTemplateIdentifier(template));
engine.subscribeToScriptHashForTemplate(generateTemplateIdentifier(template)); engine.subscribeToLockingBytecodesForTemplate(generateTemplateIdentifier(template));
}); });
}; };

View File

@@ -1,6 +1,6 @@
import { binToHex, hexToBin, sha256 } from "@bitauth/libauth"; import { binToHex, hexToBin, sha256 } from "@bitauth/libauth";
import { compileCashAssemblyString, type Engine } from "@xo-cash/engine"; import { compileCashAssemblyString, type Engine } from "@xo-cash/engine";
import type { ScriptHashData, State, UnspentOutputData } from "@xo-cash/state"; import type { ScriptHashData, UnspentOutputData } from "@xo-cash/state";
import type { import type {
XOInvitation, XOInvitation,
XOInvitationCommit, XOInvitationCommit,
@@ -146,10 +146,8 @@ export class HistoryService {
const contexts = new Map<string, InvitationContext>(); const contexts = new Map<string, InvitationContext>();
for (const invitation of this.invitations) { for (const invitation of this.invitations) {
const templateIdentifier = invitation.data.templateIdentifier; const template =
const template = templateIdentifier (await this.engine.getTemplate(invitation.data.templateIdentifier)) ?? null;
? (await this.engine.getTemplate(templateIdentifier)) ?? null
: null;
contexts.set(invitation.data.invitationIdentifier, { contexts.set(invitation.data.invitationIdentifier, {
invitation, invitation,
template, template,
@@ -166,19 +164,11 @@ export class HistoryService {
const scriptHashDataByScriptHash = new Map<string, ScriptHashData>(); const scriptHashDataByScriptHash = new Map<string, ScriptHashData>();
const templateIdentifiers = new Set<string>(); const templateIdentifiers = new Set<string>();
for (const invitation of this.invitations) { for (const utxo of allUtxos) {
if (invitation.data.templateIdentifier) { templateIdentifiers.add(utxo.templateIdentifier);
templateIdentifiers.add(invitation.data.templateIdentifier);
}
} }
for (const invitation of this.invitations) {
const uniqueScriptHashes = new Set(allUtxos.map((utxo) => utxo.scriptHash)); templateIdentifiers.add(invitation.data.templateIdentifier);
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) { for (const templateIdentifier of templateIdentifiers) {
@@ -196,10 +186,8 @@ export class HistoryService {
metadataIndex: WalletMetadataIndex, metadataIndex: WalletMetadataIndex,
): Promise<UtxoContext> { ): Promise<UtxoContext> {
const scriptHashData = metadataIndex.scriptHashDataByScriptHash.get(utxo.scriptHash); const scriptHashData = metadataIndex.scriptHashDataByScriptHash.get(utxo.scriptHash);
const templateIdentifier = scriptHashData?.templateIdentifier; const templateIdentifier = scriptHashData?.templateIdentifier ?? utxo.templateIdentifier;
const template = templateIdentifier const template = (await this.engine.getTemplate(templateIdentifier)) ?? null;
? (await this.engine.getTemplate(templateIdentifier)) ?? null
: null;
return { return {
utxo, utxo,
@@ -311,7 +299,7 @@ export class HistoryService {
if (!matchingContext) continue; if (!matchingContext) continue;
const lockingBytecode = this.getOutputLockingBytecodeHex(output) ?? matchingContext.scriptHashData?.lockingBytecode; const lockingBytecode = this.getOutputLockingBytecodeHex(output) ?? matchingContext.scriptHashData?.lockingBytecode;
const outputIdentifier = output.outputIdentifier ?? matchingContext.scriptHashData?.outputIdentifier; const outputIdentifier = output.outputIdentifier ?? matchingContext.scriptHashData?.outputIdentifier ?? matchingContext.utxo.outputIdentifier;
const role = const role =
output.roleIdentifier ?? output.roleIdentifier ??
this.getFirstEntityRole(entityRoles, commit.entityIdentifier) ?? this.getFirstEntityRole(entityRoles, commit.entityIdentifier) ??
@@ -392,21 +380,20 @@ export class HistoryService {
if (usedUtxoIds.has(this.getUtxoId(context.utxo))) return false; if (usedUtxoIds.has(this.getUtxoId(context.utxo))) return false;
if (scriptHash && context.utxo.scriptHash === scriptHash) return true; if (scriptHash && context.utxo.scriptHash === scriptHash) return true;
if (lockingBytecode && context.scriptHashData?.lockingBytecode === lockingBytecode) return true; if (lockingBytecode && context.scriptHashData?.lockingBytecode === lockingBytecode) return true;
if (output.outputIdentifier && context.utxo.outputIdentifier === output.outputIdentifier) return true;
if (output.outputIdentifier && context.scriptHashData?.outputIdentifier === output.outputIdentifier) return true;
return false; return false;
}); });
} }
private projectStandaloneUtxo(context: UtxoContext): WalletHistoryItem { private projectStandaloneUtxo(context: UtxoContext): WalletHistoryItem {
const output = this.projectUtxoOutput(context); const output = this.projectUtxoOutput(context);
const templateIdentifier = context.scriptHashData?.templateIdentifier; const templateIdentifier = context.scriptHashData?.templateIdentifier ?? context.utxo.templateIdentifier;
const role = output.role; const role = output.role;
return { return {
id: `utxo-${context.utxo.outpointTransactionHash}:${context.utxo.outpointIndex}`, id: `utxo-${context.utxo.outpointTransactionHash}:${context.utxo.outpointIndex}`,
source: "utxo", source: "utxo",
templateIdentifier: templateIdentifier ?? "", templateIdentifier,
template: context.template?.name ?? "UnknownTemplate", template: context.template?.name ?? "UnknownTemplate",
roles: role ? [role] : ["unknown"], roles: role ? [role] : ["unknown"],
description: output.description, description: output.description,
@@ -417,7 +404,7 @@ export class HistoryService {
} }
private projectUtxoOutput(context: UtxoContext): WalletHistoryOutput { private projectUtxoOutput(context: UtxoContext): WalletHistoryOutput {
const outputIdentifier = context.scriptHashData?.outputIdentifier; const outputIdentifier = context.scriptHashData?.outputIdentifier ?? context.utxo.outputIdentifier;
const role = context.scriptHashData?.roleIdentifier; const role = context.scriptHashData?.roleIdentifier;
return { return {
@@ -570,7 +557,7 @@ export class HistoryService {
variables: Record<string, XOInvitationVariableValue>, variables: Record<string, XOInvitationVariableValue>,
): string { ): string {
try { try {
return compileCashAssemblyString({ cashAssemblyText: description, variables, evaluationDecodeMode: 'utf8' }); return compileCashAssemblyString(description, variables);
} catch { } catch {
return this.interpolateSimpleCashAssemblyVariables(description, variables); return this.interpolateSimpleCashAssemblyVariables(description, variables);
} }
@@ -604,10 +591,6 @@ export class HistoryService {
: binToHex(output.lockingBytecode); : 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 { private getOutpointKey(txid: string, index: number): string {
return `${txid}:${index}`; return `${txid}:${index}`;
} }

View File

@@ -15,10 +15,6 @@ import type {
} from "@xo-cash/types"; } from "@xo-cash/types";
import type { UnspentOutputData } from "@xo-cash/state"; import type { UnspentOutputData } from "@xo-cash/state";
import { import {
bigIntToBinUint64LE,
bigIntToBinUintBE,
bigIntToBinUintLE,
bigIntToVmNumber,
binToHex, binToHex,
encodeTransaction, encodeTransaction,
generateTransaction, generateTransaction,
@@ -154,10 +150,6 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
* Start the invitation - Connect sync server and download latest invitation data. * Start the invitation - Connect sync server and download latest invitation data.
*/ */
async start(): Promise<void> { 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 { try {
// Connect to the sync server and get the invitation (in parallel) // Connect to the sync server and get the invitation (in parallel)
const [_, invitation] = await Promise.all([ const [_, invitation] = await Promise.all([
@@ -287,8 +279,7 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
private async computeStatusInternal(): Promise<string> { private async computeStatusInternal(): Promise<string> {
let missingReqs; let missingReqs;
try { try {
const missingRequirements = await this.engine.listMissingRequirements(this.data.invitationIdentifier); missingReqs = await this.engine.listMissingRequirements(this.data);
missingReqs = missingRequirements.templateRequirements;
} catch { } catch {
return "unknown"; return "unknown";
} }
@@ -403,7 +394,7 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
*/ */
async sign(): Promise<void> { async sign(): Promise<void> {
// Sign the invitation // Sign the invitation
const signedInvitation = await this.engine.signInvitation(this.data.invitationIdentifier); const signedInvitation = await this.engine.signInvitation(this.data);
// Publish the signed invitation to the sync server // Publish the signed invitation to the sync server
this.publishInvitation(signedInvitation); this.publishInvitation(signedInvitation);
@@ -422,7 +413,7 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
* @returns The transaction hash returned by the network after broadcast. * @returns The transaction hash returned by the network after broadcast.
*/ */
async broadcast(): Promise<string> { async broadcast(): Promise<string> {
const txHash = await this.engine.executeAction(this.data.invitationIdentifier, { const txHash = await this.engine.executeAction(this.data, {
broadcastTransaction: true, broadcastTransaction: true,
}); });
@@ -440,7 +431,7 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
*/ */
async append(data: AppendInvitationParameters): Promise<void> { async append(data: AppendInvitationParameters): Promise<void> {
// Append the commit to the invitation // Append the commit to the invitation
this.data = await this.engine.appendInvitation(this.data.invitationIdentifier, data); this.data = await this.engine.appendInvitation(this.data, data);
// Sync the invitation to the sync server // Sync the invitation to the sync server
await this.publishInvitation(this.data); await this.publishInvitation(this.data);
@@ -519,7 +510,7 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
const templates = await this.engine.listImportedTemplates(); const templates = await this.engine.listImportedTemplates();
// For each template, we need to create a 2d array of all the outputs // For each template, we need to create a 2d array of all the outputs
const outputs = templates.map(template => { const outputs = templates.flatMap(template => {
return Object.keys(template.outputs).map(output => { return Object.keys(template.outputs).map(output => {
const templateIdentifier = generateTemplateIdentifier(template); const templateIdentifier = generateTemplateIdentifier(template);
@@ -531,7 +522,7 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
}); });
// then, for each output, we need to get the spendable resources // then, for each output, we need to get the spendable resources
const spendableResources = await Promise.all(outputs.flat().map(output => { const spendableResources = await Promise.all(outputs.map(output => {
return this.engine.getSpendableResources(this.data, { return this.engine.getSpendableResources(this.data, {
templateIdentifier: output.templateIdentifier, templateIdentifier: output.templateIdentifier,
outputIdentifier: output.outputIdentifier, outputIdentifier: output.outputIdentifier,
@@ -555,7 +546,7 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
* Get the missing requirements for the invitation * Get the missing requirements for the invitation
*/ */
async getMissingRequirements() { async getMissingRequirements() {
return this.engine.listMissingRequirements(this.data.invitationIdentifier); return this.engine.listMissingRequirements(this.data);
} }
/** /**
@@ -617,41 +608,33 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
); );
} }
const valueSatoshisExpression = output.valueSatoshis; const valueSatoshisIdentifier = output.valueSatoshis;
if (!valueSatoshisExpression) { if (!valueSatoshisIdentifier) {
throw new Error( throw new Error(
`Value satoshis identifier not found: ${outputIdentifier} in template: ${this.data.templateIdentifier}`, `Value satoshis identifier not found: ${outputIdentifier} in template: ${this.data.templateIdentifier}`,
); );
} }
console.dir(this.data, { depth: null });
// Create a list of all the variables from the commits // Create a list of all the variables from the commits
const variables = this.data.commits.flatMap( const variables = this.data.commits.flatMap(
(c) => c.data?.variables ?? [], (c) => c.data?.variables ?? [],
); );
console.dir(variables, { depth: null });
// Create a dictionary of the variables // Create a dictionary of the variables
const formattedVariables = variables.reduce( const formattedVariables = variables.reduce(
(acc, v) => { (acc, v) => {
const { variableIdentifier, value } = v; acc[v.variableIdentifier ?? ""] = v.value;
console.log(typeof value);
acc[variableIdentifier ?? ""] = value;
return acc; return acc;
}, },
{} as Record<string, XOInvitationVariableValue>, {} as Record<string, XOInvitationVariableValue>,
); );
console.dir(formattedVariables, { depth: null });
// Compile the CashAssembly expression to get the value satoshis (It handles the variable replacement for us) // Compile the CashAssembly expression to get the value satoshis (It handles the variable replacement for us)
const valueSatoshis = compileCashAssemblyString( const valueSatoshis = await compileCashAssemblyString(
{ cashAssemblyText: String(valueSatoshisExpression), variables: formattedVariables, evaluationDecodeMode: 'bigint' }, String(valueSatoshisIdentifier),
formattedVariables,
); );
console.dir(valueSatoshis, { depth: null });
// Return the value satoshis as a bigint // Return the value satoshis as a bigint
// TODO: Check this of a vulnerability or crash - I assume there might be one if someone made the `valueSatoshis` a malicious expression // TODO: Check this of a vulnerability or crash - I assume there might be one if someone made the `valueSatoshis` a malicious expression
return BigInt(valueSatoshis); return BigInt(valueSatoshis);
@@ -705,13 +688,9 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
// Iterate through the outputs and sum the valueSatoshis // Iterate through the outputs and sum the valueSatoshis
for (const output of outputs) { for (const output of outputs) {
if (typeof output === "string") { if (typeof output === "string") {
const sats = await this.getSatsOut(output); totalSats += await this.getSatsOut(output);
console.log(`Sats for output: ${output} is ${sats}`);
totalSats += sats
} else { } else {
const sats = await this.getSatsOut(output.output); totalSats += await this.getSatsOut(output.output);
console.log(`Sats for output: ${output.output} is ${sats}`);
totalSats += sats;
} }
} }

View File

@@ -1,6 +1,5 @@
import Database from "better-sqlite3"; import Database from "better-sqlite3";
import { deserializeInvitation, serializeInvitation } from "@xo-cash/engine"; import { decodeExtendedJson, encodeExtendedJson } from "../utils/ext-json.js";
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. * 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.
@@ -57,8 +56,9 @@ export class Storage extends BaseStorage {
return fullKey.startsWith(prefix) ? fullKey.slice(prefix.length) : fullKey; return fullKey.startsWith(prefix) ? fullKey.slice(prefix.length) : fullKey;
} }
async set(key: string, value: XOInvitation): Promise<void> { async set(key: string, value: any): Promise<void> {
const encodedValue = serializeInvitation(value); // Encode the extended json object
const encodedValue = encodeExtendedJson(value);
// Insert or replace the value into the database with full key (including basePath) // Insert or replace the value into the database with full key (including basePath)
const fullKey = this.getFullKey(key); const fullKey = this.getFullKey(key);
@@ -93,10 +93,10 @@ export class Storage extends BaseStorage {
return !strippedKey.includes("."); return !strippedKey.includes(".");
}); });
// Deserialize invitations and strip basePath from keys // Decode the extended json objects and strip basePath from keys
return filteredRows.map((row) => ({ return filteredRows.map((row) => ({
key: this.stripBasePath(row.key), key: this.stripBasePath(row.key),
value: deserializeInvitation(row.value), value: decodeExtendedJson(row.value),
})); }));
} }
@@ -111,7 +111,7 @@ export class Storage extends BaseStorage {
if (!row) return null; if (!row) return null;
// Decode the extended json object // Decode the extended json object
return deserializeInvitation(row.value); return decodeExtendedJson(row.value);
} }
async remove(key: string): Promise<void> { async remove(key: string): Promise<void> {
@@ -174,9 +174,9 @@ export class InMemoryStorage extends BaseStorage {
return fullKey.startsWith(prefix) ? fullKey.slice(prefix.length) : fullKey; return fullKey.startsWith(prefix) ? fullKey.slice(prefix.length) : fullKey;
} }
async set(key: string, value: XOInvitation): Promise<void> { async set(key: string, value: any): Promise<void> {
const fullKey = this.getFullKey(key); const fullKey = this.getFullKey(key);
const encodedValue = serializeInvitation(value); const encodedValue = encodeExtendedJson(value);
this.store.set(fullKey, encodedValue); this.store.set(fullKey, encodedValue);
} }
@@ -199,7 +199,7 @@ export class InMemoryStorage extends BaseStorage {
return filteredRows.map((row) => ({ return filteredRows.map((row) => ({
key: this.stripBasePath(row.key), key: this.stripBasePath(row.key),
value: deserializeInvitation(row.value), value: decodeExtendedJson(row.value),
})); }));
} }
@@ -208,7 +208,7 @@ export class InMemoryStorage extends BaseStorage {
const encodedValue = this.store.get(fullKey); const encodedValue = this.store.get(fullKey);
if (encodedValue === undefined) return null; if (encodedValue === undefined) return null;
return deserializeInvitation(encodedValue); return decodeExtendedJson(encodedValue);
} }
async remove(key: string): Promise<void> { async remove(key: string): Promise<void> {

View File

@@ -24,7 +24,6 @@ import {
formatActionListItem, formatActionListItem,
getTemplateRoles, getTemplateRoles,
} from '../../utils/template-utils.js'; } from '../../utils/template-utils.js';
import { buildScriptHashDataMap } from '../../utils/utxo-metadata.js';
/** /**
* Template item with metadata. * Template item with metadata.
@@ -84,21 +83,12 @@ export function TemplateListScreen(): React.ReactElement {
const templateList = await appService.engine.listImportedTemplates(); const templateList = await appService.engine.listImportedTemplates();
const allUtxos = await appService.engine.listUnspentOutputsData(); const allUtxos = await appService.engine.listUnspentOutputsData();
const scriptHashDataByScriptHash =
await buildScriptHashDataMap(appService.engine);
const ownedOutputsByTemplate = new Map<string, Set<string>>(); const ownedOutputsByTemplate = new Map<string, Set<string>>();
for (const utxo of allUtxos) { for (const utxo of allUtxos) {
const scriptRow = scriptHashDataByScriptHash.get(utxo.scriptHash); const existing = ownedOutputsByTemplate.get(utxo.templateIdentifier) ?? new Set<string>();
if (scriptRow === undefined) { existing.add(utxo.outputIdentifier);
continue; ownedOutputsByTemplate.set(utxo.templateIdentifier, existing);
}
const existing =
ownedOutputsByTemplate.get(scriptRow.templateIdentifier) ??
new Set<string>();
existing.add(scriptRow.outputIdentifier);
ownedOutputsByTemplate.set(scriptRow.templateIdentifier, existing);
} }
const loadedTemplates = await Promise.all( const loadedTemplates = await Promise.all(

View File

@@ -221,7 +221,7 @@ export function InvitationScreen(): React.ReactElement {
let isCurrent = true; let isCurrent = true;
appService.engine.findOwnCommits(selectedInvitation.data.invitationIdentifier) appService.engine.getOwnCommits(selectedInvitation.data)
.then((ownCommits) => { .then((ownCommits) => {
if (!isCurrent) return; if (!isCurrent) return;
@@ -723,14 +723,10 @@ export function InvitationScreen(): React.ReactElement {
{outputTemplate?.name ?? output.outputIdentifier ?? `Output ${idx}`} {outputTemplate?.name ?? output.outputIdentifier ?? `Output ${idx}`}
{/* Output description */} {/* Output description */}
{outputTemplate?.description && ' - ' + compileCashAssemblyString({ {outputTemplate?.description && ' - ' + compileCashAssemblyString(outputTemplate?.description ?? '', variables.reduce((acc, variable) => {
cashAssemblyText: outputTemplate?.description, acc[variable.variableIdentifier] = variable.value as XOInvitationVariableValue;
variables: variables.reduce((acc, variable) => { return acc;
acc[variable.variableIdentifier] = variable.value as XOInvitationVariableValue; }, {} as Record<string, XOInvitationVariableValue>))}
return acc;
}, {} as Record<string, XOInvitationVariableValue>)
})}
{/* Output value */} {/* Output value */}
{outputSatoshis !== null && ` (${formatSatoshis(outputSatoshis)}${getFiatSuffix(outputSatoshis)})`} {outputSatoshis !== null && ` (${formatSatoshis(outputSatoshis)}${getFiatSuffix(outputSatoshis)})`}

View File

@@ -30,7 +30,7 @@ export const isInvitationRequirementsComplete = async (
invitation: Invitation, invitation: Invitation,
): Promise<boolean> => { ): Promise<boolean> => {
const missingRequirements = await invitation.getMissingRequirements(); const missingRequirements = await invitation.getMissingRequirements();
return !hasMissingRequirements(missingRequirements.templateRequirements); return !hasMissingRequirements(missingRequirements);
}; };
// TODO: Move to engine in templates.ts // TODO: Move to engine in templates.ts

View File

@@ -7,7 +7,7 @@
*/ */
import type { Invitation } from "../services/invitation.js"; import type { Invitation } from "../services/invitation.js";
import type { XOTemplate } from "@xo-cash/types"; import type { XOInvitationCommit, XOTemplate } from "@xo-cash/types";
/** /**
* Color names for invitation states. * Color names for invitation states.
@@ -249,9 +249,9 @@ export function formatInvitationId(id: string, maxLength: number = 16): string {
* @param invitation - The invitation to check * @param invitation - The invitation to check
* @returns Array of unique entity identifiers * @returns Array of unique entity identifiers
*/ */
export function getInvitationParticipants(invitation: Invitation): string[] { export function getInvitationParticipants(commits: Array<XOInvitationCommit>): string[] {
const participants = new Set<string>(); const participants = new Set<string>();
for (const commit of invitation.data.commits || []) { for (const commit of commits) {
if (commit.entityIdentifier) { if (commit.entityIdentifier) {
participants.add(commit.entityIdentifier); participants.add(commit.entityIdentifier);
} }
@@ -267,9 +267,14 @@ export function getInvitationParticipants(invitation: Invitation): string[] {
* @returns True if the user has made at least one commit * @returns True if the user has made at least one commit
*/ */
export function isUserParticipant( export function isUserParticipant(
invitation: Invitation, invitation: Invitation | Array<XOInvitationCommit>,
userEntityId: string | null, userEntityId: string | null,
): boolean { ): boolean {
if (!userEntityId) return false; if (!userEntityId) return false;
return getInvitationParticipants(invitation).includes(userEntityId);
if (Array.isArray(invitation)) {
return invitation.some(commit => commit.entityIdentifier === userEntityId);
}
return getInvitationParticipants(invitation.data.commits).includes(userEntityId);
} }

View File

@@ -1,7 +1,7 @@
import type { XOInvitation } from "@xo-cash/types"; import type { XOInvitation } from "@xo-cash/types";
import { EventEmitter } from "./event-emitter.js"; import { EventEmitter } from "./event-emitter.js";
import { SSESession, type SSEvent } from "./sse-client.js"; import { SSESession, type SSEvent } from "./sse-client.js";
import { deserializeInvitation, serializeInvitation } from "@xo-cash/engine"; import { decodeExtendedJson, encodeExtendedJson } from "./ext-json.js";
export type SyncServerEventMap = { export type SyncServerEventMap = {
connected: void; connected: void;
@@ -81,7 +81,9 @@ export class SyncServer extends EventEmitter<SyncServerEventMap> {
throw new Error(`Failed to get invitation: ${response.statusText}`); throw new Error(`Failed to get invitation: ${response.statusText}`);
} }
const invitation = deserializeInvitation(await response.text()); const invitation = decodeExtendedJson(await response.text()) as
| XOInvitation
| undefined;
return invitation; return invitation;
} }
@@ -94,7 +96,7 @@ export class SyncServer extends EventEmitter<SyncServerEventMap> {
// Send a POST request to the sync server // Send a POST request to the sync server
const response = await fetch(`${this.baseUrl}/invitations`, { const response = await fetch(`${this.baseUrl}/invitations`, {
method: "POST", method: "POST",
body: serializeInvitation(invitation), body: encodeExtendedJson(invitation),
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
}, },
@@ -107,7 +109,7 @@ export class SyncServer extends EventEmitter<SyncServerEventMap> {
// Read the returned JSON // Read the returned JSON
// TODO: This should use zod to verify the response // TODO: This should use zod to verify the response
const data = deserializeInvitation(await response.text()); const data = decodeExtendedJson(await response.text()) as XOInvitation;
return data; return data;
} }

View File

@@ -1,58 +0,0 @@
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),
});

View File

@@ -1,5 +1,5 @@
import { expect, test, describe, beforeEach, afterEach } from "vitest"; import { expect, test, describe, beforeEach, afterEach } from "vitest";
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import path from "node:path"; import path from "node:path";
@@ -98,13 +98,6 @@ const testCases: TestCase[] = [
shouldThrow: false, shouldThrow: false,
expectedData: {}, expectedData: {},
}, },
{
name: "export returns raw template json to stdout",
inputs: ["export", p2pkhTemplateIdentifier],
shouldThrow: false,
expectedData: {},
logs: [{ out: "\"name\":\"Wallet (P2PKH)\"" }],
},
// Error cases - subcommand // Error cases - subcommand
{ {
name: "throws when no subcommand provided", name: "throws when no subcommand provided",
@@ -131,18 +124,6 @@ const testCases: TestCase[] = [
shouldThrow: true, shouldThrow: true,
expectedEvent: "template.import.file_not_found", 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 // Error cases - list category
{ {
name: "throws when list category called without template identifier", name: "throws when list category called without template identifier",
@@ -282,42 +263,4 @@ describe("template command", () => {
process.chdir(originalCwd); 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);
}
});
}); });