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
### Full Installation
```bash
# Create a new directory since we are going to be pulling in engine too
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.
#### Install for bash
```bash
npm run autocomplete:install:bash
```
#### Install for zsh
```bash
npm run autocomplete:install:zsh
```
#### Install for fish
```bash
npm run autocomplete:install:fish
```
### Run the CLI
```bash
# If globally installed (Not really usable if not globally installed)
xo-cli
```
### Run the TUI
```bash
# If globally installed
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.
| Path | Purpose |
| -------------------------- | ----------------------------------------------------------------------- |
| Path | Purpose |
| --------------------------- | ----------------------------------------------------------------------- |
| `$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/.wallet` | JSON settings (`default-mnemonic`, `currency`) |
@@ -41,13 +41,13 @@ npx tsx src/index.ts # TUI
### Environment variables
| Variable | Default |
| ------------------------- | ----------------------------------------- |
| `XO_CONFIG_DIR` | `~/.config/xo-cli` |
| `SYNC_SERVER_URL` | `http://localhost:3000` |
| `DB_PATH` | `$XO_CONFIG_DIR/data` |
| `DB_FILENAME` | `xo-wallet.db` |
| `INVITATION_STORAGE_PATH` | `$XO_CONFIG_DIR/data/xo-invitations.db` |
| Variable | Default |
| ------------------------- | --------------------------------------- |
| `XO_CONFIG_DIR` | `~/.config/xo-cli` |
| `SYNC_SERVER_URL` | `http://localhost:3000` |
| `DB_PATH` | `$XO_CONFIG_DIR/data` |
| `DB_FILENAME` | `xo-wallet.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.
@@ -88,13 +88,13 @@ xo-cli resource list
## Global Options (`xo-cli`)
| Flag | Description |
| ------------------------------ | --------------------------------------------------- |
| `-m`, `--mnemonic-file <file>` | Mnemonic file (basename, cwd-relative, or absolute) |
| Flag | Description |
| ------------------------------ | ---------------------------------------------------- |
| `-m`, `--mnemonic-file <file>` | Mnemonic file (basename, cwd-relative, or absolute) |
| `--currency <code>` | Fiat display currency (e.g. `USD`, `AUD`) |
| `-o`, `--output <filename>` | Output filename (used by `mnemonic create`/`import`) |
| `-v`, `--verbose` | Verbose output |
| `-h`, `--help` | Help |
| `-v`, `--verbose` | Verbose output |
| `-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`).

View File

@@ -19,12 +19,7 @@
* xo-cli completions fish --install
*/
import {
existsSync,
readFileSync,
appendFileSync,
mkdirSync,
} from "node:fs";
import { existsSync, readFileSync, appendFileSync, mkdirSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { homedir } from "node:os";
@@ -216,13 +211,15 @@ const shellConfigs: Record<
> = {
bash: {
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,
evalCommand: (binName) => `eval "$(${binName} completions bash)"`,
},
zsh: {
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,
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.
*/
const formatInvitationForFile = (invitation: XOInvitation, indent = 2): string =>
const formatInvitationForFile = (
invitation: XOInvitation,
indent = 2,
): string =>
JSON.stringify(JSON.parse(serializeInvitation(invitation)), null, indent);
/**
@@ -358,8 +361,7 @@ export const handleInvitationExportCommand = async (
}
const invitation = deps.app.invitations.find(
(candidate) =>
candidate.data.invitationIdentifier === invitationIdentifier,
(candidate) => candidate.data.invitationIdentifier === invitationIdentifier,
);
if (!invitation) {
@@ -499,7 +501,9 @@ export const handleInvitationCommand = async (
hasMissingRequirements(missingRequirements.templateRequirements) ||
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}`);
// If there are missing requirements, print them out

View File

@@ -37,7 +37,9 @@ function formatResource(
showReserved = false,
): string {
// Format the template
const template = resource.template ? dim(`[${generateTemplateIdentifier(resource.template)}]`) : "";
const template = resource.template
? dim(`[${generateTemplateIdentifier(resource.template)}]`)
: "";
// Format the outpoint
const outpoint = bold(

View File

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

View File

@@ -4,7 +4,10 @@ import { generateTemplateIdentifier } from "@xo-cash/engine";
import type { XOTemplate } from "@xo-cash/types";
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 type { CommandDependencies, CommandIO } from "./types.js";
import { CommandError } from "./types.js";

View File

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

View File

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

View File

@@ -95,7 +95,6 @@ export class HistoryService {
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
* 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) {
const invitationIdentifier = context.utxo.reservedBy;
if (invitationIdentifier && invitationContexts.has(invitationIdentifier)) {
if (
invitationIdentifier &&
invitationContexts.has(invitationIdentifier)
) {
const group = reservedUtxosByInvitation.get(invitationIdentifier) ?? [];
group.push(context);
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>();
for (const invitation of this.invitations) {
const templateIdentifier = invitation.data.templateIdentifier;
const template = templateIdentifier
? (await this.engine.getTemplate(templateIdentifier)) ?? null
? ((await this.engine.getTemplate(templateIdentifier)) ?? null)
: null;
contexts.set(invitation.data.invitationIdentifier, {
invitation,
@@ -181,9 +185,13 @@ export class HistoryService {
}
for (const templateIdentifier of templateIdentifiers) {
const scriptHashDataList = await this.engine.listScriptHashesForTemplate(templateIdentifier);
const scriptHashDataList =
await this.engine.listScriptHashesForTemplate(templateIdentifier);
for (const scriptHashData of scriptHashDataList) {
scriptHashDataByScriptHash.set(scriptHashData.scriptHash, scriptHashData);
scriptHashDataByScriptHash.set(
scriptHashData.scriptHash,
scriptHashData,
);
}
}
@@ -194,10 +202,12 @@ export class HistoryService {
utxo: UnspentOutputData,
metadataIndex: WalletMetadataIndex,
): Promise<UtxoContext> {
const scriptHashData = metadataIndex.scriptHashDataByScriptHash.get(utxo.scriptHash);
const scriptHashData = metadataIndex.scriptHashDataByScriptHash.get(
utxo.scriptHash,
);
const templateIdentifier = scriptHashData?.templateIdentifier;
const template = templateIdentifier
? (await this.engine.getTemplate(templateIdentifier)) ?? null
? ((await this.engine.getTemplate(templateIdentifier)) ?? null)
: null;
return {
@@ -213,8 +223,15 @@ export class HistoryService {
): WalletHistoryItem {
const invitation = context.invitation.data;
const entityRoles = this.deriveInvitationEntityRoles(context);
const inputs = this.projectInvitationInputs(context, reservedContexts, entityRoles);
const inputUtxoIds = this.listInvitationInputUtxoIds(context, reservedContexts);
const inputs = this.projectInvitationInputs(
context,
reservedContexts,
entityRoles,
);
const inputUtxoIds = this.listInvitationInputUtxoIds(
context,
reservedContexts,
);
const outputs = this.projectInvitationOutputs(
context,
reservedContexts,
@@ -263,7 +280,9 @@ export class HistoryService {
const outpointIndex = input.outpointIndex;
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.
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.
if (!matchingContext) continue;
const lockingBytecode = this.getOutputLockingBytecodeHex(output) ?? matchingContext.scriptHashData?.lockingBytecode;
const outputIdentifier = output.outputIdentifier ?? matchingContext.scriptHashData?.outputIdentifier;
const lockingBytecode =
this.getOutputLockingBytecodeHex(output) ??
matchingContext.scriptHashData?.lockingBytecode;
const outputIdentifier =
output.outputIdentifier ??
matchingContext.scriptHashData?.outputIdentifier;
const role =
output.roleIdentifier ??
this.getFirstEntityRole(entityRoles, commit.entityIdentifier) ??
matchingContext.scriptHashData?.roleIdentifier;
const valueSatoshis = output.valueSatoshis !== undefined
? BigInt(output.valueSatoshis)
: BigInt(matchingContext.utxo.valueSatoshis);
const valueSatoshis =
output.valueSatoshis !== undefined
? BigInt(output.valueSatoshis)
: BigInt(matchingContext.utxo.valueSatoshis);
usedUtxoIds.add(this.getUtxoId(matchingContext.utxo));
@@ -369,8 +393,11 @@ export class HistoryService {
const outpointIndex = input.outpointIndex;
if (txid === undefined || outpointIndex === undefined) continue;
const utxoContext = reservedByOutpoint.get(this.getOutpointKey(txid, outpointIndex));
if (utxoContext) invitationInputUtxoIds.add(this.getUtxoId(utxoContext.utxo));
const utxoContext = reservedByOutpoint.get(
this.getOutpointKey(txid, outpointIndex),
);
if (utxoContext)
invitationInputUtxoIds.add(this.getUtxoId(utxoContext.utxo));
}
}
@@ -390,9 +417,17 @@ export class HistoryService {
return reservedContexts.find((context) => {
if (usedUtxoIds.has(this.getUtxoId(context.utxo))) return false;
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;
});
}
@@ -423,7 +458,11 @@ export class HistoryService {
id: this.getUtxoId(context.utxo),
outputIdentifier,
role,
description: this.describeOutputFromTemplate(outputIdentifier, context.template, {}),
description: this.describeOutputFromTemplate(
outputIdentifier,
context.template,
{},
),
valueSatoshis: BigInt(context.utxo.valueSatoshis),
outpoint: {
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 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) {
rolesByEntity.set(entityIdentifier, new Set());
}
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 ?? []) {
if (input.roleIdentifier) roles.add(input.roleIdentifier);
}
@@ -459,9 +503,10 @@ export class HistoryService {
}
const action = context.template?.actions?.[invitation.actionIdentifier];
const participantRoles = action?.requirements?.participants
?.map((participant) => participant.role)
.filter((role): role is string => typeof role === "string") ?? [];
const participantRoles =
action?.requirements?.participants
?.map((participant) => participant.role)
.filter((role): role is string => typeof role === "string") ?? [];
const explicitlyFilledRoles = new Set<string>();
for (const roles of rolesByEntity.values()) {
for (const role of roles) explicitlyFilledRoles.add(role);
@@ -473,7 +518,10 @@ export class HistoryService {
.filter(([, roles]) => roles.size === 0)
.map(([entityIdentifier]) => entityIdentifier);
if (unfilledParticipantRoles.length === 1 && entitiesWithoutRoles.length >= 1) {
if (
unfilledParticipantRoles.length === 1 &&
entitiesWithoutRoles.length >= 1
) {
const inferredRole = unfilledParticipantRoles[0];
if (inferredRole !== undefined) {
for (const entityIdentifier of entitiesWithoutRoles) {
@@ -517,12 +565,21 @@ export class HistoryService {
inputs: WalletHistoryInput[],
outputs: WalletHistoryOutput[],
): bigint {
const inputTotal = inputs.reduce((total, input) => total + (input.valueSatoshis ?? 0n), 0n);
const outputTotal = outputs.reduce((total, output) => total + (output.valueSatoshis ?? 0n), 0n);
const inputTotal = inputs.reduce(
(total, input) => total + (input.valueSatoshis ?? 0n),
0n,
);
const outputTotal = outputs.reduce(
(total, output) => total + (output.valueSatoshis ?? 0n),
0n,
);
return inputTotal + outputTotal;
}
private describeInvitation(context: InvitationContext, role?: string): string {
private describeInvitation(
context: InvitationContext,
role?: string,
): string {
const invitation = context.invitation.data;
const template = context.template;
if (!template) return invitation.actionIdentifier;
@@ -544,14 +601,27 @@ export class HistoryService {
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";
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 {
return this.describeOutputFromTemplate(outputIdentifier, context.template, context.variables);
private describeOutput(
outputIdentifier: string | undefined,
context: InvitationContext,
): string {
return this.describeOutputFromTemplate(
outputIdentifier,
context.template,
context.variables,
);
}
private describeOutputFromTemplate(
@@ -561,7 +631,10 @@ export class HistoryService {
): string {
if (!outputIdentifier) return "Output";
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(
@@ -569,16 +642,25 @@ export class HistoryService {
variables: Record<string, XOInvitationVariableValue>,
): string {
try {
return compileCashAssemblyString({ cashAssemblyText: description, variables, evaluationDecodeMode: 'utf8' });
return compileCashAssemblyString({
cashAssemblyText: description,
variables,
evaluationDecodeMode: "utf8",
});
} catch {
return this.interpolateSimpleCashAssemblyVariables(description, variables);
return this.interpolateSimpleCashAssemblyVariables(
description,
variables,
);
}
}
private extractInvitationVariables(
invitation: XOInvitation,
): Record<string, XOInvitationVariableValue> {
const committedVariables = invitation.commits.flatMap((c) => c.data.variables ?? []);
const committedVariables = invitation.commits.flatMap(
(c) => c.data.variables ?? [],
);
return committedVariables.reduce(
(acc, variable) => {
if (!variable.variableIdentifier) return acc;
@@ -596,15 +678,21 @@ export class HistoryService {
: String(input.outpointTransactionHash);
}
private getOutputLockingBytecodeHex(output: XOInvitationOutput): string | undefined {
private getOutputLockingBytecodeHex(
output: XOInvitationOutput,
): string | undefined {
if (output.lockingBytecode === undefined) return undefined;
return typeof output.lockingBytecode === "string"
? output.lockingBytecode
: binToHex(output.lockingBytecode);
}
private async getScriptHashData(scriptHash: string): Promise<ScriptHashData | undefined> {
return (this.engine as unknown as { state: State }).state.getScriptHashData(scriptHash);
private async getScriptHashData(
scriptHash: string,
): Promise<ScriptHashData | undefined> {
return (this.engine as unknown as { state: State }).state.getScriptHashData(
scriptHash,
);
}
private getOutpointKey(txid: string, index: number): string {
@@ -627,7 +715,9 @@ export class HistoryService {
return text.replace(
/\$\(<([^>]+)>\)/g,
(match, variableIdentifier: string) => {
if (!Object.prototype.hasOwnProperty.call(variables, variableIdentifier)) {
if (
!Object.prototype.hasOwnProperty.call(variables, variableIdentifier)
) {
return match;
}
return String(variables[variableIdentifier]);

View File

@@ -3,7 +3,13 @@ import type {
Engine,
GetSpendableResourcesParameters,
} 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 {
XOInvitation,
XOInvitationCommit,
@@ -92,7 +98,9 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
}
// 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
const invitationInstance = new Invitation(engineInvitation, dependencies);
@@ -287,7 +295,9 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
return payload;
}
private unwrapLegacyInvitationUpdatedPayload(payload: unknown): unknown | null {
private unwrapLegacyInvitationUpdatedPayload(
payload: unknown,
): unknown | null {
if (
payload &&
typeof payload === "object" &&
@@ -308,7 +318,10 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
invitation: XOInvitation = this.data,
): Promise<void> {
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> {
let missingReqs;
try {
const missingRequirements = await this.engine.listMissingRequirements(this.data.invitationIdentifier);
const missingRequirements = await this.engine.listMissingRequirements(
this.data.invitationIdentifier,
);
missingReqs = missingRequirements.templateRequirements;
} catch {
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.
*/
private async updateStatus(): Promise<void> {
this.computeStatus().then(status => {
this.status = status;
this.emit("invitation-status-changed", status);
}).catch((error) => {
this.status = `error (${error instanceof Error ? error.message : String(error)})`;
this.emit("error", error instanceof Error ? error : new Error(String(error)));
});
this.computeStatus()
.then((status) => {
this.status = status;
this.emit("invitation-status-changed", status);
})
.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> {
// 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
this.publishInvitation(signedInvitation);
@@ -518,9 +540,12 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
* @returns The transaction hash returned by the network after broadcast.
*/
async broadcast(): Promise<string> {
const txHash = await this.engine.executeAction(this.data.invitationIdentifier, {
broadcastTransaction: true,
});
const txHash = await this.engine.executeAction(
this.data.invitationIdentifier,
{
broadcastTransaction: true,
},
);
await this.updateStatus();
@@ -538,7 +563,10 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
await this.ensureAccepted();
// 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
await this.publishInvitation(this.data);
@@ -617,8 +645,8 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
const templates = await this.engine.listImportedTemplates();
// For each template, we need to create a 2d array of all the outputs
const outputs = templates.map(template => {
return Object.keys(template.outputs).map(output => {
const outputs = templates.map((template) => {
return Object.keys(template.outputs).map((output) => {
const templateIdentifier = generateTemplateIdentifier(template);
return {
@@ -629,14 +657,18 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
});
// then, for each output, we need to get the spendable resources
const spendableResources = await Promise.all(outputs.flat().map(output => {
return this.engine.getSpendableResources(this.data, {
templateIdentifier: output.templateIdentifier,
outputIdentifier: output.outputIdentifier,
});
}));
const spendableResources = await Promise.all(
outputs.flat().map((output) => {
return this.engine.getSpendableResources(this.data, {
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
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)
const valueSatoshis = compileCashAssemblyString(
{ cashAssemblyText: String(valueSatoshisExpression), variables: formattedVariables, evaluationDecodeMode: 'bigint' },
);
const valueSatoshis = compileCashAssemblyString({
cashAssemblyText: String(valueSatoshisExpression),
variables: formattedVariables,
evaluationDecodeMode: "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
@@ -796,7 +830,7 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
for (const output of outputs) {
if (typeof output === "string") {
const sats = await this.getSatsOut(output);
totalSats += sats
totalSats += sats;
} else {
const sats = await this.getSatsOut(output.output);
totalSats += sats;

View File

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

View File

@@ -168,7 +168,9 @@ export class SettingsService extends EventEmitter<SettingsServiceEventMap> {
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) {
normalized["default-mnemonic"] = maybeMnemonic.trim();
}

View File

@@ -1,4 +1,4 @@
import type { XOTemplate } from '@xo-cash/types';
import type { XOTemplate } from "@xo-cash/types";
/**
* Vending machine payment template.
@@ -7,271 +7,277 @@ import type { XOTemplate } from '@xo-cash/types';
* customer funds and signs the composable transaction.
*/
export const vendingMachineTemplate: XOTemplate = {
$schema: 'https://libauth.org/schemas/wallet-template-v0.schema.json',
name: 'Vending Machine',
description: 'Purchase items from a vending machine with an itemized receipt.',
icon: 'wallet',
version: '1',
supported: ['BCH_2023_05', 'BCH_2024_05', 'BCH_2025_05', 'BCH_2026_05'],
$schema: "https://libauth.org/schemas/wallet-template-v0.schema.json",
name: "Vending Machine",
description:
"Purchase items from a vending machine with an itemized receipt.",
icon: "wallet",
version: "1",
supported: ["BCH_2023_05", "BCH_2024_05", "BCH_2025_05", "BCH_2026_05"],
defaults: {
change: {
output: 'changeOutput',
role: 'merchant',
generate: ['merchantKey'],
},
defaults: {
change: {
output: "changeOutput",
role: "merchant",
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: {
name: 'Merchant',
description: 'The vending machine operator receiving payment.',
icon: 'owner',
name: "Sell Items",
description: "Receive payment for $(<receiptSummary>)",
icon: "request",
requirements: {
secrets: ["merchantKey"],
variables: [
"totalSatoshis",
"orderId",
"merchantName",
"receiptSummary",
"lineItemsJson",
],
},
},
customer: {
name: 'Customer',
description: 'The customer paying for items.',
icon: 'sender',
name: "Pay",
description: "Pay $(<totalSatoshis>) sats for $(<receiptSummary>)",
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',
generate: ['merchantKey'],
},
],
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: {},
},
role: "merchant",
values: {
generated: {
merchantKey:
"KyRQa5pEXuzVcDwnXRLpYAascjchQW5DoxVRMbj4DTxS83573mz8",
},
requirements: {
participants: [
{ role: 'merchant', slots: { min: 1, max: 1 } },
{ role: 'customer', slots: { min: 1 } },
],
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}]',
},
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',
},
},
secrets: {},
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: [
{
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: [],
},
},
outputs: [
{
lockingBytecode:
"76a91475c715ecb74178fe87933e57e947e5e92d904b8188ac",
valueSatoshis: 3500,
},
],
},
},
],
{
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 = {
$schema: 'https://libauth.org/schemas/wallet-template-v0.schema.json',
$schema: "https://libauth.org/schemas/wallet-template-v0.schema.json",
name: 'Wrapped BCH',
description: 'Convert between BCH and wBCH tokens.',
icon: 'wrap',
name: "Wrapped BCH",
description: "Convert between BCH and wBCH tokens.",
icon: "wrap",
version: '1',
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"],
roles: {
user: {
name: 'User',
description: 'The person wrapping or unwrapping BCH.',
icon: 'user',
},
},
roles: {
user: {
name: "User",
description: "The person wrapping or unwrapping BCH.",
icon: "user",
},
},
start: [
{
action: 'wrap',
role: 'user',
},
{
action: 'unwrap',
role: 'user',
},
],
start: [
{
action: "wrap",
role: "user",
},
{
action: "unwrap",
role: "user",
},
],
actions: {
wrap: {
name: 'Wrap BCH',
description: 'Convert BCH into wBCH tokens.',
icon: 'wrap',
actions: {
wrap: {
name: "Wrap BCH",
description: "Convert BCH into wBCH tokens.",
icon: "wrap",
roles: {
user: {
requirements: {
variables: ['amountToWrap', 'recipientLockingScript'],
},
},
},
roles: {
user: {
requirements: {
variables: ["amountToWrap", "recipientLockingScript"],
},
},
},
requirements: {
participants: [{ role: 'user', slots: { min: 1, max: 1 } }],
},
requirements: {
participants: [{ role: "user", slots: { min: 1, max: 1 } }],
},
transaction: 'wrapTransaction',
},
transaction: "wrapTransaction",
},
unwrap: {
name: 'Unwrap wBCH',
description: 'Convert wBCH tokens back into BCH.',
icon: 'unwrap',
unwrap: {
name: "Unwrap wBCH",
description: "Convert wBCH tokens back into BCH.",
icon: "unwrap",
roles: {
user: {
requirements: {
variables: ['amountToUnwrap', 'recipientLockingScript'],
},
},
},
roles: {
user: {
requirements: {
variables: ["amountToUnwrap", "recipientLockingScript"],
},
},
},
requirements: {
participants: [{ role: 'user', slots: { min: 1, max: 1 } }],
},
requirements: {
participants: [{ role: "user", slots: { min: 1, max: 1 } }],
},
transaction: 'unwrapTransaction',
},
},
transaction: "unwrapTransaction",
},
},
transactions: {
wrapTransaction: {
name: 'Wrapped BCH',
description: 'Wrapped $(<amountToWrap> <satoshisPerBCH> OP_DIV).$(<amountToWrap> <satoshisPerBCH> OP_MOD) BCH into wBCH tokens.',
icon: 'wrap',
transactions: {
wrapTransaction: {
name: "Wrapped BCH",
description:
"Wrapped $(<amountToWrap> <satoshisPerBCH> OP_DIV).$(<amountToWrap> <satoshisPerBCH> OP_MOD) BCH into wBCH tokens.",
icon: "wrap",
inputs: [
{ input: 'covenantInput', inputIndex: 0 },
],
outputs: [
{ output: 'covenantOutput', outputIndex: 0 },
{ output: 'wrappedTokensOutput', outputIndex: undefined },
],
inputs: [{ input: "covenantInput", inputIndex: 0 }],
outputs: [
{ output: "covenantOutput", outputIndex: 0 },
{ output: "wrappedTokensOutput", outputIndex: undefined },
],
version: 2,
locktime: 0,
composable: true,
},
version: 2,
locktime: 0,
composable: true,
},
unwrapTransaction: {
name: 'Unwrapped wBCH',
description: 'Unwrapped $(<amountToUnwrap> <satoshisPerBCH> OP_DIV).$(<amountToUnwrap> <satoshisPerBCH> OP_MOD) wBCH tokens back into BCH.',
icon: 'unwrap',
unwrapTransaction: {
name: "Unwrapped wBCH",
description:
"Unwrapped $(<amountToUnwrap> <satoshisPerBCH> OP_DIV).$(<amountToUnwrap> <satoshisPerBCH> OP_MOD) wBCH tokens back into BCH.",
icon: "unwrap",
inputs: [
{ input: 'covenantInput', inputIndex: 0 },
],
outputs: [
{ output: 'covenantOutput', outputIndex: 0 },
{ output: 'unwrappedSatoshisOutput', outputIndex: undefined },
],
inputs: [{ input: "covenantInput", inputIndex: 0 }],
outputs: [
{ output: "covenantOutput", outputIndex: 0 },
{ output: "unwrappedSatoshisOutput", outputIndex: undefined },
],
version: 2,
locktime: 0,
composable: true,
},
},
version: 2,
locktime: 0,
composable: true,
},
},
outputs: {
covenantOutput: {
name: 'wBCH Covenant',
description: 'Holds BCH and wBCH tokens that can be freely converted.',
icon: 'contract',
outputs: {
covenantOutput: {
name: "wBCH Covenant",
description: "Holds BCH and wBCH tokens that can be freely converted.",
icon: "contract",
lockingScript: 'wrapBCHLockingScript',
},
lockingScript: "wrapBCHLockingScript",
},
wrappedTokensOutput: {
name: 'Wrapped wBCH',
description: 'Wrapped $(<amountToWrap> <satoshisPerBCH> OP_DIV).$(<amountToWrap> <satoshisPerBCH> OP_MOD) wBCH tokens.',
icon: 'receive',
wrappedTokensOutput: {
name: "Wrapped wBCH",
description:
"Wrapped $(<amountToWrap> <satoshisPerBCH> OP_DIV).$(<amountToWrap> <satoshisPerBCH> OP_MOD) wBCH tokens.",
icon: "receive",
valueSatoshis: '$(<amountToWrap>)',
token: {
category: '$(<wbchTokenCategory>)',
amount: '$(<amountToWrap>)',
nft: null,
},
valueSatoshis: "$(<amountToWrap>)",
token: {
category: "$(<wbchTokenCategory>)",
amount: "$(<amountToWrap>)",
nft: null,
},
roles: {
user: {
balance: {
satoshis: true,
fungibleTokens: true,
nonfungibleTokens: true,
},
selectable: true,
},
},
roles: {
user: {
balance: {
satoshis: true,
fungibleTokens: true,
nonfungibleTokens: true,
},
selectable: true,
},
},
lockingScript: '$(<recipientLockingScript>)',
},
lockingScript: "$(<recipientLockingScript>)",
},
unwrappedSatoshisOutput: {
name: 'Unwrapped BCH',
description: 'Unwrapped $(<amountToUnwrap> <satoshisPerBCH> OP_DIV).$(<amountToUnwrap> <satoshisPerBCH> OP_MOD) BCH.',
icon: 'receive',
unwrappedSatoshisOutput: {
name: "Unwrapped BCH",
description:
"Unwrapped $(<amountToUnwrap> <satoshisPerBCH> OP_DIV).$(<amountToUnwrap> <satoshisPerBCH> OP_MOD) BCH.",
icon: "receive",
valueSatoshis: '$(<amountToUnwrap>)',
token: null,
valueSatoshis: "$(<amountToUnwrap>)",
token: null,
roles: {
user: {
balance: {
satoshis: true,
fungibleTokens: true,
nonfungibleTokens: true,
},
selectable: true,
},
},
roles: {
user: {
balance: {
satoshis: true,
fungibleTokens: true,
nonfungibleTokens: true,
},
selectable: true,
},
},
lockingScript: '$(<recipientLockingScript>)',
},
},
lockingScript: "$(<recipientLockingScript>)",
},
},
inputs: {
covenantInput: {
name: 'wBCH Covenant',
description: 'The covenant being updated.',
icon: 'contract',
inputs: {
covenantInput: {
name: "wBCH Covenant",
description: "The covenant being updated.",
icon: "contract",
unlockingScript: 'unlockCovenant',
},
},
unlockingScript: "unlockCovenant",
},
},
lockingScripts: {
wrapBCHLockingScript: {
name: 'wBCH Covenant',
description: 'Holds BCH and wBCH tokens that can be freely converted.',
icon: 'contract',
lockingScripts: {
wrapBCHLockingScript: {
name: "wBCH Covenant",
description: "Holds BCH and wBCH tokens that can be freely converted.",
icon: "contract",
lockingType: 'p2sh',
lockingBytecode: 'wrapBCHLockingBytecode',
lockingType: "p2sh",
lockingBytecode: "wrapBCHLockingBytecode",
actions: [
{ action: 'wrap', role: 'user' },
{ action: 'unwrap', role: 'user' },
],
actions: [
{ action: "wrap", role: "user" },
{ action: "unwrap", role: "user" },
],
state: {
variables: [],
secrets: [],
},
balance: {
satoshis: 0n,
fungibleTokens: 0n,
},
selectable: false,
},
},
state: {
variables: [],
secrets: [],
},
balance: {
satoshis: 0n,
fungibleTokens: 0n,
},
selectable: false,
},
},
scripts: {
enforceCovenantPersists: 'OP_INPUTINDEX OP_DUP OP_OUTPUTBYTECODE OP_SWAP OP_UTXOBYTECODE 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',
scripts: {
enforceCovenantPersists:
"OP_INPUTINDEX OP_DUP OP_OUTPUTBYTECODE OP_SWAP OP_UTXOBYTECODE 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
// because those are evaluated at compile time without transaction context.
wrapBCHLockingBytecode: 'enforceCovenantPersists enforceTokenCategoryPreserved enforceValueTokenSumConserved',
unlockCovenant: '',
},
// Direct script references — introspection opcodes must not use $(...) evaluations
// because those are evaluated at compile time without transaction context.
wrapBCHLockingBytecode:
"enforceCovenantPersists enforceTokenCategoryPreserved enforceValueTokenSumConserved",
unlockCovenant: "",
},
constants: {
wbchTokenCategory: {
name: 'wBCH Token Category',
description: 'The official token category for Wrapped BCH.',
type: 'bytes',
value: 'ff4d6e4b90aa8158d39c5dc874fd9411af1ac3b5ed6f354755e8362a0d02c6b3',
},
satoshisPerBCH: {
name: 'Satoshis per BCH',
description: 'Used to display amounts in BCH with decimals.',
type: 'integer',
value: 100000000,
},
tokenDust: {
name: 'Token Dust Limit',
description: 'Minimal satoshis required for a token-bearing output.',
type: 'integer',
value: 1000,
},
},
constants: {
wbchTokenCategory: {
name: "wBCH Token Category",
description: "The official token category for Wrapped BCH.",
type: "bytes",
value: "ff4d6e4b90aa8158d39c5dc874fd9411af1ac3b5ed6f354755e8362a0d02c6b3",
},
satoshisPerBCH: {
name: "Satoshis per BCH",
description: "Used to display amounts in BCH with decimals.",
type: "integer",
value: 100000000,
},
tokenDust: {
name: "Token Dust Limit",
description: "Minimal satoshis required for a token-bearing output.",
type: "integer",
value: 1000,
},
},
variables: {
amountToWrap: {
name: 'Amount to Wrap',
description: 'How much BCH to convert to wBCH (in satoshis).',
type: 'integer',
hint: 'satoshis',
},
amountToUnwrap: {
name: 'Amount to Unwrap',
description: 'How much wBCH to convert back to BCH (in satoshis).',
type: 'integer',
hint: 'satoshis',
},
recipientLockingScript: {
name: 'Destination',
description: 'Where to receive your BCH or wBCH tokens.',
type: 'bytes',
hint: 'lockingScript',
},
},
variables: {
amountToWrap: {
name: "Amount to Wrap",
description: "How much BCH to convert to wBCH (in satoshis).",
type: "integer",
hint: "satoshis",
},
amountToUnwrap: {
name: "Amount to Unwrap",
description: "How much wBCH to convert back to BCH (in satoshis).",
type: "integer",
hint: "satoshis",
},
recipientLockingScript: {
name: "Destination",
description: "Where to receive your BCH or wBCH tokens.",
type: "bytes",
hint: "lockingScript",
},
},
icons: [
{ name: 'wrap', hash: '0000000000000000000000' },
{ name: 'unwrap', hash: '0000000000000000000000' },
{ name: 'user', hash: '0000000000000000000000' },
{ name: 'contract', hash: '0000000000000000000000' },
{ name: 'receive', hash: '0000000000000000000000' },
],
icons: [
{ name: "wrap", hash: "0000000000000000000000" },
{ name: "unwrap", hash: "0000000000000000000000" },
{ name: "user", hash: "0000000000000000000000" },
{ name: "contract", 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.
const clipboardMethods = {
pbCopy: {
platform: (platform: string) => platform === 'darwin',
command: async (text: string) => execAsync(`printf '%s' '${text}' | pbcopy`),
platform: (platform: string) => platform === "darwin",
command: async (text: string) =>
execAsync(`printf '%s' '${text}' | pbcopy`),
},
xclip: {
platform: (platform: string) => platform === 'linux',
command: async (text: string) => execAsync(`printf '%s' '${text}' | xclip -selection clipboard`),
platform: (platform: string) => platform === "linux",
command: async (text: string) =>
execAsync(`printf '%s' '${text}' | xclip -selection clipboard`),
},
xsel: {
platform: (platform: string) => platform === 'linux',
command: async (text: string) => execAsync(`printf '%s' '${text}' | xsel --clipboard --input`),
platform: (platform: string) => platform === "linux",
command: async (text: string) =>
execAsync(`printf '%s' '${text}' | xsel --clipboard --input`),
},
ssh: {
platform: (platform: string) => platform === 'linux',
command: async (text: string) => process.stdout.write(`\x1b]52;c;${Buffer.from(text, 'utf-8').toString('base64')}\x07`),
platform: (platform: string) => platform === "linux",
command: async (text: string) =>
process.stdout.write(
`\x1b]52;c;${Buffer.from(text, "utf-8").toString("base64")}\x07`,
),
},
clip: {
platform: (platform: string) => platform === 'windows',
platform: (platform: string) => platform === "windows",
command: async (text: string) => execAsync(`echo|set /p="${text}" | clip`),
},
clipboardy: {
platform: (platform: string) => platform === 'windows',
platform: (platform: string) => platform === "windows",
command: async (text: string) => clipboardy.writeSync(text),
},
}
};
/**
* 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
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[] = [];
@@ -63,7 +71,7 @@ export async function copyToClipboard(text: string): Promise<void> {
continue;
}
return;
} catch(error) {
} catch (error) {
if (error instanceof Error) {
errors.push(error);
}
@@ -71,5 +79,7 @@ export async function copyToClipboard(text: string): Promise<void> {
}
// 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],
};
} catch (error) {
const message =
error instanceof Error ? error.message : String(error);
const message = error instanceof Error ? error.message : String(error);
return {
entries: [],
error: `Unable to read directory: ${message}`,

View File

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

View File

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

View File

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

View File

@@ -11,7 +11,8 @@ import { basename, isAbsolute, join, resolve } from "node:path";
* Base config directory. Created on first access.
*/
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 });
return dir;
}

View File

@@ -6,7 +6,9 @@
* Returns true when `value` looks like an XOTemplate object (pre-schema check).
* 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)) {
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
@@ -44,14 +44,15 @@ export abstract class BaseRates<
BCH: 8,
USD: 2,
};
const minimumFractionDigits = minimumFractionDigitsMap[normalizedCurrency] ?? 2;
const minimumFractionDigits =
minimumFractionDigitsMap[normalizedCurrency] ?? 2;
const maximumFractionDigits = Math.max(minimumFractionDigits, 8);
try {
const formatter = new Intl.NumberFormat('en-US', {
style: 'currency',
const formatter = new Intl.NumberFormat("en-US", {
style: "currency",
currency: normalizedCurrency,
currencyDisplay: 'narrowSymbol',
currencyDisplay: "narrowSymbol",
minimumFractionDigits,
maximumFractionDigits,
});
@@ -61,7 +62,7 @@ export abstract class BaseRates<
// 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.
// 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,
maximumFractionDigits,
});

View File

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

View File

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

View File

@@ -9,7 +9,8 @@ export type UnspentOutputMetadata = {
outputIdentifier?: string;
};
export type UnspentOutputWithMetadata = UnspentOutputData & UnspentOutputMetadata;
export type UnspentOutputWithMetadata = UnspentOutputData &
UnspentOutputMetadata;
/**
* 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", () => {
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).not.toContain("(__xo_cli_complete_dynamic mnemonics)");
});
@@ -68,9 +68,9 @@ describe("shell completions", () => {
const contents = readFileSync(configFile, "utf8");
expect(contents.match(/XO_CONFIG_DIR/g)).toHaveLength(2);
expect(contents.match(/eval "\$\(xo-cli completions bash\)"/g)).toHaveLength(
1,
);
expect(
contents.match(/eval "\$\(xo-cli completions bash\)"/g),
).toHaveLength(1);
});
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);
const contents = readFileSync(configFile, "utf8");
expect(contents.match(/eval "\$\(xo-cli completions bash\)"/g)).toHaveLength(
1,
);
expect(
contents.match(/eval "\$\(xo-cli completions bash\)"/g),
).toHaveLength(1);
expect(contents).toContain(
'export XO_CONFIG_DIR="${XO_CONFIG_DIR:-$HOME/.config/xo-cli}"',
);
});
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);

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;
"default-mnemonic"?: string;
};

View File

@@ -103,7 +103,7 @@ const testCases: TestCase[] = [
inputs: ["export", p2pkhTemplateIdentifier],
shouldThrow: false,
expectedData: {},
logs: [{ out: "\"name\":\"Wallet (P2PKH)\"" }],
logs: [{ out: '"name":"Wallet (P2PKH)"' }],
},
// 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
// 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
expect(resolved).toBe(expectedPath);

View File

@@ -159,7 +159,9 @@ export const createMockEngine = async (seed: string) => {
};
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");
const storage = await InMemoryStorage.create();

View File

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

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 { existsSync, mkdirSync, rmSync, writeFileSync, realpathSync } from "node:fs";
import {
existsSync,
mkdirSync,
rmSync,
writeFileSync,
realpathSync,
} from "node:fs";
import { homedir, tmpdir } from "node:os";
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
// 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
expect(resolved).toBe(expectedPath);

View File

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