import { BaseBlockchain } from './base-blockchain.js'; import { type BlockchainCallback, type BlockchainTransaction, type BlockchainTransactions, type BlockchainUTXO, type BlockchainUTXOs, } from './types.js'; import { Address, ExtMap, Outpoint, Transaction, UTXO, } from '@xocash/primitives'; import { type Output, binToHex, createVirtualMachineBCH, } from '@bitauth/libauth'; // Local Blockchain Adapter. // This simulates the BCH Blockchain. export class BlockchainInMemory extends BaseBlockchain { static from() { return new BlockchainInMemory(); } private transactions: BlockchainTransactions; private addressSubscriptions: Map>; private transactionSubscriptions: Map>; constructor() { super(); this.transactions = new ExtMap(); this.addressSubscriptions = new Map(); this.transactionSubscriptions = new Map(); } async start() {} async stop() {} async fetchUnspents(address: string) { // Derive the current UTXO set. const utxoSet = this.deriveUtxoSet().filter((blockchainUTXO) => { // Decode the address and locking bytecode. const utxoAddress = Address.fromLockscriptBytes( blockchainUTXO.utxo.output.lockingBytecode, ); const needleAddress = Address.from(address); // Compare the lockscript hex. return utxoAddress.toLockscriptHex() === needleAddress.toLockscriptHex(); }); return utxoSet; } async fetchTransaction(txHash: string) { const tx = this.transactions.get(txHash); if (!tx) { throw new Error(`Transaction "${txHash}" does not exist`); } return tx.transaction; } async fetchAddressHistory(address: string) { return this.transactions.filter((tx) => { // Check if any input or output involves the address // This is a simplified check and might need to be more sophisticated in a real implementation return ( tx.transaction .getInputs() .some((input) => binToHex(input.unlockingBytecode).includes(address), ) || tx.transaction .getOutputs() .some((output) => binToHex(output.lockingBytecode).includes(address)) ); }); } async broadcastTransaction( tx: Transaction | Uint8Array | string, opts: { skipValidation: boolean } = { skipValidation: false }, ) { // Cast to transaction object. if (!(tx instanceof Transaction)) { tx = Transaction.from(tx); } // Derive the UTXO set so that we can find source outputs. const utxoSet = this.deriveUtxoSet(); if (!opts.skipValidation) { // Find the source outputs from our UTXO set so that we can make sure the transaction is valid. const sourceOutputs = tx.getInputs().map((input) => { const utxoId = `${binToHex(input.outpointTransactionHash)}:${ input.outpointIndex }`; const blockchainUTXO = utxoSet.get(utxoId); if (!blockchainUTXO) { throw new Error(`BlockchainUTXO "${utxoId}" does not exist`); } return blockchainUTXO.utxo; }); // Validate the transaction. BlockchainInMemory.validateTransaction( tx, sourceOutputs.map((utxo) => utxo.output), ); } // Add the transaction. this.addTransaction({ hash: tx.toHash(), transaction: tx, height: 0, }); // Notify our address and transaction subscribers. this.notifyAddressSubscribers(tx); this.notifyTransactionSubscribers(tx); } async subscribeLockscript( lockscript: string | Uint8Array, callback: BlockchainCallback, ) { // Fulcrum does not support subscribing to lockscripts, so we cast it to an address. const address = Address.fromLockscript(lockscript); // TODO: Refactor to work around ScriptHash for Fulcrum. // Otherwise, we may get issues with CashAddr/Legacy Addresses. // Use our subscribeAddress function. return await this.subscribeAddress(address.toCashAddr(), callback); } async unsubscribeLockscript( lockscript: string | Uint8Array, callback: BlockchainCallback, ): Promise { // Fulcrum does not support subscribing to lockscripts, so we cast it to an address. const address = Address.fromLockscript(lockscript); // TODO: Refactor to work around ScriptHash for Fulcrum. // Otherwise, we may get issues with CashAddr/Legacy Addresses. // Use our unsubscribeAddress function. return await this.unsubscribeAddress(address.toCashAddr(), callback); } async subscribeAddress(address: string, callback: BlockchainCallback) { // Convert the address to it's lockscript hex so that it's consistently represented. const lockscriptHex = Address.from(address).toLockscriptHex(); // If no subscriptions exist yet on this lockscript, create a new Set. if (!this.addressSubscriptions.has(lockscriptHex)) { this.addressSubscriptions.set(lockscriptHex, new Set()); } // Add the address notification callback. this.addressSubscriptions.get(lockscriptHex)!.add(callback); // Return an unsubscribe callback. return async () => await this.unsubscribeAddress(address, callback); } async unsubscribeAddress(address: string, callback: BlockchainCallback) { // Convert the address to it's lockscript hex so that it's consistently represented. const lockscriptHex = Address.from(address).toLockscriptHex(); // Find all callbacks this lockscript is subscribed to. const callbacks = this.addressSubscriptions.get(lockscriptHex); if (callbacks) { if (callbacks.has(callback)) { return false; } callbacks.delete(callback); if (callbacks.size === 0) { this.addressSubscriptions.delete(address); } return true; } return false; } async subscribeTransaction( transactionHash: string, callback: BlockchainCallback, ) { if (!this.transactionSubscriptions.has(transactionHash)) { this.transactionSubscriptions.set(transactionHash, new Set()); } this.transactionSubscriptions.get(transactionHash)!.add(callback); // Return an unsubscribe callback. return async () => await this.unsubscribeTransaction(transactionHash, callback); } async unsubscribeTransaction( transactionHash: string, callback: BlockchainCallback, ) { const callbacks = this.transactionSubscriptions.get(transactionHash); if (callbacks) { if (!callbacks.has(callback)) { return false; } callbacks.delete(callback); if (callbacks.size === 0) { this.transactionSubscriptions.delete(transactionHash); } } return false; } private notifyAddressSubscribers(tx: Transaction) { // Derive the UTXO state so that we can lookup source outputs. const utxoState = this.deriveUtxoSet(); // First, notify all source output listeners. tx.getInputs().forEach((input) => { const sourceOutput = utxoState.get( `${input.outpointTransactionHash};${input.outpointIndex}`, ); // If there is no corresponding source output, skip this notification. // NOTE: This can occur if skipValidation is set to true in broadcast (which would be used for "coinbase" transactions). if (!sourceOutput) { return; } const address = Address.fromLockscriptBytes( sourceOutput.utxo.output.lockingBytecode, ).toLockscriptHex(); const callbacks = this.addressSubscriptions.get(address); if (callbacks) { callbacks.forEach((callback) => callback(address)); } }); // Then notify all output listeners. tx.getOutputs().forEach((output) => { const address = Address.fromLockscriptBytes( output.lockingBytecode, ).toLockscriptHex(); const callbacks = this.addressSubscriptions.get(address); if (callbacks) { callbacks.forEach((callback) => callback(address)); } }); } private notifyTransactionSubscribers(tx: Transaction) { const txHash = tx.toHashHex(); const callbacks = this.transactionSubscriptions.get(txHash); if (callbacks) { // In this local simulation, we're not tracking block height, so we'll use null callbacks.forEach((callback) => callback(txHash)); } } addTransaction(tx: BlockchainTransaction) { this.transactions.set(tx.hash.toHex(), tx); } removeTransaction(txHash: string) { this.transactions.delete(txHash); } deriveUtxoSet(): BlockchainUTXOs { const utxoMap: BlockchainUTXOs = new ExtMap(); // First, add all outputs as potential UTXOs for (const tx of this.transactions.values()) { tx.transaction.getOutputs().forEach((output, outpointIndex) => { const utxo: BlockchainUTXO = { height: 0, utxo: UTXO.from(new Outpoint(tx.hash.toBytes(), outpointIndex), { valueSatoshis: output.valueSatoshis, lockingBytecode: output.lockingBytecode, }), }; utxoMap.set(`${tx.hash.toHex()}:${outpointIndex}`, utxo); }); } // Then, remove all spent outputs for (const tx of this.transactions.values()) { tx.transaction.getInputs().forEach((input) => { const outpointTransactionHashHex = binToHex( input.outpointTransactionHash, ); utxoMap.delete(`${outpointTransactionHashHex}:${input.outpointIndex}`); }); } return utxoMap; } async mineBlock(outputs: Array): Promise { // Create a transaction that pays to our subscribed address. const transaction = Transaction.fromRaw({ version: 2, locktime: 0, inputs: [ { outpointIndex: 0, outpointTransactionHash: new Uint8Array(), unlockingBytecode: new Uint8Array(), sequenceNumber: 0, }, ], outputs, }); // Broadcast that transaction. await this.broadcastTransaction(transaction, { skipValidation: true }); } /** * @remarks Consider moving this to Transaction Primitive. Trade-off is that it's probably big. Maybe standalone util? */ static validateTransaction(tx: Transaction, sourceOutputs: Array) { const virtualMachine = createVirtualMachineBCH(); const virtualMachineResult = virtualMachine.verify({ transaction: { version: tx.getVersion(), locktime: tx.getLocktime(), inputs: tx.getInputs(), outputs: tx.getOutputs(), }, sourceOutputs, }); // If simulation of the transaction fails... if (typeof virtualMachineResult === 'string') { throw new Error(virtualMachineResult); } } }