Repository navigation
perf: deserialize CbTx bestCLSignature lazily and avoid verifying of duplicated chainlocks #7935
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -163,32 +163,40 @@ bool CheckCbTxBestChainlock(const CCbTx& cbTx, const CBlockIndex* pindex, const | |
| if (cbTx.nVersion < CCbTx::Version::CLSIG_AND_BALANCE) { | ||
| return true; | ||
| } | ||
|
|
||
| const CBLSSignature& cbtx_sig{cbTx.bestCLSignature.Get()}; | ||
|
|
||
| struct CachedChainlock { | ||
| const CBlockIndex* pindex{nullptr}; | ||
| CBLSSignature sig; | ||
| uint32_t height_diff{0}; | ||
| //! Passed VerifyChainLock in this process (or was carried over from such a block) rather than | ||
| //! merely matched the chainlock handler's best lock, whose verification scheme is unknown here | ||
| bool verified_here{false}; | ||
| }; | ||
| static Mutex cached_mutex; | ||
| static const CBlockIndex* cached_pindex GUARDED_BY(cached_mutex){nullptr}; | ||
| static std::optional<std::pair<CBLSSignature, uint32_t>> cached_chainlock GUARDED_BY(cached_mutex){std::nullopt}; | ||
| static std::optional<CachedChainlock> cached GUARDED_BY(cached_mutex); | ||
|
|
||
| auto best_clsig = chainlocks.GetBestChainLock(); | ||
| if (best_clsig.getHeight() == pindex->nHeight - 1 && cbTx.bestCLHeightDiff == 0 && | ||
| cbTx.bestCLSignature == best_clsig.getSig()) { | ||
| cbtx_sig == best_clsig.getSig()) { | ||
| // matches our best clsig which still hold values for the previous block | ||
| LOCK(cached_mutex); | ||
| cached_chainlock = std::make_pair(cbTx.bestCLSignature, cbTx.bestCLHeightDiff); | ||
| cached_pindex = pindex; | ||
| WITH_LOCK(cached_mutex, cached.emplace(pindex, cbtx_sig, cbTx.bestCLHeightDiff, /*verified_here=*/false)); | ||
| return true; | ||
| } | ||
|
|
||
| std::optional<std::pair<CBLSSignature, uint32_t>> prevBlockCoinbaseChainlock{std::nullopt}; | ||
| if (LOCK(cached_mutex); cached_pindex == pindex->pprev) { | ||
| prevBlockCoinbaseChainlock = cached_chainlock; | ||
| bool prev_verified_here{false}; | ||
| if (LOCK(cached_mutex); cached && cached->pindex == pindex->pprev) { | ||
| prevBlockCoinbaseChainlock = std::make_pair(cached->sig, cached->height_diff); | ||
| prev_verified_here = cached->verified_here; | ||
| } | ||
| if (!prevBlockCoinbaseChainlock.has_value()) { | ||
| prevBlockCoinbaseChainlock = GetNonNullCoinbaseChainlock(pindex->pprev); | ||
| } | ||
| // If std::optional prevBlockCoinbaseChainlock is empty, then up to the previous block, coinbase Chainlock is null. | ||
| if (prevBlockCoinbaseChainlock.has_value()) { | ||
| // Previous block Coinbase has a non-null Chainlock: current block's Chainlock must be non-null and at least as new as the previous one | ||
| if (!cbTx.bestCLSignature.IsValid()) { | ||
| if (!cbtx_sig.IsValid()) { | ||
| // IsNull() doesn't exist for CBLSSignature: we assume that a non valid BLS sig is null | ||
| return state.Invalid(BlockValidationResult::BLOCK_CONSENSUS, "bad-cbtx-null-clsig"); | ||
| } | ||
|
|
@@ -198,17 +206,23 @@ bool CheckCbTxBestChainlock(const CCbTx& cbTx, const CBlockIndex* pindex, const | |
| } | ||
|
|
||
| // IsNull() doesn't exist for CBLSSignature: we assume that a valid BLS sig is non-null | ||
| if (cbTx.bestCLSignature.IsValid()) { | ||
| if (cbtx_sig.IsValid()) { | ||
| // Reject out-of-range bestCLHeightDiff that would yield a pre-genesis ancestor height. | ||
| if (cbTx.bestCLHeightDiff >= static_cast<uint32_t>(pindex->nHeight)) { | ||
| return state.Invalid(BlockValidationResult::BLOCK_CONSENSUS, "bad-cbtx-cldiff"); | ||
| } | ||
| int curBlockCoinbaseCLHeight = pindex->nHeight - static_cast<int>(cbTx.bestCLHeightDiff) - 1; | ||
| if (best_clsig.getHeight() == curBlockCoinbaseCLHeight && best_clsig.getSig() == cbTx.bestCLSignature) { | ||
| if (best_clsig.getHeight() == curBlockCoinbaseCLHeight && best_clsig.getSig() == cbtx_sig) { | ||
| // matches our best (but outdated) clsig, no need to verify it again | ||
| LOCK(cached_mutex); | ||
| cached_chainlock = std::make_pair(cbTx.bestCLSignature, cbTx.bestCLHeightDiff); | ||
| cached_pindex = pindex; | ||
| WITH_LOCK(cached_mutex, cached.emplace(pindex, cbtx_sig, cbTx.bestCLHeightDiff, /*verified_here=*/false)); | ||
| return true; | ||
| } | ||
| // The previous block's coinbase chainlock was verified by this process; carried into this | ||
| // block with the offset grown by one it names the same height, block and signature. | ||
| if (prev_verified_here && prevBlockCoinbaseChainlock.has_value() && | ||
| cbTx.bestCLHeightDiff == prevBlockCoinbaseChainlock->second + 1 && | ||
| cbtx_sig == prevBlockCoinbaseChainlock->first) { | ||
| WITH_LOCK(cached_mutex, cached.emplace(pindex, cbtx_sig, cbTx.bestCLHeightDiff, /*verified_here=*/true)); | ||
| return true; | ||
|
Comment on lines
+222
to
226
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
🔴 Blocking: Bind carry-over verification reuse to the BLS scheme The parent pointer, signature point, and height offset do not establish that verification remains valid under the current BLS scheme. VerifyChainLock ultimately calls the default CBLSSignature::VerifyInsecure overload, which selects legacy or basic verification using bls::bls_legacy_scheme. Regtest permits V20 before V19, and feature_protx_version.py uses V19 at 200 with the framework's V20 height of 100. In that configuration, a parent can verify and cache a legacy signature before the scheme changes. Its child can contain the same G2 point canonically serialized in basic form with bestCLHeightDiff incremented by one. Point equality passes this shortcut, although full verification under basic rejects the legacy-generated signature. With no matching local best ChainLock, the base implementation reaches that full verification; the new implementation can instead accept or reject depending on whether its process-local parent cache is warm. Standard mainnet/testnet activation ordering avoids this particular transition, but it remains a validation inconsistency in a supported configuration. Store the verification scheme alongside the cached entry, require it to match before bypassing verification, and cover both warm-cache and cold-cache transition paths. source:
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Resolved (re-reviewed at |
||
| } | ||
| const CBlockIndex* pAncestor = pindex->GetAncestor(curBlockCoinbaseCLHeight); | ||
|
|
@@ -218,14 +232,12 @@ bool CheckCbTxBestChainlock(const CCbTx& cbTx, const CBlockIndex* pindex, const | |
| return state.Invalid(BlockValidationResult::BLOCK_CONSENSUS, "bad-cbtx-cldiff-ancestor"); | ||
| } | ||
| uint256 curBlockCoinbaseCLBlockHash = pAncestor->GetBlockHash(); | ||
| chainlock::ChainLockSig clsig{curBlockCoinbaseCLHeight, curBlockCoinbaseCLBlockHash, cbTx.bestCLSignature}; | ||
| chainlock::ChainLockSig clsig{curBlockCoinbaseCLHeight, curBlockCoinbaseCLBlockHash, cbtx_sig}; | ||
| llmq::VerifyRecSigStatus ret = chainlock::VerifyChainLock(consensus_params, chain, qman, clsig); | ||
| if (ret != llmq::VerifyRecSigStatus::Valid) { | ||
| return state.Invalid(BlockValidationResult::BLOCK_CONSENSUS, "bad-cbtx-invalid-clsig"); | ||
| } | ||
| LOCK(cached_mutex); | ||
| cached_chainlock = std::make_pair(cbTx.bestCLSignature, cbTx.bestCLHeightDiff); | ||
| cached_pindex = pindex; | ||
| WITH_LOCK(cached_mutex, cached.emplace(pindex, cbtx_sig, cbTx.bestCLHeightDiff, /*verified_here=*/true)); | ||
| } else if (cbTx.bestCLHeightDiff != 0) { | ||
| // Null bestCLSignature is allowed only with bestCLHeightDiff = 0 | ||
| return state.Invalid(BlockValidationResult::BLOCK_CONSENSUS, "bad-cbtx-cldiff"); | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -22,9 +22,11 @@ | |
| #include <llmq/params.h> | ||
| #include <primitives/block.h> | ||
| #include <primitives/transaction.h> | ||
| #include <streams.h> | ||
| #include <uint256.h> | ||
| #include <validation.h> | ||
|
|
||
| #include <algorithm> | ||
| #include <cstdint> | ||
| #include <limits> | ||
| #include <utility> | ||
|
|
@@ -66,7 +68,7 @@ BOOST_FIXTURE_TEST_CASE(check_cbtx_best_chainlock_rejects_excessive_height_diff, | |
|
|
||
| CCbTx cbTx; | ||
| cbTx.nVersion = CCbTx::Version::CLSIG_AND_BALANCE; | ||
| cbTx.bestCLSignature = valid_sig; | ||
| cbTx.bestCLSignature.Set(valid_sig, legacy_scheme); | ||
|
|
||
| // bestCLHeightDiff == nHeight: lower boundary of the rejected range. | ||
| cbTx.bestCLHeightDiff = static_cast<uint32_t>(pindex.nHeight); | ||
|
|
@@ -79,6 +81,20 @@ BOOST_FIXTURE_TEST_CASE(check_cbtx_best_chainlock_rejects_excessive_height_diff, | |
| BlockValidationState state_big; | ||
| BOOST_CHECK(!CheckCbTxBestChainlock(cbTx, &pindex, consensus_params, chain, qman, chainlocks, state_big)); | ||
| BOOST_CHECK_EQUAL(state_big.GetRejectReason(), "bad-cbtx-cldiff"); | ||
|
|
||
| // The signature is deserialized lazily, so the payload parser accepts any bytes in its | ||
| // place and CheckCbTx rejects an encoding that is not a signature; the valid signature | ||
| // above and the null encoding pass. | ||
| TxValidationState tx_state; | ||
| BOOST_CHECK(CheckCbTx(cbTx, /*pindexPrev=*/nullptr, /*is_v24_active=*/false, tx_state)); | ||
| std::vector<uint8_t> sig_bytes(CBLSSignature::SerSize, 0xff); | ||
| CDataStream(sig_bytes, SER_NETWORK, PROTOCOL_VERSION) >> cbTx.bestCLSignature; | ||
| BOOST_CHECK(!CheckCbTx(cbTx, /*pindexPrev=*/nullptr, /*is_v24_active=*/false, tx_state)); | ||
| BOOST_CHECK_EQUAL(tx_state.GetRejectReason(), "bad-cbtx-payload"); | ||
| std::fill(sig_bytes.begin(), sig_bytes.end(), 0); | ||
| CDataStream(sig_bytes, SER_NETWORK, PROTOCOL_VERSION) >> cbTx.bestCLSignature; | ||
| TxValidationState null_state; | ||
| BOOST_CHECK(CheckCbTx(cbTx, /*pindexPrev=*/nullptr, /*is_v24_active=*/false, null_state)); | ||
|
Comment on lines
+89
to
+97
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
🟡 Suggestion: Add regression coverage for opposite-scheme null decoding The production fallback in source:
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Deliberately not added. v20 activates strictly after v19 on every network (regtest now asserts it), so by the time a CbTx carries a signature the active scheme is always basic, and the only compatibility case this fallback exists for is legacy-encoded bytes checked by the basic decoder. Every unit test runs with the legacy scheme forced by BasicTestingSetup, where the "opposite" encoding is basic bytes under the legacy decoder. That decoder has no subgroup check and reads basic-encoded bytes as a valid element for roughly half of all signatures (the eager parser does the same), so a case written in the fixture is nondeterministic and does not represent the production path. Exercising the real direction means switching the global scheme inside the scenario, which I do not want in this test. The fallback was verified locally with exactly that flipped check: passes with the fallback, fails without it. The downstream null rules the comment mentions (bestCLHeightDiff == 0 accepted, nonzero rejected) do not depend on how the null was produced and are unchanged by this PR.
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. That explanation is convincing. With Withdrawing this finding. |
||
| } | ||
|
|
||
| namespace { | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When V20 is activated before V19—the supported regtest layout in
feature_protx_version.py(v20@100,v19@200)—the cached block H-1 signature may have been verified under legacy BLS before the global scheme flips to basic. A child can carry the same point re-encoded in basic form with diff+1; this shortcut accepts it without verifying even thoughVerifyChainLockunder basic would reject the legacy signature (and the prior code did reach that call when the local best differs), so fresh old and new nodes can disagree on the block. Store the verification scheme in the cache and require it to match before taking this shortcut.AGENTS.md reference: AGENTS.md:L15-L17
Useful? React with 👍 / 👎.