340 lines
12 KiB
TypeScript
340 lines
12 KiB
TypeScript
import { TransactionExtended, MempoolTransactionExtended, TransactionMinerInfo } from '../mempool.interfaces';
|
|
import { IEsploraApi } from './bitcoin/esplora-api.interface';
|
|
import { Common } from './common';
|
|
import bitcoinApi, { bitcoinCoreApi } from './bitcoin/bitcoin-api-factory';
|
|
import * as bitcoinjs from 'bitcoinjs-lib';
|
|
import logger from '../logger';
|
|
import config from '../config';
|
|
import pLimit from '../utils/p-limit';
|
|
|
|
class TransactionUtils {
|
|
constructor() { }
|
|
|
|
public stripCoinbaseTransaction(tx: TransactionExtended): TransactionMinerInfo {
|
|
return {
|
|
vin: [{
|
|
scriptsig: tx.vin[0].scriptsig || tx.vin[0]['coinbase']
|
|
}],
|
|
vout: tx.vout
|
|
.map((vout) => ({
|
|
scriptpubkey_address: vout.scriptpubkey_address,
|
|
scriptpubkey_asm: vout.scriptpubkey_asm,
|
|
value: vout.value
|
|
}))
|
|
.filter((vout) => vout.value)
|
|
};
|
|
}
|
|
|
|
// Wrapper for $getTransactionExtended with an automatic retry direct to Core if the first API request fails.
|
|
// Propagates any error from the retry request.
|
|
public async $getTransactionExtendedRetry(txid: string, addPrevouts = false, lazyPrevouts = false, forceCore = false, addMempoolData = false): Promise<TransactionExtended> {
|
|
try {
|
|
const result = await this.$getTransactionExtended(txid, addPrevouts, lazyPrevouts, forceCore, addMempoolData);
|
|
if (result) {
|
|
return result;
|
|
} else {
|
|
logger.err(`Cannot fetch tx ${txid}. Reason: backend returned null data`);
|
|
}
|
|
} catch (e) {
|
|
logger.err(`Cannot fetch tx ${txid}. Reason: ` + (e instanceof Error ? e.message : e));
|
|
}
|
|
// retry direct from Core if first request failed
|
|
return this.$getTransactionExtended(txid, addPrevouts, lazyPrevouts, true, addMempoolData);
|
|
}
|
|
|
|
/**
|
|
* @param txId
|
|
* @param addPrevouts
|
|
* @param lazyPrevouts
|
|
* @param forceCore - See https://github.com/mempool/mempool/issues/2904
|
|
*/
|
|
public async $getTransactionExtended(txId: string, addPrevouts = false, lazyPrevouts = false, forceCore = false, addMempoolData = false): Promise<TransactionExtended> {
|
|
let transaction: IEsploraApi.Transaction;
|
|
if (forceCore === true) {
|
|
transaction = await bitcoinCoreApi.$getRawTransaction(txId, false, addPrevouts, lazyPrevouts);
|
|
} else {
|
|
transaction = await bitcoinApi.$getRawTransaction(txId, false, addPrevouts, lazyPrevouts);
|
|
}
|
|
|
|
if (Common.isLiquid()) {
|
|
if (!isFinite(Number(transaction.fee))) {
|
|
transaction.fee = Object.values(transaction.fee || {}).reduce((total, output) => total + output, 0);
|
|
}
|
|
}
|
|
|
|
if (addMempoolData || !transaction?.status?.confirmed) {
|
|
return this.extendMempoolTransaction(transaction);
|
|
} else {
|
|
return this.extendTransaction(transaction);
|
|
}
|
|
}
|
|
|
|
public async $getMempoolTransactionExtended(txId: string, addPrevouts = false, lazyPrevouts = false, forceCore = false): Promise<MempoolTransactionExtended> {
|
|
return (await this.$getTransactionExtended(txId, addPrevouts, lazyPrevouts, forceCore, true)) as MempoolTransactionExtended;
|
|
}
|
|
|
|
public async $getMempoolTransactionsExtended(txids: string[], addPrevouts = false, lazyPrevouts = false, forceCore = false): Promise<MempoolTransactionExtended[]> {
|
|
if (forceCore || config.MEMPOOL.BACKEND !== 'esplora') {
|
|
const limiter = pLimit(8); // Run 8 requests at a time
|
|
const results = await Promise.allSettled(txids.map(
|
|
txid => limiter(() => this.$getMempoolTransactionExtended(txid, addPrevouts, lazyPrevouts, forceCore))
|
|
));
|
|
return results.filter(reply => reply.status === 'fulfilled')
|
|
.map(r => (r as PromiseFulfilledResult<MempoolTransactionExtended>).value);
|
|
} else {
|
|
const transactions = await bitcoinApi.$getMempoolTransactions(txids);
|
|
return transactions.map(transaction => {
|
|
if (Common.isLiquid()) {
|
|
if (!isFinite(Number(transaction.fee))) {
|
|
transaction.fee = Object.values(transaction.fee || {}).reduce((total, output) => total + output, 0);
|
|
}
|
|
}
|
|
|
|
return this.extendMempoolTransaction(transaction);
|
|
});
|
|
}
|
|
}
|
|
|
|
public extendTransaction(transaction: IEsploraApi.Transaction): TransactionExtended {
|
|
// @ts-ignore
|
|
if (transaction.vsize) {
|
|
// @ts-ignore
|
|
return transaction;
|
|
}
|
|
const feePerVbytes = (transaction.fee || 0) / (transaction.weight / 4);
|
|
const transactionExtended: TransactionExtended = Object.assign({
|
|
vsize: Math.round(transaction.weight / 4),
|
|
feePerVsize: feePerVbytes,
|
|
effectiveFeePerVsize: feePerVbytes,
|
|
}, transaction);
|
|
if (!transaction?.status?.confirmed && !transactionExtended.firstSeen) {
|
|
transactionExtended.firstSeen = Math.round((Date.now() / 1000));
|
|
}
|
|
return transactionExtended;
|
|
}
|
|
|
|
public extendMempoolTransaction(transaction: IEsploraApi.Transaction): MempoolTransactionExtended {
|
|
const vsize = Math.ceil(transaction.weight / 4);
|
|
const fractionalVsize = (transaction.weight / 4);
|
|
let sigops = Common.isLiquid() ? 0 : (transaction.sigops != null ? transaction.sigops : this.countSigops(transaction));
|
|
// https://github.com/bitcoin/bitcoin/blob/e9262ea32a6e1d364fb7974844fadc36f931f8c6/src/policy/policy.cpp#L295-L298
|
|
const adjustedVsize = Math.max(fractionalVsize, sigops * 5); // adjusted vsize = Max(weight, sigops * bytes_per_sigop) / witness_scale_factor
|
|
const feePerVbytes = (transaction.fee || 0) / fractionalVsize;
|
|
const adjustedFeePerVsize = (transaction.fee || 0) / adjustedVsize;
|
|
const transactionExtended: MempoolTransactionExtended = Object.assign(transaction, {
|
|
order: this.txidToOrdering(transaction.txid),
|
|
vsize: Math.round(transaction.weight / 4),
|
|
adjustedVsize,
|
|
sigops,
|
|
feePerVsize: feePerVbytes,
|
|
adjustedFeePerVsize: adjustedFeePerVsize,
|
|
effectiveFeePerVsize: adjustedFeePerVsize,
|
|
});
|
|
if (!transactionExtended?.status?.confirmed && !transactionExtended.firstSeen) {
|
|
transactionExtended.firstSeen = Math.round((Date.now() / 1000));
|
|
}
|
|
return transactionExtended;
|
|
}
|
|
|
|
public hex2ascii(hex: string) {
|
|
let str = '';
|
|
for (let i = 0; i < hex.length; i += 2) {
|
|
str += String.fromCharCode(parseInt(hex.substr(i, 2), 16));
|
|
}
|
|
return str;
|
|
}
|
|
|
|
public countScriptSigops(script: string, isRawScript: boolean = false, witness: boolean = false): number {
|
|
let sigops = 0;
|
|
// count OP_CHECKSIG and OP_CHECKSIGVERIFY
|
|
sigops += (script.match(/OP_CHECKSIG/g)?.length || 0);
|
|
|
|
// count OP_CHECKMULTISIG and OP_CHECKMULTISIGVERIFY
|
|
if (isRawScript) {
|
|
// in scriptPubKey or scriptSig, always worth 20
|
|
sigops += 20 * (script.match(/OP_CHECKMULTISIG/g)?.length || 0);
|
|
} else {
|
|
// in redeem scripts and witnesses, worth N if preceded by OP_N, 20 otherwise
|
|
const matches = script.matchAll(/(?:OP_(?:PUSHNUM_)?(\d+))? OP_CHECKMULTISIG/g);
|
|
for (const match of matches) {
|
|
const n = parseInt(match[1]);
|
|
if (Number.isInteger(n)) {
|
|
sigops += n;
|
|
} else {
|
|
sigops += 20;
|
|
}
|
|
}
|
|
}
|
|
|
|
return witness ? sigops : (sigops * 4);
|
|
}
|
|
|
|
public countSigops(transaction: IEsploraApi.Transaction): number {
|
|
let sigops = 0;
|
|
|
|
for (const input of transaction.vin) {
|
|
if (input.scriptsig_asm) {
|
|
sigops += this.countScriptSigops(input.scriptsig_asm, true);
|
|
}
|
|
if (input.prevout) {
|
|
switch (true) {
|
|
case input.prevout.scriptpubkey_type === 'p2sh' && input.witness?.length === 2 && input.scriptsig && input.scriptsig.startsWith('160014'):
|
|
case input.prevout.scriptpubkey_type === 'v0_p2wpkh':
|
|
sigops += 1;
|
|
break;
|
|
|
|
case input.prevout?.scriptpubkey_type === 'p2sh' && input.witness?.length && input.scriptsig && input.scriptsig.startsWith('220020'):
|
|
case input.prevout.scriptpubkey_type === 'v0_p2wsh':
|
|
if (input.witness?.length) {
|
|
sigops += this.countScriptSigops(bitcoinjs.script.toASM(Buffer.from(input.witness[input.witness.length - 1], 'hex')), false, true);
|
|
}
|
|
break;
|
|
|
|
case input.prevout.scriptpubkey_type === 'p2sh':
|
|
if (input.inner_redeemscript_asm) {
|
|
sigops += this.countScriptSigops(input.inner_redeemscript_asm);
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
for (const output of transaction.vout) {
|
|
if (output.scriptpubkey_asm) {
|
|
sigops += this.countScriptSigops(output.scriptpubkey_asm, true);
|
|
}
|
|
}
|
|
|
|
return sigops;
|
|
}
|
|
|
|
// returns the most significant 4 bytes of the txid as an integer
|
|
public txidToOrdering(txid: string): number {
|
|
return parseInt(
|
|
txid.substr(62, 2) +
|
|
txid.substr(60, 2) +
|
|
txid.substr(58, 2) +
|
|
txid.substr(56, 2),
|
|
16
|
|
);
|
|
}
|
|
|
|
public 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);
|
|
}
|
|
}
|
|
}
|
|
|
|
public 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(' ');
|
|
}
|
|
|
|
/**
|
|
* 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.
|
|
*/
|
|
public 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];
|
|
}
|
|
}
|
|
|
|
export default new TransactionUtils();
|