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