The tests work

This commit is contained in:
2026-09-07 10:56:08 +00:00
parent f01f9bc56b
commit b7bedc5476
19 changed files with 1855 additions and 1559 deletions
+56 -39
View File
@@ -14,11 +14,11 @@
"@electrum-cash/protocol": "^2.3.1",
"@generalprotocols/oracle-client": "^0.0.1-development.11945476152",
"@xo-cash/crypto": "^0.0.2",
"@xo-cash/engine": "file:../engine",
"@xo-cash/engine": "^0.0.2-cli.20260907103534754",
"@xo-cash/state": "^0.0.3",
"@xo-cash/templates": "^0.0.3",
"@xo-cash/types": "^0.0.5",
"@xo-cash/utils": "^0.0.5-test.20260831095544025",
"@xo-cash/utils": "^0.0.6-cli.20260907041319224",
"better-sqlite3": "^12.6.2",
"clipboardy": "^5.1.0",
"ink": "^6.6.0",
@@ -43,38 +43,6 @@
"vitest": "^4.1.2"
}
},
"../engine": {
"name": "@xo-cash/engine",
"version": "0.0.2",
"license": "MIT",
"dependencies": {
"@bitauth/libauth": "^3.1.0-next.8",
"@electrum-cash/application": "^0.2.3-development.13447192992",
"@electrum-cash/network": "^4.2.2",
"@electrum-cash/protocol": "^2.3.1",
"@electrum-cash/servers": "^3.1.0",
"@xo-cash/crypto": "0.0.3",
"@xo-cash/primitives": "0.0.2",
"@xo-cash/state": "0.0.3",
"@xo-cash/templates": "0.0.3",
"@xo-cash/types": "0.0.5",
"@xo-cash/utils": "0.0.4",
"eventemitter3": "^5.0.1"
},
"devDependencies": {
"@generalprotocols/cspell-dictionary": "^1.0.1",
"@vitest/coverage-v8": "^4.0.17",
"@viz-kit/esbuild-analyzer": "^1.0.0",
"@xo-cash/eslint-config": "1.0.1",
"cspell": "^9.6.0",
"prettier": "^3.6.2",
"tsdown": "^0.20.0-beta.4",
"typedoc": "^0.28.16",
"typedoc-plugin-coverage": "^4.0.2",
"typescript": "^5.3.2",
"vitest": "^4.0.17"
}
},
"node_modules/@alcalzone/ansi-tokenize": {
"version": "0.2.4",
"resolved": "https://registry.npmjs.org/@alcalzone/ansi-tokenize/-/ansi-tokenize-0.2.4.tgz",
@@ -1198,8 +1166,57 @@
}
},
"node_modules/@xo-cash/engine": {
"resolved": "../engine",
"link": true
"version": "0.0.2-cli.20260907103534754",
"resolved": "https://verdaccio.harvmaster.com/@xo-cash/engine/-/engine-0.0.2-cli.20260907103534754.tgz",
"integrity": "sha512-HcR5uECJ1SRLZaCfzCsSGTUMmeU4v7dMVgiLhxdbBLUFEUYKEax5bX1BuKrfaqp7+FlN772skK/rAt6TzuKFfQ==",
"license": "MIT",
"dependencies": {
"@bitauth/libauth": "^3.1.0-next.8",
"@electrum-cash/application": "^0.2.3-development.13447192992",
"@electrum-cash/network": "^4.2.2",
"@electrum-cash/protocol": "^2.3.1",
"@electrum-cash/servers": "^3.1.0",
"@xo-cash/crypto": "0.0.3",
"@xo-cash/primitives": "0.0.2",
"@xo-cash/state": "0.0.3",
"@xo-cash/templates": "0.0.3",
"@xo-cash/types": "0.0.5",
"@xo-cash/utils": "^0.0.6-cli.20260907035204932",
"eventemitter3": "^5.0.1"
}
},
"node_modules/@xo-cash/engine/node_modules/@xo-cash/crypto": {
"version": "0.0.3",
"resolved": "https://verdaccio.harvmaster.com/@xo-cash/crypto/-/crypto-0.0.3.tgz",
"integrity": "sha512-f4OXZQ5psIaDOgfAVRCtl8IeMXbdXvtLOft42ux+SUGRnrvZ40Yf//XaLshrzzrG0s/3Co1ijO7fC/nQYvNcjA==",
"license": "MIT",
"dependencies": {
"@bitauth/libauth": "^3.1.0-next.8",
"@xo-cash/primitives": "0.0.2",
"@xo-cash/types": "0.0.5",
"@xo-cash/utils": "0.0.4"
}
},
"node_modules/@xo-cash/engine/node_modules/@xo-cash/utils": {
"version": "0.0.4",
"resolved": "https://verdaccio.harvmaster.com/@xo-cash/utils/-/utils-0.0.4.tgz",
"integrity": "sha512-QsVXnf9/z5+5ywMx5BPVmREuE8OCWIKwmQMM6osu2kVJQJplqZdYy+hGRnOwPGlrrvmWr61BU9u7dBIrDwDDnw==",
"license": "MIT",
"dependencies": {
"@bitauth/libauth": "^3.1.0-next.8",
"@xo-cash/primitives": "0.0.2",
"@xo-cash/types": "0.0.4",
"zod": "^4.3.6"
}
},
"node_modules/@xo-cash/engine/node_modules/@xo-cash/utils/node_modules/@xo-cash/types": {
"version": "0.0.4",
"resolved": "https://verdaccio.harvmaster.com/@xo-cash/types/-/types-0.0.4.tgz",
"integrity": "sha512-XBB/jNC1orDcMUi5Q0g+fnPXdKdb+n7OyqmJ8pxFrfM0y9OAdLjfddMih/JWfOWQtBkvY0CtdaZapgBSrvLeSw==",
"license": "MIT",
"dependencies": {
"@bitauth/libauth": "^3.1.0-next.8"
}
},
"node_modules/@xo-cash/primitives": {
"version": "0.0.2",
@@ -1283,9 +1300,9 @@
}
},
"node_modules/@xo-cash/utils": {
"version": "0.0.5-test.20260831095544025",
"resolved": "https://verdaccio.harvmaster.com/@xo-cash/utils/-/utils-0.0.5-test.20260831095544025.tgz",
"integrity": "sha512-XWiG8tPPmXy/zrDYUjM1fILL7STaV0THE4xoj6PosuSgh1mUQNdr71Wanbhkou/Pc4HbOcT+KO+DbLz0dGMYRw==",
"version": "0.0.6-clio.20260907035202101",
"resolved": "https://verdaccio.harvmaster.com/@xo-cash/utils/-/utils-0.0.6-clio.20260907035202101.tgz",
"integrity": "sha512-DBou0RivCxg4PvyC6eWpFWXsxh2gTSD7BPeYIToqkdjweVzszvsWIUJ8tb27MxL56fW6CbGhDXyElciXEqbQmg==",
"license": "MIT",
"dependencies": {
"@bitauth/libauth": "^3.1.0-next.8",
+3 -6
View File
@@ -15,7 +15,7 @@
"build:unsafe": "tsc --nocheck --noEmitOnError false || true && npm run build:copy-scripts",
"syntax": "tsc --noEmit",
"start": "SYNC_SERVER_URL=https://v2.sync.xo.harvmaster.com node dist/index.js",
"test": "vitest --run --passWithNoTests",
"test": "vitest --run --passWithNoTests --test-timeout=15000",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage --passWithNoTests",
"format": "prettier --write \"**/*.{js,ts,md,json}\" --ignore-path .gitignore",
@@ -31,9 +31,6 @@
"cli",
"tui"
],
"overrides": {
"@xo-cash/utils": "^0.0.5-test.20260831095544025"
},
"author": "General Protocols",
"license": "ISC",
"description": "XO Wallet CLI - Terminal User Interface for XO crypto wallet",
@@ -43,11 +40,11 @@
"@electrum-cash/protocol": "^2.3.1",
"@generalprotocols/oracle-client": "^0.0.1-development.11945476152",
"@xo-cash/crypto": "^0.0.2",
"@xo-cash/engine": "file:../engine",
"@xo-cash/engine": "^0.0.2-cli.20260907103534754",
"@xo-cash/state": "^0.0.3",
"@xo-cash/templates": "^0.0.3",
"@xo-cash/types": "^0.0.5",
"@xo-cash/utils": "^0.0.5-test.20260831095544025",
"@xo-cash/utils": "^0.0.6-cli.20260907041319224",
"better-sqlite3": "^12.6.2",
"clipboardy": "^5.1.0",
"ink": "^6.6.0",
+2 -1
View File
@@ -13,6 +13,7 @@ import { createStorageAdapter, State, StorageType } from "@xo-cash/state";
import { convertMnemonicToSeedBytes } from "@xo-cash/crypto";
import { binToHex, hash256 } from "@bitauth/libauth";
import { createHash } from "crypto";
import { InMemoryBlockchainProvider } from "../../utils/blockchain/in-memory-blockchain-provider.js";
/**
* Options for creating an offline engine.
@@ -64,7 +65,7 @@ export async function createOfflineEngine(
const state = new State(storageAdapter);
// Create a minimal blockchain monitor (no electrum initialization)
const blockchainMonitor = new BlockchainMonitor(state);
const blockchainMonitor = new BlockchainMonitor(state, new InMemoryBlockchainProvider());
// Engine constructor is private; bypass for offline read-only completions.
type EngineConstructor = new (
+23 -11
View File
@@ -382,10 +382,19 @@ export const handleInvitationCommand = async (
const template = await resolveTemplate(deps, templateQuery);
const templateIdentifier = generateTemplateIdentifier(template);
// Read the variables that were passed in via `-var-<name> <value>`
// TODO: Move this to markers: InvVarHere
const variables = parseVariablesFromOptions(options);
deps.io.verbose(`Variables: ${formatObject(variables)}`);
// if (variables.length > 0) {
// // await invitationInstance.addVariables(variables);
// }
// Create an XOInvitation. We will convert this into our own invitation instance afterwards
const rawInvitation = await deps.app.engine.createInvitation({
templateIdentifier,
actionIdentifier,
variables,
});
deps.io.verbose(`XOInvitation created: ${formatObject(rawInvitation)}`);
@@ -395,13 +404,6 @@ export const handleInvitationCommand = async (
`Invitation instance created: ${formatObject(invitationInstance.data)}`,
);
// Read the variables that were passed in via `-var-<name> <value>`
const variables = parseVariablesFromOptions(options);
deps.io.verbose(`Variables: ${formatObject(variables)}`);
if (variables.length > 0) {
await invitationInstance.addVariables(variables);
}
// Build the parameters for the append call. This will resolve the inputs and outputs for the invitation.
const params = await buildAppendParams(deps, invitationInstance, options);
if (!params) {
@@ -411,6 +413,8 @@ export const handleInvitationCommand = async (
);
}
// TOOD MARKER: InvVarHere
// Append the inputs and outputs to the invitation
const { inputs, outputs } = params;
deps.io.verbose(`Inputs: ${formatObject(inputs)}`);
@@ -419,10 +423,18 @@ export const handleInvitationCommand = async (
await invitationInstance.append({ inputs, outputs });
}
if (inputs.length > 0) {
const feeAwareChange = await invitationInstance.addFeeAwareChange();
deps.io.out(
`Miner fee: ${feeAwareChange.feeSatoshis} satoshis; change: ${feeAwareChange.changeAmountSatoshis} satoshis`,
);
try {
const feeAwareChange = await invitationInstance.addFeeAwareChange();
deps.io.out(
`Miner fee: ${feeAwareChange.feeSatoshis} satoshis; change: ${feeAwareChange.changeAmountSatoshis} satoshis`,
);
} catch(error) {
deps.io.err(`Failed to add fee-aware change: ${error instanceof Error ? error.message : "unknown error"}`);
throw new CommandError(
"invitation.create.add_fee_aware_change_failed",
`Failed to add fee-aware change: ${error instanceof Error ? error.message : "unknown error"}`,
);
}
}
// Write the invitation to a file in the working directory
+5 -5
View File
@@ -227,13 +227,13 @@ export const handleResourceCommand = async (
}
// Unreserve the resources
await deps.app.engine.unreserveResources(
[{ outpointTransactionHash: hexToBin(txHash), outpointIndex: vout }],
target.reservedBy,
);
await deps.app.engine.archiveInvitation(target.reservedBy);
// TODO: This should ideally not archive the invitation, but instead just release the resource so the user can add a different resource to the same invitation.
// Maybe make this method only available on invitations that haven't been synced yet? That way the user has some time to undo it without scrapping the whole invitation,
// but is still safe from the other participants from spending the resource?
deps.io.out(
`Unreserved ${bold(`${txHash}:${vout}`)} (was reserved for ${target.reservedBy})`,
`Archived invitation ${bold(target.reservedBy)} and released its resources`,
);
// TODO: What do I want to return here?
+4 -4
View File
@@ -1,6 +1,6 @@
import { existsSync, writeFileSync } from "fs";
import path from "path";
import { generateTemplateIdentifier } from "@xo-cash/engine";
import { generateTemplateIdentifier, parseTemplate } from "@xo-cash/engine";
import type { XOTemplate } from "@xo-cash/types";
import { bold, dim, formatObject } from "../utils.js";
@@ -529,11 +529,11 @@ export const handleTemplateCommand = async (
);
// Set the default locking parameters
await deps.app.engine.setDefaultLockingParameters(
templateFile,
await deps.app.engine.updateFallbackLockingParameters({
templateIdentifier: generateTemplateIdentifier(parseTemplate(templateFile)),
outputIdentifier,
roleIdentifier,
);
});
// Return an empty object
return {};
+2 -1
View File
@@ -350,7 +350,8 @@ export class AppService extends EventEmitter<AppEventMap> {
// console.error('Unreserving resources is not currently supported by the engine')
for (const [invitationIdentifier, outputs] of byInvitation) {
// Remove them directly from state
this.state.archiveInvitation(invitationIdentifier);
// TODO: Make this parallel. CBF doing it now, because it likely breaks things.
await this.state.archiveInvitation(invitationIdentifier);
// await this.engine.unreserveResources(
// outputs.map((o) => ({
+2 -1
View File
@@ -1,5 +1,6 @@
import { binToHex, hexToBin, sha256 } from "@bitauth/libauth";
import { compileCashAssemblyString, type Engine } from "@xo-cash/engine";
import { type Engine } from "@xo-cash/engine";
import { compileCashAssemblyString } from "@xo-cash/utils";
import type { ScriptHashData, State, UnspentOutputData } from "@xo-cash/state";
import type {
XOInvitation,
+1 -1
View File
@@ -34,7 +34,7 @@ import type { BlockchainService } from "./electrum.js";
import { EventEmitter } from "../utils/event-emitter.js";
import { decodeExtendedJsonObject } from "../utils/ext-json.js";
import { compileCashAssemblyString } from "@xo-cash/engine";
import { compileCashAssemblyString } from "@xo-cash/utils";
import type { ResolvedInvitationData } from "../utils/resolve-invitation-data.js";
import { resolveCommitReferences } from "../utils/resolve-invitation-data.js";
@@ -0,0 +1,264 @@
import type { ChainStatus, ElectrumApplicationEvents, TransactionState } from '@electrum-cash/application';
import type { ScriptHash, ScriptHashListUnspentEntry, ScriptHashListUnspentResponse, ScriptHashStatus, TransactionHash, TransactionHex } from '@electrum-cash/protocol';
import type { XOBlockchainProvider } from '@xo-cash/engine';
import { binToHex, hash256, hexToBin } from '@bitauth/libauth';
import { EventEmitter } from '@xo-cash/utils';
/**
* Extremely primitive in-memory blockchain adapter for testing.
* We probably want to move to using the Mem-Cash from Mainnet-Pat
* https://github.com/mainnet-pat/mem-cash
* I haven't looked at how it works yet, though. - Harvey
*/
type TransactionReceivedEvent = {
transactionHash: string;
transactionState: TransactionState;
};
type ScriptHashUpdateEvent = {
scriptHash: string;
status: ScriptHashStatus;
};
type ChainStatusEvent = {
chainStatus: ChainStatus;
};
type InMemoryBlockchainEvents = {
TransactionReceived: TransactionReceivedEvent;
ScriptHashUpdate: ScriptHashUpdateEvent;
ChainStatus: ChainStatusEvent;
};
/**
* In-memory blockchain provider for deterministic tests and local simulations.
*/
export class InMemoryBlockchainProvider implements XOBlockchainProvider {
/**
* Event emitter compatible with electrum event shapes used by the engine.
*/
private readonly events = new EventEmitter<InMemoryBlockchainEvents>();
/**
* Flag indicating if the provider is initialized and available.
*/
private initialized = false;
/**
* Mutable synthetic chain status.
*/
private chainStatus: ChainStatus = {
currentHeight: 0,
verifiedHeight: 0,
verifiedPercent: '0',
};
/**
* Script-hash keyed in-memory UTXO view.
*/
private readonly scriptHashUnspentOutputs = new Map<ScriptHash, ScriptHashListUnspentResponse>();
/**
* Transaction hash -> transaction hex storage for inspection in tests.
*/
private readonly transactions = new Map<TransactionHash, TransactionHex>();
/**
* Initializes the in-memory provider.
*
* @param _options - Initialization options (unused for in-memory backend)
*/
async initialize(): Promise<void> {
this.initialized = true;
this.emitChainStatus();
}
/**
* Stops the in-memory provider and clears all in-memory data.
*/
async stop(): Promise<void> {
this.initialized = false;
this.scriptHashUnspentOutputs.clear();
this.transactions.clear();
}
/**
* Returns whether this provider is initialized.
*/
hasConnectedClient(): boolean {
return this.initialized;
}
/**
* Returns the current synthetic chain status.
*/
async getChainStatus(): Promise<ChainStatus> {
this.assertInitialized();
return structuredClone(this.chainStatus);
}
/**
* Stores the transaction in memory and emits a synthetic TransactionReceived event.
*
* @param transactionHex - Raw transaction hex
* @returns Deterministic transaction hash
*/
async broadcastTransaction(transactionHex: TransactionHex): Promise<TransactionHash> {
this.assertInitialized();
const transactionBytes = hexToBin(transactionHex);
if (typeof transactionBytes === 'string') {
throw new Error('Cannot broadcast invalid transaction hex in InMemoryBlockchainProvider.');
}
const transactionHash = binToHex(hash256(transactionBytes));
this.transactions.set(transactionHash, transactionHex);
this.events.emit('TransactionReceived', {
transactionHash,
transactionState: {
received: true,
verified: undefined,
contested: undefined,
finalized: false,
},
});
return transactionHash;
}
/**
* No-op subscription for in-memory backend.
*
* @param _scriptHash - Script hash to subscribe to
*/
async subscribeToScriptHash(_scriptHash: ScriptHash): Promise<void> {
this.assertInitialized();
}
/**
* Reads current in-memory unspent outputs for the provided script hash.
*
* @param scriptHash - Script hash to query
*/
async fetchScriptHashUnspentOutputs(scriptHash: ScriptHash): Promise<ScriptHashListUnspentResponse> {
this.assertInitialized();
const unspentOutputs = this.scriptHashUnspentOutputs.get(scriptHash) ?? [];
return structuredClone(unspentOutputs);
}
async fetchScriptHashUnspentTransactionOutputs(scriptHash: ScriptHash, includeLocked: boolean, includeReserved: boolean): Promise<ScriptHashListUnspentEntry[]> {
return [] as ScriptHashListUnspentEntry[];
}
async fetchTransaction(transactionHash: TransactionHash): Promise<TransactionHex> {
this.assertInitialized();
const transaction = this.transactions.get(transactionHash);
if (transaction === undefined) {
throw new Error(`Transaction ${transactionHash} not found in InMemoryBlockchainProvider.`);
}
return transaction;
}
/**
* Registers an event listener.
*
* @param eventName - Event name
* @param listener - Event listener
*/
on<EventName extends keyof ElectrumApplicationEvents>(
eventName: EventName,
listener: (...args: ElectrumApplicationEvents[EventName]) => void,
): void {
this.events.on(eventName as 'TransactionReceived' | 'ScriptHashUpdate' | 'ChainStatus', listener as (...args: any[]) => void);
}
/**
* Removes an event listener.
*
* @param eventName - Event name
* @param listener - Event listener
*/
off<EventName extends keyof ElectrumApplicationEvents>(
eventName: EventName,
listener: (...args: any[]) => void,
): void {
this.events.off(eventName as keyof InMemoryBlockchainEvents, listener);
}
/**
* Sets synthetic chain status and emits ChainStatus.
*
* @param chainStatus - Updated chain status
*/
setChainStatus(chainStatus: ChainStatus): void {
this.chainStatus = structuredClone(chainStatus);
this.emitChainStatus();
}
/**
* Advances chain height and emits ChainStatus.
*
* @param blocks - Number of blocks to advance
*/
advanceBlocks(blocks = 1): void {
this.chainStatus.currentHeight += blocks;
this.chainStatus.verifiedHeight += blocks;
const ratio = this.chainStatus.currentHeight === 0 ? 0 : (this.chainStatus.verifiedHeight / this.chainStatus.currentHeight) * 100;
this.chainStatus.verifiedPercent = ratio.toFixed(0);
this.emitChainStatus();
}
/**
* Replaces the in-memory unspent outputs for a script hash.
* Emits a ScriptHashUpdate notification.
*
* @param scriptHash - Target script hash
* @param unspentOutputs - New unspent outputs list
* @param status - Optional script hash status marker
*/
setScriptHashUnspentOutputs(scriptHash: ScriptHash, unspentOutputs: ScriptHashListUnspentResponse, status?: ScriptHashStatus): void {
this.scriptHashUnspentOutputs.set(scriptHash, structuredClone(unspentOutputs));
this.events.emit('ScriptHashUpdate', {
scriptHash,
status: status ?? ('' as ScriptHashStatus),
});
}
/**
* Gets a previously broadcast transaction hex if present.
*
* @param transactionHash - Transaction hash
* @returns Transaction hex if present
*/
getBroadcastTransaction(transactionHash: TransactionHash): TransactionHex | undefined {
const transaction = this.transactions.get(transactionHash);
return transaction === undefined ? undefined : structuredClone(transaction);
}
/**
* Emits the current chain status.
*/
private emitChainStatus(): void {
this.events.emit('ChainStatus', { chainStatus: structuredClone(this.chainStatus) });
}
/**
* Ensures the provider is initialized.
*
* @throws {Error} if provider is not initialized
*/
private assertInitialized(): void {
if (!this.initialized) {
throw new Error('InMemoryBlockchainProvider is not initialized.');
}
}
}
+1 -1
View File
@@ -23,7 +23,7 @@ type InstanceChanged = ResourceInstance & {
};
function logSyncServer(message: string): void {
console.error(`[SyncServer] ${message}`);
// console.error(`[SyncServer] ${message}`);
}
function errorMessage(error: unknown): string {
+38 -19
View File
@@ -160,7 +160,7 @@ describe("invitation command - error cases", () => {
const mockEngine = await createMockEngine(DEFAULT_SEED);
engine = mockEngine.engine;
await engine.importTemplate(p2pkhTemplate);
app = await createMockAppService(engine);
app = await createMockAppService(engine, mockEngine.state);
tempDir = mkdtempSync(path.join(tmpdir(), "xo-cli-invitation-errors-"));
paths = createMockPaths(tempDir);
});
@@ -201,7 +201,7 @@ describe("invitation command - receive flow", () => {
const mockEngine = await createMockEngine(DEFAULT_SEED);
engine = mockEngine.engine;
await engine.importTemplate(p2pkhTemplate);
app = await createMockAppService(engine);
app = await createMockAppService(engine, mockEngine.state);
tempDir = mkdtempSync(path.join(tmpdir(), "xo-cli-invitation-receive-"));
paths = createMockPaths(tempDir);
});
@@ -276,7 +276,7 @@ describe("invitation command - receive flow", () => {
await handleInvitationCommand(
createCommandDeps(app, io, paths),
["create", "Wallet (P2PKH)", "receive"],
{},
{ varTransferredSatoshis: "10000", role: "receiver" },
);
expectLogs(spies, [{ out: "All requirements satisfied" }]);
@@ -314,7 +314,7 @@ describe("invitation command - request satoshis flow", () => {
const mockEngine = await createMockEngine(DEFAULT_SEED);
engine = mockEngine.engine;
await engine.importTemplate(p2pkhTemplate);
app = await createMockAppService(engine);
app = await createMockAppService(engine, mockEngine.state);
tempDir = mkdtempSync(path.join(tmpdir(), "xo-cli-invitation-request-"));
paths = createMockPaths(tempDir);
});
@@ -410,7 +410,7 @@ describe("invitation command - send flow with resources", () => {
engine = mockEngine.engine;
state = mockEngine.state;
await engine.importTemplate(p2pkhTemplate);
app = await createMockAppService(engine);
app = await createMockAppService(engine, mockEngine.state);
tempDir = mkdtempSync(path.join(tmpdir(), "xo-cli-invitation-send-"));
paths = createMockPaths(tempDir);
});
@@ -489,7 +489,7 @@ describe("invitation command - send flow with resources", () => {
varRecipientLockingscript:
"76a91489abcdefabbaabbaabbaabbaabbaabbaabbaabba88ac",
addInput:
"0000000000000000000000000000000000000000000000000000000000000000:0",
"0000000000000000000000000000000000000000000000000000000000000001:0",
role: "sender",
},
);
@@ -536,7 +536,7 @@ describe("invitation command - multi-step append", () => {
const mockEngine = await createMockEngine(DEFAULT_SEED);
engine = mockEngine.engine;
await engine.importTemplate(p2pkhTemplate);
app = await createMockAppService(engine);
app = await createMockAppService(engine, mockEngine.state);
tempDir = mkdtempSync(path.join(tmpdir(), "xo-cli-invitation-append-"));
paths = createMockPaths(tempDir);
});
@@ -684,7 +684,7 @@ describe("invitation command - list and inspect", () => {
const mockEngine = await createMockEngine(DEFAULT_SEED);
engine = mockEngine.engine;
await engine.importTemplate(p2pkhTemplate);
app = await createMockAppService(engine);
app = await createMockAppService(engine, mockEngine.state);
tempDir = mkdtempSync(path.join(tmpdir(), "xo-cli-invitation-list-"));
paths = createMockPaths(tempDir);
});
@@ -853,7 +853,7 @@ describe("invitation command - sign flow", () => {
const mockEngine = await createMockEngine(DEFAULT_SEED);
engine = mockEngine.engine;
await engine.importTemplate(p2pkhTemplate);
app = await createMockAppService(engine);
app = await createMockAppService(engine, mockEngine.state);
tempDir = mkdtempSync(path.join(tmpdir(), "xo-cli-invitation-sign-"));
paths = createMockPaths(tempDir);
});
@@ -930,7 +930,7 @@ describe("invitation command - sign flow", () => {
const result = await handleInvitationCommand(
createCommandDeps(app, io, paths),
["create", "Wallet (P2PKH)", "receive"],
{ sign: "true" },
{ sign: "true", varTransferredSatoshis: "10000", role: "receiver" },
);
expect(result.invitationIdentifier).toMatch(/^[a-f0-9]{32}$/);
@@ -940,6 +940,24 @@ describe("invitation command - sign flow", () => {
]);
});
/**
* Tests the --sign flag on create will not auto-signs when requirements are not satisfied.
*/
test("--sign flag auto-signs on create", async () => {
const { io, spies } = createMockIO();
const result = await handleInvitationCommand(
createCommandDeps(app, io, paths),
["create", "Wallet (P2PKH)", "receive"],
{ sign: "true" },
);
expect(result.invitationIdentifier).toMatch(/^[a-f0-9]{32}$/);
expectLogs(spies, [
{ out: "Remaining requirements" },
]);
});
/**
* Tests that signing fails when invitation doesn't exist.
*/
@@ -969,12 +987,13 @@ describe("invitation command - import flow", () => {
let app: AppService;
let tempDir: string;
let paths: CommandPaths;
let mockEngine: any;
beforeEach(async () => {
const mockEngine = await createMockEngine(DEFAULT_SEED);
mockEngine = await createMockEngine(DEFAULT_SEED);
engine = mockEngine.engine;
await engine.importTemplate(p2pkhTemplate);
app = await createMockAppService(engine);
app = await createMockAppService(engine, mockEngine.state);
tempDir = mkdtempSync(path.join(tmpdir(), "xo-cli-invitation-import-"));
paths = createMockPaths(tempDir);
});
@@ -1001,7 +1020,7 @@ describe("invitation command - import flow", () => {
`inv-${createResult.invitationIdentifier}.json`,
);
const secondApp = await createMockAppService(engine);
const secondApp = await createMockAppService(engine, mockEngine.state);
const { io: importIO } = createMockIO();
const importResult = await handleInvitationCommand(
@@ -1048,7 +1067,7 @@ describe("invitation command - import flow", () => {
`inv-${createResult.invitationIdentifier}.json`,
);
const secondApp = await createMockAppService(engine);
const secondApp = await createMockAppService(engine, mockEngine.state);
const { io: importIO } = createMockIO();
await handleInvitationCommand(
@@ -1077,7 +1096,7 @@ describe("invitation command - import flow", () => {
`inv-${createResult.invitationIdentifier}.json`,
);
const secondApp = await createMockAppService(engine);
const secondApp = await createMockAppService(engine, mockEngine.state);
const { io: importIO } = createMockIO();
await handleInvitationCommand(
@@ -1106,7 +1125,7 @@ describe("invitation command - auto-inputs flow", () => {
const mockEngine = await createMockEngine(DEFAULT_SEED);
engine = mockEngine.engine;
await engine.importTemplate(p2pkhTemplate);
app = await createMockAppService(engine);
app = await createMockAppService(engine, mockEngine.state);
tempDir = mkdtempSync(path.join(tmpdir(), "xo-cli-invitation-autoinputs-"));
paths = createMockPaths(tempDir);
});
@@ -1182,7 +1201,7 @@ describe("invitation command - broadcast flow", () => {
const mockEngine = await createMockEngine(DEFAULT_SEED);
engine = mockEngine.engine;
await engine.importTemplate(p2pkhTemplate);
app = await createMockAppService(engine);
app = await createMockAppService(engine, mockEngine.state);
tempDir = mkdtempSync(path.join(tmpdir(), "xo-cli-invitation-broadcast-"));
paths = createMockPaths(tempDir);
});
@@ -1247,7 +1266,7 @@ describe("invitation command - full lifecycle", () => {
const mockEngine = await createMockEngine(DEFAULT_SEED);
engine = mockEngine.engine;
await engine.importTemplate(p2pkhTemplate);
app = await createMockAppService(engine);
app = await createMockAppService(engine, mockEngine.state);
tempDir = mkdtempSync(path.join(tmpdir(), "xo-cli-invitation-lifecycle-"));
paths = createMockPaths(tempDir);
});
@@ -1402,7 +1421,7 @@ describe("invitation command - full lifecycle", () => {
const result = await handleInvitationCommand(
createCommandDeps(app, io, paths),
["create", "Wallet (P2PKH)", "receive"],
{ sign: "true" },
{ sign: "true", varTransferredSatoshis: "10000", role: "receiver" },
);
expect(result.invitationIdentifier).toMatch(/^[a-f0-9]{32}$/);
+2 -2
View File
@@ -85,7 +85,7 @@ describe("receive command", () => {
engine = mockEngine.engine;
await engine.importTemplate(p2pkhTemplate);
app = await createMockAppService(engine);
app = await createMockAppService(engine, mockEngine.state);
tempDir = mkdtempSync(path.join(tmpdir(), "xo-cli-receive-tests-"));
});
@@ -139,4 +139,4 @@ describe("receive command", () => {
}
},
);
});
}, 1000);
+31 -14
View File
@@ -11,7 +11,7 @@ import {
reserveResource,
} from "../mocks/engine";
import { type Engine } from "@xo-cash/engine";
import { p2pkhTemplate } from "../mocks/template-p2pkh";
import { p2pkhTemplate, p2pkhTemplateIdentifier } from "../mocks/template-p2pkh";
import { AppService } from "../../../src/services/app";
import { handleResourceCommand } from "../../../src/cli/commands/resource";
@@ -125,7 +125,7 @@ describe("resource command", () => {
engine = mockEngine.engine;
await engine.importTemplate(p2pkhTemplate);
app = await createMockAppService(engine);
app = await createMockAppService(engine, mockEngine.state);
tempDir = mkdtempSync(path.join(tmpdir(), "xo-cli-resource-tests-"));
});
@@ -192,7 +192,7 @@ describe("resource command with populated data", () => {
engine = mockEngine.engine;
state = mockEngine.state;
await engine.importTemplate(p2pkhTemplate);
app = await createMockAppService(engine);
app = await createMockAppService(engine, state);
tempDir = mkdtempSync(path.join(tmpdir(), "xo-cli-resource-tests-"));
});
@@ -283,9 +283,17 @@ describe("resource command with populated data", () => {
});
test("unreserve releases a reserved UTXO", async () => {
// Create an invitation
const invitation = await engine.createInvitation({
templateIdentifier: p2pkhTemplateIdentifier,
actionIdentifier: "receive",
variables: [],
});
const invitationInstance = await app.createInvitation(invitation);
const resource = await addFakeResource(state, {
valueSatoshis: 25000,
reservedBy: "inv-123",
reservedBy: invitationInstance.data.invitationIdentifier,
});
const { io, spies } = createMockIO();
@@ -299,8 +307,8 @@ describe("resource command with populated data", () => {
);
expectLogs(spies, [
{ out: "Unreserved" },
{ out: "was reserved for inv-123" },
{ out: "Archived invitation" },
{ out: invitationInstance.data.invitationIdentifier },
]);
const resources = await engine.listUnspentOutputsData();
@@ -327,15 +335,24 @@ describe("resource command with populated data", () => {
});
test("unreserve-all releases all reserved UTXOs", async () => {
// Create 2 fake invitations
const invitation1 = await engine.createInvitation({
templateIdentifier: p2pkhTemplateIdentifier,
actionIdentifier: "receive",
variables: [],
});
const invitation2 = await engine.createInvitation({
templateIdentifier: p2pkhTemplateIdentifier,
actionIdentifier: "receive",
variables: [],
});
const invitationInstance1 = await app.createInvitation(invitation1);
const invitationInstance2 = await app.createInvitation(invitation2);
await addFakeResource(state, { valueSatoshis: 50000 });
await addFakeResource(state, {
valueSatoshis: 25000,
reservedBy: "inv-123",
});
await addFakeResource(state, {
valueSatoshis: 10000,
reservedBy: "inv-456",
});
await addFakeResource(state, { valueSatoshis: 25000, reservedBy: invitationInstance1.data.invitationIdentifier });
await addFakeResource(state, { valueSatoshis: 10000, reservedBy: invitationInstance2.data.invitationIdentifier });
const { io, spies } = createMockIO();
const result = await handleResourceCommand(
+5 -2
View File
@@ -204,9 +204,12 @@ describe("template command", () => {
beforeEach(async () => {
const mockEngine = await createMockEngine(DEFAULT_SEED);
engine = mockEngine.engine;
await engine.importTemplate(p2pkhTemplate);
const { templateIdentifier } = await engine.importTemplate(p2pkhTemplate);
if (templateIdentifier !== p2pkhTemplateIdentifier) {
throw new Error("Template identifier mismatch");
}
app = await createMockAppService(engine);
app = await createMockAppService(engine, mockEngine.state);
tempDir = mkdtempSync(path.join(tmpdir(), "xo-cli-template-tests-"));
});
+17 -4
View File
@@ -10,7 +10,7 @@ import {
UnspentOutputStatus,
type UnspentOutputData,
} from "@xo-cash/state";
import { InMemoryBlockchainProvider } from "@xo-cash/engine";
import { InMemoryBlockchainProvider } from "../../../src/utils/blockchain/in-memory-blockchain-provider.js";
import { convertMnemonicToSeedBytes } from "@xo-cash/crypto";
import { binToHex, sha256 } from "@bitauth/libauth";
@@ -20,6 +20,7 @@ import { MockElectrumService } from "./electrum-service";
import { MockRatesService } from "./rates-service";
import { RatesService } from "../../../src/services/rates";
import { SettingsService } from "../../../src/services/settings";
import { mkdirSync } from "node:fs";
export const DEFAULT_SEED =
"oven crop same above under tower promote decrease vocal pretty require slow";
@@ -68,6 +69,7 @@ export const addFakeResource = async (
options: FakeResourceOptions = {},
): Promise<UnspentOutputData> => {
const resource: UnspentOutputData = {
templateIdentifier: options.templateIdentifier ?? "test-template",
status: UnspentOutputStatus.CONFIRMED,
selectable: true,
privacy: false,
@@ -131,10 +133,16 @@ export const unreserveResource = async (
* @returns A mock engine instance.
*/
export const createMockEngine = async (seed: string) => {
const randomId = Math.random().toString(36).substring(2, 15);
const stateDir = `${tmpdir()}/test-data-${randomId}`;
mkdirSync(stateDir, { recursive: true });
// Create the in-memory storage adapter.
const storage = await createStorageAdapter({
storageType: "inmemory",
storageType: StorageType.INDEXEDDB,
accountHash: binToHex(sha256.hash(convertMnemonicToSeedBytes(seed))),
databasePath: stateDir,
databaseFilename: `xo-wallet-${randomId}.db`,
});
// Initialize the storage adapter.
@@ -152,13 +160,17 @@ export const createMockEngine = async (seed: string) => {
await blockchainMonitor.initializeEventListeners();
// Create the engine instance.
const engine = new Engine(seed, state, blockchainMonitor, blockchainProvider);
const engine = new Engine(seed, state, blockchainProvider, blockchainMonitor);
await engine.initializeStateSync();
return { engine, state, blockchainMonitor, blockchainProvider };
};
export const createMockAppService = async (engine: Engine) => {
export const createMockAppService = async (engine: Engine, state: State) => {
if (!state) {
throw new Error("State is required");
}
const settings = new SettingsService(
`${tmpdir()}/xo-cli-tests-settings.json`,
);
@@ -187,6 +199,7 @@ export const createMockAppService = async (engine: Engine) => {
mockElectrum,
rates,
settings,
state,
new Uint8Array(32).fill(1),
);
};
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -37,7 +37,7 @@ describe("loadTemplateFromFile", () => {
test("loads TypeScript templates via child process", async () => {
const tsTemplatePath = path.resolve(
process.cwd(),
"../templates/source/p2pkh.ts",
"./tests/cli/mocks/template-p2pkh.ts",
);
expect(existsSync(tsTemplatePath)).toBe(true);
+3 -3
View File
@@ -1,5 +1,5 @@
import { binToHex } from "@bitauth/libauth";
import { serializeInvitation } from "@xo-cash/engine";
import { deserializeInvitation, serializeInvitation } from "@xo-cash/engine";
import type { XOInvitation, XOInvitationCommit } from "@xo-cash/types";
import { beforeEach, describe, expect, it, vi } from "vitest";
@@ -135,7 +135,7 @@ describe("SyncServer v2 adapter", () => {
invitationIdentifier,
new Uint8Array(32).fill(1),
);
const messages: Array<{ event?: string; data: string }> = [];
const messages: Array<{ event?: string; data: XOInvitation }> = [];
sync.on("message", (message) => messages.push(message));
client.onMessage?.(
@@ -149,6 +149,6 @@ describe("SyncServer v2 adapter", () => {
expect(messages).toHaveLength(1);
expect(messages[0]?.event).toBe("invitation-updated");
expect(messages[0]?.data).toBe(serializeInvitation(updated));
expect(messages[0]?.data).toEqual(deserializeInvitation(serializeInvitation(updated)));
});
});