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
2 changes: 2 additions & 0 deletions API-CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
1 change: 1 addition & 0 deletions include/xrpl/protocol/jss.h
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
162 changes: 162 additions & 0 deletions src/test/rpc/AccountCurrencies_test.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
#include <algorithm>
#include <cstddef>
#include <optional>
#include <string>
#include <vector>

namespace xrpl {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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<json::Value> {
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();
}
};

Expand Down
107 changes: 94 additions & 13 deletions src/xrpld/rpc/handlers/account/AccountCurrencies.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
#include <xrpld/rpc/detail/RPCLedgerHelpers.h>
#include <xrpld/rpc/detail/TrustLine.h>

#include <xrpl/beast/utility/Zero.h>
#include <xrpl/json/json_value.h>
#include <xrpl/ledger/ReadView.h>
#include <xrpl/protocol/AccountID.h>
Expand All @@ -12,9 +13,12 @@
#include <xrpl/protocol/UintTypes.h>
#include <xrpl/protocol/jss.h>

#include <map>
#include <memory>
#include <optional>
#include <set>
#include <string>
#include <utility>

namespace xrpl {

Expand All @@ -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<ReadView const> ledger;
auto result = RPC::lookupLedger(ledger, context);
Expand All @@ -58,27 +70,96 @@ doAccountCurrencies(RPC::JsonContext& context)
if (!ledger->exists(keylet::account(accountID)))
return rpcError(RpcActNotFound);

std::set<Currency> 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<Currency> send, receive;
for (auto const& rspEntry : lines)
{
STAmount const& saBalance = rspEntry.getBalance();

if (saBalance < rspEntry.getLimit())
receive.insert(saBalance.get<Issue>().currency);
if ((-saBalance) < rspEntry.getLimitPeer())
send.insert(saBalance.get<Issue>().currency);
}

send.erase(badCurrency());
receive.erase(badCurrency());

if (saBalance < rspEntry.getLimit())
receive.insert(saBalance.get<Issue>().currency);
if ((-saBalance) < rspEntry.getLimitPeer())
send.insert(saBalance.get<Issue>().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::pair<Currency, AccountID>, std::optional<STAmount>> send, receive;
for (auto const& line : lines)
{
STAmount const& saBalance = line.getBalance();
Currency const& currency = saBalance.get<Issue>().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;
}
Expand Down