Repository navigation
Script interpreter: 8 consensus deviations vs Core (accepts-invalid / rejects-valid / flag integration) #3
Description
A deep review of the script-verification path (codec/interpreter.js, codec/script.js, codec/secp256k1.js, and the blocks.js integration) found eight deviations from Bitcoin Core consensus behaviour. File/line references are against the current packages/kernel/codec/ copies; the same files are vendored byte-identically in browser-node/engine/codec/ and health/engine/codec/, so fixes here should be re-vendored there.
Context on severity: in apps that validate with an empty UTXO set (e.g. health), resolvedInputs is always empty and the btc:rule-blockctx-scripts rule skips, so these are dormant there. They become live consensus bugs the moment prevouts are resolvable (browser-node with an assumeUTXO snapshot, or health's planned full validation).
Accepts-invalid (validator says ok, network rejects)
-
Stack-size limit never enforced for data pushes —
interpreter.js:526-532vs:548. The push branchcontinues past the combinedstack + altstack > 1000check, which only runs after opcode handlers. A scriptPubKey of 1001×OP_1(well under the 10,000-byte script cap) passes here; Core failsSCRIPT_ERR_STACK_SIZE. -
No 520-byte cap on initial witness stack elements (BIP141/BIP342) —
interpreter.js:585-611,:667. Core'sExecuteWitnessScriptrejects any initial witness element >MAX_SCRIPT_ELEMENT_SIZE. Here they land on the stack unchecked — e.g. a p2wpkh signature padded with trailing garbage to 600 bytes still verifies (the lenient DER parser ignores trailing bytes) and is accepted; Core rejects withPUSH_SIZE. -
Missing BIP141 structural rules in
verifyInput—interpreter.js:684-745. Three checks absent: native witness spends must have an empty scriptSig (never examined); non-witness inputs with a non-empty witness must fail (WITNESS_UNEXPECTED, silently ignored); P2SH-wrapped segwit scriptSig must be exactly one push of the redeemscript (WITNESS_MALLEATED_P2SH, extra pushes ignored). -
Witness-program shape gaps —
script.js:136-141(witness-unknownmatches only versions OP_2..OP_16) plus schema templates.OP_0 <n>with n ∉ {20,32} is a witness program Core treats as unspendable (WITNESS_PROGRAM_WRONG_LENGTH), but it falls through to bare-script evaluation and an empty scriptSig spend returns ok. ConverselyOP_1 <20 bytes>(unknown-semantics program, anyone-can-spend in Core) is evaluated as a bare script — rejected when the pushed bytes are falsy (rejects-valid) and accepted with a non-empty scriptSig (accepts-invalid). Should returnok:nulllike other unknown witness versions. -
parsePubkeylaxer than libsecp256k1 —secp256k1.js:74-91. Hybrid keys (0x06/0x07) never have the parity byte checked against y, and x/y in 65-byte encodings aren't range-checked < P. Narrow and adversarial-only, but each acceptance is a spend Core fails.
Rejects-valid (validator fails a network-valid block)
-
sighashTaprootthrows on any spent scriptPubKey or annex ≥ 253 bytes —interpreter.js:221-224(varbyteshelper), used at:240,249,253. BIP341'ssha_scriptpubkeys/sha_annexuse CompactSize; the helper throws'oversized script in sighash'at ≥ 0xfd. Becausesha_scriptpubkeyscovers all prevouts, a tx with one taproot input that also spends any large bare output (e.g. a 513-byte bare 15-of-15 multisig spk) hard-fails, as does a consensus-valid annex ≥ 253 bytes. -
Legacy sighash doesn't strip remaining
OP_CODESEPARATORs from scriptCode —interpreter.js:302+sighashLegacy(:145-168). Core'sSerializeScriptCodeskips every remaining separator after truncation; only truncate-at-last-executed is implemented (BIP143 correctly doesn't strip, so v0 is fine). A separator surviving in scriptCode (e.g. inside an untaken IF branch) yields a different sighash and rejects a network-valid signature. Historic/edge, but it hard-fails the containing block.
Integration
blocks.jsnever passes era flags toverifyInput—blocks.js:125. With flags omitted: BIP66 strict-DER, NULLDUMMY (BIP147) and NULLFAIL (BIP146) are never enforced on modern blocks (accepts-invalid), while P2SH/CLTV/CSV are enforced unconditionally at all heights (rejects-valid for deep history, e.g. pre-BIP16 spends of P2SH-shaped outputs). If flag-free operation is a deliberate trade-off it deserves a comment; otherwise the chain-params activation heights are already available to derive flags per height.
Smaller notes (uncertain / platform)
interpreter.js:451— CSV'stx.version < 2compares the codec's unsigned u32; Core compares signed int32, so version ≥ 0x80000000 diverges (contrived).interpreter.js:200—new Uint16Array([len]).bufferfor a length is platform-endian; wrong on big-endian hosts.interpreter.js:622-623— the tapscript sigops budget assumes a 1-byte CompactSize for the witness element count; ≥ 253 elements undercounts the budget by 2.
Verified-fine (checked, no action)
CScriptNum encode/decode incl. minimality and negative zero; strict-DER validator matches IsValidSignatureEncoding byte-for-byte; low-S boundary; legacy SIGHASH_SINGLE bug semantics; BIP143 midstates and zero-hash cases; BIP341 message layout, spend_type, annex detection, control-block/merkle/tweak math and parity; OP_SUCCESS set incl. error-before-success ordering; tapscript budget accounting order; CHECKMULTISIG pop order/early-exit/NULLDUMMY/NULLFAIL accounting; ECDSA/Schnorr group math and BIP340 range checks.
A consensus test-vector suite (Core's script_tests.json / tx_valid+tx_invalid) run against verifyInput would pin all of the above and is probably the right first step before fixing.
Activity
melvincarvalho commentedon Sep 5, 2026
Progress from the Core transaction corpus, now running upstream (bitcoin-desktop/schema#73, tx_valid/tx_invalid/sighash.json):
The kernel package here is still the June pin; a sync + pin bump picks all of this up.
melvincarvalho commentedon Sep 5, 2026
Items 2 and 4 fixed upstream in bitcoin-desktop/schema#80 (98fd23b): verifyInput now follows Core's VerifyScript order with the BIP 141 structural rules — IsWitnessProgram on raw bytes (direct push only), v0 length 20/32, WITNESS_MALLEATED / _MALLEATED_P2SH / _UNEXPECTED, 520-byte witness elements, v1 dispatch (native+TAPROOT = taproot; wrapped/other = unknown → ok:null, or fail under DISCOURAGE_UPGRADABLE_WITNESS_PROGRAM). The witness-unknown classifier requires a direct push too.
Vectors now: script_tests.json 1222/1273 (every real case, zero skips); tx_invalid 93/93; tx_valid 118/119 with one pin (CSV signed compare, next); sighash 500/500. Items 1, 5, 6, 8 remain unexercised by any Core vector; item 8 (era flags from block validation) is the one that turns the rest live.