From d091ebfb7e59a6de05b39abf5b9550ca4f1998b0 Mon Sep 17 00:00:00 2001 From: Nicholas Dudfield Date: Fri, 26 Jun 2026 16:50:19 +0700 Subject: [PATCH] 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. --- src/test/app/ExportResultBuilder_test.cpp | 47 ++++++++++++ src/test/app/Export_test.cpp | 8 ++- .../consensus/ConsensusExtensions_test.cpp | 40 ++++++++++- src/test/csf/Peer.h | 72 ++++++++++++++----- .../app/consensus/ConsensusExtensions.cpp | 57 +++++++++++++-- src/xrpld/app/consensus/ConsensusExtensions.h | 34 +++++++-- .../consensus/ConsensusExtensionsDesign.md | 9 +++ src/xrpld/app/consensus/RCLConsensus.cpp | 4 ++ src/xrpld/app/tx/detail/Export.cpp | 7 +- .../app/tx/detail/ExportResultBuilder.cpp | 7 ++ src/xrpld/consensus/ConsensusExtensionsTick.h | 25 +++++++ 11 files changed, 273 insertions(+), 37 deletions(-) diff --git a/src/test/app/ExportResultBuilder_test.cpp b/src/test/app/ExportResultBuilder_test.cpp index e5373c3bf..badc8988f 100644 --- a/src/test/app/ExportResultBuilder_test.cpp +++ b/src/test/app/ExportResultBuilder_test.cpp @@ -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(); + 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(); } }; diff --git a/src/test/app/Export_test.cpp b/src/test/app/Export_test.cpp index e556b890f..e28454098 100644 --- a/src/test/app/Export_test.cpp +++ b/src/test/app/Export_test.cpp @@ -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( @@ -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( diff --git a/src/test/consensus/ConsensusExtensions_test.cpp b/src/test/consensus/ConsensusExtensions_test.cpp index 3b8352cc4..6e3026cd2 100644 --- a/src/test/consensus/ConsensusExtensions_test.cpp +++ b/src/test/consensus/ConsensusExtensions_test.cpp @@ -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 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); diff --git a/src/test/csf/Peer.h b/src/test/csf/Peer.h index ac6514927..46747b01f 100644 --- a/src/test/csf/Peer.h +++ b/src/test/csf/Peer.h @@ -341,12 +341,12 @@ struct Peer hash_map 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 acceptedEntropySetHash_; + std::optional 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> 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(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_; } diff --git a/src/xrpld/app/consensus/ConsensusExtensions.cpp b/src/xrpld/app/consensus/ConsensusExtensions.cpp index ebb3acd28..fe303d30e 100644 --- a/src/xrpld/app/consensus/ConsensusExtensions.cpp +++ b/src/xrpld/app/consensus/ConsensusExtensions.cpp @@ -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::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 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_ = {}; diff --git a/src/xrpld/app/consensus/ConsensusExtensions.h b/src/xrpld/app/consensus/ConsensusExtensions.h index 809da43a0..70665a65e 100644 --- a/src/xrpld/app/consensus/ConsensusExtensions.h +++ b/src/xrpld/app/consensus/ConsensusExtensions.h @@ -70,6 +70,14 @@ private: std::shared_ptr commitSetMap_; std::shared_ptr entropySetMap_; std::shared_ptr 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 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 acceptedExportSigSetHash_; std::optional 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 agreedExportSignatures( STTx const& exportTx, diff --git a/src/xrpld/app/consensus/ConsensusExtensionsDesign.md b/src/xrpld/app/consensus/ConsensusExtensionsDesign.md index 676baf77c..b4b92d9ab 100644 --- a/src/xrpld/app/consensus/ConsensusExtensionsDesign.md +++ b/src/xrpld/app/consensus/ConsensusExtensionsDesign.md @@ -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, diff --git a/src/xrpld/app/consensus/RCLConsensus.cpp b/src/xrpld/app/consensus/RCLConsensus.cpp index 3d1592470..10f257dcb 100644 --- a/src/xrpld/app/consensus/RCLConsensus.cpp +++ b/src/xrpld/app/consensus/RCLConsensus.cpp @@ -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(); } diff --git a/src/xrpld/app/tx/detail/Export.cpp b/src/xrpld/app/tx/detail/Export.cpp index 1389aa2d8..40586d6a4 100644 --- a/src/xrpld/app/tx/detail/Export.cpp +++ b/src/xrpld/app/tx/detail/Export.cpp @@ -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); } diff --git a/src/xrpld/app/tx/detail/ExportResultBuilder.cpp b/src/xrpld/app/tx/detail/ExportResultBuilder.cpp index 983ca7c77..9f0b584a3 100644 --- a/src/xrpld/app/tx/detail/ExportResultBuilder.cpp +++ b/src/xrpld/app/tx/detail/ExportResultBuilder.cpp @@ -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; } diff --git a/src/xrpld/consensus/ConsensusExtensionsTick.h b/src/xrpld/consensus/ConsensusExtensionsTick.h index 18b04782b..662386c6a 100644 --- a/src/xrpld/consensus/ConsensusExtensionsTick.h +++ b/src/xrpld/consensus/ConsensusExtensionsTick.h @@ -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 }