Breaking Change: Update to latest XO-Engine #2
@@ -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
|
||||
|
||||
@@ -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`).
|
||||
|
||||
|
||||
@@ -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)"`,
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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: [],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
@@ -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" },
|
||||
],
|
||||
};
|
||||
|
||||
@@ -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")}`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}`,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user