The tests work
This commit is contained in:
@@ -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 (
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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?
|
||||
|
||||
@@ -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
@@ -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) => ({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user