Files
rippled/src/test/app/TrustAndBalance_test.cpp
2026-02-17 10:50:47 +00:00

681 lines
24 KiB
C++

#include <test/jtx.h>
#include <test/jtx/WSClient.h>
#include <xrpl/beast/unit_test.h>
#include <xrpl/protocol/Feature.h>
#include <xrpl/protocol/SField.h>
#include <xrpl/protocol/jss.h>
namespace xrpl {
class TrustAndBalance_test : public beast::unit_test::suite
{
void
testPayNonexistent(FeatureBitset features)
{
testcase("Payment to Nonexistent Account");
using namespace test::jtx;
Env env{*this, features};
env(pay(env.master, "alice", XRP(1)), ter(tecNO_DST_INSUF_XRP));
env.close();
}
void
testTrustNonexistent()
{
testcase("Trust Nonexistent Account");
using namespace test::jtx;
Env env{*this};
Account alice{"alice"};
env(trust(env.master, alice["USD"](100)), ter(tecNO_DST));
}
void
testCreditLimit()
{
testcase("Credit Limit");
using namespace test::jtx;
Env env{*this};
Account gw{"gateway"};
Account alice{"alice"};
Account bob{"bob"};
env.fund(XRP(10000), gw, alice, bob);
env.close();
// credit limit doesn't exist yet - verify ledger_entry
// reflects this
auto jrr = ledgerEntryState(env, gw, alice, "USD");
BEAST_EXPECT(jrr[jss::error] == "entryNotFound");
// now create a credit limit
env(trust(alice, gw["USD"](800)));
jrr = ledgerEntryState(env, gw, alice, "USD");
BEAST_EXPECT(jrr[jss::node][sfBalance.fieldName][jss::value] == "0");
BEAST_EXPECT(jrr[jss::node][sfHighLimit.fieldName][jss::value] == "800");
BEAST_EXPECT(jrr[jss::node][sfHighLimit.fieldName][jss::issuer] == alice.human());
BEAST_EXPECT(jrr[jss::node][sfHighLimit.fieldName][jss::currency] == "USD");
BEAST_EXPECT(jrr[jss::node][sfLowLimit.fieldName][jss::value] == "0");
BEAST_EXPECT(jrr[jss::node][sfLowLimit.fieldName][jss::issuer] == gw.human());
BEAST_EXPECT(jrr[jss::node][sfLowLimit.fieldName][jss::currency] == "USD");
// modify the credit limit
env(trust(alice, gw["USD"](700)));
jrr = ledgerEntryState(env, gw, alice, "USD");
BEAST_EXPECT(jrr[jss::node][sfBalance.fieldName][jss::value] == "0");
BEAST_EXPECT(jrr[jss::node][sfHighLimit.fieldName][jss::value] == "700");
BEAST_EXPECT(jrr[jss::node][sfHighLimit.fieldName][jss::issuer] == alice.human());
BEAST_EXPECT(jrr[jss::node][sfHighLimit.fieldName][jss::currency] == "USD");
BEAST_EXPECT(jrr[jss::node][sfLowLimit.fieldName][jss::value] == "0");
BEAST_EXPECT(jrr[jss::node][sfLowLimit.fieldName][jss::issuer] == gw.human());
BEAST_EXPECT(jrr[jss::node][sfLowLimit.fieldName][jss::currency] == "USD");
// set negative limit - expect failure
env(trust(alice, gw["USD"](-1)), ter(temBAD_LIMIT));
// set zero limit
env(trust(alice, gw["USD"](0)));
// ensure line is deleted
jrr = ledgerEntryState(env, gw, alice, "USD");
BEAST_EXPECT(jrr[jss::error] == "entryNotFound");
// TODO Check in both owner books.
// set another credit limit
env(trust(alice, bob["USD"](600)));
// set limit on other side
env(trust(bob, alice["USD"](500)));
// check the ledger state for the trust line
jrr = ledgerEntryState(env, alice, bob, "USD");
BEAST_EXPECT(jrr[jss::node][sfBalance.fieldName][jss::value] == "0");
BEAST_EXPECT(jrr[jss::node][sfHighLimit.fieldName][jss::value] == "500");
BEAST_EXPECT(jrr[jss::node][sfHighLimit.fieldName][jss::issuer] == bob.human());
BEAST_EXPECT(jrr[jss::node][sfHighLimit.fieldName][jss::currency] == "USD");
BEAST_EXPECT(jrr[jss::node][sfLowLimit.fieldName][jss::value] == "600");
BEAST_EXPECT(jrr[jss::node][sfLowLimit.fieldName][jss::issuer] == alice.human());
BEAST_EXPECT(jrr[jss::node][sfLowLimit.fieldName][jss::currency] == "USD");
}
void
testDirectRipple(FeatureBitset features)
{
testcase("Direct Payment, Ripple");
using namespace test::jtx;
Env env{*this, features};
Account alice{"alice"};
Account bob{"bob"};
env.fund(XRP(10000), alice, bob);
env.close();
env(trust(alice, bob["USD"](600)));
env(trust(bob, alice["USD"](700)));
// alice sends bob partial with alice as issuer
env(pay(alice, bob, alice["USD"](24)));
env.require(balance(bob, alice["USD"](24)));
// alice sends bob more with bob as issuer
env(pay(alice, bob, bob["USD"](33)));
env.require(balance(bob, alice["USD"](57)));
// bob sends back more than sent
env(pay(bob, alice, bob["USD"](90)));
env.require(balance(bob, alice["USD"](-33)));
// alice sends to her limit
env(pay(alice, bob, bob["USD"](733)));
env.require(balance(bob, alice["USD"](700)));
// bob sends to his limit
env(pay(bob, alice, bob["USD"](1300)));
env.require(balance(bob, alice["USD"](-600)));
// bob sends past limit
env(pay(bob, alice, bob["USD"](1)), ter(tecPATH_DRY));
env.require(balance(bob, alice["USD"](-600)));
}
void
testWithTransferFee(bool subscribe, bool with_rate, FeatureBitset features)
{
testcase(
std::string("Direct Payment: ") + (with_rate ? "With " : "Without ") + " Xfer Fee, " +
(subscribe ? "With " : "Without ") + " Subscribe");
using namespace test::jtx;
Env env{*this, features};
auto wsc = test::makeWSClient(env.app().config());
Account gw{"gateway"};
Account alice{"alice"};
Account bob{"bob"};
env.fund(XRP(10000), gw, alice, bob);
env.close();
env(trust(alice, gw["AUD"](100)));
env(trust(bob, gw["AUD"](100)));
env(pay(gw, alice, alice["AUD"](1)));
env.close();
env.require(balance(alice, gw["AUD"](1)));
// alice sends bob 1 AUD
env(pay(alice, bob, gw["AUD"](1)));
env.close();
env.require(balance(alice, gw["AUD"](0)));
env.require(balance(bob, gw["AUD"](1)));
env.require(balance(gw, bob["AUD"](-1)));
if (with_rate)
{
// set a transfer rate
env(rate(gw, 1.1));
env.close();
// bob sends alice 0.5 AUD with a max to spend
env(pay(bob, alice, gw["AUD"](0.5)), sendmax(gw["AUD"](0.55)));
}
else
{
// bob sends alice 0.5 AUD
env(pay(bob, alice, gw["AUD"](0.5)));
}
env.require(balance(alice, gw["AUD"](0.5)));
env.require(balance(bob, gw["AUD"](with_rate ? 0.45 : 0.5)));
env.require(balance(gw, bob["AUD"](with_rate ? -0.45 : -0.5)));
if (subscribe)
{
Json::Value jvs;
jvs[jss::accounts] = Json::arrayValue;
jvs[jss::accounts].append(gw.human());
jvs[jss::streams] = Json::arrayValue;
jvs[jss::streams].append("transactions");
jvs[jss::streams].append("ledger");
auto jv = wsc->invoke("subscribe", jvs);
BEAST_EXPECT(jv[jss::status] == "success");
env.close();
using namespace std::chrono_literals;
BEAST_EXPECT(wsc->findMsg(5s, [](auto const& jval) {
auto const& t = jval[jss::transaction];
return t[jss::TransactionType] == jss::Payment;
}));
BEAST_EXPECT(wsc->findMsg(5s, [](auto const& jval) { return jval[jss::type] == "ledgerClosed"; }));
BEAST_EXPECT(wsc->invoke("unsubscribe", jv)[jss::status] == "success");
}
}
void
testWithPath(FeatureBitset features)
{
testcase("Payments With Paths and Fees");
using namespace test::jtx;
Env env{*this, features};
Account gw{"gateway"};
Account alice{"alice"};
Account bob{"bob"};
env.fund(XRP(10000), gw, alice, bob);
env.close();
// set a transfer rate
env(rate(gw, 1.1));
env(trust(alice, gw["AUD"](100)));
env(trust(bob, gw["AUD"](100)));
env(pay(gw, alice, alice["AUD"](4.4)));
env.require(balance(alice, gw["AUD"](4.4)));
// alice sends gw issues to bob with a max spend that allows for the
// xfer rate
env(pay(alice, bob, gw["AUD"](1)), sendmax(gw["AUD"](1.1)));
env.require(balance(alice, gw["AUD"](3.3)));
env.require(balance(bob, gw["AUD"](1)));
// alice sends bob issues to bob with a max spend
env(pay(alice, bob, bob["AUD"](1)), sendmax(gw["AUD"](1.1)));
env.require(balance(alice, gw["AUD"](2.2)));
env.require(balance(bob, gw["AUD"](2)));
// alice sends gw issues to bob with a max spend
env(pay(alice, bob, gw["AUD"](1)), sendmax(alice["AUD"](1.1)));
env.require(balance(alice, gw["AUD"](1.1)));
env.require(balance(bob, gw["AUD"](3)));
// alice sends bob issues to bob with a max spend in alice issues.
// expect fail since gw is not involved
env(pay(alice, bob, bob["AUD"](1)), sendmax(alice["AUD"](1.1)), ter(tecPATH_DRY));
env.require(balance(alice, gw["AUD"](1.1)));
env.require(balance(bob, gw["AUD"](3)));
}
void
testIndirect(FeatureBitset features)
{
testcase("Indirect Payment");
using namespace test::jtx;
Env env{*this, features};
Account gw{"gateway"};
Account alice{"alice"};
Account bob{"bob"};
env.fund(XRP(10000), gw, alice, bob);
env.close();
env(trust(alice, gw["USD"](600)));
env(trust(bob, gw["USD"](700)));
env(pay(gw, alice, alice["USD"](70)));
env(pay(gw, bob, bob["USD"](50)));
env.require(balance(alice, gw["USD"](70)));
env.require(balance(bob, gw["USD"](50)));
// alice sends more than has to issuer: 100 out of 70
env(pay(alice, gw, gw["USD"](100)), ter(tecPATH_PARTIAL));
// alice sends more than has to bob: 100 out of 70
env(pay(alice, bob, gw["USD"](100)), ter(tecPATH_PARTIAL));
env.close();
env.require(balance(alice, gw["USD"](70)));
env.require(balance(bob, gw["USD"](50)));
// send with an account path
env(pay(alice, bob, gw["USD"](5)), test::jtx::path(gw));
env.require(balance(alice, gw["USD"](65)));
env.require(balance(bob, gw["USD"](55)));
}
void
testIndirectMultiPath(bool with_rate, FeatureBitset features)
{
testcase(std::string("Indirect Payment, Multi Path, ") + (with_rate ? "With " : "Without ") + " Xfer Fee, ");
using namespace test::jtx;
Env env{*this, features};
Account gw{"gateway"};
Account amazon{"amazon"};
Account alice{"alice"};
Account bob{"bob"};
Account carol{"carol"};
env.fund(XRP(10000), gw, amazon, alice, bob, carol);
env.close();
env(trust(amazon, gw["USD"](2000)));
env(trust(bob, alice["USD"](600)));
env(trust(bob, gw["USD"](1000)));
env(trust(carol, alice["USD"](700)));
env(trust(carol, gw["USD"](1000)));
if (with_rate)
env(rate(gw, 1.1));
env(pay(gw, bob, bob["USD"](100)));
env(pay(gw, carol, carol["USD"](100)));
env.close();
// alice pays amazon via multiple paths
if (with_rate)
env(pay(alice, amazon, gw["USD"](150)),
sendmax(alice["USD"](200)),
test::jtx::path(bob),
test::jtx::path(carol));
else
env(pay(alice, amazon, gw["USD"](150)), test::jtx::path(bob), test::jtx::path(carol));
if (with_rate)
{
env.require(
balance(alice, STAmount(carol["USD"].issue(), 6500000000000000ull, -14, true, STAmount::unchecked{})));
env.require(balance(carol, gw["USD"](35)));
}
else
{
env.require(balance(alice, carol["USD"](-50)));
env.require(balance(carol, gw["USD"](50)));
}
env.require(balance(alice, bob["USD"](-100)));
env.require(balance(amazon, gw["USD"](150)));
env.require(balance(bob, gw["USD"](0)));
}
void
testInvoiceID(FeatureBitset features)
{
testcase("Set Invoice ID on Payment");
using namespace test::jtx;
Env env{*this, features};
Account alice{"alice"};
auto wsc = test::makeWSClient(env.app().config());
env.fund(XRP(10000), alice);
env.close();
Json::Value jvs;
jvs[jss::accounts] = Json::arrayValue;
jvs[jss::accounts].append(env.master.human());
jvs[jss::streams] = Json::arrayValue;
jvs[jss::streams].append("transactions");
BEAST_EXPECT(wsc->invoke("subscribe", jvs)[jss::status] == "success");
char const* invoiceId = "243F6A8885A308D313198A2E03707344A4093822299F31D0082EFA98EC4E6C89";
Json::Value jv;
auto tx = env.jt(pay(env.master, alice, XRP(10000)), json(sfInvoiceID.fieldName, invoiceId));
jv[jss::tx_blob] = strHex(tx.stx->getSerializer().slice());
auto jrr = wsc->invoke("submit", jv)[jss::result];
BEAST_EXPECT(jrr[jss::status] == "success");
BEAST_EXPECT(jrr[jss::tx_json][sfInvoiceID.fieldName] == invoiceId);
env.close();
using namespace std::chrono_literals;
BEAST_EXPECT(wsc->findMsg(2s, [invoiceId](auto const& jval) {
auto const& t = jval[jss::transaction];
return t[jss::TransactionType] == jss::Payment && t[sfInvoiceID.fieldName] == invoiceId;
}));
BEAST_EXPECT(wsc->invoke("unsubscribe", jv)[jss::status] == "success");
}
void
testOwnerCountOnBalanceChange(FeatureBitset features)
{
testcase("Reserve Edge Cases at Boundary");
using namespace test::jtx;
Env env{*this, features};
Account gw{"gateway"};
Account market{"market"};
auto const USD = gw["USD"];
auto const fee = env.current()->fees().base;
auto const acctReserve = env.current()->fees().accountReserve(0);
auto const incReserve = env.current()->fees().increment;
env.fund(XRP(10000), gw, market);
env.close();
auto checkAccountUSD = [&](Account const& acc,
bool const accHigh,
std::uint32_t expectedOwnerCount,
bool expectedReserveSet,
STAmount expectedUsdBalance) {
BEAST_EXPECT(env.le(acc)->getFieldU32(sfOwnerCount) == expectedOwnerCount);
auto const line = env.le(keylet::line(acc, gw, to_currency("USD")));
BEAST_EXPECT(line && line->isFlag(accHigh ? lsfHighReserve : lsfLowReserve) == expectedReserveSet);
BEAST_EXPECT(env.balance(acc, USD) == expectedUsdBalance);
};
// Setup: fund account, create trust line with balance, clear default
// ripple, set limit to 0, clear balance. Results in owner count 0 with
// reserve flag cleared.
auto setupAccount = [&](Account const& acc, bool const accHigh, STAmount const& fundAmount) {
env.fund(fundAmount, acc);
env.close();
env(fset(gw, asfDefaultRipple));
env.close();
env(trust(acc, USD(1000)));
env.close();
env(pay(gw, acc, USD(1000)));
env.close();
checkAccountUSD(acc, accHigh, 1, true, USD(1000));
env(fclear(gw, asfDefaultRipple));
env.close();
env(trust(acc, USD(0)));
env.close();
env(pay(acc, gw, USD(1000)));
env.close();
checkAccountUSD(acc, accHigh, 0, false, USD(0));
};
// Alice receives USD via offer crossing, there's enough balance to
// cover everything (reserves, offer, fees etc.)
{
Account alice{"alice"};
bool const aliceHigh = alice.id() > gw.id();
setupAccount(alice, aliceHigh, acctReserve + incReserve + fee * 4 + XRP(25));
// Making a limit order to match against later
env(offer(gw, XRP(100), USD(100)));
env.close();
// Alice creates offer to buy USD - it should cross fully
// immediately. Both cases should succeed because there is enough
// balance to cover the reserve requirements and the offer, but
// owner counter only increments in the fixed case.
if (features[fixTrustLineOwnerCount])
{
env(offer(alice, USD(25), XRP(25)));
env.close();
// Offer crosses, alice gets USD, owner count becomes 1, reserve
// flag set
env.require(offers(alice, 0));
checkAccountUSD(alice, aliceHigh, 1, true, USD(25));
}
else
{
env(offer(alice, USD(25), XRP(25)));
env.close();
// Offer crosses, alice gets USD, owner count stays 0, reserve
// flag not set
env.require(offers(alice, 0));
checkAccountUSD(alice, aliceHigh, 0, false, USD(25));
}
}
// Bob receives USD via offer crossing, there's only 10 XRP available
// for the offer to cross, so the unconsummed part of the offer would be
// staying in the book and add another entry to the owner count.
{
Account bob{"bob"};
bool const bobHigh = bob.id() > gw.id();
setupAccount(bob, bobHigh, acctReserve + incReserve + fee * 4 + XRP(10));
// Making a limit order to match against later
env(offer(gw, XRP(100), USD(100)));
env.close();
// Bob creates offer to buy USD - it should cross immediately, but
// Bob only has 10 USD available so the offer is not supposed to be
// consumed completely.
if (features[fixTrustLineOwnerCount])
{
env(offer(bob, USD(25), XRP(25)));
env.close();
// Offer crosses, bob gets 10 USD. It only consumes 10 USD, but
// there's no more remaining unreserved XRPs available, so the
// remaining quantity is unfunded. It does not land into the
// book. The owner count is incremented and the reserve flag is
// set.
env.require(offers(bob, 0));
checkAccountUSD(bob, bobHigh, 1, true, USD(10));
}
else
{
env(offer(bob, USD(25), XRP(25)));
env.close();
// Offer crosses, bob gets 25 USD. The owner count is not
// incremented and the reserve flag is not set.
env.require(offers(bob, 0));
checkAccountUSD(bob, bobHigh, 0, false, USD(25));
}
}
// Carol creates offer to buy USD - but they only have enough XRPs for
// reserves and transaction fees.
{
Account carol{"carol"};
bool const carolHigh = carol.id() > gw.id();
setupAccount(carol, carolHigh, acctReserve + incReserve + fee * 4);
// Making a limit order to match against later
env(offer(gw, XRP(100), USD(100)));
env.close();
// Carol creates offer to buy USD - but there should be no
// unreserved XRPs available to fund the offer.
if (features[fixTrustLineOwnerCount])
{
env(offer(carol, USD(25), XRP(25)));
env.close();
// The offer is unfunded, but it still ends up in the book.
// Carol does not get owner count due to the trustline state (it
// does not change), but due to the offer.
env.require(offers(carol, 1));
checkAccountUSD(carol, carolHigh, 1, false, USD(0));
}
else
{
env(offer(carol, USD(25), XRP(25)));
env.close();
// Offer crosses, bob gets 25 USD. The owner count is not
// incremented and the reserve flag is not set.
env.require(offers(carol, 0));
checkAccountUSD(carol, carolHigh, 0, false, USD(25));
}
}
// Receiving USD via CheckCash. rippleCreditIOU is reached
// with the receiver's balance going from 0 to positive.
{
Account dave{"dave"};
bool const daveHigh = dave.id() > gw.id();
setupAccount(dave, daveHigh, acctReserve + fee * 4);
uint256 const checkId{keylet::check(gw, env.seq(gw)).key};
env(check::create(gw, dave, USD(25)));
env.close();
if (features[fixTrustLineOwnerCount])
{
env(check::cash(dave, checkId, USD(25)));
env.close();
// rippleCreditIOU set the reserve flag and incremented
// owner count. No reserve requirements since it's the only
// object owned by this account
checkAccountUSD(dave, daveHigh, 1, true, USD(25));
}
else
{
env(check::cash(dave, checkId, USD(25)));
env.close();
// Without fix: owner count stays 0, reserve not set.
checkAccountUSD(dave, daveHigh, 0, false, USD(25));
}
}
// Receiving USD via CheckCash. rippleCreditIOU is reached
// with the receiver's balance going from 0 to positive.
{
Account ed{"ed"};
bool const edHigh = ed.id() > gw.id();
setupAccount(ed, edHigh, acctReserve + incReserve * 2 + fee * 4);
env(trust(ed, gw["EUR"](1000)));
env(trust(ed, gw["JPY"](1000)));
uint256 const checkId{keylet::check(gw, env.seq(gw)).key};
env(check::create(gw, ed, USD(25)));
env.close();
if (features[fixTrustLineOwnerCount])
{
// rippleCreditIOU returns tecINSUFFICIENT_RESERVE here since
// there is not enough balance for the reserve requirement on
// 3rd trustline. Unfortunately payment engine somehow does not
// propagate this error
env(check::cash(ed, checkId, USD(25)));
env.close();
// Check cash actually failed, no change to owner count or USD
// balance
checkAccountUSD(ed, edHigh, 2, false, USD(0));
}
else
{
env(check::cash(ed, checkId, USD(25)));
env.close();
// Without fix: owner count stays 2 (due to eur and jpy
// trustlines), reserve not set.
checkAccountUSD(ed, edHigh, 2, false, USD(25));
}
}
}
public:
void
run() override
{
testTrustNonexistent();
testCreditLimit();
auto testWithFeatures = [this](FeatureBitset features) {
testPayNonexistent(features);
testDirectRipple(features);
testWithTransferFee(false, false, features);
testWithTransferFee(false, true, features);
testWithTransferFee(true, false, features);
testWithTransferFee(true, true, features);
testWithPath(features);
testIndirect(features);
testIndirectMultiPath(true, features);
testIndirectMultiPath(false, features);
testInvoiceID(features);
testOwnerCountOnBalanceChange(features);
};
using namespace test::jtx;
auto const sa = testable_amendments();
testWithFeatures(sa - featurePermissionedDEX);
testWithFeatures(sa - fixTrustLineOwnerCount);
testWithFeatures(sa);
}
};
BEAST_DEFINE_TESTSUITE(TrustAndBalance, app, xrpl);
} // namespace xrpl