Skip to content

Script interpreter: 8 consensus deviations vs Core (accepts-invalid / rejects-valid / flag integration) #3

Description

@melvincarvalho

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)

  1. Stack-size limit never enforced for data pushesinterpreter.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.

  2. 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.

  3. Missing BIP141 structural rules in verifyInputinterpreter.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).

  4. Witness-program shape gapsscript.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.

  5. parsePubkey laxer than libsecp256k1secp256k1.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)

  1. sighashTaproot throws on any spent scriptPubKey or annex ≥ 253 bytesinterpreter.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.

  2. Legacy sighash doesn't strip remaining OP_CODESEPARATORs from scriptCodeinterpreter.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

  1. blocks.js never passes era flags to verifyInputblocks.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:200new 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions