import { BlockApi, Status, TxApi, AddressApi, UTXO, VOut, Transaction, ScripthashApi, Configuration, } from "@interlay/esplora-btc-api"; import { AxiosResponse } from "axios"; import * as bitcoinjs from "bitcoinjs-lib"; import { TypeRegistry } from "@polkadot/types"; import { Bytes } from "@polkadot/types"; import Big from "big.js"; import { MAINNET_ESPLORA_BASE_PATH, REGTEST_ESPLORA_BASE_PATH, TESTNET_ESPLORA_BASE_PATH } from "../utils/constants"; import { btcToSat } from "../utils/currency"; export type TxStatus = { confirmed: boolean; confirmations: number; }; export type TxOutput = { scriptpubkey: string; scriptpubkeyAsm: string; scriptpubkeyType: string; scriptpubkeyAddress: string; value: number; }; export type TxInput = { txId: string; vout: number; isCoinbase: boolean; scriptsig: string; scriptsigAsm: string; innerRedeemscriptAsm: string; innerWitnessscriptAsm: string; sequence: number; witness: string[]; prevout: TxOutput; }; /** * Bitcoin Core API * @category Bitcoin Core */ export interface ElectrsAPI { /** * @returns The block hash of the latest Bitcoin block */ getLatestBlock(): Promise; /** * @returns The height of the latest Bitcoin block */ getLatestBlockHeight(): Promise; /** * @param txid The ID of a Bitcoin transaction * @returns The merkle inclusion proof for the transaction using bitcoind's merkleblock format. */ getMerkleProof(txid: string): Promise; /** * @param txid The ID of a Bitcoin transaction * @returns A TxStatus object, containing the confirmation status and number of confirmations */ getTransactionStatus(txid: string): Promise; /** * @param txid The ID of a Bitcoin transaction * @returns The height of the block the transaction was included in. If the block has not been confirmed, returns undefined. */ getTransactionBlockHeight(txid: string): Promise; /** * @param txid The ID of a Bitcoin transaction * @returns The raw transaction data, represented as a hex string */ getRawTransaction(txid: string): Promise; /** * Fetch the first bitcoin transaction ID based on the OP_RETURN field, recipient and amount. * Throw an error unless there is exactly one transaction with the given opcode. * * @remarks * Performs the lookup using an external service, Esplora. Requires the input string to be a hex * * @param opReturn Data string used for matching the OP_CODE of Bitcoin transactions * @param recipientAddress Match the receiving address of a transaction that contains said op_return * @param amount Match the amount (in BTC) of a transaction that contains said op_return and recipientAddress. * This parameter is only considered if `recipientAddress` is defined. * * @returns A Bitcoin transaction ID */ getTxIdByOpReturn(opReturn: string, recipientAddress?: string, amount?: Big): Promise; /** * Fetch the last bitcoin transaction ID based on the recipient address and amount. * Throw an error if no such transaction is found. * * @remarks * Performs the lookup using an external service, Esplora * * @param recipientAddress Match the receiving address of a UTXO * @param amountAsBTC Match the amount (in BTC) of a UTXO that contains said recipientAddress. * * @returns A Bitcoin transaction ID */ getTxIdByRecipientAddress(recipientAddress: string, amountAsBTC?: Big): Promise; /** * Fetch the Bitcoin transaction that matches the given TxId * * @remarks * Performs the lookup using an external service, Esplora * * @param txid A Bitcoin transaction ID * * @returns A Bitcoin Transaction object */ getTx(txid: string): Promise; /** * Fetch the Bitcoin UTXO amount that matches the given TxId and recipient * * @remarks * Performs the lookup using an external service, Esplora * * @param txid A Bitcoin transaction ID * @param recipient A Bitcoin scriptpubkey address * * @returns A UTXO amount if found, 0 otherwise */ getUtxoAmount(txid: string, recipient: string): Promise; /** * Get the parsed (as Bytes) merkle proof and raw transaction * * @remarks * Performs the lookup using an external service, Esplora * * @param txid A Bitcoin transaction ID * * @returns A tuple of Bytes object, representing [merkleProof, rawTx] */ getParsedExecutionParameters(txid: string): Promise<[Bytes, Bytes]>; /** * Return a promise that either resolves to the first txid with the given opreturn `data`, * or rejects if the `timeout` has elapsed. * * @remarks * Every 5 seconds, performs the lookup using an external service, Esplora * * @param data The opReturn of the bitcoin transaction * @param timeoutMs The duration until the Promise times out (in milliseconds) * @param retryIntervalMs The time to wait (in milliseconds) between retries * * @returns The Bitcoin txid */ waitForOpreturn(data: string, timeoutMs: number, retryIntervalMs: number): Promise; } export class DefaultElectrsAPI implements ElectrsAPI { private blockApi: BlockApi; private txApi: TxApi; private scripthashApi: ScripthashApi; private addressApi: AddressApi; constructor(network: string = "mainnet") { let basePath = ""; switch (network) { case "mainnet": basePath = MAINNET_ESPLORA_BASE_PATH; break; case "testnet": basePath = TESTNET_ESPLORA_BASE_PATH; break; case "regtest": basePath = REGTEST_ESPLORA_BASE_PATH; break; default: basePath = network; } const conf = new Configuration({ basePath }); this.blockApi = new BlockApi(conf); this.txApi = new TxApi(conf); this.scripthashApi = new ScripthashApi(conf); this.addressApi = new AddressApi(conf); } getLatestBlock(): Promise { return this.getData(this.blockApi.getLastBlockHash()); } getLatestBlockHeight(): Promise { return this.getData(this.blockApi.getLastBlockHeight()); } getMerkleProof(txid: string): Promise { return this.getData(this.txApi.getTxMerkleBlockProof(txid)); } getTx(txid: string): Promise { return this.getData(this.txApi.getTx(txid)); } async getUtxoAmount(txid: string, recipient: string): Promise { let amount = 0; if(!txid) { return amount; } const tx = await this.getTx(txid); if(!tx.vout) { return amount; } tx.vout.forEach(vout=> { if (vout.scriptpubkey_address === recipient && vout.value) { amount = vout.value; } }); return amount; } async getTxIdByRecipientAddress(recipientAddress: string, amount?: Big): Promise { try { const utxos = await this.getData(this.addressApi.getAddressUtxo(recipientAddress)); for (const utxo of utxos.reverse()) { if (this.utxoHasAtLeastAmount(utxo, amount)) { return utxo.txid; } } } catch (e) { console.log(`Error during tx lookup by address: ${e}`); } return Promise.reject(new Error("No transaction found for recipient and amount")); } /** * Check if a given UTXO has at least `amountAsBTC` * * @param vout UTXO object * @param amountAsBTC (Optional) Amount the recipient must receive * @returns Boolean value */ private utxoHasAtLeastAmount(utxo: UTXO | VOut, amount?: Big): boolean { if (amount) { const expectedBtcAsSatoshi = Number(btcToSat(amount.toString())); if (utxo.value === undefined || expectedBtcAsSatoshi > utxo.value) { return false; } } return true; } async getTxIdByOpReturn(opReturn: string, recipientAddress?: string, amount?: Big): Promise { const data = Buffer.from(opReturn, "hex"); if (data.length !== 32) { return Promise.reject("Requires a 32 byte hash as OP_RETURN"); } const opReturnBuffer = bitcoinjs.script.compile([bitcoinjs.opcodes.OP_RETURN, data]); const hash = bitcoinjs.crypto.sha256(opReturnBuffer).toString("hex"); let txs: Transaction[] = []; try { txs = await this.getData(this.scripthashApi.getRecentTxsByScripthash(hash)); } catch (e) { console.log(`Error during tx lookup by OP_RETURN: ${e}`); } for (const tx of txs) { if (tx.vout === undefined) { continue; } for (const vout of tx.vout) { if (this.txOutputHasRecipientAndAmount(vout, recipientAddress, amount)) { return tx.txid; } } } return Promise.reject("No transaction id found"); } async waitForOpreturn(data: string, timeoutMs: number, retryIntervalMs: number): Promise { return new Promise((resolve, reject) => { this.getTxIdByOpReturn(data) .then(resolve) .catch((_error) => { setTimeout(() => { console.log("Did not find opreturn, retrying..."); if(timeoutMs < retryIntervalMs) { reject("Timeout elapsed"); } else { this.waitForOpreturn(data, timeoutMs - retryIntervalMs, retryIntervalMs).then(resolve).catch(reject); } }, retryIntervalMs); }); }); } /** * Check if a given UTXO sends at least `amountAsBTC` to a certain `recipientAddress` * * @param vout UTXO object * @param recipientAddress (Optional) Address of recipient * @param amountAsBTC (Optional) Amount the recipient must receive. This parameter is only considered if the * `recipientAddress` is defined too * @returns Boolean value */ private txOutputHasRecipientAndAmount(vout: VOut, recipientAddress?: string, amount?: Big): boolean { if (recipientAddress) { if (recipientAddress !== vout.scriptpubkey_address) { return false; } return this.utxoHasAtLeastAmount(vout, amount); } return true; } /** * Broadcasts a transaction to the Bitcoin network configured in the constructor * @param hex A hex-encoded raw transaction to be broadcast to the Bitcoin blockchain * @returns The txid of the transaction */ async broadcastRawTransaction(hex: string): Promise> { return await this.txApi.postTx(hex); } async getTransactionStatus(txid: string): Promise { const status = { confirmed: false, confirmations: 0, }; const txStatus = await this.getTxStatus(txid); const latest_block_height = await this.getLatestBlockHeight(); status.confirmed = txStatus.confirmed; // NOTE: the second part of the check is only to ensure that the -1 we sometimes see // in the UI is not caused by the lib if (txStatus.block_height && latest_block_height - txStatus.block_height >= 0) { // use Bitoin Core definition of confirmations (= block depth) status.confirmations = latest_block_height - txStatus.block_height + 1; // note that block_height will only be set if confirmed == true, i.e. block // depth is at least 1. So confirmations 0 will only be returned while unconfirmed. // This is correct. } return status; } async getTransactionBlockHeight(txid: string): Promise { return (await this.getTxStatus(txid)).block_height; } async getParsedExecutionParameters(txid: string): Promise<[Bytes, Bytes]> { const [unparsedMerkleProof, unparsedRawTx] = await Promise.all([ this.getMerkleProof(txid), this.getRawTransaction(txid) ]); // To avoid taking an ApiPromise object as a constructor parameter, // use the default TypeRegistry (without custom type metadata), // because the Bytes type instantiated is provided by default. const registry = new TypeRegistry(); const merkleProof = registry.createType("Bytes", "0x" + unparsedMerkleProof); const rawTx = registry.createType("Bytes", "0x" + unparsedRawTx); return [merkleProof, rawTx]; } getRawTransaction(txid: string): Promise { return this.getData(this.txApi.getTxHex(txid)); } /** * Use the TxAPI to get the confirmationation * @param txid The ID of a Bitcoin transaction * @returns A Status object, containing transaction settlement information */ private getTxStatus(txid: string): Promise { return this.getData(this.txApi.getTxStatus(txid)); } /** * Parse an AxiosResponse Promise * @param response A generic AxiosResponse Promise * @returns The data in the response */ getData(response: Promise>): Promise { return response.then((v) => v.data); } }