diff --git a/.testnet/scenarios/entropy/quorum_degradation_smoke.py b/.testnet/scenarios/entropy/quorum_degradation_smoke.py index 458f5a5cf..3c0fa87a1 100644 --- a/.testnet/scenarios/entropy/quorum_degradation_smoke.py +++ b/.testnet/scenarios/entropy/quorum_degradation_smoke.py @@ -1,8 +1,8 @@ -""":descr: 4/5 liveness, 3/5 zero-entropy fallback, recovery""" +""":descr: 4/5 liveness, 3/5 fallback-entropy (Tier 3), recovery""" from __future__ import annotations -from helpers import require_entropy, get_entropy_tx, entropy_fields +from helpers import ZERO_DIGEST, require_entropy, get_entropy_tx, entropy_fields async def scenario(ctx, log): @@ -32,24 +32,35 @@ async def scenario(ctx, log): # Accepted/built ledgers may still later appear as validated once the full # network rejoins. For ConsensusEntropy the key invariant is that every - # ledger created during this sub-quorum window carries ZERO entropy. - degraded_zero = 0 + # ledger created during this sub-quorum window carries FALLBACK entropy + # (Tier 3: non-zero consensus-bound digest, EntropyCount=0) — never + # validator-tier entropy. + degraded_fallback = 0 degraded_end = val_after or val_before if val_before and degraded_end and degraded_end > val_before: for seq in range(val_before + 1, degraded_end + 1): ce, _ = get_entropy_tx(ctx, seq) - digest, entropy_count, is_zero = entropy_fields(ce) + digest, entropy_count, is_fallback = entropy_fields(ce) - if not is_zero: + if not is_fallback: raise AssertionError( - f"Ledger {seq}: expected ZERO entropy during 3/5 window, " - f"got Digest={digest[:16]}... EntropyCount={entropy_count}" + f"Ledger {seq}: expected fallback entropy during 3/5 " + f"window, got Digest={digest[:16]}... " + f"EntropyCount={entropy_count}" + ) + if digest == ZERO_DIGEST: + raise AssertionError( + f"Ledger {seq}: fallback digest should be non-zero " + f"(Tier 3), got zero" ) - degraded_zero += 1 - log(f" Degraded ledger {seq}: EntropyCount={entropy_count} ZERO") + degraded_fallback += 1 + log( + f" Degraded ledger {seq}: EntropyCount={entropy_count} " + f"FALLBACK" + ) - log(f"3/5 entropy summary: {degraded_zero} zero") + log(f"3/5 entropy summary: {degraded_fallback} fallback") # Log checks tied to current transition mechanics: # - commit-set SHAMap publication is the observable output of entering the @@ -99,27 +110,30 @@ async def scenario(ctx, log): # Inspect post-recovery ledgers separately from the degraded window above. # Once the network is back at quorum, non-zero entropy is valid again but # must still be quorum-met. - zero_count = 0 - nonzero_count = 0 + fallback_count = 0 + validator_count = 0 for seq in range(pre_recovery + 1, val_recovered + 1): ce, _ = get_entropy_tx(ctx, seq) - digest, entropy_count, is_zero = entropy_fields(ce) + digest, entropy_count, is_fallback = entropy_fields(ce) - if is_zero: - zero_count += 1 + if is_fallback: + fallback_count += 1 else: - nonzero_count += 1 + validator_count += 1 if entropy_count < 4: raise AssertionError( - f"Ledger {seq}: non-zero entropy with sub-quorum " + f"Ledger {seq}: validator entropy with sub-quorum " f"EntropyCount={entropy_count} (need >= 4)" ) log( f" Ledger {seq}: EntropyCount={entropy_count} " - f"{'ZERO' if is_zero else 'REAL'}" + f"{'FALLBACK' if is_fallback else 'VALIDATOR'}" ) - log(f"Entropy summary: {zero_count} zero, {nonzero_count} non-zero") + log( + f"Entropy summary: {fallback_count} fallback, " + f"{validator_count} validator" + ) log("PASS") diff --git a/.testnet/scenarios/helpers.py b/.testnet/scenarios/helpers.py index 5f6fb9f76..e8f496354 100644 --- a/.testnet/scenarios/helpers.py +++ b/.testnet/scenarios/helpers.py @@ -55,19 +55,28 @@ def get_entropy_tx(ctx, seq): def entropy_fields(ce_tx): - """Return (digest, entropy_count, is_zero) from a ConsensusEntropy tx.""" + """Return (digest, entropy_count, is_fallback) from a ConsensusEntropy tx. + + Tier 3: fallback rounds carry a deterministic non-zero consensus-bound + digest with EntropyCount=0 and EntropyTier=1 (consensus_fallback). + Validator entropy has EntropyTier=3 (validator_quorum). + """ digest = ce_tx.get("Digest", "") entropy_count = ce_tx.get("EntropyCount", -1) - is_zero = digest == ZERO_DIGEST and entropy_count == 0 - return digest, entropy_count, is_zero + tier = ce_tx.get("EntropyTier", None) + if tier is not None: + is_fallback = tier != 3 + else: + is_fallback = entropy_count == 0 + return digest, entropy_count, is_fallback def assert_valid_entropy(ce_tx, seq, seen_digests=None): - """Assert non-zero quorum-met entropy. Optionally check uniqueness.""" - digest, entropy_count, is_zero = entropy_fields(ce_tx) + """Assert quorum-met validator entropy. Optionally check uniqueness.""" + digest, entropy_count, is_fallback = entropy_fields(ce_tx) - if is_zero or not digest: - raise AssertionError(f"Ledger {seq}: zero/empty Digest") + if is_fallback or not digest or digest == ZERO_DIGEST: + raise AssertionError(f"Ledger {seq}: fallback/empty Digest") if entropy_count < 4: raise AssertionError( diff --git a/include/xrpl/protocol/EntropyTier.h b/include/xrpl/protocol/EntropyTier.h new file mode 100644 index 000000000..cee7c3a22 --- /dev/null +++ b/include/xrpl/protocol/EntropyTier.h @@ -0,0 +1,37 @@ +#ifndef RIPPLE_PROTOCOL_ENTROPY_TIER_H_INCLUDED +#define RIPPLE_PROTOCOL_ENTROPY_TIER_H_INCLUDED + +#include + +namespace ripple { + +/// Which gate the ledger's entropy passed. Stored in sfEntropyTier (UINT8) +/// on the ttCONSENSUS_ENTROPY pseudo-transaction and the ConsensusEntropy +/// ledger entry. +/// +/// EntropyCount says how many validators contributed; EntropyTier says which +/// gate the result passed. Values are strength-ordered so consumers can gate +/// with a numeric comparison (tier >= required). +enum EntropyTier : std::uint8_t { + /// No usable entropy (reserved; a fresh ConsensusEntropy entry should + /// always carry one of the tiers below). + entropyTierNone = 0, + + /// Consensus-bound deterministic fallback: derived from already-agreed + /// round inputs (parent ledger hash, base tx set hash, sequence) under + /// HashPrefix::entropyFallback when validator reveal quorum was not + /// available. Unpredictable in practice but user-influenceable via + /// transaction submission — never suitable for value-bearing outcomes. + entropyTierConsensusFallback = 1, + + /// Reserved for a future participant-aligned sub-quorum tier. + entropyTierParticipantAligned = 2, + + /// Validator commit/reveal entropy whose sidecar set passed the + /// active-validator-view quorum alignment gate. + entropyTierValidatorQuorum = 3, +}; + +} // namespace ripple + +#endif diff --git a/include/xrpl/protocol/HashPrefix.h b/include/xrpl/protocol/HashPrefix.h index 6ddcaa454..d369f1c08 100644 --- a/include/xrpl/protocol/HashPrefix.h +++ b/include/xrpl/protocol/HashPrefix.h @@ -99,6 +99,11 @@ enum class HashPrefix : std::uint32_t { /** consensus extension sidecar object */ sidecar = detail::make_hash_prefix('S', 'C', 'R'), + + /** consensus-bound fallback entropy digest (Tier 3: derived from + already-agreed round inputs when validator reveal quorum is not + available; never to be confused with validator entropy) */ + entropyFallback = detail::make_hash_prefix('E', 'F', 'B'), }; template diff --git a/include/xrpl/protocol/detail/ledger_entries.macro b/include/xrpl/protocol/detail/ledger_entries.macro index 595b58a4b..1d6c7f9c5 100644 --- a/include/xrpl/protocol/detail/ledger_entries.macro +++ b/include/xrpl/protocol/detail/ledger_entries.macro @@ -232,6 +232,7 @@ LEDGER_ENTRY(ltURI_TOKEN, 0x0055, URIToken, uri_token, ({ LEDGER_ENTRY_DUPLICATE(ltCONSENSUS_ENTROPY, 0x0058, ConsensusEntropy, consensus_entropy, ({ {sfDigest, soeREQUIRED}, {sfEntropyCount, soeREQUIRED}, + {sfEntropyTier, soeREQUIRED}, {sfLedgerSequence, soeREQUIRED}, {sfPreviousTxnID, soeREQUIRED}, {sfPreviousTxnLgrSeq, soeREQUIRED}, diff --git a/include/xrpl/protocol/detail/sfields.macro b/include/xrpl/protocol/detail/sfields.macro index 3e1c18ff8..73b0b9574 100644 --- a/include/xrpl/protocol/detail/sfields.macro +++ b/include/xrpl/protocol/detail/sfields.macro @@ -43,6 +43,7 @@ TYPED_SFIELD(sfUNLModifyDisabling, UINT8, 17) TYPED_SFIELD(sfHookResult, UINT8, 18) TYPED_SFIELD(sfWasLockingChainSend, UINT8, 19) TYPED_SFIELD(sfSidecarType, UINT8, 20) +TYPED_SFIELD(sfEntropyTier, UINT8, 21) // 16-bit integers (common) TYPED_SFIELD(sfLedgerEntryType, UINT16, 1, SField::sMD_Never) diff --git a/include/xrpl/protocol/detail/transactions.macro b/include/xrpl/protocol/detail/transactions.macro index 0720c6a28..8c60ffd5a 100644 --- a/include/xrpl/protocol/detail/transactions.macro +++ b/include/xrpl/protocol/detail/transactions.macro @@ -622,5 +622,6 @@ TRANSACTION(ttCONSENSUS_ENTROPY, 105, ConsensusEntropy, ({ {sfLedgerSequence, soeREQUIRED}, {sfDigest, soeREQUIRED}, {sfEntropyCount, soeREQUIRED}, + {sfEntropyTier, soeREQUIRED}, {sfBlob, soeOPTIONAL}, })) diff --git a/src/test/consensus/ConsensusExtensions_test.cpp b/src/test/consensus/ConsensusExtensions_test.cpp index 031baa58d..f293f28c9 100644 --- a/src/test/consensus/ConsensusExtensions_test.cpp +++ b/src/test/consensus/ConsensusExtensions_test.cpp @@ -28,6 +28,7 @@ #include #include #include +#include #include #include #include @@ -886,6 +887,8 @@ class ConsensusExtensions_test : public beast::unit_test::suite tx.getFieldH256(sfDigest) == sha512Half(std::string("standalone-entropy"), seq)); BEAST_EXPECT(tx.getFieldU16(sfEntropyCount) == 20); + BEAST_EXPECT( + tx.getFieldU8(sfEntropyTier) == entropyTierValidatorQuorum); auto duplicate = ce.buildExplicitFinalProposalTxSet(*synthetic, seq); BEAST_EXPECT(duplicate); @@ -904,7 +907,7 @@ class ConsensusExtensions_test : public beast::unit_test::suite auto const nonStandaloneSeq = ledger->seq() + 1; ConsensusExtensions zeroCe{nonStandaloneEnv.app(), activeNoopJournal()}; - zeroCe.cacheUNLReport(ledger); + zeroCe.onRoundStart(RCLCxLedger{ledger}, {}); zeroCe.setEntropyFailed(); auto zeroSynthetic = zeroCe.buildExplicitFinalProposalTxSet( nonStandaloneBase, nonStandaloneSeq); @@ -914,8 +917,18 @@ class ConsensusExtensions_test : public beast::unit_test::suite BEAST_EXPECT(zeroTx); if (zeroTx) { - BEAST_EXPECT(zeroTx->getFieldH256(sfDigest) == uint256{}); + // Tier 3 fallback digest over (prevLedgerHash, base set, seq). + auto const expectedFallback = sha512Half( + HashPrefix::entropyFallback, + ledger->info().hash, + nonStandaloneBase.id(), + nonStandaloneSeq); + BEAST_EXPECT(zeroTx->getFieldH256(sfDigest) == expectedFallback); + BEAST_EXPECT(zeroTx->getFieldH256(sfDigest) != uint256{}); BEAST_EXPECT(zeroTx->getFieldU16(sfEntropyCount) == 0); + BEAST_EXPECT( + zeroTx->getFieldU8(sfEntropyTier) == + entropyTierConsensusFallback); } auto const& valKeys = nonStandaloneEnv.app().getValidatorKeys(); @@ -976,6 +989,9 @@ class ConsensusExtensions_test : public beast::unit_test::suite revealTx->getFieldH256(sfDigest) == expectedEntropy(publicKey, reveal)); BEAST_EXPECT(revealTx->getFieldU16(sfEntropyCount) == 1); + BEAST_EXPECT( + revealTx->getFieldU8(sfEntropyTier) == + entropyTierValidatorQuorum); } } @@ -1032,7 +1048,7 @@ class ConsensusExtensions_test : public beast::unit_test::suite void testOnPreBuildInjectsZeroEntropyFallback() { - testcase("onPreBuild injects zero entropy fallback"); + testcase("onPreBuild injects consensus-bound fallback entropy"); using namespace jtx; Env env{ @@ -1045,13 +1061,14 @@ class ConsensusExtensions_test : public beast::unit_test::suite ConsensusExtensions ce{env.app(), activeNoopJournal()}; auto const ledger = env.app().getLedgerMaster().getClosedLedger(); - ce.cacheUNLReport(ledger); + ce.onRoundStart(RCLCxLedger{ledger}, {}); ce.setRngEnabledThisRound(true); BEAST_EXPECT(ce.shouldZeroEntropy()); CanonicalTXSet retriableTxs{makeHash("rng-zero-fallback-salt")}; auto const seq = ledger->seq() + 1; - ce.onPreBuild(retriableTxs, seq); + auto const txSetHash = makeHash("rng-fallback-txset"); + ce.onPreBuild(retriableTxs, seq, txSetHash); auto const tx = singleCanonicalTx(retriableTxs); BEAST_EXPECT(tx); @@ -1059,8 +1076,26 @@ class ConsensusExtensions_test : public beast::unit_test::suite return; BEAST_EXPECT(tx->getTxnType() == ttCONSENSUS_ENTROPY); BEAST_EXPECT(tx->getFieldU32(sfLedgerSequence) == seq); - BEAST_EXPECT(tx->getFieldH256(sfDigest) == uint256{}); + + // Tier 3: deterministic, consensus-bound, non-zero, fallback-labeled. + auto const expected = sha512Half( + HashPrefix::entropyFallback, ledger->info().hash, txSetHash, seq); + BEAST_EXPECT(tx->getFieldH256(sfDigest) == expected); + BEAST_EXPECT(tx->getFieldH256(sfDigest) != uint256{}); BEAST_EXPECT(tx->getFieldU16(sfEntropyCount) == 0); + BEAST_EXPECT( + tx->getFieldU8(sfEntropyTier) == entropyTierConsensusFallback); + + // Same agreed inputs => identical digest on an independent instance. + ConsensusExtensions other{env.app(), activeNoopJournal()}; + other.onRoundStart(RCLCxLedger{ledger}, {}); + other.setRngEnabledThisRound(true); + CanonicalTXSet otherTxs{makeHash("rng-zero-fallback-salt-2")}; + other.onPreBuild(otherTxs, seq, txSetHash); + auto const otherTx = singleCanonicalTx(otherTxs); + BEAST_EXPECT(otherTx); + if (otherTx) + BEAST_EXPECT(otherTx->getFieldH256(sfDigest) == expected); } void @@ -1127,7 +1162,7 @@ class ConsensusExtensions_test : public beast::unit_test::suite BEAST_EXPECT(!ce.shouldZeroEntropy()); CanonicalTXSet retriableTxs{makeHash("entropy-set-prebuild-salt")}; - ce.onPreBuild(retriableTxs, seq); + ce.onPreBuild(retriableTxs, seq, txSetHash); auto const tx = singleCanonicalTx(retriableTxs); BEAST_EXPECT(tx); @@ -1137,6 +1172,8 @@ class ConsensusExtensions_test : public beast::unit_test::suite BEAST_EXPECT( tx->getFieldH256(sfDigest) == expectedEntropy(publicKey, reveal)); BEAST_EXPECT(tx->getFieldU16(sfEntropyCount) == 1); + BEAST_EXPECT( + tx->getFieldU8(sfEntropyTier) == entropyTierValidatorQuorum); } void @@ -1677,7 +1714,7 @@ class ConsensusExtensions_test : public beast::unit_test::suite CanonicalTXSet retriableTxs{makeHash("rng-on-pre-build-salt")}; auto const seq = env.closed()->seq() + 1; - ce.onPreBuild(retriableTxs, seq); + ce.onPreBuild(retriableTxs, seq, makeHash("standalone-txset")); BEAST_EXPECT( std::distance(retriableTxs.begin(), retriableTxs.end()) == 1); @@ -1690,8 +1727,11 @@ class ConsensusExtensions_test : public beast::unit_test::suite tx->getFieldH256(sfDigest) == sha512Half(std::string("standalone-entropy"), seq)); BEAST_EXPECT(tx->getFieldU16(sfEntropyCount) == 20); + BEAST_EXPECT( + tx->getFieldU8(sfEntropyTier) == entropyTierValidatorQuorum); - ce.onPreBuild(retriableTxs, seq); + // Type-based dedup: a second injection attempt must be a no-op. + ce.onPreBuild(retriableTxs, seq, makeHash("standalone-txset")); BEAST_EXPECT( std::distance(retriableTxs.begin(), retriableTxs.end()) == 1); } diff --git a/src/test/consensus/ConsensusRng_test.cpp b/src/test/consensus/ConsensusRng_test.cpp index 062745e13..5c0b58b6b 100644 --- a/src/test/consensus/ConsensusRng_test.cpp +++ b/src/test/consensus/ConsensusRng_test.cpp @@ -149,7 +149,12 @@ public: for (Peer const* peer : majority) { BEAST_EXPECT(peer->ce().lastEntropyWasFallback_); - BEAST_EXPECT(peer->ce().lastEntropyDigest_ == uint256{}); + // Tier 3: fallback rounds carry a deterministic non-zero + // consensus-bound digest, identical across the group. + BEAST_EXPECT(peer->ce().lastEntropyDigest_ != uint256{}); + BEAST_EXPECT( + peer->ce().lastEntropyDigest_ == + majority[0]->ce().lastEntropyDigest_); BEAST_EXPECT(peer->ce().lastEntropyCount_ == 0); } } @@ -195,7 +200,12 @@ public: for (Peer const* peer : majority) { BEAST_EXPECT(peer->ce().lastEntropyWasFallback_); - BEAST_EXPECT(peer->ce().lastEntropyDigest_ == uint256{}); + // Tier 3: fallback rounds carry a deterministic non-zero + // consensus-bound digest, identical across the group. + BEAST_EXPECT(peer->ce().lastEntropyDigest_ != uint256{}); + BEAST_EXPECT( + peer->ce().lastEntropyDigest_ == + majority[0]->ce().lastEntropyDigest_); BEAST_EXPECT(peer->ce().lastEntropyCount_ == 0); } } @@ -277,7 +287,10 @@ public: for (Peer const* peer : peers) { BEAST_EXPECT(peer->ce().lastEntropyWasFallback_); - BEAST_EXPECT(peer->ce().lastEntropyDigest_ == uint256{}); + BEAST_EXPECT(peer->ce().lastEntropyDigest_ != uint256{}); + BEAST_EXPECT( + peer->ce().lastEntropyDigest_ == + peers[0]->ce().lastEntropyDigest_); } } } @@ -776,7 +789,10 @@ public: for (Peer const* peer : peers) { BEAST_EXPECT(peer->ce().lastEntropyWasFallback_); - BEAST_EXPECT(peer->ce().lastEntropyDigest_ == uint256{}); + BEAST_EXPECT(peer->ce().lastEntropyDigest_ != uint256{}); + BEAST_EXPECT( + peer->ce().lastEntropyDigest_ == + peers[0]->ce().lastEntropyDigest_); BEAST_EXPECT(peer->ce().lastEntropyCount_ == 0); } } @@ -941,12 +957,14 @@ public: for (Peer const* peer : majority) BEAST_EXPECT(peer->ce().lastEntropyDigest_ == majorityDigest); - // Peer 0 must NOT have non-zero entropy that differs from - // the majority. It should either: + // Peer 0 must NOT have validator entropy that differs from the + // majority. It should either: // a) have converged to the majority via fetch/merge, or - // b) have fallen back to zero entropy + // b) have taken the (explicitly labeled) Tier 3 fallback auto const& p0Digest = peers[0]->ce().lastEntropyDigest_; - BEAST_EXPECT(p0Digest == majorityDigest || p0Digest == uint256{}); + BEAST_EXPECT( + p0Digest == majorityDigest || + peers[0]->ce().lastEntropyWasFallback_); } void diff --git a/src/test/csf/Peer.h b/src/test/csf/Peer.h index b8280fd4f..55ce88785 100644 --- a/src/test/csf/Peer.h +++ b/src/test/csf/Peer.h @@ -348,6 +348,7 @@ struct Peer uint256 lastEntropyDigest_; std::uint16_t lastEntropyCount_ = 0; bool lastEntropyWasFallback_ = true; + std::uint8_t lastEntropyTier_ = 0; // mirrors EntropyTier values bool lastExportSucceeded_ = false; bool lastExportRetried_ = false; std::size_t exportSigFetchMerges_ = 0; @@ -702,21 +703,40 @@ struct Peer } void - finalizeRoundEntropy(std::uint32_t seq) + finalizeRoundEntropy( + std::uint32_t seq, + std::uint32_t prevLedgerId, + std::size_t txSetId) { + // CSF analog of the Tier 3 consensus-bound fallback: derived + // from already-agreed round inputs, so all same-LCL peers + // compute the same non-zero digest. Mirrors production's + // sha512Half(HashPrefix::entropyFallback, prevHash, txSet, seq). + auto const fallbackEntropy = [&] { + return sha512Half( + std::string("csf-rng-fallback"), + prevLedgerId, + static_cast(txSetId), + seq); + }; + if (!enableRngConsensus_) { + // RNG disabled: no pseudo-tx at all in production; keep the + // zero digest as the "none" marker. lastEntropyDigest_.zero(); lastEntropyCount_ = 0; lastEntropyWasFallback_ = true; + lastEntropyTier_ = 0; return; } if (shouldZeroEntropy()) { - lastEntropyDigest_.zero(); + lastEntropyDigest_ = fallbackEntropy(); lastEntropyCount_ = 0; lastEntropyWasFallback_ = true; + lastEntropyTier_ = 1; // consensus_fallback return; } @@ -732,9 +752,10 @@ struct Peer if (ordered.empty()) { - lastEntropyDigest_.zero(); + lastEntropyDigest_ = fallbackEntropy(); lastEntropyCount_ = 0; lastEntropyWasFallback_ = true; + lastEntropyTier_ = 1; // consensus_fallback return; } @@ -762,6 +783,7 @@ struct Peer lastEntropyDigest_ = digest; lastEntropyCount_ = static_cast(ordered.size()); lastEntropyWasFallback_ = false; + lastEntropyTier_ = 3; // validator_quorum } void @@ -1297,7 +1319,10 @@ struct Peer const bool consensusFail = result.state == ConsensusState::MovedOn; auto const seq = static_cast(prevLedger.seq()) + 1; - ce().finalizeRoundEntropy(seq); + ce().finalizeRoundEntropy( + seq, + static_cast(prevLedger.id()), + result.txns.id()); ce().finalizeRoundExport(); TxSet const acceptedTxs = injectTxs(prevLedger, result.txns); diff --git a/src/xrpld/app/consensus/ConsensusExtensions.cpp b/src/xrpld/app/consensus/ConsensusExtensions.cpp index 7ea2d54cf..970f7bf36 100644 --- a/src/xrpld/app/consensus/ConsensusExtensions.cpp +++ b/src/xrpld/app/consensus/ConsensusExtensions.cpp @@ -35,6 +35,7 @@ #include #include #include +#include #include #include #include @@ -443,6 +444,15 @@ ConsensusExtensions::buildExplicitFinalProposalTxSet( uint256 finalEntropy; bool hasEntropy = false; + std::uint8_t entropyTier = entropyTierNone; + std::uint16_t entropyCount = 0; + + // Tier 3 fallback over already-agreed round inputs. Uses the BASE tx + // set hash (txns.id()) — never the synthetic set's own hash (circular). + auto const fallbackEntropy = [&] { + return sha512Half( + HashPrefix::entropyFallback, roundPrevLedgerHash_, txns.id(), seq); + }; // Keep this entropy-selection logic aligned with onPreBuild(). // If these paths drift, different nodes can derive different synthetic @@ -452,11 +462,15 @@ ConsensusExtensions::buildExplicitFinalProposalTxSet( { finalEntropy = sha512Half(std::string("standalone-entropy"), seq); hasEntropy = true; + entropyTier = entropyTierValidatorQuorum; + entropyCount = 20; } else if (shouldZeroEntropy()) { - finalEntropy.zero(); + finalEntropy = fallbackEntropy(); hasEntropy = true; + entropyTier = entropyTierConsensusFallback; + entropyCount = 0; } else { @@ -485,21 +499,23 @@ ConsensusExtensions::buildExplicitFinalProposalTxSet( } finalEntropy = sha512Half(s.slice()); hasEntropy = true; + entropyTier = entropyTierValidatorQuorum; + entropyCount = static_cast(sorted.size()); } } if (!hasEntropy) { - JLOG(j_.debug()) << "RNGFINAL: no entropy available for synthetic txSet" + // Residual (no usable reveals): fall back rather than skipping, so + // the synthetic set always carries a fresh entropy pseudo-tx. + finalEntropy = fallbackEntropy(); + hasEntropy = true; + entropyTier = entropyTierConsensusFallback; + entropyCount = 0; + JLOG(j_.debug()) << "RNGFINAL: fallback entropy for synthetic txSet" << " baseTxSet=" << txns.id() << " seq=" << seq; - return std::nullopt; } - auto const entropyCount = static_cast( - app_.config().standalone() - ? 20 - : (shouldZeroEntropy() ? 0 : pendingReveals_.size())); - STTx tx(ttCONSENSUS_ENTROPY, [&](auto& obj) { obj.setFieldU32(sfLedgerSequence, seq); obj.setAccountID(sfAccount, AccountID{}); @@ -507,12 +523,32 @@ ConsensusExtensions::buildExplicitFinalProposalTxSet( obj.setFieldAmount(sfFee, STAmount{}); obj.setFieldH256(sfDigest, finalEntropy); obj.setFieldU16(sfEntropyCount, entropyCount); + obj.setFieldU8(sfEntropyTier, entropyTier); }); auto const txID = tx.getTransactionID(); - if (txns.exists(txID)) + // Dedup by type (mirrors onPreBuild): there must never be two entropy + // pseudo-txs, and a fallback digest derived from a different base set + // hash would not match by exact txID. + bool alreadyPresent = false; + txns.map_->visitLeaves( + [&](boost::intrusive_ptr const& item) { + if (alreadyPresent) + return; + try + { + SerialIter sit(item->slice()); + STTx const parsed{sit}; + if (parsed.getTxnType() == ttCONSENSUS_ENTROPY) + alreadyPresent = true; + } + catch (...) + { + } + }); + if (alreadyPresent) { - JLOG(j_.debug()) << "RNGFINAL: pseudo-tx already in base txSet" + JLOG(j_.debug()) << "RNGFINAL: entropy pseudo-tx already in base txSet" << " txHash=" << txID << " baseTxSet=" << txns.id(); return txns; } @@ -1579,7 +1615,10 @@ ConsensusExtensions::verifyPendingExportSigs( } void -ConsensusExtensions::onPreBuild(CanonicalTXSet& retriableTxs, LedgerIndex seq) +ConsensusExtensions::onPreBuild( + CanonicalTXSet& retriableTxs, + LedgerIndex seq, + uint256 const& txSetHash) { JLOG(j_.info()) << "RNG: injectEntropy" << " seq=" << seq << " commits=" << pendingCommits_.size() @@ -1592,6 +1631,17 @@ ConsensusExtensions::onPreBuild(CanonicalTXSet& retriableTxs, LedgerIndex seq) uint256 finalEntropy; bool hasEntropy = false; + std::uint8_t entropyTier = entropyTierNone; + std::uint16_t entropyCount = 0; + + // Tier 3 fallback: consensus-bound deterministic digest derived from + // already-agreed round inputs. txSetHash is the BASE (pre-injection) + // consensus tx set hash — the digest must never depend on a set that + // could contain the pseudo-tx carrying it (circular). + auto const fallbackEntropy = [&] { + return sha512Half( + HashPrefix::entropyFallback, roundPrevLedgerHash_, txSetHash, seq); + }; //@@start rng-inject-entropy-selection // Calculate entropy from collected reveals @@ -1601,19 +1651,25 @@ ConsensusExtensions::onPreBuild(CanonicalTXSet& retriableTxs, LedgerIndex seq) // so that Hook APIs (dice/random) work for testing. finalEntropy = sha512Half(std::string("standalone-entropy"), seq); hasEntropy = true; + entropyTier = entropyTierValidatorQuorum; + entropyCount = 20; // synthetic: high enough for any hook minimum JLOG(j_.info()) << "RNG: standalone synthetic entropy" << " seq=" << seq << " entropy=" << finalEntropy; } else if (shouldZeroEntropy()) { - // Liveness fallback: inject zero entropy. - // Hooks MUST check for zero to know entropy is unavailable. + // Liveness fallback (Tier 3): consensus-bound deterministic entropy + // instead of zero. EntropyTier/EntropyCount mark it fallback-grade — + // user-influenceable via tx submission, never for value-bearing use. // shouldZeroEntropy() covers: pipeline failure, no reveals, // or sub-quorum reveals (too easily influenced by a minority). - finalEntropy.zero(); + finalEntropy = fallbackEntropy(); hasEntropy = true; - JLOG(j_.warn()) << "RNG: injecting ZERO entropy" + entropyTier = entropyTierConsensusFallback; + entropyCount = 0; + JLOG(j_.warn()) << "RNG: injecting FALLBACK entropy" << " seq=" << seq << " reason=fallback" + << " entropy=" << finalEntropy << " reveals=" << pendingReveals_.size() << " threshold=" << quorumThreshold() << " entropyFailed=" << (entropyFailed_ ? "yes" : "no") @@ -1664,6 +1720,8 @@ ConsensusExtensions::onPreBuild(CanonicalTXSet& retriableTxs, LedgerIndex seq) } finalEntropy = sha512Half(s.slice()); hasEntropy = true; + entropyTier = entropyTierValidatorQuorum; + entropyCount = static_cast(sorted.size()); JLOG(j_.info()) << "RNG: injecting entropy" @@ -1672,6 +1730,20 @@ ConsensusExtensions::onPreBuild(CanonicalTXSet& retriableTxs, LedgerIndex seq) << " entropySetHash=" << entropySetMap_->getHash().as_uint256(); } } + + if (!hasEntropy) + { + // Residual: an entropy set passed the gate but yielded no parseable + // leaves. Fall back rather than skipping injection so a fresh + // ConsensusEntropy entry exists on every RNG-enabled ledger. + finalEntropy = fallbackEntropy(); + hasEntropy = true; + entropyTier = entropyTierConsensusFallback; + entropyCount = 0; + JLOG(j_.warn()) << "RNG: injecting FALLBACK entropy" + << " seq=" << seq << " reason=unparseable-entropy-set" + << " entropy=" << finalEntropy; + } //@@end rng-inject-entropy-selection //@@start rng-inject-pseudotx @@ -1696,13 +1768,6 @@ ConsensusExtensions::onPreBuild(CanonicalTXSet& retriableTxs, LedgerIndex seq) //@@start rng-inject-pseudotx-core // Account Zero convention for pseudo-transactions (same as ttFEE, etc) - auto const entropyCount = static_cast( - app_.config().standalone() - ? 20 // synthetic: high enough for Hook APIs (need >= 5) - : (shouldZeroEntropy() - ? 0 - : std::distance( - entropySetMap_->begin(), entropySetMap_->end()))); STTx tx(ttCONSENSUS_ENTROPY, [&](auto& obj) { obj.setFieldU32(sfLedgerSequence, seq); obj.setAccountID(sfAccount, AccountID{}); @@ -1710,12 +1775,17 @@ ConsensusExtensions::onPreBuild(CanonicalTXSet& retriableTxs, LedgerIndex seq) obj.setFieldAmount(sfFee, STAmount{}); obj.setFieldH256(sfDigest, finalEntropy); obj.setFieldU16(sfEntropyCount, entropyCount); + obj.setFieldU8(sfEntropyTier, entropyTier); }); auto const txID = tx.getTransactionID(); + // Dedup by type, not exact txID: with explicit-final proposals the + // agreed set can already carry an entropy pseudo-tx whose fallback + // digest was derived from a base tx set hash this node cannot + // reconstruct. There must never be two entropy pseudo-txs. auto alreadyPresent = std::any_of( retriableTxs.begin(), retriableTxs.end(), [&](auto const& entry) { - return entry.first.getTXID() == txID; + return entry.second->getTxnType() == ttCONSENSUS_ENTROPY; }); if (alreadyPresent) { @@ -2015,6 +2085,7 @@ ConsensusExtensions::onRoundStart( hash_set lastProposers) { clearRngState(); + roundPrevLedgerHash_ = prevLedger.ledger_->info().hash; cacheUNLReport(prevLedger.ledger_); setExpectedProposers(std::move(lastProposers)); resetSubState(); diff --git a/src/xrpld/app/consensus/ConsensusExtensions.h b/src/xrpld/app/consensus/ConsensusExtensions.h index c50749934..403e423c2 100644 --- a/src/xrpld/app/consensus/ConsensusExtensions.h +++ b/src/xrpld/app/consensus/ConsensusExtensions.h @@ -66,6 +66,9 @@ private: std::shared_ptr entropySetMap_; std::shared_ptr exportSigSetMap_; std::optional rngRoundSeq_; + // Consensus parent ledger hash, pinned at round start. Input to the + // Tier 3 consensus-bound fallback entropy digest. + uint256 roundPrevLedgerHash_; std::shared_ptr consensusTxSetMap_; hash_map> consensusExportTxns_; std::optional consensusTxSetHash_; @@ -299,8 +302,14 @@ public: void clearRngState(); + /// txSetHash is the BASE (pre-injection) consensus tx set hash — + /// an input to the Tier 3 fallback digest. It must never be the hash + /// of a set that could contain the entropy pseudo-tx itself. void - onPreBuild(CanonicalTXSet& retriableTxs, LedgerIndex seq); + onPreBuild( + CanonicalTXSet& retriableTxs, + LedgerIndex seq, + uint256 const& txSetHash); void harvestRngData( diff --git a/src/xrpld/app/consensus/RCLConsensus.cpp b/src/xrpld/app/consensus/RCLConsensus.cpp index fa0beaffc..32c7e4884 100644 --- a/src/xrpld/app/consensus/RCLConsensus.cpp +++ b/src/xrpld/app/consensus/RCLConsensus.cpp @@ -594,7 +594,7 @@ RCLConsensus::Adaptor::doAccept( // so ttEXPORT can observe exportSigSetHash convergence at apply time. //@@start accept-time-cleanup-disabled if (ce().rngEnabled()) - ce().onPreBuild(retriableTxs, prevLedger.seq() + 1); + ce().onPreBuild(retriableTxs, prevLedger.seq() + 1, result.txns.id()); else if (!ce().exportEnabled()) ce().clearRngState(); //@@end accept-time-cleanup-disabled diff --git a/src/xrpld/app/tx/detail/Change.cpp b/src/xrpld/app/tx/detail/Change.cpp index 62bac4725..8d9e9e2d1 100644 --- a/src/xrpld/app/tx/detail/Change.cpp +++ b/src/xrpld/app/tx/detail/Change.cpp @@ -253,6 +253,7 @@ Change::applyConsensusEntropy() sle->setFieldH256(sfDigest, entropy); sle->setFieldU16(sfEntropyCount, ctx_.tx.getFieldU16(sfEntropyCount)); + sle->setFieldU8(sfEntropyTier, ctx_.tx.getFieldU8(sfEntropyTier)); sle->setFieldU32(sfLedgerSequence, view().info().seq); // Note: sfPreviousTxnID and sfPreviousTxnLgrSeq are set automatically // by ApplyStateTable::threadItem() because isThreadedType() returns true