Skip to content

feat: featureExport / featureConsensusEntropy#693

Draft
sublimator wants to merge 323 commits into
devfrom
feature-export-rng
Draft

feat: featureExport / featureConsensusEntropy#693
sublimator wants to merge 323 commits into
devfrom
feature-export-rng

Conversation

@sublimator

@sublimator sublimator commented Feb 21, 2026

Copy link
Copy Markdown
Collaborator

Last updated: 2026-06-17 | branch: feature-export-rng | commit: c8fad50 (Merge origin/dev into feature-export-rng)


featureExport + featureConsensusEntropy: Cross-Chain Exports + Decentralized Secure Randomness

This PR introduces two features under separate amendment flags:

  • featureExport — Cross-chain transaction export: hooks or users create transactions signed by the network's validators for use on external chains
  • featureConsensusEntropy — Consensus-derived randomness: deterministic, manipulation-resistant entropy available to hooks via dice() and random()

Both share validator-set infrastructure and quorum calculation, but are independently amendment-gated.


Amendment Relationship

The two amendments are independent but complementary:

Configuration Export Behavior RNG Behavior
Neither Disabled Disabled
CE only Disabled Full commit/reveal/entropy pipeline
Export only Works with 100% quorum (unanimity) Disabled
CE + Export Works with 80% quorum + SHAMap convergence Full pipeline + export sig convergence in parallel

Neither blocks the other: CE's RNG sub-states and Export's sig convergence run in parallel during the establish phase. Both check their own gates independently and fall back gracefully if they can't converge.

The quorum rationale behind this matrix is covered once, in Tiered Quorum below.


UNL Source and Fallback Behavior

RNG uses UNLReport ActiveValidators (UNLReport.sfActiveValidators) as the canonical validator set for non-fallback entropy labels.

The validator view has two modes:

  • UNLReport-backed view: used for validator_quorum and participant_aligned RNG labels. Thresholds are ledger-anchored; the tier-2 floor is anchored to the original pre-NegativeUNL report size.
  • Trusted-config fallback view: used only as an early/test/dev collection proxy. A non-standalone node without a usable UNLReport still mints consensus_fallback entropy, even on a healthy local testnet, because local validator configuration is not a cross-node deterministic tier-label authority.
  • Export uses the same cached active-validator view for proposal ingress, sidecar merge, quorum sizing, and final verification. That view is pinned to the consensus parent ledger and falls back to configured trusted validators when no report is available.

Part 1: Cross-Chain Transaction Export

What This Feature Does

This feature enables cross-chain transaction export — allowing a hook or user on Xahau to create a transaction that Xahau's validators collectively sign. The resulting multisigned transaction is a normal, valid transaction on XRPL — no protocol changes or cooperation from XRPL are required.

How it works from XRPL's perspective

An account on XRPL is set up with a SignerList pointing to Xahau validator keys. When Xahau's validators sign an exported transaction, they're effectively acting as members of that SignerList. Once enough signatures are collected, the transaction can be submitted to XRPL as a standard multi-signed transaction. XRPL doesn't know or care that the signatures came from Xahau.

This design requires zero XRPL-side changes, no XLS specification, and no amendment on XRPL.

How it works from Xahau's perspective

A hook calls xport() or a user submits a ttEXPORT transaction (type 91). The transaction enters the open ledger (tesSUCCESS provisional), and validators attach their multisign signatures to consensus proposals. When enough validators have signed (quorum met), the export succeeds with sfExportResult in the transaction metadata — containing the fully multisigned transaction as sfExportedTxn (readable JSON, ready for raw submission to XRPL). If quorum isn't reached before LastLedgerSequence, the export expires with tecEXPORT_EXPIRED.

In standalone mode (unit tests / dev), Export::doApply signs directly with the node's own validator keys — no consensus proposals needed.

Exported transactions must use TicketSequence (with Sequence=0) because a bounced transaction on the destination chain would jam sequential sequence numbers. A NetworkID guard rejects exports targeting the local network or from unconfigured nodes.


Export Architecture

Exports use a retriable transaction pattern with proposal-based signature collection:

  1. User or hook submits ttEXPORT → enters the open ledger with tesSUCCESS (provisional, consumes sequence + fee)
  2. Validators sign via proposals → each validator extracts the inner tx (sfExportedTxn), computes its multisign signature (buildMultiSigningData + sign), and attaches txHash(32) + pubkey(33) + signature(~72) to TMProposeSet.exportSignatures (deduplicated per round via markSent())
  3. Peers harvest sigs → trusted proposals are routed to ConsensusExtensions::onTrustedPeerMessage(), sender-bound to the proposal key, and stored in ExportSigCollector
  4. Closed ledger: quorum check → Export transactor checks sig count against threshold
    • Quorum met → builds the fully multisigned tx (Signers array, empty SigningPubKey), stores it as sfExportedTxn inside sfExportResult metadata, creates shadow ticket keyed by the signed tx hash (getHash(HashPrefix::transactionID) — includes Signers)
    • Not enough sigs, before LLSterRETRY_EXPORT (retained for next ledger, no fee/sequence consumed)
    • Not enough sigs, on LLS ledgertecEXPORT_EXPIRED (sequence consumed, clean failure — prevents tefMAX_LEDGER silently dropping the tx on the next ledger)

Only the final result touches the ledger. No intermediate signature accumulation. The multisigned blob in metadata is ready for raw submission to XRPL. On a healthy network an export typically completes in the same ledger it was submitted in.


Tiered Quorum: Safety Without Complexity

Export quorum adapts based on whether ConsensusEntropy is also enabled.

Without CE, export signature collection is ephemeral: different validators may see different sig counts at ledger close, and an 80% threshold could cause validators to flip between success and retry across ledger boundaries — acceptable but not ideal. Unanimity avoids that churn entirely, at the cost of any missing validator blocking the export.

With CE, Export piggybacks on CE's ExtendedPosition serialization, sub-state machine, and SHAMap fetch/merge infrastructure to converge on a shared sig set before closing the ledger. All validators agree on exactly which exports have quorum, enabling the standard 80% threshold.

Configuration Quorum Mechanism Rationale
Export only 100% (unanimity) Ephemeral ExportSigCollector Without CE's convergence infra, 80% could cause validators to flip between success/retry across ledger boundaries. Unanimity avoids this churn.
Export + CE 80% (calculateQuorumThreshold) SHAMap-based sig set convergence CE provides ExtendedPosition, sub-state machine, and SHAMap fetch/merge for deterministic agreement
Standalone Auto-sign Export::doApply signs directly with the node's validator keys Unit tests / dev — no consensus needed

SHAMap Convergence (with CE)

When CE is enabled, export sigs converge using the same infrastructure as RNG commit/reveal:

  • exportSigSetHash added to ExtendedPosition (flag 0x10)
  • Export sig sets are unbacked SHAMapType::SIDECAR maps
  • Entries are STObject(sfGeneric) sidecars tagged with sfSidecarType=sidecarExportSig
  • Sidecar leaves use tnSIDECAR / wireTypeSidecar, are content-addressed with HashPrefix::sidecar, and are keyed by sidecar.getHash(HashPrefix::sidecar)
  • sfSidecarType is the payload discriminator for export sigs, RNG commits, and RNG reveals; the hash prefix stays generic to the sidecar leaf class instead of splitting by payload kind
  • Convergence gate runs in parallel with RNG sub-states (independent, neither blocks the other)
  • If sigs can't converge before timeout, exports retry next round (graceful fallback)

Signature Collection

Proposal Attachment

Each validator scans the open ledger for ttEXPORT txns, extracts the inner tx (sfExportedTxn), computes its multisign signature via buildMultiSigningData(), and attaches txHash + pubkey + signature to the proposal. Gated on featureExport, deduplicated per round via markSent().

Proposal attachment code (ConsensusExtensions.cpp)

📍 src/xrpld/app/consensus/ConsensusExtensions.cpp:2336-2450

2336 void
2337 ConsensusExtensions::attachExportSignatures(
2338     protocol::TMProposeSet& prop,
2339     RCLCxPeerPos::Proposal const& proposal)
2340 {
2341     auto const& valKeys = app_.getValidatorKeys();
2342 
2343     if (!exportEnabled())
2344         return;
2345 
2346     // Attach export signatures for any ttEXPORT txns in the open ledger.
2347     // Gated on featureExport amendment.
2348     // RuntimeConfig no_export_sig disables sig attachment (testing sub-quorum).
2349     {
2350         auto& rc = app_.getRuntimeConfig();
2351         if (rc.active())
2352         {
2353             if (auto cfg = rc.getConfig("*"))
2354             {
2355                 if (cfg->noExportSig && *cfg->noExportSig)
2356                 {
2357                     JLOG(j_.debug()) << "Export: skipping proposal signatures"
2358                                      << " reason=runtime-config-noExportSig";
2359                     return;
2360                 }
2361             }
2362         }
2363     }
2364 
2365     auto const openLedger = app_.openLedger().current();
2366     if (!openLedger || !openLedger->rules().enabled(featureExport))
2367         return;
2368 
2369     auto const& valPK = valKeys.keys->publicKey;
2370     auto const& valSK = valKeys.keys->secretKey;
2371     // A locally configured validator may be trusted but not active for this
2372     // round; only active validators should advertise export signatures.
2373     if (!isActiveValidator(valPK))
2374         return;
2375 
2376     auto const signerAcctID = calcAccountID(valPK);
2377     std::uint8_t attached = 0;
2378 
2379     for (auto const& [stx, meta] : openLedger->txs)
2380     {
2381         if (!stx || stx->getTxnType() != ttEXPORT)
2382             continue;
2383 
2384         if (attached >= ExportLimits::maxPendingExports)
2385         {
2386             JLOG(j_.debug())
2387                 << "Export: proposal signature attachment cap reached"
2388                 << " max=" << +ExportLimits::maxPendingExports
2389                 << " openLedgerSeq=" << openLedger->info().seq;
2390             break;
2391         }
2392 
2393         auto const txHash = stx->getTransactionID();
2394 
2395         // Only attach our sig on the first proposal this round.
2396         if (!exportSigCollector_.markSent(txHash))
2397             continue;
2398 
2399         //@@start export-compute-proposal-sig
2400         Buffer sigBuf;
2401         if (stx->isFieldPresent(sfExportedTxn))
2402         {
2403             auto const& exportedObj = const_cast<STTx&>(*stx)
2404                                           .peekAtField(sfExportedTxn)
2405                                           .downcast<STObject>();
2406 
2407             Serializer innerSer;
2408             exportedObj.add(innerSer);
2409             SerialIter sit(innerSer.slice());
2410 
2411             try
2412             {
2413                 STTx innerTx(std::ref(sit));
2414                 auto sigData = buildMultiSigningData(innerTx, signerAcctID);
2415                 sigBuf = sign(valPK, valSK, sigData.slice());
2416             }
2417             catch (std::exception const& e)
2418             {
2419                 JLOG(j_.warn()) << "Export: failed to sign inner tx"
2420                                 << " txHash=" << txHash
2421                                 << " openLedgerSeq=" << openLedger->info().seq
2422                                 << " error=" << e.what();
2423             }
2424         }
2425         //@@end export-compute-proposal-sig
2426 
2427         //@@start export-attach-wire-sigs
2428         Serializer s;
2429         s.addBitString(txHash);
2430         s.addRaw(valPK.slice());
2431         if (sigBuf.size() > 0)
2432             s.addRaw(Slice(sigBuf.data(), sigBuf.size()));
2433         prop.add_exportsignatures(s.peekData().data(), s.peekData().size());
2434         ++attached;
2435         //@@end export-attach-wire-sigs
2436 
2437         // Only store if we actually produced a signature.
2438         // sigBuf is empty if the inner tx failed to deserialize.
2439         if (sigBuf.size() > 0)
2440             exportSigCollector_.addVerifiedSignature(
2441                 txHash, valPK, sigBuf, openLedger->info().seq);
2442 
2443         JLOG(j_.debug()) << "Export: attached proposal signature"
2444                          << " txHash=" << txHash
2445                          << " signer=" << calcNodeID(valPK)
2446                          << " openLedgerSeq=" << openLedger->info().seq
2447                          << " sigLen=" << sigBuf.size()
2448                          << " attached=" << +attached;
2449     }
2450 }

Sig Harvesting

PeerImp verifies and routes trusted proposal messages; export signature parsing and storage happen in ConsensusExtensions::onTrustedPeerMessage(). Each wire entry is variable-length: txHash(32) + pubkey(33) + signature(~72). The embedded pubkey must match the proposal sender, and proposal-level trust is checked before any signature is stored.

When the matching ttEXPORT is present in the open ledger, the multisign signature is verified immediately and stored as verified. If relay ordering means the transaction is not available yet, the signature is stored as unverified and can be upgraded later by the transactor path after reconstructing the inner transaction.

ExportSigCollector

Thread-safe collector (ExportSigCollector.h) with stale cleanup (256-ledger timeout). It separates verified signatures from unverified proposal-ingress signatures. Only verified signatures count toward quorum, appear in sidecar SHAMaps, and are assembled into the multisigned transaction. Key methods:

  • addVerifiedSignature(txHash, pubkey, signature) — stores a cryptographically verified multisign signature
  • addUnverifiedSignature(txHash, pubkey, signature) — stores a trusted but not-yet-verified proposal signature
  • upgradeSignature(txHash, pubkey, verifiedBuf) — promotes a matching unverified signature after verification
  • checkQuorumAndSnapshot(txHash, threshold) — atomically checks verified quorum and returns the verified buffers
  • snapshotWithSigs() — returns verified signatures for sidecar-set construction
  • markSent(txHash) — deduplicates per consensus round
  • cleanupStale(ledgerSeq) — removes entries older than 256 ledgers

Export Transactor

The transactor has three outcomes on the closed ledger:

  1. Standalone: skips quorum check, signs directly with the node's validator keys
  2. Quorum met: assembles the Signers array, builds the multisigned blob, computes the signed tx hash, creates the shadow ticket, writes everything to sfExportResult metadata
  3. Quorum not met: terRETRY_EXPORT (before LLS) or tecEXPORT_EXPIRED (on the LLS ledger — last chance, sequence consumed cleanly)

Key detail: the shadow ticket stores getHash(HashPrefix::transactionID) of the multisigned blob (which includes the Signers array), not the unsigned inner tx hash. This ensures the hash matches what ends up in the XPOP when the tx executes on XRPL.

Export::doApply (full source)

📍 src/xrpld/app/tx/detail/Export.cpp:89-295

  89 TER
  90 Export::doApply()
  91 {
  92     auto const account = ctx_.tx.getAccountID(sfAccount);
  93 
  94     // --- Shadow ticket cancel path (mutually exclusive with export) ---
  95     if (ctx_.tx.isFieldPresent(sfCancelTicketSequence))
  96     {
  97         auto const ticketSeq = ctx_.tx.getFieldU32(sfCancelTicketSequence);
  98         return ExportLedgerOps::cancelShadowTicket(
  99             view(), account, ticketSeq, j_);
 100     }
 101 
 102     // --- Export path ---
 103 
 104     auto const txId = ctx_.tx.getTransactionID();
 105     auto const currentSeq = view().info().seq;
 106 
 107     // Open ledger: return tesSUCCESS to consume sequence + fee and
 108     // get the transaction relayed/broadcast to all validators.
 109     if (view().open())
 110     {
 111         JLOG(j_.info()) << "Export: open ledger apply"
 112                         << " ledgerSeq=" << currentSeq << " txHash=" << txId
 113                         << " result=tesSUCCESS"
 114                         << " provisional=yes";
 115         return tesSUCCESS;
 116     }
 117 
 118     auto& consensusExtensions = ctx_.app.getConsensusExtensions();
 119     auto const parentLedger =
 120         ctx_.app.getLedgerMaster().getLedgerByHash(view().info().parentHash);
 121     auto const validatorView =
 122         consensusExtensions.makeActiveValidatorView(parentLedger);
 123     auto const isActiveSigner = [&consensusExtensions,
 124                                  validatorView](PublicKey const& key) {
 125         return consensusExtensions.isActiveValidator(key, *validatorView);
 126     };
 127     // Closed-ledger export builds a local parent-ledger validator view, not the
 128     // mutable apply view or cached RNG state, so apply order cannot move
 129     // quorum.
 130     auto const unlSize = validatorView->size();
 131 
 132     // Standalone mode: no consensus running, so we skip the quorum
 133     // check and sign directly with our validator keys in the blob
 134     // assembly step below.
 135     //
 136     // Network mode: active-view 80% quorum. Export-only rounds are still
 137     // deterministic because exportSigSetHash is signed in ExtendedPosition and
 138     // converged before closed-ledger apply can use the signatures.
 139     // Deserialize the inner tx early — needed both for the upgrade
 140     // pass (verify unverified sigs) and for blob assembly.
 141     auto const& exportedObj =
 142         ctx_.tx.peekAtField(sfExportedTxn).downcast<STObject>();
 143 
 144     Serializer innerSer;
 145     exportedObj.add(innerSer);
 146     SerialIter sit(innerSer.slice());
 147 
 148     STTx innerTx(std::ref(sit));
 149 
 150     auto upgradeUnverifiedForNextRound = [&]() {
 151         if (ctx_.app.config().standalone())
 152             return;
 153 
 154         // Closed-ledger apply must not create new current-round quorum
 155         // material. These upgrades are retained for a retrying export, where
 156         // the sidecar alignment gate can publish and converge them first.
 157         // Upgrade only active-view signatures; inactive trusted signatures may
 158         // stay cached, but they must not become quorum material.
 159         ExportSignatureUpgrader::upgradeUnverifiedSignatures(
 160             consensusExtensions.exportSigCollector(),
 161             innerTx,
 162             txId,
 163             currentSeq,
 164             isActiveSigner,
 165             j_);
 166     };
 167 
 168     // Atomic quorum check + snapshot for network mode.
 169     // Only verified signatures count toward quorum and appear
 170     // in the snapshot.
 171     std::optional<std::map<PublicKey, Buffer>> collectedSigs;
 172 
 173     if (!ctx_.app.config().standalone())
 174     {
 175         std::size_t const threshold =
 176             unlSize == 0 ? 1 : calculateQuorumThreshold(unlSize);
 177 
 178         // The collector may contain old trusted signatures; quorum counts only
 179         // signatures whose keys resolve into the same frozen active view.
 180         if (!consensusExtensions.exportSigConvergenceFailed())
 181         {
 182             collectedSigs =
 183                 consensusExtensions.exportSigCollector().checkQuorumAndSnapshot(
 184                     txId, threshold, isActiveSigner);
 185         }
 186 
 187         if (!collectedSigs)
 188         {
 189             auto const sigCount =
 190                 consensusExtensions.exportSigCollector().signatureCount(
 191                     txId, isActiveSigner);
 192             // LLS semantics for retriable exports:
 193             //
 194             // Transactor::preclaim rejects with tefMAX_LEDGER when
 195             // seq > LLS, so this tx can never run past ledger LLS.
 196             // Within that window the export has three possible outcomes
 197             // each ledger:
 198             //
 199             //   ledger < LLS:  tesSUCCESS (quorum) or terRETRY_EXPORT
 200             //   ledger == LLS: tesSUCCESS (quorum) or tecEXPORT_EXPIRED
 201             //   ledger > LLS:  tefMAX_LEDGER (never reaches doApply)
 202             //
 203             // The >= check here only fires in the no-quorum branch, so
 204             // if quorum IS met on the LLS ledger it still succeeds.
 205             // tecEXPORT_EXPIRED consumes the sequence cleanly rather
 206             // than letting tefMAX_LEDGER silently drop the tx.
 207             if (ctx_.tx.isFieldPresent(sfLastLedgerSequence))
 208             {
 209                 auto const lls = ctx_.tx.getFieldU32(sfLastLedgerSequence);
 210                 if (currentSeq >= lls)
 211                 {
 212                     ctx_.app.getConsensusExtensions()
 213                         .exportSigCollector()
 214                         .clear(txId);
 215                     JLOG(j_.info())
 216                         << "Export: last ledger expired"
 217                         << " txHash=" << txId << " ledgerSeq=" << currentSeq
 218                         << " lastLedgerSequence=" << lls << " sigs=" << sigCount
 219                         << " threshold=" << threshold << " unlSize=" << unlSize
 220                         << " result=tecEXPORT_EXPIRED";
 221                     return tecEXPORT_EXPIRED;
 222                 }
 223             }
 224 
 225             upgradeUnverifiedForNextRound();
 226 
 227             JLOG(j_.info())
 228                 << "Export: insufficient signatures"
 229                 << " txHash=" << txId << " ledgerSeq=" << currentSeq
 230                 << " sigs=" << sigCount << " threshold=" << threshold
 231                 << " unlSize=" << unlSize << " exportSigConvergenceFailed="
 232                 << (consensusExtensions.exportSigConvergenceFailed() ? "yes"
 233                                                                      : "no")
 234                 << " result=terRETRY_EXPORT";
 235             return terRETRY_EXPORT;
 236         }
 237     }
 238 
 239     ExportResultBuilder::SignatureSnapshot signatures;
 240     if (ctx_.app.config().standalone())
 241     {
 242         // Standalone mode: no consensus proposals, so we sign
 243         // the inner tx directly with our own validator keys.
 244         auto const& valKeys = ctx_.app.getValidatorKeys();
 245         if (valKeys.keys)
 246         {
 247             auto const& pk = valKeys.keys->publicKey;
 248             auto const& sk = valKeys.keys->secretKey;
 249             signatures.emplace(
 250                 pk, ExportResultBuilder::signExportedTxn(innerTx, pk, sk));
 251         }
 252     }
 253     else
 254     {
 255         // Network mode: use the atomically-snapshotted sigs from
 256         // the quorum check above.
 257         signatures = *collectedSigs;
 258     }
 259 
 260     auto assembled =
 261         ExportResultBuilder::assemble(innerTx, signatures, currentSeq, txId);
 262 
 263     // Create the shadow ticket with the signed tx hash.
 264     {
 265         TER ter = ExportLedgerOps::createShadowTicket(
 266             view(), account, innerTx, assembled.signedTxHash, j_);
 267         if (!isTesSuccess(ter))
 268             return ter;
 269     }
 270 
 271     // Write the export result to metadata.  The multisigned tx is
 272     // stored as sfExportedTxn (OBJECT) so it renders as readable
 273     // JSON in metadata, not an opaque hex blob.
 274     auto* avi = dynamic_cast<ApplyViewImpl*>(&view());
 275     if (!avi)
 276     {
 277         JLOG(j_.fatal()) << "Export: cannot write ExportResult metadata"
 278                          << " txHash=" << txId << " ledgerSeq=" << currentSeq
 279                          << " reason=view-not-ApplyViewImpl";
 280         return tefINTERNAL;
 281     }
 282     avi->setExportResultMetaData(std::move(assembled.metadata));
 283 
 284     // Clean up the collector.
 285     ctx_.app.getConsensusExtensions().exportSigCollector().clear(txId);
 286 
 287     JLOG(j_.info()) << "Export: success"
 288                     << " txHash=" << txId << " ledgerSeq=" << currentSeq
 289                     << " signers=" << assembled.signerCount << " mode="
 290                     << (ctx_.app.config().standalone() ? "standalone"
 291                                                        : "network")
 292                     << " result=tesSUCCESS";
 293 
 294     return tesSUCCESS;
 295 }

Shadow Tickets and the Export Round-Trip

Export is a 3-way handshake, not fire-and-forget:

  1. Export (Xahau): User or hook creates the export. On quorum success:
    • The fully multisigned transaction is assembled (Signers array + empty SigningPubKey) and stored as sfExportedTxn inside sfExportResult metadata — readable JSON, ready for raw submission
    • A shadow ticket (ltSHADOW_TICKET) is created, keyed by account + ticket sequence, storing the signed tx hash (getHash(HashPrefix::transactionID) — includes all fields including Signers)
    • The account pays reserve for the shadow ticket
  2. Execute (XRPL): The multisigned blob from step 1 is submitted raw to XRPL. The XRPL account's SignerList points to the Xahau validator keys, so XRPL validates it as a standard multisigned transaction. It executes (or bounces), producing an XPOP.
  3. Callback (XRPL → Xahau): The XPOP is imported back via ttIMPORT. Import checks the shadow ticket exists, verifies the XPOP inner tx hash matches the shadow ticket's stored hash, consumes the shadow ticket (frees reserve), and fires hooks.

Shadow tickets are round-trip completion tokens:

  • Account-owned (like trustlines) — the account can cancel unused tickets via xport_cancel() hook API or sfCancelTicketSequence on ttEXPORT
  • Export and cancel are mutually exclusive on a single ttEXPORT transaction
  • The stored tx hash prevents replaying a different XPOP against the same shadow ticket

When ttIMPORT sees sfTicketSequence on the inner transaction, it takes the export callback path: verify shadow ticket hash match, consume the shadow ticket, fire hooks, done. The export callback path skips the sfOperationLimit and signing key match checks (the shadow ticket already proves the relationship). No B2M balance crediting. When there's no sfTicketSequence, it takes the existing Burn-to-Mint path unchanged.


Hook Integration

Hooks call xport() which internally constructs a ttEXPORT wrapper (with sfEmitDetails) and pushes it onto the emitted txn queue. The wrapper flows through the normal emitted txn path:

  1. xport_reserve(N) — reserves N export slots (also reserves emit slots)
  2. xport(inner_tx_blob) — validates inner tx → constructs ttEXPORT wrapper → emits it
  3. Emitted ttEXPORT enters the open ledger next round → proposal-based sig collection → retriable transactor

The hook receives the inner tx hash (the cross-chain transaction it built), while the ttEXPORT wrapper handles the Xahau-side lifecycle.


Export Protocol Additions

Type Name Purpose
Field sfExportResult (OBJECT 98) Export result in metadata (contains sfExportedTxn + sfLedgerSequence + sfTransactionHash)
Field sfCancelTicketSequence (UINT32 101) Cancel a shadow ticket by sequence
Field sfExportedTxn (OBJECT 90) Inner cross-chain transaction (on ttEXPORT: unsigned template; in ExportResult: fully multisigned, ready for submission)
Ledger Entry ltSHADOW_TICKET (0x5374) Round-trip completion token (account-owned)
Transaction ttEXPORT (91) User-submittable export / shadow ticket cancel
TER terRETRY_EXPORT Retained in retry set for next ledger
TER tecEXPORT_EXPIRED (200) LLS expiry, sequence consumed
Proto TMProposeSet.exportSignatures (field 13) Proposal-piggybacked validator sigs
Hook API xport(), xport_reserve(), xport_cancel() Export APIs for hooks

Part 2: Decentralized Secure Randomness

Adding randomness to deterministic consensus sounds simple until you try to do it without breaking safety. This part implements Same-Ledger Usable Randomness: finalizing entropy after user intent is locked, but before normal execution in that same ledger.

Review Scope

  • Blast radius (high level): consensus proposal encoding, establish sub-state logic, pseudo-tx injection, ledger apply ordering, transactor apply path, and hook-facing entropy APIs.
  • Amendment gating: featureConsensusEntropy is DefaultNo; behavior is inert until enabled by amendment vote.
  • Migration/upgrade: no migration required, no config changes required, and no manual database steps; amendment-gated behavior remains inert until vote-in.
  • Test coverage highlights: ConsensusEntropy_test (Hook dice()/random(), fallback semantics), ConsensusExtensions_test (the establish-phase tick gates: commit/reveal/entropy states, export sig convergence, sidecar build/fetch/merge, active validator view), ConsensusRng_test and ExtendedPosition_test (sub-state transitions, serialization compatibility, malformed wire cases), plus the export and ingress suites (Export_test, ExportSigCollector_test, ExportSignatureHarvester_test, ExportSignatureUpgrader_test, ExportResultBuilder_test, XportWrapperBuilder_test, ProposalPrecheck_test). Note featureConsensusEntropy is deliberately excluded from the default jtx::Env feature set (see Test Environment Gating); CE behavior is exercised by its dedicated suites.

How It Works

The architecture centers on converging on signed input sets rather than voting on a derived output hash. This ensures that every node can independently verify and reconstruct the final result.

1. Transport: Piggybacked Proposals

The ConsensusProposal wire format is extended via ExtendedPosition. Most entropy data (commitments and reveals) flows through existing proposal gossip with low incremental payload overhead on the fast path, while consensus latency cost comes from the added sub-state progression/timeouts.

  • Equality Firewall: ExtendedPosition::operator== only compares the txSetHash. RNG sub-state differences never stall the core consensus on user transactions.

2. Pipelined Sub-states

RNG progression runs inside internal establish sub-states. These are checkpoints within the existing consensus cadence:

  • ConvergingTx: Normal transaction convergence while harvesting entropy commitments.
  • ConvergingCommit: Locking the commitSet on the healthy 80% fast path, with bounded timeout paths so degraded rounds do not stall consensus.
  • ConvergingReveal: Targets reveals from 100% of known committers, bounded by timeout/fallback paths (including the 1.5s reveal timeout) to preserve liveness. Final tier selection happens later from the agreed entropy set, not from local pending reveals.

3. SHAMap Union Convergence

Harvested commitments and reveals are stored in ephemeral, unbacked SHAMaps.

  • Honest validators always agree on inclusion (every valid contribution belongs).
  • Differences are reconciled via Union Merge (monotonic set growth).
  • If a packet is dropped, the node uses the native InboundTransactions pipeline to fetch only the missing leaves from peers.

4. Synthetic Injection & Same-Ledger Execution

Once reveals are collected, the final entropy is computed deterministically (sha512Half(sorted_reveals)).

  • Synthetic Transaction: Right before buildLCL (Ledger Construction), the node locally synthesizes a ttCONSENSUS_ENTROPY pseudo-transaction.
  • Deterministic Ordering: This transaction is sorted to execute first, ensuring its entropy is available to every Hook and user transaction in the same block.
  • Verification: Because the inputs were agreed upon in consensus, nodes synthesize identical transactions. Any local fault is caught by the validation phase (Example 5 intuition + Theorem 8 safety framing).

Hook API Integration

Provides two new deterministic WebAssembly APIs for Hook developers:

  • dice(sides, min_tier, min_count): Returns an integer in [0, sides-1].
  • random(write_ptr, write_len, min_tier, min_count): Fills a buffer (up to 512 bytes) with consensus-derived randomness.

Entropy is tiered, and the quality requirement is a required argument — there is deliberately no default. Every ledger carries entropy at one of three tiers, recorded on the ConsensusEntropy ledger entry as sfEntropyTier + sfEntropyCount:

Tier When Properties
validator_quorum (3) Agreed reveal sidecar aligned at the 80% effective active-validator threshold from an UNLReport-backed view Strongest validator-derived tier; manipulation bounded to one bit per reveal-withholder; EntropyCount = contributing validators
participant_aligned (2) Agreed reveal sidecar aligned below validator quorum but at the participant threshold derived from the original pre-NegativeUNL UNLReport view Weaker, opt-in, provisional/degraded tier; equivocation-safe under the same ~20% Byzantine liveness bound, but not validation-quorum strength
consensus_fallback (1) Non-fallback tier unavailable, no usable UNLReport, failed alignment, timeout, or impossible quorum Deterministic digest over already-agreed inputs (H(prefix, parentLedgerHash, baseTxSetHash, seq)); unpredictable in practice but user-influenceable via tx submission — never for value-bearing outcomes. EntropyCount = 0

Both APIs gate on the caller's stated requirement: entropy is served iff it is fresh (current or previous ledger) and tier >= min_tier && count >= min_count; otherwise the call fails closed with TOO_LITTLE_ENTROPY. min_tier must be one of the stored tiers 1..3, and min_count must fit the on-ledger UINT16 EntropyCount; invalid requirements return INVALID_ARGUMENT. A lottery passes (min_tier=3, min_count=N) and refuses degraded/fallback rounds; a lower-value game may pass (2, M) and accept participant-aligned degraded entropy; a cosmetic shuffle may pass (1, 0) and accept any fresh entropy — but that acceptance is explicit and reviewable at the call site. WASM imports have no default parameters, so the alternative to required arguments is a hidden network constant invisible at every call site (the previous design hard-coded count >= 5).

Per-hook output streams are domain-separated: each call mixes the ledger entropy with the ledger seq, txn ID, originating account, hook hash, hook account, chain position, weak/strong execution flag, and a per-execution call counter, so no two hook executions (or successive calls within one) observe the same stream.

One known UX caveat (flagged as a TODO in fairRng): during open-ledger (speculative) execution, the entropy for the in-flight ledger does not exist yet, so the APIs draw on the previous ledger's entropy — while final execution during ledger close uses the current ledger's freshly injected entropy. A hook's dice()/random() results can therefore differ between provisional and final application. This is inherent to same-ledger entropy finalization (entropy is locked after user intent, so it cannot be visible to the speculative pass); only the closed-ledger result is canonical, and hook authors should treat provisional outcomes the way they already treat provisional tesSUCCESS.

fairRng (hook-facing entropy derivation)

📍 src/xrpld/app/hook/detail/applyHook.cpp:4049-4120

4049 inline std::vector<uint8_t>
4050 fairRng(
4051     ApplyContext& applyCtx,
4052     hook::HookResult& hr,
4053     uint32_t byteCount,
4054     uint32_t minTier,
4055     uint32_t minCount)
4056 {
4057     if (byteCount > 512)
4058         byteCount = 512;
4059 
4060     // force the byte count to be a multiple of 32
4061     byteCount &= ~0b11111;
4062 
4063     if (byteCount == 0)
4064         return {};
4065 
4066     auto& view = applyCtx.view();
4067 
4068     auto const sleEntropy = view.peek(ripple::keylet::consensusEntropy());
4069     auto const seq = view.info().seq;
4070 
4071     auto const entropySeq =
4072         sleEntropy ? sleEntropy->getFieldU32(sfLedgerSequence) : 0u;
4073 
4074     // Open-ledger hook execution is provisional and can only see the previous
4075     // ledger's finalized entropy. Final buildLCL execution sees the current
4076     // ledger's entropy pseudo-tx after it updates this SLE. That open-vs-final
4077     // skew is inherent to speculative execution; callers that need final
4078     // entropy must treat open-ledger dice/random results as previews.
4079     // Defensive: sfEntropyTier is soeREQUIRED, so any entry this code wrote
4080     // carries it. A missing field can only come from a pre-tier-3 persisted
4081     // entry; treat that as tier 0 (none) so the requirement check fails closed.
4082     auto const entropyTier =
4083         sleEntropy && sleEntropy->isFieldPresent(sfEntropyTier)
4084         ? sleEntropy->getFieldU8(sfEntropyTier)
4085         : std::uint8_t{0};
4086     if (!sleEntropy || entropySeq > seq || (seq - entropySeq) > 1 ||
4087         entropyTier < minTier ||
4088         sleEntropy->getFieldU16(sfEntropyCount) < minCount)
4089         return {};
4090 
4091     // we'll generate bytes in lots of 32
4092 
4093     uint256 rndData = sha512Half(
4094         view.info().seq,
4095         applyCtx.tx.getTransactionID(),
4096         hr.otxnAccount,
4097         hr.hookHash,
4098         hr.account,
4099         hr.hookChainPosition,
4100         hr.executeAgainAsWeak ? std::string("weak") : std::string("strong"),
4101         sleEntropy->getFieldH256(sfDigest),
4102         hr.rngCallCounter++);
4103 
4104     std::vector<uint8_t> bytesOut;
4105     bytesOut.resize(byteCount);
4106 
4107     uint8_t* ptr = bytesOut.data();
4108     while (1)
4109     {
4110         std::memcpy(ptr, rndData.data(), 32);
4111         ptr += 32;
4112 
4113         if (ptr - bytesOut.data() >= byteCount)
4114             break;
4115 
4116         rndData = sha512Half(rndData);
4117     }
4118 
4119     return bytesOut;
4120 }

Safety & Liveness

  • Safety: RNG machinery resides entirely in the deliberation path. Safety remains anchored to the validation-phase quorum (Chase & MacBrough 2018, §4.1 / Theorem 8).
  • Liveness: Entropy availability degrades deterministically to the labeled Tier 1 consensus-bound fallback under stress (no UNLReport, impossible quorums, timeouts), ensuring the ledger always closes and always carries a fresh, explicitly-tiered entropy object.
  • Degraded availability: participant_aligned is intentionally below validation-quorum strength and therefore opt-in. Hooks that require fail-closed validator-quorum entropy keep passing min_tier=3; hooks that can tolerate provisional/degraded entropy must opt into min_tier=2.

Adversarial Considerations (Entropy Quality)

The safety claim above is about ledger agreement. Entropy quality under adversarial behavior is a separate axis, and the commit/reveal design bounds — but does not eliminate — validator influence:

  • Grinding via skipped commits: rejected. A reveal with no commitment on record is ignored, in both the proposal-harvest path and the fetched-set merge path (reveal-without-commitment).
  • Commit-then-equivocate: a changed commitment invalidates any reveal already accepted against the old one, and drops the stale commit proof, so reveal quorum cannot be satisfied by mismatched data.
  • Reveal forgery: every reveal must satisfy sha512Half(reveal, pubKey, seq) == commitment, verified at harvest and again at merge.
  • Leaf injection during fetch: fetched commit leaves must carry a verifiable proposal-signature proof (sfBlob); fetched reveal leaves are bound to a trusted validator identity (NodeID ↔ trusted key) and verified against the on-record commitment. Entries from non-UNL senders are rejected at every boundary.
  • Residual bias — reveal withholding: a committer who withholds its reveal until the timeout effectively chooses between two outcomes (entropy with vs. without its contribution) — the classic one-bit-per-withholder bias of commit/reveal schemes. Colluding withholders near the quorum boundary can instead force the round down to participant_aligned or consensus_fallback. These transitions are labeled and bounded; neither lets an attacker choose an output value at the validator tier. The fallback tier itself is user-influenceable (a quiet-ledger submitter can grind the tx set), which is why it is a distinct labeled tier that hooks must opt into.

This makes the entropy suitable for applications where outcomes must be unpredictable and manipulation must be bounded and observable. Applications needing stronger guarantees against validator collusion (e.g., very high-value lotteries) should layer additional mechanisms on top.

Infrastructure & Support Logic

Several non-obvious plumbing changes were required to make the RNG pipeline robust and testable:

1. Fast Polling during RNG Transitions

To reduce the latency impact of the extra sub-states, the heartbeat timer accelerates to 250ms (tunable via XAHAU_RNG_POLL_MS) while in the RNG pipeline.
📍 src/xrpld/app/misc/NetworkOPs.cpp:1032-1050

1032     // Use faster polling during RNG sub-state transitions
1033     // to reduce latency of commit-reveal rounds.
1034     // Tunable via RuntimeConfig rng_poll_ms (default 250ms, min 50ms).
1035     if (mConsensus.extensionsBusy())
1036     {
1037         auto pollMs = std::chrono::milliseconds{250};
1038         auto& rc = app_.getRuntimeConfig();
1039         if (rc.active())
1040         {
1041             if (auto cfg = rc.getConfig("*"))
1042             {
1043                 if (cfg->rngPollMs)
1044                     pollMs = std::chrono::milliseconds{*cfg->rngPollMs};
1045             }
1046         }
1047         setHeartbeatTimer(pollMs);
1048     }
1049     else
1050         setHeartbeatTimer();

2. Local Testnet Resource Charging

Connections from 127.0.0.1 normally share a single IP resource bucket. This change preserves the port for loopback addresses so that local multi-node testnets don't hit peer resource limits due to the increased RNG set traffic.
📍 include/xrpl/resource/detail/Logic.h:113-117

 113         // Inbound connections from the same IP normally share one
 114         // resource bucket (port stripped) for DoS protection.  For
 115         // loopback addresses, preserve the port so local testnet nodes
 116         // each get their own bucket instead of all sharing one.
 117         auto const key = is_loopback(address) ? address : address.at_port(0);

3. Test Environment Gating

featureConsensusEntropy is excluded from default jtx::Env tests to prevent its automatic pseudo-tx injection from breaking existing test suites that rely on specific transaction counts.
📍 src/test/jtx/Env.h:86-89

  86         // TODO: ConsensusEntropy injects a pseudo-tx every ledger which
  87         // breaks existing test transaction count assumptions. Exclude from
  88         // default test set until dedicated tests are written.
  89         return FeatureBitset(feats) - featureConsensusEntropy;

4. Sidecar Set Sync Filtering

Internal RNG and Export sidecar data is stored as STObject(sfGeneric) leaves in ephemeral SHAMapType::SIDECAR maps. InboundTransactions still provides the fetch machinery, but sidecar acquisition uses SidecarSetSF instead of ConsensusTransSetSF, so fetched leaves are cached as SHAMap nodes and are never parsed or submitted as transactions.
📍 src/xrpld/app/ledger/detail/TransactionAcquire.cpp:47-56

  47 std::unique_ptr<SHAMapSyncFilter>
  48 makeSyncFilter(InboundSetKind kind, Application& app)
  49 {
  50     // Sidecars deliberately reuse candidate tx-set acquisition; the filter only
  51     // changes leaf handling so sidecar STObjects are cached, not submitted.
  52     if (kind == InboundSetKind::sidecar)
  53         return std::make_unique<SidecarSetSF>(app.getTempNodeCache());
  54 
  55     return std::make_unique<ConsensusTransSetSF>(app, app.getTempNodeCache());
  56 }

📍 src/xrpld/app/ledger/SidecarSetSF.cpp:28-40

  28 void
  29 SidecarSetSF::gotNode(
  30     bool fromFilter,
  31     SHAMapHash const& nodeHash,
  32     std::uint32_t,
  33     Blob&& nodeData,
  34     SHAMapNodeType) const
  35 {
  36     if (fromFilter)
  37         return;
  38 
  39     m_nodeCache.insert(nodeHash, nodeData);
  40 }

📍 src/xrpld/app/ledger/SidecarSetSF.cpp:42-50

  42 std::optional<Blob>
  43 SidecarSetSF::getNode(SHAMapHash const& nodeHash) const
  44 {
  45     Blob nodeData;
  46     if (m_nodeCache.retrieve(nodeHash, nodeData))
  47         return nodeData;
  48 
  49     return std::nullopt;
  50 }

Guided Code Review (Projected Source)

This section follows runtime order so the code reads as a story, not a file dump.

1) Proposal payload: ExtendedPosition carries RNG + Export sidecar fields

ExtendedPosition adds commit/reveal set identities, export sig set hash, and per-validator leaves while keeping tx-set identity explicit.
Non-obvious: operator== compares only txSetHash on purpose. That decouples core tx-set convergence from RNG/Export sub-state drift.
operator== (equality firewall):
📍 src/xrpld/app/consensus/RCLCxPeerPos.h:114-149

 114     bool
 115     operator==(ExtendedPosition const& other) const
 116     {
 117         return txSetHash == other.txSetHash;
 118     }
 119 
 120     bool
 121     operator!=(ExtendedPosition const& other) const
 122     {
 123         return !(*this == other);
 124     }
 125 
 126     // Comparison with uint256 (compares txSetHash only)
 127     bool
 128     operator==(uint256 const& hash) const
 129     {
 130         return txSetHash == hash;
 131     }
 132 
 133     bool
 134     operator!=(uint256 const& hash) const
 135     {
 136         return txSetHash != hash;
 137     }
 138 
 139     friend bool
 140     operator==(uint256 const& hash, ExtendedPosition const& pos)
 141     {
 142         return pos.txSetHash == hash;
 143     }
 144 
 145     friend bool
 146     operator!=(uint256 const& hash, ExtendedPosition const& pos)
 147     {
 148         return pos.txSetHash != hash;
 149     }

add() (signed serialization of all sidecar fields):
📍 src/xrpld/app/consensus/RCLCxPeerPos.h:163-206

 163     void
 164     add(Serializer& s) const
 165     {
 166         s.addBitString(txSetHash);
 167 
 168         // Wire compatibility: if no extensions, emit exactly 32 bytes
 169         // so legacy nodes that expect a plain uint256 work unchanged.
 170         if (!commitSetHash && !entropySetHash && !exportSigSetHash &&
 171             !exportSignaturesHash && !observedParticipantsHash &&
 172             !myCommitment && !myReveal)
 173             return;
 174 
 175         std::uint8_t flags = 0;
 176         if (commitSetHash)
 177             flags |= 0x01;
 178         if (entropySetHash)
 179             flags |= 0x02;
 180         if (myCommitment)
 181             flags |= 0x04;
 182         if (myReveal)
 183             flags |= 0x08;
 184         if (exportSigSetHash)
 185             flags |= 0x10;
 186         if (exportSignaturesHash)
 187             flags |= 0x20;
 188         if (observedParticipantsHash)
 189             flags |= 0x40;
 190         s.add8(flags);
 191 
 192         if (commitSetHash)
 193             s.addBitString(*commitSetHash);
 194         if (entropySetHash)
 195             s.addBitString(*entropySetHash);
 196         if (myCommitment)
 197             s.addBitString(*myCommitment);
 198         if (myReveal)
 199             s.addBitString(*myReveal);
 200         if (exportSigSetHash)
 201             s.addBitString(*exportSigSetHash);
 202         if (exportSignaturesHash)
 203             s.addBitString(*exportSignaturesHash);
 204         if (observedParticipantsHash)
 205             s.addBitString(*observedParticipantsHash);
 206     }

fromSerialIter() (legacy + extended wire decode):
📍 src/xrpld/app/consensus/RCLCxPeerPos.h:233-282

 233     static std::optional<ExtendedPosition>
 234     fromSerialIter(SerialIter& sit, std::size_t totalSize)
 235     {
 236         if (totalSize < 32)
 237             return std::nullopt;
 238 
 239         ExtendedPosition pos;
 240         pos.txSetHash = sit.get256();
 241 
 242         // Legacy format: exactly 32 bytes
 243         if (totalSize == 32)
 244             return pos;
 245 
 246         // Extended format: flags byte + optional uint256 fields
 247         if (sit.empty())
 248             return pos;
 249 
 250         std::uint8_t flags = sit.get8();
 251 
 252         // Reject unknown flag bits (reduces wire malleability)
 253         if (flags & 0x80)
 254             return std::nullopt;
 255 
 256         // Validate exact byte count for the flagged fields.
 257         // Each flag bit indicates a 32-byte uint256.
 258         int fieldCount = 0;
 259         for (int i = 0; i < 7; ++i)
 260             if (flags & (1 << i))
 261                 ++fieldCount;
 262 
 263         if (sit.getBytesLeft() != static_cast<std::size_t>(fieldCount * 32))
 264             return std::nullopt;
 265 
 266         if (flags & 0x01)
 267             pos.commitSetHash = sit.get256();
 268         if (flags & 0x02)
 269             pos.entropySetHash = sit.get256();
 270         if (flags & 0x04)
 271             pos.myCommitment = sit.get256();
 272         if (flags & 0x08)
 273             pos.myReveal = sit.get256();
 274         if (flags & 0x10)
 275             pos.exportSigSetHash = sit.get256();
 276         if (flags & 0x20)
 277             pos.exportSignaturesHash = sit.get256();
 278         if (flags & 0x40)
 279             pos.observedParticipantsHash = sit.get256();
 280 
 281         return pos;
 282     }

2) Harvest stage: trust boundary + reveal verification

Incoming RNG data is rejected for senders outside the active validator view, and reveals are accepted only if they match prior commitments. Non-fallback entropy labels require that active view to be UNLReport-backed; the trusted-config fallback view can collect data but mints only consensus_fallback.
📍 src/xrpld/app/consensus/ConsensusExtensions.cpp:1850-1981

1850     // Reject data from validators not in the active UNL
1851     if (!isUNLReportMember(nodeId))
1852     {
1853         JLOG(j_.trace()) << "RNG: rejecting proposal data"
1854                          << " reason=non-active-validator"
1855                          << " node=" << nodeId << " proposeSeq=" << proposeSeq
1856                          << " prevLedger=" << prevLedger;
1857         return;
1858     }
1859 
1860     // RuntimeConfig: randomly drop RNG claims for testing
1861     auto& rc = app_.getRuntimeConfig();
1862     if (rc.active())
1863     {
1864         if (auto cfg = rc.getConfig("*"))
1865         {
1866             if (cfg->rngClaimDropPctX100 && *cfg->rngClaimDropPctX100 > 0)
1867             {
1868                 static thread_local std::mt19937 rng{std::random_device{}()};
1869                 if (std::uniform_int_distribution<int>{0, 9999}(rng) <
1870                     *cfg->rngClaimDropPctX100)
1871                 {
1872                     JLOG(j_.warn())
1873                         << "RNG: TESTING dropping claim"
1874                         << " node=" << nodeId
1875                         << " dropPctX100=" << *cfg->rngClaimDropPctX100
1876                         << " proposeSeq=" << proposeSeq;
1877                     return;
1878                 }
1879             }
1880         }
1881     }
1882 
1883     // Store nodeId -> publicKey mapping for deterministic ordering
1884     nodeIdToKey_.insert_or_assign(nodeId, publicKey);
1885 
1886     //@@start rng-harvest-commit
1887     // Harvest commitment if present
1888     if (position.myCommitment)
1889     {
1890         auto [it, inserted] =
1891             pendingCommits_.emplace(nodeId, *position.myCommitment);
1892         if (!inserted && it->second != *position.myCommitment)
1893         {
1894             JLOG(j_.warn())
1895                 << "RNG: validator changed commitment"
1896                 << " node=" << nodeId << " proposeSeq=" << proposeSeq
1897                 << " old=" << it->second << " new=" << *position.myCommitment;
1898             it->second = *position.myCommitment;
1899 
1900             // commitProofs_ stores seq=0 proofs. If a validator changes its
1901             // commitment later in the round, that old proof no longer matches
1902             // the new digest and must not be embedded into a fetched commitSet.
1903             commitProofs_.erase(nodeId);
1904 
1905             // Any reveal accepted against the prior commitment is now stale.
1906             // Drop it so reveal quorum cannot be satisfied by mismatched data.
1907             if (pendingReveals_.erase(nodeId) > 0)
1908                 proposalProofs_.erase(nodeId);
1909         }
1910         else if (inserted)
1911         {
1912             JLOG(j_.trace())
1913                 << "RNG: harvested commitment"
1914                 << " node=" << nodeId << " proposeSeq=" << proposeSeq
1915                 << " commitment=" << *position.myCommitment;
1916         }
1917     }
1918     //@@end rng-harvest-commit
1919 
1920     //@@start rng-harvest-reveal-verification
1921     // Harvest reveal if present — verify it matches the stored commitment
1922     if (position.myReveal)
1923     {
1924         auto commitIt = pendingCommits_.find(nodeId);
1925         if (commitIt == pendingCommits_.end())
1926         {
1927             // No commitment on record — cannot verify. Ignore to prevent
1928             // grinding attacks where a validator skips the commit phase.
1929             JLOG(j_.warn())
1930                 << "RNG: rejecting reveal"
1931                 << " reason=no-commitment"
1932                 << " node=" << nodeId << " proposeSeq=" << proposeSeq
1933                 << " prevLedger=" << prevLedger;
1934             return;
1935         }
1936 
1937         // Verify Hash(reveal | pubKey | seq) == commitment
1938         auto const prevLgr = app_.getLedgerMaster().getLedgerByHash(prevLedger);
1939         if (!prevLgr)
1940         {
1941             JLOG(j_.warn())
1942                 << "RNG: cannot verify reveal"
1943                 << " reason=prev-ledger-unavailable"
1944                 << " node=" << nodeId << " proposeSeq=" << proposeSeq
1945                 << " prevLedger=" << prevLedger;
1946             return;
1947         }
1948 
1949         auto const seq = prevLgr->info().seq + 1;
1950         auto const calculated = sha512Half(*position.myReveal, publicKey, seq);
1951 
1952         if (calculated != commitIt->second)
1953         {
1954             JLOG(j_.warn())
1955                 << "RNG: rejecting reveal"
1956                 << " reason=commitment-mismatch"
1957                 << " node=" << nodeId << " proposeSeq=" << proposeSeq
1958                 << " seq=" << seq << " expected=" << commitIt->second
1959                 << " calculated=" << calculated;
1960             return;
1961         }
1962 
1963         auto [it, inserted] =
1964             pendingReveals_.emplace(nodeId, *position.myReveal);
1965         if (!inserted && it->second != *position.myReveal)
1966         {
1967             JLOG(j_.warn())
1968                 << "RNG: validator changed reveal"
1969                 << " node=" << nodeId << " proposeSeq=" << proposeSeq
1970                 << " old=" << it->second << " new=" << *position.myReveal;
1971             it->second = *position.myReveal;
1972         }
1973         else if (inserted)
1974         {
1975             JLOG(j_.trace())
1976                 << "RNG: harvested reveal"
1977                 << " node=" << nodeId << " proposeSeq=" << proposeSeq
1978                 << " seq=" << seq << " reveal=" << *position.myReveal;
1979         }
1980     }
1981     //@@end rng-harvest-reveal-verification

3) Quorum basis: active UNL snapshot; expected proposers are liveness hints

📍 src/xrpld/app/consensus/ConsensusExtensions.cpp:207-220

 207 std::size_t
 208 ConsensusExtensions::quorumThreshold() const
 209 {
 210     // Validator_quorum entropy uses a fixed 80% threshold over the effective
 211     // active UNL snapshot. Tier 2 participant_aligned entropy has its own
 212     // lower intersection-safe floor; recent proposers are useful for liveness
 213     // heuristics, but they do not lower either threshold.
 214     // Use the shared validator view so Tier 3 RNG and Export use the same
 215     // denominator.
 216     auto const base = activeValidatorView()->size();
 217     if (base == 0)
 218         return 1;  // safety: need at least one commit
 219     return calculateQuorumThreshold(base);
 220 }

📍 src/xrpld/app/consensus/ConsensusExtensions.cpp:271-326

 271 void
 272 ConsensusExtensions::setExpectedProposers(hash_set<NodeID> proposers)
 273 {
 274     bool const includeSelf = mode_ == ConsensusMode::proposing &&
 275         app_.getValidatorKeys().keys &&
 276         app_.getValidatorKeys().nodeID != beast::zero;
 277 
 278     if (!proposers.empty())
 279     {
 280         // Intersect recent proposers with the active UNL. This set is used as
 281         // a liveness hint only; commit quorum itself remains fixed to the
 282         // active UNL snapshot for the round.
 283         auto const validatorView = activeValidatorView();
 284         hash_set<NodeID> filtered;
 285         for (auto const& id : proposers)
 286         {
 287             if (!includeSelf && id == app_.getValidatorKeys().nodeID)
 288                 continue;
 289             // Recent proposers are only a liveness hint; filter them through
 290             // the same active view that defines commit quorum membership.
 291             if (validatorView->containsNode(id))
 292                 filtered.insert(id);
 293         }
 294         if (includeSelf)
 295             filtered.insert(app_.getValidatorKeys().nodeID);
 296         likelyParticipants_ = std::move(filtered);
 297         JLOG(j_.trace()) << "RNG: likelyParticipants"
 298                          << " source=recent-proposers"
 299                          << " count=" << likelyParticipants_.size()
 300                          << " input=" << proposers.size()
 301                          << " includeSelf=" << (includeSelf ? "yes" : "no")
 302                          << " activeValidators=" << validatorView->size();
 303         return;
 304     }
 305 
 306     // First round (or no recent data): fall back to the active UNL snapshot as
 307     // our best guess for who may still contribute before timeout.
 308     auto const validatorView = activeValidatorView();
 309     if (validatorView->size() > 0)
 310     {
 311         likelyParticipants_ = validatorView->nodeIds;
 312         JLOG(j_.trace()) << "RNG: likelyParticipants"
 313                          << " source=active-validator-view"
 314                          << " count=" << likelyParticipants_.size()
 315                          << " activeValidators=" << validatorView->size()
 316                          << " viewSource="
 317                          << (validatorView->fromUNLReport ? "UNLReport"
 318                                                           : "trusted-fallback");
 319         return;
 320     }
 321 
 322     // No data at all (shouldn't happen — cacheUNLReport falls back to
 323     // trusted keys). Leave empty; diagnostics will show no liveness hint.
 324     JLOG(j_.warn()) << "RNG: likelyParticipants unavailable"
 325                     << " reason=empty-active-validator-view";
 326 }

4) State-machine checkpoints: ConvergingTx -> ConvergingCommit -> ConvergingReveal

📍 src/xrpld/consensus/ConsensusExtensionsTick.h:94-1066

  94     // --- RNG Sub-state Checkpoints ---
  95     // These sub-states use union convergence (not avalanche).
  96     // Commits and reveals arrive piggybacked on proposals, so by the time
  97     // we reach these checkpoints most data is already collected. The
  98     // SHAMap fetch/diff/merge in onAcquiredSidecarSet is a safety net
  99     // for stragglers, not a voting mechanism.
 100     //
 101     // Why an 80% fast path for commits but 100% for reveals?
 102     //
 103     // COMMITS: the immediate transition still uses the 80%
 104     // validator_quorum threshold. If that fast path is not reached, bounded
 105     // timeout/impossible-participant logic may still proceed at the lower
 106     // entropyGateThreshold() so Tier 2 participant_aligned rounds can close.
 107     //
 108     // REVEALS: the commit set is now locked and we know *exactly* who
 109     // committed.  Every committer broadcasts their reveal immediately.
 110     // So we wait for ALL of them, with rngREVEAL_TIMEOUT (measured
 111     // from ConvergingReveal entry) as the safety valve for nodes that
 112     // crash between commit and reveal.
 113 
 114     bool const isRngEnabled = ext.rngEnabled();
 115     bool const isExportEnabled = ext.exportEnabled();
 116     auto const toMs = [](auto duration) {
 117         return std::chrono::duration_cast<std::chrono::milliseconds>(duration)
 118             .count();
 119     };
 120 
 121     JLOG(ext.j_.trace()) << "RNGGATE: phaseEstablish"
 122                          << " buildSeq=" << ctx.buildSeq << " prevSeq="
 123                          << (static_cast<std::uint32_t>(ctx.buildSeq) - 1)
 124                          << " rngEnabled=" << (isRngEnabled ? "yes" : "no")
 125                          << " exportEnabled="
 126                          << (isExportEnabled ? "yes" : "no")
 127                          << " estState=" << static_cast<int>(ext.estState_)
 128                          << " mode=" << to_string(ctx.mode)
 129                          << " roundMs=" << ctx.roundTime.count();
 130 
 131     if (isRngEnabled || isExportEnabled)
 132     {
 133         if constexpr (requires {
 134                           ext.recordParticipantDiagnostics(
 135                               ctx.mode, ctx.peerPositions);
 136                       })
 137         {
 138             // Diagnostic only: this records the active-UNL participants visible
 139             // to this node so proposals can carry a signed hash for debugging
 140             // timing/degraded-network cases. It is not a quorum denominator.
 141             ext.recordParticipantDiagnostics(ctx.mode, ctx.peerPositions);
 142         }
 143     }
 144 
 145     if (isRngEnabled)
 146     {
 147         auto const buildSeq = ctx.buildSeq;
 148         auto const estStateName = [&]() -> char const* {
 149             switch (ext.estState_)
 150             {
 151                 case EstablishState::ConvergingTx:
 152                     return "ConvergingTx";
 153                 case EstablishState::ConvergingCommit:
 154                     return "ConvergingCommit";
 155                 case EstablishState::ConvergingReveal:
 156                     return "ConvergingReveal";
 157             }
 158             return "Unknown";
 159         };
 160         auto logRngDiag = [&](char const* reason) {
 161             auto const ourPos = ctx.getPosition();
 162             auto const participants = ctx.peerPositions.size() + 1;
 163             JLOG(ext.j_.debug())
 164                 << "STALLDIAG: " << reason << " state=" << estStateName()
 165                 << " buildSeq=" << buildSeq << " phase=establish"
 166                 << " mode=" << to_string(ctx.mode)
 167                 << " roundMs=" << ctx.roundTime.count()
 168                 << " convergePct=" << ctx.convergePercent
 169                 << " participants=" << participants
 170                 << " peerPositions=" << ctx.peerPositions.size()
 171                 << " prevProposers=" << ctx.prevProposers
 172                 << " explicitFinalSent="
 173                 << (ext.explicitFinalProposalSent_ ? "yes" : "no")
 174                 << " closeTimeConsensus="
 175                 << (ctx.haveCloseTimeConsensus ? "yes" : "no")
 176                 << " txSet=" << ourPos;
 177 
 178             JLOG(ext.j_.debug())
 179                 << "STALLDIAG: sidecar"
 180                 << " commitSetHash="
 181                 << (ourPos.commitSetHash ? to_string(*ourPos.commitSetHash)
 182                                          : std::string{"none"})
 183                 << " entropySetHash="
 184                 << (ourPos.entropySetHash ? to_string(*ourPos.entropySetHash)
 185                                           : std::string{"none"})
 186                 << " exportSigSetHash="
 187                 << (ourPos.exportSigSetHash
 188                         ? to_string(*ourPos.exportSigSetHash)
 189                         : std::string{"none"})
 190                 << " myCommitment=" << (ourPos.myCommitment ? "yes" : "no")
 191                 << " myReveal=" << (ourPos.myReveal ? "yes" : "no");
 192 
 193             auto const commits = ext.pendingCommitCount();
 194             auto const quorum = ext.quorumThreshold();
 195             auto const commitQuorum = ext.hasQuorumOfCommits();
 196             auto const minReveals = ext.hasMinimumReveals();
 197             auto const anyReveals = ext.hasAnyReveals();
 198             auto const reveals = ext.pendingRevealCount();
 199             auto const likelyParticipants = ext.expectedProposerCount();
 200 
 201             JLOG(ext.j_.debug())
 202                 << "STALLDIAG: rng-counters"
 203                 << " commits=" << commits << " quorum=" << quorum
 204                 << " commitQuorum=" << (commitQuorum ? "yes" : "no")
 205                 << " reveals=" << std::to_string(reveals)
 206                 << " minReveals=" << (minReveals ? "yes" : "no")
 207                 << " anyReveals=" << (anyReveals ? "yes" : "no")
 208                 << " likelyParticipants=" << std::to_string(likelyParticipants);
 209 
 210             if constexpr (requires {
 211                               ext.observedParticipantCount();
 212                               ext.observedParticipantsHash();
 213                               ext.observedParticipantsBitmapBin();
 214                           })
 215             {
 216                 auto const observedHash = ext.observedParticipantsHash();
 217                 JLOG(ext.j_.debug())
 218                     << "STALLDIAG: participant-diagnostics"
 219                     << " observedActiveParticipants="
 220                     << ext.observedParticipantCount()
 221                     << " observedParticipantsHash="
 222                     << (observedHash ? to_string(*observedHash)
 223                                      : std::string{"none"})
 224                     << " bitmapBin=" << ext.observedParticipantsBitmapBin();
 225             }
 226         };
 227         auto publishEntropySet = [&]() {
 228             auto entropySetHash = ext.buildEntropySet(buildSeq);
 229             auto newPos = ctx.getPosition();
 230             if (newPos.entropySetHash &&
 231                 *newPos.entropySetHash == entropySetHash)
 232             {
 233                 JLOG(ext.j_.debug())
 234                     << "RNG: entropySet already published"
 235                     << " buildSeq=" << buildSeq << " hash=" << entropySetHash;
 236                 return;
 237             }
 238 
 239             newPos.entropySetHash = entropySetHash;
 240 
 241             ctx.updatePosition(newPos);
 242 
 243             // Publish entropySetHash before accepting so lagging peers
 244             // can fetch/merge reveal sets in ConvergingReveal.
 245             //
 246             // This can look redundant in healthy rounds because txSetHash
 247             // may be unchanged versus the prior proposal (for example,
 248             // seq=2 and seq=3 showing the same tx summary in monitors). We
 249             // still publish to create an additional delivery window for
 250             // entropySetHash and to trigger fetch/merge on peers that
 251             // missed earlier packets.
 252             if (ctx.mode == ConsensusMode::proposing)
 253                 ctx.propose();
 254 
 255             JLOG(ext.j_.debug())
 256                 << "RNG: published entropySet"
 257                 << " buildSeq=" << buildSeq << " hash=" << entropySetHash
 258                 << " proposing="
 259                 << (ctx.mode == ConsensusMode::proposing ? "yes" : "no");
 260         };
 261 
 262         JLOG(ext.j_.trace())
 263             << "RNG: phaseEstablish"
 264             << " buildSeq=" << buildSeq << " estState=" << estStateName()
 265             << " roundMs=" << ctx.roundTime.count()
 266             << " mode=" << to_string(ctx.mode);
 267 
 268         // Bootstrap fast-path: if the previous round didn't have
 269         // enough proposers for RNG to reach the entropy gate threshold, the
 270         // network is still converging.  Skip the entire commit/reveal
 271         // pipeline — it can only produce consensus_fallback anyway, but
 272         // each substate transition and timeout (PIPELINE_TIMEOUT,
 273         // REVEAL_TIMEOUT, conflict-wait) adds seconds of latency
 274         // per round that compound across staggered startup.
 275         //
 276         // Once prevProposers reaches the entropy gate threshold the pipeline
 277         // engages normally with all its coordination delays intact.
 278         bool rngBootstrapSkip = false;
 279         {
 280             auto const threshold = ext.entropyGateThreshold();
 281             // prevProposers is peer-only. Include our own proposer slot when
 282             // we are actively proposing, otherwise a 4/5 honest quorum appears
 283             // as only three previous proposers after one validator diverges.
 284             auto const previousParticipants = ctx.prevProposers +
 285                 (ctx.mode == ConsensusMode::proposing ? 1 : 0);
 286             if (previousParticipants < threshold)
 287             {
 288                 JLOG(ext.j_.debug())
 289                     << "RNG: bootstrap skip"
 290                     << " previousParticipants=" << previousParticipants
 291                     << " threshold=" << threshold
 292                     << " prevProposers=" << ctx.prevProposers
 293                     << " buildSeq=" << buildSeq;
 294                 rngBootstrapSkip = true;
 295             }
 296         }
 297 
 298         if (!rngBootstrapSkip && ext.estState_ == EstablishState::ConvergingTx)
 299         {
 300             // Commit quorum is fixed to 80% of the active UNL snapshot for
 301             // the round. We move immediately once that floor is met;
 302             // recent-proposer tracking is only for deciding whether more
 303             // waiting is worthwhile.
 304             if (ext.hasQuorumOfCommits())
 305             {
 306                 auto commitSetHash = ext.buildCommitSet(buildSeq);
 307 
 308                 // Keep the same entropy secret from onClose() — do NOT
 309                 // regenerate.  The commitment in the commitSet was built
 310                 // from that original secret; regenerating would make the
 311                 // later reveal fail verification.
 312                 auto newPos = ctx.getPosition();
 313                 newPos.commitSetHash = commitSetHash;
 314 
 315                 ctx.updatePosition(newPos);
 316 
 317                 if (ctx.mode == ConsensusMode::proposing)
 318                     ctx.propose();
 319 
 320                 ext.estState_ = EstablishState::ConvergingCommit;
 321                 ext.commitHashConflictStart_ = {};
 322                 JLOG(ext.j_.debug()) << "RNG: transitioned to ConvergingCommit"
 323                                      << " buildSeq=" << buildSeq
 324                                      << " commitSetHash=" << commitSetHash
 325                                      << " commits=" << ext.pendingCommitCount()
 326                                      << " quorum=" << ext.quorumThreshold();
 327                 return {};  // Wait for next tick
 328             }
 329 
 330             // Don't let the round close while waiting for commit quorum.
 331             // Without this gate, execution falls through to the normal
 332             // consensus close logic and nodes inject different entropy tiers
 333             // while others are still collecting — causing ledger
 334             // mismatches.
 335             //
 336             // However, if we've already converged on the txSet (which we
 337             // have — haveConsensus() passed above) and there aren't enough
 338             // currently participating validators to ever reach the entropy
 339             // gate threshold, skip immediately. For a badly degraded view below
 340             // the Tier 2 floor, waiting 3s per round just delays recovery.
 341             //
 342             // NOTE: Late-joining nodes (e.g. restarting after a crash)
 343             // cannot help here.  They enter the round as proposing=false
 344             // and onClose() skips commitment generation for non-proposers.
 345             // It takes at least one full round of observing before
 346             // consensus promotes them to proposing.
 347             {
 348                 // participants = peers + ourselves
 349                 auto const participants = ctx.peerPositions.size() + 1;
 350                 auto const threshold = ext.entropyGateThreshold();
 351                 bool const impossible = participants < threshold;
 352 
 353                 if (impossible)
 354                 {
 355                     JLOG(ext.j_.debug()) << "RNG: skipping commit wait"
 356                                          << " reason=impossible-entropy-gate"
 357                                          << " participants=" << participants
 358                                          << " threshold=" << threshold
 359                                          << " buildSeq=" << buildSeq;
 360                     logRngDiag("rng-commit-wait-impossible-entropy-gate");
 361                     // Fall through to close with consensus_fallback entropy.
 362                 }
 363                 else
 364                 {
 365                     bool timeout =
 366                         ctx.roundTime > ctx.parms.rngPIPELINE_TIMEOUT;
 367                     if (!timeout)
 368                     {
 369                         logRngDiag("rng-commit-wait");
 370                         return {};  // Wait for more commits
 371                     }
 372 
 373                     // Timeout waiting for additional likely participants.
 374                     // If we already meet the entropy-gate threshold (the lowest
 375                     // accepted tier's bar — min(quorum, tier2)), proceed with
 376                     // what we have — the SHAMap merge handles any remaining
 377                     // straggler fuzz for this transition round.
 378                     auto const commits = ext.pendingCommitCount();
 379                     auto const quorum = ext.entropyGateThreshold();
 380                     if (commits >= quorum)
 381                     {
 382                         JLOG(ext.j_.info())
 383                             << "RNG: commit timeout with entropy gate threshold"
 384                             << " buildSeq=" << buildSeq
 385                             << " commits=" << commits
 386                             << " entropyGateThreshold=" << quorum
 387                             << " roundMs=" << ctx.roundTime.count()
 388                             << " timeoutMs="
 389                             << ctx.parms.rngPIPELINE_TIMEOUT.count();
 390                         // Jump to the same path as ext.hasQuorumOfCommits
 391                         auto commitSetHash = ext.buildCommitSet(buildSeq);
 392                         auto newPos = ctx.getPosition();
 393                         newPos.commitSetHash = commitSetHash;
 394                         ctx.updatePosition(newPos);
 395                         if (ctx.mode == ConsensusMode::proposing)
 396                             ctx.propose();
 397                         ext.estState_ = EstablishState::ConvergingCommit;
 398                         ext.commitHashConflictStart_ = {};
 399                         JLOG(ext.j_.debug())
 400                             << "RNG: transitioned to ConvergingCommit"
 401                             << " reason=timeout-with-quorum"
 402                             << " buildSeq=" << buildSeq
 403                             << " commitSetHash=" << commitSetHash
 404                             << " commits=" << commits << " quorum=" << quorum;
 405                         return {};
 406                     }
 407                     logRngDiag("rng-commit-timeout-below-quorum");
 408                     // Truly below the entropy gate: fall through to
 409                     // consensus_fallback entropy.
 410                 }
 411             }
 412         }
 413         else if (
 414             !rngBootstrapSkip &&
 415             ext.estState_ == EstablishState::ConvergingCommit)
 416         {
 417             // If commit hashes diverge, we may not receive any additional
 418             // tx-converged proposals in this state (peers can move to the
 419             // next ledger quickly, causing prevLedger rejects). In that
 420             // case, hashes observed during ConvergingTx would never be
 421             // fetched because fetch is intentionally deferred there.
 422             //
 423             // Sweep currently tx-converged peer positions each tick so
 424             // deferred commitSet hashes still get fetched/merged even
 425             // without new accepted proposals in ConvergingCommit.
 426             {
 427                 auto const ourPos = ctx.getPosition();
 428                 for (auto const& [nodeId, peerPos] : ctx.peerPositions)
 429                 {
 430                     auto const& peerPosition = peerPos.proposal().position();
 431                     if (!(peerPosition == ourPos))
 432                         continue;
 433                     ext.fetchRngSetIfNeeded(
 434                         peerPosition.commitSetHash, Ext::SidecarKind::commit);
 435                 }
 436             }
 437 
 438             // Fast path: if no commit-set conflicts are observed, do
 439             // exactly what we did before (immediate reveal transition).
 440             //
 441             // Safety path: haveConsensus() only compares tx-set hash, not
 442             // RNG sidecar fields. So commitSetHash disagreements can exist
 443             // transiently even while tx consensus is true. We only add
 444             // delay when we *actually* observe conflicting non-empty
 445             // commitSetHash values among tx-converged positions.
 446 
 447             // --- hasConflictingCommitSetHashes logic (inlined) ---
 448             auto hasConflictingCommitSetHashes = [&]() -> bool {
 449                 auto const ourPos = ctx.getPosition();
 450                 std::optional<uint256> observed;
 451 
 452                 auto note = [&](auto const& pos) -> bool {
 453                     if (!pos.commitSetHash)
 454                         return false;
 455                     if (!observed)
 456                     {
 457                         observed = *pos.commitSetHash;
 458                         return false;
 459                     }
 460                     return *observed != *pos.commitSetHash;
 461                 };
 462 
 463                 if (note(ourPos))
 464                     return true;
 465 
 466                 for (auto const& [nodeId, peerPos] : ctx.peerPositions)
 467                 {
 468                     auto const& peerPosition = peerPos.proposal().position();
 469                     if (!(peerPosition == ourPos))
 470                         continue;
 471                     if (note(peerPosition))
 472                         return true;
 473                 }
 474                 return false;
 475             };
 476 
 477             if (hasConflictingCommitSetHashes())
 478             {
 479                 // Fetch/merge may have added missing commits since we last
 480                 // published our commitSetHash. Rebuild and re-publish so
 481                 // peers can converge on one deterministic hash instead of
 482                 // timing out.
 483                 auto pos = ctx.getPosition();
 484                 auto const previousHash = pos.commitSetHash;
 485                 auto const refreshedHash = ext.buildCommitSet(buildSeq);
 486                 if (!previousHash || *previousHash != refreshedHash)
 487                 {
 488                     pos.commitSetHash = refreshedHash;
 489                     ctx.updatePosition(pos);
 490 
 491                     if (ctx.mode == ConsensusMode::proposing)
 492                         ctx.propose();
 493 
 494                     JLOG(ext.j_.debug())
 495                         << "RNG: refreshed commitSetHash"
 496                         << " reason=merge"
 497                         << " buildSeq=" << buildSeq << " oldHash="
 498                         << (previousHash ? to_string(*previousHash)
 499                                          : std::string{"none"})
 500                         << " newHash=" << refreshedHash;
 501                 }
 502 
 503                 // Re-check after refreshing our own hash.
 504                 if (hasConflictingCommitSetHashes())
 505                 {
 506                     auto const nowSteady = ctx.nowSteady;
 507                     if (ext.commitHashConflictStart_ ==
 508                         std::chrono::steady_clock::time_point{})
 509                     {
 510                         // First observed conflict: start a bounded grace
 511                         // window so benign ordering/fetch races can settle.
 512                         ext.commitHashConflictStart_ = nowSteady;
 513                         JLOG(ext.j_.warn())
 514                             << "RNG: conflicting commitSetHash detected"
 515                             << " buildSeq=" << buildSeq << " deadlineMs="
 516                             << toMs(ctx.parms.rngREVEAL_TIMEOUT)
 517                             << " action=wait-for-fetch";
 518                         logRngDiag("rng-commit-conflict-start");
 519                         return {};
 520                     }
 521 
 522                     auto const conflictElapsed =
 523                         nowSteady - ext.commitHashConflictStart_;
 524                     if (conflictElapsed <= ctx.parms.rngREVEAL_TIMEOUT)
 525                     {
 526                         // We are still inside the grace window, so keep
 527                         // waiting. This preserves the fast path when peers
 528                         // converge after a short delay.
 529                         JLOG(ext.j_.debug())
 530                             << "RNG: commitSetHash conflict wait"
 531                             << " buildSeq=" << buildSeq
 532                             << " elapsedMs=" << toMs(conflictElapsed)
 533                             << " deadlineMs="
 534                             << toMs(ctx.parms.rngREVEAL_TIMEOUT)
 535                             << " state=ConvergingCommit";
 536                         logRngDiag("rng-commit-conflict-wait");
 537                         return {};
 538                     }
 539 
 540                     // If conflict persists past a bounded wait, force
 541                     // deterministic fallback for this round.
 542                     ext.setEntropyFailed();
 543                     ext.estState_ = EstablishState::ConvergingReveal;
 544                     // Backdate ext.revealPhaseStart_ so the ConvergingReveal
 545                     // timeout path fires immediately next tick.
 546                     ext.revealPhaseStart_ = nowSteady -
 547                         ctx.parms.rngREVEAL_TIMEOUT -
 548                         std::chrono::milliseconds{1};
 549                     ext.commitHashConflictStart_ = {};
 550                     JLOG(ext.j_.warn())
 551                         << "RNG: commitSetHash conflict timeout"
 552                         << " buildSeq=" << buildSeq
 553                         << " elapsedMs=" << toMs(conflictElapsed)
 554                         << " deadlineMs=" << toMs(ctx.parms.rngREVEAL_TIMEOUT)
 555                         << " action=consensus-fallback";
 556                     logRngDiag("rng-commit-conflict-timeout-fallback");
 557                     return {};
 558                 }
 559             }
 560 
 561             ext.commitHashConflictStart_ = {};
 562 
 563             //@@start rng-reveal-transition
 564             auto newPos = ctx.getPosition();
 565             newPos.myReveal = ext.getEntropySecret();
 566 
 567             // Self-seed our own reveal into pendingReveals so it
 568             // counts toward reveal quorum and appears in the
 569             // entropy set.  harvestRngData only sees peer proposals,
 570             // not our own.
 571             ext.selfSeedReveal();
 572 
 573             ctx.updatePosition(newPos);
 574 
 575             if (ctx.mode == ConsensusMode::proposing)
 576                 ctx.propose();
 577 
 578             ext.estState_ = EstablishState::ConvergingReveal;
 579             //@@end rng-reveal-transition
 580             ext.revealPhaseStart_ = ctx.nowSteady;
 581             JLOG(ext.j_.debug()) << "RNG: transitioned to ConvergingReveal"
 582                                  << " buildSeq=" << buildSeq
 583                                  << " reveal=" << ext.getEntropySecret()
 584                                  << " reveals=" << ext.pendingRevealCount();
 585 
 586             // Fast path:
 587             // If all required reveals are already present at transition
 588             // time, publish entropySet immediately and finish in this timer
 589             // pass. This is state-based (reveal completeness), not tied to
 590             // any particular proposal sequence number.
 591             if (ext.hasMinimumReveals())
 592             {
 593                 publishEntropySet();
 594                 ext.entropySetPublished_ = true;
 595                 ext.entropyPublishStart_ = ctx.nowSteady;
 596                 JLOG(ext.j_.debug()) << "RNG: fast-path published entropySet"
 597                                      << " buildSeq=" << buildSeq
 598                                      << " action=wait-for-peer-observation";
 599                 logRngDiag("rng-reveal-fast-path-entropy-published-wait");
 600                 return {};
 601             }
 602             else
 603             {
 604                 logRngDiag("rng-reveal-wait-after-transition");
 605                 return {};  // Wait for next tick
 606             }
 607         }
 608         else if (
 609             !rngBootstrapSkip &&
 610             ext.estState_ == EstablishState::ConvergingReveal)
 611         {
 612             // Wait for ALL committers to reveal (not just 80%).
 613             // Timeout measured from ConvergingReveal entry, not round
 614             // start.
 615             auto const elapsed = ctx.nowSteady - ext.revealPhaseStart_;
 616             bool timeout = elapsed > ctx.parms.rngREVEAL_TIMEOUT;
 617             bool ready = false;
 618             bool const revealConsensus =
 619                 ctx.haveConsensus() && ext.hasMinimumReveals();
 620 
 621             if (revealConsensus || timeout)
 622             {
 623                 JLOG(ext.j_.debug())
 624                     << "STALLDIAG: rng-reveal-gate-open"
 625                     << " revealConsensus=" << (revealConsensus ? "yes" : "no")
 626                     << " timeout=" << (timeout ? "yes" : "no")
 627                     << " elapsedMs=" << toMs(elapsed)
 628                     << " deadlineMs=" << toMs(ctx.parms.rngREVEAL_TIMEOUT)
 629                     << " buildSeq=" << buildSeq;
 630                 if (timeout && !ext.hasAnyReveals())
 631                 {
 632                     ext.setEntropyFailed();
 633                     JLOG(ext.j_.warn())
 634                         << "RNG: entropy failed"
 635                         << " reason=no-reveals"
 636                         << " buildSeq=" << buildSeq
 637                         << " elapsedMs=" << toMs(elapsed)
 638                         << " deadlineMs=" << toMs(ctx.parms.rngREVEAL_TIMEOUT);
 639                     logRngDiag("rng-reveal-timeout-no-reveals");
 640                 }
 641                 else
 642                 {
 643                     publishEntropySet();
 644                     logRngDiag("rng-reveal-published-entropy-set");
 645                 }
 646                 ready = true;
 647             }
 648 
 649             if (!ready)
 650             {
 651                 JLOG(ext.j_.debug())
 652                     << "STALLDIAG: rng-reveal-gate-blocked"
 653                     << " revealConsensus=" << (revealConsensus ? "yes" : "no")
 654                     << " timeout=" << (timeout ? "yes" : "no")
 655                     << " elapsedMs=" << toMs(elapsed)
 656                     << " deadlineMs=" << toMs(ctx.parms.rngREVEAL_TIMEOUT)
 657                     << " buildSeq=" << buildSeq;
 658                 logRngDiag("rng-reveal-wait");
 659                 return {};
 660             }
 661 
 662             // --- EntropySetHash convergence gate ---
 663             //
 664             // After publishing our entropySet, ensure peers have had
 665             // at least one observation window to see our hash (and us
 666             // theirs) before accepting.  Without this, a node can
 667             // publish + accept in the same tick, never seeing a peer's
 668             // different hash — causing asymmetric validator/fallback entropy
 669             // and a ledger fork.
 670             //
 671             // The gate works in two phases:
 672             //   1. First tick after publishing: always wait (return {})
 673             //      to give proposals time to propagate.
 674             //   2. Subsequent ticks: check for conflict, fetch/merge/
 675             //      rebuild if needed, bounded by deadline.
 676             //
 677             // Same pattern as commitSetHash conflict handling (line ~308)
 678             // and exportSigSetHash convergence gate (line ~674).
 679             {
 680                 auto const ourPos = ctx.getPosition();
 681                 if (ourPos.entropySetHash)
 682                 {
 683                     // Phase 1: on the tick we first published, always
 684                     // wait one more tick for observation.
 685                     if (!ext.entropySetPublished_)
 686                     {
 687                         ext.entropySetPublished_ = true;
 688                         ext.entropyPublishStart_ = ctx.nowSteady;
 689                         JLOG(ext.j_.debug())
 690                             << "RNG: entropySet first published"
 691                             << " buildSeq=" << buildSeq
 692                             << " hash=" << *ourPos.entropySetHash
 693                             << " action=wait-for-peer-observation";
 694                         logRngDiag("rng-entropy-hash-first-publish-wait");
 695                         return {};
 696                     }
 697 
 698                     // Phase 2: check peer agreement.  Extension hashes do not
 699                     // participate in tx-set equality, so a different
 700                     // entropySetHash is an RNG-side disagreement to resolve or
 701                     // zero out, not something that should block ordinary
 702                     // tx-set consensus indefinitely.
 703                     auto inspectEntropyPeers = [&](auto const& pos,
 704                                                    bool fetchMismatches) {
 705                         return detail::inspectTxConvergedSidecarPeers(
 706                             ctx.peerPositions,
 707                             pos,
 708                             [](auto const& position) {
 709                                 return position.entropySetHash;
 710                             },
 711                             [&](auto const& hash) {
 712                                 if (fetchMismatches)
 713                                     ext.fetchRngSetIfNeeded(
 714                                         hash, Ext::SidecarKind::reveal);
 715                             });
 716                     };
 717 
 718                     auto entropyState = inspectEntropyPeers(ourPos, true);
 719                     auto const entropyQuorum = ext.entropyGateThreshold();
 720                     auto quorumAligned = [&] {
 721                         return entropyState.quorumAligned(entropyQuorum);
 722                     };
 723                     auto fullObservation = [&] {
 724                         // Local quorum alignment is not enough if some
 725                         // tx-converged peers have not advertised their
 726                         // entropy sidecar hash yet. Otherwise one node can
 727                         // accept non-zero from an asymmetric local view while
 728                         // the rest of the network times out to zero.
 729                         return entropyState.fullObservation();
 730                     };
 731                     auto clearEntropyHash = [&] {
 732                         auto failedPos = ctx.getPosition();
 733                         if (!failedPos.entropySetHash)
 734                             return;
 735                         failedPos.entropySetHash.reset();
 736                         ctx.updatePosition(failedPos);
 737                         if (ctx.mode == ConsensusMode::proposing)
 738                             ctx.propose();
 739                     };
 740 
 741                     if (entropyState.conflict && !quorumAligned())
 742                     {
 743                         // Rebuild our entropy set after any merges.
 744                         auto const refreshedHash =
 745                             ext.buildEntropySet(buildSeq);
 746                         if (refreshedHash != *ourPos.entropySetHash)
 747                         {
 748                             auto newPos = ctx.getPosition();
 749                             newPos.entropySetHash = refreshedHash;
 750                             ctx.updatePosition(newPos);
 751                             if (ctx.mode == ConsensusMode::proposing)
 752                                 ctx.propose();
 753                             JLOG(ext.j_.debug())
 754                                 << "RNG: refreshed entropySetHash"
 755                                 << " reason=merge"
 756                                 << " buildSeq=" << buildSeq
 757                                 << " oldHash=" << *ourPos.entropySetHash
 758                                 << " newHash=" << refreshedHash;
 759                         }
 760 
 761                         // Re-check against the current local hash.  Any peer
 762                         // that still advertises a different entropySetHash is
 763                         // unresolved until it converges or the bounded RNG
 764                         // window expires and forces consensus_fallback.
 765                         entropyState =
 766                             inspectEntropyPeers(ctx.getPosition(), true);
 767                     }
 768 
 769                     if (entropyState.conflict && quorumAligned() &&
 770                         fullObservation())
 771                     {
 772                         JLOG(ext.j_.debug())
 773                             << "RNG: entropySetHash conflict ignored"
 774                             << " reason=quorum-aligned"
 775                             << " buildSeq=" << buildSeq
 776                             << " alignedParticipants="
 777                             << entropyState.alignedParticipants()
 778                             << " quorum=" << entropyQuorum
 779                             << " peersSeen=" << entropyState.peersSeen
 780                             << " txConverged=" << entropyState.txConverged;
 781                     }
 782                     else if (entropyState.conflict)
 783                     {
 784                         // Bounded grace window for unresolved entropy-side
 785                         // conflicts.
 786                         auto const entropyElapsed =
 787                             ctx.nowSteady - ext.entropyPublishStart_;
 788                         auto const entropyDeadline =
 789                             ctx.parms.rngREVEAL_TIMEOUT * 2;
 790                         if (entropyElapsed <= entropyDeadline)
 791                         {
 792                             JLOG(ext.j_.debug())
 793                                 << "RNG: entropySetHash conflict wait"
 794                                 << " buildSeq=" << buildSeq
 795                                 << " elapsedMs=" << toMs(entropyElapsed)
 796                                 << " deadlineMs=" << toMs(entropyDeadline)
 797                                 << " alignedParticipants="
 798                                 << entropyState.alignedParticipants()
 799                                 << " quorum=" << entropyQuorum
 800                                 << " peersSeen=" << entropyState.peersSeen
 801                                 << " txConverged=" << entropyState.txConverged;
 802                             logRngDiag("rng-entropy-hash-conflict-wait");
 803                             return {};
 804                         }
 805 
 806                         // Deadline exceeded — fall back to consensus_fallback.
 807                         ext.setEntropyFailed();
 808                         clearEntropyHash();
 809                         JLOG(ext.j_.warn())
 810                             << "RNG: entropySetHash conflict timeout"
 811                             << " buildSeq=" << buildSeq
 812                             << " elapsedMs=" << toMs(entropyElapsed)
 813                             << " deadlineMs=" << toMs(entropyDeadline)
 814                             << " action=consensus-fallback"
 815                             << " alignedParticipants="
 816                             << entropyState.alignedParticipants()
 817                             << " entropyGateThreshold=" << entropyQuorum
 818                             << " peersSeen=" << entropyState.peersSeen
 819                             << " txConverged=" << entropyState.txConverged;
 820                         logRngDiag("rng-entropy-hash-conflict-timeout");
 821                     }
 822 
 823                     // Positive alignment check: require at least one
 824                     // tx-converged entropy-gate cohort with a matching
 825                     // entropySetHash before accepting validator-derived
 826                     // entropy, and require every
 827                     // tx-converged peer we are counting to have advertised
 828                     // some entropySetHash.  Without the full-observation
 829                     // part, asymmetric proposal delivery lets a node accept
 830                     // validator-derived entropy while peers that are still
 831                     // missing sidecar hashes hit the deadline and
 832                     // deterministically fall back.
 833                     if (!entropyState.conflict &&
 834                         (!quorumAligned() || !fullObservation()))
 835                     {
 836                         auto const entropyElapsed =
 837                             ctx.nowSteady - ext.entropyPublishStart_;
 838                         auto const entropyDeadline =
 839                             ctx.parms.rngREVEAL_TIMEOUT * 2;
 840                         if (entropyElapsed <= entropyDeadline)
 841                         {
 842                             JLOG(ext.j_.debug())
 843                                 << "RNG: waiting for entropySetHash entropy "
 844                                    "gate alignment"
 845                                 << " buildSeq=" << buildSeq
 846                                 << " alignedParticipants="
 847                                 << entropyState.alignedParticipants()
 848                                 << " entropyGateThreshold=" << entropyQuorum
 849                                 << " peersSeen=" << entropyState.peersSeen
 850                                 << " txConverged=" << entropyState.txConverged
 851                                 << " elapsedMs=" << toMs(entropyElapsed)
 852                                 << " deadlineMs=" << toMs(entropyDeadline);
 853                             logRngDiag("rng-entropy-hash-quorum-wait");
 854                             return {};
 855                         }
 856                         ext.setEntropyFailed();
 857                         clearEntropyHash();
 858                         JLOG(ext.j_.warn())
 859                             << "RNG: entropySetHash entropy gate alignment "
 860                                "timeout"
 861                             << " buildSeq=" << buildSeq
 862                             << " action=consensus-fallback"
 863                             << " alignedParticipants="
 864                             << entropyState.alignedParticipants()
 865                             << " entropyGateThreshold=" << entropyQuorum
 866                             << " peersSeen=" << entropyState.peersSeen
 867                             << " txConverged=" << entropyState.txConverged
 868                             << " elapsedMs=" << toMs(entropyElapsed)
 869                             << " deadlineMs=" << toMs(entropyDeadline);
 870                         logRngDiag("rng-entropy-hash-quorum-timeout");
 871                     }
 872 
 873                     JLOG(ext.j_.debug())
 874                         << "RNG: entropy gate"
 875                         << " buildSeq=" << buildSeq
 876                         << " aligned=" << entropyState.aligned
 877                         << " alignedParticipants="
 878                         << entropyState.alignedParticipants()
 879                         << " entropyGateThreshold=" << entropyQuorum
 880                         << " peersSeen=" << entropyState.peersSeen
 881                         << " txConverged=" << entropyState.txConverged
 882                         << " conflict="
 883                         << (entropyState.conflict ? "yes" : "no");
 884                 }
 885             }
 886 
 887             // Optional explicit final proposal (seq=4 style):
 888             // publish a synthetic tx-set hash that includes the
 889             // consensus-entropy pseudo-tx just before accept.
 890             //
 891             // IMPORTANT DESIGN NOTE (read before editing this block):
 892             //
 893             // This path is intentionally OPTIONAL and default-off. It
 894             // exists for diagnostics/perf experiments (for example, making
 895             // monitor visibility of the final pseudo-tx set more direct),
 896             // NOT as a required step for consensus correctness.
 897             //
 898             // Why so conservative?
 899             // - The main consensus engine still keys agreement on tx-set
 900             // hash.
 901             // - Updating our tx-set hash here creates a "late identity
 902             //   change" in establish.
 903             // - Under lossy/reordered networks, peers can be slightly out
 904             // of
 905             //   phase: some nodes may have switched to the synthetic hash
 906             //   while others are still on the base hash.
 907             // - That can fragment agreement during a critical window (two
 908             //   hashes in flight for one ledger), increase proposal
 909             //   chatter, and trigger sync churn.
 910             //
 911             // Therefore this logic must remain best-effort only:
 912             // - Never required for liveness/safety.
 913             // - No extra wait tick is introduced.
 914             // - If gates are not met, we skip and continue to accept via
 915             // the
 916             //   normal implicit path (accept-time pseudo-tx injection).
 917             //
 918             // TBD (2026-03-03): We did not find a robust timing model that
 919             // folds this into a guaranteed-safe explicit final proposal
 920             // across lossy/reordered links without increasing churn. Keep
 921             // this path as opt-in for future evaluation.
 922             {
 923                 bool fullParticipantCoverage = false;
 924                 bool entropyAligned = false;
 925                 {
 926                     // Guard against "early switch" churn:
 927                     // require at least as many participants as the previous
 928                     // round before attempting the explicit-final mutation.
 929                     //
 930                     // This is a heuristic to reduce risk, not a proof of
 931                     // safety. We still keep the feature
 932                     // optional/default-off.
 933                     auto const participants = ctx.peerPositions.size() + 1;
 934                     auto const expectedParticipants = ctx.prevProposers + 1;
 935                     fullParticipantCoverage =
 936                         participants >= expectedParticipants;
 937                     // Require a majority aligned on entropySetHash before
 938                     // mutating tx-set hash. If this threshold is loosened,
 939                     // the probability of hash fragmentation rises quickly.
 940                     auto const requiredEntropyAligned =
 941                         (expectedParticipants / 2) + 1;
 942                     auto const ourPos = ctx.getPosition();
 943                     if (ourPos.entropySetHash)
 944                     {
 945                         auto const expectedEntropy = *ourPos.entropySetHash;
 946                         std::size_t alignedPeers = 0;
 947                         bool conflict = false;
 948                         for (auto const& [_, peerPos] : ctx.peerPositions)
 949                         {
 950                             auto const& peerPosition =
 951                                 peerPos.proposal().position();
 952                             if (!peerPosition.entropySetHash)
 953                                 continue;
 954                             if (*peerPosition.entropySetHash == expectedEntropy)
 955                             {
 956                                 ++alignedPeers;
 957                                 continue;
 958                             }
 959                             conflict = true;
 960                             break;
 961                         }
 962 
 963                         auto const alignedParticipants = alignedPeers + 1;
 964                         entropyAligned = !conflict &&
 965                             alignedParticipants >= requiredEntropyAligned;
 966                         if (!entropyAligned)
 967                         {
 968                             JLOG(ext.j_.debug())
 969                                 << "RNG: explicit-final entropy alignment "
 970                                    "insufficient"
 971                                 << " buildSeq=" << buildSeq
 972                                 << " alignedParticipants="
 973                                 << alignedParticipants
 974                                 << " required=" << requiredEntropyAligned
 975                                 << " conflict=" << (conflict ? "yes" : "no");
 976                         }
 977                     }
 978                     else
 979                     {
 980                         JLOG(ext.j_.debug())
 981                             << "RNG: explicit-final waiting"
 982                             << " reason=missing-local-entropySetHash"
 983                             << " buildSeq=" << buildSeq;
 984                     }
 985                 }
 986 
 987                 if (ctx.mode == ConsensusMode::proposing &&
 988                     !ext.explicitFinalProposalSent_ &&
 989                     ext.hasQuorumOfCommits() && revealConsensus &&
 990                     fullParticipantCoverage && entropyAligned &&
 991                     ext.shouldSendExplicitFinalProposal())
 992                 {
 993                     // One-shot per round. This avoids repeated mutations/
 994                     // broadcasts from timer ticks, which can amplify
 995                     // network chatter in the exact conditions
 996                     // (loss/reordering) where this path is already fragile.
 997                     auto const synthSet = ext.buildExplicitFinalProposalTxSet(
 998                         ctx.getTxns(), buildSeq);
 999                     ext.explicitFinalProposalSent_ = true;
1000 
1001                     if (synthSet)
1002                     {
1003                         auto const synthHash = synthSet->id();
1004                         auto currentPos = ctx.getPosition();
1005                         auto newPos = currentPos;
1006                         newPos.updateTxSet(synthHash);
1007 
1008                         if (!(newPos == currentPos))
1009                         {
1010                             // WARNING:
1011                             // This changes proposal tx-set identity late in
1012                             // establish. Keep this path tightly gated and
1013                             // optional. The canonical ledger path remains
1014                             // the implicit accept-time injection logic.
1015 
1016                             // Maintain the invariant that our active
1017                             // position's tx-set hash is present in
1018                             // acquired_, otherwise gotTxSet can assert if
1019                             // this set arrives back from the network.
1020                             ctx.cacheAndShareTxSet(*synthSet);
1021                             JLOG(ext.j_.debug())
1022                                 << "RNG: cached explicit-final txSet"
1023                                 << " buildSeq=" << buildSeq
1024                                 << " txSet=" << synthHash;
1025                             ctx.updatePosition(newPos);
1026                             ctx.propose();
1027                             JLOG(ext.j_.debug())
1028                                 << "RNG: explicit final proposal"
1029                                 << " buildSeq=" << buildSeq
1030                                 << " txSet=" << synthHash;
1031                             logRngDiag("rng-explicit-final-proposed");
1032                         }
1033                     }
1034                 }
1035                 else
1036                 {
1037                     char const* reason = "disabled";
1038                     if (ctx.mode != ConsensusMode::proposing)
1039                         reason = "not-proposing";
1040                     else if (ext.explicitFinalProposalSent_)
1041                         reason = "already-sent";
1042                     else if (!ext.hasQuorumOfCommits())
1043                         reason = "no-commit-quorum";
1044                     else if (!revealConsensus)
1045                         reason = "reveal-timeout";
1046                     else if (!fullParticipantCoverage)
1047                         reason = "participant-gap";
1048                     else if (!entropyAligned)
1049                         reason = "entropy-not-aligned";
1050                     JLOG(ext.j_.debug())
1051                         << "STALLDIAG: rng-explicit-final-skipped"
1052                         << " reason=" << reason << " buildSeq=" << buildSeq
1053                         << " mode=" << to_string(ctx.mode) << " sent="
1054                         << (ext.explicitFinalProposalSent_ ? "yes" : "no");
1055                 }
1056             }
1057         }
1058     }
1059     else
1060     {
1061         JLOG(ext.j_.debug())
1062             << "RNGGATE: skipping RNG substates"
1063             << " buildSeq=" << ctx.buildSeq
1064             << " prevSeq=" << (static_cast<std::uint32_t>(ctx.buildSeq) - 1)
1065             << " mode=" << to_string(ctx.mode);
1066     }

5) Export sig convergence gate (parallel with RNG)

📍 src/xrpld/consensus/ConsensusExtensionsTick.h:1070-1315

1070     // Export sig convergence gate: runs after RNG sub-states when Export has
1071     // verified signatures to converge, or when a tx-converged peer advertises
1072     // an exportSigSetHash we may need to fetch. This is a bounded safety
1073     // coordination window, not a wait-for-Export-success mechanism.
1074     if constexpr (requires { ctx.getPosition().exportSigSetHash; })
1075     {
1076         if (!ext.exportEnabled())
1077             return {.readyForAccept = true};
1078 
1079         auto startExportSigGate = [&]() -> bool {
1080             if (ext.exportSigGateStarted_)
1081                 return false;
1082             ext.exportSigGateStarted_ = true;
1083             ext.exportSigGateStart_ = ctx.nowSteady;
1084             return true;
1085         };
1086 
1087         auto fetchPeerExportSigSets = [&](auto const& pos) {
1088             std::size_t peerSets = 0;
1089             for (auto const& [_, peerPos] : ctx.peerPositions)
1090             {
1091                 auto const& pp = peerPos.proposal().position();
1092                 if (!(pp == pos))
1093                     continue;  // not tx-converged
1094                 if (!pp.exportSigSetHash)
1095                     continue;
1096 
1097                 ++peerSets;
1098                 ext.fetchRngSetIfNeeded(
1099                     pp.exportSigSetHash, Ext::SidecarKind::exportSig);
1100             }
1101             return peerSets;
1102         };
1103 
1104         bool hasLocalExportSigs = ext.hasPendingExportSigs();
1105         if (!hasLocalExportSigs)
1106         {
1107             auto const peerSets = fetchPeerExportSigSets(ctx.getPosition());
1108             if (peerSets > 0)
1109             {
1110                 startExportSigGate();
1111                 hasLocalExportSigs = ext.hasPendingExportSigs();
1112                 if (!hasLocalExportSigs)
1113                 {
1114                     auto const elapsed =
1115                         ctx.nowSteady - ext.exportSigGateStart_;
1116                     auto const deadline = ctx.parms.rngREVEAL_TIMEOUT * 2;
1117                     if (elapsed <= deadline)
1118                     {
1119                         JLOG(ext.j_.debug())
1120                             << "Export: bounded wait for advertised "
1121                                "exportSigSet fetch/merge"
1122                             << " buildSeq=" << ctx.buildSeq
1123                             << " peerSets=" << peerSets
1124                             << " elapsedMs=" << toMs(elapsed)
1125                             << " deadlineMs=" << toMs(deadline);
1126                         return {};
1127                     }
1128 
1129                     ext.setExportSigConvergenceFailed();
1130                     JLOG(ext.j_.warn())
1131                         << "Export: advertised exportSigSet fetch timeout"
1132                         << " buildSeq=" << ctx.buildSeq
1133                         << " peerSets=" << peerSets
1134                         << " elapsedMs=" << toMs(elapsed)
1135                         << " deadlineMs=" << toMs(deadline)
1136                         << " action=retry-or-expire";
1137                 }
1138             }
1139             else if (ext.hasConsensusExportTxns())
1140             {
1141                 // A candidate ttEXPORT with no local sig material gets one
1142                 // short observation window so an already-reachable peer
1143                 // exportSigSetHash can be fetched before apply. If nothing
1144                 // appears in time, apply takes the retry/expire path.
1145                 startExportSigGate();
1146                 auto const elapsed = ctx.nowSteady - ext.exportSigGateStart_;
1147                 auto const deadline = ctx.parms.rngREVEAL_TIMEOUT * 2;
1148                 if (elapsed <= deadline)
1149                 {
1150                     JLOG(ext.j_.debug())
1151                         << "Export: bounded wait for exportSigSet "
1152                            "advertisement"
1153                         << " buildSeq=" << ctx.buildSeq
1154                         << " elapsedMs=" << toMs(elapsed)
1155                         << " deadlineMs=" << toMs(deadline)
1156                         << " candidateExportTxns=yes";
1157                     return {};
1158                 }
1159 
1160                 ext.setExportSigConvergenceFailed();
1161                 JLOG(ext.j_.warn())
1162                     << "Export: exportSigSet advertisement timeout"
1163                     << " buildSeq=" << ctx.buildSeq
1164                     << " elapsedMs=" << toMs(elapsed)
1165                     << " deadlineMs=" << toMs(deadline)
1166                     << " action=retry-or-expire";
1167             }
1168         }
1169 
1170         if (hasLocalExportSigs)
1171         {
1172             //@@start export-publish-sigset-hash
1173             auto const buildSeqExport = ctx.buildSeq;
1174             auto const exportHash = ext.buildExportSigSet(buildSeqExport);
1175 
1176             auto currentPos = ctx.getPosition();
1177             bool const publishedNewHash = !currentPos.exportSigSetHash ||
1178                 *currentPos.exportSigSetHash != exportHash;
1179             if (publishedNewHash)
1180             {
1181                 currentPos.exportSigSetHash = exportHash;
1182                 ctx.updatePosition(currentPos);
1183 
1184                 if (ctx.mode == ConsensusMode::proposing)
1185                     ctx.propose();
1186 
1187                 JLOG(ext.j_.debug())
1188                     << "Export: published exportSigSetHash"
1189                     << " buildSeq=" << buildSeqExport << " hash=" << exportHash;
1190             }
1191             //@@end export-publish-sigset-hash
1192 
1193             //@@start export-sigset-conflict-wait
1194             // Check quorum agreement on exportSigSetHash. Like RNG entropy,
1195             // Export success is an accept-time derived effect outside tx-set
1196             // equality. A local-only quorum must not succeed unless enough
1197             // tx-converged peers advertise the same export sig sidecar hash.
1198             {
1199                 if (startExportSigGate() || publishedNewHash)
1200                 {
1201                     JLOG(ext.j_.debug()) << "Export: exportSigSet published"
1202                                          << " buildSeq=" << buildSeqExport
1203                                          << " hash=" << exportHash
1204                                          << " action=wait-for-peer-observation";
1205                     return {};
1206                 }
1207 
1208                 auto inspectExportPeers = [&](auto const& pos,
1209                                               bool fetchMismatches) {
1210                     return detail::inspectTxConvergedSidecarPeers(
1211                         ctx.peerPositions,
1212                         pos,
1213                         [](auto const& position) {
1214                             return position.exportSigSetHash;
1215                         },
1216                         [&](auto const& hash) {
1217                             if (fetchMismatches)
1218                                 ext.fetchRngSetIfNeeded(
1219                                     hash, Ext::SidecarKind::exportSig);
1220                         });
1221                 };
1222 
1223                 auto exportState = inspectExportPeers(ctx.getPosition(), true);
1224                 auto const exportQuorum = ext.exportSigQuorumThreshold();
1225                 auto quorumAligned = [&] {
1226                     return exportState.quorumAligned(exportQuorum);
1227                 };
1228                 auto fullObservation = [&] {
1229                     // Export success changes ledger effects too. Require a
1230                     // full view of tx-converged peers before treating a local
1231                     // quorum as safe enough to succeed in this ledger.
1232                     return exportState.fullObservation();
1233                 };
1234 
1235                 if (exportState.conflict && !quorumAligned())
1236                 {
1237                     auto const refreshedHash =
1238                         ext.buildExportSigSet(buildSeqExport);
1239                     auto current = ctx.getPosition();
1240                     if (!current.exportSigSetHash ||
1241                         *current.exportSigSetHash != refreshedHash)
1242                     {
1243                         current.exportSigSetHash = refreshedHash;
1244                         ctx.updatePosition(current);
1245                         if (ctx.mode == ConsensusMode::proposing)
1246                             ctx.propose();
1247                         JLOG(ext.j_.debug())
1248                             << "Export: refreshed exportSigSetHash"
1249                             << " reason=merge"
1250                             << " buildSeq=" << buildSeqExport << " oldHash="
1251                             << (current.exportSigSetHash
1252                                     ? to_string(*current.exportSigSetHash)
1253                                     : std::string{"none"})
1254                             << " newHash=" << refreshedHash;
1255                     }
1256 
1257                     exportState = inspectExportPeers(ctx.getPosition(), true);
1258                 }
1259 
1260                 if (exportState.conflict && quorumAligned() &&
1261                     fullObservation())
1262                 {
1263                     JLOG(ext.j_.info())
1264                         << "Export: exportSigSetHash conflict ignored"
1265                         << " reason=quorum-aligned"
1266                         << " buildSeq=" << buildSeqExport
1267                         << " alignedParticipants="
1268                         << exportState.alignedParticipants()
1269                         << " quorum=" << exportQuorum
1270                         << " peersSeen=" << exportState.peersSeen
1271                         << " txConverged=" << exportState.txConverged;
1272                 }
1273                 else if (
1274                     exportState.conflict || !quorumAligned() ||
1275                     !fullObservation())
1276                 {
1277                     auto const elapsed =
1278                         ctx.nowSteady - ext.exportSigGateStart_;
1279                     auto const deadline = ctx.parms.rngREVEAL_TIMEOUT * 2;
1280                     if (elapsed <= deadline)
1281                     {
1282                         JLOG(ext.j_.debug())
1283                             << "Export: waiting for exportSigSet quorum "
1284                                "alignment"
1285                             << " buildSeq=" << buildSeqExport
1286                             << " alignedParticipants="
1287                             << exportState.alignedParticipants()
1288                             << " quorum=" << exportQuorum
1289                             << " peersSeen=" << exportState.peersSeen
1290                             << " txConverged=" << exportState.txConverged
1291                             << " conflict="
1292                             << (exportState.conflict ? "yes" : "no")
1293                             << " elapsedMs=" << toMs(elapsed)
1294                             << " deadlineMs=" << toMs(deadline);
1295                         return {};
1296                     }
1297 
1298                     ext.setExportSigConvergenceFailed();
1299                     JLOG(ext.j_.warn())
1300                         << "Export: exportSigSet quorum alignment timeout"
1301                         << " buildSeq=" << buildSeqExport
1302                         << " action=retry-or-expire"
1303                         << " alignedParticipants="
1304                         << exportState.alignedParticipants()
1305                         << " quorum=" << exportQuorum
1306                         << " peersSeen=" << exportState.peersSeen
1307                         << " txConverged=" << exportState.txConverged
1308                         << " conflict=" << (exportState.conflict ? "yes" : "no")
1309                         << " elapsedMs=" << toMs(elapsed)
1310                         << " deadlineMs=" << toMs(deadline);
1311                 }
1312             }
1313             //@@end export-sigset-conflict-wait
1314         }
1315     }

6) Sidecar SHAMap construction: commit proofs, deterministic reveal leaves, export sig sidecars

📍 src/xrpld/app/consensus/ConsensusExtensions.cpp:652-714

 652     // Track the active RNG round explicitly. Nodes in observing/switching
 653     // mode can have a closed ledger index behind the consensus round while
 654     // still needing to fetch/merge that round's RNG sets.
 655     rngRoundSeq_ = seq;
 656 
 657     auto map =
 658         std::make_shared<SHAMap>(SHAMapType::SIDECAR, app_.getNodeFamily());
 659     map->setUnbacked();
 660 
 661     auto const validatorView = activeValidatorView();
 662     std::size_t entryCount = 0;
 663     // NOTE: avoid structured bindings in for-loops containing lambdas —
 664     // clang-14 (CI) rejects capturing them (P2036R3 not implemented).
 665     for (auto const& entry : pendingCommits_)
 666     {
 667         auto const& nid = entry.first;
 668         auto const& commit = entry.second;
 669 
 670         // Commit sidecars are consensus inputs, so only publish leaves from
 671         // the frozen validator view used by quorum calculation.
 672         if (!validatorView->containsNode(nid))
 673             continue;
 674 
 675         auto kit = nodeIdToKey_.find(nid);
 676         if (kit == nodeIdToKey_.end())
 677             continue;
 678         auto proofIt = commitProofs_.find(nid);
 679         if (proofIt == commitProofs_.end())
 680             continue;
 681 
 682         // Encode the NodeID into sfAccount so onAcquiredSidecarSet can
 683         // recover it without recomputing (master vs signing key issue).
 684         AccountID acctId;
 685         std::memcpy(acctId.data(), nid.data(), acctId.size());
 686 
 687         STObject sidecar(sfGeneric);
 688         sidecar.setFieldU8(sfSidecarType, sidecarRngCommit);
 689         sidecar.setFieldU32(sfLedgerSequence, seq);
 690         sidecar.setAccountID(sfAccount, acctId);
 691         sidecar.setFieldH256(sfDigest, commit);
 692         sidecar.setFieldVL(sfSigningPubKey, kit->second.slice());
 693         sidecar.setFieldVL(sfBlob, serializeProof(proofIt->second));
 694 
 695         auto const itemKey = sidecar.getHash(HashPrefix::sidecar);
 696         Serializer s(2048);
 697         sidecar.add(s);
 698         map->addItem(
 699             SHAMapNodeType::tnSIDECAR, make_shamapitem(itemKey, s.slice()));
 700         ++entryCount;
 701     }
 702 
 703     map = map->snapShot(false);
 704     commitSetMap_ = map;
 705 
 706     auto const hash = map->getHash().as_uint256();
 707     app_.getInboundTransactions().giveSet(hash, map, false);
 708 
 709     JLOG(j_.debug()) << "RNG: built commitSet SHAMap"
 710                      << " hash=" << hash << " seq=" << seq
 711                      << " entries=" << entryCount
 712                      << " pendingCommits=" << pendingCommits_.size()
 713                      << " activeValidators=" << validatorView->size();
 714     return hash;

📍 src/xrpld/app/consensus/ConsensusExtensions.cpp:722-782

 722     rngRoundSeq_ = seq;
 723 
 724     auto map =
 725         std::make_shared<SHAMap>(SHAMapType::SIDECAR, app_.getNodeFamily());
 726     map->setUnbacked();
 727 
 728     auto const validatorView = activeValidatorView();
 729     std::size_t entryCount = 0;
 730     // NOTE: avoid structured bindings — clang-14 can't capture them (P2036R3).
 731     for (auto const& entry : pendingReveals_)
 732     {
 733         auto const& nid = entry.first;
 734         auto const& reveal = entry.second;
 735 
 736         // Reveal sidecars must use the same validator view as the commit set
 737         // so timeout/fetch paths cannot expand the entropy participant set.
 738         if (!validatorView->containsNode(nid))
 739             continue;
 740 
 741         auto kit = nodeIdToKey_.find(nid);
 742         if (kit == nodeIdToKey_.end())
 743             continue;
 744 
 745         AccountID acctId;
 746         std::memcpy(acctId.data(), nid.data(), acctId.size());
 747 
 748         STObject sidecar(sfGeneric);
 749         sidecar.setFieldU8(sfSidecarType, sidecarRngReveal);
 750         sidecar.setFieldU32(sfLedgerSequence, seq);
 751         sidecar.setAccountID(sfAccount, acctId);
 752         sidecar.setFieldH256(sfDigest, reveal);
 753         sidecar.setFieldVL(sfSigningPubKey, kit->second.slice());
 754         // Intentionally omit sfBlob for reveal-set entries.
 755         //
 756         // Reveal proofs are timing-dependent (seq/closeTime/signature can
 757         // differ while the reveal digest is identical), which makes the
 758         // entropy-set hash non-deterministic across nodes under packet
 759         // loss/reordering.  We only need deterministic reveal material
 760         // (validator identity + digest) for fetch/merge and entropy
 761         // calculation.
 762 
 763         auto const itemKey = sidecar.getHash(HashPrefix::sidecar);
 764         Serializer s(2048);
 765         sidecar.add(s);
 766         map->addItem(
 767             SHAMapNodeType::tnSIDECAR, make_shamapitem(itemKey, s.slice()));
 768         ++entryCount;
 769     }
 770 
 771     map = map->snapShot(false);
 772     entropySetMap_ = map;
 773 
 774     auto const hash = map->getHash().as_uint256();
 775     app_.getInboundTransactions().giveSet(hash, map, false);
 776 
 777     JLOG(j_.debug()) << "RNG: built entropySet SHAMap"
 778                      << " hash=" << hash << " seq=" << seq
 779                      << " entries=" << entryCount
 780                      << " pendingReveals=" << pendingReveals_.size()
 781                      << " activeValidators=" << validatorView->size();
 782     return hash;

📍 src/xrpld/app/consensus/ConsensusExtensions.cpp:786-843

 786 uint256
 787 ConsensusExtensions::buildExportSigSet(LedgerIndex seq)
 788 {
 789     auto map =
 790         std::make_shared<SHAMap>(SHAMapType::SIDECAR, app_.getNodeFamily());
 791     map->setUnbacked();
 792 
 793     auto const validatorView = activeValidatorView();
 794     // Export sidecar convergence should not advertise signatures from trusted
 795     // but inactive validators; those signatures cannot count at apply time.
 796     auto const allSigs = exportSigCollector_.snapshotWithSigs(
 797         [this, validatorView](PublicKey const& key) {
 798             return isActiveValidator(key, *validatorView);
 799         });
 800     // Only signatures for export txns in the consensus candidate can affect
 801     // this round's sidecar hash; open-ledger-only txns stay cached for later.
 802     std::size_t entryCount = 0;
 803 
 804     for (auto const& [txHash, valSigs] : allSigs)
 805     {
 806         // Candidate membership is the deterministic publication gate. A sig
 807         // may have been verified earlier from the open ledger, but it only
 808         // enters the sidecar hash if the same tx hash is in the converged set.
 809         if (consensusExportTxns_.find(txHash) == consensusExportTxns_.end())
 810             continue;
 811 
 812         for (auto const& [valPK, sigBuf] : valSigs)
 813         {
 814             STObject sidecar(sfGeneric);
 815             sidecar.setFieldU8(sfSidecarType, sidecarExportSig);
 816             sidecar.setFieldH256(sfTransactionHash, txHash);
 817             sidecar.setFieldVL(sfSigningPubKey, valPK.slice());
 818             if (sigBuf.size() > 0)
 819                 sidecar.setFieldVL(
 820                     sfTxnSignature, Slice(sigBuf.data(), sigBuf.size()));
 821 
 822             auto const itemKey = sidecar.getHash(HashPrefix::sidecar);
 823             Serializer s;
 824             sidecar.add(s);
 825             map->addItem(
 826                 SHAMapNodeType::tnSIDECAR, make_shamapitem(itemKey, s.slice()));
 827             ++entryCount;
 828         }
 829     }
 830 
 831     map = map->snapShot(false);
 832     exportSigSetMap_ = map;
 833 
 834     auto const hash = map->getHash().as_uint256();
 835     app_.getInboundTransactions().giveSet(hash, map, false);
 836 
 837     JLOG(j_.debug()) << "Export: built exportSigSet SHAMap"
 838                      << " hash=" << hash << " seq=" << seq
 839                      << " entries=" << entryCount
 840                      << " candidateExportTxns=" << consensusExportTxns_.size()
 841                      << " activeValidators=" << validatorView->size();
 842     return hash;
 843 }

7) Injection stage (A): final entropy selection with deterministic fallback

📍 src/xrpld/app/consensus/ConsensusExtensions.cpp:1710-1719

1710     // One deterministic selector over the AGREED entropySetMap_ chooses the
1711     // digest and its tier/count. onPreBuild and buildExplicitFinalProposalTxSet
1712     // share it, so neither the implicit vs explicit-final paths on one node nor
1713     // two different nodes can derive different entropy for the same agreed
1714     // round inputs. txSetHash is the BASE (pre-injection) consensus tx set
1715     // hash.
1716     auto const selection = selectEntropy(txSetHash, seq);
1717     uint256 const finalEntropy = selection.digest;
1718     std::uint8_t const entropyTier = selection.tier;
1719     std::uint16_t const entropyCount = selection.count;

8) Injection stage (B): build and enqueue ttCONSENSUS_ENTROPY

📍 src/xrpld/app/consensus/ConsensusExtensions.cpp:1728-1823

1728     // Synthesize and inject the pseudo-transaction. The selector always yields
1729     // a digest (fallback when there is no validator entropy), so injection is
1730     // unconditional — every RNG-enabled ledger carries a ConsensusEntropy tx.
1731     {
1732         // Design note: this is the canonical/implicit path that materializes
1733         // the synthetic entropy-bearing tx-set in production.
1734         //
1735         // Why here (onAccept/buildLCL) instead of mutating proposals earlier?
1736         // - Consensus agreement is keyed by proposal txSetHash during
1737         //   establish. Late mutation of txSetHash in establish can fragment
1738         //   votes under loss/reordering (base hash vs synthetic hash).
1739         // - Injecting at accept preserves robust convergence semantics: peers
1740         //   agree on the base transaction set first, then deterministically
1741         //   derive/apply the entropy pseudo-tx for ledger construction.
1742         //
1743         // Explicit-final (seq=4 synthetic proposal) remains an optional
1744         // experiment for observability/perf testing and is default-off.
1745         // TBD (2026-03-03): revisit only with stronger evidence that explicit
1746         // publication can be made stable under tx-bearing, lossy networks.
1747 
1748         //@@start rng-inject-pseudotx-core
1749         // Account Zero convention for pseudo-transactions (same as ttFEE, etc)
1750         STTx tx(ttCONSENSUS_ENTROPY, [&](auto& obj) {
1751             obj.setFieldU32(sfLedgerSequence, seq);
1752             obj.setAccountID(sfAccount, AccountID{});
1753             obj.setFieldU32(sfSequence, 0);
1754             obj.setFieldAmount(sfFee, STAmount{});
1755             obj.setFieldH256(sfDigest, finalEntropy);
1756             obj.setFieldU16(sfEntropyCount, entropyCount);
1757             obj.setFieldU8(sfEntropyTier, entropyTier);
1758         });
1759 
1760         auto const txID = tx.getTransactionID();
1761         // Value-based dedup. There must never be two entropy pseudo-txs, but
1762         // when one is already present (explicit-final, or a peer's agreed
1763         // set) it must be VALIDATED as the exact pseudo-tx we would have
1764         // produced — not merely "same type". Injection is deterministic, so
1765         // every honest node derives the identical pseudo-tx (identical txID)
1766         // for the same agreed inputs. A present-but-different entropy pseudo-tx
1767         // is therefore a determinism violation (version skew or a divergent/
1768         // malicious peer) and must be surfaced, not silently trusted.
1769         auto const existing = std::find_if(
1770             retriableTxs.begin(), retriableTxs.end(), [](auto const& entry) {
1771                 return entry.second->getTxnType() == ttCONSENSUS_ENTROPY;
1772             });
1773         if (existing != retriableTxs.end())
1774         {
1775             auto const existingID = existing->second->getTransactionID();
1776             if (existingID == txID)
1777             {
1778                 JLOG(j_.debug()) << "RNG: entropy pseudo-tx already present"
1779                                  << " txHash=" << txID << " seq=" << seq
1780                                  << " action=skip-duplicate-verified";
1781             }
1782             else
1783             {
1784                 // The agreed tx set's hash already commits to the existing
1785                 // pseudo-tx, so we cannot replace it without forking off the
1786                 // agreed ledger. This is detect-and-log only: the existing
1787                 // pseudo-tx is KEPT and is still applied at ledger build
1788                 // (BuildLedger applyTransactions). A hard-fail/reject policy
1789                 // on mismatch is a deliberate future decision (it trades a
1790                 // determinism violation for a halt risk under benign skew).
1791                 //
1792                 // Read present fields defensively: the mismatching pseudo-tx
1793                 // may be exactly the old/malformed (pre-tier) entry we are
1794                 // guarding against, and getField...() on a missing required
1795                 // field would throw here, inside onPreBuild during build.
1796                 auto const& pres = *existing->second;
1797                 JLOG(j_.error())
1798                     << "RNG: entropy pseudo-tx MISMATCH"
1799                     << " seq=" << seq << " reason=determinism-violation"
1800                     << " action=keep-agreed-and-flag"
1801                     << " ourTxHash=" << txID << " ourDigest=" << finalEntropy
1802                     << " ourTier=" << static_cast<int>(entropyTier)
1803                     << " ourCount=" << entropyCount
1804                     << " presentTxHash=" << existingID << " presentDigest="
1805                     << (pres.isFieldPresent(sfDigest)
1806                             ? to_string(pres.getFieldH256(sfDigest))
1807                             : std::string{"<missing>"})
1808                     << " presentTier="
1809                     << (pres.isFieldPresent(sfEntropyTier)
1810                             ? std::to_string(pres.getFieldU8(sfEntropyTier))
1811                             : std::string{"<missing>"})
1812                     << " presentCount="
1813                     << (pres.isFieldPresent(sfEntropyCount)
1814                             ? std::to_string(pres.getFieldU16(sfEntropyCount))
1815                             : std::string{"<missing>"});
1816             }
1817         }
1818         else
1819         {
1820             retriableTxs.insert(std::make_shared<STTx>(std::move(tx)));
1821         }
1822         //@@end rng-inject-pseudotx-core
1823     }

9) Build stage: entropy pseudo-tx executes before normal transactions

📍 src/xrpld/app/ledger/detail/BuildLedger.cpp:111-148

 111     // CRITICAL: Apply consensus entropy pseudo-tx FIRST before any other
 112     // transactions. This ensures hooks can read entropy during this ledger.
 113     for (auto it = txns.begin(); it != txns.end(); /* manual */)
 114     {
 115         if (it->second->getTxnType() != ttCONSENSUS_ENTROPY)
 116         {
 117             ++it;
 118             continue;
 119         }
 120 
 121         auto const txid = it->first.getTXID();
 122         JLOG(j.debug()) << "Applying entropy tx FIRST: " << txid;
 123 
 124         try
 125         {
 126             auto const result =
 127                 applyTransaction(app, view, *it->second, true, tapNONE, j);
 128 
 129             if (result == ApplyTransactionResult::Success)
 130             {
 131                 ++count;
 132                 JLOG(j.debug()) << "Entropy tx applied successfully";
 133             }
 134             else
 135             {
 136                 failed.insert(txid);
 137                 JLOG(j.warn()) << "Entropy tx failed to apply";
 138             }
 139         }
 140         catch (std::exception const& ex)
 141         {
 142             JLOG(j.warn()) << "Entropy tx throws: " << ex.what();
 143             failed.insert(txid);
 144         }
 145 
 146         it = txns.erase(it);
 147         break;  // Only one entropy tx per ledger
 148     }

10) Apply stage: write consensus entropy into the singleton ledger object

📍 src/xrpld/app/tx/detail/Change.cpp:248-265

 248     auto sle = view().peek(keylet::consensusEntropy());
 249     bool const created = !sle;
 250 
 251     if (created)
 252         sle = std::make_shared<SLE>(keylet::consensusEntropy());
 253 
 254     sle->setFieldH256(sfDigest, entropy);
 255     sle->setFieldU16(sfEntropyCount, ctx_.tx.getFieldU16(sfEntropyCount));
 256     sle->setFieldU8(sfEntropyTier, ctx_.tx.getFieldU8(sfEntropyTier));
 257     sle->setFieldU32(sfLedgerSequence, view().info().seq);
 258     // Note: sfPreviousTxnID and sfPreviousTxnLgrSeq are set automatically
 259     // by ApplyStateTable::threadItem() because isThreadedType() returns true
 260     // for ledger entries that have sfPreviousTxnID in their format.
 261 
 262     if (created)
 263         view().insert(sle);
 264     else
 265         view().update(sle);

11) Wire anchor: proposal message carrying extended payload bytes

📍 include/xrpl/proto/ripple.proto:153-177

 153 message TMProposeSet
 154 {
 155     required uint32 proposeSeq          = 1;
 156     required bytes currentTxHash        = 2;    // the hash of the ledger we are proposing
 157     required bytes nodePubKey           = 3;
 158     required uint32 closeTime           = 4;
 159     required bytes signature            = 5;    // signature of above fields
 160     required bytes previousledger       = 6;
 161     repeated bytes addedTransactions    = 10;   // not required if number is large
 162     repeated bytes removedTransactions  = 11;   // not required if number is large
 163 
 164     // node vouches signature is correct
 165     optional bool checkedSignature      = 7     [deprecated=true];
 166 
 167     // Number of hops traveled
 168     optional uint32 hops                = 12    [deprecated=true];
 169 
 170     // Export signatures for pending exports seen in the proposal set. The
 171     // proposal's ExtendedPosition includes a digest of this repeated field, so
 172     // these side-channel blobs are covered by the proposal signature.
 173     // Each entry is: txnHash (32 bytes) + validator pubkey (33 bytes)
 174     // + multisign signature (variable length). Validators attach these
 175     // so export quorum can be reached within the same consensus round.
 176     repeated bytes exportSignatures     = 13;
 177 }

Add missing ttEXPORT/ttCONSENSUS_ENTROPY pseudo transaction fields required by runtime logic and ensure corresponding ledger entries carry threading/sequence fields.

Handle ttEXPORT and ttCONSENSUS_ENTROPY in hook stakeholder routing to avoid Unknown transaction type assertion during ledger close.
Comment thread include/xrpl/protocol/detail/sfields.macro Outdated
Comment thread include/xrpl/protocol/detail/sfields.macro Outdated
Comment thread src/test/app/ConsensusEntropy_test.cpp Outdated
Env env{
*this,
envconfig(),
supported_amendments() | featureExportRNG,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

you can just use supported_amendments()

Suggested change
supported_amendments() | featureExportRNG,
supported_amendments(),

Comment thread src/test/app/ConsensusEntropy_test.cpp Outdated
Env env{
*this,
envconfig(),
supported_amendments() | featureExportRNG,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
supported_amendments() | featureExportRNG,
supported_amendments(),

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'll need to check this, but note that featureExportRNG adds an extra txn to each ledger for the entropy, so a lot of tests that had exacting transaction related assertions fail with the feature enabled. There was lots of failures, so at the time I moved on and removed it from the default list.

We need a better answer for this, but I wasn't planning to update all the tests, and definitely not just yet.

Comment thread src/test/app/ConsensusEntropy_test.cpp Outdated
Comment thread src/xrpld/app/tx/detail/Import.cpp Outdated
Comment thread src/xrpld/consensus/Consensus.h Outdated
Comment thread src/xrpld/consensus/Consensus.h Outdated
Comment thread src/xrpld/consensus/Consensus.h Outdated
Comment thread src/xrpld/consensus/Consensus.h
sublimator and others added 27 commits February 25, 2026 11:53
The merge with origin/dev accidentally stripped all CLOG diagnostic
statements from the consensus code path. This restores the clog
parameter to internal Consensus.h functions (checkLedger, phaseOpen,
closeLedger, updateOurPositions, handleWrongLedger, leaveConsensus,
createDisputes) and re-adds all 46 CLOG statements that provide
per-round diagnostic detail for phase transitions, convergence
progress, dispute tracking, and pause decisions.

Also restores the origin/dev structure of Consensus.cpp by removing
the anonymous-namespace wrapper and forwarding overloads that were
merge artifacts.
The merge with origin/dev accidentally reverted 19 XRPL_ASSERT() calls
back to plain assert() and 1 UNREACHABLE() back to assert(0). These
macros provide descriptive diagnostic messages on failure and are the
project convention since the rippled 2.4.0 migration.

Files fixed:
- Consensus.h: 9 XRPL_ASSERT reversions
- RCLConsensus.cpp: 5 XRPL_ASSERT reversions
- BuildLedger.cpp: 3 XRPL_ASSERT reversions
- Change.cpp: 1 UNREACHABLE + 1 XRPL_ASSERT reversion
sfHookExportCount was at field code 23, colliding with the mainline
rippled UINT16 range. Move to 98 in the Xahau-reserved range.

Also reorder sfExportedTxn (90) before sfAmountEntry (91) for
consistency.
* Add AMM bid/create/deposit/swap/withdraw/vote invariants:
  - Deposit, Withdrawal invariants: `sqrt(asset1Balance * asset2Balance) >= LPTokens`.
  - Bid: `sqrt(asset1Balance * asset2Balance) > LPTokens` and the pool balances don't change.
  - Create: `sqrt(asset1Balance * assetBalance2) == LPTokens`.
  - Swap: `asset1BalanceAfter * asset2BalanceAfter >= asset1BalanceBefore * asset2BalanceBefore`
     and `LPTokens` don't change.
  - Vote: `LPTokens` and pool balances don't change.
  - All AMM and swap transactions: amounts and tokens are greater than zero, except on withdrawal if all tokens
    are withdrawn.
* Add AMM deposit and withdraw rounding to ensure AMM invariant:
  - On deposit, tokens out are rounded downward and deposit amount is rounded upward.
  - On withdrawal, tokens in are rounded upward and withdrawal amount is rounded downward.
* Add Order Book Offer invariant to verify consumed amounts. Consumed amounts are less than the offer.
* Fix Bid validation. `AuthAccount` can't have duplicate accounts or the submitter account.
…ers)

- Remove unnecessary cbak() stubs from ConsensusEntropy test hooks and
  recompile WASM (cbak is optional per Guard.h validator)
- Restore RCLCxPeerPos::render() lost during merge (delegates to
  ConsensusProposal::render())
- Fix Change.cpp applyAmendment() fixInnerObjTemplate2 reversion:
  use STObject::makeInnerObject() and bracket assignment (fbcff93)
- Restore txq-export-quorum-check documentation marker in TxQ.cpp
…change

Extract duplicated (n * 80 + 99) / 100 ceiling quorum formula into shared
calculateQuorumThreshold() in ConsensusParms.h, matching the standard
ValidatorList::calculateQuorum(). Used by ExportSignatureCollector,
Change.cpp, and RCLConsensus.cpp.

Revert Import.cpp quorum from ceiling back to original truncating formula
(totalValidatorCount * 0.8) since Import handles XPOP imports, not the
new Export feature. Added TODO for future upgrade.
Add single-sign rejection check in Change::applyExport() matching
rippled's multi-sign validation: SigningPubKey must be present but
empty, TxnSignature must not be present.

Fix Export_test.cpp hook to encode an empty VL blob for SigningPubKey
instead of 33 zero bytes (AI slop from export-uvtxn branch).
Due to rounding, the LPTokenBalance of the last LP might not match the LP's trustline balance. This was fixed for `AMMWithdraw` in `fixAMMv1_1` by adjusting the LPTokenBalance to be the same as the trustline balance. Since `AMMClawback` is also performing a withdrawal, we need to adjust LPTokenBalance as well in `AMMClawback.`

This change includes:
1. Refactored `verifyAndAdjustLPTokenBalance` function in `AMMUtils`, which both`AMMWithdraw` and `AMMClawback` call to adjust LPTokenBalance.
2. Added the unit test `testLastHolderLPTokenBalance` to test the scenario.
3. Modify the existing unit tests for `fixAMMClawbackRounding`.
Add a generic RuntimeConfig service for runtime-configurable parameters,
initially supporting artificial send delays and packet drops for testing
consensus behavior on local testnets.

- RuntimeConfig class with atomic fast-path gate (zero cost when inactive)
- Per-peer targeting via "*" (global) and "ip:port" keys with inheritance
- Pre-merged caching at write time for single-lookup read path
- Admin RPC handler `runtime_config` (set/clear/clear_all/get)
- Env var support: XAHAU_RUNTIME_CONFIG (JSON) or XAHAU_SEND_* vars
- PeerImp::send() integration with delay timer and probabilistic drops
- RPC handler test covering all operations and merge behavior
- Fix convergence regression caused by 2.4.0 merge: replace
  stringIsUint256Sized(currenttxhash) with size() < uint256::size()
  to accept extended proposals (>32 bytes) containing RNG fields
- Add message_types filter to RuntimeConfig for targeting specific
  protocol message categories (proposal, validation, transaction, etc.)
- Add appliesTo() method and messageCategories set to ConfigVals
- Add category name mapping helpers in RPC handler
- Add 2 test cases for message type filtering (8 total)
- Error on unknown message_types instead of silently widening scope
- Make messageCategories optional so per-peer can override global filter
  to "all categories" (nullopt=inherit, empty set=explicitly all)
- Clamp send_drop_pct to 0-100% range
- Add STARTDIAG: logging for consensus startup diagnostics
- Add 3 test cases (11 total, 58 assertions)
Move core xport_reserve and xport implementations from applyHook.cpp
DEFINE_HOOK_FUNCTION wrappers into the decoupled HookAPI class, following
the same pattern used for etxn_reserve and emit.
Add RNG regression tests for non-UNL data, reveal-without-commit, invalid reveal, and commitment-change stale-reveal handling in CSF consensus tests.
Address review findings on the tier 3 commits:

- onPreBuild dedup was type-based (skip if any ttCONSENSUS_ENTROPY
  present), which silently trusts a pre-present pseudo-tx. Injection is
  deterministic, so every honest node derives the identical pseudo-tx
  (identical txID) for the same agreed inputs. Switch to value-based
  dedup: skip only when the present pseudo-tx EQUALS the one we would
  produce; a present-but-different entropy pseudo-tx is a determinism
  violation (version skew / divergent peer) and is now logged at error
  rather than accepted blindly. (The earlier 'cannot reconstruct the
  txID locally' justification was wrong — determinism guarantees it can.)
- fairRng: read sfEntropyTier defensively (missing => 0 => fail closed).
  The field is soeREQUIRED so any entry this code wrote carries it; this
  only guards a pre-tier-3 persisted entry on a long-lived testnet.
- quorum_degradation_smoke: assert EntropyTier==consensus_fallback(1) and
  EntropyCount==0 and non-zero digest explicitly, not just is_fallback.
Follow-up to d6481a3, addressing review of that diff:

- The mismatch-log branch in onPreBuild read present pseudo-tx fields
  (sfDigest/sfEntropyTier/sfEntropyCount) unconditionally — the same
  throw hazard fairRng was hardened against, here inside onPreBuild
  during build. Read them defensively (isFieldPresent ? value :
  '<missing>'). (Note: STTx deserialization enforces all soeREQUIRED
  fields, so a tier-less ttCONSENSUS_ENTROPY cannot actually reach the
  set — this is belt-and-suspenders, not a reachable bug.)
- Clarify the comment + add action=keep-agreed-and-flag: this is
  detect-and-log, NOT rejection. The present pseudo-tx is KEPT and still
  applied at BuildLedger; a hard-fail policy on mismatch is a deliberate
  future decision (determinism-violation vs halt-risk-under-skew).
- Add testOnPreBuildEntropyMismatchKeepsAgreed: a present-but-different
  entropy pseudo-tx is kept (not replaced), set stays at one entry.
- Fix stale 'type-based dedup' comment in the standalone test.
The 'Tier N' preference-order shorthand collides with EntropyTier's
strength-ordered values (consensus_fallback=1). Use the enum names in
the degradation-smoke comments/descr to remove the ambiguity.
Foundation for Tier 2 (participant_aligned) sub-quorum entropy. No behavior
change — nothing consumes these yet; the tier ladder, gates, and selector
arrive in later commits.

- ActiveValidatorView::originalViewSize: master-key count BEFORE the nUNL
  subtraction. size() stays the effective (post-nUNL) count used by the 80%
  validator-quorum gate; originalViewSize is the original-UNL denominator that
  the 60% Tier 2 floor anchors to, since nUNL can shrink the effective view
  while leaving faulty nodes in it.
- calculateParticipantThreshold(): ceil(0.6 * count), the quorum-intersection
  floor (two such cohorts always share an honest validator under the ~20%
  Byzantine bound).
- ConsensusExtensions::tier2Threshold(): ceil(0.6 * originalView), anchored to
  originalViewSize.

Tests: originalViewSize asserted on the UNLReport, fallback, and real-ledger
nUNL paths; new arithmetic testcase locks ceil(0.6) and the sizing-note
boundaries (5->3 banded/unsafe, 6->4 smallest-safe, 8->5 one-nUNL-off).
Collapse the duplicated tier-selection logic in onPreBuild and
buildExplicitFinalProposalTxSet into a single selectEntropy() over the AGREED
entropySetMap_. No behavior change on the production (implicit) path — the
onPreBuild fallback/entropy-set/standalone/mismatch tests pass byte-identically.

Fixes the pre-existing divergence flagged in review: buildExplicitFinalProposalTxSet
derived entropy from local pendingReveals_ while onPreBuild used the agreed
entropySetMap_, so the two could mint different digests for the same round.
Both now share the selector, so the implicit and (experimental, default-off)
explicit-final paths — and any two nodes — derive identical entropy from
identical agreed inputs.

selectEntropy() returns {digest, tier, count} and is a pure function of agreed
round state, so it is directly unit-testable. Injection becomes unconditional:
the selector always yields a fallback digest when there is no validator entropy,
so every RNG-enabled ledger still carries exactly one ConsensusEntropy tx.

Sets up the tier-2 (participant_aligned) ladder, which lands next.
A 60-79% aligned cohort now mints entropy labelled participant_aligned (tier 2)
instead of falling back. Healthy >=80% rounds are unchanged (validator_quorum),
and a true minority (<60%) still falls back.

Mechanics:
- entropyGateThreshold() = min(quorumThreshold(), tier2Threshold()): the bar at
  which the pipeline engages and the entropy conflict gate resolves. In the
  normal band this is the 60% floor (of the ORIGINAL, pre-nUNL view); under
  heavy nUNL the band collapses to the 80% quorum and tier 2 vanishes.
- selectEntropy() labels the AGREED entropySetMap_ by participant count:
  >= quorum -> validator_quorum, >= tier2 -> participant_aligned, else fallback.
- Tick.h gates (bootstrap-skip, impossible-quorum, commit-timeout, entropy
  conflict gate) key off entropyGateThreshold() so sub-quorum rounds reach
  injection instead of short-circuiting.

Determinism: the tier LABEL is a function of the agreed set's leaf count
(identical on every node holding that hash); the local entropyGateThreshold
alignment only decides proceed-vs-fall-back, so divergent local views fall back
rather than fork. The 60% floor is over the ORIGINAL view -- the
quorum-intersection bound that stops a single equivocator minting two distinct
aligned digests under the ~20% Byzantine bound.

hasQuorumOfCommits() is deliberately LEFT at 80%: healthy networks keep their
exact fast-path and only step down to tier 2 via the commit-timeout path, so
this adds zero behavior change above quorum (the degraded band pays one pipeline
timeout; a fast-path is a separable follow-up). Folds into featureConsensusEntropy
(not yet active), so no new amendment.

Tests: new onPreBuild tier-2 case (5-validator view; 4/3/2 revealers ->
validator_quorum / participant_aligned / fallback) plus threshold assertions.
CSF Peer and the tick test stub mirror entropyGateThreshold() to quorumThreshold()
for now -- the end-to-end tier-2 sims (which lower it) land next. Extracted
makeUNLReportLedger / harvestCommitReveal test helpers, now shared across the
view and harvest tests.
…==0)

calculateParticipantThreshold returned ceil(0.6*n). At every n divisible by 5
(n=5,10,15,20,...) that leaves two aligned cohorts overlapping in exactly
floor(0.2*n) = f validators -- NOT strictly greater than f. So up to f Byzantine
nodes can occupy the entire overlap, leaving no honest validator shared between
the two cohorts, and a single equivocator backed by f-1 colluders can split the
round into two distinct aligned digests -> fork. The spec caught n=5 ("never 5")
but the same failure recurs at every multiple of 5.

Derive the floor from the safety invariant instead: the smallest t with
2t - n > floor(n/5), i.e. floor((n + floor(n/5)) / 2) + 1. This equals
ceil(0.6*n) everywhere except multiples of 5, where it is one higher
(n=10 -> 7, not 6). n=5 now collapses the band (tier2 == quorum), so the
"never 5" operational caveat is enforced by the math rather than a footnote.

Tests: the arithmetic test now asserts the defining invariant
2t - n > floor(n/5) AND t <= quorum for every n in 1..256 (this fails at n=10
under the old formula), plus the bumped boundaries. The onPreBuild tier-2 test
moves off n=5 (no band) onto n=6 (tier2=4, quorum=5; 5/4/3 revealers ->
validator_quorum / participant_aligned / fallback).

Found by Codex adversarial review of the tier-2 implementation.
…l dedup)

Two small follow-ups from the Codex review of the tier-2 implementation, both
on the consensus-extension internals:

- Rename shouldZeroEntropy() -> belowValidatorQuorum(). The selector replaced its
  callers, leaving it production-dead and MISNAMED: post-tier-2, "below the 80%
  validator quorum" is no longer "zero entropy" (a participant_aligned set is
  sub-quorum but non-zero). The new name + a doc comment make it a tier-3
  eligibility predicate only and warn against gating injection on it
  (selectEntropy() owns the tiering). Behavior unchanged.

- buildExplicitFinalProposalTxSet now dedups the entropy pseudo-tx by VALUE, not
  type. Its comment claimed it "mirrors onPreBuild", but onPreBuild went
  value-based: it verifies a present pseudo-tx is the EXACT txID it would have
  produced and logs a determinism-violation on mismatch. Explicit-final now does
  the same (skip-duplicate-verified on match, error log on mismatch), returning
  the base unchanged either way. Default-off experimental path, so low blast
  radius, but the two paths now agree.
The CSF peer now mirrors production's tier-2 behavior so the RNG simulations
exercise participant_aligned, not just validator_quorum/fallback:
- entropyGateThreshold() = min(quorumThreshold(), tier2Threshold()), plus a
  tier2Threshold() helper (calculateParticipantThreshold over the sim's UNL).
- finalizeRoundEntropy() labels by aligned count via the 3-tier ladder
  (>= quorum -> validator_quorum, >= tier2 -> participant_aligned, else
  fallback) instead of hardcoding tier 3.

The n%5==0 band-collapse keeps the bulk of the suite unaffected: at n=5
tier2 == quorum == 4, so the gate is unchanged for every n=5 and n<=2 network.
Only the two n=3 sub-quorum sims cross a live band (n=3: tier2=2 < quorum=3) and
now mint tier 2 instead of falling back -- the feature working, with each test's
intent preserved:
- "impossible quorum fallback" -> "quorum-impossible cohort falls to
  participant_aligned": 2 of 3 is below 80% but at the tier-2 floor, so it makes
  progress as tier 2 (no hang, no fork) rather than falling all the way back.
- "persistent loss does not shrink quorum": the 2 survivors now mint the
  labeled-weaker tier 2, NOT tier 3 -- the tier-3 quorum still did not shrink
  (a min_tier=3 hook rejects it). Intent preserved, outcome relabeled.

Other CSF suites (Consensus, ByzantineFailureSim) are unaffected (they do not
enable RNG). Next: dedicated tier-2 mint + conflict sims at n=6 (smallest size
with a non-degenerate band, f=1).
6 validators is the smallest non-degenerate tier-2 size (f=1, one-wide band {4}: tier2=4, quorum=5). Isolate 2 so the surviving 4-cohort is below the 80% quorum but at the tier-2 floor; it mints participant_aligned entropy (count 4), and all four agree on the same non-zero digest with branches==1 — no hang, no fork. Distributed confirmation of the selector ladder the unit tests already cover.
Codex review caught a CSF/production fidelity gap: production injects entropy from the AGREED entropySetMap_ (selectEntropy), which is frozen at advertise time — late-fetched or conflicting reveals merge into pendingReveals_ but are NOT injected unless a rebuild republishes the hash. The CSF peer fetched into pendingReveals_ AND finalized from pendingReveals_, so a conflict/fetch sim could count reveals production would never inject from.

Track the last advertised entropy-set hash (buildEntropySet) and finalize from the sidecar-store snapshot under that hash — the analog of the frozen entropySetMap_. Clean/no-conflict sims are unchanged (the snapshot equals pendingReveals_ there); the model is now faithful for conflict/fetch cases too. Production unaffected (test-harness only). Addresses finding 1 of codex-tier2-final-review.
Codex final-review minor (optional hardening): finalizeRoundEntropy now requires the fetched sidecar entry to be reveal-type before counting it as entropy. lastEntropySetHash_ only ever names a reveal set (hashRngSet's per-type salt rules out a cross-type hash collision), so this is purely defensive — behavior unchanged, all CSF sims green.
Codex final-review minors, both comment-only (no behavior change): EntropyTier participant_aligned no longer says "reserved for a future" tier (it is implemented in this stack); and the commit-timeout path comment said "fixed UNL quorum" but the code now uses entropyGateThreshold() (min(quorum, tier2)).
A 6-node smoke (the smallest NON-degenerate tier-2 size: tier2 floor 4, quorum 5) driving the 4/6 band. n=5 has no band (tier2 == quorum), which is why the existing degradation smoke only ever sees tier 3 / fallback.

Tier 2 is below the 80% validation quorum, so the 4/6 cohort's ledgers are provisional: the scenario confirms tier-2 injection from the cohort's logs during the window, then verifies the on-ledger EntropyTier=2 count=4 POST-RECOVERY once those ledgers become canonical and validate (the mechanism the degradation smoke also relies on). Adds the assert_participant_aligned helper and the node_count:6 suite entry.

Verified on a live testnet: PASS — 12 tier-2 injections in the 4/6 window, seq 7 & 8 validated as participant_aligned (count 4).
…er check

Codex review minors: assert_validator_quorum at the 5/6 boundary (EntropyTier=3 AND count >= quorum AND non-zero digest, not just tier==3 — catches a bad tier/count pairing); added explicit assert_validator_quorum / assert_consensus_fallback helpers and an entropy_fields() warning that its is_fallback (tier != 3) lumps participant_aligned in with fallback (safe only where no tier-2 band exists).

Robustness: tier 2 is below the validation quorum, so the validated tip stalls and which provisional ledgers it later reaches is timing-dependent (the post-recovery inspection was fragile). Replaced it with direct inspection of the surviving cohort's CLOSED ledger via ledger('closed') DURING the window; recovery is now a pure liveness check.

Verified on a live testnet: PASS — EntropyTier=2 count=4 confirmed on 4 distinct provisional ledgers (seq 7-10).
The 5/6 phase asserted validator_quorum on whichever single ledger the validated tip happened to sit on. But the ledger right at the node-5 drop can be a transient consensus_fallback (EntropyTier=1, count=0) — deterministic and by design, while the commit/reveal pipeline re-primes — so when the tip landed there the assert failed (tier=1, observed ~1 run in 4). Same class of bug as the old post-recovery flake: depending on exactly where the tip lands.

Now settle 4 ledgers past the drop, then scan the post-drop validated ledgers for a clean validator_quorum (tier 3, count >= quorum). The window is entirely 5-node cohorts, so a tier-3 there has count == 5 (faithful to '5/6 still tier 3'); the transition fallback is tolerated, not asserted on. Verified 3/3: each run found tier=3 count=5.

Also harden _closed_entropy() to raise on != 1 ConsensusEntropy pseudo-tx (mirroring get_entropy_tx) instead of silently skipping — a duplicate/missing injection now fails with a clear error rather than resurfacing as a generic 'no tier-2 ledger'.
tier2Threshold() anchors to originalViewSize (pre-nUNL), not the effective post-nUNL size(): nUNL shrinks the effective view while leaving faulty nodes in it, so a sub-quorum fraction of the effective view can exceed the Byzantine bound. A one-line regression to size() would relabel a forkable cohort as participant_aligned -- and no existing test caught it (the only nUNL test never calls tier2Threshold()/selectEntropy, and the only tier-2 selection test has no nUNL, so original == effective there).

Add testTier2ThresholdAnchorsToOriginalView: 8 active validators, 2 disabled via NegativeUNL (original 8, effective 6), asserting tier2Threshold()==5 (from the original 8) not 4 (from the effective 6), with quorum/gate cross-checks. Mutation-verified: flipping the production read to size() turns exactly this test red (tier2Threshold + entropyGateThreshold) while the existing tier-2 selection test stays green.

Also: pointer comment at tier2Threshold() linking the invariant to its guard test, and a design-doc fix -- the worked example used n=5, where the band is empty (quorum == participant_aligned == 4) so it illustrated an unreachable tier; now n=6.
The tier-2 anchor pin (20d52d8) used an 8/6 (original/effective) nUNL config whose rationale was wrong: it computed cohort overlap against the original view (2*4-8=0), but aligned cohorts form in the EFFECTIVE view, so two 4-of-6 cohorts overlap by 2*4-6=2 > floor(8/5)=1 -- 8/6 is NOT actually forkable, the original-view anchor is merely conservative there. The test still caught the originalViewSize->size() regression, but the stated reason was misleading (review catch).

Switch to 10 active / 2 disabled (original 10, effective 8), a genuinely forkable case: a 5-of-8 cohort (what the effective-size threshold would admit) overlaps only 2*5-8=2, which does NOT exceed the f=floor(10/5)=2 faulty the original UNL still tolerates -> an equivocator could mint two distinct tier-2 digests. The correct original anchor requires 7 (overlap 6 > 2) and keeps the band closed (tier2==quorum==7); a regression to size() drops the floor to 5 and re-opens the forkable [5,7) band. Assertions and rationale updated to match; suite green (897, 0 failures).
# Conflicts:
#	src/xrpld/app/hook/applyHook.h
…(F1)

The entropy/export bless-vs-fallback gate counted alignment over ALL tx-converged trusted proposers (currPeerPositions_), while the thresholds and the entropy leaf set are computed over the active validator view. A node that locally trusts proposers outside the on-ledger UNLReport active set could pad alignedParticipants(), inflating the counting universe N above originalViewSize and eroding the Tier-2 intersection margin (2t - N) below the Byzantine floor f -- so two equivocation cohorts padded by non-active aligners could each clear the gate (review finding F1). Backstopped by the 80% validation quorum, but the proof's universe and the code's universe must match.

inspectTxConvergedSidecarPeers now takes an active-view membership predicate and counts only member peers toward alignment; the local +1 is gated on localIsActiveValidator(). Both the RNG entropy gate and the export-sig gate pass ext.isUNLReportMember / ext.localIsActiveValidator, mirroring buildEntropySet / hasQuorumOfCommits' containsNode filter. New method on ConsensusExtensions + CSF Peer + the FakeExtensions stub.

Regression test (Sidecar peer alignment helper): a trusted-but-non-active aligned proposer pushes unfiltered aligned to 2 but filtered aligned stays 1, and a non-active local node's +1 is suppressed -- padding cannot satisfy the gate. ConsensusExtensions 900, ConsensusRng 386, Consensus 1399, all green.

Note: the explicit-final proposal path (ConsensusExtensionsTick.h ~:934) counts over prevProposers and is NOT yet filtered (separate, experimental path).
testInvalidEntropyRequirements rejects four invalid dice/random requirements then returned accept(0,0,0) on success. But a valid dice(6,..) returns 0..5, so a regression that let the min_tier=0 requirement through (returning a value that happened to be 0) would still pass ~1/6 of the time -- the weakest spot is exactly the min_tier lower bound it most needs to prove (review finding). Return sentinel 42 (distinct from any dice/random result and from INVALID_ARGUMENT) and assert ==42, so any leaked requirement returns its own non-42 code and fails. WASM block recompiled; ConsensusEntropy 138 tests, 0 failures.

Follow-up to F1 (fb9e271).
The explicit-final proposal path counts participants/alignment over the unfiltered trusted-proposer set (ctx.peerPositions) plus an unconditional local +1, not the active validator view -- the same gap the F1 fix closed for the main entropy gate (inspectTxConvergedSidecarPeers). It is default-off/experimental with no robust timing model found (per the existing TBD, may never ship); if it is ever enabled it must apply the same active-view membership filter. Comment-only.

Follow-up to F1 (fb9e271).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants