feat: add proof module for XPOP construction

New module at src/xrpld/app/proof/ with layered design:

- ProofBuilder: SHAMap merkle proof extraction (extractProofV1)
  Binary trie proof with 16-way branching, root hash verification.
- LedgerProof: proof-of-ledger (header fields + tx blob/meta + merkle proof)
  buildLedgerProof() extracts everything from a closed Ledger.
- XPOPv1: JSON format builder matching Import.cpp expectations
  buildXPOPv1() creates complete XPOP with validation signatures.

Designed for versioning: v1 JSON (current Import compat), future v2
binary proofs and account state proofs layer on the same core.
This commit is contained in:
Nicholas Dudfield
2026-03-18 11:29:29 +07:00
parent 655b751698
commit 705d8400db
6 changed files with 692 additions and 0 deletions

View File

@@ -0,0 +1,52 @@
#ifndef RIPPLE_APP_PROOF_LEDGERPROOF_H_INCLUDED
#define RIPPLE_APP_PROOF_LEDGERPROOF_H_INCLUDED
#include <xrpld/app/ledger/Ledger.h>
#include <xrpld/app/proof/ProofBuilder.h>
#include <xrpl/basics/base_uint.h>
#include <xrpl/protocol/STTx.h>
#include <optional>
namespace ripple {
namespace proof {
/// Proof-of-ledger: everything needed to prove a transaction (or account
/// state) was in a specific closed ledger. This is the core building
/// block for XPOPs and future proof formats.
struct LedgerProof
{
// --- Ledger Header ---
std::uint32_t ledgerIndex{0};
std::uint64_t totalCoins{0};
uint256 parentHash;
uint256 txRoot;
uint256 accountRoot;
std::uint32_t parentCloseTime{0};
std::uint32_t closeTime{0};
std::uint8_t closeTimeResolution{0};
std::uint8_t closeFlags{0};
/// Recompute the ledger hash from header fields.
uint256
computeLedgerHash() const;
// --- Transaction Proof ---
/// The transaction blob (serialized STTx).
Blob txBlob;
/// The transaction metadata blob.
Blob metaBlob;
/// Merkle proof from the transaction tree.
std::optional<MerkleProof> txProof;
};
/// Build a LedgerProof for a specific transaction in a closed ledger.
/// Returns nullopt if the transaction is not found.
std::optional<LedgerProof>
buildLedgerProof(Ledger const& ledger, uint256 const& txHash);
} // namespace proof
} // namespace ripple
#endif

View File

@@ -0,0 +1,68 @@
#ifndef RIPPLE_APP_PROOF_PROOFBUILDER_H_INCLUDED
#define RIPPLE_APP_PROOF_PROOFBUILDER_H_INCLUDED
#include <xrpld/shamap/SHAMap.h>
#include <xrpl/basics/base_uint.h>
#include <array>
#include <optional>
#include <vector>
namespace ripple {
namespace proof {
/// A single node in a binary trie merkle proof.
/// Each inner node has 16 branches (SHAMap is a 16-way radix trie).
/// The target branch contains either a child ProofNode or is the leaf.
/// Non-target branches store their hash (for reconstruction).
struct ProofNode
{
/// Branch hashes. All 16 branches are present.
/// The target branch is identified by `targetBranch`.
std::array<uint256, 16> branches;
/// Which branch (0-15) leads deeper toward the target leaf.
/// Only meaningful for inner nodes in the path.
std::uint8_t targetBranch{0};
/// True if this is the deepest inner node (branches[targetBranch]
/// is the leaf hash, not another inner node).
bool isLeafParent{false};
};
/// A merkle proof: path from root to a specific leaf in a SHAMap.
/// Verifiable by hashing from leaf up through each ProofNode.
struct MerkleProof
{
/// The key (hash) of the target item.
uint256 key;
/// The leaf hash (hash of the item's content).
uint256 leafHash;
/// Path of inner nodes from root to the leaf's parent.
std::vector<ProofNode> path;
/// Reconstruct the root hash from this proof.
/// Returns nullopt if the proof is malformed.
std::optional<uint256>
computeRoot() const;
/// Verify this proof against an expected root hash.
bool
verify(uint256 const& expectedRoot) const;
/// Serialize to JSON (v1 compatibility — nested arrays).
Json::Value
toJsonV1() const;
};
/// Extract a merkle proof for a specific item from a SHAMap.
/// V1 leaf hash: SHA512Half(txNode prefix + item_data + item_key).
/// Returns nullopt if the item is not in the map.
std::optional<MerkleProof>
extractProofV1(SHAMap const& map, uint256 const& key);
} // namespace proof
} // namespace ripple
#endif

View File

@@ -0,0 +1,72 @@
#ifndef RIPPLE_APP_PROOF_XPOPV1_H_INCLUDED
#define RIPPLE_APP_PROOF_XPOPV1_H_INCLUDED
#include <xrpld/app/proof/LedgerProof.h>
#include <xrpl/json/json_value.h>
#include <xrpl/protocol/PublicKey.h>
#include <xrpl/protocol/SecretKey.h>
#include <string>
#include <vector>
namespace ripple {
namespace proof {
/// Validator key pair for signing validations in an XPOP.
struct ValidatorKeys
{
PublicKey masterPublic;
SecretKey masterSecret;
PublicKey signingPublic; // ephemeral (manifest signing key)
SecretKey signingSecret;
std::string manifest; // base64-encoded manifest
};
/// Validator List (VL) data for the XPOP.
struct VLData
{
PublicKey masterPublic; // VL master key
SecretKey masterSecret;
std::string manifest; // base64-encoded VL manifest
std::string blob; // base64-encoded VL blob (validator list)
std::string signature; // hex signature of the blob
std::uint32_t version{1};
};
/// Build an XPOP v1 JSON object from a LedgerProof.
///
/// The XPOP format matches what Import.cpp::syntaxCheckXPOP expects:
/// {
/// "ledger": { index, coins, phash, txroot, acroot, pclose, close, cres,
/// flags }, "transaction": { blob, meta, proof }, "validation": { data: {
/// pubkey: validation_hex, ... }, unl: { ... } }
/// }
///
/// @param proof The ledger proof containing tx blob, meta, and merkle
/// proof
/// @param validators Validator keys to sign the ledger hash
/// @param vl Validator list data
/// @return XPOP as a Json::Value
Json::Value
buildXPOPv1(
LedgerProof const& proof,
std::vector<ValidatorKeys> const& validators,
VLData const& vl);
/// Convenience: build XPOP from a ReadView + tx hash + validator keys.
/// Combines buildLedgerProof + buildXPOPv1.
Json::Value
buildXPOPv1(
ReadView const& ledger,
uint256 const& txHash,
std::vector<ValidatorKeys> const& validators,
VLData const& vl);
/// Encode an XPOP Json::Value to the hex blob format that ttIMPORT expects
/// in sfBlob.
std::string
xpopToHex(Json::Value const& xpop);
} // namespace proof
} // namespace ripple
#endif

View File

@@ -0,0 +1,72 @@
#include <xrpld/app/proof/LedgerProof.h>
#include <xrpl/protocol/HashPrefix.h>
#include <xrpl/protocol/digest.h>
namespace ripple {
namespace proof {
uint256
LedgerProof::computeLedgerHash() const
{
return sha512Half(
HashPrefix::ledgerMaster,
ledgerIndex,
totalCoins,
parentHash,
txRoot,
accountRoot,
parentCloseTime,
closeTime,
closeTimeResolution,
closeFlags);
}
std::optional<LedgerProof>
buildLedgerProof(Ledger const& ledger, uint256 const& txHash)
{
auto const& info = ledger.info();
// Find the transaction in the ledger.
auto const txResult = ledger.txRead(txHash);
if (!txResult.first)
return std::nullopt;
LedgerProof proof;
// Ledger header fields.
proof.ledgerIndex = info.seq;
proof.totalCoins = info.drops.drops();
proof.parentHash = info.parentHash;
proof.txRoot = info.txHash;
proof.accountRoot = info.accountHash;
proof.parentCloseTime = info.parentCloseTime.time_since_epoch().count();
proof.closeTime = info.closeTime.time_since_epoch().count();
proof.closeTimeResolution = info.closeTimeResolution.count();
proof.closeFlags = info.closeFlags;
// Transaction blob.
{
Serializer s;
txResult.first->add(s);
proof.txBlob = s.peekData();
}
// Transaction metadata.
if (txResult.second)
{
Serializer s;
txResult.second->add(s);
proof.metaBlob = s.peekData();
}
// Merkle proof from the transaction SHAMap (v1 format).
auto const& txMap = ledger.txMap();
auto const txKey =
sha512Half(HashPrefix::transactionID, makeSlice(proof.txBlob));
proof.txProof = extractProofV1(txMap, txKey);
return proof;
}
} // namespace proof
} // namespace ripple

View File

@@ -0,0 +1,305 @@
#include <xrpld/app/proof/ProofBuilder.h>
#include <xrpl/basics/Slice.h>
#include <xrpl/basics/StringUtilities.h>
#include <xrpl/json/json_value.h>
#include <xrpl/protocol/HashPrefix.h>
#include <xrpl/protocol/digest.h>
namespace ripple {
namespace proof {
// --- MerkleProof ---
std::optional<uint256>
MerkleProof::computeRoot() const
{
if (path.empty())
return std::nullopt;
// Start with the leaf hash and work up through the path.
uint256 currentHash = leafHash;
// Path is root-to-leaf, so iterate in reverse.
for (auto it = path.rbegin(); it != path.rend(); ++it)
{
sha512_half_hasher h;
using beast::hash_append;
hash_append(h, HashPrefix::innerNode);
for (int i = 0; i < 16; ++i)
{
if (i == it->targetBranch)
hash_append(h, currentHash);
else
hash_append(h, it->branches[i]);
}
currentHash = static_cast<uint256>(h);
}
return currentHash;
}
bool
MerkleProof::verify(uint256 const& expectedRoot) const
{
auto const computed = computeRoot();
return computed && *computed == expectedRoot;
}
Json::Value
MerkleProof::toJsonV1() const
{
// Build the nested array format that Import.cpp expects.
// Start from the deepest level and work up.
// Each level is an array of 16 entries: 15 hex hashes + 1 nested
// array (or the leaf hash at the bottom level).
// Build bottom-up: the deepest ProofNode's target branch
// contains the leaf hash directly.
Json::Value current;
for (auto it = path.rbegin(); it != path.rend(); ++it)
{
Json::Value level(Json::arrayValue);
for (int i = 0; i < 16; ++i)
{
if (i == it->targetBranch)
{
if (it == path.rbegin())
{
// Deepest level: the target branch IS the leaf hash
level.append(to_string(leafHash));
}
else
{
// Inner level: the target branch is the nested proof
level.append(current);
}
}
else
{
level.append(to_string(it->branches[i]));
}
}
current = level;
}
return current;
}
// --- extractProof ---
namespace {
/// Hash a transaction blob: SHA512Half(TXN prefix + blob)
uint256
hashTxBlob(Slice const& txBlob)
{
return sha512Half(HashPrefix::transactionID, txBlob);
}
/// Hash a tx+meta pair: SHA512Half(SND prefix + vl(tx) + tx + vl(meta) + meta +
/// hashTxBlob(tx)) This matches SHAMapInnerNode's leaf hash computation.
uint256
hashTxAndMeta(Slice const& txBlob, Slice const& metaBlob)
{
Serializer s(txBlob.size() + metaBlob.size() + 256);
// The SHAMap stores tx leaves as:
// HashPrefix::txNode + vl(tx) + vl(meta) + txHash
// where vl() is the variable-length encoding.
s.addRaw(txBlob);
Serializer metaSer;
metaSer.addRaw(metaBlob);
return sha512Half(
HashPrefix::txNode,
makeSlice(s.peekData()),
makeSlice(metaSer.peekData()),
hashTxBlob(txBlob));
}
/// Simple in-memory radix trie node for proof construction.
struct TrieNode
{
uint256 hash;
uint256 key;
std::array<std::unique_ptr<TrieNode>, 16> children;
bool isLeaf{false};
bool
hasChildren() const
{
for (auto const& c : children)
if (c)
return true;
return false;
}
int
childCount() const
{
int count = 0;
for (auto const& c : children)
if (c)
++count;
return count;
}
};
/// Get the nibble at position `depth` in a 256-bit key.
int
getNibble(uint256 const& key, int depth)
{
// Each byte has 2 nibbles. depth 0 = high nibble of byte 0.
int byteIdx = depth / 2;
int nibbleIdx = depth % 2;
auto byte = key.data()[byteIdx];
return nibbleIdx == 0 ? (byte >> 4) & 0x0F : byte & 0x0F;
}
/// Insert a leaf into the trie.
void
trieInsert(TrieNode& root, uint256 const& key, uint256 const& leafHash)
{
TrieNode* node = &root;
int depth = 0;
while (depth < 64) // 256 bits / 4 bits per nibble = 64 max depth
{
int nibble = getNibble(key, depth);
if (!node->children[nibble])
{
// Empty slot — insert leaf here.
node->children[nibble] = std::make_unique<TrieNode>();
node->children[nibble]->hash = leafHash;
node->children[nibble]->key = key;
node->children[nibble]->isLeaf = true;
return;
}
if (node->children[nibble]->isLeaf)
{
// Collision — need to split.
auto existing = std::move(node->children[nibble]);
node->children[nibble] = std::make_unique<TrieNode>();
auto* newInner = node->children[nibble].get();
// Re-insert the existing leaf deeper.
int existingNibble = getNibble(existing->key, depth + 1);
newInner->children[existingNibble] = std::move(existing);
// Continue inserting the new leaf.
node = newInner;
++depth;
continue;
}
// Inner node — descend.
node = node->children[nibble].get();
++depth;
}
}
/// Compute hashes bottom-up for the trie.
uint256
trieComputeHash(TrieNode& node)
{
if (node.isLeaf)
return node.hash;
sha512_half_hasher h;
using beast::hash_append;
hash_append(h, HashPrefix::innerNode);
for (int i = 0; i < 16; ++i)
{
if (node.children[i])
hash_append(h, trieComputeHash(*node.children[i]));
else
hash_append(h, uint256{});
}
node.hash = static_cast<uint256>(h);
return node.hash;
}
/// Extract proof path from root to a specific key.
bool
trieExtractProof(
TrieNode const& node,
uint256 const& key,
int depth,
std::vector<ProofNode>& path,
uint256& outLeafHash)
{
if (node.isLeaf)
{
if (node.key == key)
{
outLeafHash = node.hash;
return true;
}
return false;
}
int nibble = getNibble(key, depth);
if (!node.children[nibble])
return false;
ProofNode pn;
pn.targetBranch = static_cast<std::uint8_t>(nibble);
for (int i = 0; i < 16; ++i)
{
if (node.children[i])
pn.branches[i] = node.children[i]->hash;
// else: already zero-initialized
}
if (node.children[nibble]->isLeaf)
{
if (node.children[nibble]->key != key)
return false;
pn.isLeafParent = true;
path.push_back(pn);
outLeafHash = node.children[nibble]->hash;
return true;
}
path.push_back(pn);
return trieExtractProof(
*node.children[nibble], key, depth + 1, path, outLeafHash);
}
} // namespace
std::optional<MerkleProof>
extractProofV1(SHAMap const& map, uint256 const& key)
{
// Build an in-memory trie from all SHAMap leaves.
// V1 leaf hash: SHA512Half(txNode prefix + item_data + item_key)
TrieNode root;
map.visitLeaves([&](boost::intrusive_ptr<SHAMapItem const> const& item) {
auto const itemHash =
sha512Half(HashPrefix::txNode, item->slice(), item->key());
trieInsert(root, item->key(), itemHash);
});
trieComputeHash(root);
MerkleProof proof;
proof.key = key;
if (!trieExtractProof(root, key, 0, proof.path, proof.leafHash))
return std::nullopt;
return proof;
}
} // namespace proof
} // namespace ripple

View File

@@ -0,0 +1,123 @@
#include <xrpld/app/proof/XPOPv1.h>
#include <xrpl/basics/StringUtilities.h>
#include <xrpl/basics/base64.h>
#include <xrpl/json/json_writer.h>
#include <xrpl/protocol/HashPrefix.h>
#include <xrpl/protocol/STValidation.h>
#include <xrpl/protocol/digest.h>
#include <xrpl/protocol/jss.h>
namespace ripple {
namespace proof {
namespace {
/// Build a validation STObject, sign it, and serialize to hex.
std::string
buildValidationHex(
uint256 const& ledgerHash,
std::uint32_t ledgerSeq,
PublicKey const& signingPublic,
SecretKey const& signingSecret)
{
auto val = std::make_shared<STValidation>(
NetClock::time_point{},
signingPublic,
signingSecret,
calcNodeID(signingPublic),
[&](STValidation& v) {
v.setFieldH256(sfLedgerHash, ledgerHash);
v.setFieldU32(sfLedgerSequence, ledgerSeq);
v.setFlag(vfFullValidation);
});
auto const serialized = val->getSerialized();
return strHex(makeSlice(serialized));
}
} // namespace
Json::Value
buildXPOPv1(
LedgerProof const& proof,
std::vector<ValidatorKeys> const& validators,
VLData const& vl)
{
Json::Value xpop(Json::objectValue);
// --- ledger section ---
Json::Value ledger(Json::objectValue);
ledger[jss::index] = proof.ledgerIndex;
ledger[jss::coins] = std::to_string(proof.totalCoins);
ledger[jss::phash] = to_string(proof.parentHash);
ledger[jss::txroot] = to_string(proof.txRoot);
ledger[jss::acroot] = to_string(proof.accountRoot);
ledger[jss::pclose] = proof.parentCloseTime;
ledger[jss::close] = proof.closeTime;
ledger[jss::cres] = proof.closeTimeResolution;
ledger[jss::flags] = proof.closeFlags;
xpop[jss::ledger] = ledger;
// --- transaction section ---
Json::Value transaction(Json::objectValue);
transaction[jss::blob] = strHex(makeSlice(proof.txBlob));
transaction[jss::meta] = strHex(makeSlice(proof.metaBlob));
if (proof.txProof)
transaction[jss::proof] = proof.txProof->toJsonV1();
else
transaction[jss::proof] = Json::Value(Json::arrayValue);
xpop[jss::transaction] = transaction;
// --- validation section ---
auto const ledgerHash = proof.computeLedgerHash();
Json::Value validation(Json::objectValue);
// validation.data: map of nodepub → serialized validation hex
Json::Value data(Json::objectValue);
for (auto const& vk : validators)
{
auto const nodepub = toBase58(TokenType::NodePublic, vk.signingPublic);
data[nodepub] = buildValidationHex(
ledgerHash, proof.ledgerIndex, vk.signingPublic, vk.signingSecret);
}
validation[jss::data] = data;
// validation.unl: VL data
Json::Value unl(Json::objectValue);
unl[jss::public_key] = strHex(vl.masterPublic);
unl[jss::manifest] = vl.manifest;
unl[jss::blob] = vl.blob;
unl[jss::signature] = vl.signature;
unl[jss::version] = vl.version;
validation[jss::unl] = unl;
xpop[jss::validation] = validation;
return xpop;
}
Json::Value
buildXPOPv1(
Ledger const& ledger,
uint256 const& txHash,
std::vector<ValidatorKeys> const& validators,
VLData const& vl)
{
auto proof = buildLedgerProof(ledger, txHash);
if (!proof)
return Json::Value{};
return buildXPOPv1(*proof, validators, vl);
}
std::string
xpopToHex(Json::Value const& xpop)
{
auto const json = Json::FastWriter().write(xpop);
return strHex(json);
}
} // namespace proof
} // namespace ripple