Pin extension apply to accepted sidecar roots

Require entropy injection and export apply to consume only sidecar roots accepted by their convergence gates. Local timeout state and later collector growth no longer participate in closed-ledger material once a sidecar root is accepted.

Also guard RCLConsensus::extensionsBusy() under the consensus mutex, cap exported multisig signer arrays to the protocol maximum after canonical signer sorting, and document the remaining accept-vs-timeout liveness tradeoff.

Add regressions for unaccepted export sidecar roots, late export collector mutation, entropy accepted-root handling, exported signer caps, and CSF accepted-sidecar behavior.
This commit is contained in:
Nicholas Dudfield
2026-06-26 16:50:19 +07:00
parent 229eb5b573
commit d091ebfb7e
11 changed files with 273 additions and 37 deletions

View File

@@ -206,12 +206,59 @@ public:
BEAST_EXPECT(!unsignedMulti.isFieldPresent(sfSigners));
}
void
testCapsSignerArray()
{
testcase("caps exported signer array");
auto const src = randomKeyPair(KeyType::secp256k1);
auto const dst = randomKeyPair(KeyType::secp256k1);
auto const innerTx = makeExportedPayment(
calcAccountID(src.first), calcAccountID(dst.first));
ExportResultBuilder::SignatureSnapshot signatures;
while (signatures.size() < STTx::maxMultiSigners() + 5)
{
auto const signer = randomKeyPair(KeyType::secp256k1);
signatures.emplace(
signer.first,
ExportResultBuilder::signExportedTxn(
innerTx, signer.first, signer.second));
}
auto assembled = ExportResultBuilder::assemble(
innerTx, signatures, 789, makeHash("many-sig-export"));
BEAST_EXPECT(assembled.signerCount == STTx::maxMultiSigners());
auto const& multiSigned =
assembled.metadata.peekAtField(sfExportedTxn).downcast<STObject>();
BEAST_EXPECT(multiSigned.isFieldPresent(sfSigners));
if (multiSigned.isFieldPresent(sfSigners))
{
auto const& signers = multiSigned.getFieldArray(sfSigners);
BEAST_EXPECT(signers.size() == STTx::maxMultiSigners());
for (std::size_t i = 1; i < signers.size(); ++i)
{
BEAST_EXPECT(
signers[i - 1].getAccountID(sfAccount) <
signers[i].getAccountID(sfAccount));
}
}
BEAST_EXPECT(
assembled.signedTxHash ==
multiSigned.getHash(HashPrefix::transactionID));
}
void
run() override
{
testAssemblesSignedMetadata();
testSkipsEmptySignatures();
testBuildMultiSignedExportedTxnDirect();
testCapsSignerArray();
}
};

View File

@@ -811,10 +811,12 @@ struct Export_test : public beast::unit_test::suite
txHash, valPK, originalSig, applySeq);
auto const agreedHash = ce.buildExportSigSet(applySeq);
BEAST_EXPECT(ce.isSidecarSet(agreedHash));
ce.acceptExportSigSet(agreedHash);
ce.setExportSigConvergenceFailed();
// Simulate an asynchronous collector mutation after sidecar agreement.
// A bad revert to the live collector at apply would assemble this late
// signature and write a different shadow-ticket hash.
// Closed-ledger apply must derive the signature snapshot from the
// agreed sidecar, not from the live collector or a local timeout flag.
std::uint8_t const lateBytes[] = {9, 8, 7};
Buffer const lateSig{lateBytes, sizeof(lateBytes)};
ce.exportSigCollector().addVerifiedSignature(
@@ -895,6 +897,7 @@ struct Export_test : public beast::unit_test::suite
txHash, valPK, sig, applySeq);
auto const agreedHash = ce.buildExportSigSet(applySeq);
BEAST_EXPECT(ce.isSidecarSet(agreedHash));
ce.acceptExportSigSet(agreedHash);
auto const parent = env.app().getLedgerMaster().getClosedLedger();
auto next = std::make_shared<Ledger>(
@@ -964,6 +967,7 @@ struct Export_test : public beast::unit_test::suite
txHash, valPK, sig, applySeq);
auto const agreedHash = ce.buildExportSigSet(applySeq);
BEAST_EXPECT(ce.isSidecarSet(agreedHash));
ce.acceptExportSigSet(agreedHash);
}
auto next = std::make_shared<Ledger>(

View File

@@ -494,6 +494,16 @@ struct FakeExtensions
return entropyHash;
}
void
acceptEntropySet(uint256 const&)
{
}
void
clearAcceptedEntropySet()
{
}
uint256
getEntropySecret() const
{
@@ -556,6 +566,16 @@ struct FakeExtensions
exportSigConvergenceFailed_ = true;
}
void
acceptExportSigSet(uint256 const&)
{
}
void
clearAcceptedExportSigSet()
{
}
template <class PeerPositions>
void
recordParticipantDiagnostics(ConsensusMode, PeerPositions const&)
@@ -1331,7 +1351,12 @@ class ConsensusExtensions_test : public beast::unit_test::suite
BEAST_EXPECT(ce.hasMinimumReveals());
auto const entropySetHash = ce.buildEntropySet(seq);
ce.acceptEntropySet(entropySetHash);
BEAST_EXPECT(ce.isSidecarSet(entropySetHash));
// Once the sidecar gate has accepted a hash, injection is derived from
// that sidecar snapshot. A local failure flag is diagnostic state, not
// an additional selector input.
ce.setEntropyFailed();
CanonicalTXSet retriableTxs{makeHash("entropy-set-prebuild-salt")};
ce.onPreBuild(retriableTxs, seq, txSetHash);
@@ -1419,7 +1444,8 @@ class ConsensusExtensions_test : public beast::unit_test::suite
prevLedger,
reveal);
}
ce.buildEntropySet(seq);
auto const entropySetHash = ce.buildEntropySet(seq);
ce.acceptEntropySet(entropySetHash);
CanonicalTXSet txs{makeHash("tier2-salt")};
ce.onPreBuild(txs, seq, txSetHash);
auto const tx = singleCanonicalTx(txs);
@@ -1585,7 +1611,8 @@ class ConsensusExtensions_test : public beast::unit_test::suite
prevLedger,
reveal);
}
ce.buildEntropySet(seq);
auto const entropySetHash = ce.buildEntropySet(seq);
ce.acceptEntropySet(entropySetHash);
CanonicalTXSet txs{makeHash("tier2-nunl-salt")};
ce.onPreBuild(txs, seq, txSetHash);
auto const tx = singleCanonicalTx(txs);
@@ -1958,6 +1985,14 @@ class ConsensusExtensions_test : public beast::unit_test::suite
txHash, valPK, originalSig, seq);
auto const exportSigSetHash = ce.buildExportSigSet(seq);
BEAST_EXPECT(ce.isSidecarSet(exportSigSetHash));
auto const view = ce.activeValidatorView();
// A locally-built export signature map is not closed-ledger material
// until the export sidecar gate accepts that exact root.
BEAST_EXPECT(!ce.agreedExportSignatures(*exportTx, txHash, *view, 1));
ce.acceptExportSigSet(makeHash("wrong-export-sigset-root"));
BEAST_EXPECT(!ce.agreedExportSignatures(*exportTx, txHash, *view, 1));
ce.acceptExportSigSet(exportSigSetHash);
// Simulate a late local collector mutation after the sidecar hash has
// converged. The live collector now differs from the agreed sidecar
@@ -1967,7 +2002,6 @@ class ConsensusExtensions_test : public beast::unit_test::suite
ce.exportSigCollector().addVerifiedSignature(
txHash, valPK, lateSig, seq);
auto const view = ce.activeValidatorView();
auto const live = ce.exportSigCollector().checkQuorumAndSnapshot(
txHash, 1, [&](PublicKey const& pk) {
return ce.isActiveValidator(pk, *view);

View File

@@ -341,12 +341,12 @@ struct Peer
hash_map<PeerID, PeerKey> nodeKeys_;
uint256 myEntropySecret_;
// Hash of the entropy reveal set this peer last advertised
// (buildEntropySet). finalizeRoundEntropy injects from the snapshot the
// sidecar store holds under this hash — the analog of production's
// FROZEN entropySetMap_ — not live pendingReveals_, so late-fetched or
// conflicting reveals that never entered the advertised set are not
// counted (matches ConsensusExtensions::selectEntropy).
// (buildEntropySet). The tick gate copies it to
// acceptedEntropySetHash_ after observation/alignment checks pass; only
// that accepted snapshot may feed finalizeRoundEntropy.
uint256 lastEntropySetHash_{};
std::optional<uint256> acceptedEntropySetHash_;
std::optional<uint256> acceptedExportSigSetHash_;
bool entropyFailed_ = false;
// Last round summary (for test assertions)
@@ -572,6 +572,30 @@ struct Peer
entropyFailed_ = true;
}
void
acceptEntropySet(uint256 const& hash)
{
acceptedEntropySetHash_ = hash;
}
void
clearAcceptedEntropySet()
{
acceptedEntropySetHash_.reset();
}
void
acceptExportSigSet(uint256 const& hash)
{
acceptedExportSigSetHash_ = hash;
}
void
clearAcceptedExportSigSet()
{
acceptedExportSigSetHash_.reset();
}
enum class SidecarKind : uint8_t {
commitSet,
entropySet,
@@ -668,6 +692,8 @@ struct Peer
likelyParticipants_.clear();
myEntropySecret_.zero();
lastEntropySetHash_.zero();
acceptedEntropySetHash_.reset();
acceptedExportSigSetHash_.reset();
entropyFailed_ = false;
exportSigGateStarted_ = false;
exportSigGateStart_ = {};
@@ -817,8 +843,8 @@ struct Peer
return;
}
// Fallback when the round failed alignment (entropyFailed_ is set
// by the tick entropy gate) or yielded no reveals.
// Fallback when the tick gate did not accept an entropy sidecar or
// the accepted sidecar yielded no reveals.
auto const fallback = [&] {
lastEntropyDigest_ = fallbackEntropy();
lastEntropyCount_ = 0;
@@ -832,8 +858,9 @@ struct Peer
// entropySetMap_, NOT live pendingReveals_. Late-fetched or
// conflicting reveals that never entered the advertised/aligned set
// are not counted, matching ConsensusExtensions::selectEntropy.
auto const* acceptedSet =
peer.sidecarStore.fetch(lastEntropySetHash_);
auto const* acceptedSet = acceptedEntropySetHash_
? peer.sidecarStore.fetch(*acceptedEntropySetHash_)
: nullptr;
std::vector<std::pair<PeerKey, uint256>> ordered;
// Defensive: lastEntropySetHash_ only ever names a reveal set (the
@@ -852,7 +879,7 @@ struct Peer
}
}
if (entropyFailed_ || ordered.empty())
if (ordered.empty())
{
fallback();
return;
@@ -907,15 +934,22 @@ struct Peer
return;
}
auto const activeSigCount = std::count_if(
pendingExportSigs_.begin(),
pendingExportSigs_.end(),
[&](auto const& entry) {
return isUNLReportMember(entry.first);
});
lastExportSucceeded_ = !exportSigConvergenceFailed_ &&
static_cast<std::size_t>(activeSigCount) >=
exportSigQuorumThreshold();
auto const* acceptedSet = acceptedExportSigSetHash_
? peer.sidecarStore.fetch(*acceptedExportSigSetHash_)
: nullptr;
std::size_t activeSigCount = 0;
if (acceptedSet &&
acceptedSet->type == SidecarStore::Type::exportSig)
{
activeSigCount = std::count_if(
acceptedSet->entries.begin(),
acceptedSet->entries.end(),
[&](auto const& entry) {
return isUNLReportMember(entry.first);
});
}
lastExportSucceeded_ = activeSigCount >= exportSigQuorumThreshold();
lastExportRetried_ = !lastExportSucceeded_;
}

View File

@@ -442,6 +442,18 @@ ConsensusExtensions::hasAnyReveals() const
return !pendingReveals_.empty();
}
void
ConsensusExtensions::acceptEntropySet(uint256 const& hash)
{
acceptedEntropySetHash_ = hash;
}
void
ConsensusExtensions::clearAcceptedEntropySet()
{
acceptedEntropySetHash_.reset();
}
ConsensusExtensions::EntropySelection
ConsensusExtensions::selectEntropy(
uint256 const& baseTxSetHash,
@@ -503,11 +515,11 @@ ConsensusExtensions::selectEntropy(
}
//@@end entropy-selector-unlreport-gate
// No agreed entropy set (round failed, or none was built) → fallback. A
// sub-quorum-but-aligned set may still qualify for participant_aligned
// (tier 2); the tier ladder below decides from the agreed participant
// count.
if (entropyFailed_ || !entropySetMap_)
// A cached entropySetMap_ is only candidate material. The tick gate marks
// the sidecar hash accepted after peer-observation/alignment checks have
// completed; injection never consults timeout state directly.
if (!acceptedEntropySetHash_ || !entropySetMap_ ||
entropySetMap_->getHash().as_uint256() != *acceptedEntropySetHash_)
return fallback();
// Derive from the AGREED entropySetMap_ — NOT local pendingReveals_. The
@@ -830,6 +842,18 @@ ConsensusExtensions::exportSigConvergenceFailed() const
return exportSigConvergenceFailed_;
}
void
ConsensusExtensions::acceptExportSigSet(uint256 const& hash)
{
acceptedExportSigSetHash_ = hash;
}
void
ConsensusExtensions::clearAcceptedExportSigSet()
{
acceptedExportSigSetHash_.reset();
}
std::optional<ConsensusExtensions::ExportSignatureSnapshot>
ConsensusExtensions::agreedExportSignatures(
STTx const& exportTx,
@@ -847,6 +871,27 @@ ConsensusExtensions::agreedExportSignatures(
ExportSignatureSnapshot signatures;
bool invalid = false;
auto const agreedHash = exportSigSetMap_->getHash().as_uint256();
// A local exportSigSetMap_ is only candidate material until the sidecar
// gate accepts its root. Without this guard, a timed-out node with a local
// partial-but-quorum map could mint a different signed export blob from
// the quorum-aligned nodes.
if (!acceptedExportSigSetHash_)
{
JLOG(j_.warn()) << "Export: exportSigSet not accepted"
<< " setHash=" << agreedHash << " txHash=" << txHash
<< " threshold=" << threshold;
return std::nullopt;
}
if (agreedHash != *acceptedExportSigSetHash_)
{
JLOG(j_.warn()) << "Export: exportSigSet hash not accepted"
<< " setHash=" << agreedHash
<< " acceptedHash=" << *acceptedExportSigSetHash_
<< " txHash=" << txHash << " threshold=" << threshold;
return std::nullopt;
}
exportSigSetMap_->visitLeaves(
[&](boost::intrusive_ptr<SHAMapItem const> const& item) {
if (invalid)
@@ -983,6 +1028,7 @@ ConsensusExtensions::clearRngStatePreservingExport()
entropyFailed_ = false;
commitSetMap_.reset();
entropySetMap_.reset();
acceptedEntropySetHash_.reset();
rngRoundSeq_.reset();
consensusTxSetMap_.reset();
consensusExportTxns_.clear();
@@ -1017,6 +1063,7 @@ ConsensusExtensions::clearRngState()
exportSigCollector_.clearAll();
}
exportSigSetMap_.reset();
acceptedExportSigSetHash_.reset();
consensusExportTxns_.clear();
exportSigGateStarted_ = false;
exportSigGateStart_ = {};

View File

@@ -70,6 +70,14 @@ private:
std::shared_ptr<SHAMap> commitSetMap_;
std::shared_ptr<SHAMap> entropySetMap_;
std::shared_ptr<SHAMap> exportSigSetMap_;
// Candidate entropy maps can be fetched/merged before they are safe to
// inject. This hash is set only by the entropy sidecar gate after the
// alignment/observation checks pass.
std::optional<uint256> acceptedEntropySetHash_;
// Export signature maps are also built from local collector state before
// accept. Closed-ledger export apply may only consume the map root the
// sidecar gate accepted for this round.
std::optional<uint256> acceptedExportSigSetHash_;
std::optional<LedgerIndex> rngRoundSeq_;
// Consensus parent ledger hash, pinned at round start. Input to the
// Tier 1 consensus_fallback entropy digest.
@@ -215,6 +223,12 @@ public:
bool
hasAnyReveals() const;
void
acceptEntropySet(uint256 const& hash);
void
clearAcceptedEntropySet();
/// Result of the shared deterministic entropy selector: the digest to
/// inject plus its tier/count labels. Both injection paths derive these
/// identically from the AGREED entropySetMap_ so they cannot drift.
@@ -226,13 +240,13 @@ public:
};
/// Deterministically choose the entropy to inject for this round from the
/// AGREED entropySetMap_ (never local pendingReveals_), labelled by agreed
/// participant count: validator_quorum (>= quorumThreshold),
/// participant_aligned (>= tier2Threshold) or consensus_fallback. In
/// non-standalone mode, non-fallback labels require an UNLReport-backed
/// active view; the trusted-fallback view is local config and mints Tier 1.
/// baseTxSetHash is the BASE (pre-injection) tx set hash used for the
/// fallback digest.
/// entropy sidecar accepted by the tick gate (never local pendingReveals_),
/// labelled by agreed participant count: validator_quorum (>=
/// quorumThreshold), participant_aligned (>= tier2Threshold) or
/// consensus_fallback. In non-standalone mode, non-fallback labels require
/// an UNLReport-backed active view; the trusted-fallback view is local
/// config and mints Tier 1. baseTxSetHash is the BASE (pre-injection) tx
/// set hash used for the fallback digest.
EntropySelection
selectEntropy(uint256 const& baseTxSetHash, LedgerIndex seq) const;
@@ -269,6 +283,12 @@ public:
bool
exportSigConvergenceFailed() const;
void
acceptExportSigSet(uint256 const& hash);
void
clearAcceptedExportSigSet();
std::optional<ExportSignatureSnapshot>
agreedExportSignatures(
STTx const& exportTx,

View File

@@ -165,6 +165,11 @@ ledger forever.
Final entropy is computed from the agreed entropy sidecar SHAMap, not from a
node's opportunistic local `pendingReveals_` map. This prevents different
local reveal subsets at timeout boundaries from producing different entropy.
The accepted-hash latch is the ledger-material boundary: a node that misses the
bounded observation window falls back instead of injecting from a local candidate
map. That does not make accept-vs-timeout decisions global; it makes any
non-fallback injection depend only on the sidecar root that this node accepted,
with ordinary validation quorum resolving boundary timing.
## Entropy Alignment Rules
@@ -337,6 +342,10 @@ export round to retry or expire. Full observation remains useful diagnostics; it
is not an Export success precondition. If no export signature hash reaches quorum
alignment by the bounded deadline, do not choose the largest non-quorum set; the
export retries or expires according to normal transaction rules.
Closed-ledger apply consumes only the accepted `exportSigSetHash` root. A node
that times out before accepting a root retries; a node that proceeds assembles
from the accepted sidecar map, never from its live collector. This avoids
successful-but-different export blobs while preserving the bounded wait model.
Closed-ledger apply must not promote unverified proposal-carried signatures into
current-round quorum material. It may verify and retain them for a future retry,

View File

@@ -999,6 +999,10 @@ RCLConsensus::phase() const
bool
RCLConsensus::extensionsBusy() const
{
// ConsensusExtensions state is mutated by timer, peer-proposal and
// sidecar-acquisition paths under this mutex. Busy polling observes the
// same state, so it must share the same synchronization boundary.
std::lock_guard _{mutex_};
return consensus_->extensionsBusy();
}

View File

@@ -182,8 +182,13 @@ Export::doApply()
<< " txHash=" << txId << " ledgerSeq=" << currentSeq
<< " unlSize=" << unlSize << " threshold=" << threshold;
}
else if (!consensusExtensions.exportSigConvergenceFailed())
else
{
// The tick gate decides when a round may stop waiting.
// Closed-ledger apply must still be a pure function of the agreed
// export sidecar and the parent-ledger validator view; local
// timeout flags must not veto a sidecar that already carries
// deterministic quorum.
collectedSigs = consensusExtensions.agreedExportSignatures(
ctx_.tx, txId, *validatorView, threshold);
}

View File

@@ -34,6 +34,13 @@ buildSigners(SignatureSnapshot const& signatures)
return a.getAccountID(sfAccount) < b.getAccountID(sfAccount);
});
// XRPL validates the Signers array size before checking signer weights.
// Export quorum is decided earlier from the agreed sidecar snapshot; this
// cap only materializes a target-chain-valid canonical prefix.
auto const maxSigners = STTx::maxMultiSigners();
if (signers.size() > maxSigners)
signers.erase(signers.begin() + maxSigners, signers.end());
return signers;
}

View File

@@ -817,6 +817,7 @@ extensionsTick(Ext& ext, Ctx const& ctx)
};
auto clearEntropyHash = [&] {
auto failedPos = ctx.getPosition();
ext.clearAcceptedEntropySet();
if (!failedPos.entropySetHash)
return;
failedPos.entropySetHash.reset();
@@ -973,6 +974,9 @@ extensionsTick(Ext& ext, Ctx const& ctx)
<< " txConverged=" << entropyState.txConverged
<< " conflict="
<< (entropyState.conflict ? "yes" : "no");
if (auto const accepted = ctx.getPosition().entropySetHash)
ext.acceptEntropySet(*accepted);
}
}
}
@@ -1046,6 +1050,7 @@ extensionsTick(Ext& ext, Ctx const& ctx)
}
ext.setExportSigConvergenceFailed();
ext.clearAcceptedExportSigSet();
JLOG(ext.j_.warn())
<< "Export: advertised exportSigSet fetch timeout"
<< " buildSeq=" << ctx.buildSeq
@@ -1077,6 +1082,7 @@ extensionsTick(Ext& ext, Ctx const& ctx)
}
ext.setExportSigConvergenceFailed();
ext.clearAcceptedExportSigSet();
JLOG(ext.j_.warn())
<< "Export: exportSigSet advertisement timeout"
<< " buildSeq=" << ctx.buildSeq
@@ -1169,6 +1175,7 @@ extensionsTick(Ext& ext, Ctx const& ctx)
return detail::exportSigSetQuorumAligned(
exportState.alignedParticipants(), exportQuorum);
};
bool acceptedExportSigHash = false;
//@@start export-sigset-alignment-check
if (exportState.conflict && !quorumAligned())
{
@@ -1213,6 +1220,7 @@ extensionsTick(Ext& ext, Ctx const& ctx)
<< " quorum=" << exportQuorum
<< " peersSeen=" << exportState.peersSeen
<< " txConverged=" << exportState.txConverged;
acceptedExportSigHash = true;
}
else if (quorumAligned() && !exportState.fullObservation())
{
@@ -1226,6 +1234,7 @@ extensionsTick(Ext& ext, Ctx const& ctx)
<< " quorum=" << exportQuorum
<< " peersSeen=" << exportState.peersSeen
<< " txConverged=" << exportState.txConverged;
acceptedExportSigHash = true;
}
//@@end export-no-veto-quorum-branches
else if (exportState.conflict || !quorumAligned())
@@ -1252,6 +1261,7 @@ extensionsTick(Ext& ext, Ctx const& ctx)
}
ext.setExportSigConvergenceFailed();
ext.clearAcceptedExportSigSet();
JLOG(ext.j_.warn())
<< "Export: exportSigSet quorum alignment timeout"
<< " buildSeq=" << buildSeqExport
@@ -1265,6 +1275,21 @@ extensionsTick(Ext& ext, Ctx const& ctx)
<< " elapsedMs=" << toMs(elapsed)
<< " deadlineMs=" << toMs(deadline);
}
else
{
acceptedExportSigHash = true;
}
if (acceptedExportSigHash)
{
// Apply must consume exactly the sidecar root that passed
// the export gate. Local collector state may continue to
// grow after this point, but it is not part of the agreed
// closed-ledger export material.
if (auto const accepted =
ctx.getPosition().exportSigSetHash)
ext.acceptExportSigSet(*accepted);
}
}
//@@end export-sigset-conflict-wait
}