Blockchain Traits
This commit is contained in:
@@ -0,0 +1,359 @@
|
||||
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<string, Set<BlockchainCallback>>;
|
||||
private transactionSubscriptions: Map<string, Set<BlockchainCallback>>;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
|
||||
this.transactions = new ExtMap<BlockchainTransaction>();
|
||||
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<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);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
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<Output>): Promise<void> {
|
||||
// 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<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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user