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-532 vs :548. The push branch continues past the combined stack + altstack > 1000 check, which only runs after opcode handlers. A scriptPubKey of 1001× OP_1 (well under the 10,000-byte script cap) passes here; Core fails SCRIPT_ERR_STACK_SIZE.
-
No 520-byte cap on initial witness stack elements (BIP141/BIP342) — interpreter.js:585-611, :667. Core's ExecuteWitnessScript rejects 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 with PUSH_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-unknown matches 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. Conversely OP_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 return ok:null like other unknown witness versions.
-
parsePubkey laxer 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)
-
sighashTaproot throws on any spent scriptPubKey or annex ≥ 253 bytes — interpreter.js:221-224 (varbytes helper), used at :240,249,253. BIP341's sha_scriptpubkeys/sha_annex use CompactSize; the helper throws 'oversized script in sighash' at ≥ 0xfd. Because sha_scriptpubkeys covers 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's SerializeScriptCode skips 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.js never passes era flags to verifyInput — 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's tx.version < 2 compares the codec's unsigned u32; Core compares signed int32, so version ≥ 0x80000000 diverges (contrived).
interpreter.js:200 — new Uint16Array([len]).buffer for 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.
A deep review of the script-verification path (
codec/interpreter.js,codec/script.js,codec/secp256k1.js, and theblocks.jsintegration) found eight deviations from Bitcoin Core consensus behaviour. File/line references are against the currentpackages/kernel/codec/copies; the same files are vendored byte-identically inbrowser-node/engine/codec/andhealth/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),resolvedInputsis always empty and thebtc:rule-blockctx-scriptsrule 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
IsValidSignatureEncodingbyte-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 againstverifyInputwould pin all of the above and is probably the right first step before fixing.