Extend script/tx unit test coverage#439
Conversation
…but-permitted combinations, and CP2SH_COLORED flag wiring - Add script-level valid/invalid OP_COLOR CP2PKH and CP2SH(P2PKH) test vectors (ECDSA and Schnorr) to script_tests.json, tx_valid.json, and tx_invalid.json. - Add script_risky_combinations_tests.cpp covering risky-but-permitted interpreter behaviors (0-of-n CHECKMULTISIG, OP_COLOR via stack manipulation, CHECKDATASIG replay, mixed ECDSA/Schnorr branches, OP_COLOR + CLTV), each verified with a control case. - Add matching mempool-level tests in txvalidation_tests.cpp proving these behaviors hold (or are rejected) at the transaction-acceptance layer, not just the raw interpreter. - Wire SCRIPT_VERIFY_CP2SH_COLORED into mapFlagNames and test_flags_list so it's nameable from JSON test flags and exercised by the flag-invariance loop. - Add duplicate_nft_issuance_after_burn_rejected to chainstate_tests.cpp, proving that burning an NFT does not erase its colorId from g_colorid_state, so re-issuance is still rejected. - Fix ColorIdentifier being shared across JSON test entries in transaction_tests.cpp (now fresh per input, matching CScriptCheck). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
azuchi
left a comment
There was a problem hiding this comment.
Overall — this is a solid test-coverage PR: test-only changes (all 9 files under src/test/), no production code touched, and the new suite passes cleanly in CI. The five "risky-but-permitted" combinations documented in the PR body are each exercised with a positive case + control, so future regressions (widening or narrowing the interpreter's behavior around these edges) will surface loudly instead of silently. The ColorIdentifier sharing fix in transaction_tests.cpp is a genuine bug fix on top of the coverage work. No blockers from me — a few small hardening suggestions below.
Verified claims from the audit
Spot-checked the load-bearing claims in the PR body against the interpreter/consensus sources on master — all check out:
OP_INVALIDOPCODE(0xFF) never appears by name in the three JSON corpora (grep -c OP_INVALIDOPCODE= 0 across all three), while0x…ffhex literals do appear (21 total). Thedefault:switch arm atinterpreter.cpp:1191is exercised; only the named constant is untested — as the body correctly notes, this isn't a real gap.SCRIPT_ERR_MIXED_SCHEME_MULTISIGexists (script_error.h:39) and is set atinterpreter.cpp:1096, matchingrisk_mixed_signature_schemescase 4c.OP_COLOR's interpreter logic atinterpreter.cpp:1150–1187really does only check (null colorId, already-set,!vfExec.empty(), 33-byte encoding) — no template-shape check — sorisk_opcolor_stack_manipulationandrisk_opcolor_with_timelockare accurate about what the raw interpreter permits.OP_CHECKDATASIG(V)atinterpreter.cpp:983–1028usesCSHA256().Write(msg).Finalize()on the raw message (no sighash, no tx binding) — sorisk_checkdatasig_replayis a legitimate replay demonstration, not a test artefact.CScriptCheckatscriptcheck.hholdscoloridas a per-instance field, default-constructed (or fromcoloridIn). Thetransaction_tests.cppfix — movingColorIdentifier colorId;inside the per-input loop — matches production semantics.
🟢 What's well done
Test + control pattern. Every risk has at least one positive case demonstrating the behavior and one control case demonstrating where the behavior doesn't apply — 1d (1-of-2 with no sig fails), 2c (canonical CP2PKH template is recognized), 3b (CHECKSIG doesn't replay across nLockTime), 4c (CHECKMULTISIG rejects mixed schemes with the dedicated error), 5b (locktime enforcement isn't swallowed by OP_COLOR). This is exactly the shape that catches a future change that "fixes" one of these into stricter behavior without updating the audit.
Belt-and-suspenders between layers. script_risky_combinations_tests.cpp proves the risky behavior at the raw-interpreter layer; txvalidation_tests.cpp proves the same behavior propagates through mempool + CreateAndProcessBlock end-to-end. That answers the "yes but does the consensus layer catch it?" question up front — for 0-of-N CHECKMULTISIG and CHECKDATASIG replay, no; for OP_COLOR non-templates, yes (via CheckColorIdentifierValidity). The split is worth its extra ~180 lines.
SCRIPT_VERIFY_CP2SH_COLORED wiring is a structural improvement, not just cosmetic. Adding the flag to mapFlagNames (nameable from JSON) and to test_flags_list in script_tests.cpp (exercised by the flag-invariance loop) closes a real hole — the audit noted the flag "could not be named from script_tests.json, tx_valid.json, or tx_invalid.json even if a test author wanted to." The two new JSON cases at the end of script_tests.json (flag off → outer HASH160 check only; flag on → redeem script's CHECKSIG genuinely evaluated) are a good demonstration of what the flag actually gates.
transaction_tests.cpp ColorIdentifier fix. The old code had a single ColorIdentifier colorId declared outside both loops in tx_valid and tx_invalid. That's a bug: once any test's script executed OP_COLOR, colorId->type != TokenTypes::NONE was true for every subsequent input in every subsequent test, and any later OP_COLOR would fail with SCRIPT_ERR_OP_COLORMULTIPLE. Moving the declaration inside the per-input loop matches CScriptCheck in production. The failure mode is order-dependent (a colored test earlier in the file poisons later ones), which is exactly the shape of bug that hides for a long time.
duplicate_nft_issuance_after_burn_rejected is well-designed. Verifies the real invariant: burning doesn't call CIssuedColorIds::Erase() (that's reorg-only), so g_colorid_state->IsIssued(colorid) stays true after burn, and a re-issuance attempt against a synthetically-restored UTXO view is correctly rejected with bad-txns-colorid-already-issued. The synthetic CCoinsViewCache is the right way to exercise the reorg-shaped condition without actually needing to construct a SHA-256 collision on the defining outpoint.
🟡 Small hardening suggestions (all optional)
-
A few control cases only check
!okwithout asserting the specific error. For examplerisk_checkmultisig_zero_of_ncase 1d:BOOST_CHECK(!ok); // no BOOST_CHECK_EQUAL(err, SCRIPT_ERR_...)
The point of the control is that "1-of-2 with no signature" fails for the multisig-verification reason, not for some unrelated stack-bookkeeping reason. Asserting the specific error (
SCRIPT_ERR_CHECKMULTISIGVERIFYhere) would make the control tighter — a future change that starts failing this case with the wrong error would still be caught. Same applies to a few other!oksites (the STANDARD-flag-set fail paths). -
duplicate_nft_issuance_after_burn_rejected's block-numbered comments couple toTestChainSetup.// Block 6: spend coinbase[1] into a plain TPC P2PKH output. // Block 7: issue an NFT (nValue must be exactly 1) from the P2PKH UTXO. // Block 8: burn the NFT
The "6/7/8" numbering assumes
TestChainSetupproduces exactly 5 initial blocks. If someone bumps that count for another test's sake (e.g.m_coinbase_txns.size()grows), these comments silently rot. Referring to them by role (// Block N: spend the defining UTXO into…) rather than absolute index would age better. The test logic is fine — this is a comment-rot concern. -
vchKeyBytes[32]in the same test uses3, 0, ...with no explanation. A one-line note ("arbitrary non-coinbase key; low byte chosen to avoid clashing with any TestChainSetup pre-defined key") would answer the obvious "why 3?" question without a reader having to look up whether it's load-bearing. -
Consider a brief note on
STANDARD_SCRIPT_VERIFY_FLAGSsemantics inrisk_opcolor_stack_manipulation. Since this PR wiresSCRIPT_VERIFY_CP2SH_COLOREDinto the standard set, and this test asserts that the SWAP-derived / ALTSTACK-derived scripts still pass under STANDARD flags, a one-line comment clarifying "the CP2SH_COLORED flag gates redeem-script evaluation for CP2SH shapes, not template shape at the interpreter layer — the non-canonical shape is still accepted byVerifyScripthere" would head off a reader wondering whether the STANDARD flag choice is significant.
Summary
Test-only, no production risk, all new suite cases pass in CI, the risky-combinations audit is faithfully backed by runnable proofs, and the ColorIdentifier sharing fix is a real bug fix. The suggestions above are quality-of-life tightening — nothing blocks landing.
|
Follow-up observation, not something this PR needs to address — flagging so we can decide whether to open a separate issue. While cross-checking the audit against upstream, I noticed several 2024–2026 additions to Bitcoin Core's Actual bug fix with matching test change (code + data)
Test-only additions (data catch-up, no code change)
Larger port (new error category + tests)
Explicitly not Tapyrus-relevantFor completeness, I did not count taproot/tapscript additions ( SuggestionNone of this is in scope for this PR. But given the coverage focus, I'd be inclined to open a separate follow-up issue tracking "Bitcoin Core script/tx test data catch-up (shared functionality only)" and pick these off one at a time — the OP_IF fix in particular is a small, clean port that closes a real (if consensus-neutral) misclassification. Happy to file the issue if that direction sounds right. |
Tapyrus Script Coverage & Risk Audit
Scope:
src/test/data/{script_tests,tx_valid,tx_invalid}.json,src/script/interpreter.cpp,src/script/standard.cpp,src/validation.cppOpcodes checked: 119
A pass over the JSON-driven script test suites, the opcode interpreter, the consensus verify flags, and the ECDSA/Schnorr/compressed-key signature paths — what's exercised, what's thin, and what the interpreter permits that's worth knowing about.
Table of contents
1. Opcode test coverage
Every opcode defined in
script.hwas matched (mnemonic, with or without theOP_prefix) against the three JSON test corpora. Counts are occurrences, not test cases — they indicate whether an opcode is exercised at all, not how thoroughly.The gaps
OP_INVALIDOPCODE0xffsentinel is never pushed by name. The unassigned range around it (0xbd–0xfe, including0xff) is covered by raw hex literals inscript_tests.json(all correctly returnBAD_OPCODE), so thedefault:switch arm is exercised — only the named constant itself is untested.OP_CHECKSIGVERIFYscript_tests.json(the pure-interpreter suite); covered only viatx_valid.json/tx_invalid.json(20 total). The VERIFY-suffix pop-on-success/fail-on-false path has no dedicated script-level test.OP_CODESEPARATORscript_tests.json(a bare no-op check); the interaction withFindAndDelete,SCRIPT_VERIFY_CONST_SCRIPTCODE, and multiple separators per script is exercised mainly throughtx_invalid.json(36 hits).OP_FALSE/OP_TRUEOP_0/OP_1(script.h:53,60). Test JSON always spells these0/1, so a zero hit on the alias name is expected, not a gap.OP_NOP2/OP_NOP3OP_CHECKLOCKTIMEVERIFY(38 hits) andOP_CHECKSEQUENCEVERIFY(107 hits) — the opcode byte is thoroughly tested under its modern name.Best-covered families
Flow control (
IF/ELSE/ENDIF270–330 hits each), stack comparison (EQUAL401,EQUALVERIFY99), and multisig (CHECKMULTISIG564,CHECKMULTISIGVERIFY451) dominate — unsurprising, sincescript_tests.jsonis built around exhaustive flow-control and signature-check permutations. All 15 disabled opcodes (CAT,SUBSTR,LEFT,RIGHT,INVERT,AND,OR,XOR,2MUL,2DIV,MUL,DIV,MOD,LSHIFT,RSHIFT) have at least 2 hits confirming they still hard-fail withDISABLED_OPCODE.Tapyrus-specific opcodes
OP_COLOR(5 hits),OP_CHECKDATASIG(53) andOP_CHECKDATASIGVERIFY(47) are Chaintope additions on top of upstream Bitcoin script. All three are exercised only inscript_tests.json— zero hits intx_valid.json/tx_invalid.json. That means their pure opcode semantics are tested, but there's no full-transaction (fee, sighash-in-context, multi-input) test exercising them end to end in the data-driven suites.2. Risky-but-permitted combinations
None of these are bugs in the sense of "the code does the wrong thing" — each behaves exactly as written. They're flagged because the combination opens more room than the canonical script templates use, and a future change elsewhere in the stack could turn that room into a real issue.
[by design]Zero-of-NCHECKMULTISIGis a trivial passIn
interpreter.cpp:1030–1114, when the scriptPubKey encodesnum_of_signatures = 0, the verificationwhileloop never executes andfSuccessstaystrue— the output is spendable by anyone, regardless of how many (or which) pubkeys are listed.[mitigated upstream]OP_COLORcan read a color ID assembled by prior stack opsThe
OP_COLORcase (interpreter.cpp:1150–1187) only checks: a non-nullcolorIdpointer (not inside a P2SH redeem script), not already set, not inside anOP_IFbranch, and that the top stack item is a well-formed 33-byte identifier. It does not require that value to be an immediate push —<a> <b> SWAP DROP COLORor aPICK/ROLLthat pulls the identifier from deeper in the stack (including scriptSig-supplied data) is accepted by the raw interpreter exactly like a direct push.[implementer risk]OP_CHECKDATASIGmessages have no built-in replay bindingThe signed message for
CHECKDATASIG/CHECKDATASIGVERIFY(interpreter.cpp:983–1028) is arbitrary stack data, SHA-256'd and verified with no hashtype byte and no implicit link to the spending transaction (no txid, sequence, or amount is mixed in automatically).[by design]ECDSA and Schnorr can be freely mixed outsideCHECKMULTISIGCHECKSIG/CHECKSIGVERIFYpick their scheme per call, purely from signature length (65 bytes incl. hashtype → Schnorr, else ECDSA/DER;interpreter.cpp:958–960).CHECKMULTISIGis the only opcode that enforces a single scheme across all its signatures (SCRIPT_ERR_MIXED_SCHEME_MULTISIG,interpreter.cpp:1076,1095–1097) — a hand-rolled threshold script built from separateCHECKSIGcalls (e.g. an OR-of-two-sigs pattern) has no such constraint and can mix schemes freely between branches.[mitigated upstream]Time-locks are only excluded from colored scripts at the validation layerOP_CHECKLOCKTIMEVERIFY/OP_CHECKSEQUENCEVERIFYhave no awareness ofcolorIdinEvalScript— nothing in the interpreter stops a script from combining<colorid> COLORwith a CLTV/CSV check ahead of it. The restriction to CP2PKH/CP2SH/burn-only templates (CLTV/CSV must live inside a CP2SH redeem script, never in a direct colored scriptPubKey) is enforced entirely byCheckColorIdentifierValidity's template match, the same consensus gate discussed above.3. Script verify flags
Tapyrus folds several flags Bitcoin Core still treats as optional (
P2SH,STRICTENC,DERSIG,LOW_S,NULLDUMMY,MINIMALDATA,CLTV,CSV) into unconditional consensus behavior —MANDATORY_SCRIPT_VERIFY_FLAGSis literally0because there's nothing left to gate (interpreter.h:98–111). That leaves 10 real flag bits.script_tests.jsonflags columnSIGPUSHONLYDISCOURAGE_UPGRADABLE_NOPSCLEANSTACKWITNESSDISCOURAGE_UPGRADABLE_WITNESS_PROGRAMMINIMALIFNULLFAILWITNESS_PUBKEYTYPECONST_SCRIPTCODECP2SH_COLOREDThe
CP2SH_COLOREDgap was structural, not accidentalmapFlagNamesintransaction_tests.cpp:40–49— the string-to-bit table every JSON test'sflagscolumn is parsed through — simply had no entry forCP2SH_COLORED. It could not be named fromscript_tests.json,tx_valid.json, ortx_invalid.jsoneven if a test author wanted to. Separately,DoTest's flag-mutation invariance loop (script_tests.cpp:185–204, which re-runs every test with each flag toggled to confirm monotonic soft-fork behavior) also omitted it fromtest_flags_list.This wasn't a real coverage hole overall — the flag has purpose-built coverage in
test/functional/feature_cp2sh_softfork.pyand dedicated cases inscript_tests.cpp/txvalidation_tests.cpp/federationparams_tests.cpp. But it meant it could never appear in, or be cross-checked against, the three general JSON corpora the way every other flag can.WITNESS-family flagsTapyrus doesn't use segwit (per the source comment at
interpreter.h:66–68, the code is "left unchanged until we can cleanup all segwit code").DISCOURAGE_UPGRADABLE_WITNESS_PROGRAMandWITNESS_PUBKEYTYPEnever appear in theflagscolumn ofscript_tests.jsoneven though they're both in the standard set and in the invariance loop — low-priority given witness scripts aren't reachable in practice, but they are dead weight in the standard flag bitmask if truly unreachable.4. Compressed keys, Schnorr & ECDSA
Tapyrus dispatches signature scheme purely by byte length — there is no explicit version tag. Coverage of the resulting matrix is thorough.
Pubkey encoding (
interpreter.cpp:65–97)0x02/0x03WITNESS_PUBKEYTYPE+SigVersion::WITNESS_V0apply — unreachable in practice (no segwit).0x040x06/0x07PUBKEYTYPEerror. Explicitly tested ("...with hybrid pubkey"cases, both schemes, both CHECKSIG and CHECKDATASIG).Signature scheme dispatch
CHECKSIG/CHECKSIGVERIFYlen == 65CHECKMULTISIG(VERIFY)len == 65SCRIPT_ERR_MIXED_SCHEME_MULTISIGif signatures disagreeCHECKDATASIG(VERIFY)len == 64The final dispatch (
GenericTransactionSignatureChecker::VerifySignature,interpreter.cpp:1435–1441) is a one-linesize() == 64 ? Verify_Schnorr : Verify_ECDSA. Malformed 64-byte data destined for Schnorr isn't pre-validated by a shape check the way ECDSA's DER encoding is (CheckSchnorrSignatureEncodingis skipped in theCHECKDATASIGpath when length already equals 64) — butsecp256k1_schnorr_verifysimply returns false on malformed input, so this is a defense-in-depth asymmetry, not an exploitable gap.Test coverage
Both schemes are paired across essentially every relevant construct in
script_tests.json: P2PK, P2PKH, P2SH-wrapped P2PK/P2PKH, 2-of-3 multisig (including an explicit"MIXED"negative case),NULLFAILon/off, DER malleability (high-S, R/S padding), undefined hashtypes, and hybrid pubkeys — each combination present for both ECDSA and Schnorr, for bothCHECKSIGandCHECKDATASIG(VERIFY). This is the most thoroughly covered corner of the four areas in this audit.PR changes: