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
+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 {