fix(rng): value-based entropy pseudo-tx dedup + tier-read hardening

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.
This commit is contained in:
Nicholas Dudfield
2026-06-12 16:39:39 +07:00
parent 0cf6f73441
commit d6481a3869
3 changed files with 61 additions and 18 deletions

View File

@@ -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(

View File

@@ -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<int>(entropyTier)
<< " ourCount=" << entropyCount
<< " presentTxHash=" << existingID << " presentDigest="
<< existing->second->getFieldH256(sfDigest)
<< " presentTier="
<< static_cast<int>(
existing->second->getFieldU8(sfEntropyTier))
<< " presentCount="
<< existing->second->getFieldU16(sfEntropyCount);
}
}
else
{

View File

@@ -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 {};