From 1bb39b9b4f075da1f7cb12b516516d5591652b85 Mon Sep 17 00:00:00 2001 From: Vladislav Vysokikh Date: Tue, 20 Jan 2026 15:47:34 +0000 Subject: [PATCH] [wip] added test for edge case balance --- src/libxrpl/ledger/View.cpp | 14 ++- src/test/app/TrustAndBalance_test.cpp | 142 +++++++++++++++++++++----- 2 files changed, 128 insertions(+), 28 deletions(-) diff --git a/src/libxrpl/ledger/View.cpp b/src/libxrpl/ledger/View.cpp index 93f03ccb82..60c816170f 100644 --- a/src/libxrpl/ledger/View.cpp +++ b/src/libxrpl/ledger/View.cpp @@ -2069,8 +2069,18 @@ rippleCreditIOU( && !(uFlags & (bSenderHigh ? lsfLowReserve : lsfHighReserve))) // Receiver reserve is not set. { - adjustOwnerCount( - view, view.peek(keylet::account(uReceiverID)), 1, j); + auto const sleReceiver = view.peek(keylet::account(uReceiverID)); + auto const ownerCount = sleReceiver->getFieldU32(sfOwnerCount); + + // Trust lines use "owner count < 2" reserve exception + XRPAmount const reserve = (ownerCount < 2) + ? XRPAmount(beast::zero) + : view.fees().accountReserve(ownerCount + 1); + + if (sleReceiver->getFieldAmount(sfBalance) < reserve) + return tecINSUFFICIENT_RESERVE; + + adjustOwnerCount(view, sleReceiver, 1, j); sleRippleState->setFieldU32( sfFlags, diff --git a/src/test/app/TrustAndBalance_test.cpp b/src/test/app/TrustAndBalance_test.cpp index d927406482..9e1316d6c7 100644 --- a/src/test/app/TrustAndBalance_test.cpp +++ b/src/test/app/TrustAndBalance_test.cpp @@ -453,7 +453,6 @@ class TrustAndBalance_test : public beast::unit_test::suite auto const USD = gw["USD"]; - // Helper lambda to check alice's owner count and reserve flag auto checkAlice = [&](std::uint32_t expectedOwnerCount, bool expectedReserveSet) { BEAST_EXPECT( @@ -468,24 +467,20 @@ class TrustAndBalance_test : public beast::unit_test::suite env.fund(XRP(10000), gw, alice, market); env.close(); - // create trust lines env(trust(alice, USD(1000))); env(trust(market, USD(1000))); env.close(); checkAlice(1, true); - // gw issues USD to alice and market env(pay(gw, alice, USD(100))); env(pay(gw, market, USD(1000))); env.close(); checkAlice(1, true); - // gw clears asfDefaultRipple env(fclear(gw, asfDefaultRipple)); env.close(); - // alice clears trustline limit. This should trigger the check for - // default ripple state and charge gw for non-default state + // alice clears trustline limit, triggering default ripple state check env(trust(alice, USD(0))); env.close(); checkAlice(1, true); @@ -495,13 +490,11 @@ class TrustAndBalance_test : public beast::unit_test::suite env.close(); checkAlice(0, false); - // market offers USD for XRP env(offer(market, XRP(100), USD(100))); env.close(); - // Now alice acquires balance again by placing an offer (direct payment - // would fail because alice set limit to 0). This offer will cross - // market's. Balance goes from 0 to positive - this triggers the new - // increment logic + + // alice acquires balance via offer crossing (direct payment would fail + // because limit is 0). Balance 0 -> positive triggers increment logic env(offer(alice, USD(50), XRP(50))); env.close(); @@ -512,31 +505,128 @@ class TrustAndBalance_test : public beast::unit_test::suite checkAlice(0, false); } + void + testInsufficientReserveOnOwnerCountChange(FeatureBitset features) + { + testcase("Insufficient Reserve on Owner Count Change"); + using namespace test::jtx; + + Env env{*this, features}; + Account gw{"gateway"}; + Account alice{"alice"}; + Account market{"market"}; + bool const aliceHigh = alice.id() > gw.id(); + + 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; + + auto checkAliceUSD = [&](std::uint32_t expectedOwnerCount, + bool expectedReserveSet) { + BEAST_EXPECT( + env.le(alice)->getFieldU32(sfOwnerCount) == expectedOwnerCount); + auto const line = + env.le(keylet::line(alice, gw, to_currency("USD"))); + BEAST_EXPECT( + line->isFlag(aliceHigh ? lsfHighReserve : lsfLowReserve) == + expectedReserveSet); + }; + + env.fund(XRP(10000), gw, market); + // Fund alice with enough for reserve + increment + 50 XRP + fees + // alice performs 6 transactions: 2x trust, 2x pay, 1x pay excess, 1x + // offer + env.fund(acctReserve + incReserve + XRP(50) + fee * 6, alice); + env.close(); + + // Create trust lines - alice needs owner count >= 2 for reserve check + env(trust(alice, USD(1000))); + env(trust(market, USD(1000))); + env.close(); + checkAliceUSD(1, true); + + env(pay(gw, alice, USD(100))); + env(pay(gw, market, USD(1000))); + env.close(); + + env(fclear(gw, asfDefaultRipple)); + env.close(); + + env(trust(alice, USD(0))); + env.close(); + checkAliceUSD(1, true); + + // alice clears the USD balance causing decrement in owners count + env(pay(alice, gw, USD(100))); + env.close(); + checkAliceUSD(0, false); + + // Reduce alice's balance to 50 XRP + fee + // Owner count is 0 + auto aliceBalance = env.balance(alice); + auto targetBalance = acctReserve + XRP(50) + fee; + if (aliceBalance > targetBalance) + { + auto excess = aliceBalance - targetBalance - fee; + if (excess > drops(0)) + env(pay(alice, gw, excess)); + env.close(); + } + + aliceBalance = env.balance(alice); + BEAST_EXPECT(aliceBalance == acctReserve + XRP(50) + fee); + + env(offer(market, XRP(100), USD(100))); + env.close(); + + // alice's offer would cross, causing USD balance 0 -> + // positive. With fix: owner count increment requires reserve, so fails. + // Without fix: no owner count change, so succeeds. + if (features[fixTrustLineOwnerCount]) + { + env(offer(alice, USD(50), XRP(50))); + env.close(); + + env.require(balance(alice, USD(0)), offers(alice, 1)); + checkAliceUSD(1, false); + } + else + { + env(offer(alice, USD(50), XRP(50))); + env.close(); + + env.require(balance(alice, USD(50)), offers(alice, 0)); + checkAliceUSD(0, false); + } + } + public: void run() override { - testTrustNonexistent(); - testCreditLimit(); + // 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); + // 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); + testInsufficientReserveOnOwnerCountChange(features); }; using namespace test::jtx; auto const sa = testable_amendments(); - testWithFeatures(sa - featurePermissionedDEX); + // testWithFeatures(sa - featurePermissionedDEX); testWithFeatures(sa - fixTrustLineOwnerCount); testWithFeatures(sa); }