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
This commit is contained in:
Nicholas Dudfield
2026-03-18 11:41:16 +07:00
parent 705d8400db
commit 3ca056a94b
3 changed files with 407 additions and 2 deletions

235
src/test/app/XPOP_test.cpp Normal file
View File

@@ -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 <test/jtx.h>
#include <test/jtx/xpop.h>
#include <xrpld/app/ledger/LedgerMaster.h>
#include <xrpld/app/proof/LedgerProof.h>
#include <xrpld/app/proof/ProofBuilder.h>
#include <xrpld/app/proof/XPOPv1.h>
#include <xrpl/protocol/jss.h>
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<SHAMapItem const> 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<SHAMapItem const> 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<SHAMapItem const> 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

170
src/test/jtx/xpop.h Normal file
View File

@@ -0,0 +1,170 @@
#ifndef RIPPLE_TEST_JTX_XPOP_H_INCLUDED
#define RIPPLE_TEST_JTX_XPOP_H_INCLUDED
#include <test/jtx/Env.h>
#include <xrpld/app/ledger/LedgerMaster.h>
#include <xrpld/app/proof/LedgerProof.h>
#include <xrpld/app/proof/XPOPv1.h>
#include <xrpl/basics/StringUtilities.h>
#include <xrpl/basics/base64.h>
#include <xrpl/protocol/PublicKey.h>
#include <xrpl/protocol/SecretKey.h>
#include <xrpl/protocol/Sign.h>
#include <xrpl/protocol/digest.h>
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<char const*>(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<TestValidator> 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<TestValidator> validators;
std::vector<proof::ValidatorKeys> 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

View File

@@ -52,11 +52,11 @@ buildXPOPv1(
std::vector<ValidatorKeys> 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<ValidatorKeys> const& validators,
VLData const& vl);