From 3ca056a94bf07bc686a66312c44ae64bdfe52a0d Mon Sep 17 00:00:00 2001 From: Nicholas Dudfield Date: Wed, 18 Mar 2026 11:41:16 +0700 Subject: [PATCH] feat: add XPOP test helper and XPOP_test suite - src/test/jtx/xpop.h: test utilities for building XPOPs from Env ledgers (TestValidator, TestVLPublisher, buildTestXPOP) - src/test/app/XPOP_test.cpp: 3 tests (133 assertions) - LedgerProof construction from payment tx - XPOP v1 JSON structure verification - Merkle proof verification for multi-tx ledgers --- src/test/app/XPOP_test.cpp | 235 +++++++++++++++++++++++++++++++++++ src/test/jtx/xpop.h | 170 +++++++++++++++++++++++++ src/xrpld/app/proof/XPOPv1.h | 4 +- 3 files changed, 407 insertions(+), 2 deletions(-) create mode 100644 src/test/app/XPOP_test.cpp create mode 100644 src/test/jtx/xpop.h diff --git a/src/test/app/XPOP_test.cpp b/src/test/app/XPOP_test.cpp new file mode 100644 index 000000000..62134c30c --- /dev/null +++ b/src/test/app/XPOP_test.cpp @@ -0,0 +1,235 @@ +//------------------------------------------------------------------------------ +/* + This file is part of rippled: https://github.com/ripple/rippled + Copyright (c) 2026 XRPL Labs + + Permission to use, copy, modify, and/or distribute this software for any + purpose with or without fee is hereby granted, provided that the above + copyright notice and this permission notice appear in all copies. + + THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES + WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF + MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR + ANY SPECIAL , DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES + WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN + ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF + OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. +*/ +//============================================================================== + +#include +#include +#include +#include +#include +#include +#include + +namespace ripple { +namespace test { + +struct XPOP_test : public beast::unit_test::suite +{ + void + testBuildLedgerProof() + { + testcase("Build LedgerProof from a payment"); + + using namespace jtx; + + Env env{*this}; + + Account const alice{"alice"}; + Account const bob{"bob"}; + + env.fund(XRP(10000), alice, bob); + env.close(); + + // Submit a payment and close the ledger. + env(pay(alice, bob, XRP(100))); + env.close(); + + // Get the tx hash from the last closed ledger. + auto const lcl = env.app().getLedgerMaster().getClosedLedger(); + BEAST_EXPECT(lcl); + + // Find a payment tx in the ledger. + uint256 paymentHash; + bool found = false; + lcl->txMap().visitLeaves( + [&](boost::intrusive_ptr const& item) { + if (!found) + { + paymentHash = item->key(); + found = true; + } + }); + BEAST_EXPECT(found); + + // Build the proof. + auto const lp = proof::buildLedgerProof(*lcl, paymentHash); + BEAST_EXPECT(lp.has_value()); + + if (lp) + { + // Verify header fields are populated. + BEAST_EXPECT(lp->ledgerIndex > 0); + BEAST_EXPECT(lp->totalCoins > 0); + BEAST_EXPECT(lp->parentHash != uint256{}); + BEAST_EXPECT(lp->txRoot != uint256{}); + BEAST_EXPECT(lp->accountRoot != uint256{}); + + // Verify tx blob is non-empty. + BEAST_EXPECT(!lp->txBlob.empty()); + BEAST_EXPECT(!lp->metaBlob.empty()); + + // Verify merkle proof exists and is valid. + BEAST_EXPECT(lp->txProof.has_value()); + if (lp->txProof) + { + auto const computedRoot = lp->txProof->computeRoot(); + BEAST_EXPECT(computedRoot.has_value()); + if (computedRoot) + BEAST_EXPECT(*computedRoot == lp->txRoot); + } + + // Verify ledger hash reconstruction. + auto const computedHash = lp->computeLedgerHash(); + BEAST_EXPECT(computedHash == lcl->info().hash); + } + } + + void + testBuildXPOPv1() + { + testcase("Build XPOP v1 JSON from a payment"); + + using namespace jtx; + + Env env{*this}; + + Account const alice{"alice"}; + Account const bob{"bob"}; + + env.fund(XRP(10000), alice, bob); + env.close(); + + env(pay(alice, bob, XRP(100))); + env.close(); + + auto const lcl = env.app().getLedgerMaster().getClosedLedger(); + BEAST_EXPECT(lcl); + + // Find a tx. + uint256 txHash; + lcl->txMap().visitLeaves( + [&](boost::intrusive_ptr const& item) { + txHash = item->key(); + }); + + // Build XPOP using the test helper. + auto const xpop = xpop::buildTestXPOP(env, txHash, 3); + BEAST_EXPECT(!xpop.isNull()); + + // Verify structure. + BEAST_EXPECT(xpop.isMember(jss::ledger)); + BEAST_EXPECT(xpop.isMember(jss::transaction)); + BEAST_EXPECT(xpop.isMember(jss::validation)); + + // Ledger section. + auto const& lgr = xpop[jss::ledger]; + BEAST_EXPECT(lgr.isMember(jss::index)); + BEAST_EXPECT(lgr.isMember(jss::coins)); + BEAST_EXPECT(lgr.isMember(jss::phash)); + BEAST_EXPECT(lgr.isMember(jss::txroot)); + BEAST_EXPECT(lgr.isMember(jss::acroot)); + BEAST_EXPECT(lgr.isMember(jss::close)); + BEAST_EXPECT(lgr.isMember(jss::pclose)); + BEAST_EXPECT(lgr.isMember(jss::cres)); + BEAST_EXPECT(lgr.isMember(jss::flags)); + + // Transaction section. + auto const& txn = xpop[jss::transaction]; + BEAST_EXPECT(txn.isMember(jss::blob)); + BEAST_EXPECT(txn.isMember(jss::meta)); + BEAST_EXPECT(txn.isMember(jss::proof)); + BEAST_EXPECT(txn[jss::blob].asString().size() > 0); + BEAST_EXPECT(txn[jss::meta].asString().size() > 0); + + // Validation section. + auto const& val = xpop[jss::validation]; + BEAST_EXPECT(val.isMember(jss::data)); + BEAST_EXPECT(val.isMember(jss::unl)); + BEAST_EXPECT(val[jss::data].size() == 3); // 3 validators + + auto const& unl = val[jss::unl]; + BEAST_EXPECT(unl.isMember(jss::public_key)); + BEAST_EXPECT(unl.isMember(jss::manifest)); + BEAST_EXPECT(unl.isMember(jss::blob)); + BEAST_EXPECT(unl.isMember(jss::signature)); + BEAST_EXPECT(unl.isMember(jss::version)); + } + + void + testMerkleProofVerification() + { + testcase("Merkle proof verifies against tx root"); + + using namespace jtx; + + Env env{*this}; + + Account const alice{"alice"}; + Account const bob{"bob"}; + Account const carol{"carol"}; + + env.fund(XRP(10000), alice, bob, carol); + env.close(); + + // Multiple transactions to create a deeper trie. + env(pay(alice, bob, XRP(10))); + env(pay(bob, carol, XRP(5))); + env(pay(carol, alice, XRP(1))); + env.close(); + + auto const lcl = env.app().getLedgerMaster().getClosedLedger(); + BEAST_EXPECT(lcl); + + // Verify proof for each transaction in the ledger. + int proofCount = 0; + lcl->txMap().visitLeaves( + [&](boost::intrusive_ptr const& item) { + auto const lp = proof::buildLedgerProof(*lcl, item->key()); + BEAST_EXPECT(lp.has_value()); + + if (lp && lp->txProof) + { + // Proof must verify against the ledger's tx root. + BEAST_EXPECT(lp->txProof->verify(lp->txRoot)); + + // JSON v1 serialization must round-trip. + auto const json = lp->txProof->toJsonV1(); + BEAST_EXPECT(!json.isNull()); + BEAST_EXPECT(json.isArray()); + + ++proofCount; + } + }); + + // We should have proven at least 3 transactions. + BEAST_EXPECT(proofCount >= 3); + } + + void + run() override + { + testBuildLedgerProof(); + testBuildXPOPv1(); + testMerkleProofVerification(); + } +}; + +BEAST_DEFINE_TESTSUITE(XPOP, app, ripple); + +} // namespace test +} // namespace ripple diff --git a/src/test/jtx/xpop.h b/src/test/jtx/xpop.h new file mode 100644 index 000000000..106484faf --- /dev/null +++ b/src/test/jtx/xpop.h @@ -0,0 +1,170 @@ +#ifndef RIPPLE_TEST_JTX_XPOP_H_INCLUDED +#define RIPPLE_TEST_JTX_XPOP_H_INCLUDED + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace ripple { +namespace test { +namespace jtx { +namespace xpop { + +/// Build a manifest string (binary, not base64). +inline std::string +makeManifestRaw( + PublicKey const& masterPub, + SecretKey const& masterSec, + PublicKey const& signingPub, + SecretKey const& signingSec, + int seq = 1) +{ + STObject st(sfGeneric); + st[sfSequence] = seq; + st[sfPublicKey] = masterPub; + st[sfSigningPubKey] = signingPub; + + sign(st, HashPrefix::manifest, *publicKeyType(signingPub), signingSec); + sign( + st, + HashPrefix::manifest, + *publicKeyType(masterPub), + masterSec, + sfMasterSignature); + + Serializer s; + st.add(s); + return std::string(static_cast(s.data()), s.size()); +} + +/// A complete test validator with all keys and manifest. +struct TestValidator +{ + PublicKey masterPublic; + SecretKey masterSecret; + PublicKey signingPublic; + SecretKey signingSecret; + std::string manifestRaw; + std::string manifestBase64; + + static TestValidator + create() + { + auto const ms = randomSecretKey(); + auto const mp = derivePublicKey(KeyType::ed25519, ms); + auto const [sp, ss] = randomKeyPair(KeyType::secp256k1); + auto raw = makeManifestRaw(mp, ms, sp, ss, 1); + return {mp, ms, sp, ss, raw, base64_encode(raw)}; + } + + proof::ValidatorKeys + toValidatorKeys() const + { + return { + masterPublic, + masterSecret, + signingPublic, + signingSecret, + manifestBase64}; + } +}; + +/// A complete test VL publisher with keys and manifest. +struct TestVLPublisher +{ + PublicKey masterPublic; + SecretKey masterSecret; + PublicKey signingPublic; + SecretKey signingSecret; + std::string manifestBase64; + + static TestVLPublisher + create() + { + auto const ms = randomSecretKey(); + auto const mp = derivePublicKey(KeyType::ed25519, ms); + auto const [sp, ss] = randomKeyPair(KeyType::secp256k1); + return { + mp, ms, sp, ss, base64_encode(makeManifestRaw(mp, ms, sp, ss, 1))}; + } + + /// Build VL data for these validators. + proof::VLData + buildVLData( + std::vector const& validators, + std::uint32_t sequence = 1, + std::uint32_t expiration = 0xFFFFFFFF) const + { + // Build the JSON blob + std::string data = "{\"sequence\":" + std::to_string(sequence) + + ",\"expiration\":" + std::to_string(expiration) + + ",\"validators\":["; + + for (std::size_t i = 0; i < validators.size(); ++i) + { + if (i > 0) + data += ","; + data += "{\"validation_public_key\":\"" + + strHex(validators[i].masterPublic) + "\",\"manifest\":\"" + + validators[i].manifestBase64 + "\"}"; + } + data += "]}"; + + auto const blob = base64_encode(data); + auto const sig = + strHex(sign(signingPublic, signingSecret, makeSlice(data))); + + return proof::VLData{ + masterPublic, masterSecret, manifestBase64, blob, sig, 1}; + } +}; + +/// Build a complete XPOP v1 JSON from an Env's last closed ledger. +/// Creates fresh validator keys and VL publisher for each call. +inline Json::Value +buildTestXPOP(Env& env, uint256 const& txHash, int validatorCount = 5) +{ + // Create validators + std::vector validators; + std::vector valKeys; + for (int i = 0; i < validatorCount; ++i) + { + validators.push_back(TestValidator::create()); + valKeys.push_back(validators.back().toValidatorKeys()); + } + + // Create VL publisher + auto const publisher = TestVLPublisher::create(); + auto const vlData = publisher.buildVLData(validators); + + // Build XPOP from the last closed ledger + auto const lcl = env.app().getLedgerMaster().getClosedLedger(); + if (!lcl) + return {}; + + return proof::buildXPOPv1(*lcl, txHash, valKeys, vlData); +} + +/// Get the hex-encoded XPOP blob suitable for sfBlob in ttIMPORT. +inline std::string +buildTestXPOPHex(Env& env, uint256 const& txHash, int validatorCount = 5) +{ + auto const xpop = buildTestXPOP(env, txHash, validatorCount); + if (xpop.isNull()) + return {}; + return proof::xpopToHex(xpop); +} + +} // namespace xpop +} // namespace jtx +} // namespace test +} // namespace ripple + +#endif diff --git a/src/xrpld/app/proof/XPOPv1.h b/src/xrpld/app/proof/XPOPv1.h index e617e5b04..17d2fedef 100644 --- a/src/xrpld/app/proof/XPOPv1.h +++ b/src/xrpld/app/proof/XPOPv1.h @@ -52,11 +52,11 @@ buildXPOPv1( std::vector const& validators, VLData const& vl); -/// Convenience: build XPOP from a ReadView + tx hash + validator keys. +/// Convenience: build XPOP from a Ledger + tx hash + validator keys. /// Combines buildLedgerProof + buildXPOPv1. Json::Value buildXPOPv1( - ReadView const& ledger, + Ledger const& ledger, uint256 const& txHash, std::vector const& validators, VLData const& vl);