Merge branch 'master' into cookie
This commit is contained in:
@@ -3,10 +3,13 @@ import { IEsploraApi } from './esplora-api.interface';
|
||||
export interface AbstractBitcoinApi {
|
||||
$getRawMempool(): Promise<IEsploraApi.Transaction['txid'][]>;
|
||||
$getRawTransaction(txId: string, skipConversion?: boolean, addPrevout?: boolean, lazyPrevouts?: boolean): Promise<IEsploraApi.Transaction>;
|
||||
$getMempoolTransactions(txids: string[]): Promise<IEsploraApi.Transaction[]>;
|
||||
$getAllMempoolTransactions(lastTxid: string);
|
||||
$getTransactionHex(txId: string): Promise<string>;
|
||||
$getBlockHeightTip(): Promise<number>;
|
||||
$getBlockHashTip(): Promise<string>;
|
||||
$getTxIdsForBlock(hash: string): Promise<string[]>;
|
||||
$getTxsForBlock(hash: string): Promise<IEsploraApi.Transaction[]>;
|
||||
$getBlockHash(height: number): Promise<string>;
|
||||
$getBlockHeader(hash: string): Promise<string>;
|
||||
$getBlock(hash: string): Promise<IEsploraApi.Block>;
|
||||
@@ -14,10 +17,14 @@ export interface AbstractBitcoinApi {
|
||||
$getAddress(address: string): Promise<IEsploraApi.Address>;
|
||||
$getAddressTransactions(address: string, lastSeenTxId: string): Promise<IEsploraApi.Transaction[]>;
|
||||
$getAddressPrefix(prefix: string): string[];
|
||||
$getScriptHash(scripthash: string): Promise<IEsploraApi.ScriptHash>;
|
||||
$getScriptHashTransactions(address: string, lastSeenTxId: string): Promise<IEsploraApi.Transaction[]>;
|
||||
$sendRawTransaction(rawTransaction: string): Promise<string>;
|
||||
$getOutspend(txId: string, vout: number): Promise<IEsploraApi.Outspend>;
|
||||
$getOutspends(txId: string): Promise<IEsploraApi.Outspend[]>;
|
||||
$getBatchedOutspends(txId: string[]): Promise<IEsploraApi.Outspend[][]>;
|
||||
|
||||
startHealthChecks(): void;
|
||||
}
|
||||
export interface BitcoinRpcCredentials {
|
||||
host: string;
|
||||
|
||||
@@ -5,6 +5,7 @@ import { IEsploraApi } from './esplora-api.interface';
|
||||
import blocks from '../blocks';
|
||||
import mempool from '../mempool';
|
||||
import { TransactionExtended } from '../../mempool.interfaces';
|
||||
import transactionUtils from '../transaction-utils';
|
||||
|
||||
class BitcoinApi implements AbstractBitcoinApi {
|
||||
private rawMempoolCache: IBitcoinApi.RawMempool | null = null;
|
||||
@@ -29,6 +30,7 @@ class BitcoinApi implements AbstractBitcoinApi {
|
||||
weight: block.weight,
|
||||
previousblockhash: block.previousblockhash,
|
||||
mediantime: block.mediantime,
|
||||
stale: block.confirmations === -1,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -58,23 +60,33 @@ class BitcoinApi implements AbstractBitcoinApi {
|
||||
});
|
||||
}
|
||||
|
||||
$getTransactionHex(txId: string): Promise<string> {
|
||||
return this.$getRawTransaction(txId, true)
|
||||
.then((tx) => tx.hex || '');
|
||||
$getMempoolTransactions(txids: string[]): Promise<IEsploraApi.Transaction[]> {
|
||||
throw new Error('Method getMempoolTransactions not supported by the Bitcoin RPC API.');
|
||||
}
|
||||
|
||||
$getAllMempoolTransactions(lastTxid: string): Promise<IEsploraApi.Transaction[]> {
|
||||
throw new Error('Method getAllMempoolTransactions not supported by the Bitcoin RPC API.');
|
||||
|
||||
}
|
||||
|
||||
async $getTransactionHex(txId: string): Promise<string> {
|
||||
const txInMempool = mempool.getMempool()[txId];
|
||||
if (txInMempool && txInMempool.hex) {
|
||||
return txInMempool.hex;
|
||||
}
|
||||
|
||||
return this.bitcoindClient.getRawTransaction(txId, true)
|
||||
.then((transaction: IBitcoinApi.Transaction) => {
|
||||
return transaction.hex;
|
||||
});
|
||||
}
|
||||
|
||||
$getBlockHeightTip(): Promise<number> {
|
||||
return this.bitcoindClient.getChainTips()
|
||||
.then((result: IBitcoinApi.ChainTips[]) => {
|
||||
return result.find(tip => tip.status === 'active')!.height;
|
||||
});
|
||||
return this.bitcoindClient.getBlockCount();
|
||||
}
|
||||
|
||||
$getBlockHashTip(): Promise<string> {
|
||||
return this.bitcoindClient.getChainTips()
|
||||
.then((result: IBitcoinApi.ChainTips[]) => {
|
||||
return result.find(tip => tip.status === 'active')!.hash;
|
||||
});
|
||||
return this.bitcoindClient.getBestBlockHash();
|
||||
}
|
||||
|
||||
$getTxIdsForBlock(hash: string): Promise<string[]> {
|
||||
@@ -82,6 +94,10 @@ class BitcoinApi implements AbstractBitcoinApi {
|
||||
.then((rpcBlock: IBitcoinApi.Block) => rpcBlock.tx);
|
||||
}
|
||||
|
||||
$getTxsForBlock(hash: string): Promise<IEsploraApi.Transaction[]> {
|
||||
throw new Error('Method getTxsForBlock not supported by the Bitcoin RPC API.');
|
||||
}
|
||||
|
||||
$getRawBlock(hash: string): Promise<Buffer> {
|
||||
return this.bitcoindClient.getBlock(hash, 0)
|
||||
.then((raw: string) => Buffer.from(raw, "hex"));
|
||||
@@ -113,6 +129,14 @@ class BitcoinApi implements AbstractBitcoinApi {
|
||||
throw new Error('Method getAddressTransactions not supported by the Bitcoin RPC API.');
|
||||
}
|
||||
|
||||
$getScriptHash(scripthash: string): Promise<IEsploraApi.ScriptHash> {
|
||||
throw new Error('Method getScriptHash not supported by the Bitcoin RPC API.');
|
||||
}
|
||||
|
||||
$getScriptHashTransactions(scripthash: string, lastSeenTxId: string): Promise<IEsploraApi.Transaction[]> {
|
||||
throw new Error('Method getScriptHashTransactions not supported by the Bitcoin RPC API.');
|
||||
}
|
||||
|
||||
$getRawMempool(): Promise<IEsploraApi.Transaction['txid'][]> {
|
||||
return this.bitcoindClient.getRawMemPool();
|
||||
}
|
||||
@@ -198,7 +222,7 @@ class BitcoinApi implements AbstractBitcoinApi {
|
||||
scriptpubkey: vout.scriptPubKey.hex,
|
||||
scriptpubkey_address: vout.scriptPubKey && vout.scriptPubKey.address ? vout.scriptPubKey.address
|
||||
: vout.scriptPubKey.addresses ? vout.scriptPubKey.addresses[0] : '',
|
||||
scriptpubkey_asm: vout.scriptPubKey.asm ? this.convertScriptSigAsm(vout.scriptPubKey.hex) : '',
|
||||
scriptpubkey_asm: vout.scriptPubKey.asm ? transactionUtils.convertScriptSigAsm(vout.scriptPubKey.hex) : '',
|
||||
scriptpubkey_type: this.translateScriptPubKeyType(vout.scriptPubKey.type),
|
||||
};
|
||||
});
|
||||
@@ -208,7 +232,7 @@ class BitcoinApi implements AbstractBitcoinApi {
|
||||
is_coinbase: !!vin.coinbase,
|
||||
prevout: null,
|
||||
scriptsig: vin.scriptSig && vin.scriptSig.hex || vin.coinbase || '',
|
||||
scriptsig_asm: vin.scriptSig && this.convertScriptSigAsm(vin.scriptSig.hex) || '',
|
||||
scriptsig_asm: vin.scriptSig && transactionUtils.convertScriptSigAsm(vin.scriptSig.hex) || '',
|
||||
sequence: vin.sequence,
|
||||
txid: vin.txid || '',
|
||||
vout: vin.vout || 0,
|
||||
@@ -280,7 +304,7 @@ class BitcoinApi implements AbstractBitcoinApi {
|
||||
}
|
||||
const innerTx = await this.$getRawTransaction(vin.txid, false, false);
|
||||
vin.prevout = innerTx.vout[vin.vout];
|
||||
this.addInnerScriptsToVin(vin);
|
||||
transactionUtils.addInnerScriptsToVin(vin);
|
||||
}
|
||||
return transaction;
|
||||
}
|
||||
@@ -319,7 +343,7 @@ class BitcoinApi implements AbstractBitcoinApi {
|
||||
}
|
||||
const innerTx = await this.$getRawTransaction(transaction.vin[i].txid, false, false);
|
||||
transaction.vin[i].prevout = innerTx.vout[transaction.vin[i].vout];
|
||||
this.addInnerScriptsToVin(transaction.vin[i]);
|
||||
transactionUtils.addInnerScriptsToVin(transaction.vin[i]);
|
||||
totalIn += innerTx.vout[transaction.vin[i].vout].value;
|
||||
}
|
||||
if (lazyPrevouts && transaction.vin.length > 12) {
|
||||
@@ -331,122 +355,7 @@ class BitcoinApi implements AbstractBitcoinApi {
|
||||
return transaction;
|
||||
}
|
||||
|
||||
private convertScriptSigAsm(hex: string): string {
|
||||
const buf = Buffer.from(hex, 'hex');
|
||||
|
||||
const b: string[] = [];
|
||||
|
||||
let i = 0;
|
||||
while (i < buf.length) {
|
||||
const op = buf[i];
|
||||
if (op >= 0x01 && op <= 0x4e) {
|
||||
i++;
|
||||
let push: number;
|
||||
if (op === 0x4c) {
|
||||
push = buf.readUInt8(i);
|
||||
b.push('OP_PUSHDATA1');
|
||||
i += 1;
|
||||
} else if (op === 0x4d) {
|
||||
push = buf.readUInt16LE(i);
|
||||
b.push('OP_PUSHDATA2');
|
||||
i += 2;
|
||||
} else if (op === 0x4e) {
|
||||
push = buf.readUInt32LE(i);
|
||||
b.push('OP_PUSHDATA4');
|
||||
i += 4;
|
||||
} else {
|
||||
push = op;
|
||||
b.push('OP_PUSHBYTES_' + push);
|
||||
}
|
||||
|
||||
const data = buf.slice(i, i + push);
|
||||
if (data.length !== push) {
|
||||
break;
|
||||
}
|
||||
|
||||
b.push(data.toString('hex'));
|
||||
i += data.length;
|
||||
} else {
|
||||
if (op === 0x00) {
|
||||
b.push('OP_0');
|
||||
} else if (op === 0x4f) {
|
||||
b.push('OP_PUSHNUM_NEG1');
|
||||
} else if (op === 0xb1) {
|
||||
b.push('OP_CLTV');
|
||||
} else if (op === 0xb2) {
|
||||
b.push('OP_CSV');
|
||||
} else if (op === 0xba) {
|
||||
b.push('OP_CHECKSIGADD');
|
||||
} else {
|
||||
const opcode = bitcoinjs.script.toASM([ op ]);
|
||||
if (opcode && op < 0xfd) {
|
||||
if (/^OP_(\d+)$/.test(opcode)) {
|
||||
b.push(opcode.replace(/^OP_(\d+)$/, 'OP_PUSHNUM_$1'));
|
||||
} else {
|
||||
b.push(opcode);
|
||||
}
|
||||
} else {
|
||||
b.push('OP_RETURN_' + op);
|
||||
}
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
|
||||
return b.join(' ');
|
||||
}
|
||||
|
||||
private addInnerScriptsToVin(vin: IEsploraApi.Vin): void {
|
||||
if (!vin.prevout) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (vin.prevout.scriptpubkey_type === 'p2sh') {
|
||||
const redeemScript = vin.scriptsig_asm.split(' ').reverse()[0];
|
||||
vin.inner_redeemscript_asm = this.convertScriptSigAsm(redeemScript);
|
||||
if (vin.witness && vin.witness.length > 2) {
|
||||
const witnessScript = vin.witness[vin.witness.length - 1];
|
||||
vin.inner_witnessscript_asm = this.convertScriptSigAsm(witnessScript);
|
||||
}
|
||||
}
|
||||
|
||||
if (vin.prevout.scriptpubkey_type === 'v0_p2wsh' && vin.witness) {
|
||||
const witnessScript = vin.witness[vin.witness.length - 1];
|
||||
vin.inner_witnessscript_asm = this.convertScriptSigAsm(witnessScript);
|
||||
}
|
||||
|
||||
if (vin.prevout.scriptpubkey_type === 'v1_p2tr' && vin.witness) {
|
||||
const witnessScript = this.witnessToP2TRScript(vin.witness);
|
||||
if (witnessScript !== null) {
|
||||
vin.inner_witnessscript_asm = this.convertScriptSigAsm(witnessScript);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* This function must only be called when we know the witness we are parsing
|
||||
* is a taproot witness.
|
||||
* @param witness An array of hex strings that represents the witness stack of
|
||||
* the input.
|
||||
* @returns null if the witness is not a script spend, and the hex string of
|
||||
* the script item if it is a script spend.
|
||||
*/
|
||||
private witnessToP2TRScript(witness: string[]): string | null {
|
||||
if (witness.length < 2) return null;
|
||||
// Note: see BIP341 for parsing details of witness stack
|
||||
|
||||
// If there are at least two witness elements, and the first byte of the
|
||||
// last element is 0x50, this last element is called annex a and
|
||||
// is removed from the witness stack.
|
||||
const hasAnnex = witness[witness.length - 1].substring(0, 2) === '50';
|
||||
// If there are at least two witness elements left, script path spending is used.
|
||||
// Call the second-to-last stack element s, the script.
|
||||
// (Note: this phrasing from BIP341 assumes we've *removed* the annex from the stack)
|
||||
if (hasAnnex && witness.length < 3) return null;
|
||||
const positionOfScript = hasAnnex ? witness.length - 3 : witness.length - 2;
|
||||
return witness[positionOfScript];
|
||||
}
|
||||
|
||||
public startHealthChecks(): void {};
|
||||
}
|
||||
|
||||
export default BitcoinApi;
|
||||
|
||||
@@ -6,7 +6,7 @@ import websocketHandler from '../websocket-handler';
|
||||
import mempool from '../mempool';
|
||||
import feeApi from '../fee-api';
|
||||
import mempoolBlocks from '../mempool-blocks';
|
||||
import bitcoinApi, { bitcoinCoreApi } from './bitcoin-api-factory';
|
||||
import bitcoinApi from './bitcoin-api-factory';
|
||||
import { Common } from '../common';
|
||||
import backendInfo from '../backend-info';
|
||||
import transactionUtils from '../transaction-utils';
|
||||
@@ -121,7 +121,8 @@ class BitcoinRoutes {
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'block-height/:height', this.getBlockHeight)
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'address/:address', this.getAddress)
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'address/:address/txs', this.getAddressTransactions)
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'address/:address/txs/chain/:txId', this.getAddressTransactions)
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'scripthash/:scripthash', this.getScriptHash)
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'scripthash/:scripthash/txs', this.getScriptHashTransactions)
|
||||
.get(config.MEMPOOL.API_URL_PREFIX + 'address-prefix/:prefix', this.getAddressPrefix)
|
||||
;
|
||||
}
|
||||
@@ -213,6 +214,7 @@ class BitcoinRoutes {
|
||||
effectiveFeePerVsize: tx.effectiveFeePerVsize || null,
|
||||
sigops: tx.sigops,
|
||||
adjustedVsize: tx.adjustedVsize,
|
||||
acceleration: tx.acceleration
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -224,7 +226,12 @@ class BitcoinRoutes {
|
||||
} else {
|
||||
let cpfpInfo;
|
||||
if (config.DATABASE.ENABLED) {
|
||||
cpfpInfo = await transactionRepository.$getCpfpInfo(req.params.txId);
|
||||
try {
|
||||
cpfpInfo = await transactionRepository.$getCpfpInfo(req.params.txId);
|
||||
} catch (e) {
|
||||
res.status(500).send('failed to get CPFP info');
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (cpfpInfo) {
|
||||
res.json(cpfpInfo);
|
||||
@@ -394,9 +401,13 @@ class BitcoinRoutes {
|
||||
|
||||
private async getBlockAuditSummary(req: Request, res: Response) {
|
||||
try {
|
||||
const transactions = await blocks.$getBlockAuditSummary(req.params.hash);
|
||||
res.setHeader('Expires', new Date(Date.now() + 1000 * 3600 * 24 * 30).toUTCString());
|
||||
res.json(transactions);
|
||||
const auditSummary = await blocks.$getBlockAuditSummary(req.params.hash);
|
||||
if (auditSummary) {
|
||||
res.setHeader('Expires', new Date(Date.now() + 1000 * 3600 * 24 * 30).toUTCString());
|
||||
res.json(auditSummary);
|
||||
} else {
|
||||
return res.status(404).send(`audit not available`);
|
||||
}
|
||||
} catch (e) {
|
||||
res.status(500).send(e instanceof Error ? e.message : e);
|
||||
}
|
||||
@@ -467,13 +478,13 @@ class BitcoinRoutes {
|
||||
}
|
||||
|
||||
let nextHash = startFromHash;
|
||||
for (let i = 0; i < 10 && nextHash; i++) {
|
||||
for (let i = 0; i < 15 && nextHash; i++) {
|
||||
const localBlock = blocks.getBlocks().find((b) => b.id === nextHash);
|
||||
if (localBlock) {
|
||||
returnBlocks.push(localBlock);
|
||||
nextHash = localBlock.previousblockhash;
|
||||
} else {
|
||||
const block = await bitcoinCoreApi.$getBlock(nextHash);
|
||||
const block = await bitcoinApi.$getBlock(nextHash);
|
||||
returnBlocks.push(block);
|
||||
nextHash = block.previousblockhash;
|
||||
}
|
||||
@@ -537,15 +548,37 @@ class BitcoinRoutes {
|
||||
}
|
||||
}
|
||||
|
||||
private async getAddressTransactions(req: Request, res: Response) {
|
||||
private async getAddressTransactions(req: Request, res: Response): Promise<void> {
|
||||
if (config.MEMPOOL.BACKEND === 'none') {
|
||||
res.status(405).send('Address lookups cannot be used with bitcoind as backend.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const transactions = await bitcoinApi.$getAddressTransactions(req.params.address, req.params.txId);
|
||||
let lastTxId: string = '';
|
||||
if (req.query.after_txid && typeof req.query.after_txid === 'string') {
|
||||
lastTxId = req.query.after_txid;
|
||||
}
|
||||
const transactions = await bitcoinApi.$getAddressTransactions(req.params.address, lastTxId);
|
||||
res.json(transactions);
|
||||
} catch (e) {
|
||||
if (e instanceof Error && e.message && (e.message.indexOf('too long') > 0 || e.message.indexOf('confirmed status') > 0)) {
|
||||
res.status(413).send(e instanceof Error ? e.message : e);
|
||||
return;
|
||||
}
|
||||
res.status(500).send(e instanceof Error ? e.message : e);
|
||||
}
|
||||
}
|
||||
|
||||
private async getScriptHash(req: Request, res: Response) {
|
||||
if (config.MEMPOOL.BACKEND === 'none') {
|
||||
res.status(405).send('Address lookups cannot be used with bitcoind as backend.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const addressData = await bitcoinApi.$getScriptHash(req.params.scripthash);
|
||||
res.json(addressData);
|
||||
} catch (e) {
|
||||
if (e instanceof Error && e.message && (e.message.indexOf('too long') > 0 || e.message.indexOf('confirmed status') > 0)) {
|
||||
return res.status(413).send(e instanceof Error ? e.message : e);
|
||||
@@ -554,8 +587,26 @@ class BitcoinRoutes {
|
||||
}
|
||||
}
|
||||
|
||||
private async getAdressTxChain(req: Request, res: Response) {
|
||||
res.status(501).send('Not implemented');
|
||||
private async getScriptHashTransactions(req: Request, res: Response): Promise<void> {
|
||||
if (config.MEMPOOL.BACKEND === 'none') {
|
||||
res.status(405).send('Address lookups cannot be used with bitcoind as backend.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
let lastTxId: string = '';
|
||||
if (req.query.after_txid && typeof req.query.after_txid === 'string') {
|
||||
lastTxId = req.query.after_txid;
|
||||
}
|
||||
const transactions = await bitcoinApi.$getScriptHashTransactions(req.params.scripthash, lastTxId);
|
||||
res.json(transactions);
|
||||
} catch (e) {
|
||||
if (e instanceof Error && e.message && (e.message.indexOf('too long') > 0 || e.message.indexOf('confirmed status') > 0)) {
|
||||
res.status(413).send(e instanceof Error ? e.message : e);
|
||||
return;
|
||||
}
|
||||
res.status(500).send(e instanceof Error ? e.message : e);
|
||||
}
|
||||
}
|
||||
|
||||
private async getAddressPrefix(req: Request, res: Response) {
|
||||
@@ -714,12 +765,7 @@ class BitcoinRoutes {
|
||||
private async $postTransaction(req: Request, res: Response) {
|
||||
res.setHeader('content-type', 'text/plain');
|
||||
try {
|
||||
let rawTx;
|
||||
if (typeof req.body === 'object') {
|
||||
rawTx = Object.keys(req.body)[0];
|
||||
} else {
|
||||
rawTx = req.body;
|
||||
}
|
||||
const rawTx = Common.getTransactionFromRequest(req, false);
|
||||
const txIdResult = await bitcoinApi.$sendRawTransaction(rawTx);
|
||||
res.send(txIdResult);
|
||||
} catch (e: any) {
|
||||
@@ -730,12 +776,8 @@ class BitcoinRoutes {
|
||||
|
||||
private async $postTransactionForm(req: Request, res: Response) {
|
||||
res.setHeader('content-type', 'text/plain');
|
||||
const matches = /tx=([a-z0-9]+)/.exec(req.body);
|
||||
let txHex = '';
|
||||
if (matches && matches[1]) {
|
||||
txHex = matches[1];
|
||||
}
|
||||
try {
|
||||
const txHex = Common.getTransactionFromRequest(req, true);
|
||||
const txIdResult = await bitcoinClient.sendRawTransaction(txHex);
|
||||
res.send(txIdResult);
|
||||
} catch (e: any) {
|
||||
|
||||
@@ -126,6 +126,77 @@ class BitcoindElectrsApi extends BitcoinApi implements AbstractBitcoinApi {
|
||||
}
|
||||
}
|
||||
|
||||
async $getScriptHash(scripthash: string): Promise<IEsploraApi.ScriptHash> {
|
||||
try {
|
||||
const balance = await this.electrumClient.blockchainScripthash_getBalance(scripthash);
|
||||
let history = memoryCache.get<IElectrumApi.ScriptHashHistory[]>('Scripthash_getHistory', scripthash);
|
||||
if (!history) {
|
||||
history = await this.electrumClient.blockchainScripthash_getHistory(scripthash);
|
||||
memoryCache.set('Scripthash_getHistory', scripthash, history, 2);
|
||||
}
|
||||
|
||||
const unconfirmed = history ? history.filter((h) => h.fee).length : 0;
|
||||
|
||||
return {
|
||||
'scripthash': scripthash,
|
||||
'chain_stats': {
|
||||
'funded_txo_count': 0,
|
||||
'funded_txo_sum': balance.confirmed ? balance.confirmed : 0,
|
||||
'spent_txo_count': 0,
|
||||
'spent_txo_sum': balance.confirmed < 0 ? balance.confirmed : 0,
|
||||
'tx_count': (history?.length || 0) - unconfirmed,
|
||||
},
|
||||
'mempool_stats': {
|
||||
'funded_txo_count': 0,
|
||||
'funded_txo_sum': balance.unconfirmed > 0 ? balance.unconfirmed : 0,
|
||||
'spent_txo_count': 0,
|
||||
'spent_txo_sum': balance.unconfirmed < 0 ? -balance.unconfirmed : 0,
|
||||
'tx_count': unconfirmed,
|
||||
},
|
||||
'electrum': true,
|
||||
};
|
||||
} catch (e: any) {
|
||||
throw new Error(typeof e === 'string' ? e : e && e.message || e);
|
||||
}
|
||||
}
|
||||
|
||||
async $getScriptHashTransactions(scripthash: string, lastSeenTxId?: string): Promise<IEsploraApi.Transaction[]> {
|
||||
try {
|
||||
loadingIndicators.setProgress('address-' + scripthash, 0);
|
||||
|
||||
const transactions: IEsploraApi.Transaction[] = [];
|
||||
let history = memoryCache.get<IElectrumApi.ScriptHashHistory[]>('Scripthash_getHistory', scripthash);
|
||||
if (!history) {
|
||||
history = await this.electrumClient.blockchainScripthash_getHistory(scripthash);
|
||||
memoryCache.set('Scripthash_getHistory', scripthash, history, 2);
|
||||
}
|
||||
if (!history) {
|
||||
throw new Error('failed to get scripthash history');
|
||||
}
|
||||
history.sort((a, b) => (b.height || 9999999) - (a.height || 9999999));
|
||||
|
||||
let startingIndex = 0;
|
||||
if (lastSeenTxId) {
|
||||
const pos = history.findIndex((historicalTx) => historicalTx.tx_hash === lastSeenTxId);
|
||||
if (pos) {
|
||||
startingIndex = pos + 1;
|
||||
}
|
||||
}
|
||||
const endIndex = Math.min(startingIndex + 10, history.length);
|
||||
|
||||
for (let i = startingIndex; i < endIndex; i++) {
|
||||
const tx = await this.$getRawTransaction(history[i].tx_hash, false, true);
|
||||
transactions.push(tx);
|
||||
loadingIndicators.setProgress('address-' + scripthash, (i + 1) / endIndex * 100);
|
||||
}
|
||||
|
||||
return transactions;
|
||||
} catch (e: any) {
|
||||
loadingIndicators.setProgress('address-' + scripthash, 100);
|
||||
throw new Error(typeof e === 'string' ? e : e && e.message || e);
|
||||
}
|
||||
}
|
||||
|
||||
private $getScriptHashBalance(scriptHash: string): Promise<IElectrumApi.ScriptHashBalance> {
|
||||
return this.electrumClient.blockchainScripthash_getBalance(this.encodeScriptHash(scriptHash));
|
||||
}
|
||||
|
||||
@@ -89,6 +89,7 @@ export namespace IEsploraApi {
|
||||
weight: number;
|
||||
previousblockhash: string;
|
||||
mediantime: number;
|
||||
stale: boolean;
|
||||
}
|
||||
|
||||
export interface Address {
|
||||
@@ -98,6 +99,13 @@ export namespace IEsploraApi {
|
||||
electrum?: boolean;
|
||||
}
|
||||
|
||||
export interface ScriptHash {
|
||||
scripthash: string;
|
||||
chain_stats: ChainStats;
|
||||
mempool_stats: MempoolStats;
|
||||
electrum?: boolean;
|
||||
}
|
||||
|
||||
export interface ChainStats {
|
||||
funded_txo_count: number;
|
||||
funded_txo_sum: number;
|
||||
|
||||
@@ -1,104 +1,260 @@
|
||||
import config from '../../config';
|
||||
import axios, { AxiosRequestConfig } from 'axios';
|
||||
import axios, { AxiosResponse } from 'axios';
|
||||
import http from 'http';
|
||||
import { AbstractBitcoinApi } from './bitcoin-api-abstract-factory';
|
||||
import { IEsploraApi } from './esplora-api.interface';
|
||||
import logger from '../../logger';
|
||||
|
||||
const axiosConnection = axios.create({
|
||||
httpAgent: new http.Agent({ keepAlive: true, })
|
||||
});
|
||||
interface FailoverHost {
|
||||
host: string,
|
||||
rtts: number[],
|
||||
rtt: number
|
||||
failures: number,
|
||||
socket?: boolean,
|
||||
outOfSync?: boolean,
|
||||
unreachable?: boolean,
|
||||
preferred?: boolean,
|
||||
}
|
||||
|
||||
class ElectrsApi implements AbstractBitcoinApi {
|
||||
private axiosConfigWithUnixSocket: AxiosRequestConfig = config.ESPLORA.UNIX_SOCKET_PATH ? {
|
||||
socketPath: config.ESPLORA.UNIX_SOCKET_PATH,
|
||||
timeout: 10000,
|
||||
} : {
|
||||
timeout: 10000,
|
||||
};
|
||||
private axiosConfigTcpSocketOnly: AxiosRequestConfig = {
|
||||
timeout: 10000,
|
||||
};
|
||||
|
||||
unixSocketRetryTimeout;
|
||||
activeAxiosConfig;
|
||||
class FailoverRouter {
|
||||
activeHost: FailoverHost;
|
||||
fallbackHost: FailoverHost;
|
||||
hosts: FailoverHost[];
|
||||
multihost: boolean;
|
||||
pollInterval: number = 60000;
|
||||
pollTimer: NodeJS.Timeout | null = null;
|
||||
pollConnection = axios.create();
|
||||
requestConnection = axios.create({
|
||||
httpAgent: new http.Agent({ keepAlive: true })
|
||||
});
|
||||
|
||||
constructor() {
|
||||
this.activeAxiosConfig = this.axiosConfigWithUnixSocket;
|
||||
// setup list of hosts
|
||||
this.hosts = (config.ESPLORA.FALLBACK || []).map(domain => {
|
||||
return {
|
||||
host: domain,
|
||||
rtts: [],
|
||||
rtt: Infinity,
|
||||
failures: 0,
|
||||
};
|
||||
});
|
||||
this.activeHost = {
|
||||
host: config.ESPLORA.UNIX_SOCKET_PATH || config.ESPLORA.REST_API_URL,
|
||||
rtts: [],
|
||||
rtt: 0,
|
||||
failures: 0,
|
||||
socket: !!config.ESPLORA.UNIX_SOCKET_PATH,
|
||||
preferred: true,
|
||||
};
|
||||
this.fallbackHost = this.activeHost;
|
||||
this.hosts.unshift(this.activeHost);
|
||||
this.multihost = this.hosts.length > 1;
|
||||
}
|
||||
|
||||
fallbackToTcpSocket() {
|
||||
if (!this.unixSocketRetryTimeout) {
|
||||
logger.err(`Unable to connect to esplora unix socket. Falling back to tcp socket. Retrying unix socket in ${config.ESPLORA.RETRY_UNIX_SOCKET_AFTER / 1000} seconds`);
|
||||
// Retry the unix socket after a few seconds
|
||||
this.unixSocketRetryTimeout = setTimeout(() => {
|
||||
logger.info(`Retrying to use unix socket for esplora now (applied for the next query)`);
|
||||
this.activeAxiosConfig = this.axiosConfigWithUnixSocket;
|
||||
this.unixSocketRetryTimeout = undefined;
|
||||
}, config.ESPLORA.RETRY_UNIX_SOCKET_AFTER);
|
||||
public startHealthChecks(): void {
|
||||
// use axios interceptors to measure request rtt
|
||||
this.pollConnection.interceptors.request.use((config) => {
|
||||
config['meta'] = { startTime: Date.now() };
|
||||
return config;
|
||||
});
|
||||
this.pollConnection.interceptors.response.use((response) => {
|
||||
response.config['meta'].rtt = Date.now() - response.config['meta'].startTime;
|
||||
return response;
|
||||
});
|
||||
|
||||
if (this.multihost) {
|
||||
this.pollHosts();
|
||||
}
|
||||
}
|
||||
|
||||
// start polling hosts to measure availability & rtt
|
||||
private async pollHosts(): Promise<void> {
|
||||
if (this.pollTimer) {
|
||||
clearTimeout(this.pollTimer);
|
||||
}
|
||||
|
||||
// Use the TCP socket (reach a different esplora instance through nginx)
|
||||
this.activeAxiosConfig = this.axiosConfigTcpSocketOnly;
|
||||
const results = await Promise.allSettled(this.hosts.map(async (host) => {
|
||||
if (host.socket) {
|
||||
return this.pollConnection.get<number>('/blocks/tip/height', { socketPath: host.host, timeout: config.ESPLORA.FALLBACK_TIMEOUT });
|
||||
} else {
|
||||
return this.pollConnection.get<number>(host.host + '/blocks/tip/height', { timeout: config.ESPLORA.FALLBACK_TIMEOUT });
|
||||
}
|
||||
}));
|
||||
const maxHeight = results.reduce((max, result) => Math.max(max, result.status === 'fulfilled' ? result.value?.data || 0 : 0), 0);
|
||||
|
||||
// update rtts & sync status
|
||||
for (let i = 0; i < results.length; i++) {
|
||||
const host = this.hosts[i];
|
||||
const result = results[i].status === 'fulfilled' ? (results[i] as PromiseFulfilledResult<AxiosResponse<number, any>>).value : null;
|
||||
if (result) {
|
||||
const height = result.data;
|
||||
const rtt = result.config['meta'].rtt;
|
||||
host.rtts.unshift(rtt);
|
||||
host.rtts.slice(0, 5);
|
||||
host.rtt = host.rtts.reduce((acc, l) => acc + l, 0) / host.rtts.length;
|
||||
if (height == null || isNaN(height) || (maxHeight - height > 2)) {
|
||||
host.outOfSync = true;
|
||||
} else {
|
||||
host.outOfSync = false;
|
||||
}
|
||||
host.unreachable = false;
|
||||
} else {
|
||||
host.unreachable = true;
|
||||
}
|
||||
}
|
||||
|
||||
this.sortHosts();
|
||||
|
||||
logger.debug(`Tomahawk ranking: ${this.hosts.map(host => '\navg rtt ' + Math.round(host.rtt).toString().padStart(5, ' ') + ' | reachable? ' + (!host.unreachable || false).toString().padStart(5, ' ') + ' | in sync? ' + (!host.outOfSync || false).toString().padStart(5, ' ') + ` | ${host.host}`).join('')}`);
|
||||
|
||||
// switch if the current host is out of sync or significantly slower than the next best alternative
|
||||
if (this.activeHost.outOfSync || this.activeHost.unreachable || (this.activeHost !== this.hosts[0] && this.hosts[0].preferred) || (!this.activeHost.preferred && this.activeHost.rtt > (this.hosts[0].rtt * 2) + 50)) {
|
||||
if (this.activeHost.unreachable) {
|
||||
logger.warn(`Unable to reach ${this.activeHost.host}, failing over to next best alternative`);
|
||||
} else if (this.activeHost.outOfSync) {
|
||||
logger.warn(`${this.activeHost.host} has fallen behind, failing over to next best alternative`);
|
||||
} else {
|
||||
logger.debug(`${this.activeHost.host} is no longer the best esplora host`);
|
||||
}
|
||||
this.electHost();
|
||||
}
|
||||
|
||||
this.pollTimer = setTimeout(() => { this.pollHosts(); }, this.pollInterval);
|
||||
}
|
||||
|
||||
$queryWrapper<T>(url, responseType = 'json'): Promise<T> {
|
||||
return axiosConnection.get<T>(url, { ...this.activeAxiosConfig, responseType: responseType })
|
||||
.then((response) => response.data)
|
||||
// sort hosts by connection quality, and update default fallback
|
||||
private sortHosts(): void {
|
||||
// sort by connection quality
|
||||
this.hosts.sort((a, b) => {
|
||||
if ((a.unreachable || a.outOfSync) === (b.unreachable || b.outOfSync)) {
|
||||
if (a.preferred === b.preferred) {
|
||||
// lower rtt is best
|
||||
return a.rtt - b.rtt;
|
||||
} else { // unless we have a preferred host
|
||||
return a.preferred ? -1 : 1;
|
||||
}
|
||||
} else { // or the host is out of sync
|
||||
return (a.unreachable || a.outOfSync) ? 1 : -1;
|
||||
}
|
||||
});
|
||||
if (this.hosts.length > 1 && this.hosts[0] === this.activeHost) {
|
||||
this.fallbackHost = this.hosts[1];
|
||||
} else {
|
||||
this.fallbackHost = this.hosts[0];
|
||||
}
|
||||
}
|
||||
|
||||
// depose the active host and choose the next best replacement
|
||||
private electHost(): void {
|
||||
this.activeHost.outOfSync = true;
|
||||
this.activeHost.failures = 0;
|
||||
this.sortHosts();
|
||||
this.activeHost = this.hosts[0];
|
||||
logger.warn(`Switching esplora host to ${this.activeHost.host}`);
|
||||
}
|
||||
|
||||
private addFailure(host: FailoverHost): FailoverHost {
|
||||
host.failures++;
|
||||
if (host.failures > 5 && this.multihost) {
|
||||
logger.warn(`Too many esplora failures on ${this.activeHost.host}, falling back to next best alternative`);
|
||||
this.electHost();
|
||||
return this.activeHost;
|
||||
} else {
|
||||
return this.fallbackHost;
|
||||
}
|
||||
}
|
||||
|
||||
private async $query<T>(method: 'get'| 'post', path, data: any, responseType = 'json', host = this.activeHost, retry: boolean = true): Promise<T> {
|
||||
let axiosConfig;
|
||||
let url;
|
||||
if (host.socket) {
|
||||
axiosConfig = { socketPath: host.host, timeout: config.ESPLORA.REQUEST_TIMEOUT, responseType };
|
||||
url = path;
|
||||
} else {
|
||||
axiosConfig = { timeout: config.ESPLORA.REQUEST_TIMEOUT, responseType };
|
||||
url = host.host + path;
|
||||
}
|
||||
return (method === 'post'
|
||||
? this.requestConnection.post<T>(url, data, axiosConfig)
|
||||
: this.requestConnection.get<T>(url, axiosConfig)
|
||||
).then((response) => { host.failures = Math.max(0, host.failures - 1); return response.data; })
|
||||
.catch((e) => {
|
||||
if (e?.code === 'ECONNREFUSED') {
|
||||
this.fallbackToTcpSocket();
|
||||
let fallbackHost = this.fallbackHost;
|
||||
if (e?.response?.status !== 404) {
|
||||
logger.warn(`esplora request failed ${e?.response?.status || 500} ${host.host}${path}`);
|
||||
fallbackHost = this.addFailure(host);
|
||||
}
|
||||
if (retry && e?.code === 'ECONNREFUSED' && this.multihost) {
|
||||
// Retry immediately
|
||||
return axiosConnection.get<T>(url, this.activeAxiosConfig)
|
||||
.then((response) => response.data)
|
||||
.catch((e) => {
|
||||
logger.warn(`Cannot query esplora through the unix socket nor the tcp socket. Exception ${e}`);
|
||||
throw e;
|
||||
});
|
||||
return this.$query(method, path, data, responseType, fallbackHost, false);
|
||||
} else {
|
||||
throw e;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public async $get<T>(path, responseType = 'json'): Promise<T> {
|
||||
return this.$query<T>('get', path, null, responseType);
|
||||
}
|
||||
|
||||
public async $post<T>(path, data: any, responseType = 'json'): Promise<T> {
|
||||
return this.$query<T>('post', path, data, responseType);
|
||||
}
|
||||
}
|
||||
|
||||
class ElectrsApi implements AbstractBitcoinApi {
|
||||
private failoverRouter = new FailoverRouter();
|
||||
|
||||
$getRawMempool(): Promise<IEsploraApi.Transaction['txid'][]> {
|
||||
return this.$queryWrapper<IEsploraApi.Transaction['txid'][]>(config.ESPLORA.REST_API_URL + '/mempool/txids');
|
||||
return this.failoverRouter.$get<IEsploraApi.Transaction['txid'][]>('/mempool/txids');
|
||||
}
|
||||
|
||||
$getRawTransaction(txId: string): Promise<IEsploraApi.Transaction> {
|
||||
return this.$queryWrapper<IEsploraApi.Transaction>(config.ESPLORA.REST_API_URL + '/tx/' + txId);
|
||||
return this.failoverRouter.$get<IEsploraApi.Transaction>('/tx/' + txId);
|
||||
}
|
||||
|
||||
async $getMempoolTransactions(txids: string[]): Promise<IEsploraApi.Transaction[]> {
|
||||
return this.failoverRouter.$post<IEsploraApi.Transaction[]>('/mempool/txs', txids, 'json');
|
||||
}
|
||||
|
||||
async $getAllMempoolTransactions(lastSeenTxid?: string): Promise<IEsploraApi.Transaction[]> {
|
||||
return this.failoverRouter.$get<IEsploraApi.Transaction[]>('/mempool/txs' + (lastSeenTxid ? '/' + lastSeenTxid : ''));
|
||||
}
|
||||
|
||||
$getTransactionHex(txId: string): Promise<string> {
|
||||
return this.$queryWrapper<string>(config.ESPLORA.REST_API_URL + '/tx/' + txId + '/hex');
|
||||
return this.failoverRouter.$get<string>('/tx/' + txId + '/hex');
|
||||
}
|
||||
|
||||
$getBlockHeightTip(): Promise<number> {
|
||||
return this.$queryWrapper<number>(config.ESPLORA.REST_API_URL + '/blocks/tip/height');
|
||||
return this.failoverRouter.$get<number>('/blocks/tip/height');
|
||||
}
|
||||
|
||||
$getBlockHashTip(): Promise<string> {
|
||||
return this.$queryWrapper<string>(config.ESPLORA.REST_API_URL + '/blocks/tip/hash');
|
||||
return this.failoverRouter.$get<string>('/blocks/tip/hash');
|
||||
}
|
||||
|
||||
$getTxIdsForBlock(hash: string): Promise<string[]> {
|
||||
return this.$queryWrapper<string[]>(config.ESPLORA.REST_API_URL + '/block/' + hash + '/txids');
|
||||
return this.failoverRouter.$get<string[]>('/block/' + hash + '/txids');
|
||||
}
|
||||
|
||||
$getTxsForBlock(hash: string): Promise<IEsploraApi.Transaction[]> {
|
||||
return this.failoverRouter.$get<IEsploraApi.Transaction[]>('/block/' + hash + '/txs');
|
||||
}
|
||||
|
||||
$getBlockHash(height: number): Promise<string> {
|
||||
return this.$queryWrapper<string>(config.ESPLORA.REST_API_URL + '/block-height/' + height);
|
||||
return this.failoverRouter.$get<string>('/block-height/' + height);
|
||||
}
|
||||
|
||||
$getBlockHeader(hash: string): Promise<string> {
|
||||
return this.$queryWrapper<string>(config.ESPLORA.REST_API_URL + '/block/' + hash + '/header');
|
||||
return this.failoverRouter.$get<string>('/block/' + hash + '/header');
|
||||
}
|
||||
|
||||
$getBlock(hash: string): Promise<IEsploraApi.Block> {
|
||||
return this.$queryWrapper<IEsploraApi.Block>(config.ESPLORA.REST_API_URL + '/block/' + hash);
|
||||
return this.failoverRouter.$get<IEsploraApi.Block>('/block/' + hash);
|
||||
}
|
||||
|
||||
$getRawBlock(hash: string): Promise<Buffer> {
|
||||
return this.$queryWrapper<any>(config.ESPLORA.REST_API_URL + '/block/' + hash + "/raw", 'arraybuffer')
|
||||
return this.failoverRouter.$get<any>('/block/' + hash + '/raw', 'arraybuffer')
|
||||
.then((response) => { return Buffer.from(response.data); });
|
||||
}
|
||||
|
||||
@@ -110,6 +266,14 @@ class ElectrsApi implements AbstractBitcoinApi {
|
||||
throw new Error('Method getAddressTransactions not implemented.');
|
||||
}
|
||||
|
||||
$getScriptHash(scripthash: string): Promise<IEsploraApi.ScriptHash> {
|
||||
throw new Error('Method getScriptHash not implemented.');
|
||||
}
|
||||
|
||||
$getScriptHashTransactions(scripthash: string, txId?: string): Promise<IEsploraApi.Transaction[]> {
|
||||
throw new Error('Method getScriptHashTransactions not implemented.');
|
||||
}
|
||||
|
||||
$getAddressPrefix(prefix: string): string[] {
|
||||
throw new Error('Method not implemented.');
|
||||
}
|
||||
@@ -119,11 +283,11 @@ class ElectrsApi implements AbstractBitcoinApi {
|
||||
}
|
||||
|
||||
$getOutspend(txId: string, vout: number): Promise<IEsploraApi.Outspend> {
|
||||
return this.$queryWrapper<IEsploraApi.Outspend>(config.ESPLORA.REST_API_URL + '/tx/' + txId + '/outspend/' + vout);
|
||||
return this.failoverRouter.$get<IEsploraApi.Outspend>('/tx/' + txId + '/outspend/' + vout);
|
||||
}
|
||||
|
||||
$getOutspends(txId: string): Promise<IEsploraApi.Outspend[]> {
|
||||
return this.$queryWrapper<IEsploraApi.Outspend[]>(config.ESPLORA.REST_API_URL + '/tx/' + txId + '/outspends');
|
||||
return this.failoverRouter.$get<IEsploraApi.Outspend[]>('/tx/' + txId + '/outspends');
|
||||
}
|
||||
|
||||
async $getBatchedOutspends(txId: string[]): Promise<IEsploraApi.Outspend[][]> {
|
||||
@@ -134,6 +298,10 @@ class ElectrsApi implements AbstractBitcoinApi {
|
||||
}
|
||||
return outspends;
|
||||
}
|
||||
|
||||
public startHealthChecks(): void {
|
||||
this.failoverRouter.startHealthChecks();
|
||||
}
|
||||
}
|
||||
|
||||
export default ElectrsApi;
|
||||
|
||||
Reference in New Issue
Block a user