fix(consensus): bound export sidecar observation

When a candidate set contains ttEXPORT but a node has no local verified export sig material yet, give tx-converged peers one bounded opportunity to advertise an exportSigSetHash before closed-ledger apply.

This is a safety coordination window, not a wait-for-Export-success mechanism. If no advertised sidecar arrives or fetched material cannot be merged by the deadline, Export convergence is marked failed and the transaction retries or expires through normal rules.

Add CSF coverage for a peer that can only succeed by fetching peer-advertised export sidecars, plus a direct ConsensusExtensionsTick test for the pre-advertisement observation window. Document the consensus-extension priority order: safe, fast, works.
This commit is contained in:
Nicholas Dudfield
2026-04-28 11:15:03 +07:00
parent 3938ba7af4
commit a3b1e45f4d
7 changed files with 150 additions and 8 deletions

View File

@@ -114,6 +114,7 @@ struct FakeExtensions
std::chrono::steady_clock::time_point exportSigGateStart_{};
bool exportSigConvergenceFailed_{false};
bool localExportSigs{true};
bool consensusExportTxns{false};
bool exportOn{true};
std::size_t exportQuorum{4};
uint256 exportHash{makeHash("local-export-sig-set")};
@@ -233,6 +234,12 @@ struct FakeExtensions
return localExportSigs;
}
bool
hasConsensusExportTxns() const
{
return consensusExportTxns;
}
uint256
buildExportSigSet(LedgerIndex)
{
@@ -462,6 +469,34 @@ class ConsensusExtensions_test : public beast::unit_test::suite
BEAST_EXPECT(ext.exportSigConvergenceFailed_);
}
void
testExportSigGateBoundsCandidateObservationWindow()
{
testcase("Export sig gate bounds candidate observation window");
FakeExtensions ext;
ext.localExportSigs = false;
ext.consensusExportTxns = true;
ExportTickHarness harness;
auto result = harness.tick(ext);
BEAST_EXPECT(!result.readyForAccept);
BEAST_EXPECT(ext.exportSigGateStarted_);
BEAST_EXPECT(!harness.position.exportSigSetHash);
BEAST_EXPECT(ext.fetchedExportSets.empty());
BEAST_EXPECT(!ext.exportSigConvergenceFailed_);
result = harness.tick(ext, std::chrono::milliseconds{100});
BEAST_EXPECT(!result.readyForAccept);
BEAST_EXPECT(!ext.exportSigConvergenceFailed_);
result = harness.tick(
ext,
harness.parms.rngREVEAL_TIMEOUT * 2 + std::chrono::milliseconds{1});
BEAST_EXPECT(result.readyForAccept);
BEAST_EXPECT(ext.exportSigConvergenceFailed_);
}
void
testExportSigGateSkipsWhenExportDisabled()
{
@@ -558,6 +593,7 @@ public:
testExportSigGateRequiresQuorumAlignment();
testExportSigGateAllowsAlignedQuorumDespiteMinorityConflict();
testExportSigGateFetchesAdvertisedPeerSets();
testExportSigGateBoundsCandidateObservationWindow();
testExportSigGateSkipsWhenExportDisabled();
testExportDisabledRoundClearsCollector();
testReplayedProposalHarvestsExportSigs();

View File

@@ -1011,6 +1011,47 @@ public:
BEAST_EXPECT(!peers[0]->ce().lastExportSucceeded_);
}
void
testExportOnlyFetchesPeerAdvertisedSigSet()
{
using namespace csf;
using namespace std::chrono;
testcase("Export-only fetches peer-advertised sig set");
ConsensusParms const parms{};
Sim sim;
PeerGroup peers = sim.createGroup(5);
for (Peer* peer : peers)
peer->ce().enableExportConsensus_ = true;
// Peer 0 remains an active validator/proposer, but starts with no
// local export signature material. It drops proposal-carried export
// signatures from peers, so only sidecar fetch/merge can populate its
// local export set.
peers[0]->ce().suppressOwnExportSig_ = true;
for (std::size_t i = 1; i < peers.size(); ++i)
peers[0]->ce().dropExportSigFrom_.insert(peers[i]->id);
peers.trustAndConnect(
peers, round<milliseconds>(0.2 * parms.ledgerGRANULARITY));
sim.run(3);
BEAST_EXPECT(sim.branches(peers) == 1);
BEAST_EXPECT(sim.synchronized(peers));
BEAST_EXPECT(peers[0]->ce().exportSigFetchMerges_ >= 4);
BEAST_EXPECT(peers[0]->ce().lastExportSucceeded_);
BEAST_EXPECT(!peers[0]->ce().lastExportRetried_);
for (std::size_t i = 1; i < peers.size(); ++i)
{
BEAST_EXPECT(peers[i]->ce().lastExportSucceeded_);
BEAST_EXPECT(!peers[i]->ce().lastExportRetried_);
}
}
void
testExportSigSetQuorumAlignmentIgnoresMinorityConflict()
{
@@ -1109,6 +1150,7 @@ public:
RUN(testExportOnlySteadyStateSucceeds);
RUN(testExportOnlyQuorumIgnoresMinorityConflict);
RUN(testExportOnlyFetchesPeerAdvertisedSigSet);
RUN(testExportSigSetQuorumAlignmentIgnoresMinorityConflict);
RUN(testExportSigSetConflictWithoutQuorumRetries);

View File

@@ -350,6 +350,7 @@ struct Peer
bool lastEntropyWasFallback_ = true;
bool lastExportSucceeded_ = false;
bool lastExportRetried_ = false;
std::size_t exportSigFetchMerges_ = 0;
// Optional test hook: force a specific commit-set hash
std::optional<uint256> forcedCommitSetHash_;
@@ -363,6 +364,9 @@ struct Peer
hash_set<PeerID> dropRevealFrom_;
// Optional test hook: drop proposal-carried export signatures.
hash_set<PeerID> dropExportSigFrom_;
// Optional test hook: stay an active proposer but do not originate an
// export signature, so tests can force sidecar-fetch-only convergence.
bool suppressOwnExportSig_ = false;
explicit Extensions(Peer& p) : peer(p), j_(p.j)
{
@@ -556,7 +560,11 @@ struct Peer
return pendingCommits_;
}();
for (auto const& [nodeId, digest] : fetched->entries)
target.emplace(nodeId, digest);
{
auto const [_, inserted] = target.emplace(nodeId, digest);
if (fetched->type == SidecarStore::Type::exportSig && inserted)
++exportSigFetchMerges_;
}
}
void
@@ -853,8 +861,11 @@ struct Peer
static_cast<std::uint32_t>(peer.id),
peer.key.second,
seq);
pos.myExportSignature = sig;
pendingExportSigs_[peer.id] = sig;
if (!suppressOwnExportSig_)
{
pos.myExportSignature = sig;
pendingExportSigs_[peer.id] = sig;
}
nodeKeys_.insert_or_assign(peer.id, peer.key);
}
@@ -893,6 +904,11 @@ struct Peer
{
return enableExportConsensus_ && !pendingExportSigs_.empty();
}
bool
hasConsensusExportTxns() const
{
return enableExportConsensus_;
}
void
setExportSigConvergenceFailed()
{

View File

@@ -738,6 +738,12 @@ ConsensusExtensions::hasPendingExportSigs() const
return false;
}
bool
ConsensusExtensions::hasConsensusExportTxns() const
{
return !consensusExportTxns_.empty();
}
void
ConsensusExtensions::setExportSigConvergenceFailed()
{

View File

@@ -235,6 +235,9 @@ public:
bool
hasPendingExportSigs() const;
bool
hasConsensusExportTxns() const;
void
setExportSigConvergenceFailed();

View File

@@ -10,6 +10,14 @@ but it must not redefine ordinary transaction-set consensus. When extension
state cannot be made safe in time, the extension degrades deterministically
and the ledger still closes.
The priority order for consensus extensions is: safe, fast, works. Safety means
extension timing must not create divergent closed-ledger effects when a bounded
coordination step can avoid it. Fast means those coordination steps stay short
and conditional, never becoming an open-ended wait for an extension feature to
succeed. Works means missed or late extension material follows that feature's
deterministic fallback, such as zero entropy for RNG or normal Export
retry/expiry, rather than blocking core consensus.
## Core Invariants
1. Core consensus remains keyed by the transaction set.
@@ -188,6 +196,14 @@ against the candidate transaction, and promoted into `ExportSigCollector`.
Closed-ledger apply snapshots that collector, so the sidecar convergence state
and the signer set used by `ttEXPORT` stay on the same path.
If the consensus candidate contains a `ttEXPORT` but the node has no eligible
local export signatures yet, the export sidecar gate opens only a bounded
safety window for tx-converged peers to advertise `exportSigSetHash`. This is
not a wait-for-Export-success mechanism; it is a short opportunity to avoid
closing a minority ledger while sidecar convergence is already reachable. If no
advertised sidecar appears by the deadline, the gate stops waiting and the
export retries or expires through normal transaction rules.
Export success requires quorum alignment on `exportSigSetHash`, not merely a
local collector quorum. If a quorum of tx-converged participants advertises the
same export signature sidecar hash, that hash is aligned and below-quorum

View File

@@ -881,8 +881,8 @@ extensionsTick(Ext& ext, Ctx const& ctx)
//@@start export-sig-convergence-gate
// Export sig convergence gate: runs after RNG sub-states when Export has
// verified signatures to converge, or when a tx-converged peer advertises
// an exportSigSetHash we may need to fetch. Builds/publishes
// exportSigSetHash and waits for quorum peer agreement before accepting.
// an exportSigSetHash we may need to fetch. This is a bounded safety
// coordination window, not a wait-for-Export-success mechanism.
if constexpr (requires { ctx.getPosition().exportSigSetHash; })
{
if (!ext.exportEnabled())
@@ -929,7 +929,8 @@ extensionsTick(Ext& ext, Ctx const& ctx)
if (elapsed <= deadline)
{
JLOG(ext.j_.debug())
<< "Export: waiting for advertised exportSigSet "
<< "Export: bounded wait for advertised "
"exportSigSet "
"fetch/merge"
<< " peerSets=" << peerSets;
return {};
@@ -938,11 +939,33 @@ extensionsTick(Ext& ext, Ctx const& ctx)
ext.setExportSigConvergenceFailed();
JLOG(ext.j_.warn())
<< "Export: advertised exportSigSet did not converge "
"locally within deadline; exports will retry or "
"expire"
"locally within bounded safety window; exports "
"will retry or expire"
<< " peerSets=" << peerSets;
}
}
else if (ext.hasConsensusExportTxns())
{
// A candidate ttEXPORT with no local sig material gets one
// short observation window so an already-reachable peer
// exportSigSetHash can be fetched before apply. If nothing
// appears in time, apply takes the retry/expire path.
startExportSigGate();
auto const elapsed = ctx.nowSteady - ext.exportSigGateStart_;
auto const deadline = ctx.parms.rngREVEAL_TIMEOUT * 2;
if (elapsed <= deadline)
{
JLOG(ext.j_.debug())
<< "Export: bounded wait for exportSigSet "
"advertisement";
return {};
}
ext.setExportSigConvergenceFailed();
JLOG(ext.j_.warn())
<< "Export: no exportSigSet advertisement within bounded "
"safety window; exports will retry or expire";
}
}
if (hasLocalExportSigs)