#include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include namespace ripple::test { class SteppingExtensions_test : public beast::unit_test::suite { static constexpr std::uint32_t networkID = 21337; static constexpr std::uint32_t observer = 3; // The first flag vote has fewer than 256 ancestors and only establishes // validation retention. The second can score a complete history window. static constexpr std::uint32_t warmLedger = 2 * FLAG_LEDGER_INTERVAL + 1; enum class Fault { none, noDirectShares, noProposalShares, noShares, duplicateTraffic, slowObserver, lateValidations, veryLateValidations, slowValidator, slowValidatorIdleExport }; struct Observations { // Seq, parent hash, built hash, captured before acquisition can replace // the node's by-sequence ledger lookup. A set deduplicates peer sends. using Build = std::tuple; std::array, 4> builds; std::array, 4> acquiredHashes; std::array secrets{}; std::array directFrames{}; std::array proposalFrames{}; std::array exportRootFrames{}; std::uint32_t droppedDirect = 0; std::uint32_t droppedProposals = 0; std::uint32_t duplicatedFrames = 0; std::uint32_t delayedValidations = 0; std::uint32_t delayedValidatorTraffic = 0; std::uint32_t validationGap = 0; std::uint32_t validatedAheadOfClosed = 0; }; template static std::shared_ptr decodeFrame(SimPipe::Frame frame) { if (frame.empty()) throw std::logic_error("Empty fixture frame"); auto const buffer = boost::asio::buffer(frame.data(), frame.size()); boost::system::error_code ec; auto const header = ripple::detail::parseMessageHeader(ec, buffer, frame.size()); if (ec || !header || header->total_wire_size != frame.size()) throw std::logic_error("Invalid fixture frame header"); auto result = ripple::detail::parseMessageContent(*header, buffer); if (!result) throw std::logic_error("Invalid fixture frame payload"); return result; } struct World { SteppingNetwork& net; bool rng; bool exportEnabled; std::shared_ptr observed = std::make_shared(); jtx::Account owner{"dsf-export-owner"}; jtx::Account destination{"dsf-export-destination"}; World(SteppingNetwork& network, bool rngOn, bool exportOn) : net(network), rng(rngOn), exportEnabled(exportOn) { net.configureNodes([stats = observed, rngOn, exportOn]( std::uint32_t id, Config& cfg) { cfg.NETWORK_ID = networkID; // Let the real flag-ledger vote produce the common active view. cfg.features.insert(featureNegativeUNL); cfg.features.insert(featureXahauGenesis); if (rngOn) cfg.features.insert(featureConsensusEntropy); if (exportOn) cfg.features.insert(featureExport); cfg.harnessEntropySecret = [stats, id]( uint256 const& parent, std::uint32_t seq) { ++stats->secrets.at(id); return sha512Half( std::string{"DSF entropy fixture"}, id, parent, seq); }; cfg.harnessPeerMessage = [stats, id]( std::uint16_t type, std::string const&, std::uint32_t, beast::IP::Endpoint const&, ::google::protobuf::Message const& msg) { if (type == protocol::mtEXPORT_SHARES) ++stats->directFrames.at(id); if (type == protocol::mtLEDGER_DATA) { auto const& data = static_cast(msg); if (data.ledgerhash().size() == uint256::bytes) { uint256 hash; std::copy( data.ledgerhash().begin(), data.ledgerhash().end(), hash.begin()); stats->acquiredHashes.at(id).insert(hash); } } if (type == protocol::mtPROPOSE_LEDGER) { auto const& proposal = static_cast(msg); if (proposal.exportsignatures_size()) ++stats->proposalFrames.at(id); SerialIter iter{ makeSlice(proposal.currenttxhash())}; auto const position = ExtendedPosition::fromSerialIter( iter, proposal.currenttxhash().size()); if (position && position->exportSigSetHash) ++stats->exportRootFrames.at(id); } }; }); net.validators(3); net.observer(); net.mesh(); for (std::uint32_t id = 0; id <= observer; ++id) { auto const prior = net.node(id).app().config().harnessPeerSend; net.raw().setPeerSendHook( id, [stats = observed, id, prior]( std::uint16_t type, std::string const& name, std::uint32_t peer, beast::IP::Endpoint const& remote, std::string const& stage, Message& message) { if (prior) prior(type, name, peer, remote, stage, message); if (type != protocol::mtSTATUS_CHANGE || stage != "call") return; // Uncompressed protocol frames have a six-byte header. auto const& frame = message.getBuffer(compression::Compressed::Off); protocol::TMStatusChange status; if (frame.size() < 6 || !status.ParseFromArray( frame.data() + 6, static_cast(frame.size() - 6))) throw std::logic_error( "Invalid fixture status frame"); if (status.newevent() != protocol::neACCEPTED_LEDGER || status.ledgerseq() <= warmLedger + 2) return; if (status.ledgerhashprevious().size() != uint256::bytes || status.ledgerhash().size() != uint256::bytes) throw std::logic_error( "Invalid fixture status hashes"); uint256 parent, hash; std::copy( status.ledgerhashprevious().begin(), status.ledgerhashprevious().end(), parent.begin()); std::copy( status.ledgerhash().begin(), status.ledgerhash().end(), hash.begin()); stats->builds.at(id).emplace( status.ledgerseq(), parent, hash); }); } } std::shared_ptr submit(std::uint32_t node, Json::Value tx, jtx::Account const& signer) { tx[jss::NetworkID] = networkID; return net.submit(node, std::move(tx), signer); } Json::Value intent(std::uint32_t ticket = 1) { std::vector keys; for (std::uint32_t i = 0; i < observer; ++i) keys.push_back( net.node(i).app().getValidatorKeys().keys->masterPublicKey); auto const roster = canonicalizeExportCommittee( makeSlice(serializeExportCommittee(keys))); if (!roster) throw std::logic_error("Invalid fixture committee"); STObject inner(sfExportedTxn); inner.setFieldU16(sfTransactionType, ttPAYMENT); inner.setFieldU32(sfFlags, tfFullyCanonicalSig); inner.setFieldU32(sfSequence, 0); inner.setFieldU32(sfTicketSequence, ticket); inner.setFieldU32(sfLastLedgerSequence, 1'000'000); inner.setFieldAmount(sfAmount, XRPAmount{1'000'000}); inner.setFieldAmount(sfFee, XRPAmount{1000}); inner.setFieldVL(sfSigningPubKey, Blob{}); inner.setAccountID(sfAccount, owner.id()); inner.setAccountID(sfDestination, destination.id()); Json::Value tx; tx[jss::TransactionType] = jss::Export; tx[jss::Account] = owner.human(); tx[jss::Fee] = "1000000"; tx[jss::LastLedgerSequence] = net.node(observer).app().openLedger().current()->seq() + ExportLimits::maxAdmissionWindowLedgers; tx[sfExportedTxn.jsonName] = inner.getJson(JsonOptions::none); tx[sfExportCommittee.jsonName] = strHex(*roster); tx[sfExportCommitteeHash.jsonName] = to_string(exportCommitteeHash(makeSlice(*roster))); return tx; } }; bool ready(World& world) { auto& net = world.net; if (!BEAST_EXPECT(net.allUp() && net.meshReady())) return false; net.runTo( warmLedger, SteppingNetwork::RunBudget{(warmLedger + 32) * 60, 2'000'000}); if (!BEAST_EXPECT(net.minValidatedSeq() >= warmLedger)) { log << net.jobDiagnostics() << std::endl; return false; } for (std::uint32_t i = 0; i <= observer; ++i) { auto const ledger = net.ledger(i, warmLedger); if (!BEAST_EXPECT(ledger != nullptr)) return false; BEAST_EXPECT( ledger->rules().enabled(featureConsensusEntropy) == world.rng); BEAST_EXPECT( ledger->rules().enabled(featureExport) == world.exportEnabled); auto const report = ledger->read(keylet::UNLReport()); if (!BEAST_EXPECT( report && report->getFieldArray(sfActiveValidators).size() == observer)) { log << "active-view warmup: node=" << i << " seq=" << warmLedger << " report=" << static_cast(report) << " members=" << (report ? report->getFieldArray(sfActiveValidators).size() : 0) << std::endl; return false; } } BEAST_EXPECT(net.ledgersAgree(warmLedger)); BEAST_EXPECT(!net.node(observer).app().getValidatorKeys().keys); BEAST_EXPECT(world.observed->secrets[observer] == 0); return true; } std::uint32_t witnessAt(SteppingNetwork& net, uint256 const& origin, std::uint32_t from) { for (auto seq = from; seq <= net.validSeq(observer); ++seq) if (auto const ledger = net.ledger(observer, seq)) for (auto const& [tx, meta] : ledger->txs) if (tx->getTxnType() == ttEXPORT_SIGNATURES && tx->getFieldH256(sfTransactionHash) == origin) return seq; return 0; } std::optional> scenario( SteppingNetwork& net, bool rng, bool exportOn, Fault fault = Fault::none) { World world(net, rng, exportOn); if (!ready(world)) return std::nullopt; auto const funding = world.submit( observer, jtx::pay(jtx::Account::master, world.owner, jtx::XRP(10'000)), jtx::Account::master); if (!BEAST_EXPECT(funding->getResult() == tesSUCCESS)) return std::nullopt; net.runTo(warmLedger + 2); if (!BEAST_EXPECT(net.minValidatedSeq() >= warmLedger + 2)) return std::nullopt; using namespace std::chrono_literals; net.recordChainHistory(); auto const stats = world.observed; bool const sluggish = fault == Fault::slowValidator || fault == Fault::slowValidatorIdleExport; bool const late = fault == Fault::lateValidations || fault == Fault::veryLateValidations; bool const createIntent = exportOn && fault != Fault::slowValidatorIdleExport; if (fault == Fault::noDirectShares || late || fault == Fault::duplicateTraffic) for (std::uint32_t from = 0; from < observer; ++from) net.faultLink( from, observer, [stats, fault](std::uint16_t type, std::size_t) { SimFault f; if (fault == Fault::noDirectShares && type == protocol::mtEXPORT_SHARES) { ++stats->droppedDirect; f.drop = true; } if (fault == Fault::lateValidations && type == protocol::mtVALIDATION) { ++stats->delayedValidations; f.delay = 5s; } if (fault == Fault::veryLateValidations && type == protocol::mtVALIDATION) { ++stats->delayedValidations; f.delay = 20s; } if (fault == Fault::duplicateTraffic && (type == protocol::mtEXPORT_SHARES || type == protocol::mtPROPOSE_LEDGER || type == protocol::mtVALIDATION)) { ++stats->duplicatedFrames; f.duplicates = 2; } return f; }); if (fault == Fault::noProposalShares || fault == Fault::noShares) for (std::uint32_t from = 0; from < observer; ++from) net.faultFrames( from, observer, [stats, fault](std::uint16_t type, SimPipe::Frame bytes) { SimFault f; if (type == protocol::mtPROPOSE_LEDGER && decodeFrame(bytes) ->exportsignatures_size() != 0) { ++stats->droppedProposals; f.drop = true; } if (fault == Fault::noShares && type == protocol::mtEXPORT_SHARES) { ++stats->droppedDirect; f.drop = true; } return f; }); if (fault == Fault::slowObserver) net.lagAccept(observer, 5s); if (sluggish) { net.lagAccept(1, 2s); for (std::uint32_t to = 0; to <= observer; ++to) if (to != 1) net.faultLink(1, to, [stats](std::uint16_t, std::size_t) { ++stats->delayedValidatorTraffic; SimFault f; f.delay = 2500ms; return f; }); } if (fault != Fault::none) { // Observe the impaired interval, then heal. These probes read // ledger cursors only; they do not alter node time or consensus. for (int second = 1; second < 16; ++second) net.in(std::chrono::seconds{second}, observer, [&net, stats]() { auto const v = net.validSeq(observer); auto const c = net.closedSeq(observer); if (v > c) stats->validatedAheadOfClosed = std::max(stats->validatedAheadOfClosed, v - c); for (std::uint32_t i = 0; i < observer; ++i) if (net.validSeq(i) > v) stats->validationGap = std::max( stats->validationGap, net.validSeq(i) - v); }); net.in(16s, observer, [&net, late, sluggish]() { net.clearLag(observer).clearLag(1); if (late) for (std::uint32_t from = 0; from < observer; ++from) net.faultLink(from, observer, {}); if (sluggish) for (std::uint32_t to = 0; to <= observer; ++to) if (to != 1) net.faultLink(1, to, {}); }); } auto const payment = world.submit( observer, jtx::pay(world.owner, world.destination, jtx::XRP(1000)), world.owner); if (!BEAST_EXPECT(payment->getResult() == tesSUCCESS)) return std::nullopt; std::optional origin; if (createIntent) { auto const tx = world.submit(observer, world.intent(), world.owner); if (!BEAST_EXPECT(tx->getResult() == tesSUCCESS)) return std::nullopt; origin = tx->getID(); } auto const target = warmLedger + 8; net.runTo( target, SteppingNetwork::RunBudget{1200, 1'000'000}, SteppingNetwork::Cadence{sluggish ? 250ms : 1000ms}); if (!BEAST_EXPECT(net.minValidatedSeq() >= target)) return std::nullopt; BEAST_EXPECT(net.ledgersAgree(target)); BEAST_EXPECT(net.validatedForkFree()); BEAST_EXPECT(net.offThreadJobs() == 0); BEAST_EXPECT(net.failedJobs() == 0); std::uint32_t localMismatches = 0; std::uint32_t localBuilds = 0; for (std::uint32_t i = 0; i <= observer; ++i) for (auto const& [seq, parent, hash] : stats->builds[i]) if (seq <= target && parent == net.ledgerHash(0, seq - 1)) { ++localBuilds; localMismatches += hash != net.ledgerHash(0, seq); } BEAST_EXPECT(localBuilds != 0); if (fault == Fault::none || fault == Fault::noDirectShares || fault == Fault::noProposalShares || fault == Fault::duplicateTraffic || fault == Fault::slowObserver) BEAST_EXPECT(localMismatches == 0); if (sluggish) BEAST_EXPECT(localMismatches != 0); std::map validatedHistory; for (std::uint32_t i = 0; i <= observer; ++i) { std::uint32_t last = 0; for (auto const& sample : net.chainHistory(i)) { BEAST_EXPECT(sample.validSeq >= last); last = sample.validSeq; if (sample.validSeq) { auto const [it, inserted] = validatedHistory.emplace( sample.validSeq, sample.validHash); BEAST_EXPECT(inserted || it->second == sample.validHash); } } } if (fault == Fault::slowObserver) BEAST_EXPECT(stats->validatedAheadOfClosed != 0); if (late) { BEAST_EXPECT(stats->delayedValidations != 0); BEAST_EXPECT(stats->validationGap != 0); } if (fault == Fault::slowObserver || sluggish) BEAST_EXPECT( net.jobDiagnostics().find("queued:lagged JtAccept") != std::string::npos); BEAST_EXPECT(world.observed->secrets[observer] == 0); for (std::uint32_t i = 0; i < observer; ++i) BEAST_EXPECT((world.observed->secrets[i] != 0) == rng); bool entropySeen = false; bool partialEntropySeen = false; bool paymentSeen = false; bool witnessSeen = false; std::uint32_t witnesses = 0; std::vector outcome; for (auto seq = warmLedger; seq <= target; ++seq) { auto const ledger = net.ledger(observer, seq); if (!BEAST_EXPECT(ledger != nullptr)) return std::nullopt; for (std::uint32_t i = 0; i < observer; ++i) BEAST_EXPECT(net.ledgerHash(i, seq) == ledger->info().hash); for (auto const& [tx, meta] : ledger->txs) { BEAST_EXPECT(meta != nullptr); entropySeen |= tx->getTxnType() == ttCONSENSUS_ENTROPY; if (tx->getTxnType() == ttCONSENSUS_ENTROPY) partialEntropySeen |= tx->getFieldU16(sfEntropyCount) != observer; paymentSeen |= tx->getTransactionID() == payment->getID(); witnessSeen |= tx->getTxnType() == ttEXPORT_SIGNATURES; witnesses += tx->getTxnType() == ttEXPORT_SIGNATURES; if (tx->getTxnType() == ttCONSENSUS_ENTROPY && seq > warmLedger && !sluggish) BEAST_EXPECT(tx->getFieldU16(sfEntropyCount) == observer); } outcome.push_back(ledger->info().hash); } BEAST_EXPECT(entropySeen == rng); if (sluggish) { BEAST_EXPECT(partialEntropySeen); BEAST_EXPECT(stats->delayedValidatorTraffic != 0); } BEAST_EXPECT(paymentSeen); BEAST_EXPECT(witnessSeen == createIntent); BEAST_EXPECT(witnesses == (createIntent ? 1 : 0)); if (fault == Fault::duplicateTraffic) BEAST_EXPECT(stats->duplicatedFrames != 0); if (origin) { auto const witnessSeq = witnessAt(net, *origin, warmLedger); BEAST_EXPECT(witnessSeq != 0); if (witnessSeq) { bool const builtWitness = stats->builds[observer].contains( {witnessSeq, net.ledgerHash(0, witnessSeq - 1), net.ledgerHash(0, witnessSeq)}); if (fault == Fault::noShares) BEAST_EXPECT(!builtWitness); else if (!late) BEAST_EXPECT(builtWitness); } auto const collected = net.node(observer) .app() .getConsensusExtensions() .postValidationExportSigCollector() .fullUnionSnapshot(); auto const found = collected.find(*origin); if (fault == Fault::noDirectShares || fault == Fault::noShares) { BEAST_EXPECT(stats->droppedDirect != 0); BEAST_EXPECT(stats->directFrames[observer] == 0); } else BEAST_EXPECT(world.observed->directFrames[observer] != 0); if (fault == Fault::noProposalShares || fault == Fault::noShares) { BEAST_EXPECT(stats->droppedProposals != 0); BEAST_EXPECT(stats->proposalFrames[observer] == 0); BEAST_EXPECT(stats->exportRootFrames[observer] != 0); } else BEAST_EXPECT(stats->proposalFrames[observer] != 0); if (fault == Fault::noShares) { BEAST_EXPECT(found == collected.end()); // Authority can overtake this observer before it finishes a // divergent build. Acquisition, not a mandatory JUMP, is the // recovery contract when both live evidence routes are absent. if (witnessSeq) BEAST_EXPECT(stats->acquiredHashes[observer].contains( net.ledgerHash(0, witnessSeq))); } else if (!late) BEAST_EXPECT( found != collected.end() && found->second.size() == observer); } else { BEAST_EXPECT(world.observed->directFrames[observer] == 0); BEAST_EXPECT(world.observed->proposalFrames[observer] == 0); } log << " behaviour: witness=" << witnessSeen << " entropy=" << entropySeen << " partialEntropy=" << partialEntropySeen << " directDropped=" << stats->droppedDirect << " proposalsDropped=" << stats->droppedProposals << " duplicated=" << stats->duplicatedFrames << " validationsDelayed=" << stats->delayedValidations << " validatorTrafficDelayed=" << stats->delayedValidatorTraffic << " validationGap=" << stats->validationGap << " validatedAhead=" << stats->validatedAheadOfClosed << " localBuilds=" << localBuilds << " localMismatches=" << localMismatches << " observerJumps=" << net.closedJumps(observer).size() << std::endl; outcome.push_back(sha512Half( stats->droppedDirect, stats->droppedProposals, stats->duplicatedFrames, stats->delayedValidations, stats->delayedValidatorTraffic, stats->validationGap, stats->validatedAheadOfClosed, localBuilds, localMismatches)); return outcome; } public: void run() override { // Optional focused iteration, e.g. --unittest-arg=case=validator. // Keep replays=N available to the existing replay combinator. std::string filter; if (auto const at = arg().find("case="); at != std::string::npos) { filter = arg().substr(at + 5); filter = filter.substr(0, filter.find(',')); } std::size_t selected = 0; auto matches = [&](std::string const& label) { if (!filter.empty() && label.find(filter) == std::string::npos) return false; ++selected; return true; }; for (auto const rng : {false, true}) for (auto const exportOn : {false, true}) { auto const label = std::string{"feature matrix rng="} + (rng ? "on" : "off") + " export=" + (exportOn ? "on" : "off"); if (!matches(label)) continue; testcase(label); expectReplays( *this, label.c_str(), [this, rng, exportOn](SteppingNetwork& net) { return scenario(net, rng, exportOn); }); } for (auto const& [label, fault, rng, exportOn] : {std::tuple{ "observer gets Export material only through proposals", Fault::noDirectShares, false, true}, std::tuple{ "observer gets Export material only through direct messages", Fault::noProposalShares, false, true}, std::tuple{ "observer misses both Export routes and acquires the witness " "ledger", Fault::noShares, false, true}, std::tuple{ "duplicated proposals validations and Export material do not " "multiply effects", Fault::duplicateTraffic, true, true}, std::tuple{ "observer builds five seconds late while validations advance", Fault::slowObserver, true, true}, std::tuple{ "observer hears validations five seconds late", Fault::lateValidations, true, true}, std::tuple{ "observer hears validations twenty seconds late", Fault::veryLateValidations, true, true}, std::tuple{ "one RNG validator with slow builds and traffic recovers", Fault::slowValidator, true, false}, std::tuple{ "sluggish RNG validator recovers with Export enabled but " "idle", Fault::slowValidatorIdleExport, true, true}}) { if (!matches(label)) continue; testcase(label); auto const selectedFault = fault; auto const enableRng = rng; auto const enableExport = exportOn; expectReplays( *this, label, [this, selectedFault, enableRng, enableExport]( SteppingNetwork& net) { return scenario( net, enableRng, enableExport, selectedFault); }); } BEAST_EXPECT(selected != 0); } }; BEAST_DEFINE_TESTSUITE(SteppingExtensions, consensus, ripple); } // namespace ripple::test