Resolve "WIP: UTXO Selection and Storage"

This commit is contained in:
Jim Hamill
2025-01-11 08:39:24 +00:00
parent f934216d68
commit 33fd9bac4d
156 changed files with 13304 additions and 4595 deletions
@@ -0,0 +1,155 @@
import { EventEmitter } from '../utils';
import {
type AddressCallback,
type BlockchainUTXOs,
type BlockchainTransactions,
type TransactionCallback,
} from './types.js';
import { type Transaction } from '@xocash/primitives';
export type BlockchainEvents = {
isConnectedUpdated: boolean;
blockHeightUpdated: number | undefined;
};
/**
* Abstract base class representing a blockchain interface implementation.
* Extends EventEmitter to provide blockchain-specific event handling capabilities.
*
* This class serves as a template for concrete blockchain implementations,
* providing a standardized interface for common blockchain operations such as
* fetching transactions, managing UTXOs, and handling blockchain subscriptions.
*
* @abstract
* @extends {EventEmitter<BlockchainEvents>}
*/
export abstract class BaseBlockchain extends EventEmitter<BlockchainEvents> {
// TODO: Lockscript might be better as the Subscribe Handler.
// From this, we can convert it to address.
// Fulcrum has Scripthash too - but I'm hesitant about this.
// Scripthash means SHA256'ing the Lockscript.
// This is fine - but, at scale, will become expensive.
// For example, imagine a HDWallet with 2000+ addresses.
// And we want to subscribe to all of them.
// Alternatively, we could just pass in an Address Primitive?
// And then have the Adapter decide what it wants to use?
/**
* Initializes the blockchain connection and required resources.
* Should be called before performing any blockchain operations.
*
* @abstract
* @returns {Promise<void>} A promise that resolves when the blockchain connection is established
* @throws {Error} If the connection cannot be established
*/
abstract start(): Promise<void>;
/**
* Gracefully terminates the blockchain connection and cleans up resources.
* Should be called when blockchain operations are no longer needed.
*
* @abstract
* @returns {Promise<void>} A promise that resolves when the shutdown is complete
*/
abstract stop(): Promise<void>;
/**
* Retrieves all unspent transaction outputs (UTXOs) for a given address.
*
* @abstract
* @param {string} address - The blockchain address to query
* @returns {Promise<BlockchainUTXOs>} A promise that resolves with the unspent outputs
* @throws {Error} If the address is invalid or the query fails
*/
abstract fetchUnspents(address: string): Promise<BlockchainUTXOs>;
/**
* Retrieves detailed information about a specific transaction.
*
* @abstract
* @param {string} txHash - The transaction hash/ID to look up
* @returns {Promise<Transaction>} A promise that resolves with the transaction details
* @throws {Error} If the transaction cannot be found or the query fails
*/
abstract fetchTransaction(txHash: string): Promise<Transaction>;
/**
* Retrieves all transactions associated with a specific address.
*
* @abstract
* @param {string} address - The blockchain address to query
* @returns {Promise<BlockchainTransactions>} A promise that resolves with the list of transactions
* @throws {Error} If the address is invalid or the query fails
*/
abstract fetchTransactions(address: string): Promise<BlockchainTransactions>;
/**
* Broadcasts a raw transaction to the blockchain network.
*
* @abstract
* @param {Uint8Array} transaction - The serialized transaction data to broadcast
* @returns {Promise<void>} A promise that resolves when the transaction is successfully broadcast
* @throws {Error} If the transaction is invalid or broadcasting fails
*/
abstract broadcastTransaction(transaction: Uint8Array): Promise<void>;
/**
* Subscribes to updates for a specific blockchain address.
* The provided callback will be invoked when relevant events occur for the address.
*
* @abstract
* @param {string} address - The blockchain address to monitor
* @param {AddressCallback} callback - The callback function to handle address-related events
* @returns {Promise<void>} A promise that resolves when the subscription is established
* @throws {Error} If the subscription cannot be created
*/
abstract subscribeAddress(
address: string,
callback: AddressCallback,
): Promise<void>;
/**
* Removes a subscription for a specific blockchain address.
*
* @abstract
* @param {string} address - The blockchain address to unsubscribe from
* @param {AddressCallback} callback - The callback function to remove
* @returns {Promise<void>} A promise that resolves when the subscription is removed
* @throws {Error} If the subscription cannot be removed
*/
abstract unsubscribeAddress(
address: string,
callback: AddressCallback,
): Promise<void>;
/**
* Subscribes to updates for a specific transaction.
* The provided callback will be invoked when relevant events occur for the transaction.
*
* @abstract
* @param {string} transactionHash - The transaction hash to monitor
* @param {TransactionCallback} callback - The callback function to handle transaction-related events
* @returns {Promise<void>} A promise that resolves when the subscription is established
* @throws {Error} If the subscription cannot be created
*/
abstract subscribeTransaction(
transactionHash: string,
callback: TransactionCallback,
): Promise<void>;
/**
* Removes a subscription for a specific transaction.
*
* @abstract
* @param {string} transactionHash - The transaction hash to unsubscribe from
* @param {TransactionCallback} callback - The callback function to remove
* @returns {Promise<void>} A promise that resolves when the subscription is removed
* @throws {Error} If the subscription cannot be removed
*/
abstract unsubscribeTransaction(
transactionHash: string,
callback: TransactionCallback,
): Promise<void>;
}
@@ -0,0 +1,544 @@
import { BaseBlockchain } from './base-blockchain.js';
import {
type AddressCallback,
type TransactionCallback,
type BlockchainTransaction,
type BlockchainUTXO,
} from './types.js';
import { type BaseStore, StoreInMemory } from '../storage';
import {
Address,
ExtMap,
Outpoint,
Transaction,
TransactionHash,
UTXO,
} from '@xocash/primitives';
import { ElectrumClient, RPCParameter } from '@electrum-cash/network';
import {
type ElectrumProtocolEvents,
type AddressSubscribeNotification,
type HeadersSubscribeNotification,
type TransactionSubscribeNotification,
initializeElectrumClient,
broadcastTransaction,
fetchUnspentTransactionOutputs,
fetchHistory,
fetchTransaction,
subscribeToBlockheaderUpdates,
} from '@electrum-cash/protocol';
import { hexToBin } from '@bitauth/libauth';
export interface BroadcastOpts {
timeoutMs: number;
waitUntilSeen: boolean;
}
export type BlockchainElectrumEvents = {
isConnectedUpdated: boolean;
blockHeightUpdated: number | undefined;
};
export type BlockchainElectrumOpts = {
servers: Array<string>;
application: string;
};
export class BlockchainElectrum extends BaseBlockchain {
// Composables.
electrumClient!: ElectrumClient<ElectrumProtocolEvents>;
// State.
isConnected = false;
blockHeight: number | undefined = undefined;
// Subscriptions State.
addressSubscriptions: Map<string, Set<AddressCallback>> = new Map();
transactionSubscriptions: Map<string, Set<TransactionCallback>> = new Map();
constructor(
public readonly store: BaseStore = new StoreInMemory(),
public readonly opts: BlockchainElectrumOpts = {
servers: [
'bch.imaginary.cash',
'cashnode.bch.ninja',
'electrum.imaginary.cash',
],
application: 'XOApp',
},
) {
super();
}
async start() {
// TODO: We want to use a Cluster instead of a Client, but as of 2022-07-07 there are issues with Subscriptions on Cluster.
// See: https://gitlab.com/GeneralProtocols/anyhedge/whitelabel/frontend/-/issues/76
// Iterate over each server in our list of servers.
// NOTE: We are still using an Electrum Client due to the above bug.
// But, if we cannot connect to the first server, we will try the second (and third if given, etc) as a fallback.
for (const electrumServer of this.opts.servers) {
// Initialize an Electrum client using the given server.
const electrum = await initializeElectrumClient(
this.opts.application,
electrumServer,
);
// Attempt to connect to this Electrum Server.
try {
await electrum.connect();
this.isConnected = true;
this.emit('isConnectedUpdated', this.isConnected);
} catch (error) {
console.warn(error);
// If it fails to connect, try the next server in our list.
continue;
}
// Save the client.
this.electrumClient = electrum;
// Setup notification handler for connected event.
this.electrumClient.on('connected', () => {
// Set the isConnected property to true.
this.isConnected = true;
// Emit an event.
this.emit('isConnectedUpdated', this.isConnected);
});
// Setup notification handler for disconnected event.
this.electrumClient.on('disconnected', () => {
// Set the isConnected property to false.
this.isConnected = false;
// Emit an event.
this.emit('isConnectedUpdated', this.isConnected);
});
// TODO: Implement me and work out interface.
this.electrumClient.on(
'blockchain.headers.subscribe',
this.handleNewBlockchainHeaders.bind(this),
);
this.electrumClient.on(
'blockchain.address.subscribe',
this.handleAddressStatusUpdates.bind(this),
);
this.electrumClient.on(
'blockchain.transaction.subscribe',
this.handleTransactionStatusUpdates.bind(this),
);
// Subscribe to block header notifications.
await subscribeToBlockheaderUpdates(this.electrumClient);
// Return to prevent further execution.
return;
}
// Throw an error if we failed to connect to any of our Electrum Servers.
throw new Error(
'Failed to connect to Electrum. Please try restarting your browser and ensure you are currently connected to the internet.',
);
}
async stop() {
// Disconnect from Electrum.
this.electrumClient.disconnect();
}
async fetchUnspents(address: string) {
// Wait for connection to be established before attempting to subscribe.
// TODO: Consider doing this at a higher level.
if (!this.isConnected) {
await this.waitFor('isConnectedUpdated', (isConnected) => isConnected);
}
// Define the cache key.
const cacheKey = `addressUnspents.${address}`;
// Attempt to get the address unspents from the cache or fetch them if not available.
const addressUnspents = await this.store.getOrSet(cacheKey, async () => {
return await fetchUnspentTransactionOutputs(
this.electrumClient,
address,
true,
true,
);
});
// Work out the locking byecode from the address.
const lockingBytecode = Address.from(address).toLockscriptBytes();
// Transform the unspents into our UTXO format.
const utxos: Array<BlockchainUTXO> = addressUnspents.map((unspent) => {
const outpoint = new Outpoint(hexToBin(unspent.tx_hash), unspent.tx_pos);
return {
height: unspent.height,
utxo: UTXO.from(outpoint, {
valueSatoshis: BigInt(unspent.value),
token: unspent.token_data
? {
amount: BigInt(unspent.token_data.amount),
category: hexToBin(unspent.token_data.category),
nft: unspent.token_data.nft
? {
capability: unspent.token_data.nft.capability,
commitment: hexToBin(unspent.token_data.nft.commitment),
}
: undefined,
}
: undefined,
lockingBytecode,
}),
};
});
// Index the UTXOs by hashInHex:index.
const utxosIndexed = ExtMap.fromArray(utxos, (blockchainUTXO) => {
return blockchainUTXO.utxo.outpoint.toString();
});
// Return the unspents.
return utxosIndexed;
}
async fetchTransaction(txHash: string) {
// Wait for connection to be established before attempting to subscribe.
// TODO: Consider doing this at a higher level.
if (!this.isConnected) {
await this.waitFor('isConnectedUpdated', (isConnected) => isConnected);
}
// Define the cache key.
const cacheKey = `transactions.${txHash}`;
// Attempt to get the transaction hex from the cache or fetch it if not available.
const transactionHex = await this.store.getOrSet(cacheKey, async () => {
return await fetchTransaction(this.electrumClient, txHash);
});
// Decode the hex into our transaction primitive.
const transaction = Transaction.fromHex(transactionHex);
// Attempt to get the transaction hex from the cache or fetch it if not available.
// NOTE: We store JSON as opposed to hex because it is MUCH faster to decode.
/*
const transaction = new Transaction(
await this.store.getOrSet(cacheKey, async () => {
const tx = await fetchTransaction(this.electrumClient, txHash);
return Transaction.fromHex(tx).transaction;
}),
);
*/
// Return the transaction hex.
return transaction;
}
async fetchTransactions(address: string) {
// Wait for connection to be established before attempting to subscribe.
// TODO: Consider doing this at a higher level.
if (!this.isConnected) {
await this.waitFor('isConnectedUpdated', (isConnected) => isConnected);
}
// Define the cache key.
const cacheKey = `addressHistory.${address}`;
// Attempt to get the transaction history from the cache or fetch them if not available.
const addressHistory = await this.store.getOrSet(cacheKey, async () => {
return await fetchHistory(this.electrumClient, address);
});
// Get the transactions for each history item.
const transactions: Array<BlockchainTransaction> = await Promise.all(
addressHistory.map(async (item) => ({
hash: TransactionHash.fromHex(item.tx_hash),
height: item.height,
transaction: await this.fetchTransaction(item.tx_hash),
})),
);
// Index the transactions by their hash (in hex).
const transactionsIndexed = ExtMap.fromArray(transactions, (tx) =>
tx.hash.toHex(),
);
// Return the transactions.
return transactionsIndexed;
}
async waitForTransaction(
tx: Uint8Array | Transaction | string,
timeoutMs = 10_000,
) {
// Cast the transaction.
if (!(tx instanceof Transaction)) {
tx = Transaction.from(tx);
}
// Attempt to broadcast the transaction.
const waitForTxPromise = new Promise<void>(async (resolve, reject) => {
// Set a timeout.
const timeout = setTimeout(() => {
reject(new Error(`Failed to broadcast ${tx.toHashHex()}`));
}, timeoutMs);
// Create a subscribe function so that we get notified when transaction is seen by network
const subscribeCallback = (_height: number | null) => {
clearTimeout(timeout);
this.unsubscribeTransaction(tx.toHashHex(), subscribeCallback);
resolve();
};
// Subscribe to the transaction.
await this.subscribeTransaction(tx.toHashHex(), subscribeCallback);
});
return waitForTxPromise;
}
// TODO: Split out the "waitUntilSeen" into own function.
// This'll be useful for protocols like BIP70/JPP/CashConnect, etc.
// Also consider a fail callback:
// If a "waitFor" fails, we probably want to invalidate the tx by sending those inputs elsewhere.
async broadcastTransaction(
tx: Transaction | Uint8Array | string,
opts: BroadcastOpts = { timeoutMs: 10_000, waitUntilSeen: true },
) {
// Wait for connection to be established before attempting to subscribe.
// TODO: Consider doing this at a higher level.
if (!this.isConnected) {
await this.waitFor('isConnectedUpdated', (isConnected) => isConnected);
}
// Cast the transaction.
if (!(tx instanceof Transaction)) {
tx = Transaction.from(tx);
}
// Create a promise that will resolve when transaction is seen.
const waitForTxPromise = this.waitForTransaction(tx, opts.timeoutMs);
// Broadcast the transaction to our Electrum node.
await broadcastTransaction(this.electrumClient, tx.toHex());
// Wait for the transaction to be seen.
await waitForTxPromise;
}
async subscribeAddress(address: string, callback: AddressCallback) {
// Wait for connection to be established before attempting to subscribe.
// TODO: Consider doing this at a higher level.
if (!this.isConnected) {
await this.waitFor('isConnectedUpdated', (isConnected) => isConnected);
}
// Get or create the set of callbacks for this transaction
let callbacks = this.addressSubscriptions.get(address);
if (!callbacks) {
callbacks = new Set();
this.addressSubscriptions.set(address, callbacks);
await this.electrumClient.subscribe(
'blockchain.address.subscribe',
address,
);
}
// Add the callback to the set
callbacks.add(callback);
}
async unsubscribeAddress(address: string, callback: AddressCallback) {
// Wait for connection to be established before attempting to subscribe.
// TODO: Consider doing this at a higher level.
if (!this.isConnected) {
await this.waitFor('isConnectedUpdated', (isConnected) => isConnected);
}
// Get the callbacks set for this address
const callbacks = this.addressSubscriptions.get(address);
if (!callbacks) {
return;
}
// Remove the specific callback
callbacks.delete(callback);
// If there are no more callbacks, remove the address entry
// and potentially unsubscribe from blockchain updates
if (callbacks.size === 0) {
this.transactionSubscriptions.delete(address);
await this.electrumClient.unsubscribe(
'blockchain.address.unsubscribe',
address,
);
}
}
async subscribeTransaction(
transactionHash: string,
callback: TransactionCallback,
) {
// Wait for connection to be established before attempting to subscribe.
// TODO: Consider doing this at a higher level.
if (!this.isConnected) {
await this.waitFor('isConnectedUpdated', (isConnected) => isConnected);
}
// Get or create the set of callbacks for this transaction
let callbacks = this.transactionSubscriptions.get(transactionHash);
if (!callbacks) {
callbacks = new Set();
this.transactionSubscriptions.set(transactionHash, callbacks);
await this.electrumClient.subscribe(
'blockchain.transaction.subscribe',
transactionHash,
);
}
// Add the callback to the set
callbacks.add(callback);
}
async unsubscribeTransaction(
transactionHash: string,
callback: TransactionCallback,
) {
// Wait for connection to be established before attempting to subscribe.
// TODO: Consider doing this at a higher level.
if (!this.isConnected) {
await this.waitFor('isConnectedUpdated', (isConnected) => isConnected);
}
// Get the callbacks set for this transaction
const callbacks = this.transactionSubscriptions.get(transactionHash);
if (!callbacks) {
return;
}
// Remove the specific callback
callbacks.delete(callback);
// If there are no more callbacks, remove the transaction entry
// and potentially unsubscribe from blockchain updates
if (callbacks.size === 0) {
this.transactionSubscriptions.delete(transactionHash);
await this.electrumClient.subscribe(
'blockchain.transaction.subscribe',
transactionHash,
);
}
}
async request(method: string, ...parameters: Array<RPCParameter>) {
// Wait for connection to be established before attempting to subscribe.
// TODO: Consider doing this at a higher level.
if (!this.isConnected) {
await this.waitFor('isConnectedUpdated', (isConnected) => isConnected);
}
const response = await this.electrumClient.request(method, ...parameters);
if (response instanceof Error) {
throw response;
}
return response;
}
protected handleNewBlockchainHeaders(
notification: HeadersSubscribeNotification,
) {
// Extract height from the first param.
// NOTE: This is an object.
// https://electrum-cash-protocol.readthedocs.io/en/latest/protocol-methods.html#blockchain-headers-subscribe
const { height } = notification.params[0];
// Assign the block height.
this.blockHeight = height;
// Emit an event to update the blockheight.
this.emit('blockHeightUpdated', this.blockHeight);
}
protected async handleAddressStatusUpdates(
notification: AddressSubscribeNotification,
) {
// Extract parameters.
const address = notification.params[0];
const status = notification.params[1];
// Get the cached status hash for this address.
const cachedStatus = await this.store.get(`addressStatus.${address}`);
// If our cache is outdated, clear the caches for this address.
if (cachedStatus !== status) {
await this.store.delete(`addressUnspents.${address}`);
await this.store.delete(`addressHistory.${address}`);
}
// Cache the new status.
await this.store.set(`addressStatus.${address}`, status);
// Get the callbacks for this transaction
const callbacks = this.addressSubscriptions.get(address);
if (!callbacks) {
return;
}
// Execute all callbacks for this transaction.
for (const callback of callbacks) {
try {
callback(status);
} catch (error) {
// Handle or log any callback execution errors
console.error(
`Error executing callback for address ${address}:`,
error,
);
}
}
}
protected handleTransactionStatusUpdates(
notification: TransactionSubscribeNotification,
) {
const txHash = notification.params[0];
const height = notification.params[1];
// Get the callbacks for this transaction
const callbacks = this.transactionSubscriptions.get(txHash);
if (!callbacks) {
return;
}
// Execute all callbacks for this transaction.
for (const callback of callbacks) {
try {
callback(height);
} catch (error) {
// Handle or log any callback execution errors
console.error(
`Error executing callback for transaction ${txHash}:`,
error,
);
}
}
}
}
@@ -0,0 +1,297 @@
import { BaseBlockchain } from './base-blockchain.js';
import {
type AddressCallback,
type TransactionCallback,
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 BlockchainLocal extends BaseBlockchain {
private transactions: BlockchainTransactions;
private addressSubscriptions: Map<string, Set<AddressCallback>>;
private transactionSubscriptions: Map<string, Set<TransactionCallback>>;
constructor() {
super();
this.transactions = new ExtMap<BlockchainTransaction>();
this.addressSubscriptions = new Map();
this.transactionSubscriptions = new Map();
}
async start() {
console.log('BlockchainLocal: Started');
}
async stop() {
console.log('BlockchainLocal: Stopped');
}
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 fetchTransactions(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.
BlockchainLocal.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 subscribeAddress(address: string, callback: AddressCallback) {
// 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(address)!.add(callback);
}
async unsubscribeAddress(address: string, callback: AddressCallback) {
// 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) {
callbacks.delete(callback);
if (callbacks.size === 0) {
this.addressSubscriptions.delete(address);
}
}
}
async subscribeTransaction(
transactionHash: string,
callback: TransactionCallback,
) {
if (!this.transactionSubscriptions.has(transactionHash)) {
this.transactionSubscriptions.set(transactionHash, new Set());
}
this.transactionSubscriptions.get(transactionHash)!.add(callback);
}
async unsubscribeTransaction(
transactionHash: string,
callback: TransactionCallback,
) {
const callbacks = this.transactionSubscriptions.get(transactionHash);
if (callbacks) {
callbacks.delete(callback);
if (callbacks.size === 0) {
this.transactionSubscriptions.delete(transactionHash);
}
}
}
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('updated'));
}
});
// 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('updated'));
}
});
}
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(null));
}
}
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;
}
/**
* @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);
}
}
}
+100
View File
@@ -0,0 +1,100 @@
import { BaseBlockchain } from './base-blockchain.js';
import { type AddressCallback, type TransactionCallback } from './types.js';
import { BlockchainElectrum } from './blockchain-electrum.js';
import { BlockchainLocal } from './blockchain-local.js';
import { Transaction } from '@xocash/primitives';
/**
* The idea here is that this will implement the IBlockchain interface.
* But, it will allow the dev to specify multiple child IBlockchain interfaces for configuration.
* So, if the network is not available, it can fallback to the local and sync later.
*
* @example
* ```ts
* // Actual interface will be more complex, but this is the rough idea.
* const blockchain = new Blockchain([
* new BlockchainLocal(...),
* new BlockchainElectrum(...)
* ])
* ```
*/
export class Blockchain extends BaseBlockchain {
blockchainLocal: BlockchainLocal;
blockchainElectrum: BlockchainElectrum;
constructor() {
super();
this.blockchainLocal = new BlockchainLocal();
this.blockchainElectrum = new BlockchainElectrum();
}
async start() {
await Promise.all([
this.blockchainLocal.start(),
this.blockchainElectrum.start(),
]);
}
async stop() {
await Promise.all([
this.blockchainLocal.stop(),
this.blockchainElectrum.stop(),
]);
}
async fetchUnspents(address: string) {
if (this.blockchainElectrum.isConnected) {
return await this.blockchainElectrum.fetchUnspents(address);
} else {
return await this.blockchainLocal.fetchUnspents(address);
}
}
async fetchTransaction(txHash: string) {
if (this.blockchainElectrum.isConnected) {
return await this.blockchainElectrum.fetchTransaction(txHash);
} else {
return await this.blockchainLocal.fetchTransaction(txHash);
}
}
async fetchTransactions(address: string) {
if (this.blockchainElectrum.isConnected) {
return await this.blockchainElectrum.fetchTransactions(address);
} else {
return await this.blockchainLocal.fetchTransactions(address);
}
}
async broadcastTransaction(tx: Uint8Array | Transaction | string) {
if (this.blockchainElectrum.isConnected) {
return await this.blockchainElectrum.broadcastTransaction(tx);
} else {
return await this.blockchainLocal.broadcastTransaction(tx);
}
}
async subscribeAddress(_address: string, _callback: AddressCallback) {
throw new Error('Not yet implemented');
}
async unsubscribeAddress(_address: string, _callback: AddressCallback) {
throw new Error('Not yet implemented');
}
async subscribeTransaction(
_transactionHash: string,
_callback: TransactionCallback,
) {
throw new Error('Not yet implemented');
}
async unsubscribeTransaction(
_transactionHash: string,
_callback: TransactionCallback,
) {
throw new Error('Not yet implemented');
}
}
+5
View File
@@ -0,0 +1,5 @@
export * from './base-blockchain.js';
export * from './blockchain.js';
export * from './blockchain-electrum.js';
export * from './blockchain-local.js';
export * from './types.js';
+28
View File
@@ -0,0 +1,28 @@
import { Transaction, TransactionHash, UTXO, ExtMap } from '@xocash/primitives';
export type AddressCallback = (status: string | null) => void;
export type TransactionCallback = (height: number | null) => void;
export type BlockchainTransaction = {
hash: TransactionHash;
height: number;
transaction: Transaction;
// TODO: DSP
// Wait, why the fuck would I need a DSP for Transaction? Did I mean to do this on UTXO?
};
export type BlockchainTransactions = ExtMap<BlockchainTransaction>;
// TODO: You were considering using classes that extend INdexedCollection. E.g.
// class BlockchainUTXOs extends ExtMap<BlockchainUTXO>
// This probably isn't a bad idea:
// 1. You nest calculateBalanceSats and calculateBalanceTokens under it
// 2. You could also do sorting methods (more pertinent for BLockchainTransactions above)
export type BlockchainUTXO = {
height: number;
utxo: UTXO;
// TODO: DSP
};
// NOTE: To minimize refactors later, use this name elsewhere; not the `ExtMap<Type>` form.
export type BlockchainUTXOs = ExtMap<BlockchainUTXO>;