290 lines
8.1 KiB
TypeScript
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);
|
|
}
|
|
}
|
|
}
|