import { BaseBlockchain } from './base-blockchain.js'; import { type BlockchainCallback, type BlockchainTransaction, type BlockchainTransactions, type BlockchainUTXO, type BlockchainUTXOs, type BlockchainChainTip, AddressStatusPayload, BlockHeightPayload, } from './types.js'; import { Address, BlockHeader, ExtMap, Outpoint, Transaction, UTXO, Bytes, } from '@xocash/primitives'; import { type Output, binToHex, createVirtualMachineBCH, } from '@bitauth/libauth'; /** * @remarks This provider DOES NOT enforce the 100 confirmations Coinbase Transaction rule. **/ export class BlockchainInMemory extends BaseBlockchain< AddressStatusPayload, BlockHeightPayload > { static async from(this: T) { const blockchain = new this(); await blockchain.start(); return blockchain as InstanceType; } private blockHeaders: Array; private transactions: BlockchainTransactions; private addressSubscriptions: Map< string, Set> >; private transactionSubscriptions: Map>; constructor() { super(); this.transactions = new ExtMap(); this.addressSubscriptions = new Map(); this.transactionSubscriptions = new Map(); this.blockHeaders = []; } 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) { const lockingBytecode = Address.from(address).toLockscriptHex(); 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(lockingBytecode), ) || tx.transaction .getOutputs() .some((output) => binToHex(output.lockingBytecode).includes(lockingBytecode), ) ); }); } async fetchBlockHeader(blockHeight: number) { const blockHeader = this.blockHeaders[blockHeight - 1]; if (!blockHeader) { throw new Error(`No block available for height ${blockHeight}`); } // Return the block header. return blockHeader; } async fetchChainTip(): Promise { const height = this.blockHeaders.length; const blockHeader = this.blockHeaders[height - 1]; return { height, blockHeader, }; } 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) { const payload = { type: 'address-status', // 32 random characters. Since we only need this to be unique per transaction, we can just randomise the first 16 bytes and pad the rest with 0s. status: Math.random().toString(36).substring(2).padEnd(32, '0'), } as const; // First, notify all source output listeners. tx.getInputs().forEach((input) => { // Skip coinbase-like inputs if (input.outpointTransactionHash.length === 0) return; const outpointTxHashHex = binToHex(input.outpointTransactionHash); const sourceTx = this.transactions.get(outpointTxHashHex)?.transaction; if (!sourceTx) return; const sourceOutput = sourceTx.getOutputs()[input.outpointIndex]; if (!sourceOutput) return; const address = Address.fromLockscriptBytes( sourceOutput.lockingBytecode, ).toLockscriptHex(); const callbacks = this.addressSubscriptions.get(address); if (callbacks) { callbacks.forEach((callback) => callback(address, payload)); } }); // 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, payload)); } }); } 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, includeTransactionHashes?: Array, ): Promise { // Create a transaction that pays to our subscribed address. const transaction = Transaction.fromRaw({ version: 2, locktime: 0, inputs: [ // There are a few requirements for a coinbase tx: https://learnmeabitcoin.com/technical/mining/coinbase-transaction/ { outpointIndex: 0, // The TxID for a coinbase tx input should be all zeros outpointTransactionHash: Bytes.fromHex( '0000000000000000000000000000000000000000000000000000000000000000', ), // Unlocking bytecode for a coinbase tx should start with the block height (this also helps us not have txid collisions when hashing this coinbase tx) unlockingBytecode: Bytes.fromUtf8( `${this.blockHeaders.length + 1}-coinbase-memory-blockchain`, ), sequenceNumber: 0, }, ], outputs, }); // Calculate the hash of this transaction. const coinbaseTxHash = transaction.toHash(); // Ensure that this transaction hash does not already exist (as per BIP30). if (this.transactions.has(coinbaseTxHash.toHex())) { throw new Error( `Transaction ID "${coinbaseTxHash.toHex()}" already exists (see BIP30 for details)`, ); } // Create a block header from the current timestamp. const newBlockHeader = BlockHeader.generateRandom( Math.floor(Date.now() / 1000), ); // Add it to our list of block headers. this.blockHeaders.push(newBlockHeader); const newHeight = this.blockHeaders.length; // Broadcast that transaction. await this.broadcastTransaction(transaction, { skipValidation: true }); // Confirm the coinbase transaction in this new block height. const coinbaseHex = transaction.toHashHex(); const coinbaseEntry = this.transactions.get(coinbaseHex); if (coinbaseEntry) { coinbaseEntry.height = newHeight; this.transactions.set(coinbaseHex, coinbaseEntry); } // Select which mempool txs to include. By default include all height <= 0. const toInclude: Array = Array.isArray(includeTransactionHashes) ? includeTransactionHashes.filter((h) => { const entry = this.transactions.get(h); return !!entry && entry.height <= 0 && h !== coinbaseHex; }) : (Array.from(this.transactions.values()) as BlockchainTransaction[]) .filter( (entry: BlockchainTransaction) => entry.height <= 0 && entry.hash.toHex() !== coinbaseHex, ) .map((entry: BlockchainTransaction) => entry.hash.toHex()); // Mark selected txs as confirmed in this block. for (const h of toInclude) { const entry = this.transactions.get(h); if (entry) { entry.height = newHeight; this.transactions.set(h, entry); // Notify the address subscribers this.notifyAddressSubscribers(entry.transaction); this.notifyTransactionSubscribers(entry.transaction); } } this.emit('blockHeightUpdated', newHeight); } /** * Replace only the last N blocks (N = blocks.length) of the chain with the * provided blocks. Transactions confirmed in the replaced blocks are first * unconfirmed (set to height 0). Transactions listed in the new blocks are * then confirmed at their new heights. Earlier blocks are preserved. */ replaceBlocks( blocks: Array<{ header: BlockHeader; transactions: Array; }>, options: Partial<{ dropMempool: boolean; }> = {}, ): void { const { dropMempool } = { dropMempool: false, ...options, }; if (blocks.length === 0) return; const currentHeight = this.blockHeaders.length; const preserved = Math.max(0, currentHeight - blocks.length); // 1) Unconfirm any transactions that were in the range being replaced this.transactions.forEach((entry) => { if (entry.height > preserved) { entry.height = 0; this.transactions.set(entry.hash.toHex(), entry); } }); // 2) Replace headers: keep older headers, then append new headers this.blockHeaders = this.blockHeaders.slice(0, preserved); for (let i = 0; i < blocks.length; i++) { this.blockHeaders.push(blocks[i].header); } // 3) Confirm the transactions in the new blocks at their new heights for (let i = 0; i < blocks.length; i++) { const height = preserved + i + 1; for (const t of blocks[i].transactions) { const tx = t instanceof Transaction ? t : Transaction.from(t as any); const txHashHex = tx.toHashHex(); // Ensure the tx is tracked if (!this.transactions.get(txHashHex)) { this.addTransaction({ hash: tx.toHash(), transaction: tx, height: 0, }); } const entry = this.transactions.get(txHashHex)!; entry.height = height; this.transactions.set(txHashHex, entry); // Notify the address subscribers this.notifyAddressSubscribers(tx); this.notifyTransactionSubscribers(tx); } } // If set, delete any transactions that are not in the new blocks if (dropMempool) { for (const tx of this.transactions.values()) { if (tx.height <= 0) { this.transactions.delete(tx.hash.toHex()); } } } this.emit('blockHeightUpdated', this.blockHeaders.length); } /** * @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); } } /** * Convert the in-memory blockchain transactions to an array of blocks. */ public toBlocks() { return this.blockHeaders.map((header, index) => ({ header, transactions: Array.from(this.transactions.values()).filter( (tx) => tx.height === index + 1, ), })); } }