Files
xahaud/src/test/consensus/ConsensusExtensions_test.cpp
Nicholas Dudfield 27ae39b91a fix(rng): use a genuinely forkable nUNL config in the anchor test
The tier-2 anchor pin (20d52d8b6) used an 8/6 (original/effective) nUNL config whose rationale was wrong: it computed cohort overlap against the original view (2*4-8=0), but aligned cohorts form in the EFFECTIVE view, so two 4-of-6 cohorts overlap by 2*4-6=2 > floor(8/5)=1 -- 8/6 is NOT actually forkable, the original-view anchor is merely conservative there. The test still caught the originalViewSize->size() regression, but the stated reason was misleading (review catch).

Switch to 10 active / 2 disabled (original 10, effective 8), a genuinely forkable case: a 5-of-8 cohort (what the effective-size threshold would admit) overlaps only 2*5-8=2, which does NOT exceed the f=floor(10/5)=2 faulty the original UNL still tolerates -> an equivocator could mint two distinct tier-2 digests. The correct original anchor requires 7 (overlap 6 > 2) and keeps the band closed (tier2==quorum==7); a regression to size() drops the floor to 5 and re-opens the forkable [5,7) band. Assertions and rationale updated to match; suite green (897, 0 failures).
2026-06-16 17:33:57 +07:00

3074 lines
108 KiB
C++

//------------------------------------------------------------------------------
/*
This file is part of rippled: https://github.com/ripple/rippled
Permission to use, copy, modify, and/or distribute this software for any
purpose with or without fee is hereby granted, provided that the above
copyright notice and this permission notice appear in all copies.
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
*/
//==============================================================================
#include <test/jtx.h>
#include <xrpld/app/consensus/ActiveValidatorView.h>
#include <xrpld/app/consensus/ConsensusExtensions.h>
#include <xrpld/app/ledger/InboundTransactions.h>
#include <xrpld/app/ledger/Ledger.h>
#include <xrpld/app/misc/CanonicalTXSet.h>
#include <xrpld/app/misc/RuntimeConfig.h>
#include <xrpld/app/misc/ValidatorKeys.h>
#include <xrpld/consensus/ConsensusExtensionsTick.h>
#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>
#include <xrpl/protocol/STObject.h>
#include <xrpl/protocol/STTx.h>
#include <xrpl/protocol/SidecarType.h>
#include <xrpl/protocol/Sign.h>
#include <xrpl/protocol/TxFlags.h>
#include <xrpl/protocol/TxFormats.h>
#include <xrpl/protocol/digest.h>
#include <cstring>
#include <deque>
namespace ripple {
namespace test {
namespace {
class ActiveNoopSink : public beast::Journal::Sink
{
public:
ActiveNoopSink() : Sink(beast::severities::kTrace, false)
{
}
bool
active(beast::severities::Severity) const override
{
return true;
}
void
write(beast::severities::Severity, std::string const&) override
{
}
void
writeAlways(beast::severities::Severity, std::string const&) override
{
}
};
beast::Journal
activeNoopJournal()
{
static ActiveNoopSink sink;
return beast::Journal{sink};
}
uint256
makeHash(char const* label)
{
return sha512Half(Slice(label, std::strlen(label)));
}
NodeID
makeNode(std::uint8_t id)
{
NodeID node;
node.zero();
node.data()[NodeID::size() - 1] = id;
return node;
}
std::string
makeExportSigBlob(uint256 const& txHash, PublicKey const& publicKey)
{
std::string blob;
blob.append(reinterpret_cast<char const*>(txHash.data()), uint256::size());
blob.append(
reinterpret_cast<char const*>(publicKey.data()), publicKey.size());
blob.push_back('\x30');
return blob;
}
STTx
makeSTTx(STObject const& obj)
{
Serializer s;
obj.add(s);
SerialIter sit{s.slice()};
return STTx{std::ref(sit)};
}
STObject
makeExportedPayment(AccountID const& src, AccountID const& dst)
{
STObject obj(sfExportedTxn);
obj.setFieldU16(sfTransactionType, ttPAYMENT);
obj.setFieldU32(sfFlags, tfFullyCanonicalSig);
obj.setFieldU32(sfSequence, 0);
obj.setFieldU32(sfTicketSequence, 1);
obj.setFieldU32(sfFirstLedgerSequence, 2);
obj.setFieldU32(sfLastLedgerSequence, 6);
obj.setFieldAmount(sfAmount, XRPAmount{1000000});
obj.setFieldAmount(sfFee, XRPAmount{10});
obj.setFieldVL(sfSigningPubKey, Blob{});
obj.setAccountID(sfAccount, src);
obj.setAccountID(sfDestination, dst);
return obj;
}
std::shared_ptr<STTx const>
makeExportTx(STObject const& inner, AccountID const& account)
{
STObject exportObj(sfGeneric);
exportObj.setFieldU16(sfTransactionType, ttEXPORT);
exportObj.setAccountID(sfAccount, account);
exportObj.setFieldU32(sfSequence, 0);
exportObj.setFieldVL(sfSigningPubKey, Blob{});
exportObj.setFieldU32(sfFirstLedgerSequence, 2);
exportObj.setFieldU32(sfLastLedgerSequence, 6);
exportObj.setFieldAmount(sfFee, XRPAmount{0});
exportObj.set(std::make_unique<STObject>(inner));
return std::make_shared<STTx const>(makeSTTx(exportObj));
}
RCLTxSet
makeRCLTxSet(Application& app, std::vector<std::shared_ptr<STTx const>> txns)
{
auto map =
std::make_shared<SHAMap>(SHAMapType::TRANSACTION, app.getNodeFamily());
map->setUnbacked();
for (auto const& tx : txns)
{
Serializer s;
tx->add(s);
map->addItem(
SHAMapNodeType::tnTRANSACTION_NM,
make_shamapitem(tx->getTransactionID(), s.slice()));
}
return RCLTxSet{map->snapShot(false)};
}
AccountID
accountFromNode(NodeID const& nodeId)
{
AccountID acctId;
std::memcpy(acctId.data(), nodeId.data(), acctId.size());
return acctId;
}
std::shared_ptr<SHAMap>
makeSidecarSet(Application& app, std::vector<STObject> const& sidecars)
{
auto map =
std::make_shared<SHAMap>(SHAMapType::SIDECAR, app.getNodeFamily());
map->setUnbacked();
for (auto const& sidecar : sidecars)
{
auto const itemKey = sidecar.getHash(HashPrefix::sidecar);
Serializer s(2048);
sidecar.add(s);
map->addItem(
SHAMapNodeType::tnSIDECAR, make_shamapitem(itemKey, s.slice()));
}
return map->snapShot(false);
}
std::shared_ptr<SHAMap>
makeRawSidecarSet(Application& app, std::string const& raw)
{
auto map =
std::make_shared<SHAMap>(SHAMapType::SIDECAR, app.getNodeFamily());
map->setUnbacked();
map->addItem(
SHAMapNodeType::tnSIDECAR,
make_shamapitem(makeHash("raw-sidecar-entry"), makeSlice(raw)));
return map->snapShot(false);
}
void
publishAndFetchSidecarSet(
Application& app,
ConsensusExtensions& ce,
std::shared_ptr<SHAMap> const& map,
ConsensusExtensions::SidecarKind kind)
{
auto const hash = map->getHash().as_uint256();
app.getInboundTransactions().giveSet(hash, map, false);
ce.fetchRngSetIfNeeded(hash, kind);
}
void
forceNonStandalone(Application& app)
{
const_cast<Config&>(app.config()).setupControl(true, true, false);
}
std::shared_ptr<STTx const>
singleCanonicalTx(CanonicalTXSet const& txs)
{
if (std::distance(txs.begin(), txs.end()) != 1)
return {};
return txs.begin()->second;
}
uint256
expectedEntropy(PublicKey const& key, uint256 const& reveal)
{
Serializer s;
s.addVL(key.slice());
s.addBitString(reveal);
return sha512Half(s.slice());
}
Buffer
signPosition(
PublicKey const& publicKey,
SecretKey const& secretKey,
ExtendedPosition const& position,
std::uint32_t proposeSeq,
NetClock::time_point closeTime,
uint256 const& prevLedger)
{
using Proposal = ConsensusProposal<NodeID, uint256, ExtendedPosition>;
Proposal proposal{
prevLedger,
proposeSeq,
position,
closeTime,
NetClock::time_point{},
calcNodeID(publicKey)};
auto const sig = signDigest(publicKey, secretKey, proposal.signingHash());
return Buffer(sig.data(), sig.size());
}
Blob
makeProofBlob(
PublicKey const& publicKey,
SecretKey const& secretKey,
ExtendedPosition const& position,
std::uint32_t proposeSeq,
NetClock::time_point closeTime,
uint256 const& prevLedger)
{
auto signature = signPosition(
publicKey, secretKey, position, proposeSeq, closeTime, prevLedger);
Serializer positionData;
position.add(positionData);
return ConsensusExtensions::serializeProof(
ConsensusExtensions::ProposalProof{
proposeSeq,
static_cast<std::uint32_t>(closeTime.time_since_epoch().count()),
prevLedger,
std::move(positionData),
std::move(signature)});
}
struct FakeTxSet
{
using ID = uint256;
uint256 hash;
uint256
id() const
{
return hash;
}
};
class FakePeerPosition
{
public:
using Proposal = ConsensusProposal<NodeID, uint256, ExtendedPosition>;
FakePeerPosition(NodeID const& nodeId, ExtendedPosition const& position)
: proposal_(
uint256{},
Proposal::seqJoin,
position,
NetClock::time_point{},
NetClock::time_point{},
nodeId)
{
}
Proposal const&
proposal() const
{
return proposal_;
}
private:
Proposal proposal_;
};
struct FakeExtensions
{
enum class SidecarKind : uint8_t { commit, reveal, exportSig };
beast::Journal j_{activeNoopJournal()};
EstablishState estState_{EstablishState::ConvergingTx};
std::chrono::steady_clock::time_point revealPhaseStart_{};
std::chrono::steady_clock::time_point commitHashConflictStart_{};
bool explicitFinalProposalSent_{false};
bool entropySetPublished_{false};
std::chrono::steady_clock::time_point entropyPublishStart_{};
bool exportSigGateStarted_{false};
std::chrono::steady_clock::time_point exportSigGateStart_{};
bool exportSigConvergenceFailed_{false};
bool rngOn{false};
bool localExportSigs{true};
bool consensusExportTxns{false};
bool exportOn{true};
bool entropyFailed{false};
std::size_t exportQuorum{4};
std::size_t commits{4};
std::size_t reveals{4};
bool commitQuorum{true};
bool minimumReveals{true};
bool anyReveals{true};
bool sendExplicitFinal{false};
uint256 exportHash{makeHash("local-export-sig-set")};
uint256 commitHash{makeHash("local-commit-set")};
uint256 entropyHash{makeHash("local-entropy-set")};
std::deque<uint256> exportHashSequence;
std::deque<uint256> commitHashSequence;
std::deque<uint256> entropyHashSequence;
std::optional<FakeTxSet> explicitFinalTxSet;
std::vector<uint256> fetchedExportSets;
std::vector<uint256> fetchedEntropySets;
std::vector<uint256> fetchedCommitSets;
int commitBuilds = 0;
int exportBuilds = 0;
int entropyBuilds = 0;
int participantDiagnostics = 0;
int selfSeeds = 0;
bool
rngEnabled() const
{
return rngOn;
}
bool
exportEnabled() const
{
return exportOn;
}
std::size_t
quorumThreshold() const
{
return exportQuorum;
}
std::size_t
entropyGateThreshold() const
{
// Stub default mirrors quorumThreshold (tier-2 band collapsed); the
// tier-2 step-down is exercised end-to-end in the CSF sims.
return exportQuorum;
}
std::size_t
exportSigQuorumThreshold() const
{
return exportQuorum;
}
std::size_t
pendingCommitCount() const
{
return rngOn ? commits : 0;
}
std::size_t
pendingRevealCount() const
{
return rngOn ? reveals : 0;
}
std::size_t
expectedProposerCount() const
{
return 0;
}
bool
hasQuorumOfCommits() const
{
return rngOn && commitQuorum;
}
bool
hasMinimumReveals() const
{
return rngOn && minimumReveals;
}
bool
hasAnyReveals() const
{
return rngOn && anyReveals;
}
uint256
buildCommitSet(LedgerIndex)
{
++commitBuilds;
if (!commitHashSequence.empty())
{
auto ret = commitHashSequence.front();
commitHashSequence.pop_front();
return ret;
}
return commitHash;
}
uint256
buildEntropySet(LedgerIndex)
{
++entropyBuilds;
if (!entropyHashSequence.empty())
{
auto ret = entropyHashSequence.front();
entropyHashSequence.pop_front();
return ret;
}
return entropyHash;
}
uint256
getEntropySecret() const
{
return makeHash("entropy-secret");
}
void
selfSeedReveal()
{
++selfSeeds;
}
void
setEntropyFailed()
{
entropyFailed = true;
}
void
fetchRngSetIfNeeded(std::optional<uint256> const& hash, SidecarKind kind)
{
if (kind == SidecarKind::reveal && hash)
fetchedEntropySets.push_back(*hash);
else if (kind == SidecarKind::commit && hash)
fetchedCommitSets.push_back(*hash);
else if (kind == SidecarKind::exportSig && hash)
fetchedExportSets.push_back(*hash);
}
bool
shouldSendExplicitFinalProposal() const
{
return sendExplicitFinal;
}
std::optional<FakeTxSet>
buildExplicitFinalProposalTxSet(FakeTxSet const&, LedgerIndex)
{
return explicitFinalTxSet;
}
bool
hasPendingExportSigs() const
{
return localExportSigs;
}
bool
hasConsensusExportTxns() const
{
return consensusExportTxns;
}
uint256
buildExportSigSet(LedgerIndex)
{
++exportBuilds;
if (!exportHashSequence.empty())
{
auto ret = exportHashSequence.front();
exportHashSequence.pop_front();
return ret;
}
return exportHash;
}
void
setExportSigConvergenceFailed()
{
exportSigConvergenceFailed_ = true;
}
template <class PeerPositions>
void
recordParticipantDiagnostics(ConsensusMode, PeerPositions const&)
{
++participantDiagnostics;
}
std::size_t
observedParticipantCount() const
{
return 1;
}
std::optional<uint256>
observedParticipantsHash() const
{
return makeHash("observed-participants");
}
std::string
observedParticipantsBitmapBin() const
{
return "1";
}
};
struct ExportTickHarness
{
ExtendedPosition position{makeHash("tx-set")};
FakeTxSet txns{position.txSetHash};
hash_map<NodeID, FakePeerPosition> peers;
ConsensusParms parms;
NetClock::time_point netNow{NetClock::duration{123}};
std::chrono::steady_clock::time_point start{};
ConsensusMode mode = ConsensusMode::proposing;
std::size_t prevProposers = 4;
int updates = 0;
int proposes = 0;
int caches = 0;
void
addPeer(
std::uint8_t id,
std::optional<uint256> exportSigSetHash,
uint256 txSetHash = makeHash("tx-set"))
{
ExtendedPosition peerPosition{txSetHash};
peerPosition.exportSigSetHash = exportSigSetHash;
peers.emplace(
makeNode(id), FakePeerPosition{makeNode(id), peerPosition});
}
void
addEntropyPeer(
std::uint8_t id,
std::optional<uint256> entropySetHash,
uint256 txSetHash = makeHash("tx-set"))
{
ExtendedPosition peerPosition{txSetHash};
peerPosition.entropySetHash = entropySetHash;
peers.emplace(
makeNode(id), FakePeerPosition{makeNode(id), peerPosition});
}
void
addCommitPeer(
std::uint8_t id,
std::optional<uint256> commitSetHash,
uint256 txSetHash = makeHash("tx-set"))
{
ExtendedPosition peerPosition{txSetHash};
peerPosition.commitSetHash = commitSetHash;
peers.emplace(
makeNode(id), FakePeerPosition{makeNode(id), peerPosition});
}
ExtensionTickResult
tick(FakeExtensions& ext, std::chrono::milliseconds elapsed = {})
{
ConsensusTick<ExtendedPosition, FakePeerPosition, FakeTxSet> ctx{
.buildSeq = 2,
.now = netNow,
.nowSteady = start + elapsed,
.roundTime = elapsed,
.mode = mode,
.prevProposers = prevProposers,
.peerPositions = peers,
.parms = parms,
.haveCloseTimeConsensus = true,
.convergePercent = 100,
.j = activeNoopJournal(),
.getPosition = [&]() -> ExtendedPosition const& {
return position;
},
.updatePosition =
[&](ExtendedPosition const& newPosition) {
position = newPosition;
++updates;
},
.propose = [&]() { ++proposes; },
.haveConsensus = []() { return true; },
.cacheAndShareTxSet = [&](FakeTxSet const&) { ++caches; },
.getTxns = [&]() -> FakeTxSet const& { return txns; }};
return extensionsTick(ext, ctx);
}
};
// Build an in-memory ledger carrying a UNLReport with the given active
// validator keys (optionally disabling some via NegativeUNL). Drives the RNG
// active-validator view. NOTE: it is not registered with the LedgerMaster, so
// it suits cacheUNLReport()/makeActiveValidatorView() (which read its SLEs
// directly) but NOT reveal verification (which resolves the round's prev ledger
// through the LedgerMaster — anchor harvests to a real closed ledger instead).
std::shared_ptr<Ledger>
makeUNLReportLedger(
jtx::Env& env,
std::vector<PublicKey> const& activeKeys,
std::vector<PublicKey> const& disabledKeys = {})
{
auto const genesis = std::make_shared<Ledger>(
create_genesis,
env.app().config(),
std::vector<uint256>{},
env.app().getNodeFamily());
auto ledger =
std::make_shared<Ledger>(*genesis, env.app().timeKeeper().closeTime());
auto report = std::make_shared<SLE>(keylet::UNLReport());
std::vector<STObject> active;
active.reserve(activeKeys.size());
for (auto const& pk : activeKeys)
{
active.push_back(STObject::makeInnerObject(sfActiveValidator));
active.back().setFieldVL(sfPublicKey, pk);
}
report->setFieldArray(
sfActiveValidators, STArray(active, sfActiveValidators));
OpenView accum(&*ledger);
accum.rawInsert(report);
if (!disabledKeys.empty())
{
auto negUnl = std::make_shared<SLE>(keylet::negativeUNL());
std::vector<STObject> disabled;
disabled.reserve(disabledKeys.size());
for (auto const& pk : disabledKeys)
{
disabled.push_back(STObject::makeInnerObject(sfDisabledValidator));
disabled.back().setFieldVL(sfPublicKey, pk);
disabled.back().setFieldU32(sfFirstLedgerSequence, ledger->seq());
}
negUnl->setFieldArray(
sfDisabledValidators, STArray(disabled, sfDisabledValidators));
accum.rawInsert(negUnl);
}
accum.apply(*ledger);
return ledger;
}
// Harvest a commit (proposeSeq 0) + matching reveal (proposeSeq 1) from one
// validator into `ce`. The commitment binds seq, which reveal verification
// recomputes as prevLedger->seq()+1 (resolved through the LedgerMaster), so
// pass a prevLedger that is actually stored and a matching seq.
void
harvestCommitReveal(
ConsensusExtensions& ce,
NodeID const& nodeId,
PublicKey const& pk,
SecretKey const& sk,
uint256 const& txSetHash,
LedgerIndex seq,
NetClock::time_point closeTime,
uint256 const& prevLedger,
uint256 const& reveal)
{
// nodeId is explicit (not calcNodeID(pk)): a validator's view identity is
// its master-key NodeID, which can differ from its signing pubkey.
auto const commitment = sha512Half(reveal, pk, seq);
ExtendedPosition commitPos{txSetHash};
commitPos.myCommitment = commitment;
auto const commitSig =
signPosition(pk, sk, commitPos, 0, closeTime, prevLedger);
ce.harvestRngData(
nodeId,
pk,
commitPos,
0,
closeTime,
prevLedger,
Slice(commitSig.data(), commitSig.size()));
ExtendedPosition revealPos{txSetHash};
revealPos.myReveal = reveal;
auto const revealSig =
signPosition(pk, sk, revealPos, 1, closeTime, prevLedger);
ce.harvestRngData(
nodeId,
pk,
revealPos,
1,
closeTime,
prevLedger,
Slice(revealSig.data(), revealSig.size()));
}
} // namespace
class ConsensusExtensions_test : public beast::unit_test::suite
{
std::vector<PublicKey>
makeValidatorKeys() const
{
std::vector<std::string> const rawKeys = {
"0388935426E0D08083314842EDFBB2D517BD47699F9A4527318A8E10468C97C05"
"2",
"02691AC5AE1C4C333AE5DF8A93BDC495F0EEBFC6DB0DA7EB6EF808F3AFC006E3F"
"E"};
std::vector<PublicKey> keys;
keys.reserve(rawKeys.size());
for (auto const& rawKey : rawKeys)
{
auto const pkHex = strUnHex(rawKey);
keys.emplace_back(makeSlice(*pkHex));
}
return keys;
}
void
testSidecarPeerAlignmentHelper()
{
testcase("Sidecar peer alignment helper");
ExportTickHarness harness;
auto const localHash = makeHash("sidecar-local");
auto const conflictHash = makeHash("sidecar-conflict");
harness.position.exportSigSetHash = localHash;
harness.addPeer(1, localHash);
harness.addPeer(2, conflictHash);
harness.addPeer(3, std::nullopt);
harness.addPeer(4, localHash, makeHash("other-tx-set"));
std::vector<uint256> fetched;
auto const state = detail::inspectTxConvergedSidecarPeers(
harness.peers,
harness.position,
[](auto const& position) { return position.exportSigSetHash; },
[&](auto const& hash) {
if (hash)
fetched.push_back(*hash);
});
BEAST_EXPECT(state.localPublished);
BEAST_EXPECT(state.conflict);
BEAST_EXPECT(state.aligned == 1);
BEAST_EXPECT(state.alignedParticipants() == 2);
BEAST_EXPECT(state.peersSeen == 2);
BEAST_EXPECT(state.txConverged == 3);
BEAST_EXPECT(state.quorumAligned(2));
BEAST_EXPECT(!state.quorumAligned(3));
BEAST_EXPECT(!state.fullObservation());
BEAST_EXPECT(fetched.size() == 1);
if (!fetched.empty())
BEAST_EXPECT(fetched.front() == conflictHash);
harness.position.exportSigSetHash.reset();
auto const unpublishedState = detail::inspectTxConvergedSidecarPeers(
harness.peers,
harness.position,
[](auto const& position) { return position.exportSigSetHash; },
[](auto const&) {});
BEAST_EXPECT(!unpublishedState.localPublished);
BEAST_EXPECT(unpublishedState.alignedParticipants() == 0);
BEAST_EXPECT(!unpublishedState.quorumAligned(1));
BEAST_EXPECT(unpublishedState.fullObservation());
}
void
testActiveValidatorViewBuilderPrefersUNLReport()
{
testcase("Active validator view builder prefers UNLReport");
auto const keys = makeValidatorKeys();
ActiveValidatorViewSource source;
source.sourceLedgerHash = makeHash("active-validator-view-source");
source.unlReportMasterKeys.emplace();
source.unlReportMasterKeys->insert(keys[0]);
source.negativeUNLEnabled = true;
source.negativeUNL.insert(keys[1]);
ActiveValidatorViewFallback fallback;
fallback.trustedMasterKeys.insert(keys[1]);
fallback.localMasterKey = keys[1];
auto const view = buildActiveValidatorView(source, fallback);
BEAST_EXPECT(view.fromUNLReport);
BEAST_EXPECT(view.sourceLedgerHash == source.sourceLedgerHash);
BEAST_EXPECT(view.size() == 1);
BEAST_EXPECT(view.originalViewSize == 1);
BEAST_EXPECT(view.containsMaster(keys[0]));
BEAST_EXPECT(!view.containsMaster(keys[1]));
BEAST_EXPECT(view.containsNode(calcNodeID(keys[0])));
}
void
testActiveValidatorViewBuilderFallback()
{
testcase("Active validator view builder fallback");
auto const keys = makeValidatorKeys();
ActiveValidatorViewSource source;
source.sourceLedgerHash = makeHash("active-validator-view-fallback");
ActiveValidatorViewFallback fallback;
fallback.trustedMasterKeys.insert(keys[0]);
fallback.trustedMasterKeys.insert(keys[1]);
fallback.localMasterKey = keys[1];
auto const view = buildActiveValidatorView(source, fallback);
BEAST_EXPECT(!view.fromUNLReport);
BEAST_EXPECT(view.sourceLedgerHash == source.sourceLedgerHash);
BEAST_EXPECT(view.size() == 2);
BEAST_EXPECT(view.originalViewSize == 2);
BEAST_EXPECT(view.containsMaster(keys[0]));
BEAST_EXPECT(view.containsMaster(keys[1]));
source.negativeUNLEnabled = true;
source.negativeUNL.insert(keys[0]);
auto const negativeView = buildActiveValidatorView(source, fallback);
BEAST_EXPECT(!negativeView.fromUNLReport);
BEAST_EXPECT(negativeView.size() == 1);
// nUNL shrinks the effective view but NOT the original-UNL denominator.
BEAST_EXPECT(negativeView.originalViewSize == 2);
BEAST_EXPECT(!negativeView.containsMaster(keys[0]));
BEAST_EXPECT(negativeView.containsMaster(keys[1]));
}
void
testActiveValidatorViewAppliesNegativeUNL()
{
testcase("Active validator view applies NegativeUNL");
using namespace jtx;
Env env{
*this,
envconfig(),
supported_amendments() | featureNegativeUNL,
nullptr};
auto const vlKeys = makeValidatorKeys();
auto const l = makeUNLReportLedger(env, vlKeys, {vlKeys[0]});
BEAST_EXPECT(l->rules().enabled(featureNegativeUNL));
ConsensusExtensions ce{env.app(), env.journal};
auto const view = ce.makeActiveValidatorView(l);
BEAST_EXPECT(view->fromUNLReport);
BEAST_EXPECT(view->size() == 1);
// Effective view is 1 (nUNL disabled one), but the original-UNL
// denominator that Tier 2's intersection floor anchors to stays 2.
BEAST_EXPECT(view->originalViewSize == 2);
BEAST_EXPECT(!view->containsMaster(vlKeys[0]));
BEAST_EXPECT(!view->containsNode(calcNodeID(vlKeys[0])));
BEAST_EXPECT(view->containsMaster(vlKeys[1]));
BEAST_EXPECT(view->containsNode(calcNodeID(vlKeys[1])));
}
void
testActiveValidatorViewNullSourceAndExpectedProposers()
{
testcase("Active validator view null source and expected proposers");
using namespace jtx;
Env env{
*this, envconfig(validator, ""), supported_amendments(), nullptr};
auto const& valKeys = env.app().getValidatorKeys();
BEAST_EXPECT(valKeys.keys);
if (!valKeys.keys)
return;
ConsensusExtensions ce{env.app(), activeNoopJournal()};
auto const view = ce.makeActiveValidatorView({});
BEAST_EXPECT(!view->fromUNLReport);
BEAST_EXPECT(view->containsNode(valKeys.nodeID));
ce.cacheUNLReport();
ce.setMode(ConsensusMode::proposing);
ce.setExpectedProposers(hash_set<NodeID>{valKeys.nodeID, makeNode(99)});
BEAST_EXPECT(ce.expectedProposerCount() == 1);
ce.setExpectedProposers({});
BEAST_EXPECT(
ce.expectedProposerCount() == ce.activeValidatorView()->size());
}
void
testParticipantThreshold()
{
testcase("participant-alignment threshold arithmetic");
// Smallest cohort t with 2t - n > floor(n/5) tolerated faults: ~0.6 n,
// but bumped at multiples of 5 where plain ceil(0.6 n) leaves the
// cohort overlap exactly == f (forkable).
BEAST_EXPECT(calculateParticipantThreshold(1) == 1);
BEAST_EXPECT(
calculateParticipantThreshold(5) == 4); // not 3 (n=5 forks)
BEAST_EXPECT(calculateParticipantThreshold(6) == 4); // == ceil(0.6*6)
BEAST_EXPECT(calculateParticipantThreshold(7) == 5);
BEAST_EXPECT(calculateParticipantThreshold(8) == 5);
BEAST_EXPECT(
calculateParticipantThreshold(10) == 7); // not 6 (n=10 forks)
BEAST_EXPECT(calculateParticipantThreshold(15) == 10);
BEAST_EXPECT(calculateParticipantThreshold(20) == 13);
// The defining safety invariant, for EVERY view size: two aligned
// cohorts always share an honest validator, i.e. their overlap (2t - n)
// strictly exceeds the tolerated Byzantine count f = floor(n/5). And
// the Tier 2 bar is never stricter than the Tier 3 validator_quorum
// bar.
for (std::size_t n = 1; n <= 256; ++n)
{
auto const t = calculateParticipantThreshold(n);
BEAST_EXPECT(2 * t > n + n / 5); // overlap 2t-n > floor(n/5)
BEAST_EXPECT(t <= calculateQuorumThreshold(n));
}
}
void
testExplicitFinalProposalTxSetBuildsEntropyTxn()
{
testcase("explicit final proposal tx set builds entropy txn");
auto extractSingleEntropyTx =
[](RCLTxSet const& set) -> std::shared_ptr<STTx const> {
std::vector<std::shared_ptr<STTx const>> txs;
set.map_->visitLeaves(
[&](boost::intrusive_ptr<SHAMapItem const> const& item) {
SerialIter sit(item->slice());
txs.push_back(std::make_shared<STTx const>(sit));
});
if (txs.size() != 1 ||
txs.front()->getTxnType() != ttCONSENSUS_ENTROPY)
return {};
return txs.front();
};
using namespace jtx;
Env env{
*this, envconfig(validator, ""), supported_amendments(), nullptr};
ConsensusExtensions ce{env.app(), activeNoopJournal()};
auto const base = makeRCLTxSet(env.app(), {});
auto const seq = env.closed()->seq() + 1;
auto synthetic = ce.buildExplicitFinalProposalTxSet(base, seq);
BEAST_EXPECT(synthetic);
if (!synthetic)
return;
auto const txPtr = extractSingleEntropyTx(*synthetic);
BEAST_EXPECT(txPtr);
if (!txPtr)
return;
auto const& tx = *txPtr;
BEAST_EXPECT(tx.getFieldU32(sfLedgerSequence) == seq);
BEAST_EXPECT(
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);
if (duplicate)
BEAST_EXPECT(duplicate->id() == synthetic->id());
Env nonStandaloneEnv{
*this,
envconfig(validator, ""),
supported_amendments() | featureConsensusEntropy,
nullptr};
forceNonStandalone(nonStandaloneEnv.app());
auto const ledger =
nonStandaloneEnv.app().getLedgerMaster().getClosedLedger();
auto const nonStandaloneBase = makeRCLTxSet(nonStandaloneEnv.app(), {});
auto const nonStandaloneSeq = ledger->seq() + 1;
ConsensusExtensions zeroCe{nonStandaloneEnv.app(), activeNoopJournal()};
zeroCe.onRoundStart(RCLCxLedger{ledger}, {});
zeroCe.setEntropyFailed();
auto zeroSynthetic = zeroCe.buildExplicitFinalProposalTxSet(
nonStandaloneBase, nonStandaloneSeq);
BEAST_EXPECT(zeroSynthetic);
auto const zeroTx =
zeroSynthetic ? extractSingleEntropyTx(*zeroSynthetic) : nullptr;
BEAST_EXPECT(zeroTx);
if (zeroTx)
{
// Tier 1 consensus_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();
BEAST_EXPECT(valKeys.keys);
if (!valKeys.keys)
return;
auto const& publicKey = valKeys.keys->publicKey;
auto const& secretKey = valKeys.keys->secretKey;
auto const nodeId = valKeys.nodeID;
auto const prevLedger = ledger->info().hash;
auto const closeTime = NetClock::time_point{NetClock::duration{654}};
auto const txSetHash = makeHash("explicit-final-nonzero-txset");
auto const reveal = makeHash("explicit-final-nonzero-reveal");
ConsensusExtensions revealCe{
nonStandaloneEnv.app(), activeNoopJournal()};
revealCe.cacheUNLReport(ledger);
harvestCommitReveal(
revealCe,
nodeId,
publicKey,
secretKey,
txSetHash,
nonStandaloneSeq,
closeTime,
prevLedger,
reveal);
revealCe.buildEntropySet(nonStandaloneSeq);
auto revealSynthetic = revealCe.buildExplicitFinalProposalTxSet(
nonStandaloneBase, nonStandaloneSeq);
BEAST_EXPECT(revealSynthetic);
auto const revealTx = revealSynthetic
? extractSingleEntropyTx(*revealSynthetic)
: nullptr;
BEAST_EXPECT(revealTx);
if (revealTx)
{
BEAST_EXPECT(
revealTx->getFieldH256(sfDigest) ==
expectedEntropy(publicKey, reveal));
BEAST_EXPECT(revealTx->getFieldU16(sfEntropyCount) == 1);
BEAST_EXPECT(
revealTx->getFieldU8(sfEntropyTier) ==
entropyTierValidatorQuorum);
}
}
void
testRuntimeConfigPolicyAccessors()
{
testcase("runtime config policy accessors");
using namespace jtx;
Env env{
*this, envconfig(validator, ""), supported_amendments(), nullptr};
ConsensusExtensions ce{env.app(), activeNoopJournal()};
BEAST_EXPECT(!ce.bootstrapFastStartEnabled());
BEAST_EXPECT(!ce.shouldSendExplicitFinalProposal());
ConfigVals cfg;
cfg.bootstrapFastStart = true;
cfg.explicitFinalProposal = true;
env.app().getRuntimeConfig().setConfig("*", cfg);
BEAST_EXPECT(ce.bootstrapFastStartEnabled());
BEAST_EXPECT(ce.shouldSendExplicitFinalProposal());
cfg.bootstrapFastStart = false;
cfg.explicitFinalProposal = false;
env.app().getRuntimeConfig().setConfig("*", cfg);
BEAST_EXPECT(!ce.bootstrapFastStartEnabled());
BEAST_EXPECT(!ce.shouldSendExplicitFinalProposal());
}
void
testDecoratePositionGeneratesCommitment()
{
testcase("decoratePosition generates commitment");
using namespace jtx;
Env env{
*this,
envconfig(validator, ""),
supported_amendments() | featureConsensusEntropy,
nullptr};
auto const ledger = env.app().getLedgerMaster().getClosedLedger();
ConsensusExtensions ce{env.app(), activeNoopJournal()};
ExtendedPosition pos{makeHash("decorate-position-enabled")};
ce.decoratePosition(pos, ledger, true);
BEAST_EXPECT(pos.myCommitment);
BEAST_EXPECT(ce.pendingCommitCount() == 1);
BEAST_EXPECT(ce.getEntropySecret() != uint256{});
BEAST_EXPECT(ce.isUNLReportMember(env.app().getValidatorKeys().nodeID));
}
void
testOnPreBuildInjectsZeroEntropyFallback()
{
testcase("onPreBuild injects consensus-bound fallback entropy");
using namespace jtx;
Env env{
*this,
envconfig(validator, ""),
supported_amendments() | featureConsensusEntropy,
nullptr};
forceNonStandalone(env.app());
BEAST_EXPECT(!env.app().config().standalone());
ConsensusExtensions ce{env.app(), activeNoopJournal()};
auto const ledger = env.app().getLedgerMaster().getClosedLedger();
ce.onRoundStart(RCLCxLedger{ledger}, {});
ce.setRngEnabledThisRound(true);
BEAST_EXPECT(ce.belowValidatorQuorum());
CanonicalTXSet retriableTxs{makeHash("rng-zero-fallback-salt")};
auto const seq = ledger->seq() + 1;
auto const txSetHash = makeHash("rng-fallback-txset");
ce.onPreBuild(retriableTxs, seq, txSetHash);
auto const tx = singleCanonicalTx(retriableTxs);
BEAST_EXPECT(tx);
if (!tx)
return;
BEAST_EXPECT(tx->getTxnType() == ttCONSENSUS_ENTROPY);
BEAST_EXPECT(tx->getFieldU32(sfLedgerSequence) == seq);
// Tier 1: 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
testOnPreBuildInjectsEntropySetEntropy()
{
testcase("onPreBuild injects entropy-set entropy");
using namespace jtx;
Env env{
*this,
envconfig(validator, ""),
supported_amendments() | featureConsensusEntropy,
nullptr};
forceNonStandalone(env.app());
auto const ledger = env.app().getLedgerMaster().getClosedLedger();
auto const& valKeys = env.app().getValidatorKeys();
BEAST_EXPECT(valKeys.keys);
if (!valKeys.keys)
return;
auto const& publicKey = valKeys.keys->publicKey;
auto const& secretKey = valKeys.keys->secretKey;
auto const nodeId = valKeys.nodeID;
auto const prevLedger = ledger->info().hash;
auto const seq = ledger->seq() + 1;
auto const closeTime = NetClock::time_point{NetClock::duration{321}};
auto const txSetHash = makeHash("prebuild-entropy-txset");
auto const reveal = makeHash("prebuild-entropy-reveal");
ConsensusExtensions ce{env.app(), activeNoopJournal()};
ce.cacheUNLReport(ledger);
harvestCommitReveal(
ce,
nodeId,
publicKey,
secretKey,
txSetHash,
seq,
closeTime,
prevLedger,
reveal);
BEAST_EXPECT(ce.hasQuorumOfCommits());
BEAST_EXPECT(ce.hasMinimumReveals());
auto const entropySetHash = ce.buildEntropySet(seq);
BEAST_EXPECT(ce.isSidecarSet(entropySetHash));
BEAST_EXPECT(!ce.belowValidatorQuorum());
CanonicalTXSet retriableTxs{makeHash("entropy-set-prebuild-salt")};
ce.onPreBuild(retriableTxs, seq, txSetHash);
auto const tx = singleCanonicalTx(retriableTxs);
BEAST_EXPECT(tx);
if (!tx)
return;
BEAST_EXPECT(tx->getTxnType() == ttCONSENSUS_ENTROPY);
BEAST_EXPECT(
tx->getFieldH256(sfDigest) == expectedEntropy(publicKey, reveal));
BEAST_EXPECT(tx->getFieldU16(sfEntropyCount) == 1);
BEAST_EXPECT(
tx->getFieldU8(sfEntropyTier) == entropyTierValidatorQuorum);
}
void
testOnPreBuildTier2ParticipantAligned()
{
testcase(
"onPreBuild labels sub-quorum aligned set participant_aligned");
using namespace jtx;
Env env{
*this,
envconfig(validator, ""),
supported_amendments() | featureConsensusEntropy,
nullptr};
forceNonStandalone(env.app());
// A 6-validator UNLReport opens the tier-2 band:
// tier2Threshold = 4 (smallest cohort whose overlap exceeds f=1)
// quorumThreshold = ceil(0.8 * 6) = 5
// so an agreed aligned count of 4 is participant_aligned, 5+ is
// validator_quorum, and < 4 falls back. (n=5 has no band -- there
// tier2 == quorum -- so 6 is the smallest size that exercises tier 2.)
constexpr std::size_t kValidators = 6;
std::vector<std::pair<PublicKey, SecretKey>> vals;
vals.reserve(kValidators);
std::vector<PublicKey> activeKeys;
activeKeys.reserve(kValidators);
for (std::size_t i = 0; i < kValidators; ++i)
{
vals.push_back(randomKeyPair(KeyType::secp256k1));
activeKeys.push_back(vals.back().first);
}
auto const viewLedger = makeUNLReportLedger(env, activeKeys);
// Thresholds derive from the cached pre-nUNL view (originalViewSize=6).
{
ConsensusExtensions ce{env.app(), activeNoopJournal()};
ce.cacheUNLReport(viewLedger);
BEAST_EXPECT(ce.activeValidatorView()->originalViewSize == 6);
BEAST_EXPECT(ce.quorumThreshold() == 5);
BEAST_EXPECT(ce.tier2Threshold() == 4);
BEAST_EXPECT(ce.entropyGateThreshold() == 4);
}
// Reveal verification resolves the round's prev ledger through the
// LedgerMaster, so anchor commitments to a real closed ledger (the view
// above comes from the in-memory UNLReport ledger).
auto const anchor = env.app().getLedgerMaster().getClosedLedger();
auto const prevLedger = anchor->info().hash;
auto const seq = anchor->info().seq + 1;
auto const closeTime = NetClock::time_point{NetClock::duration{777}};
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).
auto runWith = [&](std::size_t revealers) {
ConsensusExtensions ce{env.app(), activeNoopJournal()};
ce.cacheUNLReport(viewLedger);
for (std::size_t i = 0; i < revealers; ++i)
{
auto const reveal =
sha512Half(vals[i].first, makeHash("tier2-reveal"));
harvestCommitReveal(
ce,
calcNodeID(vals[i].first),
vals[i].first,
vals[i].second,
txSetHash,
seq,
closeTime,
prevLedger,
reveal);
}
ce.buildEntropySet(seq);
CanonicalTXSet txs{makeHash("tier2-salt")};
ce.onPreBuild(txs, seq, txSetHash);
auto const tx = singleCanonicalTx(txs);
std::pair<int, std::uint16_t> out{-1, 0};
if (tx)
out = {
tx->getFieldU8(sfEntropyTier),
tx->getFieldU16(sfEntropyCount)};
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);
// 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);
// 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);
}
void
testTier2ThresholdAnchorsToOriginalView()
{
testcase(
"Tier 2 threshold anchors to pre-nUNL original view under "
"NegativeUNL");
using namespace jtx;
Env env{
*this,
envconfig(validator, ""),
supported_amendments() | featureConsensusEntropy |
featureNegativeUNL,
nullptr};
forceNonStandalone(env.app());
// Ten active validators with two disabled via NegativeUNL: the
// original-UNL denominator is 10 (Byzantine bound f = floor(10/5) = 2)
// while the effective view is 8. Aligned cohorts form among the 8
// effective validators, so two t-cohorts overlap by 2t - 8; safety
// needs that overlap to exceed f so the two cohorts share an honest
// validator. Tier 2 MUST key off the original view:
// tier2Threshold(original 10) == 7 -> overlap 2*7 - 8 = 6 > f: safe
// tier2Threshold(effective 8) == 5 -> overlap 2*5 - 8 = 2, NOT > f
// A regression to size() would admit a 5-of-8 cohort as
// participant_aligned, but its overlap (2) does not exceed the two
// faulty validators the original UNL still tolerates, so an equivocator
// could mint two distinct tier-2 digests: a fork. nUNL drops
// honest-but-down nodes while leaving faulty ones, so the fault bound
// stays anchored to the original UNL, not the shrunk effective view.
// Under the correct anchor the band collapses (tier2 == quorum == 7)
// and no tier-2 mint happens at all; this test pins the anchor so a
// refactor to size() -- which re-opens the forkable [5, 7) band --
// fails loudly.
constexpr std::size_t kActive = 10;
constexpr std::size_t kDisabled = 2;
std::vector<PublicKey> activeKeys;
activeKeys.reserve(kActive);
for (std::size_t i = 0; i < kActive; ++i)
activeKeys.push_back(randomKeyPair(KeyType::secp256k1).first);
std::vector<PublicKey> const disabledKeys(
activeKeys.begin(), activeKeys.begin() + kDisabled);
auto const viewLedger =
makeUNLReportLedger(env, activeKeys, disabledKeys);
BEAST_EXPECT(viewLedger->rules().enabled(featureNegativeUNL));
ConsensusExtensions ce{env.app(), activeNoopJournal()};
ce.cacheUNLReport(viewLedger);
auto const view = ce.activeValidatorView();
BEAST_EXPECT(view->fromUNLReport);
BEAST_EXPECT(view->originalViewSize == kActive); // 10, pre-nUNL
BEAST_EXPECT(view->size() == kActive - kDisabled); // 8, effective
// The anchor. Tier 2 derives from originalViewSize (10 -> 7), NOT the
// effective size (8 -> 5). The gate is min(quorum, tier2); quorum
// tracks the effective view (8 -> 7), so under the correct anchor gate
// == 7 and the band is closed, while a regression to size() would drop
// the floor to 5 and the gate to min(7, 5) == 5.
BEAST_EXPECT(calculateParticipantThreshold(kActive) == 7);
BEAST_EXPECT(calculateParticipantThreshold(kActive - kDisabled) == 5);
BEAST_EXPECT(ce.tier2Threshold() == 7);
BEAST_EXPECT(ce.quorumThreshold() == 7);
BEAST_EXPECT(ce.entropyGateThreshold() == 7);
}
void
testProposalProofRoundTrip()
{
testcase("proposal proof round-trip");
auto const [publicKey, secretKey] = randomKeyPair(KeyType::secp256k1);
auto const prevLedger = makeHash("proof-prev-ledger");
auto const closeTime = NetClock::time_point{NetClock::duration{99}};
ExtendedPosition position{makeHash("proof-tx-set")};
position.myCommitment = makeHash("proof-commitment");
auto const signature = signPosition(
publicKey, secretKey, position, 0, closeTime, prevLedger);
Serializer positionData;
position.add(positionData);
ConsensusExtensions::ProposalProof proof{
0,
static_cast<std::uint32_t>(closeTime.time_since_epoch().count()),
prevLedger,
std::move(positionData),
signature};
auto const blob = ConsensusExtensions::serializeProof(proof);
auto parsed = ConsensusExtensions::deserializeProof(blob);
BEAST_EXPECT(parsed);
if (!parsed)
return;
BEAST_EXPECT(parsed->proposeSeq == proof.proposeSeq);
BEAST_EXPECT(parsed->closeTime == proof.closeTime);
BEAST_EXPECT(parsed->prevLedger == proof.prevLedger);
BEAST_EXPECT(parsed->signature == proof.signature);
BEAST_EXPECT(ConsensusExtensions::verifyProof(
blob, publicKey, *position.myCommitment, true));
BEAST_EXPECT(!ConsensusExtensions::verifyProof(
blob, publicKey, makeHash("wrong-commitment"), true));
Blob malformed{1, 2, 3};
BEAST_EXPECT(!ConsensusExtensions::deserializeProof(malformed));
BEAST_EXPECT(!ConsensusExtensions::verifyProof(
malformed, publicKey, *position.myCommitment, true));
}
void
testHarvestRngDataReplacementAndRejection()
{
testcase("harvestRngData replacement and rejection");
using namespace jtx;
Env env{
*this, envconfig(validator, ""), supported_amendments(), nullptr};
auto const ledger = env.app().getLedgerMaster().getClosedLedger();
auto const& valKeys = env.app().getValidatorKeys();
BEAST_EXPECT(valKeys.keys);
if (!valKeys.keys)
return;
auto const& publicKey = valKeys.keys->publicKey;
auto const& secretKey = valKeys.keys->secretKey;
auto const nodeId = valKeys.nodeID;
auto const prevLedger = ledger->info().hash;
auto const seq = ledger->seq() + 1;
auto const closeTime = NetClock::time_point{NetClock::duration{654}};
auto const txSetHash = makeHash("harvest-replace-txset");
auto const reveal1 = makeHash("harvest-reveal-1");
auto const reveal2 = makeHash("harvest-reveal-2");
auto const commitment1 = sha512Half(reveal1, publicKey, seq);
auto const commitment2 = sha512Half(reveal2, publicKey, seq);
ConsensusExtensions ce{env.app(), activeNoopJournal()};
ce.cacheUNLReport(ledger);
ExtendedPosition inactiveCommit{txSetHash};
inactiveCommit.myCommitment = commitment1;
ce.harvestRngData(
makeNode(99),
publicKey,
inactiveCommit,
0,
closeTime,
prevLedger,
Slice{});
BEAST_EXPECT(ce.pendingCommitCount() == 0);
ExtendedPosition earlyReveal{txSetHash};
earlyReveal.myReveal = reveal1;
ce.harvestRngData(
nodeId, publicKey, earlyReveal, 1, closeTime, prevLedger, Slice{});
BEAST_EXPECT(ce.pendingRevealCount() == 0);
ExtendedPosition commitPos{txSetHash};
commitPos.myCommitment = commitment1;
auto commitSig = signPosition(
publicKey, secretKey, commitPos, 0, closeTime, prevLedger);
ce.harvestRngData(
nodeId,
publicKey,
commitPos,
0,
closeTime,
prevLedger,
Slice(commitSig.data(), commitSig.size()));
BEAST_EXPECT(ce.pendingCommitCount() == 1);
BEAST_EXPECT(ce.hasQuorumOfCommits());
auto revealSig = signPosition(
publicKey, secretKey, earlyReveal, 1, closeTime, prevLedger);
ce.harvestRngData(
nodeId,
publicKey,
earlyReveal,
1,
closeTime,
prevLedger,
Slice(revealSig.data(), revealSig.size()));
BEAST_EXPECT(ce.pendingRevealCount() == 1);
BEAST_EXPECT(ce.hasMinimumReveals());
commitPos.myCommitment = commitment2;
commitSig = signPosition(
publicKey, secretKey, commitPos, 2, closeTime, prevLedger);
ce.harvestRngData(
nodeId,
publicKey,
commitPos,
2,
closeTime,
prevLedger,
Slice(commitSig.data(), commitSig.size()));
BEAST_EXPECT(ce.pendingCommitCount() == 1);
BEAST_EXPECT(ce.pendingRevealCount() == 0);
BEAST_EXPECT(!ce.hasQuorumOfCommits());
ce.harvestRngData(
nodeId, publicKey, earlyReveal, 3, closeTime, prevLedger, Slice{});
BEAST_EXPECT(ce.pendingRevealCount() == 0);
ExtendedPosition reveal2Pos{txSetHash};
reveal2Pos.myReveal = reveal2;
revealSig = signPosition(
publicKey, secretKey, reveal2Pos, 4, closeTime, prevLedger);
ce.harvestRngData(
nodeId,
publicKey,
reveal2Pos,
4,
closeTime,
prevLedger,
Slice(revealSig.data(), revealSig.size()));
BEAST_EXPECT(ce.pendingRevealCount() == 1);
}
void
testExportSidecarBuildFetchAndMerge()
{
testcase("Export sidecar build, fetch, and merge");
using namespace jtx;
Env env{
*this, envconfig(validator, ""), supported_amendments(), nullptr};
auto const ledger = env.app().getLedgerMaster().getClosedLedger();
auto const& valKeys = env.app().getValidatorKeys();
BEAST_EXPECT(valKeys.keys);
if (!valKeys.keys)
return;
auto const& valPK = valKeys.keys->publicKey;
auto const& valSK = valKeys.keys->secretKey;
auto const signerAccount = calcAccountID(valPK);
auto const dst = calcAccountID(randomKeyPair(KeyType::secp256k1).first);
auto const innerObj = makeExportedPayment(signerAccount, dst);
auto const innerTx = makeSTTx(innerObj);
auto const exportTx = makeExportTx(innerObj, signerAccount);
auto const txHash = exportTx->getTransactionID();
auto const txSet = makeRCLTxSet(env.app(), {exportTx});
auto const seq = ledger->seq() + 1;
ConsensusExtensions source{env.app(), activeNoopJournal()};
source.setExportEnabledThisRound(true);
source.cacheUNLReport(ledger);
source.cacheConsensusTxSet(txSet);
source.cacheConsensusTxSet(txSet);
BEAST_EXPECT(source.hasConsensusExportTxns());
BEAST_EXPECT(!source.hasPendingExportSigs());
auto const sigData = buildMultiSigningData(innerTx, signerAccount);
auto const sig = sign(valPK, valSK, sigData.slice());
Buffer sigBuf(sig.data(), sig.size());
source.exportSigCollector().addUnverifiedSignature(
txHash, valPK, sigBuf, seq);
BEAST_EXPECT(source.verifyPendingExportSigs(txSet, seq) == 1);
BEAST_EXPECT(
source.exportSigCollector().hasVerifiedSignature(txHash, valPK));
BEAST_EXPECT(source.hasPendingExportSigs());
auto const exportSigSetHash = source.buildExportSigSet(seq);
BEAST_EXPECT(source.isSidecarSet(exportSigSetHash));
ConsensusExtensions fetched{env.app(), activeNoopJournal()};
fetched.setExportEnabledThisRound(true);
fetched.cacheUNLReport(ledger);
fetched.cacheConsensusTxSet(txSet);
fetched.fetchRngSetIfNeeded(
exportSigSetHash, ConsensusExtensions::SidecarKind::exportSig);
BEAST_EXPECT(
fetched.exportSigCollector().hasVerifiedSignature(txHash, valPK));
BEAST_EXPECT(fetched.buildExportSigSet(seq) == exportSigSetHash);
}
void
testRngSidecarBuildFetchAndMerge()
{
testcase("RNG sidecar build, fetch, and merge");
using namespace jtx;
Env env{
*this, envconfig(validator, ""), supported_amendments(), nullptr};
auto const& valKeys = env.app().getValidatorKeys();
BEAST_EXPECT(valKeys.keys);
if (!valKeys.keys)
return;
auto const ledger = env.app().getLedgerMaster().getClosedLedger();
auto const& publicKey = valKeys.keys->publicKey;
auto const& secretKey = valKeys.keys->secretKey;
auto const nodeId = valKeys.nodeID;
auto const prevLedger = ledger->info().hash;
auto const seq = ledger->seq() + 1;
auto const closeTime = NetClock::time_point{NetClock::duration{777}};
auto const txSetHash = makeHash("rng-sidecar-txset");
auto const reveal = makeHash("rng-sidecar-reveal");
auto const commitment = sha512Half(reveal, publicKey, seq);
ConsensusExtensions source{env.app(), env.journal};
source.cacheUNLReport(ledger);
ExtendedPosition commitPos{txSetHash};
commitPos.myCommitment = commitment;
auto const commitSig = signPosition(
publicKey, secretKey, commitPos, 0, closeTime, prevLedger);
source.harvestRngData(
nodeId,
publicKey,
commitPos,
0,
closeTime,
prevLedger,
Slice(commitSig.data(), commitSig.size()));
BEAST_EXPECT(source.pendingCommitCount() == 1);
ExtendedPosition revealPos{txSetHash};
revealPos.myReveal = reveal;
auto const revealSig = signPosition(
publicKey, secretKey, revealPos, 1, closeTime, prevLedger);
source.harvestRngData(
nodeId,
publicKey,
revealPos,
1,
closeTime,
prevLedger,
Slice(revealSig.data(), revealSig.size()));
BEAST_EXPECT(source.pendingRevealCount() == 1);
auto const commitSetHash = source.buildCommitSet(seq);
auto const revealSetHash = source.buildEntropySet(seq);
BEAST_EXPECT(source.isSidecarSet(commitSetHash));
BEAST_EXPECT(source.isSidecarSet(revealSetHash));
ConsensusExtensions fetched{env.app(), env.journal};
fetched.cacheUNLReport(ledger);
fetched.fetchRngSetIfNeeded(
commitSetHash, ConsensusExtensions::SidecarKind::commit);
BEAST_EXPECT(fetched.pendingCommitCount() == 1);
BEAST_EXPECT(fetched.buildCommitSet(seq) == commitSetHash);
fetched.fetchRngSetIfNeeded(
revealSetHash, ConsensusExtensions::SidecarKind::reveal);
BEAST_EXPECT(fetched.pendingRevealCount() == 1);
BEAST_EXPECT(fetched.buildEntropySet(seq) == revealSetHash);
fetched.fetchRngSetIfNeeded(
std::nullopt, ConsensusExtensions::SidecarKind::commit);
fetched.fetchRngSetIfNeeded(
uint256{}, ConsensusExtensions::SidecarKind::reveal);
fetched.fetchRngSetIfNeeded(
commitSetHash, ConsensusExtensions::SidecarKind::commit);
fetched.fetchRngSetIfNeeded(
revealSetHash, ConsensusExtensions::SidecarKind::reveal);
auto const rawMap =
makeRawSidecarSet(env.app(), std::string{"cached-sidecar"});
auto const rawHash = rawMap->getHash().as_uint256();
env.app().getInboundTransactions().giveSet(rawHash, rawMap, false);
fetched.fetchRngSetIfNeeded(
rawHash, ConsensusExtensions::SidecarKind::commit);
BEAST_EXPECT(fetched.pendingCommitCount() == 1);
fetched.setRngEnabledThisRound(true);
fetched.recordParticipantDiagnostics(
ConsensusMode::proposing, std::vector<NodeID>{nodeId});
BEAST_EXPECT(fetched.observedParticipantCount() == 1);
BEAST_EXPECT(fetched.observedParticipantsHash());
BEAST_EXPECT(!fetched.observedParticipantsBitmapBin().empty());
ExtendedPosition diagnosticPos{txSetHash};
fetched.attachParticipantDiagnostics(diagnosticPos);
BEAST_EXPECT(diagnosticPos.observedParticipantsHash);
}
void
testRngSidecarRejectsInvalidFetchedEntries()
{
testcase("RNG sidecar rejects invalid fetched entries");
using namespace jtx;
Env env{
*this, envconfig(validator, ""), supported_amendments(), nullptr};
auto const ledger = env.app().getLedgerMaster().getClosedLedger();
auto const& valKeys = env.app().getValidatorKeys();
BEAST_EXPECT(valKeys.keys);
if (!valKeys.keys)
return;
auto const& publicKey = valKeys.keys->publicKey;
auto const& secretKey = valKeys.keys->secretKey;
auto const nodeId = valKeys.nodeID;
auto const prevLedger = ledger->info().hash;
auto const seq = ledger->seq() + 1;
auto const closeTime = NetClock::time_point{NetClock::duration{777}};
auto const txSetHash = makeHash("invalid-fetched-txset");
auto const digest = makeHash("invalid-fetched-digest");
auto makeRngSidecar = [&](std::uint8_t type,
NodeID const& owner,
PublicKey const& pk,
uint256 const& value,
LedgerIndex ledgerSeq) {
STObject sidecar(sfGeneric);
sidecar.setFieldU8(sfSidecarType, type);
sidecar.setFieldU32(sfLedgerSequence, ledgerSeq);
sidecar.setAccountID(sfAccount, accountFromNode(owner));
sidecar.setFieldH256(sfDigest, value);
sidecar.setFieldVL(sfSigningPubKey, pk.slice());
return sidecar;
};
auto makeCommitProof = [&](uint256 const& value, std::uint32_t n = 0) {
ExtendedPosition position{txSetHash};
position.myCommitment = value;
return makeProofBlob(
publicKey, secretKey, position, n, closeTime, prevLedger);
};
auto makeRevealProof = [&](uint256 const& value, std::uint32_t n = 1) {
ExtendedPosition position{txSetHash};
position.myReveal = value;
return makeProofBlob(
publicKey, secretKey, position, n, closeTime, prevLedger);
};
ConsensusExtensions ce{env.app(), activeNoopJournal()};
ce.cacheUNLReport(ledger);
// Commit sidecars need a verifiable proposal proof.
publishAndFetchSidecarSet(
env.app(),
ce,
makeSidecarSet(
env.app(),
{makeRngSidecar(
sidecarRngCommit, nodeId, publicKey, digest, seq)}),
ConsensusExtensions::SidecarKind::commit);
BEAST_EXPECT(ce.pendingCommitCount() == 0);
// Entries from outside the active validator view are ignored.
publishAndFetchSidecarSet(
env.app(),
ce,
makeSidecarSet(
env.app(),
{makeRngSidecar(
sidecarRngReveal, makeNode(99), publicKey, digest, seq)}),
ConsensusExtensions::SidecarKind::reveal);
BEAST_EXPECT(ce.pendingRevealCount() == 0);
// A valid active NodeID cannot be paired with an untrusted signing key.
auto const [untrustedPk, _] = randomKeyPair(KeyType::secp256k1);
publishAndFetchSidecarSet(
env.app(),
ce,
makeSidecarSet(
env.app(),
{makeRngSidecar(
sidecarRngReveal, nodeId, untrustedPk, digest, seq)}),
ConsensusExtensions::SidecarKind::reveal);
BEAST_EXPECT(ce.pendingRevealCount() == 0);
// Reveal sidecars are only valid after their matching commitment.
publishAndFetchSidecarSet(
env.app(),
ce,
makeSidecarSet(
env.app(),
{makeRngSidecar(
sidecarRngReveal, nodeId, publicKey, digest, seq)}),
ConsensusExtensions::SidecarKind::reveal);
BEAST_EXPECT(ce.pendingRevealCount() == 0);
// Corrupt leaf bytes should not make the merge path throw outward.
publishAndFetchSidecarSet(
env.app(),
ce,
makeRawSidecarSet(env.app(), std::string{"not-an-stobject"}),
ConsensusExtensions::SidecarKind::commit);
BEAST_EXPECT(ce.pendingCommitCount() == 0);
// A proof must verify the digest carried by the sidecar leaf.
auto invalidProofSidecar =
makeRngSidecar(sidecarRngCommit, nodeId, publicKey, digest, seq);
invalidProofSidecar.setFieldVL(
sfBlob, makeCommitProof(makeHash("other-commitment")));
publishAndFetchSidecarSet(
env.app(),
ce,
makeSidecarSet(env.app(), {invalidProofSidecar}),
ConsensusExtensions::SidecarKind::commit);
BEAST_EXPECT(ce.pendingCommitCount() == 0);
// verifyProof ignores trailing bytes, but deserializeProof rejects them
// before caching the proof for deterministic sidecar rebuilds.
auto malformedProof = makeCommitProof(digest);
malformedProof.push_back(0);
auto malformedProofSidecar =
makeRngSidecar(sidecarRngCommit, nodeId, publicKey, digest, seq);
malformedProofSidecar.setFieldVL(sfBlob, malformedProof);
publishAndFetchSidecarSet(
env.app(),
ce,
makeSidecarSet(env.app(), {malformedProofSidecar}),
ConsensusExtensions::SidecarKind::commit);
BEAST_EXPECT(ce.pendingCommitCount() == 0);
auto outOfRoundSidecar = makeRngSidecar(
sidecarRngCommit, nodeId, publicKey, digest, seq + 1);
outOfRoundSidecar.setFieldVL(sfBlob, makeCommitProof(digest));
publishAndFetchSidecarSet(
env.app(),
ce,
makeSidecarSet(env.app(), {outOfRoundSidecar}),
ConsensusExtensions::SidecarKind::commit);
BEAST_EXPECT(ce.pendingCommitCount() == 0);
auto nonZeroProofSidecar =
makeRngSidecar(sidecarRngCommit, nodeId, publicKey, digest, seq);
nonZeroProofSidecar.setFieldVL(sfBlob, makeCommitProof(digest, 2));
publishAndFetchSidecarSet(
env.app(),
ce,
makeSidecarSet(env.app(), {nonZeroProofSidecar}),
ConsensusExtensions::SidecarKind::commit);
BEAST_EXPECT(ce.pendingCommitCount() == 1);
BEAST_EXPECT(
ce.buildCommitSet(seq) !=
makeSidecarSet(env.app(), {nonZeroProofSidecar})
->getHash()
.as_uint256());
ConsensusExtensions replacement{env.app(), activeNoopJournal()};
replacement.cacheUNLReport(ledger);
auto const reveal1 = makeHash("fetched-reveal-1");
auto const reveal2 = makeHash("fetched-reveal-2");
auto const commit1 = sha512Half(reveal1, publicKey, seq);
auto const commit2 = sha512Half(reveal2, publicKey, seq);
auto commit1Sidecar =
makeRngSidecar(sidecarRngCommit, nodeId, publicKey, commit1, seq);
commit1Sidecar.setFieldVL(sfBlob, makeCommitProof(commit1));
publishAndFetchSidecarSet(
env.app(),
replacement,
makeSidecarSet(env.app(), {commit1Sidecar}),
ConsensusExtensions::SidecarKind::commit);
BEAST_EXPECT(replacement.pendingCommitCount() == 1);
auto reveal1Sidecar =
makeRngSidecar(sidecarRngReveal, nodeId, publicKey, reveal1, seq);
reveal1Sidecar.setFieldVL(sfBlob, makeRevealProof(reveal1));
publishAndFetchSidecarSet(
env.app(),
replacement,
makeSidecarSet(env.app(), {reveal1Sidecar}),
ConsensusExtensions::SidecarKind::reveal);
BEAST_EXPECT(replacement.pendingRevealCount() == 1);
auto commit2Sidecar =
makeRngSidecar(sidecarRngCommit, nodeId, publicKey, commit2, seq);
commit2Sidecar.setFieldVL(sfBlob, makeCommitProof(commit2));
publishAndFetchSidecarSet(
env.app(),
replacement,
makeSidecarSet(env.app(), {commit2Sidecar}),
ConsensusExtensions::SidecarKind::commit);
BEAST_EXPECT(replacement.pendingCommitCount() == 1);
BEAST_EXPECT(replacement.pendingRevealCount() == 0);
// The old reveal is no longer valid after the commitment changes.
publishAndFetchSidecarSet(
env.app(),
replacement,
makeSidecarSet(env.app(), {reveal1Sidecar}),
ConsensusExtensions::SidecarKind::reveal);
BEAST_EXPECT(replacement.pendingRevealCount() == 0);
// With a local map already built, fetched deltas merge only missing
// leaves; corrupt remote additions are ignored without disturbing local
// state.
replacement.buildCommitSet(seq);
publishAndFetchSidecarSet(
env.app(),
replacement,
makeRawSidecarSet(env.app(), std::string{"bad-diff-entry"}),
ConsensusExtensions::SidecarKind::commit);
BEAST_EXPECT(replacement.pendingCommitCount() == 1);
}
void
testOnPreBuildInjectsStandaloneEntropy()
{
testcase("onPreBuild injects standalone entropy pseudo-tx");
using namespace jtx;
Env env{
*this, envconfig(validator, ""), supported_amendments(), nullptr};
ConsensusExtensions ce{env.app(), activeNoopJournal()};
CanonicalTXSet retriableTxs{makeHash("rng-on-pre-build-salt")};
auto const seq = env.closed()->seq() + 1;
ce.onPreBuild(retriableTxs, seq, makeHash("standalone-txset"));
BEAST_EXPECT(
std::distance(retriableTxs.begin(), retriableTxs.end()) == 1);
auto const tx = retriableTxs.begin()->second;
BEAST_EXPECT(tx);
BEAST_EXPECT(tx->getTxnType() == ttCONSENSUS_ENTROPY);
BEAST_EXPECT(tx->getFieldU32(sfLedgerSequence) == seq);
BEAST_EXPECT(tx->getAccountID(sfAccount) == AccountID{});
BEAST_EXPECT(
tx->getFieldH256(sfDigest) ==
sha512Half(std::string("standalone-entropy"), seq));
BEAST_EXPECT(tx->getFieldU16(sfEntropyCount) == 20);
BEAST_EXPECT(
tx->getFieldU8(sfEntropyTier) == entropyTierValidatorQuorum);
// Value-based dedup: re-injecting with identical inputs yields the
// identical pseudo-tx (same txID) -> verified skip, still one entry.
ce.onPreBuild(retriableTxs, seq, makeHash("standalone-txset"));
BEAST_EXPECT(
std::distance(retriableTxs.begin(), retriableTxs.end()) == 1);
}
void
testOnPreBuildEntropyMismatchKeepsAgreed()
{
testcase("onPreBuild keeps a present-but-different entropy pseudo-tx");
using namespace jtx;
Env env{
*this, envconfig(validator, ""), supported_amendments(), nullptr};
auto const seq = env.closed()->seq() + 1;
// Build a ttCONSENSUS_ENTROPY, optionally without sfEntropyTier
// (mimics a pre-tier-3 / malformed entry).
auto makeEntropyTx = [&](uint256 const& digest, std::uint16_t count) {
STObject obj(sfGeneric);
obj.setFieldU16(sfTransactionType, ttCONSENSUS_ENTROPY);
obj.setFieldU32(sfLedgerSequence, seq);
obj.setAccountID(sfAccount, AccountID{});
obj.setFieldU32(sfSequence, 0);
obj.setFieldAmount(sfFee, STAmount{});
obj.setFieldVL(sfSigningPubKey, Slice{}); // pseudo-tx convention
obj.setFieldH256(sfDigest, digest);
obj.setFieldU16(sfEntropyCount, count);
obj.setFieldU8(sfEntropyTier, entropyTierConsensusFallback);
return std::make_shared<STTx const>(makeSTTx(obj));
};
// (1) Well-formed but DIFFERENT digest already present: standalone
// injection would produce its own digest, so txIDs differ -> mismatch
// branch keeps the agreed one, does not insert ours, stays at one.
{
ConsensusExtensions ce{env.app(), activeNoopJournal()};
CanonicalTXSet txs{makeHash("mismatch-wellformed-salt")};
auto const present =
makeEntropyTx(makeHash("a-different-digest"), 7);
auto const presentID = present->getTransactionID();
txs.insert(present);
ce.onPreBuild(txs, seq, makeHash("mismatch-txset"));
BEAST_EXPECT(std::distance(txs.begin(), txs.end()) == 1);
auto const kept = singleCanonicalTx(txs);
BEAST_EXPECT(kept);
if (kept)
{
BEAST_EXPECT(kept->getTransactionID() == presentID);
BEAST_EXPECT(
kept->getFieldH256(sfDigest) ==
makeHash("a-different-digest"));
}
}
// Note: a ttCONSENSUS_ENTROPY missing sfEntropyTier cannot be
// constructed (STTx deserialization enforces all soeREQUIRED fields),
// so the mismatch-branch's defensive isFieldPresent reads guard an
// unreachable-via-tx-path case — belt-and-suspenders, kept cheap.
}
void
testDiagnosticsJsonAndPositionLogging()
{
testcase("diagnostics JSON and position logging");
using namespace jtx;
Env env{
*this, envconfig(validator, ""), supported_amendments(), nullptr};
ConsensusExtensions ce{env.app(), activeNoopJournal()};
auto const& valKeys = env.app().getValidatorKeys();
BEAST_EXPECT(valKeys.keys);
if (!valKeys.keys)
return;
ce.setRngEnabledThisRound(true);
ce.recordParticipantDiagnostics(
ConsensusMode::proposing, std::vector<NodeID>{valKeys.nodeID});
Json::Value json;
ce.appendJson(json);
BEAST_EXPECT(json.isMember("rng"));
BEAST_EXPECT(json["rng"]["enabled"].asBool());
BEAST_EXPECT(json["rng"]["est_state"].asString() == "ConvergingTx");
BEAST_EXPECT(
json["rng"]["observed_active_participants"].asInt() ==
static_cast<int>(ce.observedParticipantCount()));
BEAST_EXPECT(json["rng"].isMember("observed_participants"));
BEAST_EXPECT(json["rng"].isMember("observed_participants_bitmap"));
ExtendedPosition pos{makeHash("diagnostic-tx-set")};
pos.commitSetHash = makeHash("diagnostic-commit-set");
pos.entropySetHash = makeHash("diagnostic-entropy-set");
pos.exportSigSetHash = makeHash("diagnostic-export-set");
pos.exportSignaturesHash = makeHash("diagnostic-export-signatures");
pos.observedParticipantsHash = ce.observedParticipantsHash();
pos.myCommitment = makeHash("diagnostic-commitment");
pos.myReveal = makeHash("diagnostic-reveal");
ce.logPosition(pos, activeNoopJournal());
}
void
testDecoratePositionSkipsWhenDisabled()
{
testcase("decoratePosition skips without amendment");
using namespace jtx;
Env env{
*this, envconfig(validator, ""), supported_amendments(), nullptr};
auto const ledger = env.app().getLedgerMaster().getClosedLedger();
ConsensusExtensions ce{env.app(), activeNoopJournal()};
ExtendedPosition pos{makeHash("decorate-position-tx-set")};
ce.decoratePosition(pos, ledger, true);
BEAST_EXPECT(!pos.myCommitment);
BEAST_EXPECT(ce.pendingCommitCount() == 0);
ExtendedPosition skipped{makeHash("decorate-position-skipped")};
ce.decoratePosition(skipped, ledger, false);
BEAST_EXPECT(!skipped.myCommitment);
}
void
testExportSigGateRequiresQuorumAlignment()
{
testcase("Export sig gate requires quorum alignment");
FakeExtensions ext;
ExportTickHarness harness;
auto const localHash = ext.exportHash;
harness.addPeer(1, localHash);
harness.addPeer(2, localHash);
auto result = harness.tick(ext);
BEAST_EXPECT(!result.readyForAccept);
BEAST_EXPECT(harness.position.exportSigSetHash == localHash);
BEAST_EXPECT(ext.exportSigGateStarted_);
result = harness.tick(ext, std::chrono::milliseconds{100});
BEAST_EXPECT(!result.readyForAccept);
BEAST_EXPECT(!ext.exportSigConvergenceFailed_);
result = harness.tick(
ext,
harness.parms.rngREVEAL_TIMEOUT * 2 + std::chrono::milliseconds{1});
BEAST_EXPECT(result.readyForAccept);
BEAST_EXPECT(ext.exportSigConvergenceFailed_);
}
void
testRngEntropyGateRequiresFullObservation()
{
testcase("RNG entropy gate requires full sidecar observation");
FakeExtensions ext;
ext.rngOn = true;
ext.exportOn = false;
ext.estState_ = EstablishState::ConvergingReveal;
ExportTickHarness harness;
auto const localHash = ext.entropyHash;
harness.addEntropyPeer(1, localHash);
harness.addEntropyPeer(2, localHash);
harness.addEntropyPeer(3, localHash);
harness.addEntropyPeer(4, std::nullopt);
auto result = harness.tick(ext);
BEAST_EXPECT(!result.readyForAccept);
BEAST_EXPECT(harness.position.entropySetHash == localHash);
BEAST_EXPECT(ext.entropySetPublished_);
// Quorum alignment is not safe if a tx-converged peer has not
// advertised any entropySetHash. Otherwise local observation order
// can split validator entropy from deterministic consensus_fallback.
result = harness.tick(ext, std::chrono::milliseconds{100});
BEAST_EXPECT(!result.readyForAccept);
BEAST_EXPECT(!ext.entropyFailed);
BEAST_EXPECT(harness.position.entropySetHash == localHash);
result = harness.tick(
ext,
harness.parms.rngREVEAL_TIMEOUT * 2 + std::chrono::milliseconds{1});
BEAST_EXPECT(result.readyForAccept);
BEAST_EXPECT(ext.entropyFailed);
BEAST_EXPECT(!harness.position.entropySetHash);
}
void
testRngFastPathWaitsAfterEntropyPublish()
{
testcase("RNG fast path waits after entropy publish");
FakeExtensions ext;
ext.rngOn = true;
ext.exportOn = false;
ext.estState_ = EstablishState::ConvergingCommit;
ExportTickHarness harness;
auto const localHash = ext.entropyHash;
harness.addEntropyPeer(1, localHash);
harness.addEntropyPeer(2, localHash);
harness.addEntropyPeer(3, localHash);
harness.addEntropyPeer(4, localHash);
auto result = harness.tick(ext);
BEAST_EXPECT(!result.readyForAccept);
BEAST_EXPECT(ext.estState_ == EstablishState::ConvergingReveal);
BEAST_EXPECT(ext.entropySetPublished_);
BEAST_EXPECT(harness.position.entropySetHash == localHash);
result = harness.tick(ext, std::chrono::milliseconds{100});
BEAST_EXPECT(result.readyForAccept);
BEAST_EXPECT(!ext.entropyFailed);
BEAST_EXPECT(harness.position.entropySetHash == localHash);
}
void
testRngBootstrapSkipWhenPreviousParticipantsBelowGate()
{
testcase("RNG bootstrap skip below previous participant entropy gate");
FakeExtensions ext;
ext.rngOn = true;
ext.exportOn = false;
ExportTickHarness harness;
harness.prevProposers = 2;
auto result = harness.tick(ext);
BEAST_EXPECT(result.readyForAccept);
BEAST_EXPECT(ext.estState_ == EstablishState::ConvergingTx);
BEAST_EXPECT(ext.commitBuilds == 0);
BEAST_EXPECT(!harness.position.commitSetHash);
}
void
testRngCommitWaitsWhenQuorumPossible()
{
testcase("RNG commit waits when quorum is still possible");
FakeExtensions ext;
ext.rngOn = true;
ext.exportOn = false;
ext.commitQuorum = false;
ext.commits = 2;
ExportTickHarness harness;
harness.addCommitPeer(1, std::nullopt);
harness.addCommitPeer(2, std::nullopt);
harness.addCommitPeer(3, std::nullopt);
auto result = harness.tick(ext, std::chrono::milliseconds{100});
BEAST_EXPECT(!result.readyForAccept);
BEAST_EXPECT(ext.estState_ == EstablishState::ConvergingTx);
BEAST_EXPECT(ext.commitBuilds == 0);
BEAST_EXPECT(harness.updates == 0);
}
void
testRngCommitTimeoutWithEntropyGatePublishesCommitSet()
{
testcase("RNG commit timeout with entropy gate publishes commit set");
FakeExtensions ext;
ext.rngOn = true;
ext.exportOn = false;
ext.commitQuorum = false;
ext.commits = 4;
ExportTickHarness harness;
harness.addCommitPeer(1, std::nullopt);
harness.addCommitPeer(2, std::nullopt);
harness.addCommitPeer(3, std::nullopt);
auto result = harness.tick(
ext,
harness.parms.rngPIPELINE_TIMEOUT + std::chrono::milliseconds{1});
BEAST_EXPECT(!result.readyForAccept);
BEAST_EXPECT(ext.estState_ == EstablishState::ConvergingCommit);
BEAST_EXPECT(ext.commitBuilds == 1);
BEAST_EXPECT(harness.position.commitSetHash == ext.commitHash);
BEAST_EXPECT(harness.updates == 1);
BEAST_EXPECT(harness.proposes == 1);
}
void
testRngCommitQuorumInObservingModeDoesNotPropose()
{
testcase("RNG commit quorum in observing mode does not propose");
FakeExtensions ext;
ext.rngOn = true;
ext.exportOn = false;
ExportTickHarness harness;
harness.mode = ConsensusMode::observing;
auto result = harness.tick(ext);
BEAST_EXPECT(!result.readyForAccept);
BEAST_EXPECT(ext.estState_ == EstablishState::ConvergingCommit);
BEAST_EXPECT(harness.position.commitSetHash == ext.commitHash);
BEAST_EXPECT(harness.updates == 1);
BEAST_EXPECT(harness.proposes == 0);
BEAST_EXPECT(ext.participantDiagnostics == 1);
}
void
testRngCommitConflictRefreshesHashBeforeWaiting()
{
testcase("RNG commit conflict refreshes hash before waiting");
FakeExtensions ext;
ext.rngOn = true;
ext.exportOn = false;
ext.estState_ = EstablishState::ConvergingCommit;
auto const staleHash = makeHash("stale-commit-set");
auto const refreshedHash = makeHash("refreshed-commit-set");
auto const conflictHash = makeHash("conflicting-commit-set");
ext.commitHashSequence.push_back(refreshedHash);
ExportTickHarness harness;
harness.start =
std::chrono::steady_clock::time_point{} + std::chrono::seconds{1};
harness.position.commitSetHash = staleHash;
harness.addCommitPeer(1, conflictHash);
auto result = harness.tick(ext);
BEAST_EXPECT(!result.readyForAccept);
BEAST_EXPECT(ext.estState_ == EstablishState::ConvergingCommit);
BEAST_EXPECT(ext.commitBuilds == 1);
BEAST_EXPECT(harness.position.commitSetHash == refreshedHash);
BEAST_EXPECT(harness.updates == 1);
BEAST_EXPECT(harness.proposes == 1);
BEAST_EXPECT(ext.commitHashConflictStart_ == harness.start);
BEAST_EXPECT(!ext.fetchedCommitSets.empty());
}
void
testRngCommitHashConflictTimeoutFallsBack()
{
testcase("RNG commit hash conflict timeout falls back");
FakeExtensions ext;
ext.rngOn = true;
ext.exportOn = false;
ext.estState_ = EstablishState::ConvergingCommit;
ext.commitHash = makeHash("commit-local");
ExportTickHarness harness;
harness.start =
std::chrono::steady_clock::time_point{} + std::chrono::seconds{1};
harness.position.commitSetHash = ext.commitHash;
auto const conflictHash = makeHash("commit-conflict");
harness.addCommitPeer(1, conflictHash);
auto result = harness.tick(ext);
BEAST_EXPECT(!result.readyForAccept);
BEAST_EXPECT(!ext.entropyFailed);
BEAST_EXPECT(ext.commitHashConflictStart_ == harness.start);
BEAST_EXPECT(ext.fetchedCommitSets.size() == 1);
BEAST_EXPECT(ext.fetchedCommitSets.front() == conflictHash);
result = harness.tick(
ext,
harness.parms.rngREVEAL_TIMEOUT + std::chrono::milliseconds{1});
BEAST_EXPECT(!result.readyForAccept);
BEAST_EXPECT(ext.entropyFailed);
BEAST_EXPECT(ext.estState_ == EstablishState::ConvergingReveal);
BEAST_EXPECT(
ext.commitHashConflictStart_ ==
std::chrono::steady_clock::time_point{});
}
void
testRngRevealTransitionWaitsWhenRevealsIncomplete()
{
testcase("RNG reveal transition waits when reveals are incomplete");
FakeExtensions ext;
ext.rngOn = true;
ext.exportOn = false;
ext.estState_ = EstablishState::ConvergingCommit;
ext.minimumReveals = false;
ext.reveals = 1;
ExportTickHarness harness;
harness.position.commitSetHash = ext.commitHash;
auto result = harness.tick(ext);
BEAST_EXPECT(!result.readyForAccept);
BEAST_EXPECT(ext.estState_ == EstablishState::ConvergingReveal);
BEAST_EXPECT(ext.selfSeeds == 1);
BEAST_EXPECT(ext.entropyBuilds == 0);
BEAST_EXPECT(harness.position.myReveal);
BEAST_EXPECT(!harness.position.entropySetHash);
BEAST_EXPECT(harness.updates == 1);
BEAST_EXPECT(harness.proposes == 1);
}
void
testRngRevealTimeoutWithoutRevealsFallsBack()
{
testcase("RNG reveal timeout without reveals falls back");
FakeExtensions ext;
ext.rngOn = true;
ext.exportOn = false;
ext.estState_ = EstablishState::ConvergingReveal;
ext.minimumReveals = false;
ext.anyReveals = false;
ExportTickHarness harness;
auto result = harness.tick(
ext,
harness.parms.rngREVEAL_TIMEOUT + std::chrono::milliseconds{1});
BEAST_EXPECT(result.readyForAccept);
BEAST_EXPECT(ext.entropyFailed);
BEAST_EXPECT(!harness.position.entropySetHash);
}
void
testRngEntropyPublishIsIdempotent()
{
testcase("RNG entropy publish is idempotent");
FakeExtensions ext;
ext.rngOn = true;
ext.exportOn = false;
ext.estState_ = EstablishState::ConvergingReveal;
ExportTickHarness harness;
harness.position.entropySetHash = ext.entropyHash;
auto result = harness.tick(ext);
BEAST_EXPECT(!result.readyForAccept);
BEAST_EXPECT(ext.entropyBuilds == 1);
BEAST_EXPECT(ext.entropySetPublished_);
BEAST_EXPECT(harness.position.entropySetHash == ext.entropyHash);
BEAST_EXPECT(harness.updates == 0);
BEAST_EXPECT(harness.proposes == 0);
}
void
testRngEntropyConflictTimeoutClearsHash()
{
testcase("RNG entropy conflict timeout clears hash");
FakeExtensions ext;
ext.rngOn = true;
ext.exportOn = false;
ext.estState_ = EstablishState::ConvergingReveal;
ExportTickHarness harness;
harness.position.entropySetHash = ext.entropyHash;
ext.entropySetPublished_ = true;
ext.entropyPublishStart_ = harness.start;
auto const conflictHash = makeHash("entropy-conflict");
harness.addEntropyPeer(1, conflictHash);
auto result = harness.tick(ext, std::chrono::milliseconds{100});
BEAST_EXPECT(!result.readyForAccept);
BEAST_EXPECT(!ext.entropyFailed);
BEAST_EXPECT(harness.position.entropySetHash == ext.entropyHash);
BEAST_EXPECT(!ext.fetchedEntropySets.empty());
BEAST_EXPECT(ext.fetchedEntropySets.front() == conflictHash);
result = harness.tick(
ext,
harness.parms.rngREVEAL_TIMEOUT * 2 + std::chrono::milliseconds{1});
BEAST_EXPECT(result.readyForAccept);
BEAST_EXPECT(ext.entropyFailed);
BEAST_EXPECT(!harness.position.entropySetHash);
}
void
testRngEntropyConflictRefreshesHashBeforeWaiting()
{
testcase("RNG entropy conflict refreshes hash before waiting");
FakeExtensions ext;
ext.rngOn = true;
ext.exportOn = false;
ext.estState_ = EstablishState::ConvergingReveal;
auto const staleHash = makeHash("stale-entropy-set");
auto const refreshedHash = makeHash("refreshed-entropy-set");
auto const conflictHash = makeHash("conflicting-entropy-set");
ext.entropyHashSequence.push_back(staleHash);
ext.entropyHashSequence.push_back(refreshedHash);
ExportTickHarness harness;
harness.start =
std::chrono::steady_clock::time_point{} + std::chrono::seconds{1};
harness.position.entropySetHash = staleHash;
ext.entropySetPublished_ = true;
ext.entropyPublishStart_ = harness.start;
harness.addEntropyPeer(1, conflictHash);
auto result = harness.tick(ext, std::chrono::milliseconds{100});
BEAST_EXPECT(!result.readyForAccept);
BEAST_EXPECT(!ext.entropyFailed);
BEAST_EXPECT(ext.entropyBuilds == 2);
BEAST_EXPECT(harness.position.entropySetHash == refreshedHash);
BEAST_EXPECT(harness.updates == 1);
BEAST_EXPECT(harness.proposes == 1);
BEAST_EXPECT(!ext.fetchedEntropySets.empty());
}
void
testRngEntropyConflictIgnoredWithQuorumAlignment()
{
testcase("RNG entropy conflict ignored with quorum alignment");
FakeExtensions ext;
ext.rngOn = true;
ext.exportOn = false;
ext.estState_ = EstablishState::ConvergingReveal;
ExportTickHarness harness;
auto const localHash = ext.entropyHash;
harness.position.entropySetHash = localHash;
ext.entropySetPublished_ = true;
ext.entropyPublishStart_ = harness.start;
harness.addEntropyPeer(1, localHash);
harness.addEntropyPeer(2, localHash);
harness.addEntropyPeer(3, localHash);
harness.addEntropyPeer(4, makeHash("entropy-minority-conflict"));
auto result = harness.tick(ext, std::chrono::milliseconds{100});
BEAST_EXPECT(result.readyForAccept);
BEAST_EXPECT(!ext.entropyFailed);
BEAST_EXPECT(harness.position.entropySetHash == localHash);
BEAST_EXPECT(ext.fetchedEntropySets.size() == 1);
}
void
testRngExplicitFinalProposalPublishesSyntheticTxSet()
{
testcase("RNG explicit final proposal publishes synthetic tx set");
FakeExtensions ext;
ext.rngOn = true;
ext.exportOn = false;
ext.estState_ = EstablishState::ConvergingReveal;
ext.sendExplicitFinal = true;
ext.explicitFinalTxSet = FakeTxSet{makeHash("explicit-final-tx-set")};
ExportTickHarness harness;
auto const localHash = ext.entropyHash;
harness.position.entropySetHash = localHash;
ext.entropySetPublished_ = true;
ext.entropyPublishStart_ = harness.start;
harness.addEntropyPeer(1, localHash);
harness.addEntropyPeer(2, localHash);
harness.addEntropyPeer(3, localHash);
harness.addEntropyPeer(4, localHash);
auto result = harness.tick(ext, std::chrono::milliseconds{100});
BEAST_EXPECT(result.readyForAccept);
BEAST_EXPECT(ext.explicitFinalProposalSent_);
BEAST_EXPECT(
harness.position.txSetHash == ext.explicitFinalTxSet->hash);
BEAST_EXPECT(harness.position.entropySetHash == localHash);
BEAST_EXPECT(harness.updates == 1);
BEAST_EXPECT(harness.proposes == 1);
}
void
testExportSigGateAllowsAlignedQuorumDespiteMinorityConflict()
{
testcase("Export sig gate ignores minority conflict after quorum");
FakeExtensions ext;
ExportTickHarness harness;
auto const localHash = ext.exportHash;
auto const conflictHash = makeHash("conflicting-export-sig-set");
harness.addPeer(1, localHash);
harness.addPeer(2, localHash);
harness.addPeer(3, localHash);
harness.addPeer(4, conflictHash);
auto result = harness.tick(ext);
BEAST_EXPECT(!result.readyForAccept);
result = harness.tick(ext, std::chrono::milliseconds{100});
BEAST_EXPECT(result.readyForAccept);
BEAST_EXPECT(!ext.exportSigConvergenceFailed_);
BEAST_EXPECT(ext.fetchedExportSets.size() == 1);
BEAST_EXPECT(ext.fetchedExportSets.front() == conflictHash);
}
void
testExportSigGateRequiresFullObservation()
{
testcase("Export sig gate requires full sidecar observation");
FakeExtensions ext;
ExportTickHarness harness;
auto const localHash = ext.exportHash;
harness.addPeer(1, localHash);
harness.addPeer(2, localHash);
harness.addPeer(3, localHash);
harness.addPeer(4, std::nullopt);
auto result = harness.tick(ext);
BEAST_EXPECT(!result.readyForAccept);
BEAST_EXPECT(harness.position.exportSigSetHash == localHash);
BEAST_EXPECT(ext.exportSigGateStarted_);
// Local quorum alignment is not enough if a tx-converged peer has
// not advertised any exportSigSetHash yet.
result = harness.tick(ext, std::chrono::milliseconds{100});
BEAST_EXPECT(!result.readyForAccept);
BEAST_EXPECT(!ext.exportSigConvergenceFailed_);
result = harness.tick(
ext,
harness.parms.rngREVEAL_TIMEOUT * 2 + std::chrono::milliseconds{1});
BEAST_EXPECT(result.readyForAccept);
BEAST_EXPECT(ext.exportSigConvergenceFailed_);
}
void
testExportSigGateFetchesAdvertisedPeerSets()
{
testcase("Export sig gate fetches advertised peer sets");
FakeExtensions ext;
ext.localExportSigs = false;
ExportTickHarness harness;
auto const peerHash = makeHash("peer-export-sig-set");
harness.addPeer(1, peerHash);
auto result = harness.tick(ext);
BEAST_EXPECT(!result.readyForAccept);
BEAST_EXPECT(ext.exportSigGateStarted_);
BEAST_EXPECT(!harness.position.exportSigSetHash);
BEAST_EXPECT(ext.fetchedExportSets.size() == 1);
BEAST_EXPECT(ext.fetchedExportSets.front() == peerHash);
result = harness.tick(
ext,
harness.parms.rngREVEAL_TIMEOUT * 2 + std::chrono::milliseconds{1});
BEAST_EXPECT(result.readyForAccept);
BEAST_EXPECT(ext.exportSigConvergenceFailed_);
}
void
testExportSigGateObservingModeDoesNotPropose()
{
testcase("Export sig gate observing mode does not propose");
FakeExtensions ext;
ExportTickHarness harness;
harness.mode = ConsensusMode::observing;
auto result = harness.tick(ext);
BEAST_EXPECT(!result.readyForAccept);
BEAST_EXPECT(harness.position.exportSigSetHash == ext.exportHash);
BEAST_EXPECT(harness.updates == 1);
BEAST_EXPECT(harness.proposes == 0);
}
void
testExportSigGateRefreshesHashBeforeWaiting()
{
testcase("Export sig gate refreshes hash before waiting");
FakeExtensions ext;
auto const staleHash = makeHash("stale-export-sig-set");
auto const refreshedHash = makeHash("refreshed-export-sig-set");
auto const conflictHash = makeHash("conflicting-export-sig-set");
ext.exportHashSequence.push_back(staleHash);
ext.exportHashSequence.push_back(refreshedHash);
ExportTickHarness harness;
harness.start =
std::chrono::steady_clock::time_point{} + std::chrono::seconds{1};
harness.position.exportSigSetHash = staleHash;
ext.exportSigGateStarted_ = true;
ext.exportSigGateStart_ = harness.start;
harness.addPeer(1, conflictHash);
auto result = harness.tick(ext, std::chrono::milliseconds{100});
BEAST_EXPECT(!result.readyForAccept);
BEAST_EXPECT(!ext.exportSigConvergenceFailed_);
BEAST_EXPECT(ext.exportBuilds == 2);
BEAST_EXPECT(harness.position.exportSigSetHash == refreshedHash);
BEAST_EXPECT(harness.updates == 1);
BEAST_EXPECT(harness.proposes == 1);
BEAST_EXPECT(!ext.fetchedExportSets.empty());
}
void
testExportSigGateBoundsCandidateObservationWindow()
{
testcase("Export sig gate bounds candidate observation window");
FakeExtensions ext;
ext.localExportSigs = false;
ext.consensusExportTxns = true;
ExportTickHarness harness;
auto result = harness.tick(ext);
BEAST_EXPECT(!result.readyForAccept);
BEAST_EXPECT(ext.exportSigGateStarted_);
BEAST_EXPECT(!harness.position.exportSigSetHash);
BEAST_EXPECT(ext.fetchedExportSets.empty());
BEAST_EXPECT(!ext.exportSigConvergenceFailed_);
result = harness.tick(ext, std::chrono::milliseconds{100});
BEAST_EXPECT(!result.readyForAccept);
BEAST_EXPECT(!ext.exportSigConvergenceFailed_);
result = harness.tick(
ext,
harness.parms.rngREVEAL_TIMEOUT * 2 + std::chrono::milliseconds{1});
BEAST_EXPECT(result.readyForAccept);
BEAST_EXPECT(ext.exportSigConvergenceFailed_);
}
void
testExportSigGateSkipsWhenExportDisabled()
{
testcase("Export sig gate skips when Export disabled");
FakeExtensions ext;
ext.exportOn = false;
ExportTickHarness harness;
harness.addPeer(1, ext.exportHash);
auto result = harness.tick(ext);
BEAST_EXPECT(result.readyForAccept);
BEAST_EXPECT(!ext.exportSigGateStarted_);
BEAST_EXPECT(!harness.position.exportSigSetHash);
BEAST_EXPECT(ext.exportBuilds == 0);
BEAST_EXPECT(ext.fetchedExportSets.empty());
}
void
testParticipantDiagnosticsOnlyWhenExtensionEnabled()
{
testcase("Participant diagnostics only when extension enabled");
FakeExtensions ext;
ext.rngOn = false;
ext.exportOn = false;
ExportTickHarness harness;
auto result = harness.tick(ext);
BEAST_EXPECT(result.readyForAccept);
BEAST_EXPECT(ext.participantDiagnostics == 0);
ext.exportOn = true;
ext.localExportSigs = false;
result = harness.tick(ext);
BEAST_EXPECT(result.readyForAccept);
BEAST_EXPECT(ext.participantDiagnostics == 1);
}
void
testExportDisabledRoundClearsCollector()
{
testcase("Export disabled round clears collector");
using namespace jtx;
Env env{*this, envconfig(), supported_amendments(), nullptr};
ConsensusExtensions ce{env.app(), env.journal};
auto const tx = makeHash("export-disabled-clears-collector");
auto const pk = makeValidatorKeys().front();
std::uint8_t const sigBytes[] = {1, 2, 3};
Buffer const sig{sigBytes, sizeof(sigBytes)};
ce.setExportEnabledThisRound(true);
ce.exportSigCollector().addVerifiedSignature(tx, pk, sig, 10);
ce.clearRngState();
BEAST_EXPECT(ce.exportSigCollector().signatureCount(tx) == 1);
ce.setExportEnabledThisRound(false);
ce.clearRngState();
BEAST_EXPECT(ce.exportSigCollector().signatureCount(tx) == 0);
}
void
testReplayedProposalHarvestsExportSigs()
{
testcase("Replayed proposal harvests export signatures");
using namespace jtx;
Env env{
*this, envconfig(validator, ""), supported_amendments(), nullptr};
auto const& valKeys = env.app().getValidatorKeys();
BEAST_EXPECT(valKeys.keys);
if (!valKeys.keys)
return;
ConsensusExtensions ce{env.app(), env.journal};
ce.setExportEnabledThisRound(true);
ce.cacheUNLReport();
auto const activeView = ce.activeValidatorView();
BEAST_EXPECT(activeView->sourceLedgerHash);
if (!activeView->sourceLedgerHash)
return;
auto const senderPK = valKeys.keys->publicKey;
BEAST_EXPECT(ce.isActiveValidator(senderPK, *activeView));
if (!ce.isActiveValidator(senderPK, *activeView))
return;
auto const tx = makeHash("replayed-export-sig-tx");
auto const blob = makeExportSigBlob(tx, senderPK);
ExtendedPosition position{makeHash("replayed-position")};
position.exportSignaturesHash =
proposalExportSignaturesHash(std::vector<std::string>{blob});
ce.onTrustedPeerProposal(
calcNodeID(senderPK),
senderPK,
position,
0,
NetClock::time_point{},
*activeView->sourceLedgerHash,
Slice{},
std::vector<std::string>{blob});
BEAST_EXPECT(ce.exportSigCollector().hasUnverifiedSignatures());
}
void
testWireProposalHarvestsExportSigs()
{
testcase("wire proposal harvests export signatures");
using namespace jtx;
Env env{
*this, envconfig(validator, ""), supported_amendments(), nullptr};
auto const& valKeys = env.app().getValidatorKeys();
BEAST_EXPECT(valKeys.keys);
if (!valKeys.keys)
return;
ConsensusExtensions ce{env.app(), activeNoopJournal()};
ce.setExportEnabledThisRound(true);
ce.cacheUNLReport();
protocol::TMProposeSet wire;
auto const& senderPK = valKeys.keys->publicKey;
wire.set_nodepubkey(senderPK.data(), senderPK.size());
auto const prevLedger = *ce.activeValidatorView()->sourceLedgerHash;
wire.set_previousledger(prevLedger.data(), prevLedger.size());
auto const tx = makeHash("wire-export-sig-tx");
auto const blob = makeExportSigBlob(tx, senderPK);
wire.add_exportsignatures(blob);
ce.onTrustedPeerMessage(wire);
BEAST_EXPECT(ce.exportSigCollector().hasUnverifiedSignatures());
auto const beforeMalformed =
ce.exportSigCollector().unverifiedSignatures(tx);
BEAST_EXPECT(beforeMalformed.size() == 1);
protocol::TMProposeSet malformed;
malformed.add_exportsignatures(blob);
malformed.set_nodepubkey("bad", 3);
ce.onTrustedPeerMessage(malformed);
auto const afterMalformed =
ce.exportSigCollector().unverifiedSignatures(tx);
BEAST_EXPECT(afterMalformed == beforeMalformed);
}
void
testPublicHookNoopAndFailureBranches()
{
testcase("public hook no-op and failure branches");
using namespace jtx;
Env env{
*this, envconfig(validator, ""), supported_amendments(), nullptr};
ConsensusExtensions ce{env.app(), activeNoopJournal()};
auto const ledger = env.app().getLedgerMaster().getClosedLedger();
ce.cacheUNLReport(ledger);
BEAST_EXPECT(ce.exportSigQuorumThreshold() == 1);
ce.setExportSigConvergenceFailed();
BEAST_EXPECT(ce.exportSigConvergenceFailed());
ce.setEntropyFailed();
BEAST_EXPECT(ce.belowValidatorQuorum());
ce.generateEntropySecret();
BEAST_EXPECT(!ce.hasAnyReveals());
ce.selfSeedReveal();
BEAST_EXPECT(ce.pendingRevealCount() == 1);
auto const [pk, _] = randomKeyPair(KeyType::secp256k1);
std::vector<std::string> const noSigs;
BEAST_EXPECT(
ce.harvestExportSignatures(
pk, ledger->info().hash, noSigs, "disabled") == 0);
ce.setExportEnabledThisRound(true);
BEAST_EXPECT(
ce.harvestExportSignatures(
pk, ledger->info().hash, noSigs, "empty") == 0);
protocol::TMProposeSet wire;
ce.onTrustedPeerMessage(wire);
wire.add_exportsignatures(
makeExportSigBlob(makeHash("wire-no-prev"), pk));
wire.set_nodepubkey(pk.data(), pk.size());
ce.onTrustedPeerMessage(wire);
Json::Value json;
ce.estState_ = EstablishState::ConvergingCommit;
ce.appendJson(json);
BEAST_EXPECT(json["rng"]["est_state"].asString() == "ConvergingCommit");
ce.estState_ = EstablishState::ConvergingReveal;
ce.appendJson(json);
BEAST_EXPECT(json["rng"]["est_state"].asString() == "ConvergingReveal");
ExtendedPosition logPos{makeHash("log-inactive-position")};
ce.logPosition(
logPos,
beast::Journal{beast::Journal::getNullSink()},
beast::severities::kTrace);
protocol::TMProposeSet prop;
RCLCxPeerPos::Proposal proposal{
ledger->info().hash,
0,
ExtendedPosition{makeHash("attach-export-disabled")},
NetClock::time_point{},
NetClock::time_point{},
env.app().getValidatorKeys().nodeID};
ConsensusExtensions disabled{env.app(), activeNoopJournal()};
disabled.attachExportSignatures(prop, proposal);
BEAST_EXPECT(prop.exportsignatures_size() == 0);
ConfigVals cfg;
cfg.noExportSig = true;
env.app().getRuntimeConfig().setConfig("*", cfg);
ce.attachExportSignatures(prop, proposal);
BEAST_EXPECT(prop.exportsignatures_size() == 0);
}
void
testDecorateMessageStoresSelfProofs()
{
testcase("decorateMessage stores self proofs");
using namespace jtx;
Env env{
*this,
envconfig(validator, ""),
supported_amendments() | featureConsensusEntropy,
nullptr};
auto const ledger = env.app().getLedgerMaster().getClosedLedger();
auto const& valKeys = env.app().getValidatorKeys();
BEAST_EXPECT(valKeys.keys);
if (!valKeys.keys)
return;
ConsensusExtensions ce{env.app(), activeNoopJournal()};
ExtendedPosition commitPos{makeHash("decorate-message-txset")};
ce.decoratePosition(commitPos, ledger, true);
BEAST_EXPECT(commitPos.myCommitment);
if (!commitPos.myCommitment)
return;
auto const closeTime = NetClock::time_point{NetClock::duration{12345}};
RCLCxPeerPos::Proposal commitProposal{
ledger->info().hash,
0,
commitPos,
closeTime,
NetClock::time_point{},
valKeys.nodeID};
auto const commitSig = signDigest(
valKeys.keys->publicKey,
valKeys.keys->secretKey,
commitProposal.signingHash());
protocol::TMProposeSet prop;
ce.decorateMessage(
prop,
commitProposal,
commitPos,
Buffer(commitSig.data(), commitSig.size()));
auto const seq = ledger->info().seq + 1;
auto const commitHash = ce.buildCommitSet(seq);
BEAST_EXPECT(ce.isSidecarSet(commitHash));
ExtendedPosition revealPos{commitPos.txSetHash};
revealPos.myReveal = ce.getEntropySecret();
RCLCxPeerPos::Proposal revealProposal{
ledger->info().hash,
1,
revealPos,
closeTime,
NetClock::time_point{},
valKeys.nodeID};
auto const revealSig = signDigest(
valKeys.keys->publicKey,
valKeys.keys->secretKey,
revealProposal.signingHash());
ce.decorateMessage(
prop,
revealProposal,
revealPos,
Buffer(revealSig.data(), revealSig.size()));
BEAST_EXPECT(ce.pendingRevealCount() == 1);
BEAST_EXPECT(ce.hasMinimumReveals());
auto const entropyHash = ce.buildEntropySet(seq);
BEAST_EXPECT(ce.isSidecarSet(entropyHash));
}
public:
void
run() override
{
testSidecarPeerAlignmentHelper();
testActiveValidatorViewBuilderPrefersUNLReport();
testActiveValidatorViewBuilderFallback();
testActiveValidatorViewAppliesNegativeUNL();
testActiveValidatorViewNullSourceAndExpectedProposers();
testParticipantThreshold();
testExplicitFinalProposalTxSetBuildsEntropyTxn();
testRuntimeConfigPolicyAccessors();
testDecoratePositionGeneratesCommitment();
testOnPreBuildInjectsZeroEntropyFallback();
testOnPreBuildInjectsEntropySetEntropy();
testOnPreBuildTier2ParticipantAligned();
testTier2ThresholdAnchorsToOriginalView();
testProposalProofRoundTrip();
testHarvestRngDataReplacementAndRejection();
testExportSidecarBuildFetchAndMerge();
testRngSidecarBuildFetchAndMerge();
testRngSidecarRejectsInvalidFetchedEntries();
testOnPreBuildInjectsStandaloneEntropy();
testOnPreBuildEntropyMismatchKeepsAgreed();
testDiagnosticsJsonAndPositionLogging();
testDecoratePositionSkipsWhenDisabled();
testExportSigGateRequiresQuorumAlignment();
testRngEntropyGateRequiresFullObservation();
testRngFastPathWaitsAfterEntropyPublish();
testRngBootstrapSkipWhenPreviousParticipantsBelowGate();
testRngCommitWaitsWhenQuorumPossible();
testRngCommitTimeoutWithEntropyGatePublishesCommitSet();
testRngCommitQuorumInObservingModeDoesNotPropose();
testRngCommitConflictRefreshesHashBeforeWaiting();
testRngCommitHashConflictTimeoutFallsBack();
testRngRevealTransitionWaitsWhenRevealsIncomplete();
testRngRevealTimeoutWithoutRevealsFallsBack();
testRngEntropyPublishIsIdempotent();
testRngEntropyConflictTimeoutClearsHash();
testRngEntropyConflictRefreshesHashBeforeWaiting();
testRngEntropyConflictIgnoredWithQuorumAlignment();
testRngExplicitFinalProposalPublishesSyntheticTxSet();
testExportSigGateAllowsAlignedQuorumDespiteMinorityConflict();
testExportSigGateRequiresFullObservation();
testExportSigGateFetchesAdvertisedPeerSets();
testExportSigGateObservingModeDoesNotPropose();
testExportSigGateRefreshesHashBeforeWaiting();
testExportSigGateBoundsCandidateObservationWindow();
testExportSigGateSkipsWhenExportDisabled();
testParticipantDiagnosticsOnlyWhenExtensionEnabled();
testExportDisabledRoundClearsCollector();
testReplayedProposalHarvestsExportSigs();
testWireProposalHarvestsExportSigs();
testPublicHookNoopAndFailureBranches();
testDecorateMessageStoresSelfProofs();
}
};
BEAST_DEFINE_TESTSUITE(ConsensusExtensions, consensus, ripple);
} // namespace test
} // namespace ripple