Files
xo-stack/packages/wallet/src/blockchain/blockchain-electrum.ts
T
2024-09-12 21:33:11 +10:00

290 lines
8.1 KiB
TypeScript

import type {
IBlockchain,
AddressCallback,
TransactionCallback,
UTXO,
} from './interface.js';
import {
ElectrumNotification,
ElectrumRequest,
AddressGetHistory,
AddressListUnspent,
TransactionGet,
} from './blockchain-electrum-types.js';
import { Address, Transaction } from '@xo/primitives';
import { ElectrumClient } from '@electrum-cash/network';
import { hexToBin } from '@bitauth/libauth';
export interface BroadcastOpts {
timeout: number;
waitUntilSeen: boolean;
}
export class BlockchainElectrum implements IBlockchain {
electrumClient!: ElectrumClient;
// Address subscriptions.
addressSubscriptions: { [address: string]: AddressCallback } = {};
transactionSubscriptions: { [txHash: string]: TransactionCallback } = {};
constructor(
public readonly servers: string[],
public readonly application = 'BCHApp',
public readonly version = '1.5'
) {}
async start(): Promise<void> {
// 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.servers) {
// Initialize an Electrum client using the given server.
const electrum = new ElectrumClient(
this.application,
this.version,
electrumServer,
50004,
'wss'
);
// Attempt to connect to this Electrum Server.
try {
await electrum.connect();
} 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.
this.electrumClient.on('notification', this.onNotification.bind(this));
// 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(): Promise<void> {
// Disconnect from Electrum.
this.electrumClient.disconnect();
}
async fetchUnspents(address: string): Promise<UTXO[]> {
// Fetch unspents for the given address from our Electrum server(s).
const unspentOutputs = await this.request<AddressListUnspent>(
'blockchain.address.listunspent',
address,
'include_tokens'
);
// Work out the locking byecode from the address.
const lockingBytecode =
Address.fromCashAddrOrLegacy(address).toLockscriptBytes();
// Transform the unspents into our UTXO format.
const utxos = unspentOutputs.map((unspent) => ({
outpointTransactionHash: hexToBin(unspent.tx_hash),
outpointIndex: unspent.tx_pos,
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,
}));
// Return the unspents.
return utxos;
}
async fetchTransactions(address: string): Promise<Transaction[]> {
// Get the history of the address from our Electrum server(s).
const history = await this.request<AddressGetHistory>(
'blockchain.address.get_history',
address,
0,
-1
);
// Get the transactions for each history item.
const transactions = await Promise.all(
history.map((item) =>
this.request<TransactionGet>(
'blockchain.transaction.get',
item.tx_hash,
false
)
)
);
// Decode the transactions.
const decodedTransactions = transactions.map((tx) =>
Transaction.fromHex(tx)
);
// Return the transactions.
return decodedTransactions;
}
// TODO: async broadcast() method
// This should accept options: { waitForSeen: boolean }
async broadcastTransaction(
tx: Uint8Array | Transaction | string,
opts: BroadcastOpts = { timeout: 10_000, waitUntilSeen: true }
) {
// Cast the transaction.
if (!(tx instanceof Transaction)) {
tx = Transaction.fromAny(tx);
}
// Attempt to broadcast the transaction.
await new Promise<void>(async (resolve, reject) => {
// Set a timeout.
const timeout = setTimeout(() => {
reject(new Error(`Failed to broadcast ${tx.toHashHex()}`));
}, opts.timeout);
// 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);
// Broadcast the transaction to our Electrum node.
await this.request('blockchain.transaction.broadcast', tx.toHex());
});
}
async subscribeAddress(address: string, callback: AddressCallback) {
await this.electrumClient.subscribe(
'blockchain.address.subscribe',
address
);
this.addressSubscriptions[address] = callback;
}
async unsubscribeAddress(address: string, _callback: AddressCallback) {
if (!this.addressSubscriptions[address]) {
return;
}
delete this.addressSubscriptions[address];
try {
await this.electrumClient.unsubscribe(
'blockchain.address.unsubscribe',
address
);
} catch (error) {
console.warn(`${error}`);
}
}
async subscribeTransaction(
transactionHash: string,
callback: TransactionCallback
) {
await this.electrumClient.subscribe(
'blockchain.transaction.subscribe',
transactionHash
);
this.transactionSubscriptions[transactionHash] = callback;
}
async unsubscribeTransaction(
transactionHash: string,
_callback: TransactionCallback
) {
if (!this.transactionSubscriptions[transactionHash]) {
return;
}
delete this.transactionSubscriptions[transactionHash];
try {
await this.electrumClient.unsubscribe(
'blockchain.transaction.unsubscribe',
transactionHash
);
} catch (error) {
console.warn(`${error}`);
}
}
async request<T extends ElectrumRequest>(
endpoint: T['method'],
...params: T['params']
): Promise<T['response']> {
const response = await this.electrumClient.request(endpoint, ...params);
if (response instanceof Error) {
throw response;
}
return response;
}
protected onNotification(data: ElectrumNotification) {
// Handle Address notification.
if (data.method === 'blockchain.address.subscribe') {
const address = data.params[0];
const status = data.params[1];
const subscription = this.addressSubscriptions[address];
if (!subscription) {
console.warn(
`Notification for address ${address} subscribed to, but has no handler`
);
return;
}
// Trigger the subscription's callback.
subscription(status);
}
// Handle Transaction Notification.
if (data.method === 'blockchain.transaction.subscribe') {
const txHash = data.params[0];
const height = data.params[1];
const subscription = this.transactionSubscriptions[txHash];
if (!subscription) {
console.warn(
`Notification for tx ${txHash} subscribed to, but has no handler`
);
return;
}
// Trigger the subscription's callback.
subscription(height);
}
}
}