Merge branch '87-feat-blockchain-add-re-org-and-tx-dropping-capabilities-to-blockchaininmemory' into 'development'

Resolve "feat(Blockchain): Add re-org and tx dropping capabilities to BlockchainInMemory"

Closes #87

See merge request GeneralProtocols/xo/stack!123
This commit is contained in:
Jim Hamill
2025-08-18 01:30:15 +00:00
2 changed files with 193 additions and 27 deletions
@@ -6,6 +6,8 @@ import {
type BlockchainUTXO, type BlockchainUTXO,
type BlockchainUTXOs, type BlockchainUTXOs,
type BlockchainChainTip, type BlockchainChainTip,
AddressStatusPayload,
BlockHeightPayload,
} from './types.js'; } from './types.js';
import { import {
@@ -15,6 +17,7 @@ import {
Outpoint, Outpoint,
Transaction, Transaction,
UTXO, UTXO,
Bytes,
} from '@xocash/primitives'; } from '@xocash/primitives';
import { import {
@@ -26,7 +29,10 @@ import {
/** /**
* @remarks This provider DOES NOT enforce the 100 confirmations Coinbase Transaction rule. * @remarks This provider DOES NOT enforce the 100 confirmations Coinbase Transaction rule.
**/ **/
export class BlockchainInMemory extends BaseBlockchain { export class BlockchainInMemory extends BaseBlockchain<
AddressStatusPayload,
BlockHeightPayload
> {
static async from<T extends typeof BlockchainInMemory>(this: T) { static async from<T extends typeof BlockchainInMemory>(this: T) {
const blockchain = new this(); const blockchain = new this();
await blockchain.start(); await blockchain.start();
@@ -35,7 +41,10 @@ export class BlockchainInMemory extends BaseBlockchain {
private blockHeaders: Array<BlockHeader>; private blockHeaders: Array<BlockHeader>;
private transactions: BlockchainTransactions; private transactions: BlockchainTransactions;
private addressSubscriptions: Map<string, Set<BlockchainCallback>>; private addressSubscriptions: Map<
string,
Set<BlockchainCallback<AddressStatusPayload>>
>;
private transactionSubscriptions: Map<string, Set<BlockchainCallback>>; private transactionSubscriptions: Map<string, Set<BlockchainCallback>>;
constructor() { constructor() {
@@ -78,6 +87,8 @@ export class BlockchainInMemory extends BaseBlockchain {
} }
async fetchAddressHistory(address: string) { async fetchAddressHistory(address: string) {
const lockingBytecode = Address.from(address).toLockscriptHex();
return this.transactions.filter((tx) => { return this.transactions.filter((tx) => {
// Check if any input or output involves the address // 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 // This is a simplified check and might need to be more sophisticated in a real implementation
@@ -85,17 +96,19 @@ export class BlockchainInMemory extends BaseBlockchain {
tx.transaction tx.transaction
.getInputs() .getInputs()
.some((input) => .some((input) =>
binToHex(input.unlockingBytecode).includes(address), binToHex(input.unlockingBytecode).includes(lockingBytecode),
) || ) ||
tx.transaction tx.transaction
.getOutputs() .getOutputs()
.some((output) => binToHex(output.lockingBytecode).includes(address)) .some((output) =>
binToHex(output.lockingBytecode).includes(lockingBytecode),
)
); );
}); });
} }
async fetchBlockHeader(blockHeight: number) { async fetchBlockHeader(blockHeight: number) {
const blockHeader = this.blockHeaders[blockHeight]; const blockHeader = this.blockHeaders[blockHeight - 1];
if (!blockHeader) { if (!blockHeader) {
throw new Error(`No block available for height ${blockHeight}`); throw new Error(`No block available for height ${blockHeight}`);
@@ -164,7 +177,7 @@ export class BlockchainInMemory extends BaseBlockchain {
async subscribeLockscript( async subscribeLockscript(
lockscript: string | Uint8Array, lockscript: string | Uint8Array,
callback: BlockchainCallback, callback: BlockchainCallback<AddressStatusPayload>,
) { ) {
// Fulcrum does not support subscribing to lockscripts, so we cast it to an address. // Fulcrum does not support subscribing to lockscripts, so we cast it to an address.
const address = Address.fromLockscript(lockscript); const address = Address.fromLockscript(lockscript);
@@ -178,7 +191,7 @@ export class BlockchainInMemory extends BaseBlockchain {
async unsubscribeLockscript( async unsubscribeLockscript(
lockscript: string | Uint8Array, lockscript: string | Uint8Array,
callback: BlockchainCallback, callback: BlockchainCallback<AddressStatusPayload>,
): Promise<boolean> { ): Promise<boolean> {
// Fulcrum does not support subscribing to lockscripts, so we cast it to an address. // Fulcrum does not support subscribing to lockscripts, so we cast it to an address.
const address = Address.fromLockscript(lockscript); const address = Address.fromLockscript(lockscript);
@@ -190,7 +203,10 @@ export class BlockchainInMemory extends BaseBlockchain {
return await this.unsubscribeAddress(address.toCashAddr(), callback); return await this.unsubscribeAddress(address.toCashAddr(), callback);
} }
async subscribeAddress(address: string, callback: BlockchainCallback) { async subscribeAddress(
address: string,
callback: BlockchainCallback<AddressStatusPayload>,
) {
// Convert the address to it's lockscript hex so that it's consistently represented. // Convert the address to it's lockscript hex so that it's consistently represented.
const lockscriptHex = Address.from(address).toLockscriptHex(); const lockscriptHex = Address.from(address).toLockscriptHex();
@@ -206,7 +222,10 @@ export class BlockchainInMemory extends BaseBlockchain {
return async () => await this.unsubscribeAddress(address, callback); return async () => await this.unsubscribeAddress(address, callback);
} }
async unsubscribeAddress(address: string, callback: BlockchainCallback) { async unsubscribeAddress(
address: string,
callback: BlockchainCallback<AddressStatusPayload>,
) {
// Convert the address to it's lockscript hex so that it's consistently represented. // Convert the address to it's lockscript hex so that it's consistently represented.
const lockscriptHex = Address.from(address).toLockscriptHex(); const lockscriptHex = Address.from(address).toLockscriptHex();
@@ -263,29 +282,32 @@ export class BlockchainInMemory extends BaseBlockchain {
} }
private notifyAddressSubscribers(tx: Transaction) { private notifyAddressSubscribers(tx: Transaction) {
// Derive the UTXO state so that we can lookup source outputs. const payload = {
const utxoState = this.deriveUtxoSet(); 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. // First, notify all source output listeners.
tx.getInputs().forEach((input) => { tx.getInputs().forEach((input) => {
const sourceOutput = utxoState.get( // Skip coinbase-like inputs
`${input.outpointTransactionHash};${input.outpointIndex}`, if (input.outpointTransactionHash.length === 0) return;
);
// If there is no corresponding source output, skip this notification. const outpointTxHashHex = binToHex(input.outpointTransactionHash);
// NOTE: This can occur if skipValidation is set to true in broadcast (which would be used for "coinbase" transactions). const sourceTx = this.transactions.get(outpointTxHashHex)?.transaction;
if (!sourceOutput) { if (!sourceTx) return;
return;
} const sourceOutput = sourceTx.getOutputs()[input.outpointIndex];
if (!sourceOutput) return;
const address = Address.fromLockscriptBytes( const address = Address.fromLockscriptBytes(
sourceOutput.utxo.output.lockingBytecode, sourceOutput.lockingBytecode,
).toLockscriptHex(); ).toLockscriptHex();
const callbacks = this.addressSubscriptions.get(address); const callbacks = this.addressSubscriptions.get(address);
if (callbacks) { if (callbacks) {
callbacks.forEach((callback) => callback(address)); callbacks.forEach((callback) => callback(address, payload));
} }
}); });
@@ -298,7 +320,7 @@ export class BlockchainInMemory extends BaseBlockchain {
const callbacks = this.addressSubscriptions.get(address); const callbacks = this.addressSubscriptions.get(address);
if (callbacks) { if (callbacks) {
callbacks.forEach((callback) => callback(address)); callbacks.forEach((callback) => callback(address, payload));
} }
}); });
} }
@@ -351,16 +373,26 @@ export class BlockchainInMemory extends BaseBlockchain {
return utxoMap; return utxoMap;
} }
async mineBlock(outputs: Array<Output>): Promise<void> { async mineBlock(
outputs: Array<Output>,
includeTransactionHashes?: Array<string>,
): Promise<void> {
// Create a transaction that pays to our subscribed address. // Create a transaction that pays to our subscribed address.
const transaction = Transaction.fromRaw({ const transaction = Transaction.fromRaw({
version: 2, version: 2,
locktime: 0, locktime: 0,
inputs: [ inputs: [
// There are a few requirements for a coinbase tx: https://learnmeabitcoin.com/technical/mining/coinbase-transaction/
{ {
outpointIndex: 0, outpointIndex: 0,
outpointTransactionHash: new Uint8Array(), // The TxID for a coinbase tx input should be all zeros
unlockingBytecode: new Uint8Array(), 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, sequenceNumber: 0,
}, },
], ],
@@ -384,9 +416,123 @@ export class BlockchainInMemory extends BaseBlockchain {
// Add it to our list of block headers. // Add it to our list of block headers.
this.blockHeaders.push(newBlockHeader); this.blockHeaders.push(newBlockHeader);
const newHeight = this.blockHeaders.length;
// Broadcast that transaction. // Broadcast that transaction.
await this.broadcastTransaction(transaction, { skipValidation: true }); 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);
} }
/** /**
@@ -409,4 +555,16 @@ export class BlockchainInMemory extends BaseBlockchain {
throw new Error(virtualMachineResult); 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,
),
}));
}
} }
+10 -2
View File
@@ -24,6 +24,7 @@ import {
binToHex, binToHex,
walletTemplateP2pkhNonHd, walletTemplateP2pkhNonHd,
walletTemplateToCompilerBCH, walletTemplateToCompilerBCH,
Output,
} from '@bitauth/libauth'; } from '@bitauth/libauth';
export type WalletP2PKHGenesisData = { export type WalletP2PKHGenesisData = {
@@ -228,9 +229,16 @@ export class WalletP2PKH extends BaseWallet {
const getSourceOutputs = async (tx: BlockchainTransaction) => { const getSourceOutputs = async (tx: BlockchainTransaction) => {
const sourceOutputPromises = tx.transaction const sourceOutputPromises = tx.transaction
.getInputs() .getInputs()
.map((input) => getSourceOutput(input)); // Coinbase transactions do not have source output, so we return null
.map((input) =>
getSourceOutput(input).catch(() => {
return undefined;
}),
);
return await Promise.all(sourceOutputPromises); return (await Promise.all(sourceOutputPromises)).filter(
(output): output is Output => output !== undefined,
);
}; };
// Get the source outputs for each of the transactions. // Get the source outputs for each of the transactions.