From dd21024c0ed75b8a5e29dcd8a1b8fa23f7d9be3d Mon Sep 17 00:00:00 2001 From: Nicholas Dudfield Date: Tue, 9 Jun 2026 19:56:46 +0700 Subject: [PATCH] test: improve export rng coverage --- .../app/ExportSignatureHarvester_test.cpp | 22 +- src/test/app/Export_test.cpp | 66 + src/test/app/XPOP_test.cpp | 58 + .../consensus/ConsensusExtensions_test.cpp | 1368 ++++++++++++++++- src/test/consensus/Consensus_test.cpp | 65 + src/test/csf/Peer.h | 5 +- src/test/csf/Sim.h | 9 + src/test/overlay/ProposalPrecheck_test.cpp | 232 +++ src/test/rpc/Connect_test.cpp | 54 + src/test/rpc/RuntimeConfig_test.cpp | 33 + src/test/shamap/SHAMap_test.cpp | 61 + .../consensus/ExportSignatureHarvester.cpp | 4 + src/xrpld/overlay/detail/PeerImp.cpp | 93 +- src/xrpld/overlay/detail/ProposalPrecheck.h | 138 ++ 14 files changed, 2116 insertions(+), 92 deletions(-) create mode 100644 src/test/overlay/ProposalPrecheck_test.cpp create mode 100644 src/xrpld/overlay/detail/ProposalPrecheck.h diff --git a/src/test/app/ExportSignatureHarvester_test.cpp b/src/test/app/ExportSignatureHarvester_test.cpp index 3f36970ce..03389a25c 100644 --- a/src/test/app/ExportSignatureHarvester_test.cpp +++ b/src/test/app/ExportSignatureHarvester_test.cpp @@ -16,6 +16,7 @@ */ //============================================================================== +#include #include #include #include @@ -116,16 +117,11 @@ makeBlob(uint256 const& txHash, PublicKey const& pk, Buffer const& sig) return makeBlob(txHash, pk, Slice(sig.data(), sig.size())); } -beast::Journal -journal() -{ - return beast::Journal{beast::Journal::getNullSink()}; -} - } // namespace class ExportSignatureHarvester_test : public beast::unit_test::suite { + SuiteJournal journal_{"ExportSignatureHarvester_test", *this}; std::pair const sender_ = randomKeyPair(KeyType::secp256k1); std::pair const other_ = @@ -139,7 +135,8 @@ class ExportSignatureHarvester_test : public beast::unit_test::suite ExportTxnLookup const& exportTxns, bool active = true, std::optional sourceLedgerHash = std::nullopt, - PublicKey const* sender = nullptr) const + PublicKey const* sender = nullptr, + std::size_t maxEntries = 2) const { return ExportSignatureHarvestInput{ sender ? *sender : sender_.first, @@ -150,7 +147,13 @@ class ExportSignatureHarvester_test : public beast::unit_test::suite exportTxns, 42, source_, - 2}; + maxEntries}; + } + + beast::Journal& + journal() + { + return journal_; } public: @@ -238,7 +241,8 @@ public: ExportTxnLookup lookup; ExportSigCollector collector; - auto input = makeInput(blobs, lookup, true, prevLedger_); + auto input = + makeInput(blobs, lookup, true, prevLedger_, nullptr, blobs.size()); BEAST_EXPECT(harvestExportSignatures(input, collector, journal()) == 0); BEAST_EXPECT(!collector.hasUnverifiedSignatures()); BEAST_EXPECT(collector.signatureCount(txHash) == 0); diff --git a/src/test/app/Export_test.cpp b/src/test/app/Export_test.cpp index 83fe85ee4..9477e9aad 100644 --- a/src/test/app/Export_test.cpp +++ b/src/test/app/Export_test.cpp @@ -23,6 +23,7 @@ #include #include #include +#include #include #include #include @@ -51,6 +52,12 @@ struct Export_test : public beast::unit_test::suite return cfg; } + static void + forceNonStandalone(Application& app) + { + const_cast(app.config()).setupControl(true, true, false); + } + // Build a minimal unsigned Payment STObject suitable for sfExportedTxn. static STObject buildExportedPayment( @@ -613,6 +620,64 @@ struct Export_test : public beast::unit_test::suite env.close(); } + void + testExportNetworkRetryWithoutQuorum(FeatureBitset features) + { + testcase("ttEXPORT network mode retries without quorum"); + + using namespace jtx; + + Env env{*this, exportTestConfig(), features}; + + Account const alice{"alice"}; + Account const carol{"carol"}; + + env.fund(XRP(10000), alice, carol); + env.close(); + forceNonStandalone(env.app()); + BEAST_EXPECT(!env.app().config().standalone()); + ConfigVals cfg; + cfg.noExportSig = true; + env.app().getRuntimeConfig().setConfig("*", cfg); + + auto const seq = env.current()->seq(); + auto const ticketSeq = std::uint32_t{1}; + auto const lls = seq + 5; + auto innerObj = buildExportedPayment( + alice.id(), carol.id(), seq + 1, lls, ticketSeq); + + Json::Value jv; + jv[jss::TransactionType] = jss::Export; + jv[jss::Account] = alice.human(); + jv[jss::LastLedgerSequence] = lls; + jv[sfExportedTxn.jsonName] = innerObj.getJson(JsonOptions::none); + + env(jv, fee(XRP(1)), ter(tesSUCCESS)); + BEAST_EXPECT(env.close( + env.now() + std::chrono::seconds{5}, std::chrono::milliseconds{0})); + + std::optional closedResult; + for (auto const& [stx, meta] : env.closed()->txs) + { + if (!stx || stx->getTxnType() != ttEXPORT || + stx->getAccountID(sfAccount) != alice.id()) + { + continue; + } + + auto const& exported = + stx->peekAtField(sfExportedTxn).downcast(); + if (exported.getFieldU32(sfTicketSequence) != ticketSeq) + continue; + + if (meta) + closedResult = TER::fromInt((*meta)[sfTransactionResult]); + } + + BEAST_EXPECT(!closedResult); + BEAST_EXPECT(!env.le(keylet::shadowTicket(alice.id(), ticketSeq))); + } + void testOpenLedgerExportLimit(FeatureBitset features) { @@ -1060,6 +1125,7 @@ struct Export_test : public beast::unit_test::suite // ttEXPORT transactor tests testExportTxnOpenLedger(allWithExport); + testExportNetworkRetryWithoutQuorum(allWithExport); testOpenLedgerExportLimit(allWithExport); testShadowTicketLimit(allWithExport); testShadowTicketLifecycle(allWithExport); diff --git a/src/test/app/XPOP_test.cpp b/src/test/app/XPOP_test.cpp index a894780e5..02d37dd30 100644 --- a/src/test/app/XPOP_test.cpp +++ b/src/test/app/XPOP_test.cpp @@ -20,6 +20,7 @@ #include #include #include +#include #include #include #include @@ -152,6 +153,62 @@ struct XPOP_test : public beast::unit_test::suite BEAST_EXPECT(proofJson[3].asString() == to_string(manual.leafHash)); } + void + testProofBuilderSyntheticTrie() + { + testcase("ProofBuilder synthetic trie collisions"); + + tests::TestNodeFamily f{beast::Journal{beast::Journal::getNullSink()}}; + SHAMap map{SHAMapType::TRANSACTION, f}; + + auto const keyA = uint256{ + "1000000000000000000000000000000000000000000000000000000000000001"}; + auto const keyB = uint256{ + "1800000000000000000000000000000000000000000000000000000000000002"}; + auto const keyC = uint256{ + "2000000000000000000000000000000000000000000000000000000000000003"}; + auto const keyD = uint256{ + "1f00000000000000000000000000000000000000000000000000000000000004"}; + + auto add = [&](uint256 const& key, Blob data) { + return map.addItem( + SHAMapNodeType::tnTRANSACTION_NM, + make_shamapitem(key, makeSlice(data))); + }; + auto payload = [](std::uint8_t first) { + Blob data; + data.reserve(12); + for (std::uint8_t i = 0; i < 12; ++i) + data.push_back(first + i); + return data; + }; + + BEAST_EXPECT(add(keyA, payload(0x01))); + BEAST_EXPECT(add(keyB, payload(0x11))); + BEAST_EXPECT(add(keyC, payload(0x21))); + BEAST_EXPECT(add(keyD, payload(0x31))); + map.invariants(); + + auto const proof = proof::extractProofV1(map, keyA); + BEAST_EXPECT(proof.has_value()); + if (proof) + { + BEAST_EXPECT(proof->path.size() == 2); + auto const computedRoot = proof->computeRoot(); + BEAST_EXPECT(computedRoot.has_value()); + if (computedRoot) + BEAST_EXPECT(proof->verify(*computedRoot)); + + auto const json = proof->toJsonV1(); + BEAST_EXPECT(json.isArray()); + BEAST_EXPECT(json[proof->path.front().targetBranch].isArray()); + } + + auto const nearMiss = uint256{ + "10000000000000000000000000000000000000000000000000000000000000ff"}; + BEAST_EXPECT(!proof::extractProofV1(map, nearMiss)); + } + void testBuildXPOPv1() { @@ -385,6 +442,7 @@ struct XPOP_test : public beast::unit_test::suite { testBuildLedgerProof(); testProofBuilderEdgeCases(); + testProofBuilderSyntheticTrie(); testBuildXPOPv1(); testBuildXPOPv1WithoutMerkleProof(); testMerkleProofVerification(); diff --git a/src/test/consensus/ConsensusExtensions_test.cpp b/src/test/consensus/ConsensusExtensions_test.cpp index 5103e3908..6521fa4d1 100644 --- a/src/test/consensus/ConsensusExtensions_test.cpp +++ b/src/test/consensus/ConsensusExtensions_test.cpp @@ -19,7 +19,10 @@ #include #include #include +#include #include +#include +#include #include #include #include @@ -27,6 +30,13 @@ #include #include #include +#include +#include +#include +#include +#include +#include +#include #include #include #include @@ -36,6 +46,37 @@ 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) { @@ -62,6 +103,186 @@ makeExportSigBlob(uint256 const& txHash, PublicKey const& publicKey) 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 +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(inner)); + + return std::make_shared(makeSTTx(exportObj)); +} + +RCLTxSet +makeRCLTxSet(Application& app, std::vector> txns) +{ + auto map = + std::make_shared(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 +makeSidecarSet(Application& app, std::vector const& sidecars) +{ + auto map = + std::make_shared(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 +makeRawSidecarSet(Application& app, std::string const& raw) +{ + auto map = + std::make_shared(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 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(app.config()).setupControl(true, true, false); +} + +std::shared_ptr +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; + 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(closeTime.time_since_epoch().count()), + prevLedger, + std::move(positionData), + std::move(signature)}); +} + struct FakeTxSet { using ID = uint256; @@ -105,7 +326,7 @@ struct FakeExtensions { enum class SidecarKind : uint8_t { commit, reveal, exportSig }; - beast::Journal j_{beast::Journal::getNullSink()}; + beast::Journal j_{activeNoopJournal()}; EstablishState estState_{EstablishState::ConvergingTx}; std::chrono::steady_clock::time_point revealPhaseStart_{}; std::chrono::steady_clock::time_point commitHashConflictStart_{}; @@ -391,7 +612,7 @@ struct ExportTickHarness .parms = parms, .haveCloseTimeConsensus = true, .convergePercent = 100, - .j = beast::Journal{beast::Journal::getNullSink()}, + .j = activeNoopJournal(), .getPosition = [&]() -> ExtendedPosition const& { return position; }, @@ -596,6 +817,947 @@ class ConsensusExtensions_test : public beast::unit_test::suite 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{valKeys.nodeID, makeNode(99)}); + BEAST_EXPECT(ce.expectedProposerCount() == 1); + + ce.setExpectedProposers({}); + BEAST_EXPECT( + ce.expectedProposerCount() == ce.activeValidatorView()->size()); + } + + void + testExplicitFinalProposalTxSetBuildsEntropyTxn() + { + testcase("explicit final proposal tx set builds entropy txn"); + + auto extractSingleEntropyTx = + [](RCLTxSet const& set) -> std::shared_ptr { + std::vector> txs; + set.map_->visitLeaves( + [&](boost::intrusive_ptr const& item) { + SerialIter sit(item->slice()); + txs.push_back(std::make_shared(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); + + 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.cacheUNLReport(ledger); + zeroCe.setEntropyFailed(); + auto zeroSynthetic = zeroCe.buildExplicitFinalProposalTxSet( + nonStandaloneBase, nonStandaloneSeq); + BEAST_EXPECT(zeroSynthetic); + auto const zeroTx = + zeroSynthetic ? extractSingleEntropyTx(*zeroSynthetic) : nullptr; + BEAST_EXPECT(zeroTx); + if (zeroTx) + { + BEAST_EXPECT(zeroTx->getFieldH256(sfDigest) == uint256{}); + BEAST_EXPECT(zeroTx->getFieldU16(sfEntropyCount) == 0); + } + + 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"); + auto const commitment = sha512Half(reveal, publicKey, nonStandaloneSeq); + + ConsensusExtensions revealCe{ + nonStandaloneEnv.app(), activeNoopJournal()}; + revealCe.cacheUNLReport(ledger); + + ExtendedPosition commitPos{txSetHash}; + commitPos.myCommitment = commitment; + auto const commitSig = signPosition( + publicKey, secretKey, commitPos, 0, closeTime, prevLedger); + revealCe.harvestRngData( + nodeId, + publicKey, + commitPos, + 0, + closeTime, + prevLedger, + Slice(commitSig.data(), commitSig.size())); + + ExtendedPosition revealPos{txSetHash}; + revealPos.myReveal = reveal; + auto const revealSig = signPosition( + publicKey, secretKey, revealPos, 1, closeTime, prevLedger); + revealCe.harvestRngData( + nodeId, + publicKey, + revealPos, + 1, + closeTime, + prevLedger, + Slice(revealSig.data(), revealSig.size())); + 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); + } + } + + 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 zero entropy fallback"); + + 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.cacheUNLReport(ledger); + ce.setRngEnabledThisRound(true); + BEAST_EXPECT(ce.shouldZeroEntropy()); + + CanonicalTXSet retriableTxs{makeHash("rng-zero-fallback-salt")}; + auto const seq = ledger->seq() + 1; + ce.onPreBuild(retriableTxs, seq); + + auto const tx = singleCanonicalTx(retriableTxs); + BEAST_EXPECT(tx); + if (!tx) + return; + BEAST_EXPECT(tx->getTxnType() == ttCONSENSUS_ENTROPY); + BEAST_EXPECT(tx->getFieldU32(sfLedgerSequence) == seq); + BEAST_EXPECT(tx->getFieldH256(sfDigest) == uint256{}); + BEAST_EXPECT(tx->getFieldU16(sfEntropyCount) == 0); + } + + 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"); + auto const commitment = sha512Half(reveal, publicKey, seq); + + ConsensusExtensions ce{env.app(), activeNoopJournal()}; + ce.cacheUNLReport(ledger); + + ExtendedPosition commitPos{txSetHash}; + commitPos.myCommitment = commitment; + auto const commitSig = signPosition( + publicKey, secretKey, commitPos, 0, closeTime, prevLedger); + ce.harvestRngData( + nodeId, + publicKey, + commitPos, + 0, + closeTime, + prevLedger, + Slice(commitSig.data(), commitSig.size())); + BEAST_EXPECT(ce.hasQuorumOfCommits()); + + ExtendedPosition revealPos{txSetHash}; + revealPos.myReveal = reveal; + auto const revealSig = signPosition( + publicKey, secretKey, revealPos, 1, closeTime, prevLedger); + ce.harvestRngData( + nodeId, + publicKey, + revealPos, + 1, + closeTime, + prevLedger, + Slice(revealSig.data(), revealSig.size())); + BEAST_EXPECT(ce.hasMinimumReveals()); + + auto const entropySetHash = ce.buildEntropySet(seq); + BEAST_EXPECT(ce.isSidecarSet(entropySetHash)); + BEAST_EXPECT(!ce.shouldZeroEntropy()); + + CanonicalTXSet retriableTxs{makeHash("entropy-set-prebuild-salt")}; + ce.onPreBuild(retriableTxs, seq); + + 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); + } + + 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(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}); + 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); + 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); + + ce.onPreBuild(retriableTxs, seq); + BEAST_EXPECT( + std::distance(retriableTxs.begin(), retriableTxs.end()) == 1); + } + + 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{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(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() { @@ -1311,6 +2473,191 @@ class ConsensusExtensions_test : public beast::unit_test::suite 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()); + + protocol::TMProposeSet malformed; + malformed.add_exportsignatures(blob); + malformed.set_nodepubkey("bad", 3); + ce.onTrustedPeerMessage(malformed); + BEAST_EXPECT(ce.exportSigCollector().hasUnverifiedSignatures()); + } + + 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.shouldZeroEntropy()); + + ce.generateEntropySecret(); + BEAST_EXPECT(!ce.hasAnyReveals()); + ce.selfSeedReveal(); + BEAST_EXPECT(ce.pendingRevealCount() == 1); + + auto const [pk, _] = randomKeyPair(KeyType::secp256k1); + std::vector 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 @@ -1319,6 +2666,20 @@ public: testActiveValidatorViewBuilderPrefersUNLReport(); testActiveValidatorViewBuilderFallback(); testActiveValidatorViewAppliesNegativeUNL(); + testActiveValidatorViewNullSourceAndExpectedProposers(); + testExplicitFinalProposalTxSetBuildsEntropyTxn(); + testRuntimeConfigPolicyAccessors(); + testDecoratePositionGeneratesCommitment(); + testOnPreBuildInjectsZeroEntropyFallback(); + testOnPreBuildInjectsEntropySetEntropy(); + testProposalProofRoundTrip(); + testHarvestRngDataReplacementAndRejection(); + testExportSidecarBuildFetchAndMerge(); + testRngSidecarBuildFetchAndMerge(); + testRngSidecarRejectsInvalidFetchedEntries(); + testOnPreBuildInjectsStandaloneEntropy(); + testDiagnosticsJsonAndPositionLogging(); + testDecoratePositionSkipsWhenDisabled(); testExportSigGateRequiresQuorumAlignment(); testRngEntropyGateRequiresFullObservation(); testRngFastPathWaitsAfterEntropyPublish(); @@ -1345,6 +2706,9 @@ public: testParticipantDiagnosticsOnlyWhenExtensionEnabled(); testExportDisabledRoundClearsCollector(); testReplayedProposalHarvestsExportSigs(); + testWireProposalHarvestsExportSigs(); + testPublicHookNoopAndFailureBranches(); + testDecorateMessageStoresSelfProofs(); } }; diff --git a/src/test/consensus/Consensus_test.cpp b/src/test/consensus/Consensus_test.cpp index 31bba86c2..4a14378d0 100644 --- a/src/test/consensus/Consensus_test.cpp +++ b/src/test/consensus/Consensus_test.cpp @@ -38,6 +38,13 @@ public: { } + static void + enableSilentTracing(csf::Sim& sim) + { + sim.sink.silent(true); + sim.sink.threshold(beast::severities::kTrace); + } + void testShouldCloseLedger() { @@ -214,6 +221,7 @@ public: //@@start peers-agree ConsensusParms const parms{}; Sim sim; + enableSilentTracing(sim); PeerGroup peers = sim.createGroup(5); // Connected trust and network graphs with single fixed delay @@ -260,6 +268,7 @@ public: { ConsensusParms const parms{}; Sim sim; + enableSilentTracing(sim); PeerGroup slow = sim.createGroup(1); PeerGroup fast = sim.createGroup(4); PeerGroup network = fast + slow; @@ -318,6 +327,7 @@ public: ConsensusParms const parms{}; Sim sim; + enableSilentTracing(sim); PeerGroup slow = sim.createGroup(2); PeerGroup fast = sim.createGroup(4); PeerGroup network = fast + slow; @@ -447,6 +457,7 @@ public: ConsensusParms const parms{}; Sim sim; + enableSilentTracing(sim); PeerGroup groupA = sim.createGroup(2); PeerGroup groupB = sim.createGroup(2); @@ -480,6 +491,52 @@ public: } } + void + testBootstrapFastStart() + { + using namespace csf; + using namespace std::chrono; + testcase("bootstrap fast start"); + + ConsensusParms const parms{}; + Sim sim; + enableSilentTracing(sim); + PeerGroup peers = sim.createGroup(4); + peers.trustAndConnect( + peers, round(0.2 * parms.ledgerGRANULARITY)); + + for (Peer* peer : peers) + { + peer->ce().bootstrapFastStartEnabled_ = true; + peer->targetLedgers = + static_cast(parms.bootstrapStableRoundsRequired); + peer->start(); + + auto const json = peer->consensus.getJson(true); + BEAST_EXPECT(json.isMember("bootstrap_fast_start")); + BEAST_EXPECT(json["bootstrap_fast_start"].asBool()); + BEAST_EXPECT( + json["previous_mseconds"].asInt() == + parms.bootstrapRoundTimeSeed.count()); + BEAST_EXPECT(json["bootstrap_stable_rounds"].asInt() == 0); + } + + sim.scheduler.step(); + + if (BEAST_EXPECT(sim.synchronized())) + { + for (Peer* peer : peers) + { + BEAST_EXPECT( + peer->completedLedgers == + static_cast(parms.bootstrapStableRoundsRequired)); + auto const json = peer->consensus.getJson(true); + BEAST_EXPECT(!json.isMember("bootstrap_fast_start")); + BEAST_EXPECT(peer->prevRoundTime < parms.ledgerIDLE_INTERVAL); + } + } + } + void testWrongLCL() { @@ -520,6 +577,7 @@ public: //@@end wrong-lcl-scenario Sim sim; + enableSilentTracing(sim); PeerGroup minority = sim.createGroup(2); PeerGroup majorityA = sim.createGroup(3); @@ -624,6 +682,7 @@ public: // after it is already in the establish phase of the next round. Sim sim; + enableSilentTracing(sim); PeerGroup loner = sim.createGroup(1); PeerGroup friends = sim.createGroup(3); loner.trust(loner + friends); @@ -669,6 +728,7 @@ public: ConsensusParms parms; Sim sim; + enableSilentTracing(sim); // This requires a group of 4 fast and 2 slow peers to create a // situation in which a subset of peers requires seeing additional @@ -770,6 +830,7 @@ public: { ConsensusParms const parms{}; Sim sim; + enableSilentTracing(sim); std::uint32_t numA = (numPeers - overlap) / 2; std::uint32_t numB = numPeers - numA - overlap; @@ -828,6 +889,7 @@ public: ConsensusParms const parms{}; Sim sim; + enableSilentTracing(sim); PeerGroup validators = sim.createGroup(5); PeerGroup center = sim.createGroup(1); validators.trust(validators); @@ -943,6 +1005,7 @@ public: ConsensusParms const parms{}; Sim sim; + enableSilentTracing(sim); // Goes A->B->D PeerGroup groupABD = sim.createGroup(2); @@ -1066,6 +1129,7 @@ public: ConsensusParms const parms{}; Sim sim; + enableSilentTracing(sim); SimDuration delay = round(0.2 * parms.ledgerGRANULARITY); PeerGroup behind = sim.createGroup(3); @@ -1542,6 +1606,7 @@ public: testPeersAgree(); testSlowPeers(); testCloseTimeDisagree(); + testBootstrapFastStart(); testWrongLCL(); testConsensusCloseTimeRounding(); testFork(); diff --git a/src/test/csf/Peer.h b/src/test/csf/Peer.h index b6813d75d..b8280fd4f 100644 --- a/src/test/csf/Peer.h +++ b/src/test/csf/Peer.h @@ -367,6 +367,9 @@ struct Peer // Optional test hook: stay an active proposer but do not originate an // export signature, so tests can force sidecar-fetch-only convergence. bool suppressOwnExportSig_ = false; + // Optional test hook: exercise generic Consensus bootstrap timing + // without making the CSF runtime-config aware. + bool bootstrapFastStartEnabled_ = false; explicit Extensions(Peer& p) : peer(p), j_(p.j) { @@ -887,7 +890,7 @@ struct Peer bool bootstrapFastStartEnabled() const { - return false; + return bootstrapFastStartEnabled_; } bool shouldSendExplicitFinalProposal() const diff --git a/src/test/csf/Sim.h b/src/test/csf/Sim.h index 6b5092a89..a0dbc1f36 100644 --- a/src/test/csf/Sim.h +++ b/src/test/csf/Sim.h @@ -42,6 +42,7 @@ namespace csf { class BasicSink : public beast::Journal::Sink { Scheduler::clock_type const& clock_; + bool silent_ = false; public: BasicSink(Scheduler::clock_type const& clock) @@ -49,11 +50,19 @@ public: { } + void + silent(bool value) + { + silent_ = value; + } + void write(beast::severities::Severity level, std::string const& text) override { if (level < threshold()) return; + if (silent_) + return; std::cout << clock_.now().time_since_epoch().count() << " " << text << std::endl; diff --git a/src/test/overlay/ProposalPrecheck_test.cpp b/src/test/overlay/ProposalPrecheck_test.cpp new file mode 100644 index 000000000..cef00608d --- /dev/null +++ b/src/test/overlay/ProposalPrecheck_test.cpp @@ -0,0 +1,232 @@ +//------------------------------------------------------------------------------ +/* + 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 AUTHORS 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 +#include +#include + +#include +#include +#include + +namespace ripple { +namespace test { + +namespace { + +uint256 +makeHash(char const* label) +{ + return sha512Half(Slice(label, std::strlen(label))); +} + +void +setPreviousLedger(protocol::TMProposeSet& set) +{ + auto const prev = makeHash("proposal-precheck-prev"); + set.set_previousledger(prev.data(), prev.size()); +} + +void +setPosition(protocol::TMProposeSet& set, ExtendedPosition const& position) +{ + Serializer s; + position.add(s); + set.set_currenttxhash(s.data(), s.size()); +} + +} // namespace + +class ProposalPrecheck_test : public beast::unit_test::suite +{ +public: + void + run() override + { + using enum detail::ProposalPrecheckResult; + + testcase("legacy and extended ok"); + { + protocol::TMProposeSet set; + setPreviousLedger(set); + ExtendedPosition position{makeHash("legacy-position")}; + setPosition(set, position); + + auto const precheck = + detail::checkProposalExtensions(set, false, false); + BEAST_EXPECT(precheck.result == ok); + BEAST_EXPECT(precheck.position); + if (precheck.position) + BEAST_EXPECT(precheck.position->txSetHash == position.txSetHash); + } + + testcase("malformed hashes and extended payload"); + { + protocol::TMProposeSet set; + setPreviousLedger(set); + set.set_currenttxhash("short", 5); + BEAST_EXPECT( + detail::checkProposalExtensions(set, true, true).result == + badHashes); + + set.clear_currenttxhash(); + std::string malformed(uint256::size(), '\0'); + malformed.push_back(static_cast(0x80)); + set.set_currenttxhash(malformed.data(), malformed.size()); + BEAST_EXPECT( + detail::checkProposalExtensions(set, true, true).result == + badPosition); + + protocol::TMProposeSet badPrev; + ExtendedPosition position{makeHash("bad-prev-position")}; + setPosition(badPrev, position); + badPrev.set_previousledger("short", 5); + BEAST_EXPECT( + detail::checkProposalExtensions(badPrev, true, true).result == + badHashes); + } + + testcase("feature gating"); + { + protocol::TMProposeSet entropySet; + setPreviousLedger(entropySet); + ExtendedPosition entropy{makeHash("entropy-position")}; + entropy.myCommitment = makeHash("commitment"); + setPosition(entropySet, entropy); + BEAST_EXPECT( + detail::checkProposalExtensions(entropySet, false, true) + .result == entropyDisabled); + + protocol::TMProposeSet exportSet; + setPreviousLedger(exportSet); + ExtendedPosition exportPos{makeHash("export-position")}; + exportPos.exportSigSetHash = makeHash("export-sidecar"); + setPosition(exportSet, exportPos); + BEAST_EXPECT( + detail::checkProposalExtensions(exportSet, true, false) + .result == exportDisabled); + } + + testcase("export signature binding"); + { + protocol::TMProposeSet tooMany; + setPreviousLedger(tooMany); + ExtendedPosition position{makeHash("too-many-position")}; + setPosition(tooMany, position); + for (std::uint8_t i = 0; i <= ExportLimits::maxPendingExports; ++i) + tooMany.add_exportsignatures("sig"); + BEAST_EXPECT( + detail::checkProposalExtensions(tooMany, true, true).result == + tooManyExportSignatures); + + protocol::TMProposeSet unsignedSigs; + setPreviousLedger(unsignedSigs); + setPosition(unsignedSigs, position); + unsignedSigs.add_exportsignatures("sig"); + BEAST_EXPECT( + detail::checkProposalExtensions(unsignedSigs, true, true) + .result == unsignedExportSignatures); + + protocol::TMProposeSet mismatch; + setPreviousLedger(mismatch); + ExtendedPosition mismatchPos{makeHash("mismatch-position")}; + mismatchPos.exportSignaturesHash = + proposalExportSignaturesHash(std::vector{"one"}); + setPosition(mismatch, mismatchPos); + mismatch.add_exportsignatures("two"); + BEAST_EXPECT( + detail::checkProposalExtensions(mismatch, true, true).result == + exportSignaturesHashMismatch); + + protocol::TMProposeSet missing; + setPreviousLedger(missing); + setPosition(missing, mismatchPos); + BEAST_EXPECT( + detail::checkProposalExtensions(missing, true, true).result == + missingExportSignatures); + + protocol::TMProposeSet okSet; + setPreviousLedger(okSet); + std::vector const sigs{"signed-export"}; + ExtendedPosition okPos{makeHash("export-ok-position")}; + okPos.exportSignaturesHash = proposalExportSignaturesHash(sigs); + setPosition(okSet, okPos); + okSet.add_exportsignatures(sigs.front()); + BEAST_EXPECT( + detail::checkProposalExtensions(okSet, true, true).result == + ok); + } + + testcase("rejection diagnostics"); + { + BEAST_EXPECT(!detail::proposalPrecheckRejection(ok)); + + auto const check = [&](detail::ProposalPrecheckResult result, + char const* logMessage, + char const* feeReason) { + auto const rejection = detail::proposalPrecheckRejection(result); + BEAST_EXPECT(rejection); + if (rejection) + { + BEAST_EXPECT( + std::string_view{rejection->logMessage} == + logMessage); + BEAST_EXPECT( + std::string_view{rejection->feeReason} == feeReason); + } + }; + + check(badHashes, "Proposal: malformed", "bad hashes"); + check( + badPosition, + "Proposal: malformed extended position", + "bad proposal position"); + check( + entropyDisabled, + "Proposal: entropy fields while featureConsensusEntropy disabled", + "entropy fields disabled"); + check( + exportDisabled, + "Proposal: export fields while featureExport disabled", + "export fields disabled"); + check( + tooManyExportSignatures, + "Proposal: too many export signatures", + "too many export sigs"); + check( + unsignedExportSignatures, + "Proposal: unsigned export signatures", + "unsigned export sigs"); + check( + exportSignaturesHashMismatch, + "Proposal: export signatures hash mismatch", + "export sig hash mismatch"); + check( + missingExportSignatures, + "Proposal: missing signed export signatures", + "missing export sigs"); + BEAST_EXPECT(!detail::proposalPrecheckRejection( + static_cast(255))); + } + } +}; + +BEAST_DEFINE_TESTSUITE(ProposalPrecheck, overlay, ripple); + +} // namespace test +} // namespace ripple diff --git a/src/test/rpc/Connect_test.cpp b/src/test/rpc/Connect_test.cpp index 8f3fe26ad..771174376 100644 --- a/src/test/rpc/Connect_test.cpp +++ b/src/test/rpc/Connect_test.cpp @@ -18,6 +18,7 @@ //============================================================================== #include +#include #include namespace ripple { @@ -46,11 +47,64 @@ class Connect_test : public beast::unit_test::suite } } + void + testDisconnect() + { + testcase("Disconnect"); + + using namespace test::jtx; + + Env env{*this}; + const_cast(env.app().config()).setupControl(true, true, false); + BEAST_EXPECT(!env.app().config().standalone()); + + { + auto const result = env.rpc("json", "disconnect", "{}"); + BEAST_EXPECT(result[jss::result][jss::status] == "error"); + BEAST_EXPECT(result[jss::result].isMember(jss::error)); + BEAST_EXPECT( + result[jss::result][jss::error_message] + .asString() + .find("ip") != std::string::npos); + } + + { + auto const result = env.rpc( + "json", + "disconnect", + R"({"ip":"127.0.0.1","port":"bad"})"); + BEAST_EXPECT(result[jss::result][jss::status] == "error"); + BEAST_EXPECT(result[jss::result][jss::error] == "invalidParams"); + } + + { + auto const result = + env.rpc("json", "disconnect", R"({"ip":"0.0.0.0"})"); + BEAST_EXPECT(result[jss::result][jss::status] == "success"); + BEAST_EXPECT( + result[jss::result][jss::message].asString().find( + "port: 21337") != std::string::npos); + BEAST_EXPECT( + result[jss::result][jss::message].asString().find( + "peers: 0") != std::string::npos); + } + + { + auto const result = env.rpc( + "json", "disconnect", R"({"ip":"127.0.0.1","port":6000})"); + BEAST_EXPECT(result[jss::result][jss::status] == "success"); + BEAST_EXPECT( + result[jss::result][jss::message].asString().find( + "port: 6000") != std::string::npos); + } + } + public: void run() override { testErrors(); + testDisconnect(); } }; diff --git a/src/test/rpc/RuntimeConfig_test.cpp b/src/test/rpc/RuntimeConfig_test.cpp index 2b690e8c8..07f97f29d 100644 --- a/src/test/rpc/RuntimeConfig_test.cpp +++ b/src/test/rpc/RuntimeConfig_test.cpp @@ -850,6 +850,38 @@ class RuntimeConfig_test : public beast::unit_test::suite BEAST_EXPECT(cfg->rngClaimDropPctX100 == 0); // clamped to 0% } + void + testRngAndExportRuntimeToggles() + { + testcase("rng/export runtime toggles round-trip"); + using namespace test::jtx; + Env env{*this}; + + Json::Value params; + params["set"] = Json::objectValue; + params["set"]["*"] = Json::objectValue; + params["set"]["*"]["bootstrap_fast_start"] = false; + params["set"]["*"]["rng_poll_ms"] = 5; + params["set"]["*"]["no_export_sig"] = true; + auto const result = runtimeConfig(env, params); + + auto const& global = result["configs"]["*"]; + BEAST_EXPECT(global["bootstrap_fast_start"].asBool() == false); + BEAST_EXPECT(global["rng_poll_ms"].asInt() == 50); + BEAST_EXPECT(global["no_export_sig"].asBool() == true); + + auto const cfg = env.app().getRuntimeConfig().getConfig("*"); + BEAST_EXPECT(cfg.has_value()); + if (cfg) + { + BEAST_EXPECT(cfg->bootstrapFastStart.has_value()); + BEAST_EXPECT(*cfg->bootstrapFastStart == false); + BEAST_EXPECT(cfg->rngPollMs == 50); + BEAST_EXPECT(cfg->noExportSig.has_value()); + BEAST_EXPECT(*cfg->noExportSig == true); + } + } + void testExplicitFinalProposalToggle() { @@ -968,6 +1000,7 @@ public: testDropPctClamping(); testRngClaimDropPct(); testRngClaimDropPctClamping(); + testRngAndExportRuntimeToggles(); testExplicitFinalProposalToggle(); testPerPeerClearInheritedFilter(); } diff --git a/src/test/shamap/SHAMap_test.cpp b/src/test/shamap/SHAMap_test.cpp index c8c877935..24cc7210c 100644 --- a/src/test/shamap/SHAMap_test.cpp +++ b/src/test/shamap/SHAMap_test.cpp @@ -20,10 +20,12 @@ #include #include #include +#include #include #include #include #include +#include namespace ripple { namespace tests { @@ -122,6 +124,65 @@ public: run(true, journal); run(false, journal); + testSidecarLeaf(journal); + } + + void + testSidecarLeaf(beast::Journal const& journal) + { + testcase("sidecar leaf"); + + Blob const payload{ + 0x53, 0x49, 0x44, 0x45, 0x43, 0x41, 0x52, 0x00, 0x01, 0x02, + 0x03, 0x04}; + auto const itemHash = sha512Half(HashPrefix::sidecar, makeSlice(payload)); + auto const item = make_shamapitem(itemHash, makeSlice(payload)); + + SHAMapSidecarLeafNode leaf{item, 7}; + BEAST_EXPECT(leaf.cowid() == 7); + BEAST_EXPECT(leaf.getType() == SHAMapNodeType::tnSIDECAR); + BEAST_EXPECT(leaf.getHash() == SHAMapHash{itemHash}); + BEAST_EXPECT(leaf.peekItem()->slice() == makeSlice(payload)); + + auto const label = leaf.getString(SHAMapNodeID{}); + BEAST_EXPECT(label.find(",sidecar") != std::string::npos); + + auto const cloned = leaf.clone(11); + BEAST_EXPECT(cloned->cowid() == 11); + BEAST_EXPECT(cloned->getType() == SHAMapNodeType::tnSIDECAR); + BEAST_EXPECT(cloned->getHash() == leaf.getHash()); + + Serializer wire; + leaf.serializeForWire(wire); + auto const wireSlice = wire.slice(); + auto const wirePayload = Slice{wireSlice.data(), payload.size()}; + BEAST_EXPECT(wireSlice.size() == payload.size() + 1); + BEAST_EXPECT(wirePayload == makeSlice(payload)); + BEAST_EXPECT(wireSlice[wireSlice.size() - 1] == wireTypeSidecar); + + auto const fromWire = SHAMapTreeNode::makeFromWire(wireSlice); + BEAST_EXPECT(fromWire->getType() == SHAMapNodeType::tnSIDECAR); + BEAST_EXPECT(fromWire->getHash() == leaf.getHash()); + + Serializer prefixed; + leaf.serializeWithPrefix(prefixed); + auto const fromPrefix = + SHAMapTreeNode::makeFromPrefix(prefixed.slice(), leaf.getHash()); + BEAST_EXPECT(fromPrefix->getType() == SHAMapNodeType::tnSIDECAR); + BEAST_EXPECT(fromPrefix->getHash() == leaf.getHash()); + + tests::TestNodeFamily f(journal); + SHAMap sidecars{SHAMapType::SIDECAR, f}; + BEAST_EXPECT(sidecars.addItem( + SHAMapNodeType::tnSIDECAR, make_shamapitem(*item))); + sidecars.invariants(); + BEAST_EXPECT(sidecars.hasItem(itemHash)); + BEAST_EXPECT(sidecars.peekItem(itemHash)->slice() == makeSlice(payload)); + + SHAMapMissingNode missing{SHAMapType::SIDECAR, leaf.getHash()}; + BEAST_EXPECT( + std::string{missing.what()}.find("Sidecar Tree") != + std::string::npos); } void diff --git a/src/xrpld/app/consensus/ExportSignatureHarvester.cpp b/src/xrpld/app/consensus/ExportSignatureHarvester.cpp index f2c401c1b..af323bd39 100644 --- a/src/xrpld/app/consensus/ExportSignatureHarvester.cpp +++ b/src/xrpld/app/consensus/ExportSignatureHarvester.cpp @@ -146,6 +146,10 @@ harvestExportSignatures( continue; auto const fullSlice = makeSlice(blob); + auto const pkSlice = fullSlice.substr(32, 33); + if (!publicKeyType(pkSlice)) + continue; + auto const sigSlice = fullSlice.substr(65); auto const txIt = input.exportTxns.find(txHash); diff --git a/src/xrpld/overlay/detail/PeerImp.cpp b/src/xrpld/overlay/detail/PeerImp.cpp index e51829dc1..8d91b74e3 100644 --- a/src/xrpld/overlay/detail/PeerImp.cpp +++ b/src/xrpld/overlay/detail/PeerImp.cpp @@ -33,6 +33,7 @@ #include #include #include +#include #include #include #include @@ -40,7 +41,6 @@ #include #include #include -#include #include #include @@ -1721,14 +1721,6 @@ PeerImp::onMessage(std::shared_ptr const& m) return; } - if (set.currenttxhash().size() < uint256::size() || - !stringIsUint256Sized(set.previousledger())) - { - JLOG(p_journal_.warn()) << "Proposal: malformed"; - fee_.update(Resource::feeMalformedRequest, "bad hashes"); - return; - } - // RH TODO: when isTrusted = false we should probably also cache a key // suppression for 30 seconds to avoid doing a relatively expensive lookup // every time a spam packet is received @@ -1741,85 +1733,26 @@ PeerImp::onMessage(std::shared_ptr const& m) if (!isTrusted && app_.config().RELAY_UNTRUSTED_PROPOSALS == -1) return; - auto const currentPosSlice = makeSlice(set.currenttxhash()); - SerialIter currentPosSit{currentPosSlice}; - auto const parsedPosition = ExtendedPosition::fromSerialIter( - currentPosSit, set.currenttxhash().size()); - if (!parsedPosition) + auto const openLedger = app_.openLedger().current(); + auto const precheck = detail::checkProposalExtensions( + set, + openLedger && openLedger->rules().enabled(featureConsensusEntropy), + openLedger && openLedger->rules().enabled(featureExport)); + if (auto const rejection = + detail::proposalPrecheckRejection(precheck.result)) { - JLOG(p_journal_.warn()) << "Proposal: malformed extended position"; - fee_.update(Resource::feeMalformedRequest, "bad proposal position"); - return; - } - bool const hasEntropyMaterial = parsedPosition->commitSetHash || - parsedPosition->entropySetHash || parsedPosition->myCommitment || - parsedPosition->myReveal; - bool const hasExportMaterial = parsedPosition->exportSigSetHash || - parsedPosition->exportSignaturesHash || set.exportsignatures_size() > 0; - if (hasEntropyMaterial || hasExportMaterial) - { - auto const openLedger = app_.openLedger().current(); - bool const entropyEnabled = - openLedger && openLedger->rules().enabled(featureConsensusEntropy); - bool const exportEnabled = - openLedger && openLedger->rules().enabled(featureExport); - if (hasEntropyMaterial && !entropyEnabled) - { - JLOG(p_journal_.warn()) - << "Proposal: entropy fields while featureConsensusEntropy " - "disabled"; - fee_.update( - Resource::feeMalformedRequest, "entropy fields disabled"); - return; - } - if (hasExportMaterial && !exportEnabled) - { - JLOG(p_journal_.warn()) - << "Proposal: export fields while featureExport disabled"; - fee_.update( - Resource::feeMalformedRequest, "export fields disabled"); - return; - } - } - if (set.exportsignatures_size() > ExportLimits::maxPendingExports) - { - JLOG(p_journal_.warn()) << "Proposal: too many export signatures"; - fee_.update(Resource::feeMalformedRequest, "too many export sigs"); - return; - } - - if (set.exportsignatures_size() > 0) - { - if (!parsedPosition->exportSignaturesHash) - { - JLOG(p_journal_.warn()) << "Proposal: unsigned export signatures"; - fee_.update(Resource::feeMalformedRequest, "unsigned export sigs"); - return; - } - - if (proposalExportSignaturesHash(set.exportsignatures()) != - *parsedPosition->exportSignaturesHash) - { - JLOG(p_journal_.warn()) - << "Proposal: export signatures hash mismatch"; - fee_.update( - Resource::feeMalformedRequest, "export sig hash mismatch"); - return; - } - } - else if (parsedPosition->exportSignaturesHash) - { - JLOG(p_journal_.warn()) << "Proposal: missing signed export signatures"; - fee_.update(Resource::feeMalformedRequest, "missing export sigs"); + JLOG(p_journal_.warn()) << rejection->logMessage; + fee_.update(Resource::feeMalformedRequest, rejection->feeReason); return; } + auto const& parsedPosition = *precheck.position; uint256 const prevLedger{set.previousledger()}; NetClock::time_point const closeTime{NetClock::duration{set.closetime()}}; uint256 const suppression = proposalUniqueId( - *parsedPosition, + parsedPosition, prevLedger, set.proposeseq(), closeTime, @@ -1876,7 +1809,7 @@ PeerImp::onMessage(std::shared_ptr const& m) RCLCxPeerPos::Proposal{ prevLedger, set.proposeseq(), - *parsedPosition, + parsedPosition, closeTime, app_.timeKeeper().closeTime(), calcNodeID(app_.validatorManifests().getMasterKey(publicKey))}, diff --git a/src/xrpld/overlay/detail/ProposalPrecheck.h b/src/xrpld/overlay/detail/ProposalPrecheck.h new file mode 100644 index 000000000..c22ba2fe1 --- /dev/null +++ b/src/xrpld/overlay/detail/ProposalPrecheck.h @@ -0,0 +1,138 @@ +#ifndef RIPPLE_OVERLAY_DETAIL_PROPOSALPRECHECK_H_INCLUDED +#define RIPPLE_OVERLAY_DETAIL_PROPOSALPRECHECK_H_INCLUDED + +#include +#include +#include +#include + +#include + +namespace ripple { +namespace detail { + +enum class ProposalPrecheckResult { + ok, + badHashes, + badPosition, + entropyDisabled, + exportDisabled, + tooManyExportSignatures, + unsignedExportSignatures, + exportSignaturesHashMismatch, + missingExportSignatures +}; + +struct ProposalPrecheck +{ + ProposalPrecheckResult result = ProposalPrecheckResult::ok; + std::optional position; +}; + +struct ProposalPrecheckRejection +{ + char const* logMessage; + char const* feeReason; +}; + +inline std::optional +proposalPrecheckRejection(ProposalPrecheckResult result) +{ + switch (result) + { + case ProposalPrecheckResult::ok: + return std::nullopt; + case ProposalPrecheckResult::badHashes: + return ProposalPrecheckRejection{ + "Proposal: malformed", "bad hashes"}; + case ProposalPrecheckResult::badPosition: + return ProposalPrecheckRejection{ + "Proposal: malformed extended position", + "bad proposal position"}; + case ProposalPrecheckResult::entropyDisabled: + return ProposalPrecheckRejection{ + "Proposal: entropy fields while featureConsensusEntropy disabled", + "entropy fields disabled"}; + case ProposalPrecheckResult::exportDisabled: + return ProposalPrecheckRejection{ + "Proposal: export fields while featureExport disabled", + "export fields disabled"}; + case ProposalPrecheckResult::tooManyExportSignatures: + return ProposalPrecheckRejection{ + "Proposal: too many export signatures", "too many export sigs"}; + case ProposalPrecheckResult::unsignedExportSignatures: + return ProposalPrecheckRejection{ + "Proposal: unsigned export signatures", "unsigned export sigs"}; + case ProposalPrecheckResult::exportSignaturesHashMismatch: + return ProposalPrecheckRejection{ + "Proposal: export signatures hash mismatch", + "export sig hash mismatch"}; + case ProposalPrecheckResult::missingExportSignatures: + return ProposalPrecheckRejection{ + "Proposal: missing signed export signatures", + "missing export sigs"}; + } + return std::nullopt; +} + +inline ProposalPrecheck +checkProposalExtensions( + protocol::TMProposeSet const& set, + bool entropyEnabled, + bool exportEnabled) +{ + if (set.currenttxhash().size() < uint256::size() || + set.previousledger().size() != uint256::size()) + { + return {ProposalPrecheckResult::badHashes, std::nullopt}; + } + + auto const currentPosSlice = makeSlice(set.currenttxhash()); + SerialIter currentPosSit{currentPosSlice}; + auto const parsedPosition = ExtendedPosition::fromSerialIter( + currentPosSit, set.currenttxhash().size()); + if (!parsedPosition) + return {ProposalPrecheckResult::badPosition, std::nullopt}; + + bool const hasEntropyMaterial = parsedPosition->commitSetHash || + parsedPosition->entropySetHash || parsedPosition->myCommitment || + parsedPosition->myReveal; + bool const hasExportMaterial = parsedPosition->exportSigSetHash || + parsedPosition->exportSignaturesHash || set.exportsignatures_size() > 0; + if (hasEntropyMaterial && !entropyEnabled) + return {ProposalPrecheckResult::entropyDisabled, parsedPosition}; + if (hasExportMaterial && !exportEnabled) + return {ProposalPrecheckResult::exportDisabled, parsedPosition}; + + if (set.exportsignatures_size() > ExportLimits::maxPendingExports) + return { + ProposalPrecheckResult::tooManyExportSignatures, parsedPosition}; + + if (set.exportsignatures_size() > 0) + { + if (!parsedPosition->exportSignaturesHash) + return { + ProposalPrecheckResult::unsignedExportSignatures, + parsedPosition}; + + if (proposalExportSignaturesHash(set.exportsignatures()) != + *parsedPosition->exportSignaturesHash) + { + return { + ProposalPrecheckResult::exportSignaturesHashMismatch, + parsedPosition}; + } + } + else if (parsedPosition->exportSignaturesHash) + { + return { + ProposalPrecheckResult::missingExportSignatures, parsedPosition}; + } + + return {ProposalPrecheckResult::ok, parsedPosition}; +} + +} // namespace detail +} // namespace ripple + +#endif