Files
xo-stack/packages/stack/src/blockchain/blockchain-inmemory.ts
T

552 lines
17 KiB
TypeScript

import { BaseBlockchain } from './base-blockchain.js';
import {
type BlockchainCallback,
type BlockchainTransaction,
type BlockchainTransactions,
type BlockchainUTXO,
type BlockchainUTXOs,
type BlockchainChainTip,
type 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<BlockHeightPayload> {
static async from<T extends typeof BlockchainInMemory>(this: T) {
const blockchain = new this();
await blockchain.start();
return blockchain as InstanceType<T>;
}
private blockHeaders: Array<BlockHeader>;
private transactions: BlockchainTransactions;
private addressSubscriptions: Map<string, Set<BlockchainCallback>>;
private transactionSubscriptions: Map<string, Set<BlockchainCallback>>;
constructor() {
super();
this.transactions = new ExtMap<BlockchainTransaction>();
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<BlockchainChainTip> {
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<boolean> {
// 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) {
// 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));
}
});
// 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<Output>,
includeTransactionHashes?: Array<string>,
): Promise<void> {
// 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<string> = 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<Transaction | Uint8Array | string>;
}>,
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<Output>) {
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,
),
}));
}
}