test: Add lending protocol bug reproductions

Adds 7 unit tests that reproduce externally-reported assertion
failures and invariant violations in the lending protocol:

- Vault_test: associateAsset rounding on deposit, last shareholder
  stuck after impairment
- Loan_test: computePaymentComponents interest-due-delta crash,
  doPayment partial principal, late payment fund conservation,
  doOverpayment interest-paid-agrees, computeOverpaymentComponents
  isRounded

Four of the Loan_test cases abort the debug binary when the bug
is present; they remain in run() unguarded so any regression is
loud.
This commit is contained in:
Vito
2026-04-23 14:15:01 +02:00
parent ea023121f5
commit dbbdbaacef
2 changed files with 593 additions and 0 deletions

View File

@@ -7197,6 +7197,405 @@ protected:
attemptWithdrawShares(depositorB, sharesLpB, tesSUCCESS);
}
// Reproduction of overpay_valid_overpayment.pdf.
// An overpayment whose residual amount is NOT rounded to loanScale
// fires the isRounded(asset, overpayment, loanScale) assertion in
// computeOverpaymentComponents.
void
testBugOverpayUnroundedAmount()
{
testcase("bug: computeOverpaymentComponents isRounded assertion");
using namespace jtx;
Env env(*this, all);
Account const issuer{"issuer"};
Account const vaultOwner{"vaultOwner"};
Account const depositor{"depositor"};
Account const borrower{"borrower"};
env.fund(XRP(1'000'000), issuer, vaultOwner, depositor, borrower);
env.close();
env(fset(issuer, asfDefaultRipple));
env.close();
PrettyAsset const iouAsset = issuer["USD"];
STAmount const iouLimit{iouAsset.raw(), Number{9'999'999'999'999'999LL}};
env(trust(vaultOwner, iouLimit));
env(trust(depositor, iouLimit));
env(trust(borrower, iouLimit));
env.close();
env(pay(issuer, vaultOwner, iouAsset(1'000'000)));
env(pay(issuer, depositor, iouAsset(1'000'000)));
env(pay(issuer, borrower, iouAsset(1'000'000)));
env.close();
Vault const vault{env};
auto [vaultTx, vaultKeylet] = vault.create({.owner = vaultOwner, .asset = iouAsset});
vaultTx[sfScale] = 1;
env(vaultTx);
env.close();
env(vault.deposit(
{.depositor = depositor, .id = vaultKeylet.key, .amount = iouAsset(100'000)}));
env.close();
auto const brokerKeylet = keylet::loanbroker(vaultOwner.id(), env.seq(vaultOwner));
{
using namespace loanBroker;
env(set(vaultOwner, vaultKeylet.key),
managementFeeRate(TenthBips16{1000}),
debtMaximum(Number{5000}),
fee(env.current()->fees().base * 2));
}
env.close();
auto const brokerStateBefore = env.le(brokerKeylet);
auto const loanSequence = brokerStateBefore->at(sfLoanSequence);
auto const loanKeylet = keylet::loan(brokerKeylet.key, loanSequence);
using namespace loan;
auto createJson = env.json(
set(borrower, brokerKeylet.key, Number{1000}, tfLoanOverpayment),
fee(env.current()->fees().base * 2),
json(sfCounterpartySignature, Json::objectValue));
createJson["InterestRate"] = 10000;
createJson["PaymentTotal"] = 12;
createJson["PaymentInterval"] = 60;
createJson["GracePeriod"] = 60;
createJson["OverpaymentFee"] = 1000;
createJson["OverpaymentInterestRate"] = 1000;
createJson = env.json(createJson, sig(sfCounterpartySignature, vaultOwner));
env(createJson, 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();
}
// Reproduction of overpay_interest_paid_agrees.pdf.
// An overpayment larger than the periodic payment enters the overpayment
// path with a residual rounded to loanScale (13 sig digits). The
// re-amortization then fires the "interest paid agrees" assertion in
// doOverpayment because the value change formula includes a
// management-fee delta that the independent derivation doesn't.
void
testBugOverpayInterestPaidAgrees()
{
testcase("bug: doOverpayment 'interest paid agrees' assertion");
using namespace jtx;
Env env(*this, all);
Account const issuer{"issuer"};
Account const vaultOwner{"vaultOwner"};
Account const depositor{"depositor"};
Account const borrower{"borrower"};
env.fund(XRP(1'000'000), issuer, vaultOwner, depositor, borrower);
env.close();
env(fset(issuer, asfDefaultRipple));
env.close();
PrettyAsset const iouAsset = issuer["USD"];
STAmount const iouLimit{iouAsset.raw(), Number{9'999'999'999'999'999LL}};
env(trust(vaultOwner, iouLimit));
env(trust(depositor, iouLimit));
env(trust(borrower, iouLimit));
env.close();
env(pay(issuer, vaultOwner, iouAsset(1'000'000)));
env(pay(issuer, depositor, iouAsset(1'000'000)));
env(pay(issuer, borrower, iouAsset(1'000'000)));
env.close();
// Vault with Scale=1
Vault const vault{env};
auto [vaultTx, vaultKeylet] = vault.create({.owner = vaultOwner, .asset = iouAsset});
vaultTx[sfScale] = 1;
env(vaultTx);
env.close();
env(vault.deposit(
{.depositor = depositor, .id = vaultKeylet.key, .amount = iouAsset(100'000)}));
env.close();
auto const brokerKeylet = keylet::loanbroker(vaultOwner.id(), env.seq(vaultOwner));
{
using namespace loanBroker;
env(set(vaultOwner, vaultKeylet.key),
managementFeeRate(TenthBips16{1000}), // 0.1%
debtMaximum(Number{5000}),
fee(env.current()->fees().base * 2));
}
env.close();
auto const brokerStateBefore = env.le(brokerKeylet);
auto const loanSequence = brokerStateBefore->at(sfLoanSequence);
auto const loanKeylet = keylet::loan(brokerKeylet.key, loanSequence);
// Loan with overpayment allowed.
using namespace loan;
auto createJson = env.json(
set(borrower, brokerKeylet.key, Number{1000}, tfLoanOverpayment),
fee(env.current()->fees().base * 2),
json(sfCounterpartySignature, Json::objectValue));
createJson["InterestRate"] = 10000; // 1%
createJson["PaymentTotal"] = 12;
createJson["PaymentInterval"] = 60;
createJson["GracePeriod"] = 60;
createJson["OverpaymentFee"] = 1000;
createJson["OverpaymentInterestRate"] = 1000;
createJson = env.json(createJson, sig(sfCounterpartySignature, vaultOwner));
env(createJson, ter(tesSUCCESS));
env.close();
// PeriodicPayment ~= 83.33343639002831971; overpay = periodic * 1.5
// rounded to 13 sig digits: 125.000154585.
STAmount const payAmount{iouAsset.raw(), Number{125'000'154'585LL, -9}};
env(pay(borrower, loanKeylet.key, payAmount), txflags(tfLoanOverpayment), ter(tesSUCCESS));
env.close();
}
// Reproduction of late_payment_precision.pdf.
// A late loan payment sums up regular payment + late interest + late
// fee + management fee on late interest. Each STAmount transfer is
// rounded individually, so the sum can lose a sub-0.00001 fractional
// part differently on the before- and after-sum. The debug-only
// "funds are conserved" assertion in LoanPay::doApply fires.
void
testBugLatePaymentFundConservation()
{
testcase("bug: LoanPay fund-conservation assertion on late IOU payment");
using namespace jtx;
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(fset(issuer, asfAllowTrustLineClawback));
env.close();
PrettyAsset const iouAsset = issuer["USD"];
STAmount const iouLimit{iouAsset.raw(), Number{9'999'999'999'999'999LL}};
env(trust(lender, iouLimit));
env(trust(borrower, iouLimit));
env.close();
env(pay(issuer, lender, iouAsset(9'999'999'999LL)));
env(pay(issuer, borrower, iouAsset(9'999'999'999LL)));
env.close();
// Broker: 0.5% management fee on interest.
BrokerParameters const brokerParams{
.vaultDeposit = 5'000'000'000LL,
.debtMax = 1'000'000'000,
.coverRateMin = TenthBips32{0},
.coverDeposit = 0,
.managementFeeRate = TenthBips16{5000},
.coverRateLiquidation = TenthBips32{0}};
BrokerInfo const broker{createVaultAndBroker(env, iouAsset, lender, brokerParams)};
using namespace loan;
auto const loanSetFee = fee(env.current()->fees().base * 2);
auto createJson = env.json(
set(borrower, broker.brokerID, Number{1'000'000}),
fee(loanSetFee),
json(sfCounterpartySignature, Json::objectValue));
createJson["InterestRate"] = 50000;
createJson["LateInterestRate"] = 100000;
createJson["PaymentTotal"] = 4;
createJson["PaymentInterval"] = 60;
createJson["GracePeriod"] = 60;
createJson["LoanServiceFee"] = "0.01";
createJson["LatePaymentFee"] = "0.01";
auto const brokerStateBefore = env.le(keylet::loanbroker(broker.brokerID));
auto const loanSequence = brokerStateBefore->at(sfLoanSequence);
auto const loanKeylet = keylet::loan(broker.brokerID, loanSequence);
createJson = env.json(createJson, sig(sfCounterpartySignature, lender));
env(createJson, ter(tesSUCCESS));
env.close();
auto const loanSle = env.le(loanKeylet);
BEAST_EXPECT(loanSle);
if (!loanSle)
return;
Number const tvo = loanSle->at(sfTotalValueOutstanding);
log << "TVO=" << tvo << " NextDue=" << loanSle->at(sfNextPaymentDueDate)
<< " PeriodicPayment=" << loanSle->at(sfPeriodicPayment) << std::endl;
// Advance past payment due date + grace period so the payment is
// late enough that late interest + late fee are charged. The bug
// report advances 1000 ledger accepts (each advances 1 second).
for (int i = 0; i < 1000; ++i)
env.close(std::chrono::seconds(1));
// Huge late payment — tfLoanLatePayment triggers the
// late-interest + late-fee path that loses precision. Use
// max(50*TVO, 50M) per the bug report.
Number const amount = std::max(tvo * Number{50}, Number{50'000'000});
STAmount const payAmount{iouAsset.raw(), amount};
env(loan::pay(borrower, loanKeylet.key, payAmount, tfLoanLatePayment));
env.close();
}
// Reproduction of do_payment_partial_principal.pdf.
// An integer-scale MPT loan with principal=1 and high interest causes
// the second LoanPay to compute a principal delta equal to the full
// outstanding (1), but the code still classifies this as a non-final
// payment because TVO > roundedPeriodicPayment. The strict > assertion
// in doPayment fires.
void
testBugDoPaymentPartialPrincipal()
{
testcase("bug: doPayment asserts partial principal on integer MPT");
using namespace jtx;
Env env(*this, all);
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, mptInitNoFund};
mptt.create({.maxAmt = 100'000, .flags = tfMPTCanTransfer});
PrettyAsset const asset{mptt.issuanceID()}; // scale = 1
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(5000)}));
env.close();
auto const brokerKeylet = keylet::loanbroker(lender.id(), env.seq(lender));
{
using namespace loanBroker;
env(set(lender, vaultKeylet.key),
debtMaximum(Number{100}),
fee(env.current()->fees().base * 2));
}
env.close();
auto const brokerStateBefore = env.le(brokerKeylet);
BEAST_EXPECT(brokerStateBefore);
if (!brokerStateBefore)
return;
auto const loanSequence = brokerStateBefore->at(sfLoanSequence);
auto const loanKeylet = keylet::loan(brokerKeylet.key, loanSequence);
// Loan: principal=1, interest=5% (50000 tenth-bips),
// 3 payments, 1 year interval.
{
using namespace loan;
env(set(borrower, brokerKeylet.key, Number{1}),
sig(sfCounterpartySignature, lender),
interestRate(TenthBips32{50'000}),
paymentTotal(3),
paymentInterval(31'536'000),
fee(env.current()->fees().base * 2));
}
env.close();
// LoanPay 2 MPT — the second computePaymentComponents call inside
// doPayment fires the "Partial principal payment" assertion.
env(loan::pay(borrower, loanKeylet.key, asset(2)), ter(tesSUCCESS));
env.close();
}
// Reproduction of compute_payment_components_interest_due_delta.pdf.
// A near-zero interest rate (1 TenthBips = 0.0001%) 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::objectValue));
createJson["InterestRate"] = 1; // minimum non-zero rate
createJson["PaymentTotal"] = 3;
createJson["PaymentInterval"] = 600;
auto const brokerStateBefore = env.le(keylet::loanbroker(broker.brokerID));
auto const loanSequence = brokerStateBefore->at(sfLoanSequence);
auto const keylet = keylet::loan(broker.brokerID, loanSequence);
createJson = env.json(createJson, sig(sfCounterpartySignature, lender));
env(createJson, ter(tesSUCCESS));
env.close();
// LoanPay for 35 USD — just over the ~33.33 periodic payment. If the
// bug is present, rippled aborts on the assertion; if fixed, the tx
// should apply successfully.
env(pay(borrower, keylet.key, iouAsset(35)), ter(tesSUCCESS));
env.close();
}
public:
void
run() override
@@ -7253,6 +7652,16 @@ public:
testLoanPayBrokerOwnerNoPermissionedDomainMPT();
testLoanSetBrokerOwnerNoPermissionedDomainMPT();
testSequentialFLCDepletion();
// Bug reproductions (externally-reported assertion failures).
// Each aborts the debug binary when the bug is still present.
// testBugAssociateAssetRoundingDeposit() and testBugLastShareholderStuck()
// live in Vault_test.
testBugInterestDueDeltaCrash();
testBugDoPaymentPartialPrincipal();
testBugLatePaymentFundConservation();
testBugOverpayInterestPaidAgrees();
testBugOverpayUnroundedAmount();
}
};

View File

@@ -6139,6 +6139,188 @@ class Vault_test : public beast::unit_test::suite
runTest(amendments);
}
// Reproduction of associateasset_rounding_bug.pdf.
// Alice deposits 9999999999999999 USD (16-digit IOU max). A 5e15 loan is
// issued so the vault's trust line drops to 4999999999999999. Bob then
// deposits 2 USD. sfAssetsTotal would become 10000000000000001 (17 digits);
// associateAsset() rounds it to 10000000000000000, so the invariant
// "deposit and assets outstanding must add up" fires.
void
testBugAssociateAssetRoundingDeposit()
{
using namespace test::jtx;
testcase("bug: associateAsset rounding fires deposit invariant");
Env env(*this);
Account const issuer{"issuer"};
Account const alice{"alice"};
Account const bob{"bob"};
Account const borrower{"borrower"};
env.fund(XRP(100'000), issuer, alice, bob, borrower);
env.close();
env(fset(issuer, asfDefaultRipple));
env(fset(issuer, asfAllowTrustLineClawback));
env.close();
PrettyAsset const usd{issuer["USD"]};
STAmount const trustLimit{usd.raw(), Number{9'999'999'999'999'999LL}};
STAmount const aliceFund{usd.raw(), Number{9'999'999'999'999'999LL}};
env(trust(alice, trustLimit));
env(trust(bob, trustLimit));
env(trust(borrower, trustLimit));
env.close();
env(pay(issuer, alice, aliceFund));
env(pay(issuer, bob, usd(1000)));
env.close();
Vault const vault{env};
// Scale=0 so sfAssetsTotal stores whole USD
auto [vaultTx, vaultKeylet] = vault.create({.owner = alice, .asset = usd});
vaultTx[sfScale] = 0;
env(vaultTx);
env.close();
env(vault.deposit({.depositor = alice, .id = vaultKeylet.key, .amount = aliceFund}));
env.close();
auto const brokerKeylet = keylet::loanbroker(alice.id(), env.seq(alice));
{
using namespace loanBroker;
env(set(alice, vaultKeylet.key),
debtMaximum(Number{9, 15}),
fee(env.current()->fees().base * 2));
}
env.close();
// 5e15 principal — disburses 5000000000000000 from vault to borrower.
{
using namespace loan;
env(set(borrower, brokerKeylet.key, Number{5, 15}),
sig(sfCounterpartySignature, alice),
paymentTotal(4),
paymentInterval(600),
fee(env.current()->fees().base * 2));
}
env.close();
env(vault.deposit({.depositor = bob, .id = vaultKeylet.key, .amount = usd(2)}),
ter(tecINVARIANT_FAILED));
env.close();
}
// Reproduction of last_shareholder_stuck.pdf.
// After a loan is impaired, the vault has LossUnrealized > 0. Bob withdraws
// all his shares successfully, but when the Lender tries to burn the final
// shares, AssetsTotal would not drop to zero (it still includes the
// impaired LossUnrealized), violating the zero-sized-vault invariant.
void
testBugLastShareholderStuck()
{
using namespace test::jtx;
testcase("bug: last shareholder stuck after loan impairment");
Env env(*this);
Account const issuer{"issuer"};
Account const lender{"lender"};
Account const bob{"bob"};
Account const borrower{"borrower"};
env.fund(XRP(100'000), issuer, lender, bob, borrower);
env.close();
env(fset(issuer, asfDefaultRipple));
env(fset(issuer, asfAllowTrustLineClawback));
env.close();
PrettyAsset const usd{issuer["USD"]};
STAmount const trustLimit{usd.raw(), Number{9'999'999'999'999'999LL}};
env(trust(lender, trustLimit));
env(trust(bob, trustLimit));
env(trust(borrower, trustLimit));
env.close();
env(pay(issuer, lender, usd(5000)));
env(pay(issuer, bob, usd(100'000)));
env.close();
Vault const vault{env};
// Scale=1 per the bug report — gives 10 shares per 1 USD unit.
auto [vaultTx, vaultKeylet] = vault.create({.owner = lender, .asset = usd});
vaultTx[sfScale] = 1;
env(vaultTx);
env.close();
auto const vaultSle = env.le(vaultKeylet);
BEAST_EXPECT(vaultSle);
if (!vaultSle)
return;
auto const shareMptID = vaultSle->at(sfShareMPTID);
MPTIssue const shares(shareMptID);
PrettyAsset const share{shares};
env(vault.deposit({.depositor = lender, .id = vaultKeylet.key, .amount = usd(5000)}));
env(vault.deposit({.depositor = bob, .id = vaultKeylet.key, .amount = usd(5000)}));
env.close();
auto const brokerKeylet = keylet::loanbroker(lender.id(), env.seq(lender));
{
using namespace loanBroker;
env(set(lender, vaultKeylet.key),
debtMaximum(Number{5000}),
fee(env.current()->fees().base * 2));
}
env.close();
// Capture the loan sequence *before* LoanSet — the new loan gets that id.
auto const brokerSleBefore = env.le(brokerKeylet);
BEAST_EXPECT(brokerSleBefore);
if (!brokerSleBefore)
return;
auto const loanSequence = brokerSleBefore->at(sfLoanSequence);
auto const loanKeylet = keylet::loan(brokerKeylet.key, loanSequence);
// 3333 principal, 4 payments, 600s interval — matches report.
{
using namespace loan;
env(set(borrower, brokerKeylet.key, Number{3333}),
sig(sfCounterpartySignature, lender),
paymentTotal(4),
paymentInterval(600),
fee(env.current()->fees().base * 2));
}
env.close();
// Impair the loan.
{
using namespace loan;
env(manage(lender, loanKeylet.key, tfLoanImpair), fee(env.current()->fees().base * 2));
}
env.close();
// Bob withdraws all 50000 shares — succeeds.
env(vault.withdraw(
{.depositor = bob, .id = vaultKeylet.key, .amount = STAmount(share, 50'000)}));
env.close();
// Lender tries to withdraw the remaining 50000 shares. Burning the
// last shares would leave sfAssetsTotal == sfLossUnrealized != 0,
// violating "updated zero sized vault must have no assets outstanding".
env(vault.withdraw(
{.depositor = lender, .id = vaultKeylet.key, .amount = STAmount(share, 50'000)}),
ter(tecINVARIANT_FAILED));
env.close();
}
public:
void
run() override
@@ -6162,6 +6344,8 @@ public:
testAssetsMaximum();
testBug6_LimitBypassWithShares();
testRemoveEmptyHoldingLockedAmount();
testBugAssociateAssetRoundingDeposit();
testBugLastShareholderStuck();
}
};