Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions API-CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,12 @@ API version 2 is available in `rippled` version 2.0.0 and later. See [API-VERSIO

This version is supported by all `rippled` versions. For WebSocket and HTTP JSON-RPC requests, it is currently the default API version used when no `api_version` is specified.

## Unreleased

### Additions in unreleased

- `ledger_entry`: Add full support for checks, NFT offers, payment channels, and signer lists. ([#6319](https://github.com/XRPLF/rippled/pull/6319))

## XRP Ledger server version 3.1.0

[Version 3.1.0](https://github.com/XRPLF/rippled/releases/tag/3.1.0) was released on Jan 27, 2026.
Expand Down
180 changes: 171 additions & 9 deletions src/test/rpc/LedgerEntry_test.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ std::vector<std::pair<Json::StaticString, FieldType>> mappings{
{jss::authorized, FieldType::AccountField},
{jss::credential_type, FieldType::BlobField},
{jss::currency, FieldType::CurrencyField},
{jss::destination, FieldType::AccountField},
{jss::issuer, FieldType::AccountField},
{jss::oracle_document_id, FieldType::UInt32Field},
{jss::owner, FieldType::AccountField},
Expand Down Expand Up @@ -748,14 +749,15 @@ class LedgerEntry_test : public beast::unit_test::suite
env.fund(XRP(10000), alice);
env.close();

auto const checkId = keylet::check(env.master, env.seq(env.master));
std::uint32_t const checkSeq = env.seq(env.master);
auto const checkId = keylet::check(env.master, checkSeq);

env(check::create(env.master, alice, XRP(100)));
env.close();

std::string const ledgerHash{to_string(env.closed()->header().hash)};
{
// Request a check.
// Request a check by hash.
Json::Value jvParams;
jvParams[jss::check] = to_string(checkId.key);
jvParams[jss::ledger_hash] = ledgerHash;
Expand All @@ -764,6 +766,30 @@ class LedgerEntry_test : public beast::unit_test::suite
BEAST_EXPECT(jrr[jss::node][sfLedgerEntryType.jsonName] == jss::Check);
BEAST_EXPECT(jrr[jss::node][sfSendMax.jsonName] == "100000000");
}
{
// Request a check by account and seq.
Json::Value jvParams;
jvParams[jss::check] = Json::objectValue;
jvParams[jss::check][jss::account] = env.master.human();
jvParams[jss::check][jss::seq] = checkSeq;
jvParams[jss::ledger_hash] = ledgerHash;
Json::Value const jrr =
env.rpc("json", "ledger_entry", to_string(jvParams))[jss::result];
BEAST_EXPECT(jrr[jss::node][sfLedgerEntryType.jsonName] == jss::Check);
BEAST_EXPECT(jrr[jss::node][sfSendMax.jsonName] == "100000000");
BEAST_EXPECT(jrr[jss::index] == to_string(checkId.key));
}
{
// Request a non-existent check by account and seq.
Json::Value jvParams;
jvParams[jss::check] = Json::objectValue;
jvParams[jss::check][jss::account] = env.master.human();
jvParams[jss::check][jss::seq] = checkSeq + 1000;
jvParams[jss::ledger_hash] = ledgerHash;
Json::Value const jrr =
env.rpc("json", "ledger_entry", to_string(jvParams))[jss::result];
checkErrorValue(jrr, "entryNotFound", "Entry not found.");
}
{
// Request an index that is not a check. We'll use alice's
// account root index.
Expand All @@ -784,7 +810,13 @@ class LedgerEntry_test : public beast::unit_test::suite
}
{
// Check malformed cases
runLedgerEntryTest(env, jss::check);
runLedgerEntryTest(
env,
jss::check,
{
{jss::account, "malformedAddress"},
{jss::seq, "malformedRequest"},
});
}
}

Expand Down Expand Up @@ -1463,12 +1495,14 @@ class LedgerEntry_test : public beast::unit_test::suite
uint256 const nftokenID0 = token::getNextID(env, issuer, 0, tfTransferable);
env(token::mint(issuer, 0), txflags(tfTransferable));
env.close();
uint256 const offerID = keylet::nftoffer(issuer, env.seq(issuer)).key;
std::uint32_t const offerSeq = env.seq(issuer);
uint256 const offerID = keylet::nftoffer(issuer, offerSeq).key;
env(token::createOffer(issuer, nftokenID0, drops(1)),
token::destination(buyer),
txflags(tfSellNFToken));

{
// Request by hash.
Json::Value jvParams;
jvParams[jss::nft_offer] = to_string(offerID);
Json::Value const jrr =
Expand All @@ -1478,9 +1512,39 @@ class LedgerEntry_test : public beast::unit_test::suite
BEAST_EXPECT(jrr[jss::node][sfNFTokenID.jsonName] == to_string(nftokenID0));
BEAST_EXPECT(jrr[jss::node][sfAmount.jsonName] == "1");
}
{
// Request by owner and seq.
Json::Value jvParams;
jvParams[jss::nft_offer] = Json::objectValue;
jvParams[jss::nft_offer][jss::owner] = issuer.human();
jvParams[jss::nft_offer][jss::seq] = offerSeq;
Json::Value const jrr =
env.rpc("json", "ledger_entry", to_string(jvParams))[jss::result];
BEAST_EXPECT(jrr[jss::node][sfLedgerEntryType.jsonName] == jss::NFTokenOffer);
BEAST_EXPECT(jrr[jss::node][sfOwner.jsonName] == issuer.human());
BEAST_EXPECT(jrr[jss::node][sfNFTokenID.jsonName] == to_string(nftokenID0));
BEAST_EXPECT(jrr[jss::node][sfAmount.jsonName] == "1");
BEAST_EXPECT(jrr[jss::index] == to_string(offerID));
}
{
// Request a non-existent offer by owner and seq.
Json::Value jvParams;
jvParams[jss::nft_offer] = Json::objectValue;
jvParams[jss::nft_offer][jss::owner] = issuer.human();
jvParams[jss::nft_offer][jss::seq] = offerSeq + 1000;
Json::Value const jrr =
env.rpc("json", "ledger_entry", to_string(jvParams))[jss::result];
checkErrorValue(jrr, "entryNotFound", "Entry not found.");
}

// negative tests
runLedgerEntryTest(env, jss::nft_offer);
runLedgerEntryTest(
env,
jss::nft_offer,
{
{jss::owner, "malformedOwner"},
{jss::seq, "malformedRequest"},
});
}

void
Expand Down Expand Up @@ -1641,14 +1705,15 @@ class LedgerEntry_test : public beast::unit_test::suite
return jv;
};

std::uint32_t const payChanSeq = env.seq(alice);
env(payChanCreate(alice, env.master, XRP(57), 18s, alice.pk()));
env.close();

std::string const ledgerHash{to_string(env.closed()->header().hash)};

uint256 const payChanIndex{keylet::payChan(alice, env.master, env.seq(alice) - 1).key};
uint256 const payChanIndex{keylet::payChan(alice, env.master, payChanSeq).key};
{
// Request the payment channel using its index.
// Request the payment channel using its hash.
Json::Value jvParams;
jvParams[jss::payment_channel] = to_string(payChanIndex);
jvParams[jss::ledger_hash] = ledgerHash;
Expand All @@ -1658,6 +1723,33 @@ class LedgerEntry_test : public beast::unit_test::suite
BEAST_EXPECT(jrr[jss::node][sfBalance.jsonName] == "0");
BEAST_EXPECT(jrr[jss::node][sfSettleDelay.jsonName] == 18);
}
{
// Request the payment channel by account, destination, and seq.
Json::Value jvParams;
jvParams[jss::payment_channel] = Json::objectValue;
jvParams[jss::payment_channel][jss::account] = alice.human();
jvParams[jss::payment_channel][jss::destination] = env.master.human();
jvParams[jss::payment_channel][jss::seq] = payChanSeq;
jvParams[jss::ledger_hash] = ledgerHash;
Json::Value const jrr =
env.rpc("json", "ledger_entry", to_string(jvParams))[jss::result];
BEAST_EXPECT(jrr[jss::node][sfAmount.jsonName] == "57000000");
BEAST_EXPECT(jrr[jss::node][sfBalance.jsonName] == "0");
BEAST_EXPECT(jrr[jss::node][sfSettleDelay.jsonName] == 18);
BEAST_EXPECT(jrr[jss::index] == to_string(payChanIndex));
}
{
// Request a non-existent payment channel by account, destination, and seq.
Json::Value jvParams;
jvParams[jss::payment_channel] = Json::objectValue;
jvParams[jss::payment_channel][jss::account] = alice.human();
jvParams[jss::payment_channel][jss::destination] = env.master.human();
jvParams[jss::payment_channel][jss::seq] = payChanSeq + 1000;
jvParams[jss::ledger_hash] = ledgerHash;
Json::Value const jrr =
env.rpc("json", "ledger_entry", to_string(jvParams))[jss::result];
checkErrorValue(jrr, "entryNotFound", "Entry not found.");
}
{
// Request an index that is not a payment channel.
Json::Value jvParams;
Expand All @@ -1670,7 +1762,14 @@ class LedgerEntry_test : public beast::unit_test::suite

{
// Malformed paychan field
runLedgerEntryTest(env, jss::payment_channel);
runLedgerEntryTest(
env,
jss::payment_channel,
{
{jss::account, "malformedAddress"},
{jss::destination, "malformedDestination"},
Copy link

Copilot AI Feb 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test expects the new error code malformedDestination for invalid payment_channel.destination. If the handler switches to an existing error code (e.g. malformedAddress) for consistency with other AccountID fields, adjust this expectation accordingly so the negative tests match the API contract.

Suggested change
{jss::destination, "malformedDestination"},
{jss::destination, "malformedAddress"},

Copilot uses AI. Check for mistakes.
{jss::seq, "malformedRequest"},
});
}
}

Expand Down Expand Up @@ -1819,7 +1918,70 @@ class LedgerEntry_test : public beast::unit_test::suite
testcase("Signer List");
using namespace test::jtx;
Env env{*this};
runLedgerEntryTest(env, jss::signer_list);
Account const alice{"alice"};
Account const bogie{"bogie"};
Account const demon{"demon"};

env.fund(XRP(10000), alice);
env.close();

// Attach phantom signers to alice.
env(signers(alice, 1, {{bogie, 1}, {demon, 1}}));
env.close();

std::string const ledgerHash{to_string(env.closed()->header().hash)};
auto const signerListIndex = keylet::signers(alice).key;
{
// Request by hash.
Json::Value jvParams;
jvParams[jss::signer_list] = to_string(signerListIndex);
jvParams[jss::ledger_hash] = ledgerHash;
Json::Value const jrr =
env.rpc("json", "ledger_entry", to_string(jvParams))[jss::result];
BEAST_EXPECT(jrr[jss::node][sfLedgerEntryType.jsonName] == jss::SignerList);
BEAST_EXPECT(jrr[jss::node][sfSignerQuorum.jsonName] == 1);
}
{
// Request by account.
Json::Value jvParams;
jvParams[jss::signer_list] = Json::objectValue;
jvParams[jss::signer_list][jss::account] = alice.human();
jvParams[jss::ledger_hash] = ledgerHash;
Json::Value const jrr =
env.rpc("json", "ledger_entry", to_string(jvParams))[jss::result];
BEAST_EXPECT(jrr[jss::node][sfLedgerEntryType.jsonName] == jss::SignerList);
BEAST_EXPECT(jrr[jss::node][sfSignerQuorum.jsonName] == 1);
BEAST_EXPECT(jrr[jss::index] == to_string(signerListIndex));
}
{
// Request a non-existent signer list by account.
Json::Value jvParams;
jvParams[jss::signer_list] = Json::objectValue;
jvParams[jss::signer_list][jss::account] = env.master.human();
jvParams[jss::ledger_hash] = ledgerHash;
Json::Value const jrr =
env.rpc("json", "ledger_entry", to_string(jvParams))[jss::result];
checkErrorValue(jrr, "entryNotFound", "Entry not found.");
}
{
// Request an index that is not a signer list.
Json::Value jvParams;
jvParams[jss::signer_list] = ledgerHash;
jvParams[jss::ledger_hash] = ledgerHash;
Json::Value const jrr =
env.rpc("json", "ledger_entry", to_string(jvParams))[jss::result];
checkErrorValue(jrr, "entryNotFound", "Entry not found.");
}

{
// Malformed signer_list fields
runLedgerEntryTest(
env,
jss::signer_list,
{
{jss::account, "malformedAddress"},
});
}
}

void
Expand Down
63 changes: 59 additions & 4 deletions src/xrpld/rpc/handlers/LedgerEntry.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -160,7 +160,21 @@ parseCheck(
Json::StaticString const fieldName,
[[maybe_unused]] unsigned const apiVersion)
{
return parseObjectID(params, fieldName, "hex string");
if (!params.isObject())
{
return parseObjectID(params, fieldName);
}

auto const account =
LedgerEntryHelpers::requiredAccountID(params, jss::account, "malformedAddress");
if (!account)
return Unexpected(account.error());

auto const sequence = LedgerEntryHelpers::requiredUInt32(params, jss::seq, "malformedRequest");
if (!sequence)
return Unexpected(sequence.error());

return keylet::check(*account, *sequence).key;
}

static Expected<uint256, Json::Value>
Expand Down Expand Up @@ -544,7 +558,20 @@ parseNFTokenOffer(
Json::StaticString const fieldName,
[[maybe_unused]] unsigned const apiVersion)
{
return parseObjectID(params, fieldName, "hex string");
if (!params.isObject())
{
return parseObjectID(params, fieldName);
}

auto const owner = LedgerEntryHelpers::requiredAccountID(params, jss::owner, "malformedOwner");
if (!owner)
return Unexpected(owner.error());

auto const sequence = LedgerEntryHelpers::requiredUInt32(params, jss::seq, "malformedRequest");
if (!sequence)
return Unexpected(sequence.error());

return keylet::nftoffer(*owner, *sequence).key;
}

static Expected<uint256, Json::Value>
Expand Down Expand Up @@ -609,7 +636,26 @@ parsePayChannel(
Json::StaticString const fieldName,
[[maybe_unused]] unsigned const apiVersion)
{
return parseObjectID(params, fieldName, "hex string");
if (!params.isObject())
{
return parseObjectID(params, fieldName);
}

auto const account =
LedgerEntryHelpers::requiredAccountID(params, jss::account, "malformedAddress");
if (!account)
return Unexpected(account.error());

auto const destination =
LedgerEntryHelpers::requiredAccountID(params, jss::destination, "malformedDestination");
Copy link

Copilot AI Feb 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

parsePayChannel introduces a new error code string malformedDestination for an invalid destination AccountID. This error code doesn’t appear to be used anywhere else in the codebase, while other AccountID fields in this handler consistently use malformedAddress (or malformedOwner for owner fields). Consider using an existing error code (e.g. malformedAddress) to avoid expanding the public error surface with a one-off code; update the corresponding unit test expectations if you change it.

Suggested change
LedgerEntryHelpers::requiredAccountID(params, jss::destination, "malformedDestination");
LedgerEntryHelpers::requiredAccountID(params, jss::destination, "malformedAddress");

Copilot uses AI. Check for mistakes.
if (!destination)
return Unexpected(destination.error());

auto const sequence = LedgerEntryHelpers::requiredUInt32(params, jss::seq, "malformedRequest");
if (!sequence)
return Unexpected(sequence.error());

return keylet::payChan(*account, *destination, *sequence).key;
}

static Expected<uint256, Json::Value>
Expand Down Expand Up @@ -696,7 +742,16 @@ parseSignerList(
Json::StaticString const fieldName,
[[maybe_unused]] unsigned const apiVersion)
{
return parseObjectID(params, fieldName, "hex string");
if (!params.isObject())
{
return parseObjectID(params, fieldName);
}

auto const id = LedgerEntryHelpers::requiredAccountID(params, jss::account, "malformedAddress");
if (!id)
return Unexpected(id.error());

return keylet::signers(*id).key;
}

static Expected<uint256, Json::Value>
Expand Down