The tests work
This commit is contained in:
Generated
+56
-39
@@ -14,11 +14,11 @@
|
|||||||
"@electrum-cash/protocol": "^2.3.1",
|
"@electrum-cash/protocol": "^2.3.1",
|
||||||
"@generalprotocols/oracle-client": "^0.0.1-development.11945476152",
|
"@generalprotocols/oracle-client": "^0.0.1-development.11945476152",
|
||||||
"@xo-cash/crypto": "^0.0.2",
|
"@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/state": "^0.0.3",
|
||||||
"@xo-cash/templates": "^0.0.3",
|
"@xo-cash/templates": "^0.0.3",
|
||||||
"@xo-cash/types": "^0.0.5",
|
"@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",
|
"better-sqlite3": "^12.6.2",
|
||||||
"clipboardy": "^5.1.0",
|
"clipboardy": "^5.1.0",
|
||||||
"ink": "^6.6.0",
|
"ink": "^6.6.0",
|
||||||
@@ -43,38 +43,6 @@
|
|||||||
"vitest": "^4.1.2"
|
"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": {
|
"node_modules/@alcalzone/ansi-tokenize": {
|
||||||
"version": "0.2.4",
|
"version": "0.2.4",
|
||||||
"resolved": "https://registry.npmjs.org/@alcalzone/ansi-tokenize/-/ansi-tokenize-0.2.4.tgz",
|
"resolved": "https://registry.npmjs.org/@alcalzone/ansi-tokenize/-/ansi-tokenize-0.2.4.tgz",
|
||||||
@@ -1198,8 +1166,57 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@xo-cash/engine": {
|
"node_modules/@xo-cash/engine": {
|
||||||
"resolved": "../engine",
|
"version": "0.0.2-cli.20260907103534754",
|
||||||
"link": true
|
"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": {
|
"node_modules/@xo-cash/primitives": {
|
||||||
"version": "0.0.2",
|
"version": "0.0.2",
|
||||||
@@ -1283,9 +1300,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@xo-cash/utils": {
|
"node_modules/@xo-cash/utils": {
|
||||||
"version": "0.0.5-test.20260831095544025",
|
"version": "0.0.6-clio.20260907035202101",
|
||||||
"resolved": "https://verdaccio.harvmaster.com/@xo-cash/utils/-/utils-0.0.5-test.20260831095544025.tgz",
|
"resolved": "https://verdaccio.harvmaster.com/@xo-cash/utils/-/utils-0.0.6-clio.20260907035202101.tgz",
|
||||||
"integrity": "sha512-XWiG8tPPmXy/zrDYUjM1fILL7STaV0THE4xoj6PosuSgh1mUQNdr71Wanbhkou/Pc4HbOcT+KO+DbLz0dGMYRw==",
|
"integrity": "sha512-DBou0RivCxg4PvyC6eWpFWXsxh2gTSD7BPeYIToqkdjweVzszvsWIUJ8tb27MxL56fW6CbGhDXyElciXEqbQmg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@bitauth/libauth": "^3.1.0-next.8",
|
"@bitauth/libauth": "^3.1.0-next.8",
|
||||||
|
|||||||
+3
-6
@@ -15,7 +15,7 @@
|
|||||||
"build:unsafe": "tsc --nocheck --noEmitOnError false || true && npm run build:copy-scripts",
|
"build:unsafe": "tsc --nocheck --noEmitOnError false || true && npm run build:copy-scripts",
|
||||||
"syntax": "tsc --noEmit",
|
"syntax": "tsc --noEmit",
|
||||||
"start": "SYNC_SERVER_URL=https://v2.sync.xo.harvmaster.com node dist/index.js",
|
"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:watch": "vitest",
|
||||||
"test:coverage": "vitest run --coverage --passWithNoTests",
|
"test:coverage": "vitest run --coverage --passWithNoTests",
|
||||||
"format": "prettier --write \"**/*.{js,ts,md,json}\" --ignore-path .gitignore",
|
"format": "prettier --write \"**/*.{js,ts,md,json}\" --ignore-path .gitignore",
|
||||||
@@ -31,9 +31,6 @@
|
|||||||
"cli",
|
"cli",
|
||||||
"tui"
|
"tui"
|
||||||
],
|
],
|
||||||
"overrides": {
|
|
||||||
"@xo-cash/utils": "^0.0.5-test.20260831095544025"
|
|
||||||
},
|
|
||||||
"author": "General Protocols",
|
"author": "General Protocols",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"description": "XO Wallet CLI - Terminal User Interface for XO crypto wallet",
|
"description": "XO Wallet CLI - Terminal User Interface for XO crypto wallet",
|
||||||
@@ -43,11 +40,11 @@
|
|||||||
"@electrum-cash/protocol": "^2.3.1",
|
"@electrum-cash/protocol": "^2.3.1",
|
||||||
"@generalprotocols/oracle-client": "^0.0.1-development.11945476152",
|
"@generalprotocols/oracle-client": "^0.0.1-development.11945476152",
|
||||||
"@xo-cash/crypto": "^0.0.2",
|
"@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/state": "^0.0.3",
|
||||||
"@xo-cash/templates": "^0.0.3",
|
"@xo-cash/templates": "^0.0.3",
|
||||||
"@xo-cash/types": "^0.0.5",
|
"@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",
|
"better-sqlite3": "^12.6.2",
|
||||||
"clipboardy": "^5.1.0",
|
"clipboardy": "^5.1.0",
|
||||||
"ink": "^6.6.0",
|
"ink": "^6.6.0",
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { createStorageAdapter, State, StorageType } from "@xo-cash/state";
|
|||||||
import { convertMnemonicToSeedBytes } from "@xo-cash/crypto";
|
import { convertMnemonicToSeedBytes } from "@xo-cash/crypto";
|
||||||
import { binToHex, hash256 } from "@bitauth/libauth";
|
import { binToHex, hash256 } from "@bitauth/libauth";
|
||||||
import { createHash } from "crypto";
|
import { createHash } from "crypto";
|
||||||
|
import { InMemoryBlockchainProvider } from "../../utils/blockchain/in-memory-blockchain-provider.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Options for creating an offline engine.
|
* Options for creating an offline engine.
|
||||||
@@ -64,7 +65,7 @@ export async function createOfflineEngine(
|
|||||||
const state = new State(storageAdapter);
|
const state = new State(storageAdapter);
|
||||||
|
|
||||||
// Create a minimal blockchain monitor (no electrum initialization)
|
// 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.
|
// Engine constructor is private; bypass for offline read-only completions.
|
||||||
type EngineConstructor = new (
|
type EngineConstructor = new (
|
||||||
|
|||||||
@@ -382,10 +382,19 @@ export const handleInvitationCommand = async (
|
|||||||
const template = await resolveTemplate(deps, templateQuery);
|
const template = await resolveTemplate(deps, templateQuery);
|
||||||
const templateIdentifier = generateTemplateIdentifier(template);
|
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
|
// Create an XOInvitation. We will convert this into our own invitation instance afterwards
|
||||||
const rawInvitation = await deps.app.engine.createInvitation({
|
const rawInvitation = await deps.app.engine.createInvitation({
|
||||||
templateIdentifier,
|
templateIdentifier,
|
||||||
actionIdentifier,
|
actionIdentifier,
|
||||||
|
variables,
|
||||||
});
|
});
|
||||||
deps.io.verbose(`XOInvitation created: ${formatObject(rawInvitation)}`);
|
deps.io.verbose(`XOInvitation created: ${formatObject(rawInvitation)}`);
|
||||||
|
|
||||||
@@ -395,13 +404,6 @@ export const handleInvitationCommand = async (
|
|||||||
`Invitation instance created: ${formatObject(invitationInstance.data)}`,
|
`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.
|
// Build the parameters for the append call. This will resolve the inputs and outputs for the invitation.
|
||||||
const params = await buildAppendParams(deps, invitationInstance, options);
|
const params = await buildAppendParams(deps, invitationInstance, options);
|
||||||
if (!params) {
|
if (!params) {
|
||||||
@@ -411,6 +413,8 @@ export const handleInvitationCommand = async (
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TOOD MARKER: InvVarHere
|
||||||
|
|
||||||
// Append the inputs and outputs to the invitation
|
// Append the inputs and outputs to the invitation
|
||||||
const { inputs, outputs } = params;
|
const { inputs, outputs } = params;
|
||||||
deps.io.verbose(`Inputs: ${formatObject(inputs)}`);
|
deps.io.verbose(`Inputs: ${formatObject(inputs)}`);
|
||||||
@@ -419,10 +423,18 @@ export const handleInvitationCommand = async (
|
|||||||
await invitationInstance.append({ inputs, outputs });
|
await invitationInstance.append({ inputs, outputs });
|
||||||
}
|
}
|
||||||
if (inputs.length > 0) {
|
if (inputs.length > 0) {
|
||||||
const feeAwareChange = await invitationInstance.addFeeAwareChange();
|
try {
|
||||||
deps.io.out(
|
const feeAwareChange = await invitationInstance.addFeeAwareChange();
|
||||||
`Miner fee: ${feeAwareChange.feeSatoshis} satoshis; change: ${feeAwareChange.changeAmountSatoshis} satoshis`,
|
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
|
// Write the invitation to a file in the working directory
|
||||||
|
|||||||
@@ -227,13 +227,13 @@ export const handleResourceCommand = async (
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Unreserve the resources
|
// Unreserve the resources
|
||||||
await deps.app.engine.unreserveResources(
|
await deps.app.engine.archiveInvitation(target.reservedBy);
|
||||||
[{ outpointTransactionHash: hexToBin(txHash), outpointIndex: vout }],
|
|
||||||
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(
|
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?
|
// TODO: What do I want to return here?
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { existsSync, writeFileSync } from "fs";
|
import { existsSync, writeFileSync } from "fs";
|
||||||
import path from "path";
|
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 type { XOTemplate } from "@xo-cash/types";
|
||||||
|
|
||||||
import { bold, dim, formatObject } from "../utils.js";
|
import { bold, dim, formatObject } from "../utils.js";
|
||||||
@@ -529,11 +529,11 @@ export const handleTemplateCommand = async (
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Set the default locking parameters
|
// Set the default locking parameters
|
||||||
await deps.app.engine.setDefaultLockingParameters(
|
await deps.app.engine.updateFallbackLockingParameters({
|
||||||
templateFile,
|
templateIdentifier: generateTemplateIdentifier(parseTemplate(templateFile)),
|
||||||
outputIdentifier,
|
outputIdentifier,
|
||||||
roleIdentifier,
|
roleIdentifier,
|
||||||
);
|
});
|
||||||
|
|
||||||
// Return an empty object
|
// Return an empty object
|
||||||
return {};
|
return {};
|
||||||
|
|||||||
+2
-1
@@ -350,7 +350,8 @@ export class AppService extends EventEmitter<AppEventMap> {
|
|||||||
// console.error('Unreserving resources is not currently supported by the engine')
|
// console.error('Unreserving resources is not currently supported by the engine')
|
||||||
for (const [invitationIdentifier, outputs] of byInvitation) {
|
for (const [invitationIdentifier, outputs] of byInvitation) {
|
||||||
// Remove them directly from state
|
// 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(
|
// await this.engine.unreserveResources(
|
||||||
// outputs.map((o) => ({
|
// outputs.map((o) => ({
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { binToHex, hexToBin, sha256 } from "@bitauth/libauth";
|
import { binToHex, hexToBin, sha256 } from "@bitauth/libauth";
|
||||||
import { compileCashAssemblyString, type Engine } from "@xo-cash/engine";
|
import { type Engine } from "@xo-cash/engine";
|
||||||
|
import { compileCashAssemblyString } from "@xo-cash/utils";
|
||||||
import type { ScriptHashData, State, UnspentOutputData } from "@xo-cash/state";
|
import type { ScriptHashData, State, UnspentOutputData } from "@xo-cash/state";
|
||||||
import type {
|
import type {
|
||||||
XOInvitation,
|
XOInvitation,
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ import type { BlockchainService } from "./electrum.js";
|
|||||||
|
|
||||||
import { EventEmitter } from "../utils/event-emitter.js";
|
import { EventEmitter } from "../utils/event-emitter.js";
|
||||||
import { decodeExtendedJsonObject } from "../utils/ext-json.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 type { ResolvedInvitationData } from "../utils/resolve-invitation-data.js";
|
||||||
import { resolveCommitReferences } 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.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -23,7 +23,7 @@ type InstanceChanged = ResourceInstance & {
|
|||||||
};
|
};
|
||||||
|
|
||||||
function logSyncServer(message: string): void {
|
function logSyncServer(message: string): void {
|
||||||
console.error(`[SyncServer] ${message}`);
|
// console.error(`[SyncServer] ${message}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
function errorMessage(error: unknown): string {
|
function errorMessage(error: unknown): string {
|
||||||
|
|||||||
@@ -160,7 +160,7 @@ describe("invitation command - error cases", () => {
|
|||||||
const mockEngine = await createMockEngine(DEFAULT_SEED);
|
const mockEngine = await createMockEngine(DEFAULT_SEED);
|
||||||
engine = mockEngine.engine;
|
engine = mockEngine.engine;
|
||||||
await engine.importTemplate(p2pkhTemplate);
|
await engine.importTemplate(p2pkhTemplate);
|
||||||
app = await createMockAppService(engine);
|
app = await createMockAppService(engine, mockEngine.state);
|
||||||
tempDir = mkdtempSync(path.join(tmpdir(), "xo-cli-invitation-errors-"));
|
tempDir = mkdtempSync(path.join(tmpdir(), "xo-cli-invitation-errors-"));
|
||||||
paths = createMockPaths(tempDir);
|
paths = createMockPaths(tempDir);
|
||||||
});
|
});
|
||||||
@@ -201,7 +201,7 @@ describe("invitation command - receive flow", () => {
|
|||||||
const mockEngine = await createMockEngine(DEFAULT_SEED);
|
const mockEngine = await createMockEngine(DEFAULT_SEED);
|
||||||
engine = mockEngine.engine;
|
engine = mockEngine.engine;
|
||||||
await engine.importTemplate(p2pkhTemplate);
|
await engine.importTemplate(p2pkhTemplate);
|
||||||
app = await createMockAppService(engine);
|
app = await createMockAppService(engine, mockEngine.state);
|
||||||
tempDir = mkdtempSync(path.join(tmpdir(), "xo-cli-invitation-receive-"));
|
tempDir = mkdtempSync(path.join(tmpdir(), "xo-cli-invitation-receive-"));
|
||||||
paths = createMockPaths(tempDir);
|
paths = createMockPaths(tempDir);
|
||||||
});
|
});
|
||||||
@@ -276,7 +276,7 @@ describe("invitation command - receive flow", () => {
|
|||||||
await handleInvitationCommand(
|
await handleInvitationCommand(
|
||||||
createCommandDeps(app, io, paths),
|
createCommandDeps(app, io, paths),
|
||||||
["create", "Wallet (P2PKH)", "receive"],
|
["create", "Wallet (P2PKH)", "receive"],
|
||||||
{},
|
{ varTransferredSatoshis: "10000", role: "receiver" },
|
||||||
);
|
);
|
||||||
|
|
||||||
expectLogs(spies, [{ out: "All requirements satisfied" }]);
|
expectLogs(spies, [{ out: "All requirements satisfied" }]);
|
||||||
@@ -314,7 +314,7 @@ describe("invitation command - request satoshis flow", () => {
|
|||||||
const mockEngine = await createMockEngine(DEFAULT_SEED);
|
const mockEngine = await createMockEngine(DEFAULT_SEED);
|
||||||
engine = mockEngine.engine;
|
engine = mockEngine.engine;
|
||||||
await engine.importTemplate(p2pkhTemplate);
|
await engine.importTemplate(p2pkhTemplate);
|
||||||
app = await createMockAppService(engine);
|
app = await createMockAppService(engine, mockEngine.state);
|
||||||
tempDir = mkdtempSync(path.join(tmpdir(), "xo-cli-invitation-request-"));
|
tempDir = mkdtempSync(path.join(tmpdir(), "xo-cli-invitation-request-"));
|
||||||
paths = createMockPaths(tempDir);
|
paths = createMockPaths(tempDir);
|
||||||
});
|
});
|
||||||
@@ -410,7 +410,7 @@ describe("invitation command - send flow with resources", () => {
|
|||||||
engine = mockEngine.engine;
|
engine = mockEngine.engine;
|
||||||
state = mockEngine.state;
|
state = mockEngine.state;
|
||||||
await engine.importTemplate(p2pkhTemplate);
|
await engine.importTemplate(p2pkhTemplate);
|
||||||
app = await createMockAppService(engine);
|
app = await createMockAppService(engine, mockEngine.state);
|
||||||
tempDir = mkdtempSync(path.join(tmpdir(), "xo-cli-invitation-send-"));
|
tempDir = mkdtempSync(path.join(tmpdir(), "xo-cli-invitation-send-"));
|
||||||
paths = createMockPaths(tempDir);
|
paths = createMockPaths(tempDir);
|
||||||
});
|
});
|
||||||
@@ -489,7 +489,7 @@ describe("invitation command - send flow with resources", () => {
|
|||||||
varRecipientLockingscript:
|
varRecipientLockingscript:
|
||||||
"76a91489abcdefabbaabbaabbaabbaabbaabbaabbaabba88ac",
|
"76a91489abcdefabbaabbaabbaabbaabbaabbaabbaabba88ac",
|
||||||
addInput:
|
addInput:
|
||||||
"0000000000000000000000000000000000000000000000000000000000000000:0",
|
"0000000000000000000000000000000000000000000000000000000000000001:0",
|
||||||
role: "sender",
|
role: "sender",
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -536,7 +536,7 @@ describe("invitation command - multi-step append", () => {
|
|||||||
const mockEngine = await createMockEngine(DEFAULT_SEED);
|
const mockEngine = await createMockEngine(DEFAULT_SEED);
|
||||||
engine = mockEngine.engine;
|
engine = mockEngine.engine;
|
||||||
await engine.importTemplate(p2pkhTemplate);
|
await engine.importTemplate(p2pkhTemplate);
|
||||||
app = await createMockAppService(engine);
|
app = await createMockAppService(engine, mockEngine.state);
|
||||||
tempDir = mkdtempSync(path.join(tmpdir(), "xo-cli-invitation-append-"));
|
tempDir = mkdtempSync(path.join(tmpdir(), "xo-cli-invitation-append-"));
|
||||||
paths = createMockPaths(tempDir);
|
paths = createMockPaths(tempDir);
|
||||||
});
|
});
|
||||||
@@ -684,7 +684,7 @@ describe("invitation command - list and inspect", () => {
|
|||||||
const mockEngine = await createMockEngine(DEFAULT_SEED);
|
const mockEngine = await createMockEngine(DEFAULT_SEED);
|
||||||
engine = mockEngine.engine;
|
engine = mockEngine.engine;
|
||||||
await engine.importTemplate(p2pkhTemplate);
|
await engine.importTemplate(p2pkhTemplate);
|
||||||
app = await createMockAppService(engine);
|
app = await createMockAppService(engine, mockEngine.state);
|
||||||
tempDir = mkdtempSync(path.join(tmpdir(), "xo-cli-invitation-list-"));
|
tempDir = mkdtempSync(path.join(tmpdir(), "xo-cli-invitation-list-"));
|
||||||
paths = createMockPaths(tempDir);
|
paths = createMockPaths(tempDir);
|
||||||
});
|
});
|
||||||
@@ -853,7 +853,7 @@ describe("invitation command - sign flow", () => {
|
|||||||
const mockEngine = await createMockEngine(DEFAULT_SEED);
|
const mockEngine = await createMockEngine(DEFAULT_SEED);
|
||||||
engine = mockEngine.engine;
|
engine = mockEngine.engine;
|
||||||
await engine.importTemplate(p2pkhTemplate);
|
await engine.importTemplate(p2pkhTemplate);
|
||||||
app = await createMockAppService(engine);
|
app = await createMockAppService(engine, mockEngine.state);
|
||||||
tempDir = mkdtempSync(path.join(tmpdir(), "xo-cli-invitation-sign-"));
|
tempDir = mkdtempSync(path.join(tmpdir(), "xo-cli-invitation-sign-"));
|
||||||
paths = createMockPaths(tempDir);
|
paths = createMockPaths(tempDir);
|
||||||
});
|
});
|
||||||
@@ -930,7 +930,7 @@ describe("invitation command - sign flow", () => {
|
|||||||
const result = await handleInvitationCommand(
|
const result = await handleInvitationCommand(
|
||||||
createCommandDeps(app, io, paths),
|
createCommandDeps(app, io, paths),
|
||||||
["create", "Wallet (P2PKH)", "receive"],
|
["create", "Wallet (P2PKH)", "receive"],
|
||||||
{ sign: "true" },
|
{ sign: "true", varTransferredSatoshis: "10000", role: "receiver" },
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(result.invitationIdentifier).toMatch(/^[a-f0-9]{32}$/);
|
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.
|
* Tests that signing fails when invitation doesn't exist.
|
||||||
*/
|
*/
|
||||||
@@ -969,12 +987,13 @@ describe("invitation command - import flow", () => {
|
|||||||
let app: AppService;
|
let app: AppService;
|
||||||
let tempDir: string;
|
let tempDir: string;
|
||||||
let paths: CommandPaths;
|
let paths: CommandPaths;
|
||||||
|
let mockEngine: any;
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
const mockEngine = await createMockEngine(DEFAULT_SEED);
|
mockEngine = await createMockEngine(DEFAULT_SEED);
|
||||||
engine = mockEngine.engine;
|
engine = mockEngine.engine;
|
||||||
await engine.importTemplate(p2pkhTemplate);
|
await engine.importTemplate(p2pkhTemplate);
|
||||||
app = await createMockAppService(engine);
|
app = await createMockAppService(engine, mockEngine.state);
|
||||||
tempDir = mkdtempSync(path.join(tmpdir(), "xo-cli-invitation-import-"));
|
tempDir = mkdtempSync(path.join(tmpdir(), "xo-cli-invitation-import-"));
|
||||||
paths = createMockPaths(tempDir);
|
paths = createMockPaths(tempDir);
|
||||||
});
|
});
|
||||||
@@ -1001,7 +1020,7 @@ describe("invitation command - import flow", () => {
|
|||||||
`inv-${createResult.invitationIdentifier}.json`,
|
`inv-${createResult.invitationIdentifier}.json`,
|
||||||
);
|
);
|
||||||
|
|
||||||
const secondApp = await createMockAppService(engine);
|
const secondApp = await createMockAppService(engine, mockEngine.state);
|
||||||
const { io: importIO } = createMockIO();
|
const { io: importIO } = createMockIO();
|
||||||
|
|
||||||
const importResult = await handleInvitationCommand(
|
const importResult = await handleInvitationCommand(
|
||||||
@@ -1048,7 +1067,7 @@ describe("invitation command - import flow", () => {
|
|||||||
`inv-${createResult.invitationIdentifier}.json`,
|
`inv-${createResult.invitationIdentifier}.json`,
|
||||||
);
|
);
|
||||||
|
|
||||||
const secondApp = await createMockAppService(engine);
|
const secondApp = await createMockAppService(engine, mockEngine.state);
|
||||||
const { io: importIO } = createMockIO();
|
const { io: importIO } = createMockIO();
|
||||||
|
|
||||||
await handleInvitationCommand(
|
await handleInvitationCommand(
|
||||||
@@ -1077,7 +1096,7 @@ describe("invitation command - import flow", () => {
|
|||||||
`inv-${createResult.invitationIdentifier}.json`,
|
`inv-${createResult.invitationIdentifier}.json`,
|
||||||
);
|
);
|
||||||
|
|
||||||
const secondApp = await createMockAppService(engine);
|
const secondApp = await createMockAppService(engine, mockEngine.state);
|
||||||
const { io: importIO } = createMockIO();
|
const { io: importIO } = createMockIO();
|
||||||
|
|
||||||
await handleInvitationCommand(
|
await handleInvitationCommand(
|
||||||
@@ -1106,7 +1125,7 @@ describe("invitation command - auto-inputs flow", () => {
|
|||||||
const mockEngine = await createMockEngine(DEFAULT_SEED);
|
const mockEngine = await createMockEngine(DEFAULT_SEED);
|
||||||
engine = mockEngine.engine;
|
engine = mockEngine.engine;
|
||||||
await engine.importTemplate(p2pkhTemplate);
|
await engine.importTemplate(p2pkhTemplate);
|
||||||
app = await createMockAppService(engine);
|
app = await createMockAppService(engine, mockEngine.state);
|
||||||
tempDir = mkdtempSync(path.join(tmpdir(), "xo-cli-invitation-autoinputs-"));
|
tempDir = mkdtempSync(path.join(tmpdir(), "xo-cli-invitation-autoinputs-"));
|
||||||
paths = createMockPaths(tempDir);
|
paths = createMockPaths(tempDir);
|
||||||
});
|
});
|
||||||
@@ -1182,7 +1201,7 @@ describe("invitation command - broadcast flow", () => {
|
|||||||
const mockEngine = await createMockEngine(DEFAULT_SEED);
|
const mockEngine = await createMockEngine(DEFAULT_SEED);
|
||||||
engine = mockEngine.engine;
|
engine = mockEngine.engine;
|
||||||
await engine.importTemplate(p2pkhTemplate);
|
await engine.importTemplate(p2pkhTemplate);
|
||||||
app = await createMockAppService(engine);
|
app = await createMockAppService(engine, mockEngine.state);
|
||||||
tempDir = mkdtempSync(path.join(tmpdir(), "xo-cli-invitation-broadcast-"));
|
tempDir = mkdtempSync(path.join(tmpdir(), "xo-cli-invitation-broadcast-"));
|
||||||
paths = createMockPaths(tempDir);
|
paths = createMockPaths(tempDir);
|
||||||
});
|
});
|
||||||
@@ -1247,7 +1266,7 @@ describe("invitation command - full lifecycle", () => {
|
|||||||
const mockEngine = await createMockEngine(DEFAULT_SEED);
|
const mockEngine = await createMockEngine(DEFAULT_SEED);
|
||||||
engine = mockEngine.engine;
|
engine = mockEngine.engine;
|
||||||
await engine.importTemplate(p2pkhTemplate);
|
await engine.importTemplate(p2pkhTemplate);
|
||||||
app = await createMockAppService(engine);
|
app = await createMockAppService(engine, mockEngine.state);
|
||||||
tempDir = mkdtempSync(path.join(tmpdir(), "xo-cli-invitation-lifecycle-"));
|
tempDir = mkdtempSync(path.join(tmpdir(), "xo-cli-invitation-lifecycle-"));
|
||||||
paths = createMockPaths(tempDir);
|
paths = createMockPaths(tempDir);
|
||||||
});
|
});
|
||||||
@@ -1402,7 +1421,7 @@ describe("invitation command - full lifecycle", () => {
|
|||||||
const result = await handleInvitationCommand(
|
const result = await handleInvitationCommand(
|
||||||
createCommandDeps(app, io, paths),
|
createCommandDeps(app, io, paths),
|
||||||
["create", "Wallet (P2PKH)", "receive"],
|
["create", "Wallet (P2PKH)", "receive"],
|
||||||
{ sign: "true" },
|
{ sign: "true", varTransferredSatoshis: "10000", role: "receiver" },
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(result.invitationIdentifier).toMatch(/^[a-f0-9]{32}$/);
|
expect(result.invitationIdentifier).toMatch(/^[a-f0-9]{32}$/);
|
||||||
|
|||||||
@@ -85,7 +85,7 @@ describe("receive command", () => {
|
|||||||
engine = mockEngine.engine;
|
engine = mockEngine.engine;
|
||||||
await engine.importTemplate(p2pkhTemplate);
|
await engine.importTemplate(p2pkhTemplate);
|
||||||
|
|
||||||
app = await createMockAppService(engine);
|
app = await createMockAppService(engine, mockEngine.state);
|
||||||
|
|
||||||
tempDir = mkdtempSync(path.join(tmpdir(), "xo-cli-receive-tests-"));
|
tempDir = mkdtempSync(path.join(tmpdir(), "xo-cli-receive-tests-"));
|
||||||
});
|
});
|
||||||
@@ -139,4 +139,4 @@ describe("receive command", () => {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
});
|
}, 1000);
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import {
|
|||||||
reserveResource,
|
reserveResource,
|
||||||
} from "../mocks/engine";
|
} from "../mocks/engine";
|
||||||
import { type Engine } from "@xo-cash/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 { AppService } from "../../../src/services/app";
|
||||||
|
|
||||||
import { handleResourceCommand } from "../../../src/cli/commands/resource";
|
import { handleResourceCommand } from "../../../src/cli/commands/resource";
|
||||||
@@ -125,7 +125,7 @@ describe("resource command", () => {
|
|||||||
engine = mockEngine.engine;
|
engine = mockEngine.engine;
|
||||||
await engine.importTemplate(p2pkhTemplate);
|
await engine.importTemplate(p2pkhTemplate);
|
||||||
|
|
||||||
app = await createMockAppService(engine);
|
app = await createMockAppService(engine, mockEngine.state);
|
||||||
|
|
||||||
tempDir = mkdtempSync(path.join(tmpdir(), "xo-cli-resource-tests-"));
|
tempDir = mkdtempSync(path.join(tmpdir(), "xo-cli-resource-tests-"));
|
||||||
});
|
});
|
||||||
@@ -192,7 +192,7 @@ describe("resource command with populated data", () => {
|
|||||||
engine = mockEngine.engine;
|
engine = mockEngine.engine;
|
||||||
state = mockEngine.state;
|
state = mockEngine.state;
|
||||||
await engine.importTemplate(p2pkhTemplate);
|
await engine.importTemplate(p2pkhTemplate);
|
||||||
app = await createMockAppService(engine);
|
app = await createMockAppService(engine, state);
|
||||||
tempDir = mkdtempSync(path.join(tmpdir(), "xo-cli-resource-tests-"));
|
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 () => {
|
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, {
|
const resource = await addFakeResource(state, {
|
||||||
valueSatoshis: 25000,
|
valueSatoshis: 25000,
|
||||||
reservedBy: "inv-123",
|
reservedBy: invitationInstance.data.invitationIdentifier,
|
||||||
});
|
});
|
||||||
|
|
||||||
const { io, spies } = createMockIO();
|
const { io, spies } = createMockIO();
|
||||||
@@ -299,8 +307,8 @@ describe("resource command with populated data", () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
expectLogs(spies, [
|
expectLogs(spies, [
|
||||||
{ out: "Unreserved" },
|
{ out: "Archived invitation" },
|
||||||
{ out: "was reserved for inv-123" },
|
{ out: invitationInstance.data.invitationIdentifier },
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const resources = await engine.listUnspentOutputsData();
|
const resources = await engine.listUnspentOutputsData();
|
||||||
@@ -327,15 +335,24 @@ describe("resource command with populated data", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("unreserve-all releases all reserved UTXOs", async () => {
|
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: 50000 });
|
||||||
await addFakeResource(state, {
|
await addFakeResource(state, { valueSatoshis: 25000, reservedBy: invitationInstance1.data.invitationIdentifier });
|
||||||
valueSatoshis: 25000,
|
await addFakeResource(state, { valueSatoshis: 10000, reservedBy: invitationInstance2.data.invitationIdentifier });
|
||||||
reservedBy: "inv-123",
|
|
||||||
});
|
|
||||||
await addFakeResource(state, {
|
|
||||||
valueSatoshis: 10000,
|
|
||||||
reservedBy: "inv-456",
|
|
||||||
});
|
|
||||||
|
|
||||||
const { io, spies } = createMockIO();
|
const { io, spies } = createMockIO();
|
||||||
const result = await handleResourceCommand(
|
const result = await handleResourceCommand(
|
||||||
|
|||||||
@@ -204,9 +204,12 @@ describe("template command", () => {
|
|||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
const mockEngine = await createMockEngine(DEFAULT_SEED);
|
const mockEngine = await createMockEngine(DEFAULT_SEED);
|
||||||
engine = mockEngine.engine;
|
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-"));
|
tempDir = mkdtempSync(path.join(tmpdir(), "xo-cli-template-tests-"));
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import {
|
|||||||
UnspentOutputStatus,
|
UnspentOutputStatus,
|
||||||
type UnspentOutputData,
|
type UnspentOutputData,
|
||||||
} from "@xo-cash/state";
|
} 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 { convertMnemonicToSeedBytes } from "@xo-cash/crypto";
|
||||||
|
|
||||||
import { binToHex, sha256 } from "@bitauth/libauth";
|
import { binToHex, sha256 } from "@bitauth/libauth";
|
||||||
@@ -20,6 +20,7 @@ import { MockElectrumService } from "./electrum-service";
|
|||||||
import { MockRatesService } from "./rates-service";
|
import { MockRatesService } from "./rates-service";
|
||||||
import { RatesService } from "../../../src/services/rates";
|
import { RatesService } from "../../../src/services/rates";
|
||||||
import { SettingsService } from "../../../src/services/settings";
|
import { SettingsService } from "../../../src/services/settings";
|
||||||
|
import { mkdirSync } from "node:fs";
|
||||||
|
|
||||||
export const DEFAULT_SEED =
|
export const DEFAULT_SEED =
|
||||||
"oven crop same above under tower promote decrease vocal pretty require slow";
|
"oven crop same above under tower promote decrease vocal pretty require slow";
|
||||||
@@ -68,6 +69,7 @@ export const addFakeResource = async (
|
|||||||
options: FakeResourceOptions = {},
|
options: FakeResourceOptions = {},
|
||||||
): Promise<UnspentOutputData> => {
|
): Promise<UnspentOutputData> => {
|
||||||
const resource: UnspentOutputData = {
|
const resource: UnspentOutputData = {
|
||||||
|
templateIdentifier: options.templateIdentifier ?? "test-template",
|
||||||
status: UnspentOutputStatus.CONFIRMED,
|
status: UnspentOutputStatus.CONFIRMED,
|
||||||
selectable: true,
|
selectable: true,
|
||||||
privacy: false,
|
privacy: false,
|
||||||
@@ -131,10 +133,16 @@ export const unreserveResource = async (
|
|||||||
* @returns A mock engine instance.
|
* @returns A mock engine instance.
|
||||||
*/
|
*/
|
||||||
export const createMockEngine = async (seed: string) => {
|
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.
|
// Create the in-memory storage adapter.
|
||||||
const storage = await createStorageAdapter({
|
const storage = await createStorageAdapter({
|
||||||
storageType: "inmemory",
|
storageType: StorageType.INDEXEDDB,
|
||||||
accountHash: binToHex(sha256.hash(convertMnemonicToSeedBytes(seed))),
|
accountHash: binToHex(sha256.hash(convertMnemonicToSeedBytes(seed))),
|
||||||
|
databasePath: stateDir,
|
||||||
|
databaseFilename: `xo-wallet-${randomId}.db`,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Initialize the storage adapter.
|
// Initialize the storage adapter.
|
||||||
@@ -152,13 +160,17 @@ export const createMockEngine = async (seed: string) => {
|
|||||||
await blockchainMonitor.initializeEventListeners();
|
await blockchainMonitor.initializeEventListeners();
|
||||||
|
|
||||||
// Create the engine instance.
|
// Create the engine instance.
|
||||||
const engine = new Engine(seed, state, blockchainMonitor, blockchainProvider);
|
const engine = new Engine(seed, state, blockchainProvider, blockchainMonitor);
|
||||||
await engine.initializeStateSync();
|
await engine.initializeStateSync();
|
||||||
|
|
||||||
return { engine, state, blockchainMonitor, blockchainProvider };
|
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(
|
const settings = new SettingsService(
|
||||||
`${tmpdir()}/xo-cli-tests-settings.json`,
|
`${tmpdir()}/xo-cli-tests-settings.json`,
|
||||||
);
|
);
|
||||||
@@ -187,6 +199,7 @@ export const createMockAppService = async (engine: Engine) => {
|
|||||||
mockElectrum,
|
mockElectrum,
|
||||||
rates,
|
rates,
|
||||||
settings,
|
settings,
|
||||||
|
state,
|
||||||
new Uint8Array(32).fill(1),
|
new Uint8Array(32).fill(1),
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
+1395
-1444
File diff suppressed because it is too large
Load Diff
@@ -37,7 +37,7 @@ describe("loadTemplateFromFile", () => {
|
|||||||
test("loads TypeScript templates via child process", async () => {
|
test("loads TypeScript templates via child process", async () => {
|
||||||
const tsTemplatePath = path.resolve(
|
const tsTemplatePath = path.resolve(
|
||||||
process.cwd(),
|
process.cwd(),
|
||||||
"../templates/source/p2pkh.ts",
|
"./tests/cli/mocks/template-p2pkh.ts",
|
||||||
);
|
);
|
||||||
expect(existsSync(tsTemplatePath)).toBe(true);
|
expect(existsSync(tsTemplatePath)).toBe(true);
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { binToHex } from "@bitauth/libauth";
|
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 type { XOInvitation, XOInvitationCommit } from "@xo-cash/types";
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
@@ -135,7 +135,7 @@ describe("SyncServer v2 adapter", () => {
|
|||||||
invitationIdentifier,
|
invitationIdentifier,
|
||||||
new Uint8Array(32).fill(1),
|
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));
|
sync.on("message", (message) => messages.push(message));
|
||||||
|
|
||||||
client.onMessage?.(
|
client.onMessage?.(
|
||||||
@@ -149,6 +149,6 @@ describe("SyncServer v2 adapter", () => {
|
|||||||
|
|
||||||
expect(messages).toHaveLength(1);
|
expect(messages).toHaveLength(1);
|
||||||
expect(messages[0]?.event).toBe("invitation-updated");
|
expect(messages[0]?.event).toBe("invitation-updated");
|
||||||
expect(messages[0]?.data).toBe(serializeInvitation(updated));
|
expect(messages[0]?.data).toEqual(deserializeInvitation(serializeInvitation(updated)));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user