fix(consensus): require export sigset quorum alignment

This commit is contained in:
Nicholas Dudfield
2026-04-27 17:36:06 +07:00
parent f13233b00a
commit 2218bdd7f3
10 changed files with 992 additions and 108 deletions

View File

@@ -19,14 +19,279 @@
#include <test/jtx.h>
#include <xrpld/app/consensus/ConsensusExtensions.h>
#include <xrpld/app/ledger/Ledger.h>
#include <xrpld/consensus/ConsensusExtensionsTick.h>
#include <xrpld/consensus/ConsensusProposal.h>
#include <xrpl/basics/StringUtilities.h>
#include <xrpl/beast/unit_test.h>
#include <xrpl/protocol/Feature.h>
#include <xrpl/protocol/Indexes.h>
#include <xrpl/protocol/digest.h>
#include <cstring>
namespace ripple {
namespace test {
namespace {
uint256
makeHash(char const* label)
{
return sha512Half(Slice(label, std::strlen(label)));
}
NodeID
makeNode(std::uint8_t id)
{
NodeID node;
node.zero();
node.data()[NodeID::size() - 1] = id;
return node;
}
struct FakeTxSet
{
using ID = uint256;
uint256 hash;
uint256
id() const
{
return hash;
}
};
class FakePeerPosition
{
public:
using Proposal = ConsensusProposal<NodeID, uint256, ExtendedPosition>;
FakePeerPosition(NodeID const& nodeId, ExtendedPosition const& position)
: proposal_(
uint256{},
Proposal::seqJoin,
position,
NetClock::time_point{},
NetClock::time_point{},
nodeId)
{
}
Proposal const&
proposal() const
{
return proposal_;
}
private:
Proposal proposal_;
};
struct FakeExtensions
{
enum class SidecarKind : uint8_t { commit, reveal, exportSig };
beast::Journal j_{beast::Journal::getNullSink()};
EstablishState estState_{EstablishState::ConvergingTx};
std::chrono::steady_clock::time_point revealPhaseStart_{};
std::chrono::steady_clock::time_point commitHashConflictStart_{};
bool explicitFinalProposalSent_{false};
bool entropySetPublished_{false};
std::chrono::steady_clock::time_point entropyPublishStart_{};
bool exportSigGateStarted_{false};
std::chrono::steady_clock::time_point exportSigGateStart_{};
bool exportSigConvergenceFailed_{false};
bool localExportSigs{true};
bool exportOn{true};
std::size_t exportQuorum{4};
uint256 exportHash{makeHash("local-export-sig-set")};
std::vector<uint256> fetchedExportSets;
int exportBuilds = 0;
bool
rngEnabled() const
{
return false;
}
bool
exportEnabled() const
{
return exportOn;
}
std::size_t
quorumThreshold() const
{
return exportQuorum;
}
std::size_t
exportSigQuorumThreshold() const
{
return exportQuorum;
}
std::size_t
pendingCommitCount() const
{
return 0;
}
std::size_t
pendingRevealCount() const
{
return 0;
}
std::size_t
expectedProposerCount() const
{
return 0;
}
bool
hasQuorumOfCommits() const
{
return false;
}
bool
hasMinimumReveals() const
{
return false;
}
bool
hasAnyReveals() const
{
return false;
}
uint256
buildCommitSet(LedgerIndex)
{
return makeHash("commit-set");
}
uint256
buildEntropySet(LedgerIndex)
{
return makeHash("entropy-set");
}
uint256
getEntropySecret() const
{
return makeHash("entropy-secret");
}
void
selfSeedReveal()
{
}
void
setEntropyFailed()
{
}
void
fetchRngSetIfNeeded(std::optional<uint256> const& hash, SidecarKind kind)
{
if (kind == SidecarKind::exportSig && hash)
fetchedExportSets.push_back(*hash);
}
bool
shouldSendExplicitFinalProposal() const
{
return false;
}
std::optional<FakeTxSet>
buildExplicitFinalProposalTxSet(FakeTxSet const&, LedgerIndex)
{
return std::nullopt;
}
bool
hasPendingExportSigs() const
{
return localExportSigs;
}
uint256
buildExportSigSet(LedgerIndex)
{
++exportBuilds;
return exportHash;
}
void
setExportSigConvergenceFailed()
{
exportSigConvergenceFailed_ = true;
}
};
struct ExportTickHarness
{
ExtendedPosition position{makeHash("tx-set")};
FakeTxSet txns{position.txSetHash};
hash_map<NodeID, FakePeerPosition> peers;
ConsensusParms parms;
NetClock::time_point netNow{NetClock::duration{123}};
std::chrono::steady_clock::time_point start{};
int updates = 0;
int proposes = 0;
void
addPeer(
std::uint8_t id,
std::optional<uint256> exportSigSetHash,
uint256 txSetHash = makeHash("tx-set"))
{
ExtendedPosition peerPosition{txSetHash};
peerPosition.exportSigSetHash = exportSigSetHash;
peers.emplace(
makeNode(id), FakePeerPosition{makeNode(id), peerPosition});
}
ExtensionTickResult
tick(FakeExtensions& ext, std::chrono::milliseconds elapsed = {})
{
ConsensusTick<ExtendedPosition, FakePeerPosition, FakeTxSet> ctx{
.buildSeq = 2,
.now = netNow,
.nowSteady = start + elapsed,
.roundTime = elapsed,
.mode = ConsensusMode::proposing,
.prevProposers = 0,
.peerPositions = peers,
.parms = parms,
.haveCloseTimeConsensus = true,
.convergePercent = 100,
.j = beast::Journal{beast::Journal::getNullSink()},
.getPosition = [&]() -> ExtendedPosition const& {
return position;
},
.updatePosition =
[&](ExtendedPosition const& newPosition) {
position = newPosition;
++updates;
},
.propose = [&]() { ++proposes; },
.haveConsensus = []() { return true; },
.cacheAndShareTxSet = [](FakeTxSet const&) {},
.getTxns = [&]() -> FakeTxSet const& { return txns; }};
return extensionsTick(ext, ctx);
}
};
} // namespace
class ConsensusExtensions_test : public beast::unit_test::suite
{
std::vector<PublicKey>
@@ -106,11 +371,113 @@ class ConsensusExtensions_test : public beast::unit_test::suite
BEAST_EXPECT(view->containsNode(calcNodeID(vlKeys[1])));
}
void
testExportSigGateRequiresQuorumAlignment()
{
testcase("Export sig gate requires quorum alignment");
FakeExtensions ext;
ExportTickHarness harness;
auto const localHash = ext.exportHash;
harness.addPeer(1, localHash);
harness.addPeer(2, localHash);
auto result = harness.tick(ext);
BEAST_EXPECT(!result.readyForAccept);
BEAST_EXPECT(harness.position.exportSigSetHash == localHash);
BEAST_EXPECT(ext.exportSigGateStarted_);
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
testExportSigGateAllowsAlignedQuorumDespiteMinorityConflict()
{
testcase("Export sig gate ignores minority conflict after quorum");
FakeExtensions ext;
ExportTickHarness harness;
auto const localHash = ext.exportHash;
auto const conflictHash = makeHash("conflicting-export-sig-set");
harness.addPeer(1, localHash);
harness.addPeer(2, localHash);
harness.addPeer(3, localHash);
harness.addPeer(4, conflictHash);
auto result = harness.tick(ext);
BEAST_EXPECT(!result.readyForAccept);
result = harness.tick(ext, std::chrono::milliseconds{100});
BEAST_EXPECT(result.readyForAccept);
BEAST_EXPECT(!ext.exportSigConvergenceFailed_);
BEAST_EXPECT(ext.fetchedExportSets.size() == 1);
BEAST_EXPECT(ext.fetchedExportSets.front() == conflictHash);
}
void
testExportSigGateFetchesAdvertisedPeerSets()
{
testcase("Export sig gate fetches advertised peer sets");
FakeExtensions ext;
ext.localExportSigs = false;
ExportTickHarness harness;
auto const peerHash = makeHash("peer-export-sig-set");
harness.addPeer(1, peerHash);
auto result = harness.tick(ext);
BEAST_EXPECT(!result.readyForAccept);
BEAST_EXPECT(ext.exportSigGateStarted_);
BEAST_EXPECT(!harness.position.exportSigSetHash);
BEAST_EXPECT(ext.fetchedExportSets.size() == 1);
BEAST_EXPECT(ext.fetchedExportSets.front() == peerHash);
result = harness.tick(
ext,
harness.parms.rngREVEAL_TIMEOUT * 2 + std::chrono::milliseconds{1});
BEAST_EXPECT(result.readyForAccept);
BEAST_EXPECT(ext.exportSigConvergenceFailed_);
}
void
testExportSigGateSkipsWhenExportDisabled()
{
testcase("Export sig gate skips when Export disabled");
FakeExtensions ext;
ext.exportOn = false;
ExportTickHarness harness;
harness.addPeer(1, ext.exportHash);
auto result = harness.tick(ext);
BEAST_EXPECT(result.readyForAccept);
BEAST_EXPECT(!ext.exportSigGateStarted_);
BEAST_EXPECT(!harness.position.exportSigSetHash);
BEAST_EXPECT(ext.exportBuilds == 0);
BEAST_EXPECT(ext.fetchedExportSets.empty());
}
public:
void
run() override
{
testActiveValidatorViewAppliesNegativeUNL();
testExportSigGateRequiresQuorumAlignment();
testExportSigGateAllowsAlignedQuorumDespiteMinorityConflict();
testExportSigGateFetchesAdvertisedPeerSets();
testExportSigGateSkipsWhenExportDisabled();
}
};

View File

@@ -937,5 +937,180 @@ public:
};
BEAST_DEFINE_TESTSUITE(ConsensusRng, consensus, ripple);
class ConsensusExport_test : public beast::unit_test::suite
{
SuiteJournal journal_;
public:
ConsensusExport_test() : journal_("ConsensusExport_test", *this)
{
}
void
testExportOnlySteadyStateSucceeds()
{
using namespace csf;
using namespace std::chrono;
testcase("Export-only sig set converges");
ConsensusParms const parms{};
Sim sim;
PeerGroup peers = sim.createGroup(5);
for (Peer* peer : peers)
peer->ce().enableExportConsensus_ = true;
peers.trustAndConnect(
peers, round<milliseconds>(0.2 * parms.ledgerGRANULARITY));
sim.run(2);
BEAST_EXPECT(sim.synchronized(peers));
for (Peer const* peer : peers)
{
BEAST_EXPECT(peer->ce().lastExportSucceeded_);
BEAST_EXPECT(!peer->ce().lastExportRetried_);
}
}
void
testExportOnlyRequiresUnanimousAlignment()
{
using namespace csf;
using namespace std::chrono;
testcase("Export-only sig set requires unanimous alignment");
ConsensusParms const parms{};
Sim sim;
PeerGroup peers = sim.createGroup(5);
for (Peer* peer : peers)
peer->ce().enableExportConsensus_ = true;
peers.trustAndConnect(
peers, round<milliseconds>(0.2 * parms.ledgerGRANULARITY));
peers[0]->ce().forcedExportSigSetHash_ =
sha512Half(std::string("forced-export-only"));
sim.run(3);
BEAST_EXPECT(sim.branches(peers) == 1);
for (Peer const* peer : peers)
{
BEAST_EXPECT(!peer->ce().lastExportSucceeded_);
BEAST_EXPECT(peer->ce().lastExportRetried_);
}
}
void
testExportSigSetQuorumAlignmentIgnoresMinorityConflict()
{
using namespace csf;
using namespace std::chrono;
testcase("Export sig set quorum ignores minority conflict");
ConsensusParms const parms{};
Sim sim;
PeerGroup peers = sim.createGroup(5);
for (Peer* peer : peers)
{
peer->ce().enableRngConsensus_ = true;
peer->ce().enableExportConsensus_ = true;
}
peers.trustAndConnect(
peers, round<milliseconds>(0.2 * parms.ledgerGRANULARITY));
// Warmup: populate prevProposers so the RNG path does not bootstrap
// skip the extension tick scenario.
sim.run(1);
BEAST_EXPECT(sim.synchronized(peers));
peers[0]->ce().forcedExportSigSetHash_ =
sha512Half(std::string("forced-export-minority"));
sim.run(3);
PeerGroup honest{
std::vector<Peer*>{peers[1], peers[2], peers[3], peers[4]}};
BEAST_EXPECT(sim.branches(honest) == 1);
BEAST_EXPECT(sim.synchronized(honest));
for (Peer const* peer : honest)
{
BEAST_EXPECT(peer->ce().lastExportSucceeded_);
BEAST_EXPECT(!peer->ce().lastExportRetried_);
}
BEAST_EXPECT(!peers[0]->ce().lastExportSucceeded_);
}
void
testExportSigSetConflictWithoutQuorumRetries()
{
using namespace csf;
using namespace std::chrono;
testcase("Export sig set conflict without quorum retries");
ConsensusParms const parms{};
Sim sim;
PeerGroup peers = sim.createGroup(5);
for (Peer* peer : peers)
{
peer->ce().enableRngConsensus_ = true;
peer->ce().enableExportConsensus_ = true;
}
peers.trustAndConnect(
peers, round<milliseconds>(0.2 * parms.ledgerGRANULARITY));
sim.run(1);
BEAST_EXPECT(sim.synchronized(peers));
peers[0]->ce().forcedExportSigSetHash_ =
sha512Half(std::string("forced-export-conflict-a"));
peers[1]->ce().forcedExportSigSetHash_ =
sha512Half(std::string("forced-export-conflict-b"));
sim.run(3);
BEAST_EXPECT(sim.branches(peers) == 1);
for (Peer const* peer : peers)
{
BEAST_EXPECT(!peer->ce().lastExportSucceeded_);
BEAST_EXPECT(peer->ce().lastExportRetried_);
}
}
void
run() override
{
auto const* filter = std::getenv("XAHAU_EXPORT_TEST");
std::string f = filter ? filter : "";
#define RUN(method) \
do \
{ \
if (f.empty() || std::string(#method).find(f) != std::string::npos) \
method(); \
} while (false)
RUN(testExportOnlySteadyStateSucceeds);
RUN(testExportOnlyRequiresUnanimousAlignment);
RUN(testExportSigSetQuorumAlignmentIgnoresMinorityConflict);
RUN(testExportSigSetConflictWithoutQuorumRetries);
#undef RUN
}
};
BEAST_DEFINE_TESTSUITE(ConsensusExport, consensus, ripple);
} // namespace test
} // namespace ripple

View File

@@ -63,7 +63,7 @@ namespace bc = boost::container;
/// into the correct local set without content-sniffing heuristics.
struct SidecarStore
{
enum class Type { commit, reveal };
enum class Type { commit, reveal, exportSig };
using EntrySet = hash_map<PeerID, uint256>;
@@ -328,13 +328,18 @@ struct Peer
bool explicitFinalProposalSent_{false};
bool entropySetPublished_{false};
std::chrono::steady_clock::time_point entropyPublishStart_{};
bool exportSigGateStarted_{false};
std::chrono::steady_clock::time_point exportSigGateStart_{};
bool exportSigConvergenceFailed_{false};
// RNG state
bool enableRngConsensus_ = false;
bool enableExportConsensus_ = false;
hash_set<PeerID> unlNodes_;
hash_set<PeerID> likelyParticipants_;
hash_map<PeerID, uint256> pendingCommits_;
hash_map<PeerID, uint256> pendingReveals_;
hash_map<PeerID, uint256> pendingExportSigs_;
hash_map<PeerID, PeerKey> nodeKeys_;
uint256 myEntropySecret_;
bool entropyFailed_ = false;
@@ -343,15 +348,21 @@ struct Peer
uint256 lastEntropyDigest_;
std::uint16_t lastEntropyCount_ = 0;
bool lastEntropyWasFallback_ = true;
bool lastExportSucceeded_ = false;
bool lastExportRetried_ = false;
// Optional test hook: force a specific commit-set hash
std::optional<uint256> forcedCommitSetHash_;
// Optional test hook: force a specific entropy-set hash
std::optional<uint256> forcedEntropySetHash_;
// Optional test hook: force a specific export sig-set hash
std::optional<uint256> forcedExportSigSetHash_;
// Optional test hook: drop reveals from specific peers
// (simulates asymmetric reveal delivery / packet loss)
hash_set<PeerID> dropRevealFrom_;
// Optional test hook: drop proposal-carried export signatures.
hash_set<PeerID> dropExportSigFrom_;
explicit Extensions(Peer& p) : peer(p), j_(p.j)
{
@@ -365,6 +376,12 @@ struct Peer
return enableRngConsensus_;
}
bool
exportEnabled() const
{
return enableExportConsensus_;
}
std::size_t
quorumThreshold() const
{
@@ -374,6 +391,16 @@ struct Peer
return calculateQuorumThreshold(base == 0 ? 1 : base);
}
std::size_t
exportSigQuorumThreshold() const
{
if (!enableExportConsensus_)
return (std::numeric_limits<std::size_t>::max)() / 4;
auto const base =
unlNodes_.empty() ? std::size_t{1} : unlNodes_.size();
return enableRngConsensus_ ? calculateQuorumThreshold(base) : base;
}
std::size_t
pendingCommitCount() const
{
@@ -450,6 +477,17 @@ struct Peer
return hash;
}
uint256
buildExportSigSet(Ledger::Seq seq)
{
if (forcedExportSigSetHash_)
return *forcedExportSigSetHash_;
auto const hash = hashRngSet(pendingExportSigs_, seq, "export-sig");
peer.sidecarStore.publish(
hash, SidecarStore::Type::exportSig, pendingExportSigs_);
return hash;
}
void
generateEntropySecret()
{
@@ -505,17 +543,28 @@ struct Peer
if (!fetched)
return;
// Union merge into the correct local set based on type.
auto& target = (fetched->type == SidecarStore::Type::commit)
? pendingCommits_
: pendingReveals_;
auto& target = [&]() -> hash_map<PeerID, uint256>& {
switch (fetched->type)
{
case SidecarStore::Type::commit:
return pendingCommits_;
case SidecarStore::Type::reveal:
return pendingReveals_;
case SidecarStore::Type::exportSig:
return pendingExportSigs_;
}
return pendingCommits_;
}();
for (auto const& [nodeId, digest] : fetched->entries)
target.emplace(nodeId, digest);
}
void
fetchSidecarsIfNeeded(ProposalPosition const&)
fetchSidecarsIfNeeded(ProposalPosition const& pos)
{
// CSF does not model SHAMap acquisition
fetchRngSetIfNeeded(pos.commitSetHash, SidecarKind::commit);
fetchRngSetIfNeeded(pos.entropySetHash, SidecarKind::reveal);
fetchRngSetIfNeeded(pos.exportSigSetHash, SidecarKind::exportSig);
}
void
@@ -523,10 +572,14 @@ struct Peer
{
pendingCommits_.clear();
pendingReveals_.clear();
pendingExportSigs_.clear();
nodeKeys_.clear();
likelyParticipants_.clear();
myEntropySecret_.zero();
entropyFailed_ = false;
exportSigGateStarted_ = false;
exportSigGateStart_ = {};
exportSigConvergenceFailed_ = false;
}
void
@@ -577,14 +630,14 @@ struct Peer
Ledger::ID const& prevLedger,
std::uint64_t)
{
if (!enableRngConsensus_)
if (!enableRngConsensus_ && !enableExportConsensus_)
return;
if (!isUNLReportMember(nodeId))
return;
nodeKeys_.insert_or_assign(nodeId, publicKey);
if (position.myCommitment)
if (enableRngConsensus_ && position.myCommitment)
{
auto [it, inserted] =
pendingCommits_.emplace(nodeId, *position.myCommitment);
@@ -595,32 +648,40 @@ struct Peer
}
}
if (!position.myReveal)
if (!enableRngConsensus_ || !position.myReveal)
{
if (enableExportConsensus_ && position.myExportSignature &&
dropExportSigFrom_.count(nodeId) == 0)
pendingExportSigs_[nodeId] = *position.myExportSignature;
return;
}
// Test hook: drop reveals from specific peers
if (dropRevealFrom_.count(nodeId) > 0)
return;
if (dropRevealFrom_.count(nodeId) == 0)
{
auto const commitIt = pendingCommits_.find(nodeId);
if (commitIt != pendingCommits_.end())
{
auto const prevIt = peer.ledgers.find(prevLedger);
if (prevIt != peer.ledgers.end())
{
auto const seq =
static_cast<std::uint32_t>(prevIt->second.seq()) +
1;
auto const expected = sha512Half(
*position.myReveal,
static_cast<std::uint32_t>(publicKey.first),
publicKey.second,
seq);
if (expected == commitIt->second)
pendingReveals_[nodeId] = *position.myReveal;
}
}
}
auto const commitIt = pendingCommits_.find(nodeId);
if (commitIt == pendingCommits_.end())
return;
auto const prevIt = peer.ledgers.find(prevLedger);
if (prevIt == peer.ledgers.end())
return;
auto const seq =
static_cast<std::uint32_t>(prevIt->second.seq()) + 1;
auto const expected = sha512Half(
*position.myReveal,
static_cast<std::uint32_t>(publicKey.first),
publicKey.second,
seq);
if (expected != commitIt->second)
return;
pendingReveals_[nodeId] = *position.myReveal;
if (enableExportConsensus_ && position.myExportSignature &&
dropExportSigFrom_.count(nodeId) == 0)
pendingExportSigs_[nodeId] = *position.myExportSignature;
}
bool
@@ -692,6 +753,28 @@ struct Peer
lastEntropyWasFallback_ = false;
}
void
finalizeRoundExport()
{
if (!enableExportConsensus_)
{
lastExportSucceeded_ = false;
lastExportRetried_ = false;
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();
lastExportRetried_ = !lastExportSucceeded_;
}
// --- Lifecycle hooks (matching design doc) ---
template <class Ledger_t>
@@ -738,6 +821,8 @@ struct Peer
Ledger_t const& prevLedger,
bool proposing)
{
decorateExportPosition(pos, prevLedger, proposing);
if (!enableRngConsensus_ || !proposing || !peer.runAsValidator)
return;
generateEntropySecret();
@@ -752,6 +837,27 @@ struct Peer
nodeKeys_.insert_or_assign(peer.id, peer.key);
}
template <class Ledger_t>
void
decorateExportPosition(
ProposalPosition& pos,
Ledger_t const& prevLedger,
bool proposing)
{
if (!enableExportConsensus_ || !proposing || !peer.runAsValidator)
return;
auto const seq = static_cast<std::uint32_t>(prevLedger.seq()) + 1;
auto const sig = sha512Half(
std::string("csf-export-sig"),
static_cast<std::uint32_t>(peer.id),
peer.key.second,
seq);
pos.myExportSignature = sig;
pendingExportSigs_[peer.id] = sig;
nodeKeys_.insert_or_assign(peer.id, peer.key);
}
void
appendJson(Json::Value&) const
{
@@ -782,22 +888,25 @@ struct Peer
{
return std::nullopt;
}
uint256
buildExportSigSet(Ledger::Seq)
{
return uint256{};
}
bool
hasPendingExportSigs() const
{
return false;
return enableExportConsensus_ && !pendingExportSigs_.empty();
}
void
setExportSigConvergenceFailed()
{
if (enableExportConsensus_)
exportSigConvergenceFailed_ = true;
}
// --- Sub-state accessors ---
bool
extensionsBusy() const
{
return estState_ != EstablishState::ConvergingTx;
return estState_ != EstablishState::ConvergingTx ||
(exportEnabled() &&
(exportSigGateStarted_ || hasPendingExportSigs()));
}
EstablishState
estState() const
@@ -813,6 +922,9 @@ struct Peer
explicitFinalProposalSent_ = false;
entropySetPublished_ = false;
entropyPublishStart_ = {};
exportSigGateStarted_ = false;
exportSigGateStart_ = {};
exportSigConvergenceFailed_ = false;
}
/// Defined in test/csf/PeerTick.h (keeps xrpld/app dependency
@@ -1167,6 +1279,7 @@ struct Peer
auto const seq = static_cast<std::uint32_t>(prevLedger.seq()) + 1;
ce().finalizeRoundEntropy(seq);
ce().finalizeRoundExport();
TxSet const acceptedTxs = injectTxs(prevLedger, result.txns);
Ledger const newLedger = oracle.accept(

View File

@@ -43,8 +43,10 @@ struct RngPosition
TxSet::ID txSetHash{};
std::optional<uint256> commitSetHash;
std::optional<uint256> entropySetHash;
std::optional<uint256> exportSigSetHash;
std::optional<uint256> myCommitment;
std::optional<uint256> myReveal;
std::optional<uint256> myExportSignature;
RngPosition() = default;
explicit RngPosition(TxSet::ID txSet) : txSetHash(txSet)
@@ -125,8 +127,10 @@ hash_append(Hasher& h, RngPosition const& pos)
hash_append(h, pos.txSetHash);
appendOpt(pos.commitSetHash);
appendOpt(pos.entropySetHash);
appendOpt(pos.exportSigSetHash);
appendOpt(pos.myCommitment);
appendOpt(pos.myReveal);
appendOpt(pos.myExportSignature);
}
/** Proposal is a position taken in the consensus process.

View File

@@ -134,6 +134,19 @@ ConsensusExtensions::quorumThreshold() const
return calculateQuorumThreshold(base);
}
std::size_t
ConsensusExtensions::exportSigQuorumThreshold() const
{
auto const base = activeValidatorView()->size();
if (base == 0)
return 1;
// Export can operate without ConsensusEntropy. In that mode it uses the
// original unanimity rule, but still relies on the same sidecar alignment
// gate so all nodes make the same accept-time decision.
return rngEnabled() ? calculateQuorumThreshold(base) : base;
}
void
ConsensusExtensions::setExpectedProposers(hash_set<NodeID> proposers)
{
@@ -287,6 +300,12 @@ ConsensusExtensions::rngEnabled() const
return rngEnabledThisRound_;
}
bool
ConsensusExtensions::exportEnabled() const
{
return exportEnabledThisRound_;
}
bool
ConsensusExtensions::bootstrapFastStartEnabled() const
{
@@ -607,6 +626,18 @@ ConsensusExtensions::hasPendingExportSigs() const
return !allSigs.empty();
}
void
ConsensusExtensions::setExportSigConvergenceFailed()
{
exportSigConvergenceFailed_ = true;
}
bool
ConsensusExtensions::exportSigConvergenceFailed() const
{
return exportSigConvergenceFailed_;
}
void
ConsensusExtensions::generateEntropySecret()
{
@@ -658,14 +689,17 @@ ConsensusExtensions::clearRngState()
exportSigSetMap_.reset();
rngRoundSeq_.reset();
pendingRngFetches_.clear();
exportSigGateStarted_ = false;
exportSigGateStart_ = {};
exportSigConvergenceFailed_ = false;
likelyParticipants_.clear();
commitProofs_.clear();
proposalProofs_.clear();
//@@end round-stop-rng-reset
// Keep the round-level enable latch intact here. Consensus::startRound()
// calls preStartRound() first to snapshot whether RNG is enabled for the
// upcoming round, then immediately clears per-round working state.
// Resetting rngEnabledThisRound_ here would wipe that snapshot before
// Keep the round-level enable latches intact here. Consensus::startRound()
// calls preStartRound() first to snapshot which extensions are enabled for
// the upcoming round, then immediately clears per-round working state.
// Resetting these latches here would wipe that snapshot before
// phaseEstablish() can consult it.
}
//@@end clear-rng-state

View File

@@ -95,6 +95,7 @@ private:
uint256 myEntropySecret_;
bool entropyFailed_ = false;
bool rngEnabledThisRound_ = false;
bool exportEnabledThisRound_ = false;
// Real SHAMaps for the current round (unbacked, ephemeral)
std::shared_ptr<SHAMap> commitSetMap_;
@@ -126,6 +127,9 @@ public:
bool explicitFinalProposalSent_{false};
bool entropySetPublished_{false};
std::chrono::steady_clock::time_point entropyPublishStart_{};
bool exportSigGateStarted_{false};
std::chrono::steady_clock::time_point exportSigGateStart_{};
bool exportSigConvergenceFailed_{false};
/** Proof data from a proposal signature, for embedding in SHAMap
entries. Contains everything needed to independently verify
that a validator committed/revealed a specific value. */
@@ -172,6 +176,9 @@ public:
std::size_t
quorumThreshold() const;
std::size_t
exportSigQuorumThreshold() const;
void
setExpectedProposers(hash_set<NodeID> proposers);
@@ -199,6 +206,9 @@ public:
bool
rngEnabled() const;
bool
exportEnabled() const;
bool
bootstrapFastStartEnabled() const;
@@ -220,6 +230,12 @@ public:
bool
hasPendingExportSigs() const;
void
setExportSigConvergenceFailed();
bool
exportSigConvergenceFailed() const;
bool
isSidecarSet(uint256 const& hash) const;
@@ -378,10 +394,18 @@ public:
rngEnabledThisRound_ = v;
}
void
setExportEnabledThisRound(bool v)
{
exportEnabledThisRound_ = v;
}
bool
extensionsBusy() const
{
return estState_ != EstablishState::ConvergingTx;
return estState_ != EstablishState::ConvergingTx ||
(exportEnabled() &&
(exportSigGateStarted_ || hasPendingExportSigs()));
}
EstablishState
@@ -399,6 +423,9 @@ public:
explicitFinalProposalSent_ = false;
entropySetPublished_ = false;
entropyPublishStart_ = {};
exportSigGateStarted_ = false;
exportSigGateStart_ = {};
exportSigConvergenceFailed_ = false;
}
};

View File

@@ -157,23 +157,40 @@ conflicts and produce asymmetric zero/non-zero outcomes.
gated.
Export can run without ConsensusEntropy, but then it uses a conservative
ephemeral mode. With ConsensusEntropy active, export can use the shared
`ExtendedPosition` and sidecar convergence machinery to converge a verified
export signature set and use the normal 80% threshold.
ephemeral mode: verified export signature sidecars still converge through
`ExtendedPosition`, but success requires unanimity of the active validator
view. With ConsensusEntropy active, the same sidecar machinery uses the normal
80% threshold.
The extended proposal machinery is enabled when either feature needs signed
sidecar fields. Do not make Export depend on RNG availability just because RNG
was the first consumer of `ExtendedPosition`.
When `featureExport` is disabled, the export sidecar gate is disabled too. Stale
collector entries must not keep a stopped amendment active.
Only verified export signatures count toward quorum or enter export sidecar
SHAMaps. Proposal-ingress signatures are sender-bound to the trusted proposal
validator and may be stored as unverified until the matching export transaction
is available for cryptographic verification.
Export success requires quorum alignment on `exportSigSetHash`, not merely a
local collector quorum. If the verified signature set cannot align by the
bounded deadline, the export retries or expires according to normal transaction
rules.
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,
where they can be published in a sidecar set and converged before use.
Export sig convergence runs in parallel with RNG. An export-side convergence
failure must not change RNG semantics; an RNG fallback must not make export
unsafe. Each feature has its own gate and fallback.
CSF consensus tests model the export sidecar gate directly. Testnet scenarios
under `.testnet/scenarios/export/` cover live-node Export+CE behavior and the
Export-only unanimity mode.
## Review Checklist
When changing consensus extension code, check these questions:
@@ -189,4 +206,6 @@ When changing consensus extension code, check these questions:
- Are proposal-visible or validation-visible sidecar fields covered by the
relevant signature and duplicate/replay identity?
- Are export signatures verified before they count?
- Does export success require `exportSigSetHash` alignment, not just local
collector quorum?
- Are CE and Export still independently gated and independently stoppable?

View File

@@ -1083,9 +1083,12 @@ RCLConsensus::Adaptor::preStartRound(
{
ce().setRngEnabledThisRound(
prevLgr.ledger_->rules().enabled(featureConsensusEntropy));
ce().setExportEnabledThisRound(
prevLgr.ledger_->rules().enabled(featureExport));
JLOG(j_.trace()) << "RNGGATE: preStartRound prevSeq=" << prevLgr.seq()
<< " rulesEnabled=" << ce().rngEnabled();
<< " rulesEnabled=" << ce().rngEnabled()
<< " exportEnabled=" << ce().exportEnabled();
// We have a key, we do not want out of sync validations after a restart
// and are not amendment blocked.

View File

@@ -142,13 +142,13 @@ Export::doApply()
STTx innerTx(std::ref(sit));
// Upgrade pass: verify any unverified sigs in the collector.
// We always have the inner tx here (it's ctx_.tx), so we can
// verify sigs that couldn't be checked at proposal ingestion
// time due to relay ordering. This upgrades them to verified
// so they count toward quorum.
if (!ctx_.app.config().standalone())
{
auto upgradeUnverifiedForNextRound = [&]() {
if (ctx_.app.config().standalone())
return;
// Closed-ledger apply must not create new current-round quorum
// material. These upgrades are retained for a retrying export, where
// the sidecar alignment gate can publish and converge them first.
auto& collector = consensusExtensions.exportSigCollector();
auto const unverified = collector.unverifiedSignatures(txId);
for (auto const& [valPK, sigBuf] : unverified)
@@ -173,7 +173,7 @@ Export::doApply()
<< txId << " — removing invalid sig";
}
}
}
};
// Atomic quorum check + snapshot for network mode.
// Only verified signatures count toward quorum and appear
@@ -183,18 +183,22 @@ Export::doApply()
if (!ctx_.app.config().standalone())
{
std::size_t threshold;
bool const ceEnabled = view().rules().enabled(featureConsensusEntropy);
if (unlSize == 0)
threshold = 1;
else if (view().rules().enabled(featureConsensusEntropy))
else if (ceEnabled)
threshold = calculateQuorumThreshold(unlSize);
else
threshold = unlSize;
// The collector may contain old trusted signatures; quorum counts only
// signatures whose keys resolve into the same frozen active view.
collectedSigs =
consensusExtensions.exportSigCollector().checkQuorumAndSnapshot(
txId, threshold, isActiveSigner);
if (!consensusExtensions.exportSigConvergenceFailed())
{
collectedSigs =
consensusExtensions.exportSigCollector().checkQuorumAndSnapshot(
txId, threshold, isActiveSigner);
}
if (!collectedSigs)
{
@@ -231,10 +235,15 @@ Export::doApply()
}
}
upgradeUnverifiedForNextRound();
JLOG(j_.info())
<< "Export: not enough sigs at ledger " << currentSeq
<< " sigs=" << sigCount << " threshold=" << threshold
<< " unlSize=" << unlSize << " -> terRETRY_EXPORT";
<< " unlSize=" << unlSize << " exportSigConvergenceFailed="
<< (consensusExtensions.exportSigConvergenceFailed() ? "yes"
: "no")
<< " -> terRETRY_EXPORT";
return terRETRY_EXPORT;
}
}

View File

@@ -879,78 +879,211 @@ extensionsTick(Ext& ext, Ctx const& ctx)
//@@end rng-phase-establish-substates
//@@start export-sig-convergence-gate
// Export sig convergence gate: runs after RNG sub-states, only when
// both CE and Export are enabled. Builds/publishes exportSigSetHash
// and waits for peer agreement before accepting.
// 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.
if constexpr (requires { ctx.getPosition().exportSigSetHash; })
{
// Only run when CE is active (provides ExtendedPosition infra)
// and there are export sigs to converge.
if (isRngEnabled)
{
if (ext.hasPendingExportSigs())
if (!ext.exportEnabled())
return {.readyForAccept = true};
auto startExportSigGate = [&]() -> bool {
if (ext.exportSigGateStarted_)
return false;
ext.exportSigGateStarted_ = true;
ext.exportSigGateStart_ = ctx.nowSteady;
return true;
};
auto fetchPeerExportSigSets = [&](auto const& pos) {
std::size_t peerSets = 0;
for (auto const& [_, peerPos] : ctx.peerPositions)
{
//@@start export-publish-sigset-hash
auto const buildSeqExport = ctx.buildSeq;
auto const exportHash = ext.buildExportSigSet(buildSeqExport);
auto const& pp = peerPos.proposal().position();
if (!(pp == pos))
continue; // not tx-converged
if (!pp.exportSigSetHash)
continue;
auto currentPos = ctx.getPosition();
if (!currentPos.exportSigSetHash ||
*currentPos.exportSigSetHash != exportHash)
++peerSets;
ext.fetchRngSetIfNeeded(
pp.exportSigSetHash, Ext::SidecarKind::exportSig);
}
return peerSets;
};
bool hasLocalExportSigs = ext.hasPendingExportSigs();
if (!hasLocalExportSigs)
{
auto const peerSets = fetchPeerExportSigSets(ctx.getPosition());
if (peerSets > 0)
{
startExportSigGate();
hasLocalExportSigs = ext.hasPendingExportSigs();
if (!hasLocalExportSigs)
{
currentPos.exportSigSetHash = exportHash;
ctx.updatePosition(currentPos);
auto const elapsed =
ctx.nowSteady - ext.exportSigGateStart_;
auto const deadline = ctx.parms.rngREVEAL_TIMEOUT * 2;
if (elapsed <= deadline)
{
JLOG(ext.j_.debug())
<< "Export: waiting for advertised exportSigSet "
"fetch/merge"
<< " peerSets=" << peerSets;
return {};
}
if (ctx.mode == ConsensusMode::proposing)
ctx.propose();
JLOG(ext.j_.debug())
<< "Export: published exportSigSetHash=" << exportHash;
ext.setExportSigConvergenceFailed();
JLOG(ext.j_.warn())
<< "Export: advertised exportSigSet did not converge "
"locally within deadline; exports will retry or "
"expire"
<< " peerSets=" << peerSets;
}
//@@end export-publish-sigset-hash
}
}
//@@start export-sigset-conflict-wait
// Check peer agreement on exportSigSetHash.
// If any tx-converged peer has a different non-empty hash,
// wait briefly for fetch/merge to resolve it.
if (hasLocalExportSigs)
{
//@@start export-publish-sigset-hash
auto const buildSeqExport = ctx.buildSeq;
auto const exportHash = ext.buildExportSigSet(buildSeqExport);
auto currentPos = ctx.getPosition();
bool const publishedNewHash = !currentPos.exportSigSetHash ||
*currentPos.exportSigSetHash != exportHash;
if (publishedNewHash)
{
currentPos.exportSigSetHash = exportHash;
ctx.updatePosition(currentPos);
if (ctx.mode == ConsensusMode::proposing)
ctx.propose();
JLOG(ext.j_.debug())
<< "Export: published exportSigSetHash=" << exportHash;
}
//@@end export-publish-sigset-hash
//@@start export-sigset-conflict-wait
// Check quorum agreement on exportSigSetHash. Like RNG entropy,
// Export success is an accept-time derived effect outside tx-set
// equality. A local-only quorum must not succeed unless enough
// tx-converged peers advertise the same export sig sidecar hash.
{
if (startExportSigGate() || publishedNewHash)
{
JLOG(ext.j_.debug())
<< "Export: exportSigSet published, waiting for peer "
"observation";
return {};
}
struct ExportPeerState
{
bool conflict = false;
std::size_t aligned = 0;
std::size_t peersSeen = 0;
};
auto inspectExportPeers =
[&](auto const& pos,
bool fetchMismatches) -> ExportPeerState {
ExportPeerState state;
if (!pos.exportSigSetHash)
return state;
for (auto const& [_, peerPos] : ctx.peerPositions)
{
auto const& pp = peerPos.proposal().position();
if (!(pp == pos))
continue; // not tx-converged
if (!pp.exportSigSetHash)
continue;
if (*pp.exportSigSetHash != exportHash)
continue; // peer hasn't published yet
++state.peersSeen;
if (*pp.exportSigSetHash == *pos.exportSigSetHash)
{
conflict = true;
++state.aligned;
continue;
}
// Trigger fetch for the differing set
state.conflict = true;
if (fetchMismatches)
ext.fetchRngSetIfNeeded(
pp.exportSigSetHash,
Ext::SidecarKind::exportSig);
break;
}
}
return state;
};
auto exportState = inspectExportPeers(ctx.getPosition(), true);
auto const exportQuorum = ext.exportSigQuorumThreshold();
auto quorumAligned = [&] {
return exportState.aligned + 1 >= exportQuorum;
};
if (exportState.conflict && !quorumAligned())
{
auto const refreshedHash =
ext.buildExportSigSet(buildSeqExport);
auto current = ctx.getPosition();
if (!current.exportSigSetHash ||
*current.exportSigSetHash != refreshedHash)
{
current.exportSigSetHash = refreshedHash;
ctx.updatePosition(current);
if (ctx.mode == ConsensusMode::proposing)
ctx.propose();
JLOG(ext.j_.debug())
<< "Export: refreshed exportSigSetHash after merge "
"to "
<< refreshedHash;
}
if (conflict)
{
// Don't block indefinitely — use the same pipeline
// timeout as RNG.
bool const timeout =
ctx.roundTime > ctx.parms.rngPIPELINE_TIMEOUT;
if (!timeout)
{
JLOG(ext.j_.debug())
<< "Export: exportSigSetHash conflict, waiting";
return {};
}
JLOG(ext.j_.info())
<< "Export: exportSigSetHash conflict timed out, "
"proceeding (exports will retry next round)";
}
exportState = inspectExportPeers(ctx.getPosition(), true);
}
if (exportState.conflict && quorumAligned())
{
JLOG(ext.j_.info())
<< "Export: exportSigSetHash conflict ignored after "
"quorum alignment"
<< " alignedParticipants=" << (exportState.aligned + 1)
<< " quorum=" << exportQuorum;
}
else if (exportState.conflict || !quorumAligned())
{
auto const elapsed =
ctx.nowSteady - ext.exportSigGateStart_;
auto const deadline = ctx.parms.rngREVEAL_TIMEOUT * 2;
if (elapsed <= deadline)
{
JLOG(ext.j_.debug())
<< "Export: waiting for exportSigSet quorum "
"alignment"
<< " alignedParticipants="
<< (exportState.aligned + 1)
<< " quorum=" << exportQuorum
<< " peersSeen=" << exportState.peersSeen
<< " conflict="
<< (exportState.conflict ? "yes" : "no");
return {};
}
ext.setExportSigConvergenceFailed();
JLOG(ext.j_.warn())
<< "Export: exportSigSet quorum alignment missing "
"within deadline; exports will retry or expire"
<< " alignedParticipants=" << (exportState.aligned + 1)
<< " quorum=" << exportQuorum
<< " peersSeen=" << exportState.peersSeen
<< " conflict="
<< (exportState.conflict ? "yes" : "no");
}
//@@end export-sigset-conflict-wait
}
//@@end export-sigset-conflict-wait
}
}
//@@end export-sig-convergence-gate