feat: record consensus entropy denominator

This commit is contained in:
Nicholas Dudfield
2026-07-02 17:38:40 +07:00
parent 9a99c2d400
commit fa876927a9
16 changed files with 94 additions and 36 deletions

View File

@@ -26,6 +26,7 @@
#define sfLedgerFixType ((1U << 16U) + 22U)
#define sfHookExportCount ((1U << 16U) + 98U)
#define sfEntropyCount ((1U << 16U) + 99U)
#define sfEntropyDenominator ((1U << 16U) + 100U)
#define sfNetworkID ((2U << 16U) + 1U)
#define sfFlags ((2U << 16U) + 2U)
#define sfSourceTag ((2U << 16U) + 3U)

View File

@@ -26,6 +26,7 @@
#define sfLedgerFixType ((1U << 16U) + 22U)
#define sfHookExportCount ((1U << 16U) + 98U)
#define sfEntropyCount ((1U << 16U) + 99U)
#define sfEntropyDenominator ((1U << 16U) + 100U)
#define sfNetworkID ((2U << 16U) + 1U)
#define sfFlags ((2U << 16U) + 2U)
#define sfSourceTag ((2U << 16U) + 3U)

View File

@@ -9,9 +9,12 @@ namespace ripple {
/// 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).
/// EntropyCount says how many validators contributed; EntropyDenominator says
/// how many active validators were in the ledger-anchored view for that
/// non-fallback result; EntropyTier says which gate the result passed. Fallback
/// entropy carries count=0/denominator=0 because no validator-derived
/// denominator was accepted. Tier values are strength-ordered so consumers can
/// gate with a numeric comparison (tier >= required).
///
/// RESIDUAL BIAS — applies to EVERY tier, including participant_aligned and
/// validator_quorum, not just the fallback. This is a commit/reveal scheme: a

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},
{sfEntropyDenominator, soeREQUIRED},
{sfEntropyTier, soeREQUIRED},
{sfLedgerSequence, soeREQUIRED},
{sfPreviousTxnID, soeREQUIRED},

View File

@@ -63,6 +63,7 @@ TYPED_SFIELD(sfHookStateScale, UINT16, 21)
TYPED_SFIELD(sfLedgerFixType, UINT16, 22)
TYPED_SFIELD(sfHookExportCount, UINT16, 98)
TYPED_SFIELD(sfEntropyCount, UINT16, 99)
TYPED_SFIELD(sfEntropyDenominator, UINT16, 100)
// 32-bit integers (common)
TYPED_SFIELD(sfNetworkID, UINT32, 1)

View File

@@ -622,6 +622,7 @@ TRANSACTION(ttCONSENSUS_ENTROPY, 105, ConsensusEntropy, ({
{sfLedgerSequence, soeREQUIRED},
{sfDigest, soeREQUIRED},
{sfEntropyCount, soeREQUIRED},
{sfEntropyDenominator, soeREQUIRED},
{sfEntropyTier, soeREQUIRED},
}))

View File

@@ -84,6 +84,7 @@ class ConsensusEntropy_test : public beast::unit_test::suite
auto const count = sle->getFieldU16(sfEntropyCount);
BEAST_EXPECT(count >= 5);
BEAST_EXPECT(sle->getFieldU16(sfEntropyDenominator) >= count);
auto const sleSeq = sle->getFieldU32(sfLedgerSequence);
BEAST_EXPECT(sleSeq == env.closed()->seq());
@@ -445,7 +446,8 @@ class ConsensusEntropy_test : public beast::unit_test::suite
BEAST_REQUIRE(env.le(keylet::consensusEntropy()));
// Standalone entropy carries EntropyCount=20 / tier validator_quorum.
// Standalone entropy carries EntropyCount=20,
// EntropyDenominator=20, and tier validator_quorum.
// A hook demanding min_count=21 states a requirement this ledger
// cannot meet, so dice must fail closed with TOO_LITTLE_ENTROPY (-48)
// rather than silently serving weaker entropy.

View File

@@ -65,6 +65,7 @@ struct PseudoTx_test : public beast::unit_test::suite
obj.setFieldAmount(sfFee, STAmount{});
obj.setFieldH256(sfDigest, uint256(3));
obj.setFieldU16(sfEntropyCount, 1);
obj.setFieldU16(sfEntropyDenominator, 1);
obj.setFieldU8(sfEntropyTier, entropyTierValidatorQuorum);
}));

View File

@@ -48,6 +48,7 @@
#include <deque>
#include <limits>
#include <string>
#include <tuple>
namespace ripple {
namespace test {
@@ -1238,6 +1239,7 @@ class ConsensusExtensions_test : public beast::unit_test::suite
BEAST_EXPECT(tx->getFieldH256(sfDigest) == expected);
BEAST_EXPECT(tx->getFieldH256(sfDigest) != uint256{});
BEAST_EXPECT(tx->getFieldU16(sfEntropyCount) == 0);
BEAST_EXPECT(tx->getFieldU16(sfEntropyDenominator) == 0);
BEAST_EXPECT(
tx->getFieldU8(sfEntropyTier) == entropyTierConsensusFallback);
@@ -1283,6 +1285,7 @@ class ConsensusExtensions_test : public beast::unit_test::suite
txSetHash,
fallbackDigest,
static_cast<std::uint8_t>(entropyTierConsensusFallback),
static_cast<std::uint16_t>(0),
static_cast<std::uint16_t>(0));
auto const salt = ce.txnOrderingSalt(txSetHash, seq);
@@ -1351,6 +1354,7 @@ 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->getFieldU16(sfEntropyDenominator) == 1);
BEAST_EXPECT(
tx->getFieldU8(sfEntropyTier) == entropyTierValidatorQuorum);
}
@@ -1420,6 +1424,7 @@ 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->getFieldU16(sfEntropyDenominator) == 1);
BEAST_EXPECT(
tx->getFieldU8(sfEntropyTier) == entropyTierValidatorQuorum);
}
@@ -1476,7 +1481,8 @@ class ConsensusExtensions_test : public beast::unit_test::suite
auto const txSetHash = makeHash("tier2-txset");
// Harvest commit+reveal from `revealers` of the 6 validators, build the
// agreed entropy set, inject, and return the labelled (tier, count).
// agreed entropy set, inject, and return the labelled
// (tier, count, denominator).
auto runWith = [&](std::size_t revealers) {
ConsensusExtensions ce{env.app(), activeNoopJournal()};
ce.cacheUNLReport(viewLedger);
@@ -1501,28 +1507,32 @@ class ConsensusExtensions_test : public beast::unit_test::suite
CanonicalTXSet txs{makeHash("tier2-salt")};
ce.onPreBuild(txs, seq, txSetHash);
auto const tx = singleCanonicalTx(txs);
std::pair<int, std::uint16_t> out{-1, 0};
std::tuple<int, std::uint16_t, std::uint16_t> out{-1, 0, 0};
if (tx)
out = {
tx->getFieldU8(sfEntropyTier),
tx->getFieldU16(sfEntropyCount)};
tx->getFieldU16(sfEntropyCount),
tx->getFieldU16(sfEntropyDenominator)};
return out;
};
// 5 of 6 aligned -> validator_quorum (count >= quorum 5).
auto const q = runWith(5);
BEAST_EXPECT(q.first == entropyTierValidatorQuorum);
BEAST_EXPECT(q.second == 5);
BEAST_EXPECT(std::get<0>(q) == entropyTierValidatorQuorum);
BEAST_EXPECT(std::get<1>(q) == 5);
BEAST_EXPECT(std::get<2>(q) == kValidators);
// 4 of 6 aligned -> participant_aligned (count >= tier2 4, < quorum 5).
auto const p = runWith(4);
BEAST_EXPECT(p.first == entropyTierParticipantAligned);
BEAST_EXPECT(p.second == 4);
BEAST_EXPECT(std::get<0>(p) == entropyTierParticipantAligned);
BEAST_EXPECT(std::get<1>(p) == 4);
BEAST_EXPECT(std::get<2>(p) == kValidators);
// 3 of 6 aligned -> below the tier-2 floor -> consensus_fallback.
auto const f = runWith(3);
BEAST_EXPECT(f.first == entropyTierConsensusFallback);
BEAST_EXPECT(f.second == 0);
BEAST_EXPECT(std::get<0>(f) == entropyTierConsensusFallback);
BEAST_EXPECT(std::get<1>(f) == 0);
BEAST_EXPECT(std::get<2>(f) == 0);
}
void
@@ -1669,25 +1679,29 @@ class ConsensusExtensions_test : public beast::unit_test::suite
CanonicalTXSet txs{makeHash("tier2-nunl-salt")};
ce.onPreBuild(txs, seq, txSetHash);
auto const tx = singleCanonicalTx(txs);
std::pair<int, std::uint16_t> out{-1, 0};
std::tuple<int, std::uint16_t, std::uint16_t> out{-1, 0, 0};
if (tx)
out = {
tx->getFieldU8(sfEntropyTier),
tx->getFieldU16(sfEntropyCount)};
tx->getFieldU16(sfEntropyCount),
tx->getFieldU16(sfEntropyDenominator)};
return out;
};
auto const q = runWith(16);
BEAST_EXPECT(q.first == entropyTierValidatorQuorum);
BEAST_EXPECT(q.second == 16);
BEAST_EXPECT(std::get<0>(q) == entropyTierValidatorQuorum);
BEAST_EXPECT(std::get<1>(q) == 16);
BEAST_EXPECT(std::get<2>(q) == kOriginal - kDisabled);
auto const p = runWith(15);
BEAST_EXPECT(p.first == entropyTierParticipantAligned);
BEAST_EXPECT(p.second == 15);
BEAST_EXPECT(std::get<0>(p) == entropyTierParticipantAligned);
BEAST_EXPECT(std::get<1>(p) == 15);
BEAST_EXPECT(std::get<2>(p) == kOriginal - kDisabled);
auto const f = runWith(12);
BEAST_EXPECT(f.first == entropyTierConsensusFallback);
BEAST_EXPECT(f.second == 0);
BEAST_EXPECT(std::get<0>(f) == entropyTierConsensusFallback);
BEAST_EXPECT(std::get<1>(f) == 0);
BEAST_EXPECT(std::get<2>(f) == 0);
}
void
@@ -2321,6 +2335,7 @@ 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->getFieldU16(sfEntropyDenominator) == 20);
BEAST_EXPECT(
tx->getFieldU8(sfEntropyTier) == entropyTierValidatorQuorum);
@@ -2353,6 +2368,7 @@ class ConsensusExtensions_test : public beast::unit_test::suite
obj.setFieldVL(sfSigningPubKey, Slice{}); // pseudo-tx convention
obj.setFieldH256(sfDigest, digest);
obj.setFieldU16(sfEntropyCount, count);
obj.setFieldU16(sfEntropyDenominator, count);
obj.setFieldU8(sfEntropyTier, entropyTierConsensusFallback);
return std::make_shared<STTx const>(makeSTTx(obj));
};

View File

@@ -66,6 +66,8 @@ public:
{
BEAST_EXPECT(!peer->ce().lastEntropyWasFallback_);
BEAST_EXPECT(peer->ce().lastEntropyCount_ > 0);
BEAST_EXPECT(
peer->ce().lastEntropyDenominator_ == peers.size());
BEAST_EXPECT(peer->ce().lastEntropyDigest_ != uint256{});
}
}
@@ -908,6 +910,7 @@ public:
peer->ce().lastEntropyDigest_ ==
peers[0]->ce().lastEntropyDigest_);
BEAST_EXPECT(peer->ce().lastEntropyCount_ == 0);
BEAST_EXPECT(peer->ce().lastEntropyDenominator_ == 0);
}
}
@@ -981,6 +984,7 @@ public:
BEAST_EXPECT(
peer->ce().lastEntropyTier_ == entropyTierConsensusFallback);
BEAST_EXPECT(peer->ce().lastEntropyCount_ == 0);
BEAST_EXPECT(peer->ce().lastEntropyDenominator_ == 0);
BEAST_EXPECT(peer->ce().lastEntropyDigest_ != uint256{});
}
BEAST_EXPECT(
@@ -1038,12 +1042,14 @@ public:
BEAST_EXPECT(
peers[i]->ce().lastEntropyTier_ == entropyTierValidatorQuorum);
BEAST_EXPECT(peers[i]->ce().lastEntropyCount_ == 4);
BEAST_EXPECT(peers[i]->ce().lastEntropyDenominator_ == 5);
}
BEAST_EXPECT(peers[0]->ce().lastEntropyWasFallback_);
BEAST_EXPECT(
peers[0]->ce().lastEntropyTier_ == entropyTierConsensusFallback);
BEAST_EXPECT(peers[0]->ce().lastEntropyCount_ == 0);
BEAST_EXPECT(peers[0]->ce().lastEntropyDenominator_ == 0);
BEAST_EXPECT(peers[0]->ce().lastEntropyDigest_ != cohortDigest);
}

View File

@@ -352,6 +352,7 @@ struct Peer
// Last round summary (for test assertions)
uint256 lastEntropyDigest_;
std::uint16_t lastEntropyCount_ = 0;
std::uint16_t lastEntropyDenominator_ = 0;
bool lastEntropyWasFallback_ = true;
EntropyTier lastEntropyTier_ = entropyTierNone;
bool lastExportSucceeded_ = false;
@@ -819,6 +820,7 @@ struct Peer
// zero digest as the "none" marker.
lastEntropyDigest_.zero();
lastEntropyCount_ = 0;
lastEntropyDenominator_ = 0;
lastEntropyWasFallback_ = true;
lastEntropyTier_ = entropyTierNone;
return;
@@ -829,6 +831,7 @@ struct Peer
auto const fallback = [&] {
lastEntropyDigest_ = fallbackEntropy();
lastEntropyCount_ = 0;
lastEntropyDenominator_ = 0;
lastEntropyWasFallback_ = true;
lastEntropyTier_ = entropyTierConsensusFallback;
};
@@ -902,6 +905,8 @@ struct Peer
}
lastEntropyDigest_ = digest;
lastEntropyCount_ = static_cast<std::uint16_t>(count);
lastEntropyDenominator_ =
static_cast<std::uint16_t>(unlNodes_.size());
lastEntropyWasFallback_ = false;
}

View File

@@ -25,11 +25,12 @@ determinism or liveness.**
**INV-1 — Determinism of the injected object.**
Given the same parent ledger and the same *agreed* entropy sidecar, every honest
node injects the byte-identical `ttCONSENSUS_ENTROPY` (digest, tier, count). That
node injects the byte-identical `ttCONSENSUS_ENTROPY` (digest, tier, count,
denominator). That
object is ledger state. Therefore **non-fallback entropy must not read mutable
local collector state or timing-derived state.** The selector derives non-fallback
`(digest, tier, count)` only from the accepted `entropySetMap_` (matched to the
hash the gate accepted) plus the parent-ledger active view. Local timeout or
`(digest, tier, count, denominator)` only from the accepted `entropySetMap_`
(matched to the hash the gate accepted) plus the parent-ledger active view. Local timeout or
diagnostic state such as `entropyFailed_` must not override an accepted root at
injection time; a node that never accepts a root falls back through the normal
missing-accepted-root path.

View File

@@ -782,6 +782,7 @@ ConsensusExtensions::selectEntropy(
agreedTxSetHash,
seq),
entropyTierConsensusFallback,
0,
0};
};
//@@end entropy-selector-fallback
@@ -792,6 +793,7 @@ ConsensusExtensions::selectEntropy(
return {
sha512Half(std::string("standalone-entropy"), seq),
entropyTierValidatorQuorum,
20,
20};
//@@end entropy-selector-standalone
@@ -836,9 +838,9 @@ ConsensusExtensions::selectEntropy(
// Derive from the AGREED entropySetMap_ — NOT local pendingReveals_. The
// map's hash was published in proposals and accepted by the gate, so every
// node holding the same entropySetHash produces byte-identical entropy and
// the same tier/count. Read leaves through the shared sidecar admission
// helper so accepted-map consumption enforces the same content-address/type
// contract as snapshot construction.
// the same tier/count/denominator labels. Read leaves through the shared
// sidecar admission helper so accepted-map consumption enforces the same
// content-address/type contract as snapshot construction.
std::vector<std::pair<PublicKey, uint256>> sorted;
entropySetMap_->visitLeaves(
[&](boost::intrusive_ptr<SHAMapItem const> const& item) {
@@ -887,6 +889,7 @@ ConsensusExtensions::selectEntropy(
}
auto const digest = sha512Half(s.slice());
auto const count = static_cast<std::uint16_t>(sorted.size());
auto const denominator = static_cast<std::uint16_t>(validatorView->size());
//@@start entropy-selector-tier-ladder
// Tier ladder over the AGREED participant count — deterministic on every
@@ -901,7 +904,7 @@ ConsensusExtensions::selectEntropy(
validatorView->size(),
validatorView->originalViewSize);
if (tier != entropyTierConsensusFallback)
return {digest, static_cast<std::uint8_t>(tier), count};
return {digest, static_cast<std::uint8_t>(tier), count, denominator};
return fallback();
//@@end entropy-selector-tier-ladder
}
@@ -926,7 +929,8 @@ ConsensusExtensions::txnOrderingSalt(
agreedTxSetHash,
selection.digest,
selection.tier,
selection.count);
selection.count,
selection.denominator);
}
bool
@@ -1711,19 +1715,21 @@ ConsensusExtensions::onPreBuild(
//@@start rng-inject-entropy-selection
// One deterministic selector over the AGREED entropySetMap_ chooses the
// digest and its tier/count. Every node derives the same entropy for
// the same agreed round inputs. txSetHash is the agreed pre-injection
// consensus tx set hash.
// 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);
uint256 const finalEntropy = selection.digest;
std::uint8_t const entropyTier = selection.tier;
std::uint16_t const entropyCount = selection.count;
std::uint16_t const entropyDenominator = selection.denominator;
//@@end rng-inject-entropy-selection
JLOG(j_.info()) << "RNG: entropy selected"
<< " seq=" << seq
<< " tier=" << static_cast<int>(entropyTier)
<< " count=" << entropyCount
<< " denominator=" << entropyDenominator
<< " digest=" << finalEntropy;
//@@start rng-inject-pseudotx
@@ -1755,6 +1761,7 @@ ConsensusExtensions::onPreBuild(
obj.setFieldAmount(sfFee, STAmount{});
obj.setFieldH256(sfDigest, finalEntropy);
obj.setFieldU16(sfEntropyCount, entropyCount);
obj.setFieldU16(sfEntropyDenominator, entropyDenominator);
obj.setFieldU8(sfEntropyTier, entropyTier);
});
@@ -1820,6 +1827,11 @@ ConsensusExtensions::onPreBuild(
<< (pres.isFieldPresent(sfEntropyCount)
? std::to_string(
pres.getFieldU16(sfEntropyCount))
: std::string{"<missing>"})
<< " presentDenominator="
<< (pres.isFieldPresent(sfEntropyDenominator)
? std::to_string(
pres.getFieldU16(sfEntropyDenominator))
: std::string{"<missing>"});
}
}

View File

@@ -257,13 +257,15 @@ public:
clearAcceptedEntropySet();
/// Result of the shared deterministic entropy selector: the digest to
/// inject plus its tier/count labels. Both injection paths derive these
/// identically from the AGREED entropySetMap_ so they cannot drift.
/// inject plus its tier/count/denominator labels. Both injection paths
/// derive these identically from the AGREED entropySetMap_ so they cannot
/// drift.
struct EntropySelection
{
uint256 digest;
std::uint8_t tier = 0; // EntropyTier; the selector always sets this
std::uint16_t count = 0;
std::uint16_t denominator = 0;
};
/// Deterministically choose the entropy to inject for this round from the

View File

@@ -347,8 +347,11 @@ non-UNLReport (config-fallback) view, every case below instead mints
The fallback pseudo-transaction is deterministic — every node derives the same
digest from `(HashPrefix::entropyFallback, parentLedgerHash, agreedTxSetHash,
seq)` — and labeled with `EntropyTier = consensus_fallback` and
`EntropyCount = 0`. Hooks state their own requirements via the required
seq)` — and labeled with `EntropyTier = consensus_fallback`,
`EntropyCount = 0`, and `EntropyDenominator = 0`. Non-fallback entropy records
both the contributor count and the active-validator denominator used for the
validator-quorum threshold; the participant-aligned floor still uses the
original pre-NegativeUNL view internally. Hooks state their own requirements via the required
`min_tier`/`min_count` arguments to `dice()`/`random()`: a hook that demands
validator-tier entropy fails closed with `TOO_LITTLE_ENTROPY` on fallback
ledgers, while a hook that opts into fallback-grade randomness must do so

View File

@@ -309,6 +309,8 @@ Change::applyConsensusEntropy()
sle->setFieldH256(sfDigest, entropy);
sle->setFieldU16(sfEntropyCount, ctx_.tx.getFieldU16(sfEntropyCount));
sle->setFieldU16(
sfEntropyDenominator, ctx_.tx.getFieldU16(sfEntropyDenominator));
sle->setFieldU8(sfEntropyTier, ctx_.tx.getFieldU8(sfEntropyTier));
sle->setFieldU32(sfLedgerSequence, view().info().seq);
// Note: sfPreviousTxnID and sfPreviousTxnLgrSeq are set automatically