import { TransactionFlags } from '@app/shared/filters.utils'; import { getVarIntLength, parseMultisigScript, isPoint } from '@app/shared/script.utils'; import { Transaction, Vin } from '@interfaces/electrs.interface'; import { CpfpInfo, RbfInfo, TransactionStripped } from '@interfaces/node-api.interface'; import { StateService } from '@app/services/state.service'; import { Hash } from './sha256'; // Bitcoin Core default policy settings const MAX_STANDARD_TX_WEIGHT = 400_000; const MAX_BLOCK_SIGOPS_COST = 80_000; const MAX_STANDARD_TX_SIGOPS_COST = (MAX_BLOCK_SIGOPS_COST / 5); const MIN_STANDARD_TX_NONWITNESS_SIZE = 65; const MAX_P2SH_SIGOPS = 15; const MAX_STANDARD_P2WSH_STACK_ITEMS = 100; const MAX_STANDARD_P2WSH_STACK_ITEM_SIZE = 80; const MAX_STANDARD_TAPSCRIPT_STACK_ITEM_SIZE = 80; const MAX_STANDARD_P2WSH_SCRIPT_SIZE = 3600; const MAX_STANDARD_SCRIPTSIG_SIZE = 1650; const DUST_RELAY_TX_FEE = 3; const MAX_OP_RETURN_RELAY = 83; const DEFAULT_PERMIT_BAREMULTISIG = true; export function countScriptSigops(script: string, isRawScript: boolean = false, witness: boolean = false): number { if (!script?.length) { return 0; } 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); } export function setSchnorrSighashFlags(flags: bigint, witness: string[]): bigint { // no witness items if (!witness?.length) { return flags; } const hasAnnex = witness.length > 1 && witness[witness.length - 1].startsWith('50'); if (witness?.length === (hasAnnex ? 2 : 1)) { // keypath spend, signature is the only witness item if (witness[0].length === 130) { flags |= setSighashFlags(flags, witness[0]); } else { flags |= TransactionFlags.sighash_default; } } else { // scriptpath spend, all items except for the script, control block and annex could be signatures for (let i = 0; i < witness.length - (hasAnnex ? 3 : 2); i++) { // handle probable signatures if (witness[i].length === 130) { flags |= setSighashFlags(flags, witness[i]); } else if (witness[i].length === 128) { flags |= TransactionFlags.sighash_default; } } } return flags; } export function isDERSig(w: string): boolean { // heuristic to detect probable DER signatures return (w.length >= 18 && w.startsWith('30') // minimum DER signature length is 8 bytes + sighash flag (see https://mempool.space/testnet/tx/c6c232a36395fa338da458b86ff1327395a9afc28c5d2daa4273e410089fd433) && ['01', '02', '03', '81', '82', '83'].includes(w.slice(-2)) // signature must end with a valid sighash flag && (w.length === (2 * parseInt(w.slice(2, 4), 16)) + 6) // second byte encodes the combined length of the R and S components ); } /** * Validates most standardness rules * * returns true early if any standardness rule is violated, otherwise false * (except for non-mandatory-script-verify-flag and p2sh script evaluation rules which are *not* enforced) * * As standardness rules change, we'll need to apply the rules in force *at the time* to older blocks. * For now, just pull out individual rules into versioned functions where necessary. */ export function isNonStandard(tx: Transaction, height?: number, network?: string): boolean { // version if (isNonStandardVersion(tx, height, network)) { return true; } // tx-size if (tx.weight > MAX_STANDARD_TX_WEIGHT) { return true; } // tx-size-small if (getNonWitnessSize(tx) < MIN_STANDARD_TX_NONWITNESS_SIZE) { return true; } // bad-txns-too-many-sigops if (tx.sigops && tx.sigops > MAX_STANDARD_TX_SIGOPS_COST) { return true; } // input validation for (const vin of tx.vin) { if (vin.is_coinbase) { // standardness rules don't apply to coinbase transactions return false; } // scriptsig-size if ((vin.scriptsig.length / 2) > MAX_STANDARD_SCRIPTSIG_SIZE) { return true; } // scriptsig-not-pushonly if (vin.scriptsig_asm) { for (const op of vin.scriptsig_asm.split(' ')) { if (opcodes[op] && opcodes[op] > opcodes['OP_16']) { return true; } } } // bad-txns-nonstandard-inputs if (vin.prevout?.scriptpubkey_type === 'p2sh') { // TODO: evaluate script (https://github.com/bitcoin/bitcoin/blob/1ac627c485a43e50a9a49baddce186ee3ad4daad/src/policy/policy.cpp#L177) // countScriptSigops returns the witness-scaled sigops, so divide by 4 before comparison with MAX_P2SH_SIGOPS const sigops = (countScriptSigops(vin.inner_redeemscript_asm || '') / 4); if (sigops > MAX_P2SH_SIGOPS) { return true; } } else if (['unknown', 'provably_unspendable', 'empty'].includes(vin.prevout?.scriptpubkey_type || '')) { return true; } else if (isNonStandardAnchor(tx, height, network)) { return true; } // TODO: bad-witness-nonstandard } // output validation let opreturnCount = 0; for (const vout of tx.vout) { // scriptpubkey if (['nonstandard', 'provably_unspendable', 'empty'].includes(vout.scriptpubkey_type)) { // (non-standard output type) return true; } else if (vout.scriptpubkey_type === 'unknown') { // undefined segwit version/length combinations are actually standard in outputs // https://github.com/bitcoin/bitcoin/blob/2c79abc7ad4850e9e3ba32a04c530155cda7f980/src/script/interpreter.cpp#L1950-L1951 if (vout.scriptpubkey.startsWith('00') || !isWitnessProgram(vout.scriptpubkey)) { return true; } } else if (vout.scriptpubkey_type === 'multisig') { if (!DEFAULT_PERMIT_BAREMULTISIG) { // bare-multisig return true; } const mOfN = parseMultisigScript(vout.scriptpubkey_asm); if (!mOfN || mOfN.n < 1 || mOfN.n > 3 || mOfN.m < 1 || mOfN.m > mOfN.n) { // (non-standard bare multisig threshold) return true; } } else if (vout.scriptpubkey_type === 'op_return') { opreturnCount++; if ((vout.scriptpubkey.length / 2) > MAX_OP_RETURN_RELAY) { // over default datacarrier limit return true; } } // dust // (we could probably hardcode this for the different output types...) if (vout.scriptpubkey_type !== 'op_return') { let dustSize = (vout.scriptpubkey.length / 2); // add varint length overhead dustSize += getVarIntLength(dustSize); // add value size dustSize += 8; if (isWitnessProgram(vout.scriptpubkey)) { dustSize += 67; } else { dustSize += 148; } if (vout.value < (dustSize * DUST_RELAY_TX_FEE)) { // under minimum output size return true; } } } // multi-op-return if (opreturnCount > 1) { return true; } // TODO: non-mandatory-script-verify-flag return false; } // Individual versioned standardness rules const V3_STANDARDNESS_ACTIVATION_HEIGHT = { 'testnet4': 42_000, 'testnet': 2_900_000, 'signet': 211_000, '': 863_500, }; function isNonStandardVersion(tx: Transaction, height?: number, network?: string): boolean { let TX_MAX_STANDARD_VERSION = 3; if ( height != null && network != null && V3_STANDARDNESS_ACTIVATION_HEIGHT[network] && height <= V3_STANDARDNESS_ACTIVATION_HEIGHT[network] ) { // V3 transactions were non-standard to spend before v28.x (scheduled for 2024/09/30 https://github.com/bitcoin/bitcoin/issues/29891) TX_MAX_STANDARD_VERSION = 2; } if (tx.version > TX_MAX_STANDARD_VERSION) { return true; } return false; } const ANCHOR_STANDARDNESS_ACTIVATION_HEIGHT = { 'testnet4': 42_000, 'testnet': 2_900_000, 'signet': 211_000, '': 863_500, }; function isNonStandardAnchor(tx: Transaction, height?: number, network?: string): boolean { if ( height != null && network != null && ANCHOR_STANDARDNESS_ACTIVATION_HEIGHT[network] && height <= ANCHOR_STANDARDNESS_ACTIVATION_HEIGHT[network] ) { // anchor outputs were non-standard to spend before v28.x (scheduled for 2024/09/30 https://github.com/bitcoin/bitcoin/issues/29891) return true; } return false; } // A witness program is any valid scriptpubkey that consists of a 1-byte push opcode // followed by a data push between 2 and 40 bytes. // https://github.com/bitcoin/bitcoin/blob/2c79abc7ad4850e9e3ba32a04c530155cda7f980/src/script/script.cpp#L224-L240 function isWitnessProgram(scriptpubkey: string): false | { version: number, program: string } { if (scriptpubkey.length < 8 || scriptpubkey.length > 84) { return false; } const version = parseInt(scriptpubkey.slice(0,2), 16); if (version !== 0 && version < 0x51 || version > 0x60) { return false; } const push = parseInt(scriptpubkey.slice(2,4), 16); if (push + 2 === (scriptpubkey.length / 2)) { return { version: version ? version - 0x50 : 0, program: scriptpubkey.slice(4), }; } return false; } export function getNonWitnessSize(tx: Transaction): number { let weight = tx.weight; let hasWitness = false; for (const vin of tx.vin) { if (vin.witness?.length) { hasWitness = true; // witness count weight -= getVarIntLength(vin.witness.length); for (const witness of vin.witness) { // witness item size + content weight -= getVarIntLength(witness.length / 2) + (witness.length / 2); } } } if (hasWitness) { // marker & segwit flag weight -= 2; } return Math.ceil(weight / 4); } export function setSegwitSighashFlags(flags: bigint, witness: string[]): bigint { for (const w of witness) { if (isDERSig(w)) { flags |= setSighashFlags(flags, w); } } return flags; } export function setLegacySighashFlags(flags: bigint, scriptsig_asm: string): bigint { for (const item of scriptsig_asm.split(' ')) { // skip op_codes if (item.startsWith('OP_')) { continue; } // check pushed data if (isDERSig(item)) { flags |= setSighashFlags(flags, item); } } return flags; } export function setSighashFlags(flags: bigint, signature: string): bigint { switch(signature.slice(-2)) { case '01': return flags | TransactionFlags.sighash_all; case '02': return flags | TransactionFlags.sighash_none; case '03': return flags | TransactionFlags.sighash_single; case '81': return flags | TransactionFlags.sighash_all | TransactionFlags.sighash_acp; case '82': return flags | TransactionFlags.sighash_none | TransactionFlags.sighash_acp; case '83': return flags | TransactionFlags.sighash_single | TransactionFlags.sighash_acp; default: return flags | TransactionFlags.sighash_default; // taproot only } } export function isBurnKey(pubkey: string): boolean { return [ '022222222222222222222222222222222222222222222222222222222222222222', '033333333333333333333333333333333333333333333333333333333333333333', '020202020202020202020202020202020202020202020202020202020202020202', '030303030303030303030303030303030303030303030303030303030303030303', ].includes(pubkey); } export function getTransactionFlags(tx: Transaction, cpfpInfo?: CpfpInfo, replacement?: boolean, height?: number, network?: string): bigint { let flags = tx.flags ? BigInt(tx.flags) : 0n; // Update variable flags (CPFP, RBF) if (cpfpInfo) { if (cpfpInfo.ancestors.length) { flags |= TransactionFlags.cpfp_child; } if (cpfpInfo.descendants?.length) { flags |= TransactionFlags.cpfp_parent; } } if (replacement) { flags |= TransactionFlags.replacement; } // Already processed static flags, no need to do it again if (tx.flags) { return flags; } // Process static flags if (tx.version === 1) { flags |= TransactionFlags.v1; } else if (tx.version === 2) { flags |= TransactionFlags.v2; } else if (tx.version === 3) { flags |= TransactionFlags.v3; } const reusedInputAddresses: { [address: string ]: number } = {}; const reusedOutputAddresses: { [address: string ]: number } = {}; const inValues = {}; const outValues = {}; let rbf = false; for (const vin of tx.vin) { if (vin.sequence < 0xfffffffe) { rbf = true; } switch (vin.prevout?.scriptpubkey_type) { case 'p2pk': flags |= TransactionFlags.p2pk; break; case 'multisig': flags |= TransactionFlags.p2ms; break; case 'p2pkh': flags |= TransactionFlags.p2pkh; break; case 'p2sh': flags |= TransactionFlags.p2sh; break; case 'v0_p2wpkh': flags |= TransactionFlags.p2wpkh; break; case 'v0_p2wsh': flags |= TransactionFlags.p2wsh; break; case 'v1_p2tr': { flags |= TransactionFlags.p2tr; // every valid taproot input has at least one witness item, however transactions // created before taproot activation don't need to have any witness data // (see https://mempool.space/tx/b10c007c60e14f9d087e0291d4d0c7869697c6681d979c6639dbd960792b4d41) if (vin.witness?.length) { // in taproot, if the last witness item begins with 0x50, it's an annex const hasAnnex = vin.witness?.[vin.witness.length - 1].startsWith('50'); // script spends have more than one witness item, not counting the annex (if present) if (vin.witness.length > (hasAnnex ? 2 : 1)) { // the script itself is the second-to-last witness item, not counting the annex const asm = vin.inner_witnessscript_asm; // inscriptions smuggle data within an 'OP_0 OP_IF ... OP_ENDIF' envelope if (asm?.includes('OP_0 OP_IF')) { flags |= TransactionFlags.inscription; } } } } break; } // sighash flags if (vin.prevout?.scriptpubkey_type === 'v1_p2tr') { flags |= setSchnorrSighashFlags(flags, vin.witness); } else if (vin.witness) { flags |= setSegwitSighashFlags(flags, vin.witness); } else if (vin.scriptsig?.length) { flags |= setLegacySighashFlags(flags, vin.scriptsig_asm); } if (vin.prevout?.scriptpubkey_address) { reusedInputAddresses[vin.prevout?.scriptpubkey_address] = (reusedInputAddresses[vin.prevout?.scriptpubkey_address] || 0) + 1; } inValues[vin.prevout?.value || Math.random()] = (inValues[vin.prevout?.value || Math.random()] || 0) + 1; } if (rbf) { flags |= TransactionFlags.rbf; } else { flags |= TransactionFlags.no_rbf; } let hasFakePubkey = false; let P2WSHCount = 0; let olgaSize = 0; for (const vout of tx.vout) { switch (vout.scriptpubkey_type) { case 'p2pk': { flags |= TransactionFlags.p2pk; // detect fake pubkey (i.e. not a valid DER point on the secp256k1 curve) hasFakePubkey = hasFakePubkey || !isPoint(vout.scriptpubkey?.slice(2, -2)); } break; case 'multisig': { flags |= TransactionFlags.p2ms; // detect fake pubkeys (i.e. not valid DER points on the secp256k1 curve) const asm = vout.scriptpubkey_asm; for (const key of (asm?.split(' ') || [])) { if (!hasFakePubkey && !key.startsWith('OP_')) { hasFakePubkey = hasFakePubkey || isBurnKey(key) || !isPoint(key); } } } break; case 'p2pkh': flags |= TransactionFlags.p2pkh; break; case 'p2sh': flags |= TransactionFlags.p2sh; break; case 'v0_p2wpkh': flags |= TransactionFlags.p2wpkh; break; case 'v0_p2wsh': flags |= TransactionFlags.p2wsh; break; case 'v1_p2tr': flags |= TransactionFlags.p2tr; break; case 'op_return': flags |= TransactionFlags.op_return; break; } if (vout.scriptpubkey_address) { reusedOutputAddresses[vout.scriptpubkey_address] = (reusedOutputAddresses[vout.scriptpubkey_address] || 0) + 1; } if (vout.scriptpubkey_type === 'v0_p2wsh') { if (!P2WSHCount) { olgaSize = parseInt(vout.scriptpubkey.slice(4, 8), 16); } P2WSHCount++; if (P2WSHCount === Math.ceil((olgaSize + 2) / 32)) { const nullBytes = (P2WSHCount * 32) - olgaSize - 2; if (vout.scriptpubkey.endsWith(''.padEnd(nullBytes * 2, '0'))) { flags |= TransactionFlags.fake_scripthash; } } } else { P2WSHCount = 0; } outValues[vout.value || Math.random()] = (outValues[vout.value || Math.random()] || 0) + 1; } if (hasFakePubkey) { flags |= TransactionFlags.fake_pubkey; } // fast but bad heuristic to detect possible coinjoins // (at least 5 inputs and 5 outputs, less than half of which are unique amounts, with no address reuse) const addressReuse = Object.keys(reusedOutputAddresses).reduce((acc, key) => Math.max(acc, (reusedInputAddresses[key] || 0) + (reusedOutputAddresses[key] || 0)), 0) > 1; if (!addressReuse && tx.vin.length >= 5 && tx.vout.length >= 5 && (Object.keys(inValues).length + Object.keys(outValues).length) <= (tx.vin.length + tx.vout.length) / 2 ) { flags |= TransactionFlags.coinjoin; } // more than 5:1 input:output ratio if (tx.vin.length / tx.vout.length >= 5) { flags |= TransactionFlags.consolidation; } // less than 1:5 input:output ratio if (tx.vin.length / tx.vout.length <= 0.2) { flags |= TransactionFlags.batch_payout; } if (isNonStandard(tx, height, network)) { flags |= TransactionFlags.nonstandard; } return flags; } export function getUnacceleratedFeeRate(tx: Transaction, accelerated: boolean): number { if (accelerated) { let ancestorVsize = tx.weight / 4; let ancestorFee = tx.fee; for (const ancestor of tx.ancestors || []) { ancestorVsize += (ancestor.weight / 4); ancestorFee += ancestor.fee; } return Math.min(tx.fee / (tx.weight / 4), (ancestorFee / ancestorVsize)); } else { return tx.effectiveFeePerVsize; } } export function identifyPrioritizedTransactions(transactions: TransactionStripped[]): { prioritized: string[], deprioritized: string[] } { // find the longest increasing subsequence of transactions // (adapted from https://en.wikipedia.org/wiki/Longest_increasing_subsequence#Efficient_algorithms) // should be O(n log n) const X = transactions.slice(1).reverse(); // standard block order is by *decreasing* effective fee rate, but we want to iterate in increasing order (and skip the coinbase) if (X.length < 2) { return { prioritized: [], deprioritized: [] }; } const N = X.length; const P: number[] = new Array(N); const M: number[] = new Array(N + 1); M[0] = -1; // undefined so can be set to any value let L = 0; for (let i = 0; i < N; i++) { // Binary search for the smallest positive l ≤ L // such that X[M[l]].effectiveFeePerVsize > X[i].effectiveFeePerVsize let lo = 1; let hi = L + 1; while (lo < hi) { const mid = lo + Math.floor((hi - lo) / 2); // lo <= mid < hi if (X[M[mid]].rate > X[i].rate) { hi = mid; } else { // if X[M[mid]].effectiveFeePerVsize < X[i].effectiveFeePerVsize lo = mid + 1; } } // After searching, lo == hi is 1 greater than the // length of the longest prefix of X[i] const newL = lo; // The predecessor of X[i] is the last index of // the subsequence of length newL-1 P[i] = M[newL - 1]; M[newL] = i; if (newL > L) { // If we found a subsequence longer than any we've // found yet, update L L = newL; } } // Reconstruct the longest increasing subsequence // It consists of the values of X at the L indices: // ..., P[P[M[L]]], P[M[L]], M[L] const LIS: TransactionStripped[] = new Array(L); let k = M[L]; for (let j = L - 1; j >= 0; j--) { LIS[j] = X[k]; k = P[k]; } const lisMap = new Map(); LIS.forEach((tx, index) => lisMap.set(tx.txid, index)); const prioritized: string[] = []; const deprioritized: string[] = []; let lastRate = 0; for (const tx of X) { if (lisMap.has(tx.txid)) { lastRate = tx.rate; } else { if (Math.abs(tx.rate - lastRate) < 0.1) { // skip if the rate is almost the same as the previous transaction } else if (tx.rate <= lastRate) { prioritized.push(tx.txid); } else { deprioritized.push(tx.txid); } } } return { prioritized, deprioritized }; } // Adapted from mempool backend https://github.com/mempool/mempool/blob/14e49126c3ca8416a8d7ad134a95c5e090324d69/backend/src/api/transaction-utils.ts#L254 // Converts hex bitcoin script to ASM function convertScriptSigAsm(hex: string): string { const buf = new Uint8Array(hex.length / 2); for (let i = 0; i < buf.length; i++) { buf[i] = parseInt(hex.substr(i * 2, 2), 16); } const b = []; let i = 0; while (i < buf.length) { const op = buf[i]; if (op >= 0x01 && op <= 0x4e) { i++; let push; if (op === 0x4c) { push = buf[i]; b.push('OP_PUSHDATA1'); i += 1; } else if (op === 0x4d) { push = buf[i] | (buf[i + 1] << 8); b.push('OP_PUSHDATA2'); i += 2; } else if (op === 0x4e) { push = buf[i] | (buf[i + 1] << 8) | (buf[i + 2] << 16) | (buf[i + 3] << 24); 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(uint8ArrayToHexString(data)); 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 = opcodes[op]; if (opcode) { b.push(opcode); } else { b.push('OP_RETURN_' + op); } } i += 1; } } return b.join(' '); } // Copied from mempool backend https://github.com/mempool/mempool/blob/14e49126c3ca8416a8d7ad134a95c5e090324d69/backend/src/api/transaction-utils.ts#L327 /** * 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. */ function 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]; } // Copied from mempool backend https://github.com/mempool/mempool/blob/14e49126c3ca8416a8d7ad134a95c5e090324d69/backend/src/api/transaction-utils.ts#L227 // Fills inner_redeemscript_asm and inner_witnessscript_asm fields of fetched prevouts for decoded transactions export function addInnerScriptsToVin(vin: Vin): void { if (!vin.prevout) { return; } if (vin.prevout.scriptpubkey_type === 'p2sh') { const redeemScript = vin.scriptsig_asm.split(' ').reverse()[0]; vin.inner_redeemscript_asm = convertScriptSigAsm(redeemScript); if (vin.witness && vin.witness.length > 2) { const witnessScript = vin.witness[vin.witness.length - 1]; vin.inner_witnessscript_asm = convertScriptSigAsm(witnessScript); } } if (vin.prevout.scriptpubkey_type === 'v0_p2wsh' && vin.witness) { const witnessScript = vin.witness[vin.witness.length - 1]; vin.inner_witnessscript_asm = convertScriptSigAsm(witnessScript); } if (vin.prevout.scriptpubkey_type === 'v1_p2tr' && vin.witness) { const witnessScript = witnessToP2TRScript(vin.witness); if (witnessScript !== null) { vin.inner_witnessscript_asm = convertScriptSigAsm(witnessScript); } } } // Adapted from bitcoinjs-lib at https://github.com/bitcoinjs/bitcoinjs-lib/blob/32e08aa57f6a023e995d8c4f0c9fbdc5f11d1fa0/ts_src/transaction.ts#L78 // Reads buffer of raw transaction data function fromBuffer(buffer: Uint8Array, network: string): Transaction { let offset = 0; function readInt8(): number { if (offset + 1 > buffer.length) { throw new Error('Buffer out of bounds'); } return buffer[offset++]; } function readInt16() { if (offset + 2 > buffer.length) { throw new Error('Buffer out of bounds'); } const value = buffer[offset] | (buffer[offset + 1] << 8); offset += 2; return value; } function readInt32(unsigned = false): number { if (offset + 4 > buffer.length) { throw new Error('Buffer out of bounds'); } const value = buffer[offset] | (buffer[offset + 1] << 8) | (buffer[offset + 2] << 16) | (buffer[offset + 3] << 24); offset += 4; if (unsigned) { return value >>> 0; } return value; } function readInt64(): bigint { if (offset + 8 > buffer.length) { throw new Error('Buffer out of bounds'); } const low = BigInt(buffer[offset] | (buffer[offset + 1] << 8) | (buffer[offset + 2] << 16) | (buffer[offset + 3] << 24)); const high = BigInt(buffer[offset + 4] | (buffer[offset + 5] << 8) | (buffer[offset + 6] << 16) | (buffer[offset + 7] << 24)); offset += 8; return (high << 32n) | (low & 0xffffffffn); } function readVarInt(): bigint { const first = readInt8(); if (first < 0xfd) { return BigInt(first); } else if (first === 0xfd) { return BigInt(readInt16()); } else if (first === 0xfe) { return BigInt(readInt32(true)); } else if (first === 0xff) { return readInt64(); } else { throw new Error("Invalid VarInt prefix"); } } function readSlice(n: number | bigint): Uint8Array { const length = Number(n); if (offset + length > buffer.length) { throw new Error('Cannot read slice out of bounds'); } const slice = buffer.slice(offset, offset + length); offset += length; return slice; } function readVarSlice(): Uint8Array { return readSlice(readVarInt()); } function readVector(): Uint8Array[] { const count = readVarInt(); const vector = []; for (let i = 0; i < count; i++) { vector.push(readVarSlice()); } return vector; } // Parse raw transaction const tx = { status: { confirmed: null, block_height: null, block_hash: null, block_time: null, } } as Transaction; tx.version = readInt32(); const marker = readInt8(); const flag = readInt8(); let hasWitnesses = false; if ( marker === 0x00 && flag === 0x01 ) { hasWitnesses = true; } else { offset -= 2; } const vinLen = readVarInt(); tx.vin = []; for (let i = 0; i < vinLen; ++i) { const txid = uint8ArrayToHexString(readSlice(32).reverse()); const vout = readInt32(true); const scriptsig = uint8ArrayToHexString(readVarSlice()); const sequence = readInt32(true); const is_coinbase = txid === '0'.repeat(64); const scriptsig_asm = convertScriptSigAsm(scriptsig); tx.vin.push({ txid, vout, scriptsig, sequence, is_coinbase, scriptsig_asm, prevout: null }); } const voutLen = readVarInt(); tx.vout = []; for (let i = 0; i < voutLen; ++i) { const value = Number(readInt64()); const scriptpubkeyArray = readVarSlice(); const scriptpubkey = uint8ArrayToHexString(scriptpubkeyArray) const scriptpubkey_asm = convertScriptSigAsm(scriptpubkey); const toAddress = scriptPubKeyToAddress(scriptpubkey, network); const scriptpubkey_type = toAddress.type; const scriptpubkey_address = toAddress?.address; tx.vout.push({ value, scriptpubkey, scriptpubkey_asm, scriptpubkey_type, scriptpubkey_address }); } let witnessSize = 0; if (hasWitnesses) { const startOffset = offset; for (let i = 0; i < vinLen; ++i) { tx.vin[i].witness = readVector().map(uint8ArrayToHexString); } witnessSize = offset - startOffset + 2; } tx.locktime = readInt32(true); if (offset !== buffer.length) { throw new Error('Transaction has unexpected data'); } tx.size = buffer.length; tx.weight = (tx.size - witnessSize) * 3 + tx.size; tx.txid = txid(tx); return tx; } export function decodeRawTransaction(rawtx: string, network: string): Transaction { if (!rawtx.length || rawtx.length % 2 !== 0 || !/^[0-9a-fA-F]*$/.test(rawtx)) { throw new Error('Invalid hex string'); } const buffer = new Uint8Array(rawtx.length / 2); for (let i = 0; i < rawtx.length; i += 2) { buffer[i / 2] = parseInt(rawtx.substring(i, i + 2), 16); } return fromBuffer(buffer, network); } function serializeTransaction(tx: Transaction): Uint8Array { const result: number[] = []; // Add version result.push(...intToBytes(tx.version, 4)); // Add input count and inputs result.push(...varIntToBytes(tx.vin.length)); for (const input of tx.vin) { result.push(...hexStringToUint8Array(input.txid).reverse()); result.push(...intToBytes(input.vout, 4)); const scriptSig = hexStringToUint8Array(input.scriptsig); result.push(...varIntToBytes(scriptSig.length)); result.push(...scriptSig); result.push(...intToBytes(input.sequence, 4)); } // Add output count and outputs result.push(...varIntToBytes(tx.vout.length)); for (const output of tx.vout) { result.push(...bigIntToBytes(BigInt(output.value), 8)); const scriptPubKey = hexStringToUint8Array(output.scriptpubkey); result.push(...varIntToBytes(scriptPubKey.length)); result.push(...scriptPubKey); } // Add locktime result.push(...intToBytes(tx.locktime, 4)); return new Uint8Array(result); } function txid(tx: Transaction): string { const serializedTx = serializeTransaction(tx); const hash1 = new Hash().update(serializedTx).digest(); const hash2 = new Hash().update(hash1).digest(); return uint8ArrayToHexString(hash2.reverse()); } // Copied from mempool backend https://github.com/mempool/mempool/blob/14e49126c3ca8416a8d7ad134a95c5e090324d69/backend/src/api/transaction-utils.ts#L177 export function countSigops(transaction: Transaction): number { let sigops = 0; for (const input of transaction.vin) { if (input.scriptsig_asm) { sigops += 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 += countScriptSigops(convertScriptSigAsm(input.witness[input.witness.length - 1]), false, true); } break; case input.prevout.scriptpubkey_type === 'p2sh': if (input.inner_redeemscript_asm) { sigops += countScriptSigops(input.inner_redeemscript_asm); } break; } } } for (const output of transaction.vout) { if (output.scriptpubkey_asm) { sigops += countScriptSigops(output.scriptpubkey_asm, true); } } return sigops; } function scriptPubKeyToAddress(scriptPubKey: string, network: string): { address: string, type: string } { // P2PKH if (/^76a914[0-9a-f]{40}88ac$/.test(scriptPubKey)) { return { address: p2pkh(scriptPubKey.substring(6, 6 + 40), network), type: 'p2pkh' }; } // P2PK if (/^21[0-9a-f]{66}ac$/.test(scriptPubKey) || /^41[0-9a-f]{130}ac$/.test(scriptPubKey)) { return { address: null, type: 'p2pk' }; } // P2SH if (/^a914[0-9a-f]{40}87$/.test(scriptPubKey)) { return { address: p2sh(scriptPubKey.substring(4, 4 + 40), network), type: 'p2sh' }; } // P2WPKH if (/^0014[0-9a-f]{40}$/.test(scriptPubKey)) { return { address: p2wpkh(scriptPubKey.substring(4, 4 + 40), network), type: 'v0_p2wpkh' }; } // P2WSH if (/^0020[0-9a-f]{64}$/.test(scriptPubKey)) { return { address: p2wsh(scriptPubKey.substring(4, 4 + 64), network), type: 'v0_p2wsh' }; } // P2TR if (/^5120[0-9a-f]{64}$/.test(scriptPubKey)) { return { address: p2tr(scriptPubKey.substring(4, 4 + 64), network), type: 'v1_p2tr' }; } // multisig if (/^[0-9a-f]+ae$/.test(scriptPubKey)) { return { address: null, type: 'multisig' }; } // anchor if (scriptPubKey === '51024e73') { return { address: p2a(network), type: 'anchor' }; } // op_return if (/^6a/.test(scriptPubKey)) { return { address: null, type: 'op_return' }; } return { address: null, type: 'unknown' }; } function p2pkh(pubKeyHash: string, network: string): string { const pubkeyHashArray = hexStringToUint8Array(pubKeyHash); const version = ['testnet', 'testnet4', 'signet'].includes(network) ? 0x6f : 0x00; const versionedPayload = Uint8Array.from([version, ...pubkeyHashArray]); const hash1 = new Hash().update(versionedPayload).digest(); const hash2 = new Hash().update(hash1).digest(); const checksum = hash2.slice(0, 4); const finalPayload = Uint8Array.from([...versionedPayload, ...checksum]); const bitcoinAddress = base58Encode(finalPayload); return bitcoinAddress; } function p2sh(scriptHash: string, network: string): string { const scriptHashArray = hexStringToUint8Array(scriptHash); const version = ['testnet', 'testnet4', 'signet'].includes(network) ? 0xc4 : 0x05; const versionedPayload = Uint8Array.from([version, ...scriptHashArray]); const hash1 = new Hash().update(versionedPayload).digest(); const hash2 = new Hash().update(hash1).digest(); const checksum = hash2.slice(0, 4); const finalPayload = Uint8Array.from([...versionedPayload, ...checksum]); const bitcoinAddress = base58Encode(finalPayload); return bitcoinAddress; } function p2wpkh(pubKeyHash: string, network: string): string { const pubkeyHashArray = hexStringToUint8Array(pubKeyHash); const hrp = ['testnet', 'testnet4', 'signet'].includes(network) ? 'tb' : 'bc'; const version = 0; const words = [version].concat(toWords(pubkeyHashArray)); const bech32Address = bech32Encode(hrp, words); return bech32Address; } function p2wsh(scriptHash: string, network: string): string { const scriptHashArray = hexStringToUint8Array(scriptHash); const hrp = ['testnet', 'testnet4', 'signet'].includes(network) ? 'tb' : 'bc'; const version = 0; const words = [version].concat(toWords(scriptHashArray)); const bech32Address = bech32Encode(hrp, words); return bech32Address; } function p2tr(pubKeyHash: string, network: string): string { const pubkeyHashArray = hexStringToUint8Array(pubKeyHash); const hrp = ['testnet', 'testnet4', 'signet'].includes(network) ? 'tb' : 'bc'; const version = 1; const words = [version].concat(toWords(pubkeyHashArray)); const bech32Address = bech32Encode(hrp, words, 0x2bc830a3); return bech32Address; } function p2a(network: string): string { const pubkeyHashArray = hexStringToUint8Array('4e73'); const hrp = ['testnet', 'testnet4', 'signet'].includes(network) ? 'tb' : 'bc'; const version = 1; const words = [version].concat(toWords(pubkeyHashArray)); const bech32Address = bech32Encode(hrp, words, 0x2bc830a3); return bech32Address; } // base58 encoding function base58Encode(data: Uint8Array): string { const BASE58_ALPHABET = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"; let hexString = Array.from(data) .map(byte => byte.toString(16).padStart(2, '0')) .join(''); let num = BigInt("0x" + hexString); let encoded = ""; while (num > 0) { const remainder = Number(num % 58n); num = num / 58n; encoded = BASE58_ALPHABET[remainder] + encoded; } for (let byte of data) { if (byte === 0) { encoded = "1" + encoded; } else { break; } } return encoded; } // bech32 encoding // Adapted from https://github.com/bitcoinjs/bech32/blob/5ceb0e3d4625561a459c85643ca6947739b2d83c/src/index.ts function bech32Encode(prefix: string, words: number[], constant: number = 1) { const BECH32_ALPHABET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l"; const checksum = createChecksum(prefix, words, constant); const combined = words.concat(checksum); let result = prefix + '1'; for (let i = 0; i < combined.length; ++i) { result += BECH32_ALPHABET.charAt(combined[i]); } return result; } function polymodStep(pre) { const GENERATORS = [0x3b6a57b2, 0x26508e6d, 0x1ea119fa, 0x3d4233dd, 0x2a1462b3]; const b = pre >> 25; return ( ((pre & 0x1ffffff) << 5) ^ ((b & 1 ? GENERATORS[0] : 0) ^ (b & 2 ? GENERATORS[1] : 0) ^ (b & 4 ? GENERATORS[2] : 0) ^ (b & 8 ? GENERATORS[3] : 0) ^ (b & 16 ? GENERATORS[4] : 0)) ); } function prefixChk(prefix) { let chk = 1; for (let i = 0; i < prefix.length; ++i) { const c = prefix.charCodeAt(i); chk = polymodStep(chk) ^ (c >> 5); } chk = polymodStep(chk); for (let i = 0; i < prefix.length; ++i) { const c = prefix.charCodeAt(i); chk = polymodStep(chk) ^ (c & 0x1f); } return chk; } function createChecksum(prefix: string, words: number[], constant: number) { const POLYMOD_CONST = constant; let chk = prefixChk(prefix); for (let i = 0; i < words.length; ++i) { const x = words[i]; chk = polymodStep(chk) ^ x; } for (let i = 0; i < 6; ++i) { chk = polymodStep(chk); } chk ^= POLYMOD_CONST; const checksum = []; for (let i = 0; i < 6; ++i) { checksum.push((chk >> (5 * (5 - i))) & 31); } return checksum; } function convertBits(data, fromBits, toBits, pad) { let acc = 0; let bits = 0; const ret = []; const maxV = (1 << toBits) - 1; for (let i = 0; i < data.length; ++i) { const value = data[i]; if (value < 0 || value >> fromBits) throw new Error('Invalid value'); acc = (acc << fromBits) | value; bits += fromBits; while (bits >= toBits) { bits -= toBits; ret.push((acc >> bits) & maxV); } } if (pad) { if (bits > 0) { ret.push((acc << (toBits - bits)) & maxV); } } else if (bits >= fromBits || ((acc << (toBits - bits)) & maxV)) { throw new Error('Invalid data'); } return ret; } function toWords(bytes) { return convertBits(bytes, 8, 5, true); } // Helper functions function uint8ArrayToHexString(uint8Array: Uint8Array): string { return Array.from(uint8Array).map(byte => byte.toString(16).padStart(2, '0')).join(''); } function hexStringToUint8Array(hex: string): Uint8Array { const buf = new Uint8Array(hex.length / 2); for (let i = 0; i < buf.length; i++) { buf[i] = parseInt(hex.substr(i * 2, 2), 16); } return buf; } function intToBytes(value: number, byteLength: number): number[] { const bytes = []; for (let i = 0; i < byteLength; i++) { bytes.push((value >> (8 * i)) & 0xff); } return bytes; } function bigIntToBytes(value: bigint, byteLength: number): number[] { const bytes = []; for (let i = 0; i < byteLength; i++) { bytes.push(Number((value >> BigInt(8 * i)) & 0xffn)); } return bytes; } function varIntToBytes(value: number | bigint): number[] { const bytes = []; if (typeof value === 'number') { if (value < 0xfd) { bytes.push(value); } else if (value <= 0xffff) { bytes.push(0xfd, value & 0xff, (value >> 8) & 0xff); } else if (value <= 0xffffffff) { bytes.push(0xfe, ...intToBytes(value, 4)); } } else { if (value < 0xfdn) { bytes.push(Number(value)); } else if (value <= 0xffffn) { bytes.push(0xfd, Number(value & 0xffn), Number((value >> 8n) & 0xffn)); } else if (value <= 0xffffffffn) { bytes.push(0xfe, ...intToBytes(Number(value), 4)); } else { bytes.push(0xff, ...bigIntToBytes(value, 8)); } } return bytes; } // Inversed the opcodes object from https://github.com/mempool/mempool/blob/14e49126c3ca8416a8d7ad134a95c5e090324d69/backend/src/utils/bitcoin-script.ts#L1 const opcodes = { 0: 'OP_0', 76: 'OP_PUSHDATA1', 77: 'OP_PUSHDATA2', 78: 'OP_PUSHDATA4', 79: 'OP_PUSHNUM_NEG1', 80: 'OP_RESERVED', 81: 'OP_PUSHNUM_1', 82: 'OP_PUSHNUM_2', 83: 'OP_PUSHNUM_3', 84: 'OP_PUSHNUM_4', 85: 'OP_PUSHNUM_5', 86: 'OP_PUSHNUM_6', 87: 'OP_PUSHNUM_7', 88: 'OP_PUSHNUM_8', 89: 'OP_PUSHNUM_9', 90: 'OP_PUSHNUM_10', 91: 'OP_PUSHNUM_11', 92: 'OP_PUSHNUM_12', 93: 'OP_PUSHNUM_13', 94: 'OP_PUSHNUM_14', 95: 'OP_PUSHNUM_15', 96: 'OP_PUSHNUM_16', 97: 'OP_NOP', 98: 'OP_VER', 99: 'OP_IF', 100: 'OP_NOTIF', 101: 'OP_VERIF', 102: 'OP_VERNOTIF', 103: 'OP_ELSE', 104: 'OP_ENDIF', 105: 'OP_VERIFY', 106: 'OP_RETURN', 107: 'OP_TOALTSTACK', 108: 'OP_FROMALTSTACK', 109: 'OP_2DROP', 110: 'OP_2DUP', 111: 'OP_3DUP', 112: 'OP_2OVER', 113: 'OP_2ROT', 114: 'OP_2SWAP', 115: 'OP_IFDUP', 116: 'OP_DEPTH', 117: 'OP_DROP', 118: 'OP_DUP', 119: 'OP_NIP', 120: 'OP_OVER', 121: 'OP_PICK', 122: 'OP_ROLL', 123: 'OP_ROT', 124: 'OP_SWAP', 125: 'OP_TUCK', 126: 'OP_CAT', 127: 'OP_SUBSTR', 128: 'OP_LEFT', 129: 'OP_RIGHT', 130: 'OP_SIZE', 131: 'OP_INVERT', 132: 'OP_AND', 133: 'OP_OR', 134: 'OP_XOR', 135: 'OP_EQUAL', 136: 'OP_EQUALVERIFY', 137: 'OP_RESERVED1', 138: 'OP_RESERVED2', 139: 'OP_1ADD', 140: 'OP_1SUB', 141: 'OP_2MUL', 142: 'OP_2DIV', 143: 'OP_NEGATE', 144: 'OP_ABS', 145: 'OP_NOT', 146: 'OP_0NOTEQUAL', 147: 'OP_ADD', 148: 'OP_SUB', 149: 'OP_MUL', 150: 'OP_DIV', 151: 'OP_MOD', 152: 'OP_LSHIFT', 153: 'OP_RSHIFT', 154: 'OP_BOOLAND', 155: 'OP_BOOLOR', 156: 'OP_NUMEQUAL', 157: 'OP_NUMEQUALVERIFY', 158: 'OP_NUMNOTEQUAL', 159: 'OP_LESSTHAN', 160: 'OP_GREATERTHAN', 161: 'OP_LESSTHANOREQUAL', 162: 'OP_GREATERTHANOREQUAL', 163: 'OP_MIN', 164: 'OP_MAX', 165: 'OP_WITHIN', 166: 'OP_RIPEMD160', 167: 'OP_SHA1', 168: 'OP_SHA256', 169: 'OP_HASH160', 170: 'OP_HASH256', 171: 'OP_CODESEPARATOR', 172: 'OP_CHECKSIG', 173: 'OP_CHECKSIGVERIFY', 174: 'OP_CHECKMULTISIG', 175: 'OP_CHECKMULTISIGVERIFY', 176: 'OP_NOP1', 177: 'OP_CHECKLOCKTIMEVERIFY', 178: 'OP_CHECKSEQUENCEVERIFY', 179: 'OP_NOP4', 180: 'OP_NOP5', 181: 'OP_NOP6', 182: 'OP_NOP7', 183: 'OP_NOP8', 184: 'OP_NOP9', 185: 'OP_NOP10', 186: 'OP_CHECKSIGADD', 253: 'OP_PUBKEYHASH', 254: 'OP_PUBKEY', 255: 'OP_INVALIDOPCODE', };