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 }