mirror of
https://github.com/Xahau/xahaud.git
synced 2026-07-28 09:30:10 +00:00
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:
@@ -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")
|
||||
|
||||
@@ -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(
|
||||
|
||||
37
include/xrpl/protocol/EntropyTier.h
Normal file
37
include/xrpl/protocol/EntropyTier.h
Normal 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
|
||||
@@ -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>
|
||||
|
||||
@@ -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},
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -622,5 +622,6 @@ TRANSACTION(ttCONSENSUS_ENTROPY, 105, ConsensusEntropy, ({
|
||||
{sfLedgerSequence, soeREQUIRED},
|
||||
{sfDigest, soeREQUIRED},
|
||||
{sfEntropyCount, soeREQUIRED},
|
||||
{sfEntropyTier, soeREQUIRED},
|
||||
{sfBlob, soeOPTIONAL},
|
||||
}))
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user