From 00dcff50ee7ba05bc8f48011b906eb9ffaf0798b Mon Sep 17 00:00:00 2001 From: Mononaut Date: Fri, 22 Mar 2024 09:52:27 +0000 Subject: [PATCH] Add Goggles filters tags to the transaction page --- .../transaction/transaction.component.html | 16 + .../transaction/transaction.component.scss | 5 + .../transaction/transaction.component.ts | 6 + .../src/app/interfaces/electrs.interface.ts | 1 + frontend/src/app/shared/filters.utils.ts | 43 +- frontend/src/app/shared/script.utils.ts | 281 ++++++++++++ frontend/src/app/shared/transaction.utils.ts | 418 ++++++++++++++++++ 7 files changed, 749 insertions(+), 21 deletions(-) create mode 100644 frontend/src/app/shared/script.utils.ts create mode 100644 frontend/src/app/shared/transaction.utils.ts diff --git a/frontend/src/app/components/transaction/transaction.component.html b/frontend/src/app/components/transaction/transaction.component.html index b767d74d8..277ab5a4b 100644 --- a/frontend/src/app/components/transaction/transaction.component.html +++ b/frontend/src/app/components/transaction/transaction.component.html @@ -91,6 +91,7 @@ + @@ -168,6 +169,7 @@ + @@ -563,3 +565,17 @@ + + + + + + + + Accelerated + + {{ filter.label }} + + + + diff --git a/frontend/src/app/components/transaction/transaction.component.scss b/frontend/src/app/components/transaction/transaction.component.scss index 3e699aba4..bfdd4cc03 100644 --- a/frontend/src/app/components/transaction/transaction.component.scss +++ b/frontend/src/app/components/transaction/transaction.component.scss @@ -310,3 +310,8 @@ margin-left: 5px; } } + +.goggles-icon { + display: block; + width: 2.2em; +} \ No newline at end of file diff --git a/frontend/src/app/components/transaction/transaction.component.ts b/frontend/src/app/components/transaction/transaction.component.ts index 82f26d3d7..79f8ebebe 100644 --- a/frontend/src/app/components/transaction/transaction.component.ts +++ b/frontend/src/app/components/transaction/transaction.component.ts @@ -21,6 +21,8 @@ import { ApiService } from '../../services/api.service'; import { SeoService } from '../../services/seo.service'; import { StorageService } from '../../services/storage.service'; import { seoDescriptionNetwork } from '../../shared/common.utils'; +import { getTransactionFlags } from '../../shared/transaction.utils'; +import { Filter, toFilters, TransactionFlags } from '../../shared/filters.utils'; import { BlockExtended, CpfpInfo, RbfTree, MempoolPosition, DifficultyAdjustment, Acceleration } from '../../interfaces/node-api.interface'; import { LiquidUnblinding } from './liquid-ublinding'; import { RelativeUrlPipe } from '../../shared/pipes/relative-url/relative-url.pipe'; @@ -89,6 +91,7 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy { adjustedVsize: number | null; pool: Pool | null; auditStatus: AuditStatus | null; + filters: Filter[] = []; showCpfpDetails = false; fetchCpfp$ = new Subject(); fetchRbfHistory$ = new Subject(); @@ -677,6 +680,8 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy { this.segwitEnabled = !this.tx.status.confirmed || isFeatureActive(this.stateService.network, this.tx.status.block_height, 'segwit'); this.taprootEnabled = !this.tx.status.confirmed || isFeatureActive(this.stateService.network, this.tx.status.block_height, 'taproot'); this.rbfEnabled = !this.tx.status.confirmed || isFeatureActive(this.stateService.network, this.tx.status.block_height, 'rbf'); + this.tx.flags = getTransactionFlags(this.tx); + this.filters = this.tx.flags ? toFilters(this.tx.flags).filter(f => f.txPage) : []; } else { this.segwitEnabled = false; this.taprootEnabled = false; @@ -723,6 +728,7 @@ export class TransactionComponent implements OnInit, AfterViewInit, OnDestroy { this.hasEffectiveFeeRate = false; this.rbfInfo = null; this.rbfReplaces = []; + this.filters = []; this.showCpfpDetails = false; this.accelerationInfo = null; this.txInBlockIndex = null; diff --git a/frontend/src/app/interfaces/electrs.interface.ts b/frontend/src/app/interfaces/electrs.interface.ts index 3378ede76..de2cbeeaf 100644 --- a/frontend/src/app/interfaces/electrs.interface.ts +++ b/frontend/src/app/interfaces/electrs.interface.ts @@ -27,6 +27,7 @@ export interface Transaction { _channels?: TransactionChannels; price?: Price; sigops?: number; + flags?: bigint; } export interface TransactionChannels { diff --git a/frontend/src/app/shared/filters.utils.ts b/frontend/src/app/shared/filters.utils.ts index 2a4155dff..ab99e00ce 100644 --- a/frontend/src/app/shared/filters.utils.ts +++ b/frontend/src/app/shared/filters.utils.ts @@ -6,6 +6,7 @@ export interface Filter { group?: string, important?: boolean, tooltip?: boolean, + txPage?: boolean, } export type FilterMode = 'and' | 'or'; @@ -74,40 +75,40 @@ export function toFilters(flags: bigint): Filter[] { export const TransactionFilters: { [key: string]: Filter } = { /* features */ - rbf: { key: 'rbf', label: 'RBF enabled', flag: TransactionFlags.rbf, toggle: 'rbf', important: true, tooltip: true, }, - no_rbf: { key: 'no_rbf', label: 'RBF disabled', flag: TransactionFlags.no_rbf, toggle: 'rbf', important: true, tooltip: true, }, - v1: { key: 'v1', label: 'Version 1', flag: TransactionFlags.v1, toggle: 'version', tooltip: true, }, - v2: { key: 'v2', label: 'Version 2', flag: TransactionFlags.v2, toggle: 'version', tooltip: true, }, - v3: { key: 'v3', label: 'Version 3', flag: TransactionFlags.v3, toggle: 'version', tooltip: true, }, - nonstandard: { key: 'nonstandard', label: 'Non-Standard', flag: TransactionFlags.nonstandard, important: true, tooltip: true, }, + rbf: { key: 'rbf', label: 'RBF enabled', flag: TransactionFlags.rbf, toggle: 'rbf', important: true, tooltip: true, txPage: false, }, + no_rbf: { key: 'no_rbf', label: 'RBF disabled', flag: TransactionFlags.no_rbf, toggle: 'rbf', important: true, tooltip: true, txPage: false, }, + v1: { key: 'v1', label: 'Version 1', flag: TransactionFlags.v1, toggle: 'version', tooltip: true, txPage: false, }, + v2: { key: 'v2', label: 'Version 2', flag: TransactionFlags.v2, toggle: 'version', tooltip: true, txPage: false, }, + v3: { key: 'v3', label: 'Version 3', flag: TransactionFlags.v3, toggle: 'version', tooltip: true, txPage: false, }, + nonstandard: { key: 'nonstandard', label: 'Non-Standard', flag: TransactionFlags.nonstandard, important: true, tooltip: true, txPage: true, }, /* address types */ - p2pk: { key: 'p2pk', label: 'P2PK', flag: TransactionFlags.p2pk, important: true, tooltip: true, }, - p2ms: { key: 'p2ms', label: 'Bare multisig', flag: TransactionFlags.p2ms, important: true, tooltip: true, }, + p2pk: { key: 'p2pk', label: 'P2PK', flag: TransactionFlags.p2pk, important: true, tooltip: true, txPage: true, }, + p2ms: { key: 'p2ms', label: 'Bare multisig', flag: TransactionFlags.p2ms, important: true, tooltip: true, txPage: true, }, p2pkh: { key: 'p2pkh', label: 'P2PKH', flag: TransactionFlags.p2pkh, important: true, tooltip: false, }, p2sh: { key: 'p2sh', label: 'P2SH', flag: TransactionFlags.p2sh, important: true, tooltip: false, }, p2wpkh: { key: 'p2wpkh', label: 'P2WPKH', flag: TransactionFlags.p2wpkh, important: true, tooltip: false, }, p2wsh: { key: 'p2wsh', label: 'P2WSH', flag: TransactionFlags.p2wsh, important: true, tooltip: false, }, p2tr: { key: 'p2tr', label: 'Taproot', flag: TransactionFlags.p2tr, important: true, tooltip: false, }, /* behavior */ - cpfp_parent: { key: 'cpfp_parent', label: 'Paid for by child', flag: TransactionFlags.cpfp_parent, important: true, tooltip: true, }, - cpfp_child: { key: 'cpfp_child', label: 'Pays for parent', flag: TransactionFlags.cpfp_child, important: true, tooltip: true, }, - replacement: { key: 'replacement', label: 'Replacement', flag: TransactionFlags.replacement, important: true, tooltip: true, }, + cpfp_parent: { key: 'cpfp_parent', label: 'Paid for by child', flag: TransactionFlags.cpfp_parent, important: true, tooltip: true, txPage: false, }, + cpfp_child: { key: 'cpfp_child', label: 'Pays for parent', flag: TransactionFlags.cpfp_child, important: true, tooltip: true, txPage: false, }, + replacement: { key: 'replacement', label: 'Replacement', flag: TransactionFlags.replacement, important: true, tooltip: true, txPage: false, }, acceleration: window?.['__env']?.ACCELERATOR ? { key: 'acceleration', label: 'Accelerated', flag: TransactionFlags.acceleration, important: false } : undefined, /* data */ - op_return: { key: 'op_return', label: 'OP_RETURN', flag: TransactionFlags.op_return, important: true, tooltip: true, }, - fake_pubkey: { key: 'fake_pubkey', label: 'Fake pubkey', flag: TransactionFlags.fake_pubkey, tooltip: true, }, - inscription: { key: 'inscription', label: 'Inscription', flag: TransactionFlags.inscription, important: true, tooltip: true, }, - fake_scripthash: { key: 'fake_scripthash', label: 'Fake scripthash', flag: TransactionFlags.fake_scripthash, tooltip: true,}, + op_return: { key: 'op_return', label: 'OP_RETURN', flag: TransactionFlags.op_return, important: true, tooltip: true, txPage: true, }, + fake_pubkey: { key: 'fake_pubkey', label: 'Fake pubkey', flag: TransactionFlags.fake_pubkey, tooltip: true, txPage: true, }, + inscription: { key: 'inscription', label: 'Inscription', flag: TransactionFlags.inscription, important: true, tooltip: true, txPage: true, }, + fake_scripthash: { key: 'fake_scripthash', label: 'Fake scripthash', flag: TransactionFlags.fake_scripthash, tooltip: true, txPage: true,}, /* heuristics */ - coinjoin: { key: 'coinjoin', label: 'Coinjoin', flag: TransactionFlags.coinjoin, important: true, tooltip: true, }, - consolidation: { key: 'consolidation', label: 'Consolidation', flag: TransactionFlags.consolidation, tooltip: true, }, - batch_payout: { key: 'batch_payout', label: 'Batch payment', flag: TransactionFlags.batch_payout, tooltip: true, }, + coinjoin: { key: 'coinjoin', label: 'Coinjoin', flag: TransactionFlags.coinjoin, important: true, tooltip: true, txPage: true, }, + consolidation: { key: 'consolidation', label: 'Consolidation', flag: TransactionFlags.consolidation, tooltip: true, txPage: true, }, + batch_payout: { key: 'batch_payout', label: 'Batch payment', flag: TransactionFlags.batch_payout, tooltip: true, txPage: true, }, /* sighash */ sighash_all: { key: 'sighash_all', label: 'sighash_all', flag: TransactionFlags.sighash_all }, - sighash_none: { key: 'sighash_none', label: 'sighash_none', flag: TransactionFlags.sighash_none, tooltip: true, }, - sighash_single: { key: 'sighash_single', label: 'sighash_single', flag: TransactionFlags.sighash_single, tooltip: true, }, + sighash_none: { key: 'sighash_none', label: 'sighash_none', flag: TransactionFlags.sighash_none, tooltip: true }, + sighash_single: { key: 'sighash_single', label: 'sighash_single', flag: TransactionFlags.sighash_single, tooltip: true }, sighash_default: { key: 'sighash_default', label: 'sighash_default', flag: TransactionFlags.sighash_default }, - sighash_acp: { key: 'sighash_acp', label: 'sighash_anyonecanpay', flag: TransactionFlags.sighash_acp, tooltip: true, }, + sighash_acp: { key: 'sighash_acp', label: 'sighash_anyonecanpay', flag: TransactionFlags.sighash_acp, tooltip: true }, }; export const FilterGroups: { label: string, filters: Filter[]}[] = [ diff --git a/frontend/src/app/shared/script.utils.ts b/frontend/src/app/shared/script.utils.ts new file mode 100644 index 000000000..556cd40f2 --- /dev/null +++ b/frontend/src/app/shared/script.utils.ts @@ -0,0 +1,281 @@ +const opcodes = { + OP_FALSE: 0, + OP_0: 0, + OP_PUSHDATA1: 76, + OP_PUSHDATA2: 77, + OP_PUSHDATA4: 78, + OP_1NEGATE: 79, + OP_PUSHNUM_NEG1: 79, + OP_RESERVED: 80, + OP_TRUE: 81, + OP_1: 81, + OP_2: 82, + OP_3: 83, + OP_4: 84, + OP_5: 85, + OP_6: 86, + OP_7: 87, + OP_8: 88, + OP_9: 89, + OP_10: 90, + OP_11: 91, + OP_12: 92, + OP_13: 93, + OP_14: 94, + OP_15: 95, + OP_16: 96, + OP_PUSHNUM_1: 81, + OP_PUSHNUM_2: 82, + OP_PUSHNUM_3: 83, + OP_PUSHNUM_4: 84, + OP_PUSHNUM_5: 85, + OP_PUSHNUM_6: 86, + OP_PUSHNUM_7: 87, + OP_PUSHNUM_8: 88, + OP_PUSHNUM_9: 89, + OP_PUSHNUM_10: 90, + OP_PUSHNUM_11: 91, + OP_PUSHNUM_12: 92, + OP_PUSHNUM_13: 93, + OP_PUSHNUM_14: 94, + OP_PUSHNUM_15: 95, + OP_PUSHNUM_16: 96, + OP_NOP: 97, + OP_VER: 98, + OP_IF: 99, + OP_NOTIF: 100, + OP_VERIF: 101, + OP_VERNOTIF: 102, + OP_ELSE: 103, + OP_ENDIF: 104, + OP_VERIFY: 105, + OP_RETURN: 106, + OP_TOALTSTACK: 107, + OP_FROMALTSTACK: 108, + OP_2DROP: 109, + OP_2DUP: 110, + OP_3DUP: 111, + OP_2OVER: 112, + OP_2ROT: 113, + OP_2SWAP: 114, + OP_IFDUP: 115, + OP_DEPTH: 116, + OP_DROP: 117, + OP_DUP: 118, + OP_NIP: 119, + OP_OVER: 120, + OP_PICK: 121, + OP_ROLL: 122, + OP_ROT: 123, + OP_SWAP: 124, + OP_TUCK: 125, + OP_CAT: 126, + OP_SUBSTR: 127, + OP_LEFT: 128, + OP_RIGHT: 129, + OP_SIZE: 130, + OP_INVERT: 131, + OP_AND: 132, + OP_OR: 133, + OP_XOR: 134, + OP_EQUAL: 135, + OP_EQUALVERIFY: 136, + OP_RESERVED1: 137, + OP_RESERVED2: 138, + OP_1ADD: 139, + OP_1SUB: 140, + OP_2MUL: 141, + OP_2DIV: 142, + OP_NEGATE: 143, + OP_ABS: 144, + OP_NOT: 145, + OP_0NOTEQUAL: 146, + OP_ADD: 147, + OP_SUB: 148, + OP_MUL: 149, + OP_DIV: 150, + OP_MOD: 151, + OP_LSHIFT: 152, + OP_RSHIFT: 153, + OP_BOOLAND: 154, + OP_BOOLOR: 155, + OP_NUMEQUAL: 156, + OP_NUMEQUALVERIFY: 157, + OP_NUMNOTEQUAL: 158, + OP_LESSTHAN: 159, + OP_GREATERTHAN: 160, + OP_LESSTHANOREQUAL: 161, + OP_GREATERTHANOREQUAL: 162, + OP_MIN: 163, + OP_MAX: 164, + OP_WITHIN: 165, + OP_RIPEMD160: 166, + OP_SHA1: 167, + OP_SHA256: 168, + OP_HASH160: 169, + OP_HASH256: 170, + OP_CODESEPARATOR: 171, + OP_CHECKSIG: 172, + OP_CHECKSIGVERIFY: 173, + OP_CHECKMULTISIG: 174, + OP_CHECKMULTISIGVERIFY: 175, + OP_NOP1: 176, + OP_NOP2: 177, + OP_CHECKLOCKTIMEVERIFY: 177, + OP_CLTV: 177, + OP_NOP3: 178, + OP_CHECKSEQUENCEVERIFY: 178, + OP_CSV: 178, + OP_NOP4: 179, + OP_NOP5: 180, + OP_NOP6: 181, + OP_NOP7: 182, + OP_NOP8: 183, + OP_NOP9: 184, + OP_NOP10: 185, + OP_CHECKSIGADD: 186, + OP_PUBKEYHASH: 253, + OP_PUBKEY: 254, + OP_INVALIDOPCODE: 255, +}; +// add unused opcodes +for (let i = 187; i <= 255; i++) { + opcodes[`OP_RETURN_${i}`] = i; +} + +export { opcodes }; + +/** extracts m and n from a multisig script (asm), returns nothing if it is not a multisig script */ +export function parseMultisigScript(script: string): void | { m: number, n: number } { + if (!script) { + return; + } + const ops = script.split(' '); + if (ops.length < 3 || ops.pop() !== 'OP_CHECKMULTISIG') { + return; + } + const opN = ops.pop(); + if (!opN) { + return; + } + if (!opN.startsWith('OP_PUSHNUM_')) { + return; + } + const n = parseInt(opN.match(/[0-9]+/)?.[0] || '', 10); + if (ops.length < n * 2 + 1) { + return; + } + // pop n public keys + for (let i = 0; i < n; i++) { + if (!/^0((2|3)\w{64}|4\w{128})$/.test(ops.pop() || '')) { + return; + } + if (!/^OP_PUSHBYTES_(33|65)$/.test(ops.pop() || '')) { + return; + } + } + const opM = ops.pop(); + if (!opM) { + return; + } + if (!opM.startsWith('OP_PUSHNUM_')) { + return; + } + const m = parseInt(opM.match(/[0-9]+/)?.[0] || '', 10); + + if (ops.length) { + return; + } + + return { m, n }; +} + +export function getVarIntLength(n: number): number { + if (n < 0xfd) { + return 1; + } else if (n <= 0xffff) { + return 3; + } else if (n <= 0xffffffff) { + return 5; + } else { + return 9; + } +} + +function powMod(x: bigint, power: number, modulo: bigint): bigint { + for (let i = 0; i < power; i++) { + x = (x * x) % modulo; + } + return x; +} + +function sqrtMod(x: bigint, P: bigint): bigint { + const b2 = (x * x * x) % P; + const b3 = (b2 * b2 * x) % P; + const b6 = (powMod(b3, 3, P) * b3) % P; + const b9 = (powMod(b6, 3, P) * b3) % P; + const b11 = (powMod(b9, 2, P) * b2) % P; + const b22 = (powMod(b11, 11, P) * b11) % P; + const b44 = (powMod(b22, 22, P) * b22) % P; + const b88 = (powMod(b44, 44, P) * b44) % P; + const b176 = (powMod(b88, 88, P) * b88) % P; + const b220 = (powMod(b176, 44, P) * b44) % P; + const b223 = (powMod(b220, 3, P) * b3) % P; + const t1 = (powMod(b223, 23, P) * b22) % P; + const t2 = (powMod(t1, 6, P) * b2) % P; + const root = powMod(t2, 2, P); + return root; +} + +const curveP = BigInt(`0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEFFFFFC2F`); + +/** + * This function tells whether the point given is a DER encoded point on the ECDSA curve. + * @param {string} pointHex The point as a hex string (*must not* include a '0x' prefix) + * @returns {boolean} true if the point is on the SECP256K1 curve + */ +export function isPoint(pointHex: string): boolean { + if (!pointHex?.length) { + return false; + } + if ( + !( + // is uncompressed + ( + (pointHex.length === 130 && pointHex.startsWith('04')) || + // OR is compressed + (pointHex.length === 66 && + (pointHex.startsWith('02') || pointHex.startsWith('03'))) + ) + ) + ) { + return false; + } + + // Function modified slightly from noble-curves + + + // Now we know that pointHex is a 33 or 65 byte hex string. + const isCompressed = pointHex.length === 66; + + const x = BigInt(`0x${pointHex.slice(2, 66)}`); + if (x >= curveP) { + return false; + } + + if (!isCompressed) { + const y = BigInt(`0x${pointHex.slice(66, 130)}`); + if (y >= curveP) { + return false; + } + // Just check y^2 = x^3 + 7 (secp256k1 curve) + return (y * y) % curveP === (x * x * x + 7n) % curveP; + } else { + // Get unaltered y^2 (no mod p) + const ySquared = (x * x * x + 7n) % curveP; + // Try to sqrt it, it will round down if not perfect root + const y = sqrtMod(ySquared, curveP); + // If we square and it's equal, then it was a perfect root and valid point. + return (y * y) % curveP === ySquared; + } +} \ No newline at end of file diff --git a/frontend/src/app/shared/transaction.utils.ts b/frontend/src/app/shared/transaction.utils.ts new file mode 100644 index 000000000..aebdc1ed9 --- /dev/null +++ b/frontend/src/app/shared/transaction.utils.ts @@ -0,0 +1,418 @@ +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 (['unknown', 'provably_unspendable', 'empty'].includes(vout.scriptpubkey_type)) { + // (non-standard output type) + 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; +} + +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; +} \ No newline at end of file