636 lines
19 KiB
TypeScript
636 lines
19 KiB
TypeScript
import { BaseBlockchain } from './base-blockchain.js';
|
|
import {
|
|
type BlockchainCallback,
|
|
type BlockchainTransaction,
|
|
type BlockchainUTXO,
|
|
} from './types.js';
|
|
|
|
import { type BaseStore, StoreInMemory } from '../storage/index.js';
|
|
|
|
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 type BlockchainElectrumParams = {
|
|
store?: BaseStore;
|
|
opts?: Partial<BlockchainElectrumOpts>;
|
|
};
|
|
|
|
export type AddressStatusPayload = {
|
|
type: 'address-status';
|
|
status: string | null;
|
|
};
|
|
|
|
export type BlockHeightPayload = {
|
|
type: 'block-height';
|
|
height: number | null;
|
|
};
|
|
|
|
export class BlockchainElectrum extends BaseBlockchain<
|
|
AddressStatusPayload,
|
|
BlockHeightPayload
|
|
> {
|
|
// Composables.
|
|
electrumClient!: ElectrumClient<ElectrumProtocolEvents>;
|
|
|
|
// State.
|
|
isConnected = false;
|
|
blockHeight: number | undefined = undefined;
|
|
|
|
// Address Subscriptions State.
|
|
addressSubscriptions: Map<
|
|
string,
|
|
Set<BlockchainCallback<AddressStatusPayload>>
|
|
> = new Map();
|
|
|
|
// Transaction Subscription State.
|
|
transactionSubscriptions: Map<
|
|
string,
|
|
Set<BlockchainCallback<BlockHeightPayload>>
|
|
> = new Map();
|
|
|
|
// Options
|
|
store: BaseStore;
|
|
opts: BlockchainElectrumOpts;
|
|
|
|
constructor(public readonly params: BlockchainElectrumParams = {}) {
|
|
super();
|
|
|
|
// Set the store, or use the memory store by default.
|
|
this.store = params.store ?? new StoreInMemory();
|
|
|
|
// Combine provided options with default options.
|
|
this.opts = {
|
|
servers: [
|
|
'bch.imaginary.cash',
|
|
'cashnode.bch.ninja',
|
|
'electrum.imaginary.cash',
|
|
],
|
|
application: 'XOApp',
|
|
...params.opts,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Creates a new BlockchainElectrum instance.
|
|
* @param {BlockchainElectrumParams} params - The parameters for the BlockchainElectrum instance.
|
|
* @returns {BlockchainElectrum} A new BlockchainElectrum instance.
|
|
*/
|
|
static async from(
|
|
params: BlockchainElectrumParams = {},
|
|
): Promise<BlockchainElectrum> {
|
|
const blockchain = new BlockchainElectrum(params);
|
|
|
|
await blockchain.start();
|
|
|
|
return blockchain;
|
|
}
|
|
|
|
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 transactionRaw = await this.store.getOrSet(cacheKey, async () => {
|
|
const txHex = await fetchTransaction(this.electrumClient, txHash);
|
|
return Transaction.fromHex(txHex).toRaw();
|
|
});
|
|
|
|
// Decode the hex into our transaction primitive.
|
|
const transaction = Transaction.fromRaw(transactionRaw);
|
|
|
|
// Return the transaction hex.
|
|
return transaction;
|
|
}
|
|
|
|
async fetchAddressHistory(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 = () => {
|
|
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 subscribeLockscript(
|
|
lockscript: string | Uint8Array,
|
|
callback: BlockchainCallback<AddressStatusPayload>,
|
|
) {
|
|
// Fulcrum does not support subscribing to lockscripts, so we cast it to an address.
|
|
const address = Address.fromLockscript(lockscript);
|
|
|
|
// TODO: Refactor to work around ScriptHash for Fulcrum.
|
|
// Otherwise, we may get issues with CashAddr/Legacy Addresses.
|
|
|
|
// Use our subscribeAddress function.
|
|
return await this.subscribeAddress(address.toCashAddr(), callback);
|
|
}
|
|
|
|
async unsubscribeLockscript(
|
|
lockscript: string | Uint8Array,
|
|
callback: BlockchainCallback<AddressStatusPayload>,
|
|
): Promise<boolean> {
|
|
// Fulcrum does not support subscribing to lockscripts, so we cast it to an address.
|
|
const address = Address.fromLockscript(lockscript);
|
|
|
|
// TODO: Refactor to work around ScriptHash for Fulcrum.
|
|
// Otherwise, we may get issues with CashAddr/Legacy Addresses.
|
|
|
|
// Use our unsubscribeAddress function.
|
|
return await this.unsubscribeAddress(address.toCashAddr(), callback);
|
|
}
|
|
|
|
async subscribeAddress(
|
|
address: string,
|
|
callback: BlockchainCallback<AddressStatusPayload>,
|
|
) {
|
|
// 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);
|
|
}
|
|
|
|
// TODO: Refactor to work around ScriptHash for Fulcrum.
|
|
// Otherwise, we may get issues with CashAddr/Legacy Addresses.
|
|
|
|
// 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: BlockchainCallback<AddressStatusPayload>,
|
|
) {
|
|
// 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);
|
|
}
|
|
|
|
// TODO: Refactor to work around ScriptHash for Fulcrum.
|
|
// Otherwise, we may get issues with CashAddr/Legacy Addresses.
|
|
|
|
// Get the callbacks set for this address
|
|
const callbacks = this.addressSubscriptions.get(address);
|
|
if (!callbacks) {
|
|
return false;
|
|
}
|
|
|
|
// Find the index of the callback in the handlers array
|
|
if (!callbacks.has(callback)) {
|
|
return false;
|
|
}
|
|
|
|
// 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,
|
|
);
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
async subscribeTransaction(
|
|
transactionHash: string,
|
|
callback: BlockchainCallback<BlockHeightPayload>,
|
|
) {
|
|
// 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: BlockchainCallback<BlockHeightPayload>,
|
|
) {
|
|
// 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 false;
|
|
}
|
|
|
|
if (!callbacks.has(callback)) {
|
|
return false;
|
|
}
|
|
|
|
// 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,
|
|
);
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
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(address, { type: 'address-status', 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(txHash, { type: 'block-height', height });
|
|
} catch (error) {
|
|
// Handle or log any callback execution errors
|
|
console.error(
|
|
`Error executing callback for transaction ${txHash}:`,
|
|
error,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|