WIP (BlockchainLocal working)
Signed-off-by: James Zuccon <zuccon@gmail.com>
This commit is contained in:
@@ -0,0 +1,289 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user