Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions src/chainlock/handler.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,7 @@ MessageProcessingResult ChainlockHandler::ProcessCoinbaseChainLock(const CBlock&
}

const auto cbtx = GetTxPayload<CCbTx>(*block.vtx.front(), /*assert_type=*/false);
if (!cbtx || cbtx->nVersion < CCbTx::Version::CLSIG_AND_BALANCE || !cbtx->bestCLSignature.IsValid()) {
if (!cbtx || cbtx->nVersion < CCbTx::Version::CLSIG_AND_BALANCE || !cbtx->bestCLSignature.Get().IsValid()) {
return {};
}

Expand All @@ -126,7 +126,7 @@ MessageProcessingResult ChainlockHandler::ProcessCoinbaseChainLock(const CBlock&
if (ancestor == nullptr) {
return {};
}
const ChainLockSig clsig{height, ancestor->GetBlockHash(), cbtx->bestCLSignature};
const ChainLockSig clsig{height, ancestor->GetBlockHash(), cbtx->bestCLSignature.Get()};
return ProcessNewChainLock(/*from=*/-1, clsig, qman, ::SerializeHash(clsig));
}

Expand Down
21 changes: 19 additions & 2 deletions src/evo/cbtx.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,23 @@ bool CheckCbTx(const CCbTx& cbTx, const CBlockIndex* pindexPrev, bool is_v24_act
}
}

// The signature is deserialized lazily. Get() decodes it in the active scheme only and
// resets the wrapper on failure, which is what the eager parser did with bytes encoded
// in the opposite scheme; bytes that decode in neither scheme made it throw, so they
// are rejected here.
if (cbTx.nVersion >= CCbTx::Version::CLSIG_AND_BALANCE) {
CDataStream ss(SER_NETWORK, PROTOCOL_VERSION);
ss << cbTx.bestCLSignature;
if (!cbTx.bestCLSignature.Get().IsValid()) {
try {
CBLSSignature sig;
ss >> sig;
} catch (const std::ios_base::failure&) {
return state.Invalid(TxValidationResult::TX_CONSENSUS, "bad-cbtx-payload");
}
}
}

return true;
}

Expand Down Expand Up @@ -203,8 +220,8 @@ std::optional<std::pair<CBLSSignature, uint32_t>> GetNonNullCoinbaseChainlock(co
}
const auto payload = GetTxPayload<CCbTx>(*block.vtx[0]);
if (!payload || payload->nVersion < CCbTx::Version::CLSIG_AND_BALANCE || payload->nHeight != height ||
!payload->bestCLSignature.IsValid() || payload->bestCLHeightDiff >= uint32_t(height)) {
!payload->bestCLSignature.Get().IsValid() || payload->bestCLHeightDiff >= uint32_t(height)) {
return std::nullopt;
}
return std::make_pair(payload->bestCLSignature, payload->bestCLHeightDiff);
return std::make_pair(payload->bestCLSignature.Get(), payload->bestCLHeightDiff);
}
2 changes: 1 addition & 1 deletion src/evo/cbtx.h
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ class CCbTx
uint256 merkleRootMNList;
uint256 merkleRootQuorums;
uint32_t bestCLHeightDiff{0};
CBLSSignature bestCLSignature;
CBLSLazySignature bestCLSignature;
CAmount creditPoolBalance{0};
/** Merkle root over the instance hashes of the block's version 2+ asset unlock transactions
* (block order; null when there are none). Their txids exclude the quorum signing info, so
Expand Down
50 changes: 31 additions & 19 deletions src/evo/specialtxman.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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");
}
Expand All @@ -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) {
Comment on lines +222 to +224

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Bind the carry-over cache to the BLS scheme

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 though VerifyChainLock under 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 👍 / 👎.

WITH_LOCK(cached_mutex, cached.emplace(pindex, cbtx_sig, cbTx.bestCLHeightDiff, /*verified_here=*/true));
return true;
Comment on lines +222 to 226

@thepastaclaw thepastaclaw Oct 22, 2026 •

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ Resolved at 44c111f6; see the replies below.

🔴 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: muse-spark-1.3-contributor (phase1-reviewer: general); gpt-6.1-sol (phase2-reviewer: general, dash-core-commit-history)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Resolved (re-reviewed at 44c111f6): This is addressed at the current head: cache entries now distinguish locally verified ChainLocks from values merely matched to the handler's best lock, and regtest requires V20Height >= V19Height. That prevents the carry-over shortcut from reusing a signature across the legacy-to-basic transition.

}
const CBlockIndex* pAncestor = pindex->GetAncestor(curBlockCoinbaseCLHeight);
Expand All @@ -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");
Expand Down
3 changes: 3 additions & 0 deletions src/kernel/chainparams.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -1031,6 +1031,9 @@ class CRegTestParams : public CChainParams {
assert(consensus.V20Height >= consensus.DIP0003Height);
// MN_RR reallocate part of reward to CreditPool which exits since V20
assert(consensus.MN_RRHeight >= consensus.V20Height);
// Coinbase chainlocks (V20) are verified once and reused by the next block, so the BLS
// scheme must not change after V20 activation
assert(consensus.V20Height >= consensus.V19Height);
}

private:
Expand Down
5 changes: 4 additions & 1 deletion src/node/miner.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -354,7 +354,10 @@ std::unique_ptr<CBlockTemplate> BlockAssembler::CreateNewBlock(const CScript& sc
throw std::runtime_error(strprintf("%s: CalcCbTxMerkleRootQuorums failed: %s", __func__, state.ToString()));
}
if (fV20Active_context) {
if (CalcCbTxBestChainlock(m_chainlocks, pindexPrev, cbTx.bestCLHeightDiff, cbTx.bestCLSignature)) {
CBLSSignature best_cl_sig;
const bool has_best_cl{CalcCbTxBestChainlock(m_chainlocks, pindexPrev, cbTx.bestCLHeightDiff, best_cl_sig)};
cbTx.bestCLSignature.Set(best_cl_sig, bls::bls_legacy_scheme.load());
if (has_best_cl) {
LogPrintf("CreateNewBlock() h[%d] CbTx bestCLHeightDiff[%d] CLSig[%s]\n", nHeight, cbTx.bestCLHeightDiff, cbTx.bestCLSignature.ToString());
} else {
// not an error
Expand Down
18 changes: 17 additions & 1 deletion src/test/evo_cbtx_tests.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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>
Expand Down Expand Up @@ -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);
Expand All @@ -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

@thepastaclaw thepastaclaw Oct 26, 2026 •

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ Withdrawn at 78c28ede; see the replies below.

🟡 Suggestion: Add regression coverage for opposite-scheme null decoding

The production fallback in CheckCbTx preserves the eager parser's behavior for a signature encoded under the opposite BLS scheme: the eager decoder accepts the bytes, resets the resulting signature to null, and downstream validation then applies the height-difference rules. The test currently covers an active-scheme signature, an all-zero null encoding, and an all-0xff rejection, but does not exercise this compatibility path. Add a case that serializes a signature with the opposite scheme and verifies both that bestCLHeightDiff == 0 is accepted as null and that a nonzero difference is rejected.

source: gpt-6.1-sol (phase2-reviewer: general)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That explanation is convincing. With BasicTestingSetup forcing the legacy scheme, the requested opposite-scheme direction would require changing global scheme state, while serializing basic bytes and decoding them as legacy does not deterministically exercise the production fallback. Since the height-difference rules are independent of how the null signature is produced, I’m withdrawing this suggestion.

Withdrawing this finding.

}

namespace {
Expand Down
8 changes: 4 additions & 4 deletions src/test/llmq_chainlock_tests.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ BOOST_FIXTURE_TEST_CASE(historical_coinbase_lookup_from_disk, RegTestingSetup)
} else {
certified = activation + 16999;
}
payload.bestCLSignature = signature;
payload.bestCLSignature.Set(signature, bls::bls_legacy_scheme.load());
payload.bestCLHeightDiff = height - certified - 1;
}
CMutableTransaction coinbase;
Expand Down Expand Up @@ -415,7 +415,7 @@ BOOST_FIXTURE_TEST_CASE(coinbase_chainlock_processing, RegTestingSetup)

CCbTx cbtx;
cbtx.nVersion = CCbTx::Version::CLSIG_AND_BALANCE;
cbtx.bestCLSignature = CreateRandomBLSSignature();
cbtx.bestCLSignature.Set(CreateRandomBLSSignature(), bls::bls_legacy_scheme.load());
CMutableTransaction tx;
tx.nVersion = CTransaction::SPECIAL_VERSION;
tx.nType = TRANSACTION_COINBASE;
Expand All @@ -439,7 +439,7 @@ BOOST_FIXTURE_TEST_CASE(coinbase_chainlock_processing, RegTestingSetup)
set_payload();
process(&indexes.back());
const int32_t height = 499 - offset;
const ChainLockSig expected{height, hashes[height], cbtx.bestCLSignature};
const ChainLockSig expected{height, hashes[height], cbtx.bestCLSignature.Get()};
BOOST_CHECK(m_node.clhandler->AlreadyHave(CInv{MSG_CLSIG, ::SerializeHash(expected)}));
}
const auto seen = m_node.clhandler->SeenChainLockCacheSizeForTesting();
Expand All @@ -464,7 +464,7 @@ BOOST_FIXTURE_TEST_CASE(coinbase_chainlock_processing, RegTestingSetup)
process(&indexes.back());
BOOST_REQUIRE(m_node.sporkman->UpdateSpork(SPORK_19_CHAINLOCKS_ENABLED, 0).has_value());

cbtx.bestCLSignature = {};
cbtx.bestCLSignature = CBLSLazySignature{};
set_payload();
process(&indexes.back());
cbtx.nVersion = CCbTx::Version::MERKLE_ROOT_QUORUMS;
Expand Down
11 changes: 6 additions & 5 deletions src/test/quorum_proofs_tests.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -163,11 +163,12 @@ struct QuorumProofGenerationSetup : TestingSetup {
});
BOOST_REQUIRE(match != commitments.end());
const size_t key = size_t(match - commitments.begin());
payload.bestCLSignature = keys[key].Sign(llmq::SignHash{params.type, signer->quorumHash,
chainlock::GenSigRequestId(height - 1),
previous->GetBlockHash()}
.Get(),
false);
payload.bestCLSignature.Set(keys[key].Sign(llmq::SignHash{params.type, signer->quorumHash,
chainlock::GenSigRequestId(height - 1),
previous->GetBlockHash()}
.Get(),
false),
bls::bls_legacy_scheme.load());
}
const bool mining = height == checkpoint_height - params.dkgInterval || height == checkpoint_height ||
(mine_nonstandard_handoff && height == handoff_height);
Expand Down
2 changes: 2 additions & 0 deletions test/functional/feature_protx_version.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@ def set_test_params(self):
f'-vbparams=v24:{self.mocktime}:999999999999:350:10:8:6:5:0',
]] * 2
self.set_dash_test_params(2, 1, evo_count=2, extra_args=self.extra_args)
# regtest requires v20 not earlier than v19
self.delay_v20_and_mn_rr(200)

def get_peer_ids(self, node_idx):
return {peer['id'] for peer in self.nodes[node_idx].getpeerinfo()}
Expand Down
Loading