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,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,
);
}
}
}
}