diff --git a/.testnet/scenarios/entropy/quorum_degradation_smoke.py b/.testnet/scenarios/entropy/quorum_degradation_smoke.py index b90b9fdf6..6b83f204d 100644 --- a/.testnet/scenarios/entropy/quorum_degradation_smoke.py +++ b/.testnet/scenarios/entropy/quorum_degradation_smoke.py @@ -41,18 +41,27 @@ async def scenario(ctx, log): for seq in range(val_before + 1, degraded_end + 1): ce, _ = get_entropy_tx(ctx, seq) digest, entropy_count, is_fallback = entropy_fields(ce) + tier = ce.get("EntropyTier") - if not is_fallback: + # Tier 3 = consensus_fallback (1): explicit tier, count 0, + # deterministic NON-zero digest. + if tier != 1: raise AssertionError( - f"Ledger {seq}: expected fallback entropy during 3/5 " - f"window, got Digest={digest[:16]}... " - f"EntropyCount={entropy_count}" + f"Ledger {seq}: expected EntropyTier==1 " + f"(consensus_fallback) during 3/5 window, got {tier} " + f"(EntropyCount={entropy_count})" ) - if digest == ZERO_DIGEST: + if entropy_count != 0: raise AssertionError( - f"Ledger {seq}: fallback digest should be non-zero " - f"(Tier 3), got zero" + f"Ledger {seq}: fallback EntropyCount must be 0, got " + f"{entropy_count}" ) + if not digest or digest == ZERO_DIGEST: + raise AssertionError( + f"Ledger {seq}: fallback digest must be non-zero " + f"(Tier 3), got {digest[:16]}..." + ) + assert is_fallback # tier==1 implies fallback degraded_fallback += 1 log( diff --git a/src/xrpld/app/consensus/ConsensusExtensions.cpp b/src/xrpld/app/consensus/ConsensusExtensions.cpp index 970f7bf36..97e871e19 100644 --- a/src/xrpld/app/consensus/ConsensusExtensions.cpp +++ b/src/xrpld/app/consensus/ConsensusExtensions.cpp @@ -1779,19 +1779,46 @@ ConsensusExtensions::onPreBuild( }); 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) { + // Value-based dedup. There must never be two entropy pseudo-txs, but + // when one is already present (explicit-final, or a peer's agreed + // set) it must be VALIDATED as the exact pseudo-tx we would have + // produced — not merely "same type". Injection is deterministic, so + // every honest node derives the identical pseudo-tx (identical txID) + // for the same agreed inputs. A present-but-different entropy pseudo-tx + // is therefore a determinism violation (version skew or a divergent/ + // malicious peer) and must be surfaced, not silently trusted. + auto const existing = std::find_if( + retriableTxs.begin(), retriableTxs.end(), [](auto const& entry) { return entry.second->getTxnType() == ttCONSENSUS_ENTROPY; }); - if (alreadyPresent) + if (existing != retriableTxs.end()) { - JLOG(j_.debug()) << "RNG: entropy pseudo-tx already present" - << " txHash=" << txID << " seq=" << seq - << " action=skip-duplicate"; + auto const existingID = existing->second->getTransactionID(); + if (existingID == txID) + { + JLOG(j_.debug()) << "RNG: entropy pseudo-tx already present" + << " txHash=" << txID << " seq=" << seq + << " action=skip-duplicate-verified"; + } + else + { + // The agreed tx set's hash already commits to the existing + // pseudo-tx, so we cannot replace it without forking off the + // agreed ledger; keep it, but loudly flag the mismatch. + JLOG(j_.error()) + << "RNG: entropy pseudo-tx MISMATCH" + << " seq=" << seq << " reason=determinism-violation" + << " ourTxHash=" << txID << " ourDigest=" << finalEntropy + << " ourTier=" << static_cast(entropyTier) + << " ourCount=" << entropyCount + << " presentTxHash=" << existingID << " presentDigest=" + << existing->second->getFieldH256(sfDigest) + << " presentTier=" + << static_cast( + existing->second->getFieldU8(sfEntropyTier)) + << " presentCount=" + << existing->second->getFieldU16(sfEntropyCount); + } } else { diff --git a/src/xrpld/app/hook/detail/applyHook.cpp b/src/xrpld/app/hook/detail/applyHook.cpp index 4248e5147..e57ebbacb 100644 --- a/src/xrpld/app/hook/detail/applyHook.cpp +++ b/src/xrpld/app/hook/detail/applyHook.cpp @@ -4068,8 +4068,15 @@ fairRng( // TODO: open-ledger entropy uses previous ledger's entropy, so // dice/random results will differ between speculative and final // execution. This needs further thought re: UX implications. + // Defensive: sfEntropyTier is soeREQUIRED, so any entry this code wrote + // carries it. A missing field can only come from a pre-tier-3 persisted + // entry; treat that as tier 0 (none) so the requirement check fails closed. + auto const entropyTier = + sleEntropy && sleEntropy->isFieldPresent(sfEntropyTier) + ? sleEntropy->getFieldU8(sfEntropyTier) + : std::uint8_t{0}; if (!sleEntropy || entropySeq > seq || (seq - entropySeq) > 1 || - sleEntropy->getFieldU8(sfEntropyTier) < minTier || + entropyTier < minTier || sleEntropy->getFieldU16(sfEntropyCount) < minCount) return {};