459 lines
16 KiB
TypeScript
459 lines
16 KiB
TypeScript
import { TransactionFlags } from './filters.utils';
|
|
import { getVarIntLength, opcodes, parseMultisigScript, isPoint } from './script.utils';
|
|
import { Transaction } from '../interfaces/electrs.interface';
|
|
import { CpfpInfo, RbfInfo } from '../interfaces/node-api.interface';
|
|
|
|
// Bitcoin Core default policy settings
|
|
const TX_MAX_STANDARD_VERSION = 2;
|
|
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)
|
|
*/
|
|
export function isNonStandard(tx: Transaction): boolean {
|
|
// version
|
|
if (tx.version > TX_MAX_STANDARD_VERSION) {
|
|
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;
|
|
}
|
|
// 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 (['v0_p2wpkh', 'v0_p2wsh', 'v1_p2tr'].includes(vout.scriptpubkey_type)) {
|
|
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;
|
|
}
|
|
|
|
// 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): 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': {
|
|
if (!vin.witness?.length) {
|
|
throw new Error('Taproot input missing witness data');
|
|
}
|
|
flags |= TransactionFlags.p2tr;
|
|
// 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)) {
|
|
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;
|
|
}
|
|
} |