feat(rng): tier 3 consensus-bound fallback entropy

Replace the zero-entropy fallback with a deterministic consensus-bound
digest so every RNG-enabled ledger carries usable entropy:

- sha512Half(HashPrefix::entropyFallback, prevLedgerHash, baseTxSetHash,
  seq) — all inputs are already consensus-agreed at injection time, so
  no new agreement machinery is needed and the digest is identical on
  every node building the same ledger
- new sfEntropyTier (UINT8) on the ttCONSENSUS_ENTROPY pseudo-tx and
  ConsensusEntropy ledger entry: EntropyCount says how many validators
  contributed, EntropyTier says which gate the result passed
  (validator_quorum vs consensus_fallback; participant_aligned reserved)
- the fallback digest derives from the BASE (pre-injection) tx set hash
  to avoid circularity; entropy pseudo-tx dedup is now type-based since
  an explicit-final synthetic set can carry a pseudo-tx whose txID
  implicit nodes cannot re-derive
- unparseable-entropy-set residual now falls back instead of skipping
  injection, so a fresh ConsensusEntropy entry exists every ledger
- CSF Peer mirrors the fallback analog; sims assert deterministic
  non-zero fallback digests across same-LCL peers
- testnet scenarios updated: degraded windows expect labeled fallback
  entropy, never validator-tier

The fallback tier is user-influenceable via tx submission (quiet-ledger
grinding) and is labeled accordingly — hook-facing gating lands with the
min_tier/min_count API change.
This commit is contained in:
Nicholas Dudfield
2026-06-10 16:32:49 +07:00
parent fbdec3be66
commit c92c0656ec
14 changed files with 305 additions and 73 deletions

View File

@@ -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")

View File

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

View File

@@ -0,0 +1,37 @@
#ifndef RIPPLE_PROTOCOL_ENTROPY_TIER_H_INCLUDED
#define RIPPLE_PROTOCOL_ENTROPY_TIER_H_INCLUDED
#include <cstdint>
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

View File

@@ -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 <class Hasher>

View File

@@ -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},

View File

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

View File

@@ -622,5 +622,6 @@ TRANSACTION(ttCONSENSUS_ENTROPY, 105, ConsensusEntropy, ({
{sfLedgerSequence, soeREQUIRED},
{sfDigest, soeREQUIRED},
{sfEntropyCount, soeREQUIRED},
{sfEntropyTier, soeREQUIRED},
{sfBlob, soeOPTIONAL},
}))

View File

@@ -28,6 +28,7 @@
#include <xrpld/consensus/ConsensusProposal.h>
#include <xrpl/basics/StringUtilities.h>
#include <xrpl/beast/unit_test.h>
#include <xrpl/protocol/EntropyTier.h>
#include <xrpl/protocol/Feature.h>
#include <xrpl/protocol/Indexes.h>
#include <xrpl/protocol/STAmount.h>
@@ -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);
}

View File

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

View File

@@ -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<std::uint64_t>(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<std::uint16_t>(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<std::uint32_t>(prevLedger.seq()) + 1;
ce().finalizeRoundEntropy(seq);
ce().finalizeRoundEntropy(
seq,
static_cast<std::uint32_t>(prevLedger.id()),
result.txns.id());
ce().finalizeRoundExport();
TxSet const acceptedTxs = injectTxs(prevLedger, result.txns);

View File

@@ -35,6 +35,7 @@
#include <xrpld/shamap/SHAMap.h>
#include <xrpl/basics/random.h>
#include <xrpl/crypto/csprng.h>
#include <xrpl/protocol/EntropyTier.h>
#include <xrpl/protocol/ExportLimits.h>
#include <xrpl/protocol/Feature.h>
#include <xrpl/protocol/Indexes.h>
@@ -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<std::uint16_t>(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<std::uint16_t>(
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<SHAMapItem const> 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<std::uint16_t>(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<std::uint16_t>(
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<NodeID> lastProposers)
{
clearRngState();
roundPrevLedgerHash_ = prevLedger.ledger_->info().hash;
cacheUNLReport(prevLedger.ledger_);
setExpectedProposers(std::move(lastProposers));
resetSubState();

View File

@@ -66,6 +66,9 @@ private:
std::shared_ptr<SHAMap> entropySetMap_;
std::shared_ptr<SHAMap> exportSigSetMap_;
std::optional<LedgerIndex> rngRoundSeq_;
// Consensus parent ledger hash, pinned at round start. Input to the
// Tier 3 consensus-bound fallback entropy digest.
uint256 roundPrevLedgerHash_;
std::shared_ptr<SHAMap const> consensusTxSetMap_;
hash_map<uint256, std::shared_ptr<STTx const>> consensusExportTxns_;
std::optional<uint256> 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(

View File

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

View File

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