mirror of
https://github.com/Xahau/xahaud.git
synced 2026-07-28 17:40:10 +00:00
fix(consensus): require export sigset quorum alignment
This commit is contained in:
@@ -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();
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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?
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user