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; }