Breaking Change: Update to latest XO-Engine #2

Open
Harvmaster wants to merge 22 commits from kiok-update into main
59 changed files with 4850 additions and 2230 deletions
Showing only changes of commit c7e1d69e2d - Show all commits

View File

@@ -3,6 +3,7 @@
## Installation ## Installation
### Full Installation ### Full Installation
```bash ```bash
# Create a new directory since we are going to be pulling in engine too # Create a new directory since we are going to be pulling in engine too
mkdir xo-terminal && cd xo-terminal mkdir xo-terminal && cd xo-terminal
@@ -131,27 +132,32 @@ These commands add `XO_CONFIG_DIR` to your shell config with a default of
generated assignment, to use a different wallet-state directory. generated assignment, to use a different wallet-state directory.
#### Install for bash #### Install for bash
```bash ```bash
npm run autocomplete:install:bash npm run autocomplete:install:bash
``` ```
#### Install for zsh #### Install for zsh
```bash ```bash
npm run autocomplete:install:zsh npm run autocomplete:install:zsh
``` ```
#### Install for fish #### Install for fish
```bash ```bash
npm run autocomplete:install:fish npm run autocomplete:install:fish
``` ```
### Run the CLI ### Run the CLI
```bash ```bash
# If globally installed (Not really usable if not globally installed) # If globally installed (Not really usable if not globally installed)
xo-cli xo-cli
``` ```
### Run the TUI ### Run the TUI
```bash ```bash
# If globally installed # If globally installed
xo-tui xo-tui

View File

@@ -11,8 +11,8 @@ There are two global commands after install:
Wallet state lives under **`${XO_CONFIG_DIR:-~/.config/xo-cli}`**, so you can run commands from any directory. Set `XO_CONFIG_DIR` to use a different wallet-state root. Wallet state lives under **`${XO_CONFIG_DIR:-~/.config/xo-cli}`**, so you can run commands from any directory. Set `XO_CONFIG_DIR` to use a different wallet-state root.
| Path | Purpose | | Path | Purpose |
| -------------------------- | ----------------------------------------------------------------------- | | --------------------------- | ----------------------------------------------------------------------- |
| `$XO_CONFIG_DIR/mnemonics/` | Mnemonic files (`mnemonic-*`) | | `$XO_CONFIG_DIR/mnemonics/` | Mnemonic files (`mnemonic-*`) |
| `$XO_CONFIG_DIR/data/` | Engine DB (`xo-wallet.db`) and invitation storage (`xo-invitations.db`) | | `$XO_CONFIG_DIR/data/` | Engine DB (`xo-wallet.db`) and invitation storage (`xo-invitations.db`) |
| `$XO_CONFIG_DIR/.wallet` | JSON settings (`default-mnemonic`, `currency`) | | `$XO_CONFIG_DIR/.wallet` | JSON settings (`default-mnemonic`, `currency`) |
@@ -41,13 +41,13 @@ npx tsx src/index.ts # TUI
### Environment variables ### Environment variables
| Variable | Default | | Variable | Default |
| ------------------------- | ----------------------------------------- | | ------------------------- | --------------------------------------- |
| `XO_CONFIG_DIR` | `~/.config/xo-cli` | | `XO_CONFIG_DIR` | `~/.config/xo-cli` |
| `SYNC_SERVER_URL` | `http://localhost:3000` | | `SYNC_SERVER_URL` | `http://localhost:3000` |
| `DB_PATH` | `$XO_CONFIG_DIR/data` | | `DB_PATH` | `$XO_CONFIG_DIR/data` |
| `DB_FILENAME` | `xo-wallet.db` | | `DB_FILENAME` | `xo-wallet.db` |
| `INVITATION_STORAGE_PATH` | `$XO_CONFIG_DIR/data/xo-invitations.db` | | `INVITATION_STORAGE_PATH` | `$XO_CONFIG_DIR/data/xo-invitations.db` |
Use an absolute path for a custom root. Setting `XO_CONFIG_DIR` does not copy state from the default directory. Use an absolute path for a custom root. Setting `XO_CONFIG_DIR` does not copy state from the default directory.
@@ -88,13 +88,13 @@ xo-cli resource list
## Global Options (`xo-cli`) ## Global Options (`xo-cli`)
| Flag | Description | | Flag | Description |
| ------------------------------ | --------------------------------------------------- | | ------------------------------ | ---------------------------------------------------- |
| `-m`, `--mnemonic-file <file>` | Mnemonic file (basename, cwd-relative, or absolute) | | `-m`, `--mnemonic-file <file>` | Mnemonic file (basename, cwd-relative, or absolute) |
| `--currency <code>` | Fiat display currency (e.g. `USD`, `AUD`) | | `--currency <code>` | Fiat display currency (e.g. `USD`, `AUD`) |
| `-o`, `--output <filename>` | Output filename (used by `mnemonic create`/`import`) | | `-o`, `--output <filename>` | Output filename (used by `mnemonic create`/`import`) |
| `-v`, `--verbose` | Verbose output | | `-v`, `--verbose` | Verbose output |
| `-h`, `--help` | Help | | `-h`, `--help` | Help |
Advanced: you can pass `--database-path`, `--database-filename`, and `--invitation-storage-path` to override the defaults under `$XO_CONFIG_DIR/data/` (see `src/cli/index.ts`). Advanced: you can pass `--database-path`, `--database-filename`, and `--invitation-storage-path` to override the defaults under `$XO_CONFIG_DIR/data/` (see `src/cli/index.ts`).

View File

@@ -19,12 +19,7 @@
* xo-cli completions fish --install * xo-cli completions fish --install
*/ */
import { import { existsSync, readFileSync, appendFileSync, mkdirSync } from "node:fs";
existsSync,
readFileSync,
appendFileSync,
mkdirSync,
} from "node:fs";
import { dirname, join } from "node:path"; import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import { homedir } from "node:os"; import { homedir } from "node:os";
@@ -216,13 +211,15 @@ const shellConfigs: Record<
> = { > = {
bash: { bash: {
configFile: join(homedir(), ".bashrc"), configFile: join(homedir(), ".bashrc"),
configDirCommand: 'export XO_CONFIG_DIR="${XO_CONFIG_DIR:-$HOME/.config/xo-cli}"', configDirCommand:
'export XO_CONFIG_DIR="${XO_CONFIG_DIR:-$HOME/.config/xo-cli}"',
configDirPattern: /^\s*(?:export\s+)?XO_CONFIG_DIR=/m, configDirPattern: /^\s*(?:export\s+)?XO_CONFIG_DIR=/m,
evalCommand: (binName) => `eval "$(${binName} completions bash)"`, evalCommand: (binName) => `eval "$(${binName} completions bash)"`,
}, },
zsh: { zsh: {
configFile: join(homedir(), ".zshrc"), configFile: join(homedir(), ".zshrc"),
configDirCommand: 'export XO_CONFIG_DIR="${XO_CONFIG_DIR:-$HOME/.config/xo-cli}"', configDirCommand:
'export XO_CONFIG_DIR="${XO_CONFIG_DIR:-$HOME/.config/xo-cli}"',
configDirPattern: /^\s*(?:export\s+)?XO_CONFIG_DIR=/m, configDirPattern: /^\s*(?:export\s+)?XO_CONFIG_DIR=/m,
evalCommand: (binName) => `eval "$(${binName} completions zsh)"`, evalCommand: (binName) => `eval "$(${binName} completions zsh)"`,
}, },

View File

@@ -23,7 +23,10 @@ const DUST_THRESHOLD = 546n;
/** /**
* Serializes an invitation to pretty-printed JSON for file export. * Serializes an invitation to pretty-printed JSON for file export.
*/ */
const formatInvitationForFile = (invitation: XOInvitation, indent = 2): string => const formatInvitationForFile = (
invitation: XOInvitation,
indent = 2,
): string =>
JSON.stringify(JSON.parse(serializeInvitation(invitation)), null, indent); JSON.stringify(JSON.parse(serializeInvitation(invitation)), null, indent);
/** /**
@@ -358,8 +361,7 @@ export const handleInvitationExportCommand = async (
} }
const invitation = deps.app.invitations.find( const invitation = deps.app.invitations.find(
(candidate) => (candidate) => candidate.data.invitationIdentifier === invitationIdentifier,
candidate.data.invitationIdentifier === invitationIdentifier,
); );
if (!invitation) { if (!invitation) {
@@ -499,7 +501,9 @@ export const handleInvitationCommand = async (
hasMissingRequirements(missingRequirements.templateRequirements) || hasMissingRequirements(missingRequirements.templateRequirements) ||
missingRequirements.inputsMissingSignatures.length > 0; missingRequirements.inputsMissingSignatures.length > 0;
deps.io.verbose(`Missing requirements: ${formatObject(missingRequirements)}`); deps.io.verbose(
`Missing requirements: ${formatObject(missingRequirements)}`,
);
deps.io.verbose(`Has missing requirements: ${hasMissing}`); deps.io.verbose(`Has missing requirements: ${hasMissing}`);
// If there are missing requirements, print them out // If there are missing requirements, print them out
@@ -693,7 +697,7 @@ export const handleInvitationCommand = async (
// Return the invitation identifier // Return the invitation identifier
return { invitationIdentifier }; return { invitationIdentifier };
} }
case "broadcast": { case "broadcast": {
// Get the invitation identifier from the arguments // Get the invitation identifier from the arguments
const invitationIdentifier = args[1]; const invitationIdentifier = args[1];
@@ -940,7 +944,7 @@ export const handleInvitationCommand = async (
deps.io.verbose( deps.io.verbose(
`Invitation created: ${formatObject(invitationInstance.data)}`, `Invitation created: ${formatObject(invitationInstance.data)}`,
); );
// Return the invitation identifier // Return the invitation identifier
return { return {
invitationIdentifier: invitationInstance.data.invitationIdentifier, invitationIdentifier: invitationInstance.data.invitationIdentifier,

View File

@@ -37,7 +37,9 @@ function formatResource(
showReserved = false, showReserved = false,
): string { ): string {
// Format the template // Format the template
const template = resource.template ? dim(`[${generateTemplateIdentifier(resource.template)}]`) : ""; const template = resource.template
? dim(`[${generateTemplateIdentifier(resource.template)}]`)
: "";
// Format the outpoint // Format the outpoint
const outpoint = bold( const outpoint = bold(
@@ -51,7 +53,7 @@ function formatResource(
const output = resource.outputIdentifier const output = resource.outputIdentifier
? dim(resource.outputIdentifier) ? dim(resource.outputIdentifier)
: ""; : "";
// Format the height // Format the height
const height = dim(`(height ${resource.minedAtHeight})`); const height = dim(`(height ${resource.minedAtHeight})`);
@@ -233,7 +235,7 @@ export const handleResourceCommand = async (
deps.io.out( deps.io.out(
`Unreserved ${bold(`${txHash}:${vout}`)} (was reserved for ${target.reservedBy})`, `Unreserved ${bold(`${txHash}:${vout}`)} (was reserved for ${target.reservedBy})`,
); );
// TODO: What do I want to return here? // TODO: What do I want to return here?
return {}; return {};
} }

View File

@@ -83,7 +83,7 @@ export const handleSettingsCommand = async (
const value = const value =
key === "currency" key === "currency"
? settings.getCurrency() ? settings.getCurrency()
: settings.getDefaultMnemonic() ?? ""; : (settings.getDefaultMnemonic() ?? "");
deps.io.out(value); deps.io.out(value);
return { key, value }; return { key, value };
} }

View File

@@ -4,7 +4,10 @@ import { generateTemplateIdentifier } from "@xo-cash/engine";
import type { XOTemplate } from "@xo-cash/types"; import type { XOTemplate } from "@xo-cash/types";
import { bold, dim, formatObject } from "../utils.js"; import { bold, dim, formatObject } from "../utils.js";
import { loadTemplateFromFile, TemplateLoadError } from "../../utils/load-template-from-file.js"; import {
loadTemplateFromFile,
TemplateLoadError,
} from "../../utils/load-template-from-file.js";
import { resolveTemplateReferences } from "../../utils/templates.js"; import { resolveTemplateReferences } from "../../utils/templates.js";
import type { CommandDependencies, CommandIO } from "./types.js"; import type { CommandDependencies, CommandIO } from "./types.js";
import { CommandError } from "./types.js"; import { CommandError } from "./types.js";

View File

@@ -181,16 +181,20 @@ async function main(): Promise<void> {
// Create an App instance // Create an App instance
io.verbose("Creating app instance..."); io.verbose("Creating app instance...");
const app = await AppService.create(mnemonic, { const app = await AppService.create(
syncServerUrl: options["syncServerUrl"] ?? "http://localhost:3000", mnemonic,
engineConfig: { {
databasePath: options["databasePath"] ?? paths.dataDir, syncServerUrl: options["syncServerUrl"] ?? "http://localhost:3000",
databaseFilename: options["databaseFilename"] ?? "xo-wallet.db", engineConfig: {
databasePath: options["databasePath"] ?? paths.dataDir,
databaseFilename: options["databaseFilename"] ?? "xo-wallet.db",
},
invitationStoragePath:
options["invitationStoragePath"] ??
join(paths.dataDir, "xo-invitations.db"),
}, },
invitationStoragePath: settings,
options["invitationStoragePath"] ?? );
join(paths.dataDir, "xo-invitations.db"),
}, settings);
io.verbose("App instance created"); io.verbose("App instance created");
// Start the app // Start the app

View File

@@ -100,8 +100,12 @@ 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(
engine.subscribeToScriptHashForTemplate(generateTemplateIdentifier(template)); generateTemplateIdentifier(template),
);
engine.subscribeToScriptHashForTemplate(
generateTemplateIdentifier(template),
);
}); });
}; };
@@ -127,7 +131,14 @@ export class AppService extends EventEmitter<AppEventMap> {
}); });
const rates = await RatesService.create(settings); const rates = await RatesService.create(settings);
return new AppService(engine, walletStorage, config, electrum, rates, settings); return new AppService(
engine,
walletStorage,
config,
electrum,
rates,
settings,
);
} }
constructor( constructor(
@@ -298,9 +309,9 @@ export class AppService extends EventEmitter<AppEventMap> {
async start(): Promise<void> { async start(): Promise<void> {
// Start rates in the background so BCH -> fiat conversions become reactive in the TUI. // Start rates in the background so BCH -> fiat conversions become reactive in the TUI.
this.rates.start().catch((err) => this.rates
console.error('Error starting rates service:', err), .start()
); .catch((err) => console.error("Error starting rates service:", err));
// Get the invitations db // Get the invitations db
const invitationsDb = this.storage.child("invitations"); const invitationsDb = this.storage.child("invitations");

View File

@@ -80,7 +80,7 @@ interface WalletMetadataIndex {
* I've tried to fundamental approaches so far: * I've tried to fundamental approaches so far:
* - UTXO first * - UTXO first
* - Invitation first * - Invitation first
* *
* The issue is that neither of these end up being simple or effective * The issue is that neither of these end up being simple or effective
* UTXO first makes tracking utxos across invitations extremely difficult. So if you receive a UTXO from an invitation and then spend it on another, you wont even see that old invitation. * UTXO first makes tracking utxos across invitations extremely difficult. So if you receive a UTXO from an invitation and then spend it on another, you wont even see that old invitation.
* Invitation first makes fitting UTXOs that dont have an invitation (say if someone sent directly to your address) extremely difficult. You end up having to run a UTXO first pass anyway, and then end up with conflicts around resolved roles. * Invitation first makes fitting UTXOs that dont have an invitation (say if someone sent directly to your address) extremely difficult. You end up having to run a UTXO first pass anyway, and then end up with conflicts around resolved roles.
@@ -95,7 +95,6 @@ export class HistoryService {
private invitations: Invitation[], private invitations: Invitation[],
) {} ) {}
/** /**
* I Might swap this over to invitation based history before the event to make it a bit more evident... Really not happy with the UTXO for demo purposes * I Might swap this over to invitation based history before the event to make it a bit more evident... Really not happy with the UTXO for demo purposes
* But for the actual usage, UTXO is easier to follow - just not good for demo * But for the actual usage, UTXO is easier to follow - just not good for demo
@@ -114,7 +113,10 @@ export class HistoryService {
for (const context of utxoContexts) { for (const context of utxoContexts) {
const invitationIdentifier = context.utxo.reservedBy; const invitationIdentifier = context.utxo.reservedBy;
if (invitationIdentifier && invitationContexts.has(invitationIdentifier)) { if (
invitationIdentifier &&
invitationContexts.has(invitationIdentifier)
) {
const group = reservedUtxosByInvitation.get(invitationIdentifier) ?? []; const group = reservedUtxosByInvitation.get(invitationIdentifier) ?? [];
group.push(context); group.push(context);
reservedUtxosByInvitation.set(invitationIdentifier, group); reservedUtxosByInvitation.set(invitationIdentifier, group);
@@ -141,13 +143,15 @@ export class HistoryService {
}); });
} }
private async buildInvitationContextIndex(): Promise<Map<string, InvitationContext>> { private async buildInvitationContextIndex(): Promise<
Map<string, InvitationContext>
> {
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 templateIdentifier = invitation.data.templateIdentifier;
const template = templateIdentifier const template = templateIdentifier
? (await this.engine.getTemplate(templateIdentifier)) ?? null ? ((await this.engine.getTemplate(templateIdentifier)) ?? null)
: null; : null;
contexts.set(invitation.data.invitationIdentifier, { contexts.set(invitation.data.invitationIdentifier, {
invitation, invitation,
@@ -181,9 +185,13 @@ export class HistoryService {
} }
for (const templateIdentifier of templateIdentifiers) { for (const templateIdentifier of templateIdentifiers) {
const scriptHashDataList = await this.engine.listScriptHashesForTemplate(templateIdentifier); const scriptHashDataList =
await this.engine.listScriptHashesForTemplate(templateIdentifier);
for (const scriptHashData of scriptHashDataList) { for (const scriptHashData of scriptHashDataList) {
scriptHashDataByScriptHash.set(scriptHashData.scriptHash, scriptHashData); scriptHashDataByScriptHash.set(
scriptHashData.scriptHash,
scriptHashData,
);
} }
} }
@@ -194,10 +202,12 @@ export class HistoryService {
utxo: UnspentOutputData, utxo: UnspentOutputData,
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;
const template = templateIdentifier const template = templateIdentifier
? (await this.engine.getTemplate(templateIdentifier)) ?? null ? ((await this.engine.getTemplate(templateIdentifier)) ?? null)
: null; : null;
return { return {
@@ -213,8 +223,15 @@ export class HistoryService {
): WalletHistoryItem { ): WalletHistoryItem {
const invitation = context.invitation.data; const invitation = context.invitation.data;
const entityRoles = this.deriveInvitationEntityRoles(context); const entityRoles = this.deriveInvitationEntityRoles(context);
const inputs = this.projectInvitationInputs(context, reservedContexts, entityRoles); const inputs = this.projectInvitationInputs(
const inputUtxoIds = this.listInvitationInputUtxoIds(context, reservedContexts); context,
reservedContexts,
entityRoles,
);
const inputUtxoIds = this.listInvitationInputUtxoIds(
context,
reservedContexts,
);
const outputs = this.projectInvitationOutputs( const outputs = this.projectInvitationOutputs(
context, context,
reservedContexts, reservedContexts,
@@ -263,7 +280,9 @@ export class HistoryService {
const outpointIndex = input.outpointIndex; const outpointIndex = input.outpointIndex;
if (txid === undefined || outpointIndex === undefined) continue; if (txid === undefined || outpointIndex === undefined) continue;
const utxoContext = reservedByOutpoint.get(this.getOutpointKey(txid, outpointIndex)); const utxoContext = reservedByOutpoint.get(
this.getOutpointKey(txid, outpointIndex),
);
// TODO: Remove this reservation-based filter once Engine/library cleanup releases stale invitation reservations internally. // TODO: Remove this reservation-based filter once Engine/library cleanup releases stale invitation reservations internally.
if (!utxoContext) continue; if (!utxoContext) continue;
@@ -309,15 +328,20 @@ export class HistoryService {
// UTXO-first: committed outputs only matter here if they resolve to a wallet UTXO currently reserved by this invitation. // UTXO-first: committed outputs only matter here if they resolve to a wallet UTXO currently reserved by this invitation.
if (!matchingContext) continue; if (!matchingContext) continue;
const lockingBytecode = this.getOutputLockingBytecodeHex(output) ?? matchingContext.scriptHashData?.lockingBytecode; const lockingBytecode =
const outputIdentifier = output.outputIdentifier ?? matchingContext.scriptHashData?.outputIdentifier; this.getOutputLockingBytecodeHex(output) ??
matchingContext.scriptHashData?.lockingBytecode;
const outputIdentifier =
output.outputIdentifier ??
matchingContext.scriptHashData?.outputIdentifier;
const role = const role =
output.roleIdentifier ?? output.roleIdentifier ??
this.getFirstEntityRole(entityRoles, commit.entityIdentifier) ?? this.getFirstEntityRole(entityRoles, commit.entityIdentifier) ??
matchingContext.scriptHashData?.roleIdentifier; matchingContext.scriptHashData?.roleIdentifier;
const valueSatoshis = output.valueSatoshis !== undefined const valueSatoshis =
? BigInt(output.valueSatoshis) output.valueSatoshis !== undefined
: BigInt(matchingContext.utxo.valueSatoshis); ? BigInt(output.valueSatoshis)
: BigInt(matchingContext.utxo.valueSatoshis);
usedUtxoIds.add(this.getUtxoId(matchingContext.utxo)); usedUtxoIds.add(this.getUtxoId(matchingContext.utxo));
@@ -369,8 +393,11 @@ export class HistoryService {
const outpointIndex = input.outpointIndex; const outpointIndex = input.outpointIndex;
if (txid === undefined || outpointIndex === undefined) continue; if (txid === undefined || outpointIndex === undefined) continue;
const utxoContext = reservedByOutpoint.get(this.getOutpointKey(txid, outpointIndex)); const utxoContext = reservedByOutpoint.get(
if (utxoContext) invitationInputUtxoIds.add(this.getUtxoId(utxoContext.utxo)); this.getOutpointKey(txid, outpointIndex),
);
if (utxoContext)
invitationInputUtxoIds.add(this.getUtxoId(utxoContext.utxo));
} }
} }
@@ -390,9 +417,17 @@ export class HistoryService {
return reservedContexts.find((context) => { return reservedContexts.find((context) => {
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.scriptHashData?.outputIdentifier === output.outputIdentifier) return true; if (
output.outputIdentifier &&
context.scriptHashData?.outputIdentifier === output.outputIdentifier
)
return true;
return false; return false;
}); });
} }
@@ -423,7 +458,11 @@ export class HistoryService {
id: this.getUtxoId(context.utxo), id: this.getUtxoId(context.utxo),
outputIdentifier, outputIdentifier,
role, role,
description: this.describeOutputFromTemplate(outputIdentifier, context.template, {}), description: this.describeOutputFromTemplate(
outputIdentifier,
context.template,
{},
),
valueSatoshis: BigInt(context.utxo.valueSatoshis), valueSatoshis: BigInt(context.utxo.valueSatoshis),
outpoint: { outpoint: {
txid: context.utxo.outpointTransactionHash, txid: context.utxo.outpointTransactionHash,
@@ -435,17 +474,22 @@ export class HistoryService {
}; };
} }
private deriveInvitationEntityRoles(context: InvitationContext): Map<string, string[]> { private deriveInvitationEntityRoles(
context: InvitationContext,
): Map<string, string[]> {
const invitation = context.invitation.data; const invitation = context.invitation.data;
const rolesByEntity = new Map<string, Set<string>>(); const rolesByEntity = new Map<string, Set<string>>();
const allEntities = new Set(invitation.commits.map((commit) => commit.entityIdentifier)); const allEntities = new Set(
invitation.commits.map((commit) => commit.entityIdentifier),
);
for (const entityIdentifier of allEntities) { for (const entityIdentifier of allEntities) {
rolesByEntity.set(entityIdentifier, new Set()); rolesByEntity.set(entityIdentifier, new Set());
} }
for (const commit of invitation.commits) { for (const commit of invitation.commits) {
const roles = rolesByEntity.get(commit.entityIdentifier) ?? new Set<string>(); const roles =
rolesByEntity.get(commit.entityIdentifier) ?? new Set<string>();
for (const input of commit.data.inputs ?? []) { for (const input of commit.data.inputs ?? []) {
if (input.roleIdentifier) roles.add(input.roleIdentifier); if (input.roleIdentifier) roles.add(input.roleIdentifier);
} }
@@ -459,9 +503,10 @@ export class HistoryService {
} }
const action = context.template?.actions?.[invitation.actionIdentifier]; const action = context.template?.actions?.[invitation.actionIdentifier];
const participantRoles = action?.requirements?.participants const participantRoles =
?.map((participant) => participant.role) action?.requirements?.participants
.filter((role): role is string => typeof role === "string") ?? []; ?.map((participant) => participant.role)
.filter((role): role is string => typeof role === "string") ?? [];
const explicitlyFilledRoles = new Set<string>(); const explicitlyFilledRoles = new Set<string>();
for (const roles of rolesByEntity.values()) { for (const roles of rolesByEntity.values()) {
for (const role of roles) explicitlyFilledRoles.add(role); for (const role of roles) explicitlyFilledRoles.add(role);
@@ -473,7 +518,10 @@ export class HistoryService {
.filter(([, roles]) => roles.size === 0) .filter(([, roles]) => roles.size === 0)
.map(([entityIdentifier]) => entityIdentifier); .map(([entityIdentifier]) => entityIdentifier);
if (unfilledParticipantRoles.length === 1 && entitiesWithoutRoles.length >= 1) { if (
unfilledParticipantRoles.length === 1 &&
entitiesWithoutRoles.length >= 1
) {
const inferredRole = unfilledParticipantRoles[0]; const inferredRole = unfilledParticipantRoles[0];
if (inferredRole !== undefined) { if (inferredRole !== undefined) {
for (const entityIdentifier of entitiesWithoutRoles) { for (const entityIdentifier of entitiesWithoutRoles) {
@@ -517,12 +565,21 @@ export class HistoryService {
inputs: WalletHistoryInput[], inputs: WalletHistoryInput[],
outputs: WalletHistoryOutput[], outputs: WalletHistoryOutput[],
): bigint { ): bigint {
const inputTotal = inputs.reduce((total, input) => total + (input.valueSatoshis ?? 0n), 0n); const inputTotal = inputs.reduce(
const outputTotal = outputs.reduce((total, output) => total + (output.valueSatoshis ?? 0n), 0n); (total, input) => total + (input.valueSatoshis ?? 0n),
0n,
);
const outputTotal = outputs.reduce(
(total, output) => total + (output.valueSatoshis ?? 0n),
0n,
);
return inputTotal + outputTotal; return inputTotal + outputTotal;
} }
private describeInvitation(context: InvitationContext, role?: string): string { private describeInvitation(
context: InvitationContext,
role?: string,
): string {
const invitation = context.invitation.data; const invitation = context.invitation.data;
const template = context.template; const template = context.template;
if (!template) return invitation.actionIdentifier; if (!template) return invitation.actionIdentifier;
@@ -544,14 +601,27 @@ export class HistoryService {
return this.compileDescription(descriptionTemplate, context.variables); return this.compileDescription(descriptionTemplate, context.variables);
} }
private describeInput(inputIdentifier: string | undefined, context: InvitationContext): string { private describeInput(
inputIdentifier: string | undefined,
context: InvitationContext,
): string {
if (!inputIdentifier) return "Input"; if (!inputIdentifier) return "Input";
const input = context.template?.inputs?.[inputIdentifier]; const input = context.template?.inputs?.[inputIdentifier];
return this.compileDescription(input?.description ?? input?.name ?? inputIdentifier, context.variables); return this.compileDescription(
input?.description ?? input?.name ?? inputIdentifier,
context.variables,
);
} }
private describeOutput(outputIdentifier: string | undefined, context: InvitationContext): string { private describeOutput(
return this.describeOutputFromTemplate(outputIdentifier, context.template, context.variables); outputIdentifier: string | undefined,
context: InvitationContext,
): string {
return this.describeOutputFromTemplate(
outputIdentifier,
context.template,
context.variables,
);
} }
private describeOutputFromTemplate( private describeOutputFromTemplate(
@@ -561,7 +631,10 @@ export class HistoryService {
): string { ): string {
if (!outputIdentifier) return "Output"; if (!outputIdentifier) return "Output";
const output = template?.outputs?.[outputIdentifier]; const output = template?.outputs?.[outputIdentifier];
return this.compileDescription(output?.description ?? output?.name ?? outputIdentifier, variables); return this.compileDescription(
output?.description ?? output?.name ?? outputIdentifier,
variables,
);
} }
private compileDescription( private compileDescription(
@@ -569,16 +642,25 @@ export class HistoryService {
variables: Record<string, XOInvitationVariableValue>, variables: Record<string, XOInvitationVariableValue>,
): string { ): string {
try { try {
return compileCashAssemblyString({ cashAssemblyText: description, variables, evaluationDecodeMode: 'utf8' }); return compileCashAssemblyString({
cashAssemblyText: description,
variables,
evaluationDecodeMode: "utf8",
});
} catch { } catch {
return this.interpolateSimpleCashAssemblyVariables(description, variables); return this.interpolateSimpleCashAssemblyVariables(
description,
variables,
);
} }
} }
private extractInvitationVariables( private extractInvitationVariables(
invitation: XOInvitation, invitation: XOInvitation,
): Record<string, XOInvitationVariableValue> { ): Record<string, XOInvitationVariableValue> {
const committedVariables = invitation.commits.flatMap((c) => c.data.variables ?? []); const committedVariables = invitation.commits.flatMap(
(c) => c.data.variables ?? [],
);
return committedVariables.reduce( return committedVariables.reduce(
(acc, variable) => { (acc, variable) => {
if (!variable.variableIdentifier) return acc; if (!variable.variableIdentifier) return acc;
@@ -596,15 +678,21 @@ export class HistoryService {
: String(input.outpointTransactionHash); : String(input.outpointTransactionHash);
} }
private getOutputLockingBytecodeHex(output: XOInvitationOutput): string | undefined { private getOutputLockingBytecodeHex(
output: XOInvitationOutput,
): string | undefined {
if (output.lockingBytecode === undefined) return undefined; if (output.lockingBytecode === undefined) return undefined;
return typeof output.lockingBytecode === "string" return typeof output.lockingBytecode === "string"
? output.lockingBytecode ? output.lockingBytecode
: binToHex(output.lockingBytecode); : binToHex(output.lockingBytecode);
} }
private async getScriptHashData(scriptHash: string): Promise<ScriptHashData | undefined> { private async getScriptHashData(
return (this.engine as unknown as { state: State }).state.getScriptHashData(scriptHash); 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 {
@@ -627,7 +715,9 @@ export class HistoryService {
return text.replace( return text.replace(
/\$\(<([^>]+)>\)/g, /\$\(<([^>]+)>\)/g,
(match, variableIdentifier: string) => { (match, variableIdentifier: string) => {
if (!Object.prototype.hasOwnProperty.call(variables, variableIdentifier)) { if (
!Object.prototype.hasOwnProperty.call(variables, variableIdentifier)
) {
return match; return match;
} }
return String(variables[variableIdentifier]); return String(variables[variableIdentifier]);

View File

@@ -3,7 +3,13 @@ import type {
Engine, Engine,
GetSpendableResourcesParameters, GetSpendableResourcesParameters,
} from "@xo-cash/engine"; } from "@xo-cash/engine";
import { generateTemplateIdentifier, hasInvitationExpired, mergeInvitationCommits, serializeInvitation, deserializeInvitation } from "@xo-cash/engine"; import {
generateTemplateIdentifier,
hasInvitationExpired,
mergeInvitationCommits,
serializeInvitation,
deserializeInvitation,
} from "@xo-cash/engine";
import type { import type {
XOInvitation, XOInvitation,
XOInvitationCommit, XOInvitationCommit,
@@ -92,7 +98,9 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
} }
// engine invitation (I have no idea if this is required) // engine invitation (I have no idea if this is required)
const engineInvitation = await dependencies.engine.importInvitation(serializeInvitation(invitation)); const engineInvitation = await dependencies.engine.importInvitation(
serializeInvitation(invitation),
);
// Create the invitation // Create the invitation
const invitationInstance = new Invitation(engineInvitation, dependencies); const invitationInstance = new Invitation(engineInvitation, dependencies);
@@ -287,7 +295,9 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
return payload; return payload;
} }
private unwrapLegacyInvitationUpdatedPayload(payload: unknown): unknown | null { private unwrapLegacyInvitationUpdatedPayload(
payload: unknown,
): unknown | null {
if ( if (
payload && payload &&
typeof payload === "object" && typeof payload === "object" &&
@@ -308,7 +318,10 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
invitation: XOInvitation = this.data, invitation: XOInvitation = this.data,
): Promise<void> { ): Promise<void> {
this.syncServer.publishInvitation(invitation).catch((error) => { this.syncServer.publishInvitation(invitation).catch((error) => {
this.emit("error", error instanceof Error ? error : new Error(String(error))); this.emit(
"error",
error instanceof Error ? error : new Error(String(error)),
);
}); });
} }
@@ -362,7 +375,9 @@ 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); const missingRequirements = await this.engine.listMissingRequirements(
this.data.invitationIdentifier,
);
missingReqs = missingRequirements.templateRequirements; missingReqs = missingRequirements.templateRequirements;
} catch { } catch {
return "unknown"; return "unknown";
@@ -454,13 +469,18 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
* Update the status of the invitation and emit the new single-word status. * Update the status of the invitation and emit the new single-word status.
*/ */
private async updateStatus(): Promise<void> { private async updateStatus(): Promise<void> {
this.computeStatus().then(status => { this.computeStatus()
this.status = status; .then((status) => {
this.emit("invitation-status-changed", status); this.status = status;
}).catch((error) => { this.emit("invitation-status-changed", status);
this.status = `error (${error instanceof Error ? error.message : String(error)})`; })
this.emit("error", error instanceof Error ? error : new Error(String(error))); .catch((error) => {
}); this.status = `error (${error instanceof Error ? error.message : String(error)})`;
this.emit(
"error",
error instanceof Error ? error : new Error(String(error)),
);
});
} }
/** /**
@@ -499,7 +519,9 @@ 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.invitationIdentifier,
);
// Publish the signed invitation to the sync server // Publish the signed invitation to the sync server
this.publishInvitation(signedInvitation); this.publishInvitation(signedInvitation);
@@ -518,9 +540,12 @@ 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(
broadcastTransaction: true, this.data.invitationIdentifier,
}); {
broadcastTransaction: true,
},
);
await this.updateStatus(); await this.updateStatus();
@@ -538,7 +563,10 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
await this.ensureAccepted(); await this.ensureAccepted();
// 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.invitationIdentifier,
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);
@@ -617,8 +645,8 @@ 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.map((template) => {
return Object.keys(template.outputs).map(output => { return Object.keys(template.outputs).map((output) => {
const templateIdentifier = generateTemplateIdentifier(template); const templateIdentifier = generateTemplateIdentifier(template);
return { return {
@@ -629,14 +657,18 @@ 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(
return this.engine.getSpendableResources(this.data, { outputs.flat().map((output) => {
templateIdentifier: output.templateIdentifier, return this.engine.getSpendableResources(this.data, {
outputIdentifier: output.outputIdentifier, templateIdentifier: output.templateIdentifier,
}); outputIdentifier: output.outputIdentifier,
})); });
}),
);
const unspentOutputs = spendableResources.flatMap(resource => resource.unspentOutputs); const unspentOutputs = spendableResources.flatMap(
(resource) => resource.unspentOutputs,
);
// Update the status of the invitation // Update the status of the invitation
await this.updateStatus(); await this.updateStatus();
@@ -738,9 +770,11 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
); );
// 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 = compileCashAssemblyString({
{ cashAssemblyText: String(valueSatoshisExpression), variables: formattedVariables, evaluationDecodeMode: 'bigint' }, cashAssemblyText: String(valueSatoshisExpression),
); variables: formattedVariables,
evaluationDecodeMode: "bigint",
});
// 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
@@ -796,7 +830,7 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
for (const output of outputs) { for (const output of outputs) {
if (typeof output === "string") { if (typeof output === "string") {
const sats = await this.getSatsOut(output); const sats = await this.getSatsOut(output);
totalSats += sats totalSats += sats;
} else { } else {
const sats = await this.getSatsOut(output.output); const sats = await this.getSatsOut(output.output);
totalSats += sats; totalSats += sats;

View File

@@ -1,16 +1,14 @@
import { OracleClient } from '@generalprotocols/oracle-client'; import { OracleClient } from "@generalprotocols/oracle-client";
import { EventEmitter } from '../utils/event-emitter.js'; import { EventEmitter } from "../utils/event-emitter.js";
import { import { type RatesEventMap } from "../utils/rates/base-rates.js";
type RatesEventMap, import { RatesOracle } from "../utils/rates/rates-oracles.js";
} from '../utils/rates/base-rates.js'; import { SettingsService } from "./settings.js";
import { RatesOracle } from '../utils/rates/rates-oracles.js';
import { SettingsService } from './settings.js';
/** /**
* Event map emitted by {@link RatesService}. * Event map emitted by {@link RatesService}.
*/ */
export type RatesServiceEventMap = { export type RatesServiceEventMap = {
'rate-updated': { "rate-updated": {
numeratorUnitCode: string; numeratorUnitCode: string;
denominatorUnitCode: string; denominatorUnitCode: string;
price: number; price: number;
@@ -39,8 +37,8 @@ export interface RatesAdapter {
listPairs(): Promise<Set<string>>; listPairs(): Promise<Set<string>>;
formatCurrency(amount: number, targetCurrency: string): string; formatCurrency(amount: number, targetCurrency: string): string;
on( on(
type: 'rateUpdated', type: "rateUpdated",
listener: (detail: RatesEventMap['rateUpdated']) => void, listener: (detail: RatesEventMap["rateUpdated"]) => void,
): () => void; ): () => void;
} }
@@ -96,7 +94,7 @@ export class RatesService extends EventEmitter<RatesServiceEventMap> {
} }
this.started = true; this.started = true;
this.unsubscribeFromAdapter = this.adapter.on('rateUpdated', (event) => { this.unsubscribeFromAdapter = this.adapter.on("rateUpdated", (event) => {
this.handleRateUpdated(event); this.handleRateUpdated(event);
}); });
@@ -145,9 +143,9 @@ export class RatesService extends EventEmitter<RatesServiceEventMap> {
*/ */
public convertBchToFiat( public convertBchToFiat(
satoshis: bigint, satoshis: bigint,
targetCurrency: string = 'USD', targetCurrency: string = "USD",
): number | null { ): number | null {
const rate = this.getRate(targetCurrency, 'BCH'); const rate = this.getRate(targetCurrency, "BCH");
if (rate === null) { if (rate === null) {
return null; return null;
} }
@@ -161,7 +159,7 @@ export class RatesService extends EventEmitter<RatesServiceEventMap> {
*/ */
public formatBchToFiat( public formatBchToFiat(
satoshis: bigint, satoshis: bigint,
targetCurrency: string = 'USD', targetCurrency: string = "USD",
): string | null { ): string | null {
const normalizedCurrency = targetCurrency.toUpperCase(); const normalizedCurrency = targetCurrency.toUpperCase();
const amount = this.convertBchToFiat(satoshis, normalizedCurrency); const amount = this.convertBchToFiat(satoshis, normalizedCurrency);
@@ -195,7 +193,7 @@ export class RatesService extends EventEmitter<RatesServiceEventMap> {
/** /**
* Handles normalized updates from the underlying adapter. * Handles normalized updates from the underlying adapter.
*/ */
private handleRateUpdated(event: RatesEventMap['rateUpdated']): void { private handleRateUpdated(event: RatesEventMap["rateUpdated"]): void {
const numeratorUnitCode = event.numeratorUnitCode.toUpperCase(); const numeratorUnitCode = event.numeratorUnitCode.toUpperCase();
const denominatorUnitCode = event.denominatorUnitCode.toUpperCase(); const denominatorUnitCode = event.denominatorUnitCode.toUpperCase();
const pair = this.getPairKey(numeratorUnitCode, denominatorUnitCode); const pair = this.getPairKey(numeratorUnitCode, denominatorUnitCode);
@@ -206,7 +204,7 @@ export class RatesService extends EventEmitter<RatesServiceEventMap> {
updatedAt, updatedAt,
}); });
this.emit('rate-updated', { this.emit("rate-updated", {
numeratorUnitCode, numeratorUnitCode,
denominatorUnitCode, denominatorUnitCode,
price: event.price, price: event.price,

View File

@@ -168,7 +168,9 @@ export class SettingsService extends EventEmitter<SettingsServiceEventMap> {
return normalized; return normalized;
} }
const maybeMnemonic = (input as Record<string, unknown>)["default-mnemonic"]; const maybeMnemonic = (input as Record<string, unknown>)[
"default-mnemonic"
];
if (typeof maybeMnemonic === "string" && maybeMnemonic.trim().length > 0) { if (typeof maybeMnemonic === "string" && maybeMnemonic.trim().length > 0) {
normalized["default-mnemonic"] = maybeMnemonic.trim(); normalized["default-mnemonic"] = maybeMnemonic.trim();
} }

View File

@@ -142,7 +142,7 @@ export class Storage extends BaseStorage {
* *
* This adapter is useful for tests and short-lived sessions where persisted * This adapter is useful for tests and short-lived sessions where persisted
* SQLite state is not needed. * SQLite state is not needed.
* *
* TODO: Move this somewhere else. There is no reason for this to be in the main codebase. We should put this stricly in the tests beacuse that were its actually being used. * TODO: Move this somewhere else. There is no reason for this to be in the main codebase. We should put this stricly in the tests beacuse that were its actually being used.
* Ideally, we would provide these kind of generic fills as part of our packages somewhere, but these interfaces dont fit our current design. * Ideally, we would provide these kind of generic fills as part of our packages somewhere, but these interfaces dont fit our current design.
*/ */

View File

@@ -1,4 +1,4 @@
import type { XOTemplate } from '@xo-cash/types'; import type { XOTemplate } from "@xo-cash/types";
/** /**
* Vending machine payment template. * Vending machine payment template.
@@ -7,271 +7,277 @@ import type { XOTemplate } from '@xo-cash/types';
* customer funds and signs the composable transaction. * customer funds and signs the composable transaction.
*/ */
export const vendingMachineTemplate: XOTemplate = { export const vendingMachineTemplate: XOTemplate = {
$schema: 'https://libauth.org/schemas/wallet-template-v0.schema.json', $schema: "https://libauth.org/schemas/wallet-template-v0.schema.json",
name: 'Vending Machine', name: "Vending Machine",
description: 'Purchase items from a vending machine with an itemized receipt.', description:
icon: 'wallet', "Purchase items from a vending machine with an itemized receipt.",
version: '1', icon: "wallet",
supported: ['BCH_2023_05', 'BCH_2024_05', 'BCH_2025_05', 'BCH_2026_05'], version: "1",
supported: ["BCH_2023_05", "BCH_2024_05", "BCH_2025_05", "BCH_2026_05"],
defaults: { defaults: {
change: { change: {
output: 'changeOutput', output: "changeOutput",
role: 'merchant', role: "merchant",
generate: ['merchantKey'], generate: ["merchantKey"],
},
}, },
},
roles: { roles: {
merchant: {
name: "Merchant",
description: "The vending machine operator receiving payment.",
icon: "owner",
},
customer: {
name: "Customer",
description: "The customer paying for items.",
icon: "sender",
},
},
start: [
{
action: "purchaseItems",
role: "merchant",
generate: ["merchantKey"],
},
],
actions: {
purchaseItems: {
name: "Purchase Items",
description: "Purchase: $(<receiptSummary>) for $(<totalSatoshis>) sats",
icon: "request",
roles: {
merchant: { merchant: {
name: 'Merchant', name: "Sell Items",
description: 'The vending machine operator receiving payment.', description: "Receive payment for $(<receiptSummary>)",
icon: 'owner', icon: "request",
requirements: {
secrets: ["merchantKey"],
variables: [
"totalSatoshis",
"orderId",
"merchantName",
"receiptSummary",
"lineItemsJson",
],
},
}, },
customer: { customer: {
name: 'Customer', name: "Pay",
description: 'The customer paying for items.', description: "Pay $(<totalSatoshis>) sats for $(<receiptSummary>)",
icon: 'sender', icon: "send",
requirements: {},
}, },
}, },
start: [ requirements: {
participants: [
{ role: "merchant", slots: { min: 1, max: 1 } },
{ role: "customer", slots: { min: 1 } },
],
},
transaction: "purchaseItemsTransaction",
},
},
transactions: {
purchaseItemsTransaction: {
name: "Vending Purchase",
description: "Order $(<orderId>): $(<receiptSummary>)",
icon: "request",
roles: {
merchant: {
name: "Received Payment",
description:
"Received $(<totalSatoshis>) sats from $(<merchantName>) sale",
icon: "receive",
},
customer: {
name: "Sent Payment",
description: "Paid $(<totalSatoshis>) sats for $(<receiptSummary>)",
icon: "send",
},
},
inputs: [],
outputs: [{ output: "purchaseOutput" }],
version: 2,
locktime: 0,
composable: true,
},
},
/** No custom input templates — customer UTXOs are selected at funding time. */
inputs: {},
outputs: {
changeOutput: {
name: "Change",
description: "Funds returned as change.",
icon: "receive",
lockingScript: "merchantReceivingLockingScript",
},
purchaseOutput: {
name: "Purchase Payment",
description: "$(<totalSatoshis>) sats to $(<merchantName>)",
icon: "request",
roles: {
merchant: {
name: "Payment Received",
description:
"Received $(<totalSatoshis>) sats for $(<receiptSummary>)",
},
customer: {
name: "Payment Sent",
description: "Sent $(<totalSatoshis>) sats for $(<receiptSummary>)",
},
},
lockingScript: "merchantReceivingLockingScript",
valueSatoshis: "$(<totalSatoshis>)",
token: null,
},
},
lockingScripts: {
merchantReceivingLockingScript: {
name: "Merchant Receive",
description: "Funds received by the vending machine merchant.",
icon: "address",
lockingType: "p2pkh",
lockingBytecode: "lockMerchantP2PKH",
unlockingBytecode: "unlockMerchantP2PKH",
actions: [],
state: { variables: [], secrets: [] },
balance: {},
roles: {
merchant: {
state: {
variables: [],
secrets: ["merchantKey"],
},
actions: [],
balance: {
satoshis: true,
fungibleTokens: true,
nonfungibleTokens: true,
},
selectable: true,
},
},
},
},
scripts: {
lockMerchantP2PKH:
"OP_DUP OP_HASH160 <$(<merchantKey.public_key> OP_HASH160)> OP_EQUALVERIFY OP_CHECKSIG",
unlockMerchantP2PKH:
"<merchantKey.schnorr_signature.all_outputs> <merchantKey.public_key>",
},
constants: {
dustLimit: {
name: "Dust Limit",
description: "Minimum satoshis for P2PKH outputs.",
type: "integer",
value: 546,
},
},
variables: {
merchantKey: {
name: "Merchant Private Key",
description: "Private key for the vending machine merchant wallet.",
type: "bytes",
hint: "private_key",
},
totalSatoshis: {
name: "Total Price",
description: "Total purchase price in satoshis",
type: "integer",
hint: "satoshis",
},
orderId: {
name: "Order ID",
description: "Unique order identifier",
type: "string",
},
merchantName: {
name: "Merchant Name",
description: "Display name of the vending machine",
type: "string",
},
receiptSummary: {
name: "Receipt Summary",
description: "Human-readable list of purchased items",
type: "string",
},
lineItemsJson: {
name: "Line Items",
description: "JSON-encoded line items for the purchase",
type: "string",
},
},
icons: [
{ name: "wallet", hash: "0000000000000000000000" },
{ name: "owner", hash: "0000000000000000000000" },
{ name: "sender", hash: "0000000000000000000000" },
{ name: "request", hash: "0000000000000000000000" },
{ name: "receive", hash: "0000000000000000000000" },
{ name: "send", hash: "0000000000000000000000" },
],
scenarios: [
{
name: "purchase items happy path",
description: "Merchant requests payment for vending machine items.",
action: "purchaseItems",
roles: [
{ {
action: 'purchaseItems', role: "merchant",
role: 'merchant', values: {
generate: ['merchantKey'], generated: {
}, merchantKey:
], "KyRQa5pEXuzVcDwnXRLpYAascjchQW5DoxVRMbj4DTxS83573mz8",
actions: {
purchaseItems: {
name: 'Purchase Items',
description: 'Purchase: $(<receiptSummary>) for $(<totalSatoshis>) sats',
icon: 'request',
roles: {
merchant: {
name: 'Sell Items',
description: 'Receive payment for $(<receiptSummary>)',
icon: 'request',
requirements: {
secrets: ['merchantKey'],
variables: [
'totalSatoshis',
'orderId',
'merchantName',
'receiptSummary',
'lineItemsJson',
],
},
},
customer: {
name: 'Pay',
description: 'Pay $(<totalSatoshis>) sats for $(<receiptSummary>)',
icon: 'send',
requirements: {},
},
}, },
variables: {
requirements: { totalSatoshis: 3500,
participants: [ orderId: "order-demo-1",
{ role: 'merchant', slots: { min: 1, max: 1 } }, merchantName: "XO Snack Machine",
{ role: 'customer', slots: { min: 1 } }, receiptSummary: "2× Cola, 1× Chips",
], lineItemsJson:
'[{"name":"Cola","qty":2},{"name":"Chips","qty":1}]',
}, },
secrets: {},
transaction: 'purchaseItemsTransaction',
},
},
transactions: {
purchaseItemsTransaction: {
name: 'Vending Purchase',
description: 'Order $(<orderId>): $(<receiptSummary>)',
icon: 'request',
roles: {
merchant: {
name: 'Received Payment',
description: 'Received $(<totalSatoshis>) sats from $(<merchantName>) sale',
icon: 'receive',
},
customer: {
name: 'Sent Payment',
description: 'Paid $(<totalSatoshis>) sats for $(<receiptSummary>)',
icon: 'send',
},
},
inputs: [], inputs: [],
outputs: [{ output: 'purchaseOutput' }], outputs: [
version: 2, {
locktime: 0, lockingBytecode:
composable: true, "76a91475c715ecb74178fe87933e57e947e5e92d904b8188ac",
}, valueSatoshis: 3500,
}, },
/** No custom input templates — customer UTXOs are selected at funding time. */
inputs: {},
outputs: {
changeOutput: {
name: 'Change',
description: 'Funds returned as change.',
icon: 'receive',
lockingScript: 'merchantReceivingLockingScript',
},
purchaseOutput: {
name: 'Purchase Payment',
description: '$(<totalSatoshis>) sats to $(<merchantName>)',
icon: 'request',
roles: {
merchant: {
name: 'Payment Received',
description: 'Received $(<totalSatoshis>) sats for $(<receiptSummary>)',
},
customer: {
name: 'Payment Sent',
description: 'Sent $(<totalSatoshis>) sats for $(<receiptSummary>)',
},
},
lockingScript: 'merchantReceivingLockingScript',
valueSatoshis: '$(<totalSatoshis>)',
token: null,
},
},
lockingScripts: {
merchantReceivingLockingScript: {
name: 'Merchant Receive',
description: 'Funds received by the vending machine merchant.',
icon: 'address',
lockingType: 'p2pkh',
lockingBytecode: 'lockMerchantP2PKH',
unlockingBytecode: 'unlockMerchantP2PKH',
actions: [],
state: { variables: [], secrets: [] },
balance: {},
roles: {
merchant: {
state: {
variables: [],
secrets: ['merchantKey'],
},
actions: [],
balance: {
satoshis: true,
fungibleTokens: true,
nonfungibleTokens: true,
},
selectable: true,
},
},
},
},
scripts: {
lockMerchantP2PKH:
'OP_DUP OP_HASH160 <$(<merchantKey.public_key> OP_HASH160)> OP_EQUALVERIFY OP_CHECKSIG',
unlockMerchantP2PKH:
'<merchantKey.schnorr_signature.all_outputs> <merchantKey.public_key>',
},
constants: {
dustLimit: {
name: 'Dust Limit',
description: 'Minimum satoshis for P2PKH outputs.',
type: 'integer',
value: 546,
},
},
variables: {
merchantKey: {
name: 'Merchant Private Key',
description: 'Private key for the vending machine merchant wallet.',
type: 'bytes',
hint: 'private_key',
},
totalSatoshis: {
name: 'Total Price',
description: 'Total purchase price in satoshis',
type: 'integer',
hint: 'satoshis',
},
orderId: {
name: 'Order ID',
description: 'Unique order identifier',
type: 'string',
},
merchantName: {
name: 'Merchant Name',
description: 'Display name of the vending machine',
type: 'string',
},
receiptSummary: {
name: 'Receipt Summary',
description: 'Human-readable list of purchased items',
type: 'string',
},
lineItemsJson: {
name: 'Line Items',
description: 'JSON-encoded line items for the purchase',
type: 'string',
},
},
icons: [
{ name: 'wallet', hash: '0000000000000000000000' },
{ name: 'owner', hash: '0000000000000000000000' },
{ name: 'sender', hash: '0000000000000000000000' },
{ name: 'request', hash: '0000000000000000000000' },
{ name: 'receive', hash: '0000000000000000000000' },
{ name: 'send', hash: '0000000000000000000000' },
],
scenarios: [
{
name: 'purchase items happy path',
description: 'Merchant requests payment for vending machine items.',
action: 'purchaseItems',
roles: [
{
role: 'merchant',
values: {
generated: {
merchantKey: 'KyRQa5pEXuzVcDwnXRLpYAascjchQW5DoxVRMbj4DTxS83573mz8',
},
variables: {
totalSatoshis: 3500,
orderId: 'order-demo-1',
merchantName: 'XO Snack Machine',
receiptSummary: '2× Cola, 1× Chips',
lineItemsJson: '[{"name":"Cola","qty":2},{"name":"Chips","qty":1}]',
},
secrets: {},
inputs: [],
outputs: [
{
lockingBytecode: '76a91475c715ecb74178fe87933e57e947e5e92d904b8188ac',
valueSatoshis: 3500,
},
],
},
},
{
role: 'customer',
values: {
generated: {},
variables: {},
secrets: {},
inputs: [],
outputs: [],
},
},
], ],
},
}, },
], {
role: "customer",
values: {
generated: {},
variables: {},
secrets: {},
inputs: [],
outputs: [],
},
},
],
},
],
}; };

View File

@@ -1,266 +1,270 @@
import type { XOTemplate } from '@xo-cash/types'; import type { XOTemplate } from "@xo-cash/types";
export const wrapBCHTemplate: XOTemplate = { export const wrapBCHTemplate: XOTemplate = {
$schema: 'https://libauth.org/schemas/wallet-template-v0.schema.json', $schema: "https://libauth.org/schemas/wallet-template-v0.schema.json",
name: 'Wrapped BCH', name: "Wrapped BCH",
description: 'Convert between BCH and wBCH tokens.', description: "Convert between BCH and wBCH tokens.",
icon: 'wrap', icon: "wrap",
version: '1', version: "1",
supported: ['BCH_2023_05', 'BCH_2024_05', 'BCH_2025_05', 'BCH_2026_05'], supported: ["BCH_2023_05", "BCH_2024_05", "BCH_2025_05", "BCH_2026_05"],
roles: { roles: {
user: { user: {
name: 'User', name: "User",
description: 'The person wrapping or unwrapping BCH.', description: "The person wrapping or unwrapping BCH.",
icon: 'user', icon: "user",
}, },
}, },
start: [ start: [
{ {
action: 'wrap', action: "wrap",
role: 'user', role: "user",
}, },
{ {
action: 'unwrap', action: "unwrap",
role: 'user', role: "user",
}, },
], ],
actions: { actions: {
wrap: { wrap: {
name: 'Wrap BCH', name: "Wrap BCH",
description: 'Convert BCH into wBCH tokens.', description: "Convert BCH into wBCH tokens.",
icon: 'wrap', icon: "wrap",
roles: { roles: {
user: { user: {
requirements: { requirements: {
variables: ['amountToWrap', 'recipientLockingScript'], variables: ["amountToWrap", "recipientLockingScript"],
}, },
}, },
}, },
requirements: { requirements: {
participants: [{ role: 'user', slots: { min: 1, max: 1 } }], participants: [{ role: "user", slots: { min: 1, max: 1 } }],
}, },
transaction: 'wrapTransaction', transaction: "wrapTransaction",
}, },
unwrap: { unwrap: {
name: 'Unwrap wBCH', name: "Unwrap wBCH",
description: 'Convert wBCH tokens back into BCH.', description: "Convert wBCH tokens back into BCH.",
icon: 'unwrap', icon: "unwrap",
roles: { roles: {
user: { user: {
requirements: { requirements: {
variables: ['amountToUnwrap', 'recipientLockingScript'], variables: ["amountToUnwrap", "recipientLockingScript"],
}, },
}, },
}, },
requirements: { requirements: {
participants: [{ role: 'user', slots: { min: 1, max: 1 } }], participants: [{ role: "user", slots: { min: 1, max: 1 } }],
}, },
transaction: 'unwrapTransaction', transaction: "unwrapTransaction",
}, },
}, },
transactions: { transactions: {
wrapTransaction: { wrapTransaction: {
name: 'Wrapped BCH', name: "Wrapped BCH",
description: 'Wrapped $(<amountToWrap> <satoshisPerBCH> OP_DIV).$(<amountToWrap> <satoshisPerBCH> OP_MOD) BCH into wBCH tokens.', description:
icon: 'wrap', "Wrapped $(<amountToWrap> <satoshisPerBCH> OP_DIV).$(<amountToWrap> <satoshisPerBCH> OP_MOD) BCH into wBCH tokens.",
icon: "wrap",
inputs: [ inputs: [{ input: "covenantInput", inputIndex: 0 }],
{ input: 'covenantInput', inputIndex: 0 }, outputs: [
], { output: "covenantOutput", outputIndex: 0 },
outputs: [ { output: "wrappedTokensOutput", outputIndex: undefined },
{ output: 'covenantOutput', outputIndex: 0 }, ],
{ output: 'wrappedTokensOutput', outputIndex: undefined },
],
version: 2, version: 2,
locktime: 0, locktime: 0,
composable: true, composable: true,
}, },
unwrapTransaction: { unwrapTransaction: {
name: 'Unwrapped wBCH', name: "Unwrapped wBCH",
description: 'Unwrapped $(<amountToUnwrap> <satoshisPerBCH> OP_DIV).$(<amountToUnwrap> <satoshisPerBCH> OP_MOD) wBCH tokens back into BCH.', description:
icon: 'unwrap', "Unwrapped $(<amountToUnwrap> <satoshisPerBCH> OP_DIV).$(<amountToUnwrap> <satoshisPerBCH> OP_MOD) wBCH tokens back into BCH.",
icon: "unwrap",
inputs: [ inputs: [{ input: "covenantInput", inputIndex: 0 }],
{ input: 'covenantInput', inputIndex: 0 }, outputs: [
], { output: "covenantOutput", outputIndex: 0 },
outputs: [ { output: "unwrappedSatoshisOutput", outputIndex: undefined },
{ output: 'covenantOutput', outputIndex: 0 }, ],
{ output: 'unwrappedSatoshisOutput', outputIndex: undefined },
],
version: 2, version: 2,
locktime: 0, locktime: 0,
composable: true, composable: true,
}, },
}, },
outputs: { outputs: {
covenantOutput: { covenantOutput: {
name: 'wBCH Covenant', name: "wBCH Covenant",
description: 'Holds BCH and wBCH tokens that can be freely converted.', description: "Holds BCH and wBCH tokens that can be freely converted.",
icon: 'contract', icon: "contract",
lockingScript: 'wrapBCHLockingScript', lockingScript: "wrapBCHLockingScript",
}, },
wrappedTokensOutput: { wrappedTokensOutput: {
name: 'Wrapped wBCH', name: "Wrapped wBCH",
description: 'Wrapped $(<amountToWrap> <satoshisPerBCH> OP_DIV).$(<amountToWrap> <satoshisPerBCH> OP_MOD) wBCH tokens.', description:
icon: 'receive', "Wrapped $(<amountToWrap> <satoshisPerBCH> OP_DIV).$(<amountToWrap> <satoshisPerBCH> OP_MOD) wBCH tokens.",
icon: "receive",
valueSatoshis: '$(<amountToWrap>)', valueSatoshis: "$(<amountToWrap>)",
token: { token: {
category: '$(<wbchTokenCategory>)', category: "$(<wbchTokenCategory>)",
amount: '$(<amountToWrap>)', amount: "$(<amountToWrap>)",
nft: null, nft: null,
}, },
roles: { roles: {
user: { user: {
balance: { balance: {
satoshis: true, satoshis: true,
fungibleTokens: true, fungibleTokens: true,
nonfungibleTokens: true, nonfungibleTokens: true,
}, },
selectable: true, selectable: true,
}, },
}, },
lockingScript: '$(<recipientLockingScript>)', lockingScript: "$(<recipientLockingScript>)",
}, },
unwrappedSatoshisOutput: { unwrappedSatoshisOutput: {
name: 'Unwrapped BCH', name: "Unwrapped BCH",
description: 'Unwrapped $(<amountToUnwrap> <satoshisPerBCH> OP_DIV).$(<amountToUnwrap> <satoshisPerBCH> OP_MOD) BCH.', description:
icon: 'receive', "Unwrapped $(<amountToUnwrap> <satoshisPerBCH> OP_DIV).$(<amountToUnwrap> <satoshisPerBCH> OP_MOD) BCH.",
icon: "receive",
valueSatoshis: '$(<amountToUnwrap>)', valueSatoshis: "$(<amountToUnwrap>)",
token: null, token: null,
roles: { roles: {
user: { user: {
balance: { balance: {
satoshis: true, satoshis: true,
fungibleTokens: true, fungibleTokens: true,
nonfungibleTokens: true, nonfungibleTokens: true,
}, },
selectable: true, selectable: true,
}, },
}, },
lockingScript: '$(<recipientLockingScript>)', lockingScript: "$(<recipientLockingScript>)",
}, },
}, },
inputs: { inputs: {
covenantInput: { covenantInput: {
name: 'wBCH Covenant', name: "wBCH Covenant",
description: 'The covenant being updated.', description: "The covenant being updated.",
icon: 'contract', icon: "contract",
unlockingScript: 'unlockCovenant', unlockingScript: "unlockCovenant",
}, },
}, },
lockingScripts: { lockingScripts: {
wrapBCHLockingScript: { wrapBCHLockingScript: {
name: 'wBCH Covenant', name: "wBCH Covenant",
description: 'Holds BCH and wBCH tokens that can be freely converted.', description: "Holds BCH and wBCH tokens that can be freely converted.",
icon: 'contract', icon: "contract",
lockingType: 'p2sh', lockingType: "p2sh",
lockingBytecode: 'wrapBCHLockingBytecode', lockingBytecode: "wrapBCHLockingBytecode",
actions: [ actions: [
{ action: 'wrap', role: 'user' }, { action: "wrap", role: "user" },
{ action: 'unwrap', role: 'user' }, { action: "unwrap", role: "user" },
], ],
state: { state: {
variables: [], variables: [],
secrets: [], secrets: [],
}, },
balance: { balance: {
satoshis: 0n, satoshis: 0n,
fungibleTokens: 0n, fungibleTokens: 0n,
}, },
selectable: false, selectable: false,
}, },
}, },
scripts: { scripts: {
enforceCovenantPersists: 'OP_INPUTINDEX OP_DUP OP_OUTPUTBYTECODE OP_SWAP OP_UTXOBYTECODE OP_EQUAL OP_VERIFY', enforceCovenantPersists:
enforceTokenCategoryPreserved: 'OP_INPUTINDEX OP_DUP OP_OUTPUTTOKENCATEGORY OP_SWAP OP_UTXOTOKENCATEGORY OP_EQUAL OP_VERIFY', "OP_INPUTINDEX OP_DUP OP_OUTPUTBYTECODE OP_SWAP OP_UTXOBYTECODE OP_EQUAL OP_VERIFY",
enforceValueTokenSumConserved: 'OP_INPUTINDEX OP_UTXOVALUE OP_INPUTINDEX OP_UTXOTOKENAMOUNT OP_ADD OP_INPUTINDEX OP_OUTPUTVALUE OP_INPUTINDEX OP_OUTPUTTOKENAMOUNT OP_ADD OP_EQUAL OP_VERIFY', enforceTokenCategoryPreserved:
"OP_INPUTINDEX OP_DUP OP_OUTPUTTOKENCATEGORY OP_SWAP OP_UTXOTOKENCATEGORY OP_EQUAL OP_VERIFY",
enforceValueTokenSumConserved:
"OP_INPUTINDEX OP_UTXOVALUE OP_INPUTINDEX OP_UTXOTOKENAMOUNT OP_ADD OP_INPUTINDEX OP_OUTPUTVALUE OP_INPUTINDEX OP_OUTPUTTOKENAMOUNT OP_ADD OP_EQUAL OP_VERIFY",
// Direct script references — introspection opcodes must not use $(...) evaluations // Direct script references — introspection opcodes must not use $(...) evaluations
// because those are evaluated at compile time without transaction context. // because those are evaluated at compile time without transaction context.
wrapBCHLockingBytecode: 'enforceCovenantPersists enforceTokenCategoryPreserved enforceValueTokenSumConserved', wrapBCHLockingBytecode:
unlockCovenant: '', "enforceCovenantPersists enforceTokenCategoryPreserved enforceValueTokenSumConserved",
}, unlockCovenant: "",
},
constants: { constants: {
wbchTokenCategory: { wbchTokenCategory: {
name: 'wBCH Token Category', name: "wBCH Token Category",
description: 'The official token category for Wrapped BCH.', description: "The official token category for Wrapped BCH.",
type: 'bytes', type: "bytes",
value: 'ff4d6e4b90aa8158d39c5dc874fd9411af1ac3b5ed6f354755e8362a0d02c6b3', value: "ff4d6e4b90aa8158d39c5dc874fd9411af1ac3b5ed6f354755e8362a0d02c6b3",
}, },
satoshisPerBCH: { satoshisPerBCH: {
name: 'Satoshis per BCH', name: "Satoshis per BCH",
description: 'Used to display amounts in BCH with decimals.', description: "Used to display amounts in BCH with decimals.",
type: 'integer', type: "integer",
value: 100000000, value: 100000000,
}, },
tokenDust: { tokenDust: {
name: 'Token Dust Limit', name: "Token Dust Limit",
description: 'Minimal satoshis required for a token-bearing output.', description: "Minimal satoshis required for a token-bearing output.",
type: 'integer', type: "integer",
value: 1000, value: 1000,
}, },
}, },
variables: { variables: {
amountToWrap: { amountToWrap: {
name: 'Amount to Wrap', name: "Amount to Wrap",
description: 'How much BCH to convert to wBCH (in satoshis).', description: "How much BCH to convert to wBCH (in satoshis).",
type: 'integer', type: "integer",
hint: 'satoshis', hint: "satoshis",
}, },
amountToUnwrap: { amountToUnwrap: {
name: 'Amount to Unwrap', name: "Amount to Unwrap",
description: 'How much wBCH to convert back to BCH (in satoshis).', description: "How much wBCH to convert back to BCH (in satoshis).",
type: 'integer', type: "integer",
hint: 'satoshis', hint: "satoshis",
}, },
recipientLockingScript: { recipientLockingScript: {
name: 'Destination', name: "Destination",
description: 'Where to receive your BCH or wBCH tokens.', description: "Where to receive your BCH or wBCH tokens.",
type: 'bytes', type: "bytes",
hint: 'lockingScript', hint: "lockingScript",
}, },
}, },
icons: [ icons: [
{ name: 'wrap', hash: '0000000000000000000000' }, { name: "wrap", hash: "0000000000000000000000" },
{ name: 'unwrap', hash: '0000000000000000000000' }, { name: "unwrap", hash: "0000000000000000000000" },
{ name: 'user', hash: '0000000000000000000000' }, { name: "user", hash: "0000000000000000000000" },
{ name: 'contract', hash: '0000000000000000000000' }, { name: "contract", hash: "0000000000000000000000" },
{ name: 'receive', hash: '0000000000000000000000' }, { name: "receive", hash: "0000000000000000000000" },
], ],
}; };

View File

@@ -13,30 +13,36 @@ const execAsync = promisify(exec);
// The command is a function that returns a promise that resolves to the result of the command. // The command is a function that returns a promise that resolves to the result of the command.
const clipboardMethods = { const clipboardMethods = {
pbCopy: { pbCopy: {
platform: (platform: string) => platform === 'darwin', platform: (platform: string) => platform === "darwin",
command: async (text: string) => execAsync(`printf '%s' '${text}' | pbcopy`), command: async (text: string) =>
execAsync(`printf '%s' '${text}' | pbcopy`),
}, },
xclip: { xclip: {
platform: (platform: string) => platform === 'linux', platform: (platform: string) => platform === "linux",
command: async (text: string) => execAsync(`printf '%s' '${text}' | xclip -selection clipboard`), command: async (text: string) =>
execAsync(`printf '%s' '${text}' | xclip -selection clipboard`),
}, },
xsel: { xsel: {
platform: (platform: string) => platform === 'linux', platform: (platform: string) => platform === "linux",
command: async (text: string) => execAsync(`printf '%s' '${text}' | xsel --clipboard --input`), command: async (text: string) =>
execAsync(`printf '%s' '${text}' | xsel --clipboard --input`),
}, },
ssh: { ssh: {
platform: (platform: string) => platform === 'linux', platform: (platform: string) => platform === "linux",
command: async (text: string) => process.stdout.write(`\x1b]52;c;${Buffer.from(text, 'utf-8').toString('base64')}\x07`), command: async (text: string) =>
process.stdout.write(
`\x1b]52;c;${Buffer.from(text, "utf-8").toString("base64")}\x07`,
),
}, },
clip: { clip: {
platform: (platform: string) => platform === 'windows', platform: (platform: string) => platform === "windows",
command: async (text: string) => execAsync(`echo|set /p="${text}" | clip`), command: async (text: string) => execAsync(`echo|set /p="${text}" | clip`),
}, },
clipboardy: { clipboardy: {
platform: (platform: string) => platform === 'windows', platform: (platform: string) => platform === "windows",
command: async (text: string) => clipboardy.writeSync(text), command: async (text: string) => clipboardy.writeSync(text),
}, },
} };
/** /**
* Attempts to copy text to clipboard using multiple methods. * Attempts to copy text to clipboard using multiple methods.
@@ -51,7 +57,9 @@ export async function copyToClipboard(text: string): Promise<void> {
// Escape the text for shell commands // Escape the text for shell commands
const escapedText = text.replace(/'/g, "'\\''"); const escapedText = text.replace(/'/g, "'\\''");
const availableMethods = Object.values(clipboardMethods).filter(method => method.platform(platform)); const availableMethods = Object.values(clipboardMethods).filter((method) =>
method.platform(platform),
);
const errors: Error[] = []; const errors: Error[] = [];
@@ -63,7 +71,7 @@ export async function copyToClipboard(text: string): Promise<void> {
continue; continue;
} }
return; return;
} catch(error) { } catch (error) {
if (error instanceof Error) { if (error instanceof Error) {
errors.push(error); errors.push(error);
} }
@@ -71,5 +79,7 @@ export async function copyToClipboard(text: string): Promise<void> {
} }
// All methods failed // All methods failed
throw new Error(`Clipboard not available. ${errors.map(error => error.message).join('\n')}`); throw new Error(
`Clipboard not available. ${errors.map((error) => error.message).join("\n")}`,
);
} }

View File

@@ -160,8 +160,7 @@ export function listDirectoryEntries(
entries: [...entries, ...directories, ...files], entries: [...entries, ...directories, ...files],
}; };
} catch (error) { } catch (error) {
const message = const message = error instanceof Error ? error.message : String(error);
error instanceof Error ? error.message : String(error);
return { return {
entries: [], entries: [],
error: `Unable to read directory: ${message}`, error: `Unable to read directory: ${message}`,

View File

@@ -51,7 +51,7 @@ export function buildHistoryDisplayRows(
type: "history_output", type: "history_output",
label: output.outpoint label: output.outpoint
? `${output.outpoint.txid}:${output.outpoint.index}` ? `${output.outpoint.txid}:${output.outpoint.index}`
: output.outputIdentifier ?? "Output", : (output.outputIdentifier ?? "Output"),
description: `${item.template} | ${roles} | ${output.description}`, description: `${item.template} | ${roles} | ${output.description}`,
timestamp: item.createdAtTimestamp, timestamp: item.createdAtTimestamp,
isNested: false, isNested: false,
@@ -96,7 +96,7 @@ export function buildHistoryDisplayRows(
type: "history_output", type: "history_output",
label: output.outpoint label: output.outpoint
? `${output.outpoint.txid}:${output.outpoint.index}` ? `${output.outpoint.txid}:${output.outpoint.index}`
: output.outputIdentifier ?? "Output", : (output.outputIdentifier ?? "Output"),
description: output.description, description: output.description,
isNested: true, isNested: true,
valueSatoshis: output.valueSatoshis, valueSatoshis: output.valueSatoshis,

View File

@@ -65,8 +65,18 @@ export const roleRequiresInputs = (
const actionRole = action.roles?.[roleIdentifier]; const actionRole = action.roles?.[roleIdentifier];
const actionRequirements = action.requirements; const actionRequirements = action.requirements;
const actionRoleRequirements = actionRole && actionRequirements && actionRequirements.participants?.find((participant) => participant.role === roleIdentifier); const actionRoleRequirements =
const roleSlotsMin = actionRoleRequirements && actionRoleRequirements.slots && actionRoleRequirements.slots.min > 0 ? actionRoleRequirements.slots.min : 0; actionRole &&
actionRequirements &&
actionRequirements.participants?.find(
(participant) => participant.role === roleIdentifier,
);
const roleSlotsMin =
actionRoleRequirements &&
actionRoleRequirements.slots &&
actionRoleRequirements.slots.min > 0
? actionRoleRequirements.slots.min
: 0;
if (roleSlotsMin > 0) return true; if (roleSlotsMin > 0) return true;
const transactionIdentifier = action.transaction; const transactionIdentifier = action.transaction;
@@ -78,7 +88,6 @@ export const roleRequiresInputs = (
return (roleInputs?.length ?? 0) > 0; return (roleInputs?.length ?? 0) > 0;
}; };
export const getTransactionOutputIdentifier = ( export const getTransactionOutputIdentifier = (
output: XOTemplateTransactionOutput, output: XOTemplateTransactionOutput,
): string | undefined => { ): string | undefined => {
@@ -136,7 +145,8 @@ export const resolveProvidedLockingBytecodeHex = (
return undefined; return undefined;
} }
const lockingScriptDefinition = template.lockingScripts?.[outputDefinition.lockingScript]; const lockingScriptDefinition =
template.lockingScripts?.[outputDefinition.lockingScript];
const scriptIdentifier = lockingScriptDefinition?.lockingBytecode; const scriptIdentifier = lockingScriptDefinition?.lockingBytecode;
if (!scriptIdentifier) return undefined; if (!scriptIdentifier) return undefined;

View File

@@ -71,12 +71,7 @@ function resolveTemplateModuleLoaderPath(): string {
} }
/** TypeScript extensions that require tsx to evaluate the template module. */ /** TypeScript extensions that require tsx to evaluate the template module. */
const TYPESCRIPT_TEMPLATE_EXTENSIONS = new Set([ const TYPESCRIPT_TEMPLATE_EXTENSIONS = new Set([".ts", ".tsx", ".mts", ".cts"]);
".ts",
".tsx",
".mts",
".cts",
]);
/** /**
* Loads a TS/JS template module in an isolated child process. * Loads a TS/JS template module in an isolated child process.
@@ -155,7 +150,9 @@ async function loadTemplateModuleViaChildProcess(
} }
if (stdout.trim().length === 0) { if (stdout.trim().length === 0) {
reject(new TemplateLoadError("Template module loader returned no output.")); reject(
new TemplateLoadError("Template module loader returned no output."),
);
return; return;
} }
@@ -174,7 +171,9 @@ export async function loadTemplateFromFile(filePath: string): Promise<string> {
const absolutePath = path.resolve(filePath); const absolutePath = path.resolve(filePath);
if (!fs.existsSync(absolutePath)) { if (!fs.existsSync(absolutePath)) {
throw new TemplateLoadError(`Template file does not exist: ${absolutePath}`); throw new TemplateLoadError(
`Template file does not exist: ${absolutePath}`,
);
} }
const extension = path.extname(absolutePath).toLowerCase(); const extension = path.extname(absolutePath).toLowerCase();

View File

@@ -11,7 +11,8 @@ import { basename, isAbsolute, join, resolve } from "node:path";
* Base config directory. Created on first access. * Base config directory. Created on first access.
*/ */
export function getConfigDir(): string { export function getConfigDir(): string {
const dir = process.env["XO_CONFIG_DIR"] || join(homedir(), ".config", "xo-cli"); const dir =
process.env["XO_CONFIG_DIR"] || join(homedir(), ".config", "xo-cli");
mkdirSync(dir, { recursive: true }); mkdirSync(dir, { recursive: true });
return dir; return dir;
} }

View File

@@ -6,7 +6,9 @@
* Returns true when `value` looks like an XOTemplate object (pre-schema check). * Returns true when `value` looks like an XOTemplate object (pre-schema check).
* Used only to pick the correct export before {@link parseTemplate} validates fully. * Used only to pick the correct export before {@link parseTemplate} validates fully.
*/ */
export function isTemplateLike(value: unknown): value is Record<string, unknown> { export function isTemplateLike(
value: unknown,
): value is Record<string, unknown> {
if (value === null || typeof value !== "object" || Array.isArray(value)) { if (value === null || typeof value !== "object" || Array.isArray(value)) {
return false; return false;
} }

View File

@@ -1,4 +1,4 @@
import { EventEmitter } from '../event-emitter.js'; import { EventEmitter } from "../event-emitter.js";
/** /**
* Events emitted by our Rates Adapters * Events emitted by our Rates Adapters
@@ -44,14 +44,15 @@ export abstract class BaseRates<
BCH: 8, BCH: 8,
USD: 2, USD: 2,
}; };
const minimumFractionDigits = minimumFractionDigitsMap[normalizedCurrency] ?? 2; const minimumFractionDigits =
minimumFractionDigitsMap[normalizedCurrency] ?? 2;
const maximumFractionDigits = Math.max(minimumFractionDigits, 8); const maximumFractionDigits = Math.max(minimumFractionDigits, 8);
try { try {
const formatter = new Intl.NumberFormat('en-US', { const formatter = new Intl.NumberFormat("en-US", {
style: 'currency', style: "currency",
currency: normalizedCurrency, currency: normalizedCurrency,
currencyDisplay: 'narrowSymbol', currencyDisplay: "narrowSymbol",
minimumFractionDigits, minimumFractionDigits,
maximumFractionDigits, maximumFractionDigits,
}); });
@@ -61,7 +62,7 @@ export abstract class BaseRates<
// Some numerator symbols from oracle pairs (e.g. DOGE/BCH) are not ISO-4217 // Some numerator symbols from oracle pairs (e.g. DOGE/BCH) are not ISO-4217
// fiat currency codes, so Intl currency formatting will throw a RangeError. // fiat currency codes, so Intl currency formatting will throw a RangeError.
// In that case we still return a human-readable formatted value. // In that case we still return a human-readable formatted value.
const numericFormatter = new Intl.NumberFormat('en-US', { const numericFormatter = new Intl.NumberFormat("en-US", {
minimumFractionDigits, minimumFractionDigits,
maximumFractionDigits, maximumFractionDigits,
}); });

View File

@@ -3,11 +3,11 @@ import {
OracleMetadataMessage, OracleMetadataMessage,
OraclePriceMessage, OraclePriceMessage,
type OracleMetadataMap, type OracleMetadataMap,
} from '@generalprotocols/oracle-client'; } from "@generalprotocols/oracle-client";
import { type RatesEventMap, BaseRates } from './base-rates.js'; import { type RatesEventMap, BaseRates } from "./base-rates.js";
import { type OffCallback } from '../event-emitter.js'; import { type OffCallback } from "../event-emitter.js";
import { SettingsService } from '../../services/settings.js'; import { SettingsService } from "../../services/settings.js";
// Add the Oracle Price Message to our Events for this Adapter. // Add the Oracle Price Message to our Events for this Adapter.
export type RatesOracleEventMap = RatesEventMap & { export type RatesOracleEventMap = RatesEventMap & {
@@ -42,7 +42,7 @@ export class RatesOracle extends BaseRates<RatesOracleEventMap> {
private started: boolean = false; private started: boolean = false;
private targetNumeratorUnitCode: string; private targetNumeratorUnitCode: string;
private targetDenominatorUnitCode: string = 'BCH'; private targetDenominatorUnitCode: string = "BCH";
private unsubscribeFromSettings: OffCallback | null = null; private unsubscribeFromSettings: OffCallback | null = null;
public constructor(client: OracleClient, settings: SettingsService) { public constructor(client: OracleClient, settings: SettingsService) {
@@ -63,7 +63,7 @@ export class RatesOracle extends BaseRates<RatesOracleEventMap> {
} }
this.started = true; this.started = true;
this.unsubscribeFromSettings = this.settings.on( this.unsubscribeFromSettings = this.settings.on(
'settings-updated', "settings-updated",
this.handleSettingsUpdated.bind(this), this.handleSettingsUpdated.bind(this),
); );
@@ -150,7 +150,11 @@ export class RatesOracle extends BaseRates<RatesOracleEventMap> {
this.handlePriceMessage(message); this.handlePriceMessage(message);
} }
} catch (error) { } catch (error) {
console.error('Error refreshing prices for oracle:', oracle.publicKey, error); console.error(
"Error refreshing prices for oracle:",
oracle.publicKey,
error,
);
} }
}), }),
); );
@@ -183,8 +187,10 @@ export class RatesOracle extends BaseRates<RatesOracleEventMap> {
return; return;
} }
const sourceNumeratorUnitCode = oracle.SOURCE_NUMERATOR_UNIT_CODE.toUpperCase(); const sourceNumeratorUnitCode =
const sourceDenominatorUnitCode = oracle.SOURCE_DENOMINATOR_UNIT_CODE.toUpperCase(); oracle.SOURCE_NUMERATOR_UNIT_CODE.toUpperCase();
const sourceDenominatorUnitCode =
oracle.SOURCE_DENOMINATOR_UNIT_CODE.toUpperCase();
// Only emit the pair currently selected in settings. // Only emit the pair currently selected in settings.
if ( if (
@@ -197,7 +203,7 @@ export class RatesOracle extends BaseRates<RatesOracleEventMap> {
// Scale the price // Scale the price
const priceValue = message.priceValue / oracle.ATTESTATION_SCALING; const priceValue = message.priceValue / oracle.ATTESTATION_SCALING;
this.emit('rateUpdated', { this.emit("rateUpdated", {
numeratorUnitCode: sourceNumeratorUnitCode, numeratorUnitCode: sourceNumeratorUnitCode,
denominatorUnitCode: sourceDenominatorUnitCode, denominatorUnitCode: sourceDenominatorUnitCode,
price: priceValue, price: priceValue,
@@ -208,13 +214,11 @@ export class RatesOracle extends BaseRates<RatesOracleEventMap> {
/** /**
* Tracks updates to settings and switches the actively emitted fiat pair. * Tracks updates to settings and switches the actively emitted fiat pair.
*/ */
private handleSettingsUpdated( private handleSettingsUpdated(event: {
event: { key: "currency" | "default-mnemonic";
key: 'currency' | 'default-mnemonic'; value: string | undefined;
value: string | undefined; }) {
}, if (event.key !== "currency" || !event.value) {
) {
if (event.key !== 'currency' || !event.value) {
return; return;
} }
@@ -223,7 +227,7 @@ export class RatesOracle extends BaseRates<RatesOracleEventMap> {
// Refresh so listeners get the latest value for the new currency quickly. // Refresh so listeners get the latest value for the new currency quickly.
if (this.started) { if (this.started) {
this.refreshPrices().catch((error) => { this.refreshPrices().catch((error) => {
console.error('Error refreshing prices after currency update:', error); console.error("Error refreshing prices after currency update:", error);
}); });
} }
} }

View File

@@ -27,8 +27,7 @@ try {
const template = pickTemplateExport(loadedModule); const template = pickTemplateExport(loadedModule);
process.stdout.write(serializeTemplate(template as XOTemplate)); process.stdout.write(serializeTemplate(template as XOTemplate));
} catch (error) { } catch (error) {
const message = const message = error instanceof Error ? error.message : String(error);
error instanceof Error ? error.message : String(error);
console.error(`Failed to load template module: ${message}`); console.error(`Failed to load template module: ${message}`);
process.exit(1); process.exit(1);
} }

View File

@@ -188,13 +188,13 @@ export function getRolesForAction(
); );
return startEntries.map((entry) => { return startEntries.map((entry) => {
const roleDef = template.roles?.[entry.role || '']; const roleDef = template.roles?.[entry.role || ""];
const roleObj = typeof roleDef === "object" ? roleDef : null; const roleObj = typeof roleDef === "object" ? roleDef : null;
// TODO: This is ugly. Lot of conditionals. Need to take a much closer look at this. // TODO: This is ugly. Lot of conditionals. Need to take a much closer look at this.
return { return {
roleId: entry.role || '', roleId: entry.role || "",
name: roleObj?.name || entry.role || '', name: roleObj?.name || entry.role || "",
description: roleObj?.description, description: roleObj?.description,
}; };
}); });

View File

@@ -9,7 +9,8 @@ export type UnspentOutputMetadata = {
outputIdentifier?: string; outputIdentifier?: string;
}; };
export type UnspentOutputWithMetadata = UnspentOutputData & UnspentOutputMetadata; export type UnspentOutputWithMetadata = UnspentOutputData &
UnspentOutputMetadata;
/** /**
* Builds a lookup map from script hash to its stored metadata. * Builds a lookup map from script hash to its stored metadata.

View File

@@ -55,7 +55,7 @@ describe("shell completions", () => {
test("uses shell-native mnemonic completion in fish", () => { test("uses shell-native mnemonic completion in fish", () => {
const completions = generateFishCompletions("xo-cli"); const completions = generateFishCompletions("xo-cli");
expect(completions).toContain("set -l config_dir \"$XO_CONFIG_DIR\""); expect(completions).toContain('set -l config_dir "$XO_CONFIG_DIR"');
expect(completions).toContain("(__xo_cli_complete_mnemonics)"); expect(completions).toContain("(__xo_cli_complete_mnemonics)");
expect(completions).not.toContain("(__xo_cli_complete_dynamic mnemonics)"); expect(completions).not.toContain("(__xo_cli_complete_dynamic mnemonics)");
}); });
@@ -68,9 +68,9 @@ describe("shell completions", () => {
const contents = readFileSync(configFile, "utf8"); const contents = readFileSync(configFile, "utf8");
expect(contents.match(/XO_CONFIG_DIR/g)).toHaveLength(2); expect(contents.match(/XO_CONFIG_DIR/g)).toHaveLength(2);
expect(contents.match(/eval "\$\(xo-cli completions bash\)"/g)).toHaveLength( expect(
1, contents.match(/eval "\$\(xo-cli completions bash\)"/g),
); ).toHaveLength(1);
}); });
test("adds a missing default without duplicating an existing loader", () => { test("adds a missing default without duplicating an existing loader", () => {
@@ -79,16 +79,18 @@ describe("shell completions", () => {
expect(installCompletions("bash", "xo-cli", configFile)).toBe(true); expect(installCompletions("bash", "xo-cli", configFile)).toBe(true);
const contents = readFileSync(configFile, "utf8"); const contents = readFileSync(configFile, "utf8");
expect(contents.match(/eval "\$\(xo-cli completions bash\)"/g)).toHaveLength( expect(
1, contents.match(/eval "\$\(xo-cli completions bash\)"/g),
); ).toHaveLength(1);
expect(contents).toContain( expect(contents).toContain(
'export XO_CONFIG_DIR="${XO_CONFIG_DIR:-$HOME/.config/xo-cli}"', 'export XO_CONFIG_DIR="${XO_CONFIG_DIR:-$HOME/.config/xo-cli}"',
); );
}); });
test("preserves an existing custom config directory assignment", () => { test("preserves an existing custom config directory assignment", () => {
const configFile = createConfigFile("export XO_CONFIG_DIR=/tmp/custom-xo\n"); const configFile = createConfigFile(
"export XO_CONFIG_DIR=/tmp/custom-xo\n",
);
expect(installCompletions("zsh", "xo-cli", configFile)).toBe(true); expect(installCompletions("zsh", "xo-cli", configFile)).toBe(true);

View File

@@ -57,7 +57,9 @@ describe("settings command", () => {
{}, {},
); );
const persisted = JSON.parse(readFileSync(paths.walletConfigPath, "utf8")) as { const persisted = JSON.parse(
readFileSync(paths.walletConfigPath, "utf8"),
) as {
currency: string; currency: string;
"default-mnemonic"?: string; "default-mnemonic"?: string;
}; };

View File

@@ -103,7 +103,7 @@ const testCases: TestCase[] = [
inputs: ["export", p2pkhTemplateIdentifier], inputs: ["export", p2pkhTemplateIdentifier],
shouldThrow: false, shouldThrow: false,
expectedData: {}, expectedData: {},
logs: [{ out: "\"name\":\"Wallet (P2PKH)\"" }], logs: [{ out: '"name":"Wallet (P2PKH)"' }],
}, },
// Error cases - subcommand // Error cases - subcommand
{ {

View File

@@ -113,7 +113,9 @@ describe("mnemonic utilities", () => {
// Due to some weird MacOS behavior we need to use realpathSync to get the correct path // Due to some weird MacOS behavior we need to use realpathSync to get the correct path
// Basically, tmpDir() returns a symlink, but moving to that path puts us at `/private/${tmpDir()}` // Basically, tmpDir() returns a symlink, but moving to that path puts us at `/private/${tmpDir()}`
const expectedPath = realpathSync(path.join(tempDir, "mnemonic-relative")); const expectedPath = realpathSync(
path.join(tempDir, "mnemonic-relative"),
);
// Compare to the expected path // Compare to the expected path
expect(resolved).toBe(expectedPath); expect(resolved).toBe(expectedPath);

View File

@@ -159,7 +159,9 @@ export const createMockEngine = async (seed: string) => {
}; };
export const createMockAppService = async (engine: Engine) => { export const createMockAppService = async (engine: Engine) => {
const settings = new SettingsService(`${tmpdir()}/xo-cli-tests-settings.json`); const settings = new SettingsService(
`${tmpdir()}/xo-cli-tests-settings.json`,
);
settings.setCurrency("USD"); settings.setCurrency("USD");
const storage = await InMemoryStorage.create(); const storage = await InMemoryStorage.create();

View File

@@ -5,7 +5,10 @@ export class MockRatesService extends BaseRates {
super(); super();
} }
async getRate(numeratorUnitCode: string, denominatorUnitCode: string): Promise<number> { async getRate(
numeratorUnitCode: string,
denominatorUnitCode: string,
): Promise<number> {
return 1; return 1;
} }
@@ -20,4 +23,4 @@ export class MockRatesService extends BaseRates {
async listPairs(): Promise<Set<string>> { async listPairs(): Promise<Set<string>> {
return new Set(); return new Set();
} }
} }

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,11 @@
import { expect, test, describe, beforeEach, afterEach } from "vitest"; import { expect, test, describe, beforeEach, afterEach } from "vitest";
import { existsSync, mkdirSync, rmSync, writeFileSync, realpathSync } from "node:fs"; import {
existsSync,
mkdirSync,
rmSync,
writeFileSync,
realpathSync,
} from "node:fs";
import { homedir, tmpdir } from "node:os"; import { homedir, tmpdir } from "node:os";
import path from "node:path"; import path from "node:path";
@@ -130,7 +136,9 @@ describe("paths utilities", () => {
// Due to some weird MacOS behavior we need to use realpathSync to get the correct path // Due to some weird MacOS behavior we need to use realpathSync to get the correct path
// Basically, tmpDir() returns a symlink, but moving to that path puts us at `/private/${tmpDir()}` // Basically, tmpDir() returns a symlink, but moving to that path puts us at `/private/${tmpDir()}`
const expectedPath = realpathSync(path.join(tempDir, "mnemonic-cwd-test")); const expectedPath = realpathSync(
path.join(tempDir, "mnemonic-cwd-test"),
);
// Compare to the expected path // Compare to the expected path
expect(resolved).toBe(expectedPath); expect(resolved).toBe(expectedPath);

View File

@@ -21,7 +21,7 @@ describe("formatDialogMessageLines", () => {
test("breaks long dot-separated paths at segment boundaries", () => { test("breaks long dot-separated paths at segment boundaries", () => {
const line = const line =
"- actions.requestFungibleTokens.roles.receiver.requirements: Unrecognized key: \"generate\""; '- actions.requestFungibleTokens.roles.receiver.requirements: Unrecognized key: "generate"';
const lines = formatDialogMessageLines(line, 56); const lines = formatDialogMessageLines(line, 56);
expect(lines.length).toBeGreaterThan(1); expect(lines.length).toBeGreaterThan(1);