Resolve "WIP: UTXO Selection and Storage"
This commit is contained in:
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user