fix(consensus): sanitize live extension build inputs

This commit is contained in:
Nicholas Dudfield
2026-07-16 17:05:08 +07:00
parent 6706d642f8
commit 2a671fc3a7
6 changed files with 175 additions and 45 deletions

View File

@@ -2849,6 +2849,60 @@ class ConsensusExtensions_test : public beast::unit_test::suite
}
}
void
testLiveBuildSetStripsOnlyExtensionPseudos()
{
testcase("live build set strips only extension pseudo-txs");
using namespace jtx;
Env env{
*this, envconfig(validator, ""), supported_amendments(), nullptr};
auto const seq = env.closed()->seq() + 1;
auto const validatorKey = makeValidatorKeys().front();
auto const fee = std::make_shared<STTx const>(ttFEE, [&](auto& obj) {
obj.setAccountID(sfAccount, AccountID{});
obj.setFieldU32(sfLedgerSequence, seq);
});
auto const amendment =
std::make_shared<STTx const>(ttAMENDMENT, [&](auto& obj) {
obj.setAccountID(sfAccount, AccountID{});
obj.setFieldU32(sfLedgerSequence, seq);
obj.setFieldH256(sfAmendment, makeHash("legacy-amendment"));
});
auto const negativeUNL =
std::make_shared<STTx const>(ttUNL_MODIFY, [&](auto& obj) {
obj.setAccountID(sfAccount, AccountID{});
obj.setFieldU8(sfUNLModifyDisabling, 1);
obj.setFieldU32(sfLedgerSequence, seq);
obj.setFieldVL(sfUNLModifyValidator, validatorKey);
});
auto const suppliedEntropy =
makeConsensusEntropyTx(seq, makeHash("supplied-digest"), 7);
auto const suppliedWitness =
makeExportSignaturesTx(seq, makeHash("supplied-export-origin"));
auto const agreed = makeRCLTxSet(
env.app(),
{fee, amendment, negativeUNL, suppliedEntropy, suppliedWitness});
ConsensusExtensions ce{env.app(), activeNoopJournal()};
auto const build = ce.makeLiveBuildTxSet(agreed);
BEAST_EXPECT(build.suppliedEntropy == 1);
BEAST_EXPECT(build.suppliedExportWitnesses == 1);
BEAST_EXPECT(build.txns.exists(fee->getTransactionID()));
BEAST_EXPECT(build.txns.exists(amendment->getTransactionID()));
BEAST_EXPECT(build.txns.exists(negativeUNL->getTransactionID()));
BEAST_EXPECT(!build.txns.exists(suppliedEntropy->getTransactionID()));
BEAST_EXPECT(!build.txns.exists(suppliedWitness->getTransactionID()));
auto const expected =
makeRCLTxSet(env.app(), {fee, amendment, negativeUNL});
BEAST_EXPECT(build.txns.id() == expected.id());
BEAST_EXPECT(agreed.exists(suppliedEntropy->getTransactionID()));
BEAST_EXPECT(agreed.exists(suppliedWitness->getTransactionID()));
}
void
testDiagnosticsJsonAndPositionLogging()
{
@@ -4346,6 +4400,7 @@ public:
testRngSidecarBuildsLocalSnapshots();
testOnPreBuildInjectsStandaloneEntropy();
testOnPreBuildStripsSuppliedExtensionPseudos();
testLiveBuildSetStripsOnlyExtensionPseudos();
testDiagnosticsJsonAndPositionLogging();
testDecoratePositionSkipsWhenDisabled();
testExportSigGateRequiresQuorumAlignment();

View File

@@ -120,11 +120,13 @@ below-quorum conflict, which lets a minority equivocation recreate a veto.
Under no-UNLReport / lost reveals / failed alignment / timeout / impossible
quorum, the round mints an **explicitly labeled lower tier**, never an unlabeled
or non-deterministic value. The tier-1 fallback is a pure function of
*already-agreed* inputs: `H(entropyFallback, parentLedgerHash, agreedTxSetHash,
*already-agreed* inputs: `H(entropyFallback, parentLedgerHash, buildTxSetHash,
seq)` — and must **never** depend on the post-injection tx set (no circular
dependency on the set that carries the pseudo-tx).
*Enforced:* `selectEntropy` fallback path; `agreedTxSetHash` is the
pre-injection set hash.
dependency on the set that carries the pseudo-tx). `buildTxSetHash` is the raw
agreed set after removing only supplied ConsensusEntropy and Export synthetic
transactions; legacy protocol pseudos remain included.
*Enforced:* `makeLiveBuildTxSet` before ordering and `selectEntropy` fallback;
the original consensus-set hash remains separate bookkeeping.
**INV-6 — Bounded, opt-in entropy quality.**
Hooks state `min_tier` explicitly on every draw (no hidden network default).

View File

@@ -1442,20 +1442,20 @@ ConsensusExtensions::clearAcceptedEntropySet()
ConsensusExtensions::EntropySelection
ConsensusExtensions::selectEntropy(
uint256 const& agreedTxSetHash,
uint256 const& buildTxSetHash,
LedgerIndex seq) const
{
//@@start entropy-selector-fallback
// Tier 1 fallback: consensus-bound deterministic digest over already-agreed
// round inputs. agreedTxSetHash is the pre-injection consensus tx set hash:
// the digest must never depend on a set that could contain the pseudo-tx
// carrying it (circular).
// round inputs. buildTxSetHash is the sanitized pre-injection live-build
// set hash: the digest must never depend on a set that could contain a
// supplied or derived extension pseudo-tx.
auto const fallback = [&]() -> EntropySelection {
return {
sha512Half(
HashPrefix::entropyFallback,
roundPrevLedgerHash_,
agreedTxSetHash,
buildTxSetHash,
seq),
entropyTierConsensusFallback,
0,
@@ -1675,16 +1675,16 @@ ConsensusExtensions::rngEnabled() const
uint256
ConsensusExtensions::txnOrderingSalt(
uint256 const& agreedTxSetHash,
uint256 const& buildTxSetHash,
LedgerIndex seq) const
{
if (!rngEnabled())
return agreedTxSetHash;
return buildTxSetHash;
auto const selection = selectEntropy(agreedTxSetHash, seq);
auto const selection = selectEntropy(buildTxSetHash, seq);
return sha512Half(
HashPrefix::entropyTxnOrder,
agreedTxSetHash,
buildTxSetHash,
selection.digest,
selection.tier,
selection.count,
@@ -2379,11 +2379,46 @@ ConsensusExtensions::observedParticipantsBitmapBin() const
return observedParticipantsBitmapBin_;
}
ConsensusExtensions::LiveBuildTxSet
ConsensusExtensions::makeLiveBuildTxSet(RCLTxSet const& agreedTxs) const
{
RCLTxSet::MutableTxSet mutableSet{agreedTxs};
std::vector<uint256> suppliedEntropy;
std::vector<uint256> suppliedExportWitnesses;
for (auto const& item : *agreedTxs.map_)
{
try
{
STTx const tx{SerialIter{item.slice()}};
if (tx.getTxnType() == ttCONSENSUS_ENTROPY)
suppliedEntropy.push_back(item.key());
else if (tx.getTxnType() == ttEXPORT_SIGNATURES)
suppliedExportWitnesses.push_back(item.key());
}
catch (std::exception const&)
{
// Preserve malformed entries here. The existing canonical-set
// construction path records their parse failure separately.
}
}
for (auto const& id : suppliedEntropy)
mutableSet.erase(id);
for (auto const& id : suppliedExportWitnesses)
mutableSet.erase(id);
return LiveBuildTxSet{
RCLTxSet{mutableSet},
suppliedEntropy.size(),
suppliedExportWitnesses.size()};
}
void
ConsensusExtensions::onPreBuild(
CanonicalTXSet& retriableTxs,
LedgerIndex seq,
uint256 const& txSetHash)
uint256 const& buildTxSetHash)
{
//@@start extension-live-pseudo-authority
// The agreed user transaction set never authorizes synthetic extension
@@ -2435,9 +2470,9 @@ ConsensusExtensions::onPreBuild(
//@@start rng-inject-entropy-selection
// One deterministic selector over the AGREED entropySetMap_ chooses the
// 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);
// same entropy for the same accepted round inputs. buildTxSetHash is
// the sanitized pre-injection live-build set hash.
auto const selection = selectEntropy(buildTxSetHash, seq);
uint256 const finalEntropy = selection.digest;
std::uint8_t const entropyTier = selection.tier;
std::uint16_t const entropyCount = selection.count;

View File

@@ -112,6 +112,13 @@ class ConsensusExtensions
clearDeferredExportShares();
public:
struct LiveBuildTxSet
{
RCLTxSet txns;
std::size_t suppliedEntropy;
std::size_t suppliedExportWitnesses;
};
beast::Journal j_; // public: accessed by extensionsTick template
/** Proof data from a proposal signature, for embedding in SHAMap
@@ -365,15 +372,15 @@ public:
/// quorumThreshold), participant_aligned (>= tier2Threshold) or
/// consensus_fallback. In non-standalone mode, non-fallback labels require
/// an UNLReport-backed active view; the trusted-fallback view is local
/// config and mints Tier 1. agreedTxSetHash is the pre-injection consensus
/// tx set hash used for the fallback digest.
/// config and mints Tier 1. buildTxSetHash is the sanitized pre-injection
/// live-build set hash used for the fallback digest.
EntropySelection
selectEntropy(uint256 const& agreedTxSetHash, LedgerIndex seq) const;
selectEntropy(uint256 const& buildTxSetHash, LedgerIndex seq) const;
/// Extend the legacy closed-ledger transaction-order salt with the same
/// consensus entropy selected for the ledger's entropy pseudo-tx.
uint256
txnOrderingSalt(uint256 const& agreedTxSetHash, LedgerIndex seq) const;
txnOrderingSalt(uint256 const& buildTxSetHash, LedgerIndex seq) const;
bool
rngEnabled() const;
@@ -509,9 +516,15 @@ public:
void
onReplayBuild();
/// txSetHash is the agreed pre-injection consensus tx set hash — an input
/// Return the agreed transaction set with only consensus-extension
/// synthetic transactions removed. Legacy fee, amendment, nUNL, and other
/// protocol pseudos remain ordinary members of the consensus set.
LiveBuildTxSet
makeLiveBuildTxSet(RCLTxSet const& agreedTxs) const;
/// txSetHash is the sanitized pre-injection live-build set hash — an input
/// to the Tier 1 consensus_fallback digest. It must never be the hash of a
/// set that could contain the entropy pseudo-tx itself.
/// set that could contain an extension pseudo-tx itself.
void
onPreBuild(
CanonicalTXSet& retriableTxs,

View File

@@ -361,8 +361,10 @@ non-UNLReport (config-fallback) view, every case below instead mints
- No peer entropy hash is observed in time: fall back to the Tier 1 digest.
The fallback pseudo-transaction is deterministic — every node derives the same
digest from `(HashPrefix::entropyFallback, parentLedgerHash, agreedTxSetHash,
seq)` — and labeled with `EntropyTier = consensus_fallback`,
digest from `(HashPrefix::entropyFallback, parentLedgerHash, buildTxSetHash,
seq)`, where `buildTxSetHash` is the agreed set after removing only supplied
ConsensusEntropy and Export synthetic transactions — and labels it with
`EntropyTier = consensus_fallback`,
`EntropyCount = 0`, `EntropyDenominator = 0`, and an empty
`EntropyContributors` bitmap. Non-fallback entropy records both the contributor
count and the active-validator denominator used for the validator-quorum
@@ -398,14 +400,17 @@ ledger's finalized entropy; final buildLCL execution sees the current ledger's
entropy pseudo-tx after it updates the SLE. Hooks that need final entropy must
treat open-ledger RNG results as previews.
The fallback digest derives from the agreed pre-injection tx set hash to avoid
circularity. The agreed user transaction set is not authority for synthetic
extension state: during a live build, every supplied `ttCONSENSUS_ENTROPY` or
`ttEXPORT_SIGNATURES` transaction is discarded and logged as an invariant
violation, then the canonical synthetic stream is derived from accepted
extension evidence. Historical replay is selected before live materialization
and consumes the persisted transaction order, including its recorded synthetic
transactions, without regeneration.
The fallback digest derives from the sanitized pre-injection live-build set
hash to avoid circularity and synthetic-input authority. Sanitization removes
only supplied `ttCONSENSUS_ENTROPY` and `ttEXPORT_SIGNATURES` transactions;
legacy fee, amendment, NegativeUNL, and other protocol pseudos remain ordinary
members of the agreed set. Supplied extension pseudos are discarded and logged
as an invariant violation before they can influence fallback entropy,
transaction ordering, or ledger state, then the canonical synthetic stream is
derived from accepted extension evidence. The original agreed-set hash remains
the consensus-bookkeeping value carried by validations. Historical replay is
selected before live materialization and consumes the persisted transaction
order, including its recorded synthetic transactions, without regeneration.
## Local Snapshot Alignment Rules

View File

@@ -581,20 +581,45 @@ RCLConsensus::Adaptor::doAccept(
//--------------------------------------------------------------------------
std::set<TxID> failed;
// Select replay before choosing the transaction stream used to derive the
// ledger. Replay consumes the persisted ordered stream. A live build uses
// a sanitized view of the agreed set so supplied extension pseudos cannot
// influence fallback entropy, transaction ordering, or ledger state.
auto replayData = ledgerMaster_.releaseReplay();
auto const consensusTxSetHash = result.txns.id();
auto const liveBuild = replayData
? std::optional<ConsensusExtensions::LiveBuildTxSet>{}
: std::optional<ConsensusExtensions::LiveBuildTxSet>{
ce().makeLiveBuildTxSet(result.txns)};
auto const& buildTxs = liveBuild ? liveBuild->txns : result.txns;
auto const buildTxSetHash = buildTxs.id();
if (liveBuild &&
(liveBuild->suppliedEntropy != 0 ||
liveBuild->suppliedExportWitnesses != 0))
{
JLOG(j_.error())
<< "ConsensusExtensions: excluded supplied synthetic txs from "
"live build"
<< " seq=" << (prevLedger.seq() + 1)
<< " consensusSet=" << consensusTxSetHash
<< " buildSet=" << buildTxSetHash
<< " entropy=" << liveBuild->suppliedEntropy
<< " exportWitnesses=" << liveBuild->suppliedExportWitnesses;
}
// We want to put transactions in an unpredictable but deterministic order.
// ConsensusEntropy extends the agreed tx-set salt with the selected
// ledger entropy; when disabled this remains the legacy tx-set hash salt.
// ConsensusEntropy extends the sanitized live-build salt with the selected
// ledger entropy; when disabled this remains that sanitized set hash.
//
// FIXME: Use a std::vector and a custom sorter instead of CanonicalTXSet?
//@@start txn-ordering-salt-build-inputs
auto const agreedTxSetHash = result.txns.map_->getHash().as_uint256();
auto const buildSeq = prevLedger.seq() + 1;
CanonicalTXSet retriableTxs{
ce().txnOrderingSalt(agreedTxSetHash, buildSeq)};
CanonicalTXSet retriableTxs{ce().txnOrderingSalt(buildTxSetHash, buildSeq)};
JLOG(j_.debug()) << "Building canonical tx set: " << retriableTxs.key();
for (auto const& item : *result.txns.map_)
for (auto const& item : *buildTxs.map_)
{
try
{
@@ -611,11 +636,6 @@ RCLConsensus::Adaptor::doAccept(
}
//@@end txn-ordering-salt-build-inputs
// Select replay before live materialization. Replay consumes the persisted
// ordered transaction stream, including its recorded extension pseudos;
// live builds derive extension pseudos from accepted extension evidence.
auto replayData = ledgerMaster_.releaseReplay();
//@@start auxiliary-pre-build-injection
// Inject extension pseudo-transactions only for a live build. Entropy and
// Export witness injection are independently gated inside onPreBuild;
@@ -627,7 +647,7 @@ RCLConsensus::Adaptor::doAccept(
}
else if (ce().rngEnabled() || ce().exportEnabled())
{
ce().onPreBuild(retriableTxs, buildSeq, agreedTxSetHash);
ce().onPreBuild(retriableTxs, buildSeq, buildTxSetHash);
}
else
{