refactor(rng): remove explicit-final proposal path

This commit is contained in:
Nicholas Dudfield
2026-06-22 15:07:18 +07:00
parent 3970912735
commit 60469dbd86
10 changed files with 17 additions and 624 deletions

View File

@@ -331,7 +331,6 @@ struct FakeExtensions
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};
@@ -348,14 +347,12 @@ struct FakeExtensions
bool commitQuorum{true};
bool minimumReveals{true};
bool anyReveals{true};
bool sendExplicitFinal{false};
uint256 exportHash{makeHash("local-export-sig-set")};
uint256 commitHash{makeHash("local-commit-set")};
uint256 entropyHash{makeHash("local-entropy-set")};
std::deque<uint256> exportHashSequence;
std::deque<uint256> commitHashSequence;
std::deque<uint256> entropyHashSequence;
std::optional<FakeTxSet> explicitFinalTxSet;
std::vector<uint256> fetchedExportSets;
std::vector<uint256> fetchedEntropySets;
std::vector<uint256> fetchedCommitSets;
@@ -504,18 +501,6 @@ struct FakeExtensions
fetchedExportSets.push_back(*hash);
}
bool
shouldSendExplicitFinalProposal() const
{
return sendExplicitFinal;
}
std::optional<FakeTxSet>
buildExplicitFinalProposalTxSet(FakeTxSet const&, LedgerIndex)
{
return explicitFinalTxSet;
}
bool
hasPendingExportSigs() const
{
@@ -1030,140 +1015,6 @@ class ConsensusExtensions_test : public beast::unit_test::suite
}
}
void
testExplicitFinalProposalTxSetBuildsEntropyTxn()
{
testcase("explicit final proposal tx set builds entropy txn");
auto extractSingleEntropyTx =
[](RCLTxSet const& set) -> std::shared_ptr<STTx const> {
std::vector<std::shared_ptr<STTx const>> txs;
set.map_->visitLeaves(
[&](boost::intrusive_ptr<SHAMapItem const> const& item) {
SerialIter sit(item->slice());
txs.push_back(std::make_shared<STTx const>(sit));
});
if (txs.size() != 1 ||
txs.front()->getTxnType() != ttCONSENSUS_ENTROPY)
return {};
return txs.front();
};
using namespace jtx;
Env env{
*this, envconfig(validator, ""), supported_amendments(), nullptr};
ConsensusExtensions ce{env.app(), activeNoopJournal()};
auto const base = makeRCLTxSet(env.app(), {});
auto const seq = env.closed()->seq() + 1;
auto synthetic = ce.buildExplicitFinalProposalTxSet(base, seq);
BEAST_EXPECT(synthetic);
if (!synthetic)
return;
auto const txPtr = extractSingleEntropyTx(*synthetic);
BEAST_EXPECT(txPtr);
if (!txPtr)
return;
auto const& tx = *txPtr;
BEAST_EXPECT(tx.getFieldU32(sfLedgerSequence) == seq);
BEAST_EXPECT(
tx.getFieldH256(sfDigest) ==
sha512Half(std::string("standalone-entropy"), seq));
BEAST_EXPECT(tx.getFieldU16(sfEntropyCount) == 20);
BEAST_EXPECT(
tx.getFieldU8(sfEntropyTier) == entropyTierValidatorQuorum);
auto duplicate = ce.buildExplicitFinalProposalTxSet(*synthetic, seq);
BEAST_EXPECT(duplicate);
if (duplicate)
BEAST_EXPECT(duplicate->id() == synthetic->id());
Env nonStandaloneEnv{
*this,
envconfig(validator, ""),
supported_amendments() | featureConsensusEntropy,
nullptr};
forceNonStandalone(nonStandaloneEnv.app());
auto const ledger =
nonStandaloneEnv.app().getLedgerMaster().getClosedLedger();
auto const nonStandaloneBase = makeRCLTxSet(nonStandaloneEnv.app(), {});
auto const nonStandaloneSeq = ledger->seq() + 1;
ConsensusExtensions zeroCe{nonStandaloneEnv.app(), activeNoopJournal()};
zeroCe.onRoundStart(RCLCxLedger{ledger}, {});
zeroCe.setEntropyFailed();
auto zeroSynthetic = zeroCe.buildExplicitFinalProposalTxSet(
nonStandaloneBase, nonStandaloneSeq);
BEAST_EXPECT(zeroSynthetic);
auto const zeroTx =
zeroSynthetic ? extractSingleEntropyTx(*zeroSynthetic) : nullptr;
BEAST_EXPECT(zeroTx);
if (zeroTx)
{
// Tier 1 consensus_fallback digest over (prevLedgerHash, base set,
// seq).
auto const expectedFallback = sha512Half(
HashPrefix::entropyFallback,
ledger->info().hash,
nonStandaloneBase.id(),
nonStandaloneSeq);
BEAST_EXPECT(zeroTx->getFieldH256(sfDigest) == expectedFallback);
BEAST_EXPECT(zeroTx->getFieldH256(sfDigest) != uint256{});
BEAST_EXPECT(zeroTx->getFieldU16(sfEntropyCount) == 0);
BEAST_EXPECT(
zeroTx->getFieldU8(sfEntropyTier) ==
entropyTierConsensusFallback);
}
auto const& valKeys = nonStandaloneEnv.app().getValidatorKeys();
BEAST_EXPECT(valKeys.keys);
if (!valKeys.keys)
return;
auto const& publicKey = valKeys.keys->publicKey;
auto const& secretKey = valKeys.keys->secretKey;
auto const nodeId = valKeys.nodeID;
auto const prevLedger = ledger->info().hash;
auto const closeTime = NetClock::time_point{NetClock::duration{654}};
auto const txSetHash = makeHash("explicit-final-nonzero-txset");
auto const reveal = makeHash("explicit-final-nonzero-reveal");
auto const viewLedger = makeUNLReportLedger(
nonStandaloneEnv, std::vector<PublicKey>{publicKey});
ConsensusExtensions revealCe{
nonStandaloneEnv.app(), activeNoopJournal()};
revealCe.cacheUNLReport(viewLedger);
harvestCommitReveal(
revealCe,
nodeId,
publicKey,
secretKey,
txSetHash,
nonStandaloneSeq,
closeTime,
prevLedger,
reveal);
revealCe.buildEntropySet(nonStandaloneSeq);
auto revealSynthetic = revealCe.buildExplicitFinalProposalTxSet(
nonStandaloneBase, nonStandaloneSeq);
BEAST_EXPECT(revealSynthetic);
auto const revealTx = revealSynthetic
? extractSingleEntropyTx(*revealSynthetic)
: nullptr;
BEAST_EXPECT(revealTx);
if (revealTx)
{
BEAST_EXPECT(
revealTx->getFieldH256(sfDigest) ==
expectedEntropy(publicKey, reveal));
BEAST_EXPECT(revealTx->getFieldU16(sfEntropyCount) == 1);
BEAST_EXPECT(
revealTx->getFieldU8(sfEntropyTier) ==
entropyTierValidatorQuorum);
}
}
void
testRuntimeConfigPolicyAccessors()
{
@@ -1175,20 +1026,15 @@ class ConsensusExtensions_test : public beast::unit_test::suite
ConsensusExtensions ce{env.app(), activeNoopJournal()};
BEAST_EXPECT(!ce.bootstrapFastStartEnabled());
BEAST_EXPECT(!ce.shouldSendExplicitFinalProposal());
ConfigVals cfg;
cfg.bootstrapFastStart = true;
cfg.explicitFinalProposal = true;
env.app().getRuntimeConfig().setConfig("*", cfg);
BEAST_EXPECT(ce.bootstrapFastStartEnabled());
BEAST_EXPECT(ce.shouldSendExplicitFinalProposal());
cfg.bootstrapFastStart = false;
cfg.explicitFinalProposal = false;
env.app().getRuntimeConfig().setConfig("*", cfg);
BEAST_EXPECT(!ce.bootstrapFastStartEnabled());
BEAST_EXPECT(!ce.shouldSendExplicitFinalProposal());
}
void
@@ -2684,38 +2530,6 @@ class ConsensusExtensions_test : public beast::unit_test::suite
BEAST_EXPECT(ext.fetchedEntropySets.size() == 1);
}
void
testRngExplicitFinalProposalPublishesSyntheticTxSet()
{
testcase("RNG explicit final proposal publishes synthetic tx set");
FakeExtensions ext;
ext.rngOn = true;
ext.exportOn = false;
ext.estState_ = EstablishState::ConvergingReveal;
ext.sendExplicitFinal = true;
ext.explicitFinalTxSet = FakeTxSet{makeHash("explicit-final-tx-set")};
ExportTickHarness harness;
auto const localHash = ext.entropyHash;
harness.position.entropySetHash = localHash;
ext.entropySetPublished_ = true;
ext.entropyPublishStart_ = harness.start;
harness.addEntropyPeer(1, localHash);
harness.addEntropyPeer(2, localHash);
harness.addEntropyPeer(3, localHash);
harness.addEntropyPeer(4, localHash);
auto result = harness.tick(ext, std::chrono::milliseconds{100});
BEAST_EXPECT(result.readyForAccept);
BEAST_EXPECT(ext.explicitFinalProposalSent_);
BEAST_EXPECT(
harness.position.txSetHash == ext.explicitFinalTxSet->hash);
BEAST_EXPECT(harness.position.entropySetHash == localHash);
BEAST_EXPECT(harness.updates == 1);
BEAST_EXPECT(harness.proposes == 1);
}
void
testExportSigGateAllowsAlignedQuorumDespiteMinorityConflict()
{
@@ -3177,7 +2991,6 @@ public:
testActiveValidatorViewAppliesNegativeUNL();
testActiveValidatorViewNullSourceAndExpectedProposers();
testParticipantThreshold();
testExplicitFinalProposalTxSetBuildsEntropyTxn();
testRuntimeConfigPolicyAccessors();
testDecoratePositionGeneratesCommitment();
testOnPreBuildInjectsZeroEntropyFallback();
@@ -3210,7 +3023,6 @@ public:
testRngEntropyConflictTimeoutClearsHash();
testRngEntropyConflictRefreshesHashBeforeWaiting();
testRngEntropyConflictIgnoredWithQuorumAlignment();
testRngExplicitFinalProposalPublishesSyntheticTxSet();
testExportSigGateAllowsAlignedQuorumDespiteMinorityConflict();
testExportSigGateAllowsQuorumDespiteMissingObservation();
testExportSigGateFetchesAdvertisedPeerSets();

View File

@@ -325,7 +325,6 @@ struct Peer
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};
@@ -965,16 +964,6 @@ struct Peer
return bootstrapFastStartEnabled_;
}
bool
shouldSendExplicitFinalProposal() const
{
return false;
}
std::optional<TxSet>
buildExplicitFinalProposalTxSet(TxSet const&, Ledger::Seq)
{
return std::nullopt;
}
bool
hasPendingExportSigs() const
{
return enableExportConsensus_ && !pendingExportSigs_.empty();
@@ -1010,7 +999,6 @@ struct Peer
estState_ = EstablishState::ConvergingTx;
revealPhaseStart_ = {};
commitHashConflictStart_ = {};
explicitFinalProposalSent_ = false;
entropySetPublished_ = false;
entropyPublishStart_ = {};
exportSigGateStarted_ = false;

View File

@@ -110,7 +110,6 @@ class RuntimeConfig_test : public beast::unit_test::suite
ConfigVals peer;
peer.sendDelayMs = 500;
peer.sendDelayJitterMs = 25;
peer.explicitFinalProposal = true;
peer.rngPollMs = 75;
peer.noExportSig = true;
peer.messageCategories = std::set<std::size_t>{};
@@ -122,8 +121,6 @@ class RuntimeConfig_test : public beast::unit_test::suite
BEAST_EXPECT(merged.rngClaimDropPctX100 == 750);
BEAST_EXPECT(merged.bootstrapFastStart.has_value());
BEAST_EXPECT(*merged.bootstrapFastStart == false);
BEAST_EXPECT(merged.explicitFinalProposal.has_value());
BEAST_EXPECT(*merged.explicitFinalProposal == true);
BEAST_EXPECT(merged.rngPollMs == 75);
BEAST_EXPECT(merged.noExportSig.has_value());
BEAST_EXPECT(*merged.noExportSig == true);
@@ -138,7 +135,7 @@ class RuntimeConfig_test : public beast::unit_test::suite
inactive.sendDropPctX100 = 0;
inactive.rngClaimDropPctX100 = 0;
BEAST_EXPECT(!inactive.active());
inactive.explicitFinalProposal = false;
inactive.bootstrapFastStart = false;
BEAST_EXPECT(inactive.active());
}
@@ -304,7 +301,6 @@ class RuntimeConfig_test : public beast::unit_test::suite
EnvVarGuard jitter{"XAHAU_SEND_DELAY_JITTER_MS", "3"};
EnvVarGuard drop{"XAHAU_SEND_DROP_PCT", "4.5"};
EnvVarGuard rngDrop{"XAHAU_RNG_CLAIM_DROP_PCT", "6.25"};
EnvVarGuard explicitFinal{"XAHAUD_EXPLICIT_FINAL_PROPOSAL", "off"};
EnvVarGuard bootstrap{"XAHAUD_BOOTSTRAP_FAST_START", "yes"};
EnvVarGuard rngPoll{"XAHAU_RNG_POLL_MS", "5"};
EnvVarGuard noExportSig{"XAHAUD_NO_EXPORT_SIG", "0"};
@@ -319,8 +315,6 @@ class RuntimeConfig_test : public beast::unit_test::suite
BEAST_EXPECT(cfg->sendDelayJitterMs == 3);
BEAST_EXPECT(cfg->sendDropPctX100 == 450);
BEAST_EXPECT(cfg->rngClaimDropPctX100 == 625);
BEAST_EXPECT(cfg->explicitFinalProposal.has_value());
BEAST_EXPECT(*cfg->explicitFinalProposal == false);
BEAST_EXPECT(cfg->bootstrapFastStart.has_value());
BEAST_EXPECT(*cfg->bootstrapFastStart == true);
BEAST_EXPECT(cfg->rngPollMs == 50);
@@ -337,12 +331,12 @@ class RuntimeConfig_test : public beast::unit_test::suite
"XAHAU_RUNTIME_CONFIG",
R"({"*":{"send_delay_ms":100,"send_delay_jitter_ms":20,)"
R"("send_drop_pct":1.25,"rng_claim_drop_pct":3.5,)"
R"("explicit_final_proposal":true,"bootstrap_fast_start":false,)"
R"("bootstrap_fast_start":false,)"
R"("rng_poll_ms":5,"no_export_sig":true,)"
R"("message_types":["proposal"]},)"
R"("10.0.0.5:51235":{"send_delay_ms":200,)"
R"("send_drop_pct":2.5,"rng_claim_drop_pct":4.5,)"
R"("explicit_final_proposal":false,"bootstrap_fast_start":true,)"
R"("bootstrap_fast_start":true,)"
R"("rng_poll_ms":125,"no_export_sig":false,)"
R"("message_types":[]}})"};
@@ -354,8 +348,6 @@ class RuntimeConfig_test : public beast::unit_test::suite
BEAST_EXPECT(global->sendDelayJitterMs == 20);
BEAST_EXPECT(global->sendDropPctX100 == 125);
BEAST_EXPECT(global->rngClaimDropPctX100 == 350);
BEAST_EXPECT(global->explicitFinalProposal.has_value());
BEAST_EXPECT(*global->explicitFinalProposal == true);
BEAST_EXPECT(global->bootstrapFastStart.has_value());
BEAST_EXPECT(*global->bootstrapFastStart == false);
BEAST_EXPECT(global->rngPollMs == 50);
@@ -371,8 +363,6 @@ class RuntimeConfig_test : public beast::unit_test::suite
BEAST_EXPECT(peer->sendDelayJitterMs == 20);
BEAST_EXPECT(peer->sendDropPctX100 == 250);
BEAST_EXPECT(peer->rngClaimDropPctX100 == 450);
BEAST_EXPECT(peer->explicitFinalProposal.has_value());
BEAST_EXPECT(*peer->explicitFinalProposal == false);
BEAST_EXPECT(peer->bootstrapFastStart.has_value());
BEAST_EXPECT(*peer->bootstrapFastStart == true);
BEAST_EXPECT(peer->rngPollMs == 125);
@@ -882,54 +872,6 @@ class RuntimeConfig_test : public beast::unit_test::suite
}
}
void
testExplicitFinalProposalToggle()
{
testcase("explicit_final_proposal round-trips and merges");
using namespace test::jtx;
Env env{*this};
// Global default for this node: skip explicit final proposal.
{
Json::Value params;
params["set"] = Json::objectValue;
params["set"]["*"] = Json::objectValue;
params["set"]["*"]["explicit_final_proposal"] = false;
auto result = runtimeConfig(env, params);
auto const& global = result["configs"]["*"];
BEAST_EXPECT(global["explicit_final_proposal"].asBool() == false);
}
auto& rc = env.app().getRuntimeConfig();
BEAST_EXPECT(rc.active());
// Global view is false.
auto globalCfg = rc.getConfig("*");
BEAST_EXPECT(globalCfg.has_value());
BEAST_EXPECT(globalCfg->explicitFinalProposal.has_value());
BEAST_EXPECT(*globalCfg->explicitFinalProposal == false);
// Per-peer override can re-enable.
{
Json::Value params;
params["set"] = Json::objectValue;
params["set"]["10.0.0.2:51235"] = Json::objectValue;
params["set"]["10.0.0.2:51235"]["explicit_final_proposal"] = true;
runtimeConfig(env, params);
}
auto peerCfg = rc.getConfig("10.0.0.2:51235");
BEAST_EXPECT(peerCfg.has_value());
BEAST_EXPECT(peerCfg->explicitFinalProposal.has_value());
BEAST_EXPECT(*peerCfg->explicitFinalProposal == true);
auto otherCfg = rc.getConfig("10.0.0.3:51235");
BEAST_EXPECT(otherCfg.has_value());
BEAST_EXPECT(otherCfg->explicitFinalProposal.has_value());
BEAST_EXPECT(*otherCfg->explicitFinalProposal == false);
}
void
testPerPeerClearInheritedFilter()
{
@@ -1001,7 +943,6 @@ public:
testRngClaimDropPct();
testRngClaimDropPctClamping();
testRngAndExportRuntimeToggles();
testExplicitFinalProposalToggle();
testPerPeerClearInheritedFilter();
}
};

View File

@@ -554,122 +554,6 @@ ConsensusExtensions::bootstrapFastStartEnabled() const
return false;
}
bool
ConsensusExtensions::shouldSendExplicitFinalProposal() const
{
// Explicit-final-proposal policy is node-local and experimental.
//
// Default behavior is implicit finalization (no extra seq=4 proposal):
// entropy pseudo-tx is injected in onAccept/buildLCL.
//
// We only enable explicit-final when operators intentionally opt in via
// runtime config/env for measurement/diagnostics.
//
// TODO: remove the explicit-final proposal path. The implicit accept-time
// injection path is the consensus path; explicit-final never found a robust
// timing model and still carries separate experimental-only alignment
// hazards. Do not promote this by just widening the gates.
auto const cfg = app_.getRuntimeConfig().getConfig("*");
if (cfg && cfg->explicitFinalProposal.has_value())
return *cfg->explicitFinalProposal;
return false;
}
std::optional<RCLTxSet>
ConsensusExtensions::buildExplicitFinalProposalTxSet(
RCLTxSet const& txns,
LedgerIndex seq)
{
JLOG(j_.debug()) << "RNGFINAL: build synthetic txSet"
<< " baseTxSet=" << txns.id() << " seq=" << seq
<< " commits=" << pendingCommits_.size()
<< " reveals=" << pendingReveals_.size()
<< " entropyFailed=" << (entropyFailed_ ? "yes" : "no");
// Shared deterministic selector over the AGREED entropySetMap_ — the same
// one onPreBuild uses — NOT local pendingReveals_, which can diverge from
// the agreed set at timeout boundaries. Routing explicit-final
// (experimental, default-off) through it keeps this path byte-identical to
// the implicit one. txns.id() is the BASE tx set hash for the fallback.
//
// TODO: delete this with the explicit-final proposal path; keep this helper
// only while the runtime-config experiment still exists.
auto const selection = selectEntropy(txns.id(), seq);
uint256 const finalEntropy = selection.digest;
std::uint8_t const entropyTier = selection.tier;
std::uint16_t const entropyCount = selection.count;
JLOG(j_.debug()) << "RNGFINAL: entropy selected"
<< " seq=" << seq
<< " tier=" << static_cast<int>(entropyTier)
<< " count=" << entropyCount << " digest=" << finalEntropy
<< " baseTxSet=" << txns.id();
STTx tx(ttCONSENSUS_ENTROPY, [&](auto& obj) {
obj.setFieldU32(sfLedgerSequence, seq);
obj.setAccountID(sfAccount, AccountID{});
obj.setFieldU32(sfSequence, 0);
obj.setFieldAmount(sfFee, STAmount{});
obj.setFieldH256(sfDigest, finalEntropy);
obj.setFieldU16(sfEntropyCount, entropyCount);
obj.setFieldU8(sfEntropyTier, entropyTier);
});
auto const txID = tx.getTransactionID();
// Value-based dedup (mirrors onPreBuild): there must never be two entropy
// pseudo-txs. If one is already in the base set it must be the EXACT
// pseudo-tx we would have produced (injection is deterministic, so the same
// agreed inputs yield an identical txID); a present-but-different one is a
// determinism violation to surface, not silently accept. Either way return
// the base unchanged — explicit-final is best-effort and must not rewrite
// an already-committed set.
std::optional<uint256> presentID;
txns.map_->visitLeaves(
[&](boost::intrusive_ptr<SHAMapItem const> const& item) {
if (presentID)
return;
try
{
SerialIter sit(item->slice());
STTx const parsed{sit};
if (parsed.getTxnType() == ttCONSENSUS_ENTROPY)
presentID = parsed.getTransactionID();
}
catch (...)
{
}
});
if (presentID)
{
if (*presentID == txID)
JLOG(j_.debug())
<< "RNGFINAL: entropy pseudo-tx already in base txSet"
<< " txHash=" << txID << " baseTxSet=" << txns.id()
<< " action=skip-duplicate-verified";
else
JLOG(j_.error())
<< "RNGFINAL: entropy pseudo-tx MISMATCH in base txSet"
<< " reason=determinism-violation action=keep-base"
<< " ourTxHash=" << txID << " presentTxHash=" << *presentID
<< " baseTxSet=" << txns.id();
return txns;
}
RCLTxSet::MutableTxSet mutableTxSet{txns};
Serializer ser(512);
tx.add(ser);
mutableTxSet.insert(RCLCxTx{make_shamapitem(txID, ser.slice())});
auto syntheticSet = RCLTxSet{mutableTxSet};
auto const hash = syntheticSet.id();
app_.getInboundTransactions().giveSet(hash, syntheticSet.map_, false);
JLOG(j_.debug()) << "RNGFINAL: built synthetic txSet"
<< " syntheticTxSet=" << hash << " baseTxSet=" << txns.id()
<< " txHash=" << txID << " entropyCount=" << entropyCount;
return syntheticSet;
}
uint256
ConsensusExtensions::buildCommitSet(LedgerIndex seq)
{
@@ -1866,11 +1750,9 @@ ConsensusExtensions::onPreBuild(
//@@start rng-inject-entropy-selection
// One deterministic selector over the AGREED entropySetMap_ chooses the
// digest and its tier/count. onPreBuild and buildExplicitFinalProposalTxSet
// share it, so neither the implicit vs explicit-final paths on one node nor
// two different nodes can derive different entropy for the same agreed
// round inputs. txSetHash is the BASE (pre-injection) consensus tx set
// hash.
// digest and its tier/count. Every node derives the same entropy for the
// same agreed round inputs. txSetHash is the BASE (pre-injection)
// consensus tx set hash.
auto const selection = selectEntropy(txSetHash, seq);
uint256 const finalEntropy = selection.digest;
std::uint8_t const entropyTier = selection.tier;
@@ -1898,11 +1780,6 @@ ConsensusExtensions::onPreBuild(
// agree on the base transaction set first, then deterministically
// derive/apply the entropy pseudo-tx for ledger construction.
//
// Explicit-final (seq=4 synthetic proposal) remains an optional
// experiment for observability/perf testing and is default-off.
// TBD (2026-03-03): revisit only with stronger evidence that explicit
// publication can be made stable under tx-bearing, lossy networks.
//@@start rng-inject-pseudotx-core
// Account Zero convention for pseudo-transactions (same as ttFEE, etc)
STTx tx(ttCONSENSUS_ENTROPY, [&](auto& obj) {
@@ -1917,13 +1794,13 @@ ConsensusExtensions::onPreBuild(
auto const txID = tx.getTransactionID();
// Value-based dedup. There must never be two entropy pseudo-txs, but
// when one is already present (explicit-final, or a peer's agreed
// set) it must be VALIDATED as the exact pseudo-tx we would have
// produced — not merely "same type". Injection is deterministic, so
// every honest node derives the identical pseudo-tx (identical txID)
// for the same agreed inputs. A present-but-different entropy pseudo-tx
// is therefore a determinism violation (version skew or a divergent/
// malicious peer) and must be surfaced, not silently trusted.
// when one is already present in the agreed set it must be VALIDATED as
// the exact pseudo-tx we would have produced — not merely "same type".
// Injection is deterministic, so every honest node derives the
// identical pseudo-tx (identical txID) for the same agreed inputs. A
// present-but-different entropy pseudo-tx is therefore a determinism
// violation (version skew or a divergent/malicious peer) and must be
// surfaced, not silently trusted.
auto const existing = std::find_if(
retriableTxs.begin(), retriableTxs.end(), [](auto const& entry) {
return entry.second->getTxnType() == ttCONSENSUS_ENTROPY;

View File

@@ -101,7 +101,6 @@ public:
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};
@@ -226,12 +225,6 @@ public:
bool
bootstrapFastStartEnabled() const;
bool
shouldSendExplicitFinalProposal() const;
std::optional<RCLTxSet>
buildExplicitFinalProposalTxSet(RCLTxSet const& txns, LedgerIndex seq);
uint256
buildCommitSet(LedgerIndex seq);
@@ -502,7 +495,6 @@ public:
estState_ = EstablishState::ConvergingTx;
revealPhaseStart_ = {};
commitHashConflictStart_ = {};
explicitFinalProposalSent_ = false;
entropySetPublished_ = false;
entropyPublishStart_ = {};
exportSigGateStarted_ = false;

View File

@@ -210,12 +210,6 @@ If no entropy hash reaches the entropy gate threshold before the bounded
deadline, the round must fall back to the Tier 1 consensus-bound digest. This
is the safe degradation path, not a consensus failure.
> Known exception: the experimental, default-off explicit-final proposal path
> counts alignment over the unfiltered proposer set (not the active view). It is
> flagged in-code as an outstanding F1 gap, but the intended disposition is
> removal rather than repair/promotion; it must not be enabled as a production
> consensus path.
Examples with six active validators on a UNLReport-anchored view (validator_quorum
threshold five, participant_aligned threshold four; six is the smallest view with
a non-empty Tier 2 band and non-zero tolerated Byzantine count; at five validators
@@ -253,10 +247,10 @@ after it updates the SLE. Hooks that need final entropy must treat open-ledger
RNG results as previews.
The fallback digest derives from the BASE (pre-injection) tx set hash to avoid
circularity, and entropy pseudo-tx deduplication is value-based: if an
explicit-final synthetic set already contains the exact pseudo-tx, injection
skips it; a present-but-different pseudo-tx is logged as a determinism
violation and left in the agreed set.
circularity, and entropy pseudo-tx deduplication is value-based: if the agreed
set already contains the exact pseudo-tx, injection skips it; a
present-but-different pseudo-tx is logged as a determinism violation and left in
the agreed set.
## Sidecar Convergence Rules

View File

@@ -41,13 +41,6 @@ struct ConfigVals
std::optional<int> sendDelayJitterMs;
std::optional<int> sendDropPctX100; // 0-10000 (pct * 100, avoids float)
std::optional<int> rngClaimDropPctX100; // 0-10000 (pct * 100)
// Controls explicit final proposal broadcast in the RNG reveal phase.
// true = attempt explicit-final proposal (experimental)
// false = keep implicit mode (recommended default for production)
//
// NOTE: This knob is intentionally explicit opt-in. The consensus system
// is fully functional without it via accept-time pseudo-tx injection.
std::optional<bool> explicitFinalProposal;
// Bootstrap fast start: seed prevRoundTime_ to 3s instead of 15s on first
// round, auto-disables after stable quorum is observed.
std::optional<bool> bootstrapFastStart;
@@ -76,7 +69,6 @@ struct ConfigVals
(sendDelayJitterMs && *sendDelayJitterMs > 0) ||
(sendDropPctX100 && *sendDropPctX100 > 0) ||
(rngClaimDropPctX100 && *rngClaimDropPctX100 > 0) ||
explicitFinalProposal.has_value() ||
bootstrapFastStart.has_value() || rngPollMs.has_value() ||
noExportSig.has_value();
}
@@ -94,8 +86,6 @@ struct ConfigVals
result.sendDropPctX100 = other.sendDropPctX100;
if (other.rngClaimDropPctX100)
result.rngClaimDropPctX100 = other.rngClaimDropPctX100;
if (other.explicitFinalProposal.has_value())
result.explicitFinalProposal = other.explicitFinalProposal;
if (other.bootstrapFastStart.has_value())
result.bootstrapFastStart = other.bootstrapFastStart;
if (other.rngPollMs)

View File

@@ -257,8 +257,6 @@ parseConfigVals(Json::Value const& v)
if (v.isMember("rng_claim_drop_pct"))
cfg.rngClaimDropPctX100 =
static_cast<int>(v["rng_claim_drop_pct"].asDouble() * 100);
if (v.isMember("explicit_final_proposal"))
cfg.explicitFinalProposal = v["explicit_final_proposal"].asBool();
if (v.isMember("bootstrap_fast_start"))
cfg.bootstrapFastStart = v["bootstrap_fast_start"].asBool();
if (v.isMember("rng_poll_ms"))
@@ -346,11 +344,6 @@ RuntimeConfig::RuntimeConfig()
global.sendDropPctX100 = static_cast<int>(std::atof(env) * 100);
if (auto const* env = std::getenv("XAHAU_RNG_CLAIM_DROP_PCT"))
global.rngClaimDropPctX100 = static_cast<int>(std::atof(env) * 100);
// Explicit-final proposal is intentionally opt-in and defaults to
// implicit behavior when unset.
if (auto parsed =
parseBoolEnv(std::getenv("XAHAUD_EXPLICIT_FINAL_PROPOSAL")))
global.explicitFinalProposal = *parsed;
if (auto parsed = parseBoolEnv(std::getenv("XAHAUD_BOOTSTRAP_FAST_START")))
global.bootstrapFastStart = *parsed;
if (auto const* env = std::getenv("XAHAU_RNG_POLL_MS"))

View File

@@ -188,8 +188,6 @@ extensionsTick(Ext& ext, Ctx const& ctx)
<< " participants=" << participants
<< " peerPositions=" << ctx.peerPositions.size()
<< " prevProposers=" << ctx.prevProposers
<< " explicitFinalSent="
<< (ext.explicitFinalProposalSent_ ? "yes" : "no")
<< " closeTimeConsensus="
<< (ctx.haveCloseTimeConsensus ? "yes" : "no")
<< " txSet=" << ourPos;
@@ -906,193 +904,6 @@ extensionsTick(Ext& ext, Ctx const& ctx)
<< (entropyState.conflict ? "yes" : "no");
}
}
// Optional explicit final proposal (seq=4 style):
// publish a synthetic tx-set hash that includes the
// consensus-entropy pseudo-tx just before accept.
//
// IMPORTANT DESIGN NOTE (read before editing this block):
//
// This path is intentionally OPTIONAL and default-off. It
// exists for diagnostics/perf experiments (for example, making
// monitor visibility of the final pseudo-tx set more direct),
// NOT as a required step for consensus correctness.
//
// Why so conservative?
// - The main consensus engine still keys agreement on tx-set
// hash.
// - Updating our tx-set hash here creates a "late identity
// change" in establish.
// - Under lossy/reordered networks, peers can be slightly out
// of
// phase: some nodes may have switched to the synthetic hash
// while others are still on the base hash.
// - That can fragment agreement during a critical window (two
// hashes in flight for one ledger), increase proposal
// chatter, and trigger sync churn.
//
// Therefore this logic must remain best-effort only:
// - Never required for liveness/safety.
// - No extra wait tick is introduced.
// - If gates are not met, we skip and continue to accept via
// the
// normal implicit path (accept-time pseudo-tx injection).
//
// TODO: remove this explicit-final proposal path. We did not find
// a robust timing model that folds it into a guaranteed-safe
// explicit final proposal across lossy/reordered links without
// increasing churn. It remains opt-in only until deleted.
{
bool fullParticipantCoverage = false;
bool entropyAligned = false;
{
// Guard against "early switch" churn:
// require at least as many participants as the previous
// round before attempting the explicit-final mutation.
//
// This is a heuristic to reduce risk, not a proof of
// safety. We still keep the feature
// optional/default-off.
//
// OUTSTANDING (F1): unlike the main entropy gate
// (inspectTxConvergedSidecarPeers), this explicit-final
// participant/alignment count is over the UNFILTERED
// trusted-proposer set (ctx.peerPositions) plus an
// unconditional local +1 -- NOT the active validator view.
// A trusted-but-non-active proposer can pad both
// `participants` and `alignedPeers`, inflating the counting
// universe above originalViewSize and eroding the same
// Tier-2 intersection margin that the F1 fix closed for the
// main gate. Before this path is EVER enabled it must
// filter peers through activeValidatorView()->containsNode
// and gate the local +1 on local active-view membership
// (mirror inspectTxConvergedSidecarPeers) if this code
// survives long enough to be touched. Preferred disposition
// is deletion, not promotion.
auto const participants = ctx.peerPositions.size() + 1;
auto const expectedParticipants = ctx.prevProposers + 1;
fullParticipantCoverage =
participants >= expectedParticipants;
// Require a majority aligned on entropySetHash before
// mutating tx-set hash. If this threshold is loosened,
// the probability of hash fragmentation rises quickly.
auto const requiredEntropyAligned =
(expectedParticipants / 2) + 1;
auto const ourPos = ctx.getPosition();
if (ourPos.entropySetHash)
{
auto const expectedEntropy = *ourPos.entropySetHash;
std::size_t alignedPeers = 0;
bool conflict = false;
for (auto const& [_, peerPos] : ctx.peerPositions)
{
auto const& peerPosition =
peerPos.proposal().position();
if (!peerPosition.entropySetHash)
continue;
if (*peerPosition.entropySetHash == expectedEntropy)
{
++alignedPeers;
continue;
}
conflict = true;
break;
}
auto const alignedParticipants = alignedPeers + 1;
entropyAligned = !conflict &&
alignedParticipants >= requiredEntropyAligned;
if (!entropyAligned)
{
JLOG(ext.j_.debug())
<< "RNG: explicit-final entropy alignment "
"insufficient"
<< " buildSeq=" << buildSeq
<< " alignedParticipants="
<< alignedParticipants
<< " required=" << requiredEntropyAligned
<< " conflict=" << (conflict ? "yes" : "no");
}
}
else
{
JLOG(ext.j_.debug())
<< "RNG: explicit-final waiting"
<< " reason=missing-local-entropySetHash"
<< " buildSeq=" << buildSeq;
}
}
if (ctx.mode == ConsensusMode::proposing &&
!ext.explicitFinalProposalSent_ &&
ext.hasQuorumOfCommits() && revealConsensus &&
fullParticipantCoverage && entropyAligned &&
ext.shouldSendExplicitFinalProposal())
{
// One-shot per round. This avoids repeated mutations/
// broadcasts from timer ticks, which can amplify
// network chatter in the exact conditions
// (loss/reordering) where this path is already fragile.
auto const synthSet = ext.buildExplicitFinalProposalTxSet(
ctx.getTxns(), buildSeq);
ext.explicitFinalProposalSent_ = true;
if (synthSet)
{
auto const synthHash = synthSet->id();
auto currentPos = ctx.getPosition();
auto newPos = currentPos;
newPos.updateTxSet(synthHash);
if (!(newPos == currentPos))
{
// WARNING:
// This changes proposal tx-set identity late in
// establish. Keep this path tightly gated and
// optional. The canonical ledger path remains
// the implicit accept-time injection logic.
// Maintain the invariant that our active
// position's tx-set hash is present in
// acquired_, otherwise gotTxSet can assert if
// this set arrives back from the network.
ctx.cacheAndShareTxSet(*synthSet);
JLOG(ext.j_.debug())
<< "RNG: cached explicit-final txSet"
<< " buildSeq=" << buildSeq
<< " txSet=" << synthHash;
ctx.updatePosition(newPos);
ctx.propose();
JLOG(ext.j_.debug())
<< "RNG: explicit final proposal"
<< " buildSeq=" << buildSeq
<< " txSet=" << synthHash;
logRngDiag("rng-explicit-final-proposed");
}
}
}
else
{
char const* reason = "disabled";
if (ctx.mode != ConsensusMode::proposing)
reason = "not-proposing";
else if (ext.explicitFinalProposalSent_)
reason = "already-sent";
else if (!ext.hasQuorumOfCommits())
reason = "no-commit-quorum";
else if (!revealConsensus)
reason = "reveal-timeout";
else if (!fullParticipantCoverage)
reason = "participant-gap";
else if (!entropyAligned)
reason = "entropy-not-aligned";
JLOG(ext.j_.debug())
<< "STALLDIAG: rng-explicit-final-skipped"
<< " reason=" << reason << " buildSeq=" << buildSeq
<< " mode=" << to_string(ctx.mode) << " sent="
<< (ext.explicitFinalProposalSent_ ? "yes" : "no");
}
}
}
}
else

View File

@@ -62,9 +62,6 @@ doRuntimeConfig(RPC::JsonContext& context)
pct = 100.0;
cfg.rngClaimDropPctX100 = static_cast<int>(pct * 100);
}
if (v.isMember("explicit_final_proposal"))
cfg.explicitFinalProposal =
v["explicit_final_proposal"].asBool();
if (v.isMember("bootstrap_fast_start"))
cfg.bootstrapFastStart = v["bootstrap_fast_start"].asBool();
if (v.isMember("rng_poll_ms"))
@@ -131,8 +128,6 @@ doRuntimeConfig(RPC::JsonContext& context)
entry["send_drop_pct"] = *cfg.sendDropPctX100 / 100.0;
if (cfg.rngClaimDropPctX100)
entry["rng_claim_drop_pct"] = *cfg.rngClaimDropPctX100 / 100.0;
if (cfg.explicitFinalProposal.has_value())
entry["explicit_final_proposal"] = *cfg.explicitFinalProposal;
if (cfg.bootstrapFastStart.has_value())
entry["bootstrap_fast_start"] = *cfg.bootstrapFastStart;
if (cfg.rngPollMs)