From cdace5c514176376cfb781a39666b83dba1d4106 Mon Sep 17 00:00:00 2001 From: Aleksandr Platonenkov Date: Sun, 19 Jul 2026 15:24:28 -0300 Subject: [PATCH] feat: Add optional expanded response format to account_currencies Add an optional boolean 'expanded' request parameter to the account_currencies method. When expanded is true, send_currencies and receive_currencies contain objects keyed by asset instead of plain currency codes. Entries for tokens held (or receivable) from a counterparty carry currency, issuer (the counterparty) and value (the maximum amount that can be received or sent on that line). The account's own issuance is aggregated into a single entry per currency with issuer set to the requested account and no value, so that issuing accounts with many holders do not produce one entry per trust line (compare the obligations section of gateway_balances). Like the legacy format, membership and value are derived from trust line limits and balances alone and do not reflect freeze or authorization state; account_lines reports the full per-line state. Currency codes alone are ambiguous because an account may hold trust lines with the same currency code from different issuers, and the legacy response gives no way to tell which issuer-specific asset is usable. The default response format is unchanged for backward compatibility. --- API-CHANGELOG.md | 2 + include/xrpl/protocol/jss.h | 1 + src/test/rpc/AccountCurrencies_test.cpp | 162 ++++++++++++++++++ .../handlers/account/AccountCurrencies.cpp | 107 ++++++++++-- 4 files changed, 259 insertions(+), 13 deletions(-) diff --git a/API-CHANGELOG.md b/API-CHANGELOG.md index a04f2653285..460965ac8af 100644 --- a/API-CHANGELOG.md +++ b/API-CHANGELOG.md @@ -40,6 +40,8 @@ This section contains changes targeting a future version. - `LEDGER_ENTRY_FLAGS`: Maps ledger entry type names to their flags and flag values. - `ACCOUNT_SET_FLAGS`: Maps AccountSet flag names (asf flags) to their numeric values. +- `account_currencies`: Added an optional boolean `expanded` request parameter. When `expanded: true`, the `send_currencies` and `receive_currencies` arrays contain objects instead of plain currency codes, keyed by asset: entries for tokens held (or receivable) from a counterparty carry `currency`, `issuer` (the counterparty), and `value` (the maximum amount that can be received or sent on that line, as a decimal string); the account's own issuance across all of its holders is aggregated into a single entry per currency with `issuer` set to the requested account and no `value` (compare the aggregated `obligations` section of `gateway_balances`). This disambiguates currencies with the same code issued by different issuers without enumerating every holder of an issuing account. As with the legacy format, membership and `value` are derived from trust line limits and balances alone and do not reflect freeze or authorization state; use `account_lines` for per-line state. The default response format is unchanged. A non-boolean `expanded` value returns `invalidParams`. ([#6629](https://github.com/XRPLF/rippled/issues/6629)) + ### Bugfixes - Peer Crawler: The `port` field in `overlay.active[]` now consistently returns an integer instead of a string for outbound peers. [#6318](https://github.com/XRPLF/rippled/pull/6318) diff --git a/include/xrpl/protocol/jss.h b/include/xrpl/protocol/jss.h index 63e877ca311..327da4a784a 100644 --- a/include/xrpl/protocol/jss.h +++ b/include/xrpl/protocol/jss.h @@ -248,6 +248,7 @@ JSS(error_code); // out: error JSS(error_exception); // out: Submit JSS(error_message); // out: error JSS(expand); // in: handler/Ledger +JSS(expanded); // in: AccountCurrencies JSS(expected_date); // out: any (warnings) JSS(expected_date_UTC); // out: any (warnings) JSS(expected_ledger_size); // out: TxQ diff --git a/src/test/rpc/AccountCurrencies_test.cpp b/src/test/rpc/AccountCurrencies_test.cpp index cc7884cc362..97deaef843b 100644 --- a/src/test/rpc/AccountCurrencies_test.cpp +++ b/src/test/rpc/AccountCurrencies_test.cpp @@ -14,6 +14,7 @@ #include #include #include +#include #include namespace xrpl { @@ -85,6 +86,24 @@ class AccountCurrencies_test : public beast::unit_test::Suite testInvalidIdentParam(json::Value(json::ValueType::Array)); } + { + // test expanded non-boolean + auto testInvalidExpandedParam = [&](auto const& param) { + json::Value params; + params[jss::account] = alice.human(); + params[jss::expanded] = param; + auto jrr = env.rpc("json", "account_currencies", to_string(params))[jss::result]; + BEAST_EXPECT(jrr[jss::error] == "invalidParams"); + BEAST_EXPECT(jrr[jss::error_message] == "Invalid field 'expanded'."); + }; + + testInvalidExpandedParam(1); + testInvalidExpandedParam("true"); + testInvalidExpandedParam(json::Value(json::ValueType::Null)); + testInvalidExpandedParam(json::Value(json::ValueType::Object)); + testInvalidExpandedParam(json::Value(json::ValueType::Array)); + } + { json::Value params; params[jss::account] = "llIIOO"; // these are invalid in bitcoin alphabet @@ -192,12 +211,155 @@ class AccountCurrencies_test : public beast::unit_test::Suite BEAST_EXPECT(arrayCheck(jss::send_currencies, gwCurrenciesNoUSA)); } + void + testExpanded() + { + testcase("Expanded request for account_currencies"); + + using namespace test::jtx; + Env env{*this}; + + auto const alice = Account{"alice"}; + auto const gw1 = Account{"gw1"}; + auto const gw2 = Account{"gw2"}; + env.fund(XRP(10000), alice, gw1, gw2); + env.close(); + + // Two trust lines with the same currency code but different issuers + env(trust(alice, gw1["USD"](100))); + env(trust(alice, gw2["USD"](100))); + env.close(); + env(pay(gw1, alice, gw1["USD"](50))); + env.close(); + + json::Value params; + params[jss::account] = alice.human(); + params[jss::expanded] = true; + + auto findEntry = [](json::Value const& array, + std::string const& currency, + std::string const& issuer) -> std::optional { + for (auto const& entry : array) + { + if (entry[jss::currency].asString() == currency && + entry[jss::issuer].asString() == issuer) + return entry; + } + return std::nullopt; + }; + + { + // Both issuers are reported separately in receive_currencies, + // only the funded line qualifies for send_currencies + auto const result = + env.rpc("json", "account_currencies", to_string(params))[jss::result]; + BEAST_EXPECT( + result[jss::receive_currencies].isArray() && + result[jss::receive_currencies].size() == 2); + BEAST_EXPECT( + result[jss::send_currencies].isArray() && result[jss::send_currencies].size() == 1); + auto const recv1 = findEntry(result[jss::receive_currencies], "USD", gw1.human()); + auto const recv2 = findEntry(result[jss::receive_currencies], "USD", gw2.human()); + auto const send1 = findEntry(result[jss::send_currencies], "USD", gw1.human()); + BEAST_EXPECT(recv1 && recv2 && send1); + // value is the remaining capacity of the line: alice holds 50 + // out of a 100 limit from gw1, so she can receive 50 more and + // send the 50 she holds + BEAST_EXPECT(recv1 && (*recv1)[jss::value] == "50"); + BEAST_EXPECT(recv2 && (*recv2)[jss::value] == "100"); + BEAST_EXPECT(send1 && (*send1)[jss::value] == "50"); + // entries carry exactly currency, issuer and value + for (auto const& entry : {recv1, recv2, send1}) + BEAST_EXPECT(entry && entry->size() == 3); + } + + { + // like the legacy format, membership and value ignore freeze + // state: freezing the line changes nothing in the response + env(trust(gw1, alice["USD"](0), tfSetFreeze)); + env.close(); + auto const result = + env.rpc("json", "account_currencies", to_string(params))[jss::result]; + auto const entry = findEntry(result[jss::receive_currencies], "USD", gw1.human()); + BEAST_EXPECT(entry && (*entry)[jss::value] == "50" && entry->size() == 3); + env(trust(gw1, alice["USD"](0), tfClearFreeze)); + env.close(); + } + + { + // a second currency from another issuer: entries report the + // full remaining capacity of the line + auto const gw3 = Account{"gw3"}; + env.fund(XRP(10000), gw3); + env.close(); + env(trust(alice, gw3["EUR"](100))); + env.close(); + + auto result = env.rpc("json", "account_currencies", to_string(params))[jss::result]; + auto entry = findEntry(result[jss::receive_currencies], "EUR", gw3.human()); + BEAST_EXPECT(entry && (*entry)[jss::value] == "100"); + + // gw3's own perspective: issuance capacity is reported as a + // single aggregated entry with issuer = gw3 and no value + json::Value gwParams; + gwParams[jss::account] = gw3.human(); + gwParams[jss::expanded] = true; + result = env.rpc("json", "account_currencies", to_string(gwParams))[jss::result]; + BEAST_EXPECT(result[jss::send_currencies].size() == 1); + BEAST_EXPECT(result[jss::receive_currencies].size() == 0); + entry = findEntry(result[jss::send_currencies], "EUR", gw3.human()); + BEAST_EXPECT(entry && !entry->isMember(jss::value)); + BEAST_EXPECT(entry && entry->size() == 2); + } + + { + // Issuer with multiple holders: self-issued entries are + // aggregated to one entry per currency instead of one entry + // per trust line + auto const bob = Account{"bob"}; + env.fund(XRP(10000), bob); + env.close(); + env(trust(bob, gw1["USD"](200))); + env.close(); + env(pay(gw1, bob, gw1["USD"](30))); + env.close(); + + json::Value gwParams; + gwParams[jss::account] = gw1.human(); + gwParams[jss::expanded] = true; + auto const result = + env.rpc("json", "account_currencies", to_string(gwParams))[jss::result]; + // two holders (alice and bob), but a single aggregated entry + BEAST_EXPECT(result[jss::send_currencies].size() == 1); + BEAST_EXPECT(result[jss::receive_currencies].size() == 1); + auto const sendEntry = findEntry(result[jss::send_currencies], "USD", gw1.human()); + auto const recvEntry = findEntry(result[jss::receive_currencies], "USD", gw1.human()); + BEAST_EXPECT(sendEntry && recvEntry); + // aggregated self-issuance entries carry no value + BEAST_EXPECT(sendEntry && !sendEntry->isMember(jss::value)); + BEAST_EXPECT(recvEntry && !recvEntry->isMember(jss::value)); + } + + { + // expanded: false behaves exactly like the legacy format + params[jss::expanded] = false; + auto const result = + env.rpc("json", "account_currencies", to_string(params))[jss::result]; + BEAST_EXPECT( + result[jss::receive_currencies].isArray() && + result[jss::receive_currencies].size() == 2); + for (auto const& c : result[jss::receive_currencies]) + BEAST_EXPECT(c.isString()); + } + } + public: void run() override { testBadInput(); testBasic(); + testExpanded(); } }; diff --git a/src/xrpld/rpc/handlers/account/AccountCurrencies.cpp b/src/xrpld/rpc/handlers/account/AccountCurrencies.cpp index 058c10e224a..7782471895a 100644 --- a/src/xrpld/rpc/handlers/account/AccountCurrencies.cpp +++ b/src/xrpld/rpc/handlers/account/AccountCurrencies.cpp @@ -2,6 +2,7 @@ #include #include +#include #include #include #include @@ -12,9 +13,12 @@ #include #include +#include #include +#include #include #include +#include namespace xrpl { @@ -40,6 +44,14 @@ doAccountCurrencies(RPC::JsonContext& context) strIdent = params[jss::ident].asString(); } + bool expanded = false; + if (params.isMember(jss::expanded)) + { + if (!params[jss::expanded].isBool()) + return RPC::invalidFieldError(jss::expanded); + expanded = params[jss::expanded].asBool(); + } + // Get the current ledger std::shared_ptr ledger; auto result = RPC::lookupLedger(ledger, context); @@ -58,27 +70,96 @@ doAccountCurrencies(RPC::JsonContext& context) if (!ledger->exists(keylet::account(accountID))) return rpcError(RpcActNotFound); - std::set send, receive; - for (auto const& rspEntry : RPCTrustLine::getItems(accountID, *ledger)) + auto const lines = RPCTrustLine::getItems(accountID, *ledger); + + if (!expanded) { - STAmount const& saBalance = rspEntry.getBalance(); + std::set send, receive; + for (auto const& rspEntry : lines) + { + STAmount const& saBalance = rspEntry.getBalance(); + + if (saBalance < rspEntry.getLimit()) + receive.insert(saBalance.get().currency); + if ((-saBalance) < rspEntry.getLimitPeer()) + send.insert(saBalance.get().currency); + } + + send.erase(badCurrency()); + receive.erase(badCurrency()); - if (saBalance < rspEntry.getLimit()) - receive.insert(saBalance.get().currency); - if ((-saBalance) < rspEntry.getLimitPeer()) - send.insert(saBalance.get().currency); + json::Value& sendCurrencies = (result[jss::send_currencies] = json::ValueType::Array); + for (auto const& c : send) + sendCurrencies.append(to_string(c)); + + json::Value& recvCurrencies = (result[jss::receive_currencies] = json::ValueType::Array); + for (auto const& c : receive) + recvCurrencies.append(to_string(c)); + + return result; + } + + // Expanded mode: report entries keyed by the asset (currency, issuer) + // rather than bare currency codes. + // + // Trust lines where the requested account holds (or may hold) the + // peer's tokens produce one entry per line with issuer = peer and + // value = the remaining capacity of the line. + // + // Trust lines where the requested account is itself the issuer are + // aggregated into a single entry per currency with issuer = the + // requested account and no value, so that issuing accounts with many + // holders do not produce one entry per trust line (compare the + // aggregated "obligations" section of gateway_balances). Together the + // two kinds of entries cover exactly the currency codes reported by + // the legacy response format. + // + // Like the legacy format, membership and value are derived from the + // limits and balances alone and do not take freeze or authorization + // state into account; account_lines reports the full per-line state. + std::map, std::optional> send, receive; + for (auto const& line : lines) + { + STAmount const& saBalance = line.getBalance(); + Currency const& currency = saBalance.get().currency; + if (currency == badCurrency()) + continue; + + auto const peerKey = std::make_pair(currency, line.getAccountIDPeer()); + auto const selfKey = std::make_pair(currency, accountID); + + // room to hold more of the peer's tokens; a negative balance is + // owed back separately, so cap the capacity at the limit + if (line.getLimit() > beast::kZero && saBalance < line.getLimit()) + receive.emplace( + peerKey, saBalance > beast::kZero ? line.getLimit() - saBalance : line.getLimit()); + // holds the peer's tokens, so they can be sent + if (saBalance > beast::kZero) + send.emplace(peerKey, saBalance); + // owes tokens on this line, so own issuance can be received back + if (saBalance < beast::kZero) + receive.emplace(selfKey, std::nullopt); + // the peer extends trust that is not exhausted, so more can be issued + if (line.getLimitPeer() > beast::kZero && (-saBalance) < line.getLimitPeer()) + send.emplace(selfKey, std::nullopt); } - send.erase(badCurrency()); - receive.erase(badCurrency()); + auto const appendEntries = [](json::Value& array, auto const& entries) { + for (auto const& [key, value] : entries) + { + json::Value& entry = array.append(json::ValueType::Object); + entry[jss::currency] = to_string(key.first); + entry[jss::issuer] = to_string(key.second); + if (value) + entry[jss::value] = value->getText(); + } + }; json::Value& sendCurrencies = (result[jss::send_currencies] = json::ValueType::Array); - for (auto const& c : send) - sendCurrencies.append(to_string(c)); + appendEntries(sendCurrencies, send); json::Value& recvCurrencies = (result[jss::receive_currencies] = json::ValueType::Array); - for (auto const& c : receive) - recvCurrencies.append(to_string(c)); + appendEntries(recvCurrencies, receive); return result; }