Skip to content

script: cache verified P2MR data-signature checks (v1.0.1) - #265

Merged
kiwidream merged 2 commits into
1.x.xfrom
kiwidream/rg-259-datasig-pqc-cache
Oct 30, 2026
Merged

kiwidream merged 2 commits into
1.x.xfrom
kiwidream/rg-259-datasig-pqc-cache

Conversation

@kiwidream

@kiwidream kiwidream commented Oct 30, 2026 •

Copy link
Copy Markdown
Member

Summary

Refs #259. Targets the v1.0.1 release line (1.x.x at 4fb9dbeac3). 1.x.x is frozen for v1.0.1 (#181), so a maintainer must lift the freeze before merging, and the v1.0.1 release notes in #181 need this entry (fragment: doc/release-notes-265.md).

OP_CHECKDATASIGPQC / OP_CHECKDATASIGADDPQC called CPQCPubKey::Verify directly, bypassing the signature cache that #162 added for P2MR transaction signatures. A data-signature digest does not commit to the spending transaction, and v30.2's reject filter is keyed by txid/wtxid, so a leaf such as <msg> <pk> OP_CHECKDATASIGPQC OP_NOT with one valid signature could be replayed in mutated transactions. Each replay cost a full SLH-DSA verification (~1.9 ms for a 3,755-byte witness) during mempool admission. With this change such replays cost one verification.

  • Hook: new BaseSignatureChecker::VerifyPQCDataSignature(sig, pubkey, datasig_hash). EvalCheckDataSigPQC still charges validation weight and runs every pubkey, message and signature-size check first, then calls the hook instead of the primitive. The default runs the primitive, so behaviour is unchanged for every checker that does not override it.
  • Cache: CachingTransactionSignatureChecker overrides the hook. Verified successes are stored in the shared cuckoo cache under a separately salted 'D' domain, keyed by SHA256(nonce || 'D' || 0^31 || datasig digest || pubkey || sig). The store/consume rules (Get(entry, !store), insert only when store and valid) and the shared budget are the same as script: cache successful P2MR PQC signature checks #162. Inputs the interpreter never passes (an invalid key or a wrong-size signature) skip the cache. Failures are never cached.
  • Consensus-neutral: validity, validation weight, error codes and script flags are unchanged. A cache hit stands in only for a primitive success on exactly the same (digest, pubkey, signature).

Checker audit (every BaseSignatureChecker subclass and wrapper)

Checker Behaviour
DeferringSignatureChecker (and so SignatureExtractorChecker in sign.cpp, SignatureWeightChecker in feebumper.h) forwards to the wrapped checker
GenericTransactionSignatureChecker / TransactionSignatureChecker / MutableTransactionSignatureChecker default primitive
CachingTransactionSignatureChecker cached, domain 'D'
DummySignatureChecker (sign.cpp), miniscript TestSignatureChecker, P2MRBoundarySignatureChecker, fuzz FuzzedSignatureChecker and CheckerContext default primitive, which matches today's behaviour: data signatures were always verified for real regardless of checker. The fuzz checkers only run BASE/WITNESS_V0, where the opcodes are BAD_OPCODE.

Known limitation: residual DoS (not fixed here)

The coordinator decided against a negative (failure) cache. Anyone holding one valid signature can mint unlimited distinct invalid tuples that each cost a full verification, for example by editing top-layer WOTS or auth-path bytes. A failure cache would therefore stop only exact replays. Anyone can mint distinct full-cost invalid PQC signatures for free, on both the data-signature and transaction-signature paths. Because v30.2 doesn't punish peers that relay consensus-invalid transactions, a peer can cost about 0.5 µs of message-handler and cs_main time per witness byte.

On the transaction-signature variant in #259: P2MR v1 accepts every Taproot hash type, including SIGHASH_NONE|ANYONECANPAY (0x82). A valid transaction signature whose sighash excludes the mutated fields is therefore already absorbed by #162's positive cache, because the sighash is unchanged and the entry hits. Only the invalid-signature case remains, and it is the limitation above.

Follow-up options, not implemented here (another worker owns net_processing):

  • discourage peers whose P2MR PQC signatures fail under consensus flags;
  • a per-peer budget for script-validation cost.

Engineering practices applied

  • EP-STATE:
    • A cached result is served only for exactly the inputs that produced it. The entry binds every byte of the digest, pubkey and signature (tested at middle and last byte of each).
    • It is domain-separated from the 'P', 'S' and 'E' domains, with planted entries tested in both directions.
    • The invalid-key/byte-shift collision is guarded and tested.
    • Repeating the same operation produces one effect: ScopedPQCVerificationCounter shows N replays cost 1 backend call.
    • The concurrent test covers racing store/consume threads on both domains.
    • The existing shared_mutex and cuckoo cache are reused; no new locks.
  • EP-COMPAT:
    • No serialized, persisted or P2P format changes. The cache is in-memory and salted per process.
    • v1.0.0 and v1.0.1 nodes accept and reject the same transactions and blocks, so mixed versions are unaffected.
    • The new virtual is traced through every subclass and wrapper (table above).
  • EP-VALIDATION:
    • Entry-boundary checks stay in the interpreter, unchanged and ahead of the hook.
    • The caching override additionally refuses malformed direct-call inputs before touching the cache. Tested: a key-prefixed signature with an invalid key, and a short signature. Both fail with 0 backend calls and no cache events.
  • EP-ERRORS: failure outcomes are preserved and never cached. There are no resources to release.

Testing

  • Built locally: cmake --build build -j12 (RelWithDebInfo, BUILD_TESTS=ON, BUILD_BENCH=ON, wallet on, GUI off); clean.
  • Ran focused unit or functional tests for the changed area.
  • Ran lint or formatting checks relevant to this change: git diff --check, lint-includes.py, lint-include-guards.py, lint-circular-dependencies.py, lint-locale-dependence.py, lint-tests.py, lint-files.py, all pass.
  • Not run. Reason:

Results:

  • test_qbit --run_test=pqc_sigcache_tests: pass (*** No errors detected).
    • New data_signature_replays_verify_once covers acceptance (a). For both CHECKDATASIGPQC and CHECKDATASIGADDPQC failing leaves, 8 mutated replays cost 8 backend calls uncached and 1 in store mode. Each result is false/EVAL_FALSE, identical to the uncached checker. Consume mode hits with 0 calls, or verifies without storing on a cold cache. Weight and final stack are identical uncached, cold and warm under v2 and legacy weight (e.g. weight_left 122/122/122 and 75/75/75).
    • Updated data_signatures_bypass_the_cache → data_signatures_use_their_own_cache_domain covers acceptance (d). It keeps the original check that a transaction-signature entry does not satisfy a data-signature check, and adds the reverse direction, byte binding, failures never stored, and the guard.
    • Extended concurrent_checks_of_one_signature covers acceptance (e): hits=32 misses=64 verified_ok=16 verified_fail=48 backend_calls=64 for 8 threads × 3 rounds × 4 tuples.
    • All other cases are unchanged and pass (acceptance (f)).
  • test_qbit --run_test=txvalidationcache_tests: pass. New pqc_data_signature_reuse_across_mutated_spends covers the ATMP criterion:
    • 4 mutated ProcessTransaction submissions reusing one valid data-signature witness. Events are [MISS, VERIFIED_OK(inserted)] then [HIT] ×3, for 1 backend verify in total. Every submission is rejected with mempool-script-verify-flag-failed (Script evaluated without error but finished with a false/empty top stack element).
    • 2 submissions with a flipped-bit data signature cost 1 verify each and never gain an entry. This is the documented limitation.
  • script_p2mr_tests, p2mr_datapqchash_tests, pqc_tests, script_tests, miniscript_tests: pass. P2MR v1 vector and conformance coverage lives in script_p2mr_tests, pqc_sigcache_tests (14-vector witness corpus) and p2mr_datapqchash_tests.
  • Full unit suite build/bin/test_qbit (all suites, run under the host lock with MemoryMax=20G): pass (*** No errors detected, 4m43s).
  • Not run: ctest and unfiltered bench_qbit, which the release-gate rules forbid on this host because of bench_sanity_check memory use. No functional tests were run: the change is confined to the in-process script checker, and the unit-level ATMP test covers ProcessTransaction.

Local /code-review medium found no correctness issue. Items considered and left as is:

  • Consume-erase on block hits, observer events without a domain tag, and failing scripts still caching valid signatures: these are the existing script: cache successful P2MR PQC signature checks #162 / Core semantics, kept by design.
  • Changing script: cache successful P2MR PQC signature checks #162's tx-entry encoding: out of scope. The tx path is protected by the interpreter's pubkey-size assert, and the data path is guarded explicitly.
  • DUMMY_CHECKER verifying data signatures for real: this is the pre-existing behaviour, which consensus neutrality requires us to keep.
  • Factoring out the shared cache-lookup helper and extending fuzzing: optional, and outside this task's paths.

Target Branch

  • This PR targets main or a maintainer-requested release branch such as 0.1.x. It targets 1.x.x for v1.0.1.

Risk / Review Notes

  • Consensus, script, crypto, wallet, P2P, release, CI, or security-sensitive behavior changed.
  • No consensus, script, crypto, wallet, P2P, release, CI, or security-sensitive behavior changed.

Notes:

  • Script and validation performance code changed, but there is no consensus change: validity, weight and error codes are identical, and all existing tests pass unchanged except the deliberately updated bypass test.
  • The cache is consulted in block validation (consume mode) exactly as script: cache successful P2MR PQC signature checks #162 does for transaction signatures. Only verified successes are ever inserted.

Docs / Process Impact

  • I updated public docs because this PR changes user-visible behavior, integration guidance, release/process guidance, or expected validation. The release-notes fragment doc/release-notes-265.md covers the cache change and the known limitation; there is no other public doc describing the bypass.
  • No public docs update needed. Reason:

🤖 Generated with Claude Code

Route OP_CHECKDATASIGPQC and OP_CHECKDATASIGADDPQC verification through a
new BaseSignatureChecker::VerifyPQCDataSignature hook. The default runs the
primitive, so every checker that does not override it (signing, dummy,
miniscript, test and fuzz checkers) behaves as before, and
DeferringSignatureChecker forwards it to the checker it wraps.

CachingTransactionSignatureChecker caches verified data-signature successes
in the shared signature cache under a separately salted 'D' domain, keyed
by the QbitDataSigPQC digest, public key and signature, with the same
store/consume rules and shared budget as #162. Failures are never cached.
The interpreter still charges validation weight and runs every size check
before calling the checker, so validity, weight and error codes are
unchanged.

A valid data signature does not commit to the spending transaction, so a
leaf such as <msg> <pk> OP_CHECKDATASIGPQC OP_NOT could be replayed across
mutated transactions, each costing a full SLH-DSA verification during
mempool admission. Such replays now cost one verification.

Tests: pqc_sigcache_tests replaces data_signatures_bypass_the_cache with
data_signatures_use_their_own_cache_domain, adds
data_signature_replays_verify_once, and extends
concurrent_checks_of_one_signature to data signatures.
txvalidationcache_tests adds pqc_data_signature_reuse_across_mutated_spends.

Refs #259

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 30, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-30T14:42:53.300445Z 9850248 New commits
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

Refs #259

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@kiwidream
kiwidream merged commit 02a465d into 1.x.x Oct 30, 2026
36 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant