Files
rippled/src/test/app/lending/LoanRounding_test.cpp
2026-08-19 09:47:11 +01:00

1126 lines
46 KiB
C++

#include <test/app/lending/LoanTestBase.h>
#include <test/jtx/Account.h>
#include <test/jtx/Env.h>
#include <test/jtx/TestHelpers.h>
#include <test/jtx/amount.h>
#include <test/jtx/fee.h>
#include <test/jtx/flags.h>
#include <test/jtx/jtx_json.h>
#include <test/jtx/mpt.h>
#include <test/jtx/pay.h>
#include <test/jtx/ter.h>
#include <test/jtx/trust.h>
#include <test/jtx/txflags.h>
#include <test/jtx/vault.h>
#include <xrpl/basics/Number.h>
#include <xrpl/beast/unit_test/suite.h>
#include <xrpl/beast/utility/Journal.h>
#include <xrpl/beast/utility/Zero.h>
#include <xrpl/json/json_value.h>
#include <xrpl/ledger/ApplyView.h>
#include <xrpl/ledger/OpenView.h>
#include <xrpl/ledger/Sandbox.h>
#include <xrpl/ledger/helpers/LendingHelpers.h>
#include <xrpl/protocol/Asset.h>
#include <xrpl/protocol/Feature.h>
#include <xrpl/protocol/Indexes.h>
#include <xrpl/protocol/Issue.h>
#include <xrpl/protocol/Keylet.h>
#include <xrpl/protocol/Protocol.h>
#include <xrpl/protocol/SField.h>
#include <xrpl/protocol/STAmount.h>
#include <xrpl/protocol/SeqProxy.h>
#include <xrpl/protocol/TER.h>
#include <xrpl/protocol/TxFlags.h>
#include <xrpl/protocol/Units.h>
#include <array>
#include <chrono>
#include <cstdint>
#include <optional>
#include <ostream>
#include <string>
#include <tuple>
namespace xrpl::test {
class LoanRounding_test : public LoanTestBase
{
private:
void
testDustManipulation(FeatureBitset features)
{
testcase("Dust manipulation");
using namespace jtx;
using namespace std::chrono_literals;
Env env{*this, features};
// Setup: Create accounts
Account const issuer{"issuer"};
Account const lender{"lender"};
Account const borrower{"borrower"};
Account const victim{"victim"};
env.fund(XRP(1'000'000'00), issuer, lender, borrower, victim);
env.close();
// Step 1: Create vault with IOU asset
auto asset = issuer["USD"];
env(trust(lender, asset(100000)));
env(trust(borrower, asset(100000)));
env(trust(victim, asset(100000)));
env(pay(issuer, lender, asset(50000)));
env(pay(issuer, borrower, asset(50000)));
env(pay(issuer, victim, asset(50000)));
env.close();
BrokerParameters const brokerParams{
.vaultDeposit = 10000,
.debtMax = Number{0},
.coverRateMin = TenthBips32{1000},
.coverRateLiquidation = TenthBips32{2500}};
auto broker = createVaultAndBroker(env, asset, lender, brokerParams);
auto const loanKeyletOpt = [&]() -> std::optional<Keylet> {
auto const brokerSle = env.le(keylet::loanBroker(broker.brokerID));
if (!BEAST_EXPECT(brokerSle))
return std::nullopt;
// Broker has no loans
BEAST_EXPECT(brokerSle->at(sfOwnerCount) == 0);
// The loan keylet is based on the LoanSequence of the
// _LOAN_BROKER_ object.
auto const loanSequence = brokerSle->at(sfLoanSequence);
return keylet::loan(broker.brokerID, SeqProxy::rawSequence(loanSequence));
}();
if (!loanKeyletOpt)
return;
auto const& vaultKeylet = broker.vaultKeylet();
{
auto const vaultSle = env.le(vaultKeylet);
Number const assetsTotal = vaultSle->at(sfAssetsTotal);
Number const assetsAvail = vaultSle->at(sfAssetsAvailable);
log << "Before loan creation:" << std::endl;
log << " AssetsTotal: " << assetsTotal << std::endl;
log << " AssetsAvailable: " << assetsAvail << std::endl;
log << " Difference: " << (assetsTotal - assetsAvail) << std::endl;
// before the loan the assets total and available should be equal
BEAST_EXPECT(assetsAvail == assetsTotal);
BEAST_EXPECT(assetsAvail == broker.asset(brokerParams.vaultDeposit).number());
}
Keylet const& loanKeylet = *loanKeyletOpt;
LoanParameters const loanParams{
.account = lender,
.counter = borrower,
.principalRequest = Number{100},
.interest = TenthBips32{1922},
.payTotal = 5816,
.payInterval = 86400 * 6,
.gracePd = 86400 * 5,
};
env(loanParams(env, broker));
env.close();
// Wait for loan to be late enough to default
env.close(std::chrono::seconds(86400 * 40)); // 40 days
{
auto const vaultSle = env.le(vaultKeylet);
Number const assetsTotal = vaultSle->at(sfAssetsTotal);
Number const assetsAvail = vaultSle->at(sfAssetsAvailable);
log << "After loan creation:" << std::endl;
log << " AssetsTotal: " << assetsTotal << std::endl;
log << " AssetsAvailable: " << assetsAvail << std::endl;
log << " Difference: " << (assetsTotal - assetsAvail) << std::endl;
auto const loanSle = env.le(loanKeylet);
if (!BEAST_EXPECT(loanSle))
return;
auto const state = constructLoanState(loanSle);
log << "Loan state:" << std::endl;
log << " ValueOutstanding: " << state.valueOutstanding << std::endl;
log << " PrincipalOutstanding: " << state.principalOutstanding << std::endl;
log << " InterestOutstanding: " << state.interestOutstanding() << std::endl;
log << " InterestDue: " << state.interestDue << std::endl;
log << " FeeDue: " << state.managementFeeDue << std::endl;
// after loan creation the assets total and available should
// reflect the value of the loan
BEAST_EXPECT(assetsAvail < assetsTotal);
BEAST_EXPECT(
assetsAvail ==
broker.asset(brokerParams.vaultDeposit - loanParams.principalRequest).number());
BEAST_EXPECT(
assetsTotal ==
broker.asset(brokerParams.vaultDeposit + state.interestDue).number());
}
// Step 7: Trigger default (dust adjustment will occur)
env(jtx::loan::manage(lender, loanKeylet.key, tfLoanDefault));
env.close();
// Step 8: Verify phantom assets created
{
auto const vaultSle2 = env.le(vaultKeylet);
Number const assetsTotal2 = vaultSle2->at(sfAssetsTotal);
Number const assetsAvail2 = vaultSle2->at(sfAssetsAvailable);
log << "After default:" << std::endl;
log << " AssetsTotal: " << assetsTotal2 << std::endl;
log << " AssetsAvailable: " << assetsAvail2 << std::endl;
log << " Difference: " << (assetsTotal2 - assetsAvail2) << std::endl;
// after a default the assets total and available should be equal
BEAST_EXPECT(assetsAvail2 == assetsTotal2);
}
}
void
testRoundingAllowsUndercoverage(FeatureBitset features)
{
testcase("Minimum cover rounding allows undercoverage (XRP)");
using namespace jtx;
using namespace loan_broker;
Env env{*this, features};
Account const lender{"lender"};
Account const borrower{"borrower"};
env.fund(XRP(200'000), lender, borrower);
env.close();
// Vault with XRP asset
Vault const vault{env};
auto [vaultCreate, vaultKeylet] = vault.create({.owner = lender, .asset = xrpIssue()});
env(vaultCreate);
env.close();
BEAST_EXPECT(env.le(vaultKeylet));
// Seed the vault with XRP so it can fund the loan principal
PrettyAsset const xrpAsset{xrpIssue(), 1};
BrokerParameters const brokerParams{
.vaultDeposit = 1'000,
.debtMax = Number{0},
.coverRateMin = TenthBips32{10'000},
.coverDeposit = 82,
};
auto const brokerInfo = createVaultAndBroker(env, xrpAsset, lender, brokerParams);
// Create a loan with principal 804 XRP and 0% interest (so
// DebtTotal increases by exactly 804)
env(loan::set(borrower, brokerInfo.brokerID, xrpAsset(804).value()),
loan::kInterestRate(TenthBips32(0)),
Sig(sfCounterpartySignature, lender),
Fee(env.current()->fees().base * 2));
BEAST_EXPECT(env.ter() == tesSUCCESS);
env.close();
// Verify DebtTotal is exactly 804
if (auto const brokerSle = env.le(keylet::loanBroker(brokerInfo.brokerID));
BEAST_EXPECT(brokerSle))
{
log << *brokerSle << std::endl;
BEAST_EXPECT(brokerSle->at(sfDebtTotal) == Number(804));
}
// Attempt to withdraw 2 XRP to self, leaving 80 XRP CoverAvailable.
// The minimum is 80.4 XRP, which rounds up to 81 XRP, so this fails.
env(coverWithdraw(lender, brokerInfo.brokerID, xrpAsset(2).value()),
Ter(tecINSUFFICIENT_FUNDS));
BEAST_EXPECT(env.ter() == tecINSUFFICIENT_FUNDS);
env.close();
// Attempt to withdraw 1 XRP to self, leaving 81 XRP CoverAvailable.
// because that leaves sufficient cover, this succeeds
env(coverWithdraw(lender, brokerInfo.brokerID, xrpAsset(1).value()));
BEAST_EXPECT(env.ter() == tesSUCCESS);
env.close();
// Validate CoverAvailable == 81 XRP and DebtTotal remains 804
if (auto const brokerSle = env.le(keylet::loanBroker(brokerInfo.brokerID));
BEAST_EXPECT(brokerSle))
{
log << *brokerSle << std::endl;
BEAST_EXPECT(brokerSle->at(sfCoverAvailable) == xrpAsset(81).value());
BEAST_EXPECT(brokerSle->at(sfDebtTotal) == Number(804));
// Also demonstrate that the true minimum (804 * 10%) exceeds 80
auto const theoreticalMin = tenthBipsOfValue(Number(804), TenthBips32(10'000));
log << "Theoretical min cover: " << theoreticalMin << std::endl;
BEAST_EXPECT(Number(804, -1) == theoreticalMin);
}
}
void
testYieldTheftRounding(std::uint32_t flags)
{
testcase("Rounding manipulation does not permit yield theft");
using namespace jtx;
using namespace loan;
// 1. Setup Environment
Env env(*this, all_);
Account const issuer{"issuer"};
Account const lender{"lender"};
Account const borrower{"borrower"};
env.fund(XRP(1000), issuer, lender, borrower);
env.close();
// 2. Asset Selection
PrettyAsset const iou = issuer["USD"];
env(trust(lender, iou(100'000'000)));
env(trust(borrower, iou(100'000'000)));
env(pay(issuer, lender, iou(100'000'000)));
env(pay(issuer, borrower, iou(100'000'000)));
env.close();
// 3. Create Vault and Broker with High Debt Limit (100M)
auto const brokerInfo = createVaultAndBroker(
env,
iou,
lender,
{
.vaultDeposit = 5'000'000,
.debtMax = Number{100'000'000},
.coverDeposit = 500'000,
});
auto const [currentSeq, vaultKeylet] = [&]() {
auto const brokerSle = env.le(keylet::loanBroker(brokerInfo.brokerID));
if (!BEAST_EXPECT(brokerSle))
return std::make_tuple(0u, keylet::unchecked(beast::kZero));
auto const currentSeq = brokerSle->at(sfLoanSequence);
auto const vaultKeylet = keylet::vault(brokerSle->at(sfVaultID));
return std::make_tuple(currentSeq, vaultKeylet);
}();
// 4. Loan Parameters (Attack Vector)
Number const principal = 1'000'000;
TenthBips32 const interestRate = TenthBips32{1}; // 0.001%
std::uint32_t const paymentInterval = 86400;
std::uint32_t const paymentTotal = 3650;
auto const loanSetFee = Fee(env.current()->fees().base * 2);
env(set(borrower, brokerInfo.brokerID, iou(principal).value(), flags),
Sig(sfCounterpartySignature, lender),
loan::kInterestRate(interestRate),
loan::kPaymentInterval(paymentInterval),
loan::kPaymentTotal(paymentTotal),
Fee(loanSetFee));
env.close();
// --- RETRIEVE OBJECTS & SETUP ATTACK ---
auto borrowerBalance = [&]() { return env.balance(borrower, iou); };
auto const borrowerScale = static_cast<STAmount const&>(borrowerBalance()).exponent();
auto const loanKeylet =
keylet::loan(brokerInfo.brokerID, SeqProxy::rawSequence(currentSeq));
auto const maybePeriodicPayment = [&]() -> std::optional<STAmount> {
auto const loanSle = env.le(loanKeylet);
if (!BEAST_EXPECT(loanSle))
return std::nullopt;
// Construct Payment
return STAmount{iou, loanSle->at(sfPeriodicPayment)};
}();
if (!maybePeriodicPayment)
return;
auto const periodicPayment = *maybePeriodicPayment;
auto const roundedPayment =
roundToScale(periodicPayment, borrowerScale, Number::RoundingMode::Upward);
// ATTACK: Add dust buffer (1e-9) to force 'excess' logic execution
STAmount const paymentBuffer{iou, Number(1, -9)};
STAmount const attackPayment = periodicPayment + paymentBuffer;
auto const maybeInitialVaultAssets = [&]() -> std::optional<Number> {
auto const vault = env.le(vaultKeylet);
if (!BEAST_EXPECT(vault))
return std::nullopt;
return vault->at(sfAssetsTotal);
}();
if (!maybeInitialVaultAssets)
return;
auto const initialVaultAssets = *maybeInitialVaultAssets;
// 5. Execution Loop
int yieldTheftCount = 0;
auto previousAssetsTotal = initialVaultAssets;
for (int i = 0; i < 100; ++i)
{
auto const balanceBefore = borrowerBalance();
env(pay(borrower, loanKeylet.key, attackPayment, flags));
env.close();
auto const borrowerDelta = balanceBefore - borrowerBalance();
BEAST_EXPECT(borrowerDelta.signum() == roundedPayment.signum());
auto const loanSle = env.le(loanKeylet);
if (!BEAST_EXPECT(loanSle))
break;
auto const updatedPayment = STAmount{iou, loanSle->at(sfPeriodicPayment)};
BEAST_EXPECT(
(roundToScale(updatedPayment, borrowerScale, Number::RoundingMode::Upward) ==
roundedPayment));
BEAST_EXPECT(
(updatedPayment == periodicPayment) ||
(flags == tfLoanOverpayment && i >= 2 && updatedPayment < periodicPayment));
auto const currentVaultSle = env.le(vaultKeylet);
if (!BEAST_EXPECT(currentVaultSle))
break;
auto const currentAssetsTotal = currentVaultSle->at(sfAssetsTotal);
auto const delta = currentAssetsTotal - previousAssetsTotal;
BEAST_EXPECT(
(delta == beast::kZero && borrowerDelta <= roundedPayment) ||
(delta > beast::kZero && borrowerDelta > roundedPayment));
// If tx succeeded but Assets Total didn't change, interest was
// stolen.
if (delta == beast::kZero && borrowerDelta > roundedPayment)
{
yieldTheftCount++;
}
previousAssetsTotal = currentAssetsTotal;
}
BEAST_EXPECTS(yieldTheftCount == 0, std::to_string(yieldTheftCount));
}
// Regression for the dual-rounding fix at coarse (integer-MPT) scale.
//
// Loan: P=1, r=50% (50000 tenth-bips), n=3, yearly interval. The
// amortization schedule produces a fractional principal
// (~0.47) which under round-to-nearest collapses to 0 in a single
// step, causing `doPayment`'s strict `>` assertion on principal to
// fire mid-loan. With fixCleanup3_2_0 enabled, principal is rounded
// upward (sticks at 1 across the first two periods) and only clears
// in the final payment.
//
// The test pays one period at a time across three LoanPay
// transactions and verifies the loan completes (paymentRemaining=0)
// with totals matching the loan's economics (1 principal + 2 interest).
void
testIntegerScalePrincipalSticks(FeatureBitset features)
{
// Without fixCleanup3_2_0, this behavior will abort the server, so
// don't run without it.
if (!features[fixCleanup3_2_0])
return;
testcase("edge: integer MPT principal stuck mid-loan completes via final");
using namespace jtx;
Env env(*this, features);
Account const issuer{"issuer"};
Account const lender{"lender"};
Account const borrower{"borrower"};
env.fund(XRP(100'000), issuer, lender, borrower);
env.close();
MPTTester mptt{env, issuer, kMptInitNoFund};
mptt.create({.maxAmt = 100'000, .flags = tfMPTCanTransfer});
PrettyAsset const asset{mptt.issuanceID()};
mptt.authorize({.account = lender});
mptt.authorize({.account = borrower});
env(pay(issuer, lender, asset(10'000)));
env(pay(issuer, borrower, asset(10'000)));
env.close();
Vault const vault{env};
auto [vaultTx, vaultKeylet] = vault.create({.owner = lender, .asset = asset});
env(vaultTx);
env.close();
env(vault.deposit({.depositor = lender, .id = vaultKeylet.key, .amount = asset(5'000)}));
env.close();
auto const brokerKeylet =
keylet::loanBroker(lender.id(), SeqProxy::rawSequence(env.seq(lender)));
env(loan_broker::set(lender, vaultKeylet.key),
loan_broker::kDebtMaximum(Number{100}),
Fee(env.current()->fees().base * 2));
env.close();
auto const brokerStateBefore = env.le(brokerKeylet);
if (!BEAST_EXPECT(brokerStateBefore))
return;
auto const loanSequence = brokerStateBefore->at(sfLoanSequence);
auto const loanKeylet = keylet::loan(brokerKeylet.key, SeqProxy::rawSequence(loanSequence));
env(loan::set(borrower, brokerKeylet.key, Number{1}),
Sig(sfCounterpartySignature, lender),
loan::kInterestRate(TenthBips32{50'000}),
loan::kPaymentTotal(3),
loan::kPaymentInterval(31'536'000),
Fee(env.current()->fees().base * 2));
env.close();
auto const borrowerStart = env.balance(borrower, asset).value();
// Three separate periodic payments of 1 each. Expected per-period
// evolution at integer MPT scale (TVO = PO + interestDue +
// managementFeeDue):
// start: PO=1, TVO=3, paymentRemaining=3
// after pay #1: PO=1, TVO=2, paymentRemaining=2 (principal sticks)
// after pay #2: PO=1, TVO=1, paymentRemaining=1 (principal sticks)
// after pay #3: PO=0, TVO=0, paymentRemaining=0 (final clears)
std::array<Number, 3> const expectedPO{Number{1}, Number{1}, Number{0}};
std::array<Number, 3> const expectedTVO{Number{2}, Number{1}, Number{0}};
std::array<std::uint32_t, 3> const expectedRemaining{2, 1, 0};
for (int i = 0; i < 3; ++i)
{
env(loan::pay(borrower, loanKeylet.key, asset(1)), Ter(tesSUCCESS));
env.close();
auto const sle = env.le(loanKeylet);
if (!BEAST_EXPECT(sle))
return;
BEAST_EXPECT(sle->at(sfPrincipalOutstanding) == expectedPO[i]);
BEAST_EXPECT(sle->at(sfTotalValueOutstanding) == expectedTVO[i]);
BEAST_EXPECT(sle->at(sfPaymentRemaining) == expectedRemaining[i]);
}
// Borrower paid 3 total regardless of fee split (1 principal + 2
// interest+fee, matching loan economics).
auto const borrowerEnd = env.balance(borrower, asset).value();
BEAST_EXPECT(borrowerStart - borrowerEnd == asset(3).value());
}
#if LOAN_TODO
void
testLoanCoverMinimumRoundingExploit(FeatureBitset features)
{
auto testLoanCoverMinimumRoundingExploit = [&, this](Number const& principalRequest) {
testcase << "LoanBrokerCoverClawback drains cover via rounding"
<< " principalRequested=" << to_string(principalRequest);
using namespace jtx;
using namespace loan;
using namespace loan_broker;
Env env(*this, features);
Account const issuer{"issuer"};
Account const lender{"lender"};
Account const borrower{"borrower"};
env.fund(XRP(1'000'000'000), issuer, lender, borrower);
env.close();
env(fset(issuer, asfAllowTrustLineClawback));
env.close();
PrettyAsset const asset = issuer[iouCurrency];
env(trust(lender, asset(2'000'0000)));
env(trust(borrower, asset(2'000'0000)));
env.close();
env(pay(issuer, lender, asset(2'000'0000)));
env.close();
BrokerParameters brokerParams{.debtMax = 0, .coverRateMin = TenthBips32{10'000}};
BrokerInfo broker{createVaultAndBroker(env, asset, lender, brokerParams)};
auto const loanSetFee = Fee(env.current()->fees().base * 2);
auto createTx = env.jt(
set(borrower, broker.brokerID, principalRequest),
Sig(sfCounterpartySignature, lender),
loanSetFee,
kPaymentInterval(600),
kPaymentTotal(1),
kGracePeriod(60));
env(createTx);
env.close();
auto const brokerBefore = env.le(keylet::loanBroker(broker.brokerID));
BEAST_EXPECT(brokerBefore);
if (!brokerBefore)
return;
Number const debtOutstanding = brokerBefore->at(sfDebtTotal);
Number const coverAvailableBefore = brokerBefore->at(sfCoverAvailable);
BEAST_EXPECT(debtOutstanding > Number{});
BEAST_EXPECT(coverAvailableBefore > Number{});
log << "debt=" << to_string(debtOutstanding)
<< " cover_available=" << to_string(coverAvailableBefore);
env(coverClawback(issuer, 0), loanBrokerID(broker.brokerID));
env.close();
auto const brokerAfter = env.le(keylet::loanBroker(broker.brokerID));
BEAST_EXPECT(brokerAfter);
if (!brokerAfter)
return;
Number const debtAfter = brokerAfter->at(sfDebtTotal);
// the debt has not changed
BEAST_EXPECT(debtAfter == debtOutstanding);
Number const coverAvailableAfter = brokerAfter->at(sfCoverAvailable);
// since the cover rate min != 0, the cover available should not
// be zero
BEAST_EXPECT(coverAvailableAfter != Number{});
};
// Call the lambda with different principal values
testLoanCoverMinimumRoundingExploit(Number{1, -30}); // 1e-30 units
testLoanCoverMinimumRoundingExploit(Number{1, -20}); // 1e-20 units
testLoanCoverMinimumRoundingExploit(Number{1, -10}); // 1e-10 units
testLoanCoverMinimumRoundingExploit(Number{1, 1}); // 1e-10 units
}
#endif
// A residual overpayment can reduce the stored principal by one scale-unit
// *less* than computeOverpaymentComponents predicts, firing the
// "principal change agrees" XRPL_ASSERT_PARTS in doOverpayment:
//
// trackedPrincipalDelta == principalOutstanding - newPrincipalOutstanding
//
// tryOverpayment re-amortizes the loan at the reduced principal, then
// re-derives the theoretical principal from the new periodic payment via
// (P * paymentFactor) / paymentFactor. That round-trip is not exact in
// Number's 19-digit arithmetic; a positive residual pushes the recomputed
// principal a hair above the exact grid point `oldPrincipal - delta`, and
// the Upward rounding in tryOverpayment then bumps it a full scale-unit
// higher. The principal therefore drops by `delta - 1 unit`, not `delta`.
//
// Concrete case (isolated, at the tryOverpayment level):
// A 100 USD loan at the minimum non-zero rate, 3 payments, loanScale -10.
// After one regular payment (principalOutstanding 66.6666666674) a residual overpayment of
// 0.049999998 yields trackedPrincipalDelta 0.048999998 but only reduces the principal by
// 0.0489999979 (newPrincipal 66.6176666695) — short by 1e-10.
//
// With fixCleanup3_2_0, tryOverpayment pins the new principal to the exact,
// on-grid reduction (oldPrincipal - trackedPrincipalDelta) instead of the
// lossy (P*factor)/factor round-trip, so the assertion holds and the
// overpayment applies cleanly. The three "principal change agrees" /
// "interest paid agrees" / "principal payment matches" assertions are
// gated behind the same amendment, so without it they are disabled (the
// server does not abort) and the loan keeps the pre-amendment computation.
//
// The test runs the same scenario under both amendment settings and checks
// the stored principal against a ground-truth value derived independently of
// the loan-state computation under test.
void
testBugOverpaymentPrincipalChange()
{
testcase("bug: doOverpayment asserts 'principal change agrees'");
using namespace jtx;
using namespace loan;
using namespace xrpl::detail;
struct Params
{
TenthBips32 interestRate;
TenthBips16 managementFeeRate;
std::uint32_t paymentTotal;
std::uint32_t paymentInterval;
std::int64_t principal;
Number overpayment;
TenthBips32 overpaymentInterestRate;
TenthBips32 overpaymentFeeRate;
std::optional<int> vaultScale;
};
struct Result
{
Number principalOutstanding; // stored principal after the LoanPay
Number expectedNewPrincipal; // ground truth, independent of the fix
Number managementFeeChange; // managementFeeOutstanding after - before
Number unit; // one scale-unit at the loan scale
};
auto runScenario = [this](FeatureBitset features, Params const& p) -> Result {
Env env(*this, features);
Account const issuer{"issuer"};
Account const lender{"vaultOwner"};
Account const borrower{"borrower"};
PrettyAsset const iouAsset = createFundedRippleIouAsset(env, issuer, lender, borrower);
Asset const asset = iouAsset.raw();
auto const broker = createVaultAndBroker(
env,
iouAsset,
lender,
{.vaultDeposit = 900'000,
.debtMax = 0,
.managementFeeRate = p.managementFeeRate,
.vaultScale = p.vaultScale});
auto const brokerSle = env.le(broker.brokerKeylet());
BEAST_EXPECT(brokerSle);
auto const loanSequence = brokerSle ? brokerSle->at(sfLoanSequence) : 0;
auto const loanKeylet =
keylet::loan(broker.brokerID, SeqProxy::rawSequence(loanSequence));
env(set(borrower, broker.brokerID, Number{p.principal}, tfLoanOverpayment),
Sig(sfCounterpartySignature, lender),
kInterestRate(p.interestRate),
kPaymentTotal(p.paymentTotal),
kPaymentInterval(p.paymentInterval),
kGracePeriod(p.paymentInterval),
kOverpaymentFee(p.overpaymentFeeRate),
kOverpaymentInterestRate(p.overpaymentInterestRate),
Fee(env.current()->fees().base * 2),
Ter(tesSUCCESS));
env.close();
// The single LoanPay below makes one regular payment (the overpayment
// is smaller than one period) and leaves the residual as an
// overpayment.
auto const s = getCurrentState(env, broker, loanKeylet);
auto const periodicRate = loanPeriodicRate(s.interestRate, s.paymentInterval);
auto const onePeriod = computePaymentComponents(
env.current()->rules(),
asset,
s.loanScale,
s.totalValue,
s.principalOutstanding,
s.managementFeeOutstanding,
s.periodicPayment,
periodicRate,
s.paymentRemaining,
p.managementFeeRate);
// Ground truth: the stored principal must drop by exactly the regular
// payment's principal portion plus the overpayment's principal
// portion. computeOverpaymentComponents depends only on the
// overpayment amount and rates (not on the loan-state computation
// under test), so it is an independent oracle. Both components are
// computed under the same rules as the env so the payment factor
// matches.
auto const overpaymentComponents = computeOverpaymentComponents(
env.current()->rules(),
asset,
s.loanScale,
p.overpayment,
p.overpaymentInterestRate,
p.overpaymentFeeRate,
p.managementFeeRate);
Number const expectedNewPrincipal = s.principalOutstanding -
onePeriod.trackedPrincipalDelta - overpaymentComponents.trackedPrincipalDelta;
Number const managementFeeBefore = s.managementFeeOutstanding;
STAmount const payAmount{asset, onePeriod.trackedValueDelta + p.overpayment};
env(pay(borrower, loanKeylet.key, payAmount),
Txflags(tfLoanOverpayment),
Ter(tesSUCCESS));
env.close();
auto const loanSle = env.le(loanKeylet);
BEAST_EXPECT(loanSle);
return Result{
.principalOutstanding = loanSle ? Number{loanSle->at(sfPrincipalOutstanding)} : 0,
.expectedNewPrincipal = expectedNewPrincipal,
.managementFeeChange =
(loanSle ? Number{loanSle->at(sfManagementFeeOutstanding)} : Number{0}) -
managementFeeBefore,
.unit = Number{1, s.loanScale}};
};
// Scenario 1: the original near-zero-rate principal reproduction
// (loanScale -10, no management fee). 0.049999998 is smaller than one
// period, so it stays a residual overpayment.
Params const principalCase{
.interestRate = TenthBips32{1},
.managementFeeRate = TenthBips16{0},
.paymentTotal = 3,
.paymentInterval = 60,
.principal = 100,
.overpayment = Number{49999998, -9},
.overpaymentInterestRate = TenthBips32{1000},
.overpaymentFeeRate = TenthBips32{1000},
.vaultScale = 1};
// With fixCleanup3_2_0 the stored principal lands exactly on the
// ground-truth grid point: it is reduced by exactly the overpayment's
// principal portion. This is the key correctness check: if the principal
// pin were removed (even with the assertions still gated off), the lossy
// (P * factor) / factor round-trip would leave the principal one
// scale-unit high and this would fail.
Result const fixed = runScenario(all_, principalCase);
BEAST_EXPECTS(
fixed.principalOutstanding == fixed.expectedNewPrincipal,
"fixed principal " + to_string(fixed.principalOutstanding) + " != expected " +
to_string(fixed.expectedNewPrincipal));
// Without the amendment the loan amortizes with the catastrophically
// cancelling near-zero payment factor, so its schedule (and ground truth)
// differ from the fixed case; the gated assertions keep the server from
// aborting and the overpayment still lands exactly on that schedule.
Result const legacy = runScenario(all_ - fixCleanup3_2_0, principalCase);
BEAST_EXPECTS(
legacy.principalOutstanding == legacy.expectedNewPrincipal,
"legacy principal " + to_string(legacy.principalOutstanding) + " != expected " +
to_string(legacy.expectedNewPrincipal));
// Scenario 2: a normal-rate loan with a 10% management fee. At a normal
// rate the payment factor is identical across the amendment, so toggling
// fixCleanup3_2_0 isolates the fix. This overpayment (found by search)
// lands on a state where both the principal and the management fee differ
// by one scale-unit between the fixed and legacy paths.
Params const feeCase{
.interestRate = TenthBips32{10000},
.managementFeeRate = TenthBips16{10000},
.paymentTotal = 6,
.paymentInterval = 30u * 24 * 60 * 60,
.principal = 1000,
.overpayment = Number{214367363, -10},
.overpaymentInterestRate = TenthBips32{0},
.overpaymentFeeRate = TenthBips32{0},
.vaultScale = std::nullopt};
Result const feeFixed = runScenario(all_, feeCase);
Result const feeLegacy = runScenario(all_ - fixCleanup3_2_0, feeCase);
// With the fix the principal is the exact reduction; without it the lossy
// (P * factor) / factor round-trip leaves it one scale-unit high.
BEAST_EXPECTS(
feeFixed.principalOutstanding == feeFixed.expectedNewPrincipal,
"fee-case fixed principal " + to_string(feeFixed.principalOutstanding) +
" != expected " + to_string(feeFixed.expectedNewPrincipal));
BEAST_EXPECTS(
feeLegacy.principalOutstanding == feeLegacy.expectedNewPrincipal + feeLegacy.unit,
"fee-case legacy principal " + to_string(feeLegacy.principalOutstanding) +
" != expected " + to_string(feeLegacy.expectedNewPrincipal + feeLegacy.unit));
// Management fee: the overpayment re-amortizes a fee-bearing loan, so the management fee
// outstanding drops.
//
// Unlike the principal that is already at the correct precision, the re-amortized
// management fee is tenthBipsOfValue of the new schedule's gross interest, which depends
// on the recomputed periodic payment. So the expected change below is a pinned constant
// captured from a passing run a magic value only because there is nothing simpler to
// compare against.
//
// At the integration level, toggling the amendment also changes the regular payment's
// rounding so a fixed-vs-legacy comparison cannot isolate the overpayment management-fee
// fix.
BEAST_EXPECT(feeFixed.managementFeeChange == feeLegacy.managementFeeChange);
BEAST_EXPECTS(
(feeFixed.managementFeeChange == Number{-8219709543, -10}),
"fee-case mgmt fee change " + to_string(feeFixed.managementFeeChange));
}
// An overpayment whose residual amount has more precision than loanScale
// fires the isRounded(asset, overpayment, loanScale) assertion in
// computeOverpaymentComponents (and a downstream "interest paid agrees"
// assertion in doOverpayment). fixCleanup3_2_0 rounds the residual down
// to loanScale before passing it in. The pre-amendment path can't be
// tested here because the assertion fires in Debug builds and aborts
// the test process — see the PR description for context.
void
testBugOverpayUnroundedAmount()
{
testcase("bug: computeOverpaymentComponents isRounded assertion");
using namespace jtx;
using namespace loan;
Env env(*this, all_);
Account const issuer{"issuer"};
Account const lender{"vaultOwner"};
Account const borrower{"borrower"};
PrettyAsset const iouAsset = createFundedRippleIouAsset(env, issuer, lender, borrower);
auto const broker = createVaultAndBroker(
env,
iouAsset,
lender,
{.vaultDeposit = 100'000,
.debtMax = 5000,
.managementFeeRate = TenthBips16{1000},
.vaultScale = 1});
auto const sleBroker = env.le(broker.brokerKeylet());
if (!BEAST_EXPECT(sleBroker))
return;
auto const loanSequence = sleBroker->at(sfLoanSequence);
auto const loanKeylet = keylet::loan(broker.brokerID, SeqProxy::rawSequence(loanSequence));
using namespace loan;
env(set(borrower, broker.brokerID, Number{1000}, tfLoanOverpayment),
Sig(sfCounterpartySignature, lender),
kInterestRate(TenthBips32{10000}),
kPaymentTotal(12),
kPaymentInterval(60),
kGracePeriod(60),
kOverpaymentFee(TenthBips32{1000}),
kOverpaymentInterestRate(TenthBips32{1000}),
Fee(env.current()->fees().base * 2),
Ter(tesSUCCESS));
env.close();
// periodic * 1.5 at 15-sig-digit precision: 125.000154585042. This
// has too many digits to round cleanly to loanScale=-10, so the
// overpayment residual fails the isRounded check.
STAmount const payAmount{iouAsset.raw(), Number{125'000'154'585'042LL, -12}};
env(pay(borrower, loanKeylet.key, payAmount), Txflags(tfLoanOverpayment), Ter(tesSUCCESS));
env.close();
}
// A near-zero interest rate on a 100 USD loan
// produces total interest of ~6 units at loanScale -9. Numerical error
// in the amortization formula pushes the theoretical principal above
// the theoretical value, producing a negative theoretical interest.
// The payment delta then exceeds the actual outstanding interest,
// violating XRPL_ASSERT_PARTS in computePaymentComponents.
void
testBugInterestDueDeltaCrash()
{
testcase("bug: LoanPay asserts 'interest due delta' on near-zero rate");
using namespace jtx;
using namespace std::chrono_literals;
Env env(*this, all_);
Account const issuer{"issuer"};
Account const lender{"lender"};
Account const borrower{"borrower"};
env.fund(XRP(1'000'000), issuer, lender, borrower);
env.close();
env(fset(issuer, asfDefaultRipple));
env.close();
PrettyAsset const iouAsset = issuer["USD"];
env(trust(lender, iouAsset(1'000'000'000)));
env(trust(borrower, iouAsset(1'000'000'000)));
env(pay(issuer, lender, iouAsset(5'000'000)));
env(pay(issuer, borrower, iouAsset(5'000'000)));
env.close();
BrokerParameters const brokerParams{
.vaultDeposit = 1'000'000,
.debtMax = 1'000'000,
.coverRateMin = TenthBips32{0},
.coverDeposit = 0,
.managementFeeRate = TenthBips16{0},
.coverRateLiquidation = TenthBips32{0}};
BrokerInfo const broker{createVaultAndBroker(env, iouAsset, lender, brokerParams)};
using namespace loan;
auto const loanSetFee = Fee(env.current()->fees().base * 2);
Number const principalRequest{100};
auto createJson = env.json(
set(borrower, broker.brokerID, principalRequest),
Fee(loanSetFee),
Json(sfCounterpartySignature, json::ValueType::Object));
createJson["InterestRate"] = 1; // minimum non-zero rate
createJson["PaymentTotal"] = 3;
createJson["PaymentInterval"] = 600;
auto const keylet = nextLoanKeylet(env, broker);
createJson = env.json(createJson, Sig(sfCounterpartySignature, lender));
env(createJson, Ter(tesSUCCESS));
env.close();
// For principal=100, n=3 the amortization schedule produces a
// periodic payment ≈ 33.33 USD. We pay 35 USD, which is more than
// one period's worth — enough for the LoanPay path to enter
// computePaymentComponents and reach the assertion that fires
// when the bug is present. With the fix, the tx applies cleanly.
env(pay(borrower, keylet.key, iouAsset(35)), Ter(tesSUCCESS));
env.close();
}
// LoanDelete::deleteActiveLoan clears any sub-scale residual left on
// LoanBroker.DebtTotal when the last active loan is removed. In production
// the residual comes from cross-loan rounding when multiple loans on the
// same broker operate at significantly different scales (see the comment
// above the adjustImpreciseNumber call in LoanPay.cpp's doApply). Building
// that accumulation deterministically from real txs is fragile, so this
// test installs a sub-drop residual directly on the broker SLE via
// OpenLedger::modify — the same lower-layer edit LoanTwoStep_test's
// makeVaultAccrual uses to force VaultVersion::Legacy — and then submits
// the LoanDelete against the mutated open view. LoanBrokerInvariant only
// forbids negative DebtTotal, so a positive sub-scale value is
// invariant-safe; the residual (5e-8 drops) rounds toward zero to 0 drops
// so the XRPL_ASSERT_PARTS guarding the branch also holds.
void
testDeleteLastLoanClearsDebtDust()
{
testcase("coverage: LoanDelete clears sub-scale DebtTotal dust on last loan");
using namespace jtx;
using namespace loan;
Account const issuer{"issuer"};
Account const lender{"lender"};
Account const borrower{"borrower"};
Env env(*this, all_);
env.fund(XRP(1'000'000), issuer, lender, borrower);
env.close();
// scale = 1 keeps xrpAsset(N) at N drops so the tiny residual
// installed below is unambiguously sub-drop.
PrettyAsset const xrpAsset{xrpIssue(), 1};
// 0% interest so origination and payoff cancel to exactly zero on
// DebtTotal; the residual we test is installed by hand below.
BrokerParameters const brokerParams{
.vaultDeposit = 100'000,
.debtMax = 10'000,
.coverRateMin = TenthBips32{0},
.coverDeposit = 0,
.managementFeeRate = TenthBips16{0},
.coverRateLiquidation = TenthBips32{0}};
BrokerInfo const broker{createVaultAndBroker(env, xrpAsset, lender, brokerParams)};
auto const sleBroker0 = env.le(broker.brokerKeylet());
if (!BEAST_EXPECT(sleBroker0))
return;
auto const loanKeylet =
keylet::loan(broker.brokerID, SeqProxy::rawSequence(sleBroker0->at(sfLoanSequence)));
// Active loan (immediate flow), single payment, 0% interest.
env(set(borrower, broker.brokerID, xrpAsset(100).value()),
Sig(sfCounterpartySignature, lender),
kInterestRate(TenthBips32{0}),
kPaymentTotal(1),
kPaymentInterval(3600),
Fee(env.current()->fees().base * 2));
env.close();
// Fully pay off; 0% interest means the actual debit is exactly the
// principal, so DebtTotal returns cleanly to zero.
env(pay(borrower, loanKeylet.key, xrpAsset(200).value(), tfLoanFullPayment));
env.close();
// Baseline: DebtTotal is exactly zero, the broker still owns the
// (now fully-paid) loan, and PaymentRemaining is zero so LoanDelete
// will not trip tecHAS_OBLIGATIONS.
if (auto const b = env.le(broker.brokerKeylet()); BEAST_EXPECT(b))
{
BEAST_EXPECT(b->at(sfDebtTotal) == beast::kZero);
BEAST_EXPECT(b->at(sfOwnerCount) == 1);
}
if (auto const l = env.le(loanKeylet); BEAST_EXPECT(l))
BEAST_EXPECT(l->at(sfPaymentRemaining) == 0);
// Install a sub-drop residual on the broker's DebtTotal directly on
// the open ledger. Not closing after: OpenLedger::accept rebuilds
// the open view from the last-closed ledger and re-applies pending
// txs, discarding raw mutations, so every post-condition below is
// read from the open view.
Number const kResidual{5, -8};
auto const mutated =
env.app().getOpenLedger().modify([&](OpenView& view, beast::Journal) -> bool {
Sandbox sb(&view, TapNone);
auto b = sb.peek(broker.brokerKeylet());
if (!b)
return false;
b->at(sfDebtTotal) = kResidual;
sb.update(b);
sb.apply(view);
return true;
});
if (!BEAST_EXPECT(mutated))
return;
// Sanity: the residual is visible on the open view and rounds to
// zero at the vault's asset scale (which is what the branch's
// XRPL_ASSERT_PARTS requires).
if (auto const b = env.le(broker.brokerKeylet()); BEAST_EXPECT(b))
BEAST_EXPECT(b->at(sfDebtTotal) == kResidual);
if (auto const v = env.le(broker.vaultKeylet()); BEAST_EXPECT(v))
{
BEAST_EXPECT(
roundToAsset(
v->at(sfAsset),
Number{kResidual},
getAssetsTotalScale(v),
Number::RoundingMode::TowardsZero) == beast::kZero);
}
// Delete against the mutated open view. The last-loan branch of
// deleteActiveLoan fires: DebtTotal is zeroed, OwnerCount goes to
// zero, and the loan SLE is erased.
env(del(lender, loanKeylet.key));
if (auto const b = env.le(broker.brokerKeylet()); BEAST_EXPECT(b))
{
BEAST_EXPECT(b->at(sfDebtTotal) == beast::kZero);
BEAST_EXPECT(b->at(sfOwnerCount) == 0);
}
BEAST_EXPECT(!env.le(loanKeylet));
}
void
runAmendmentIndependent()
{
for (auto const flags : {0u, tfLoanOverpayment})
testYieldTheftRounding(flags);
testBugOverpaymentPrincipalChange();
testBugOverpayUnroundedAmount();
testBugInterestDueDeltaCrash();
testDeleteLastLoanClearsDebtDust();
}
// Tests run under each entry in amendmentCombinations().
void
runAmendmentSensitive(FeatureBitset features)
{
testDustManipulation(features);
testRoundingAllowsUndercoverage(features);
testIntegerScalePrincipalSticks(features);
#if LOAN_TODO
testLoanCoverMinimumRoundingExploit(features);
#endif
}
public:
void
run() override
{
runAmendmentIndependent();
for (auto const& features : jtx::amendmentCombinations(
{fixCleanup3_1_3, fixCleanup3_2_0, featureMPTokensV2}, all_))
runAmendmentSensitive(features);
}
};
BEAST_DEFINE_TESTSUITE(LoanRounding, tx, xrpl);
} // namespace xrpl::test