From 2a671fc3a7ed1a6cbcf3d6c331e2ebbbec7adb08 Mon Sep 17 00:00:00 2001 From: Nicholas Dudfield Date: Thu, 16 Jul 2026 17:05:08 +0700 Subject: [PATCH] fix(consensus): sanitize live extension build inputs --- .../consensus/ConsensusExtensions_test.cpp | 55 +++++++++++++++++ .../app/consensus/ConsensusEntropyIntent.md | 10 +-- .../app/consensus/ConsensusExtensions.cpp | 61 +++++++++++++++---- src/xrpld/app/consensus/ConsensusExtensions.h | 25 ++++++-- .../consensus/ConsensusExtensionsDesign.md | 25 +++++--- src/xrpld/app/consensus/RCLConsensus.cpp | 44 +++++++++---- 6 files changed, 175 insertions(+), 45 deletions(-) diff --git a/src/test/consensus/ConsensusExtensions_test.cpp b/src/test/consensus/ConsensusExtensions_test.cpp index 485f91829..932c42192 100644 --- a/src/test/consensus/ConsensusExtensions_test.cpp +++ b/src/test/consensus/ConsensusExtensions_test.cpp @@ -2849,6 +2849,60 @@ class ConsensusExtensions_test : public beast::unit_test::suite } } + void + testLiveBuildSetStripsOnlyExtensionPseudos() + { + testcase("live build set strips only extension pseudo-txs"); + + using namespace jtx; + Env env{ + *this, envconfig(validator, ""), supported_amendments(), nullptr}; + auto const seq = env.closed()->seq() + 1; + auto const validatorKey = makeValidatorKeys().front(); + + auto const fee = std::make_shared(ttFEE, [&](auto& obj) { + obj.setAccountID(sfAccount, AccountID{}); + obj.setFieldU32(sfLedgerSequence, seq); + }); + auto const amendment = + std::make_shared(ttAMENDMENT, [&](auto& obj) { + obj.setAccountID(sfAccount, AccountID{}); + obj.setFieldU32(sfLedgerSequence, seq); + obj.setFieldH256(sfAmendment, makeHash("legacy-amendment")); + }); + auto const negativeUNL = + std::make_shared(ttUNL_MODIFY, [&](auto& obj) { + obj.setAccountID(sfAccount, AccountID{}); + obj.setFieldU8(sfUNLModifyDisabling, 1); + obj.setFieldU32(sfLedgerSequence, seq); + obj.setFieldVL(sfUNLModifyValidator, validatorKey); + }); + auto const suppliedEntropy = + makeConsensusEntropyTx(seq, makeHash("supplied-digest"), 7); + auto const suppliedWitness = + makeExportSignaturesTx(seq, makeHash("supplied-export-origin")); + + auto const agreed = makeRCLTxSet( + env.app(), + {fee, amendment, negativeUNL, suppliedEntropy, suppliedWitness}); + ConsensusExtensions ce{env.app(), activeNoopJournal()}; + auto const build = ce.makeLiveBuildTxSet(agreed); + + BEAST_EXPECT(build.suppliedEntropy == 1); + BEAST_EXPECT(build.suppliedExportWitnesses == 1); + BEAST_EXPECT(build.txns.exists(fee->getTransactionID())); + BEAST_EXPECT(build.txns.exists(amendment->getTransactionID())); + BEAST_EXPECT(build.txns.exists(negativeUNL->getTransactionID())); + BEAST_EXPECT(!build.txns.exists(suppliedEntropy->getTransactionID())); + BEAST_EXPECT(!build.txns.exists(suppliedWitness->getTransactionID())); + + auto const expected = + makeRCLTxSet(env.app(), {fee, amendment, negativeUNL}); + BEAST_EXPECT(build.txns.id() == expected.id()); + BEAST_EXPECT(agreed.exists(suppliedEntropy->getTransactionID())); + BEAST_EXPECT(agreed.exists(suppliedWitness->getTransactionID())); + } + void testDiagnosticsJsonAndPositionLogging() { @@ -4346,6 +4400,7 @@ public: testRngSidecarBuildsLocalSnapshots(); testOnPreBuildInjectsStandaloneEntropy(); testOnPreBuildStripsSuppliedExtensionPseudos(); + testLiveBuildSetStripsOnlyExtensionPseudos(); testDiagnosticsJsonAndPositionLogging(); testDecoratePositionSkipsWhenDisabled(); testExportSigGateRequiresQuorumAlignment(); diff --git a/src/xrpld/app/consensus/ConsensusEntropyIntent.md b/src/xrpld/app/consensus/ConsensusEntropyIntent.md index c57993fa0..808a2e6b1 100644 --- a/src/xrpld/app/consensus/ConsensusEntropyIntent.md +++ b/src/xrpld/app/consensus/ConsensusEntropyIntent.md @@ -120,11 +120,13 @@ below-quorum conflict, which lets a minority equivocation recreate a veto. Under no-UNLReport / lost reveals / failed alignment / timeout / impossible quorum, the round mints an **explicitly labeled lower tier**, never an unlabeled or non-deterministic value. The tier-1 fallback is a pure function of -*already-agreed* inputs: `H(entropyFallback, parentLedgerHash, agreedTxSetHash, +*already-agreed* inputs: `H(entropyFallback, parentLedgerHash, buildTxSetHash, seq)` — and must **never** depend on the post-injection tx set (no circular -dependency on the set that carries the pseudo-tx). -*Enforced:* `selectEntropy` fallback path; `agreedTxSetHash` is the -pre-injection set hash. +dependency on the set that carries the pseudo-tx). `buildTxSetHash` is the raw +agreed set after removing only supplied ConsensusEntropy and Export synthetic +transactions; legacy protocol pseudos remain included. +*Enforced:* `makeLiveBuildTxSet` before ordering and `selectEntropy` fallback; +the original consensus-set hash remains separate bookkeeping. **INV-6 — Bounded, opt-in entropy quality.** Hooks state `min_tier` explicitly on every draw (no hidden network default). diff --git a/src/xrpld/app/consensus/ConsensusExtensions.cpp b/src/xrpld/app/consensus/ConsensusExtensions.cpp index 9665c904e..1f402312c 100644 --- a/src/xrpld/app/consensus/ConsensusExtensions.cpp +++ b/src/xrpld/app/consensus/ConsensusExtensions.cpp @@ -1442,20 +1442,20 @@ ConsensusExtensions::clearAcceptedEntropySet() ConsensusExtensions::EntropySelection ConsensusExtensions::selectEntropy( - uint256 const& agreedTxSetHash, + uint256 const& buildTxSetHash, LedgerIndex seq) const { //@@start entropy-selector-fallback // Tier 1 fallback: consensus-bound deterministic digest over already-agreed - // round inputs. agreedTxSetHash is the pre-injection consensus tx set hash: - // the digest must never depend on a set that could contain the pseudo-tx - // carrying it (circular). + // round inputs. buildTxSetHash is the sanitized pre-injection live-build + // set hash: the digest must never depend on a set that could contain a + // supplied or derived extension pseudo-tx. auto const fallback = [&]() -> EntropySelection { return { sha512Half( HashPrefix::entropyFallback, roundPrevLedgerHash_, - agreedTxSetHash, + buildTxSetHash, seq), entropyTierConsensusFallback, 0, @@ -1675,16 +1675,16 @@ ConsensusExtensions::rngEnabled() const uint256 ConsensusExtensions::txnOrderingSalt( - uint256 const& agreedTxSetHash, + uint256 const& buildTxSetHash, LedgerIndex seq) const { if (!rngEnabled()) - return agreedTxSetHash; + return buildTxSetHash; - auto const selection = selectEntropy(agreedTxSetHash, seq); + auto const selection = selectEntropy(buildTxSetHash, seq); return sha512Half( HashPrefix::entropyTxnOrder, - agreedTxSetHash, + buildTxSetHash, selection.digest, selection.tier, selection.count, @@ -2379,11 +2379,46 @@ ConsensusExtensions::observedParticipantsBitmapBin() const return observedParticipantsBitmapBin_; } +ConsensusExtensions::LiveBuildTxSet +ConsensusExtensions::makeLiveBuildTxSet(RCLTxSet const& agreedTxs) const +{ + RCLTxSet::MutableTxSet mutableSet{agreedTxs}; + std::vector suppliedEntropy; + std::vector suppliedExportWitnesses; + + for (auto const& item : *agreedTxs.map_) + { + try + { + STTx const tx{SerialIter{item.slice()}}; + if (tx.getTxnType() == ttCONSENSUS_ENTROPY) + suppliedEntropy.push_back(item.key()); + else if (tx.getTxnType() == ttEXPORT_SIGNATURES) + suppliedExportWitnesses.push_back(item.key()); + } + catch (std::exception const&) + { + // Preserve malformed entries here. The existing canonical-set + // construction path records their parse failure separately. + } + } + + for (auto const& id : suppliedEntropy) + mutableSet.erase(id); + for (auto const& id : suppliedExportWitnesses) + mutableSet.erase(id); + + return LiveBuildTxSet{ + RCLTxSet{mutableSet}, + suppliedEntropy.size(), + suppliedExportWitnesses.size()}; +} + void ConsensusExtensions::onPreBuild( CanonicalTXSet& retriableTxs, LedgerIndex seq, - uint256 const& txSetHash) + uint256 const& buildTxSetHash) { //@@start extension-live-pseudo-authority // The agreed user transaction set never authorizes synthetic extension @@ -2435,9 +2470,9 @@ ConsensusExtensions::onPreBuild( //@@start rng-inject-entropy-selection // One deterministic selector over the AGREED entropySetMap_ chooses the // digest and its tier/count/denominator labels. Every node derives the - // same entropy for the same agreed round inputs. txSetHash is the - // agreed pre-injection consensus tx set hash. - auto const selection = selectEntropy(txSetHash, seq); + // same entropy for the same accepted round inputs. buildTxSetHash is + // the sanitized pre-injection live-build set hash. + auto const selection = selectEntropy(buildTxSetHash, seq); uint256 const finalEntropy = selection.digest; std::uint8_t const entropyTier = selection.tier; std::uint16_t const entropyCount = selection.count; diff --git a/src/xrpld/app/consensus/ConsensusExtensions.h b/src/xrpld/app/consensus/ConsensusExtensions.h index e40e628e2..b93ef35eb 100644 --- a/src/xrpld/app/consensus/ConsensusExtensions.h +++ b/src/xrpld/app/consensus/ConsensusExtensions.h @@ -112,6 +112,13 @@ class ConsensusExtensions clearDeferredExportShares(); public: + struct LiveBuildTxSet + { + RCLTxSet txns; + std::size_t suppliedEntropy; + std::size_t suppliedExportWitnesses; + }; + beast::Journal j_; // public: accessed by extensionsTick template /** Proof data from a proposal signature, for embedding in SHAMap @@ -365,15 +372,15 @@ public: /// quorumThreshold), participant_aligned (>= tier2Threshold) or /// consensus_fallback. In non-standalone mode, non-fallback labels require /// an UNLReport-backed active view; the trusted-fallback view is local - /// config and mints Tier 1. agreedTxSetHash is the pre-injection consensus - /// tx set hash used for the fallback digest. + /// config and mints Tier 1. buildTxSetHash is the sanitized pre-injection + /// live-build set hash used for the fallback digest. EntropySelection - selectEntropy(uint256 const& agreedTxSetHash, LedgerIndex seq) const; + selectEntropy(uint256 const& buildTxSetHash, LedgerIndex seq) const; /// Extend the legacy closed-ledger transaction-order salt with the same /// consensus entropy selected for the ledger's entropy pseudo-tx. uint256 - txnOrderingSalt(uint256 const& agreedTxSetHash, LedgerIndex seq) const; + txnOrderingSalt(uint256 const& buildTxSetHash, LedgerIndex seq) const; bool rngEnabled() const; @@ -509,9 +516,15 @@ public: void onReplayBuild(); - /// txSetHash is the agreed pre-injection consensus tx set hash — an input + /// Return the agreed transaction set with only consensus-extension + /// synthetic transactions removed. Legacy fee, amendment, nUNL, and other + /// protocol pseudos remain ordinary members of the consensus set. + LiveBuildTxSet + makeLiveBuildTxSet(RCLTxSet const& agreedTxs) const; + + /// txSetHash is the sanitized pre-injection live-build set hash — an input /// to the Tier 1 consensus_fallback digest. It must never be the hash of a - /// set that could contain the entropy pseudo-tx itself. + /// set that could contain an extension pseudo-tx itself. void onPreBuild( CanonicalTXSet& retriableTxs, diff --git a/src/xrpld/app/consensus/ConsensusExtensionsDesign.md b/src/xrpld/app/consensus/ConsensusExtensionsDesign.md index 3c0c1ef13..8c0528edd 100644 --- a/src/xrpld/app/consensus/ConsensusExtensionsDesign.md +++ b/src/xrpld/app/consensus/ConsensusExtensionsDesign.md @@ -361,8 +361,10 @@ non-UNLReport (config-fallback) view, every case below instead mints - No peer entropy hash is observed in time: fall back to the Tier 1 digest. The fallback pseudo-transaction is deterministic — every node derives the same -digest from `(HashPrefix::entropyFallback, parentLedgerHash, agreedTxSetHash, -seq)` — and labeled with `EntropyTier = consensus_fallback`, +digest from `(HashPrefix::entropyFallback, parentLedgerHash, buildTxSetHash, +seq)`, where `buildTxSetHash` is the agreed set after removing only supplied +ConsensusEntropy and Export synthetic transactions — and labels it with +`EntropyTier = consensus_fallback`, `EntropyCount = 0`, `EntropyDenominator = 0`, and an empty `EntropyContributors` bitmap. Non-fallback entropy records both the contributor count and the active-validator denominator used for the validator-quorum @@ -398,14 +400,17 @@ ledger's finalized entropy; final buildLCL execution sees the current ledger's entropy pseudo-tx after it updates the SLE. Hooks that need final entropy must treat open-ledger RNG results as previews. -The fallback digest derives from the agreed pre-injection tx set hash to avoid -circularity. The agreed user transaction set is not authority for synthetic -extension state: during a live build, every supplied `ttCONSENSUS_ENTROPY` or -`ttEXPORT_SIGNATURES` transaction is discarded and logged as an invariant -violation, then the canonical synthetic stream is derived from accepted -extension evidence. Historical replay is selected before live materialization -and consumes the persisted transaction order, including its recorded synthetic -transactions, without regeneration. +The fallback digest derives from the sanitized pre-injection live-build set +hash to avoid circularity and synthetic-input authority. Sanitization removes +only supplied `ttCONSENSUS_ENTROPY` and `ttEXPORT_SIGNATURES` transactions; +legacy fee, amendment, NegativeUNL, and other protocol pseudos remain ordinary +members of the agreed set. Supplied extension pseudos are discarded and logged +as an invariant violation before they can influence fallback entropy, +transaction ordering, or ledger state, then the canonical synthetic stream is +derived from accepted extension evidence. The original agreed-set hash remains +the consensus-bookkeeping value carried by validations. Historical replay is +selected before live materialization and consumes the persisted transaction +order, including its recorded synthetic transactions, without regeneration. ## Local Snapshot Alignment Rules diff --git a/src/xrpld/app/consensus/RCLConsensus.cpp b/src/xrpld/app/consensus/RCLConsensus.cpp index 8174bfee5..5bb0569fe 100644 --- a/src/xrpld/app/consensus/RCLConsensus.cpp +++ b/src/xrpld/app/consensus/RCLConsensus.cpp @@ -581,20 +581,45 @@ RCLConsensus::Adaptor::doAccept( //-------------------------------------------------------------------------- std::set failed; + // Select replay before choosing the transaction stream used to derive the + // ledger. Replay consumes the persisted ordered stream. A live build uses + // a sanitized view of the agreed set so supplied extension pseudos cannot + // influence fallback entropy, transaction ordering, or ledger state. + auto replayData = ledgerMaster_.releaseReplay(); + auto const consensusTxSetHash = result.txns.id(); + auto const liveBuild = replayData + ? std::optional{} + : std::optional{ + ce().makeLiveBuildTxSet(result.txns)}; + auto const& buildTxs = liveBuild ? liveBuild->txns : result.txns; + auto const buildTxSetHash = buildTxs.id(); + + if (liveBuild && + (liveBuild->suppliedEntropy != 0 || + liveBuild->suppliedExportWitnesses != 0)) + { + JLOG(j_.error()) + << "ConsensusExtensions: excluded supplied synthetic txs from " + "live build" + << " seq=" << (prevLedger.seq() + 1) + << " consensusSet=" << consensusTxSetHash + << " buildSet=" << buildTxSetHash + << " entropy=" << liveBuild->suppliedEntropy + << " exportWitnesses=" << liveBuild->suppliedExportWitnesses; + } + // We want to put transactions in an unpredictable but deterministic order. - // ConsensusEntropy extends the agreed tx-set salt with the selected - // ledger entropy; when disabled this remains the legacy tx-set hash salt. + // ConsensusEntropy extends the sanitized live-build salt with the selected + // ledger entropy; when disabled this remains that sanitized set hash. // // FIXME: Use a std::vector and a custom sorter instead of CanonicalTXSet? //@@start txn-ordering-salt-build-inputs - auto const agreedTxSetHash = result.txns.map_->getHash().as_uint256(); auto const buildSeq = prevLedger.seq() + 1; - CanonicalTXSet retriableTxs{ - ce().txnOrderingSalt(agreedTxSetHash, buildSeq)}; + CanonicalTXSet retriableTxs{ce().txnOrderingSalt(buildTxSetHash, buildSeq)}; JLOG(j_.debug()) << "Building canonical tx set: " << retriableTxs.key(); - for (auto const& item : *result.txns.map_) + for (auto const& item : *buildTxs.map_) { try { @@ -611,11 +636,6 @@ RCLConsensus::Adaptor::doAccept( } //@@end txn-ordering-salt-build-inputs - // Select replay before live materialization. Replay consumes the persisted - // ordered transaction stream, including its recorded extension pseudos; - // live builds derive extension pseudos from accepted extension evidence. - auto replayData = ledgerMaster_.releaseReplay(); - //@@start auxiliary-pre-build-injection // Inject extension pseudo-transactions only for a live build. Entropy and // Export witness injection are independently gated inside onPreBuild; @@ -627,7 +647,7 @@ RCLConsensus::Adaptor::doAccept( } else if (ce().rngEnabled() || ce().exportEnabled()) { - ce().onPreBuild(retriableTxs, buildSeq, agreedTxSetHash); + ce().onPreBuild(retriableTxs, buildSeq, buildTxSetHash); } else {