xrpld
Loading...
Searching...
No Matches
LoanRounding_test.cpp
1#include <test/app/lending/LoanTestBase.h>
2#include <test/jtx/Account.h>
3#include <test/jtx/Env.h>
4#include <test/jtx/TestHelpers.h>
5#include <test/jtx/amount.h>
6#include <test/jtx/fee.h>
7#include <test/jtx/flags.h>
8#include <test/jtx/jtx_json.h>
9#include <test/jtx/mpt.h>
10#include <test/jtx/pay.h>
11#include <test/jtx/ter.h>
12#include <test/jtx/trust.h>
13#include <test/jtx/txflags.h>
14#include <test/jtx/vault.h>
15
16#include <xrpl/basics/Number.h>
17#include <xrpl/beast/unit_test/suite.h>
18#include <xrpl/beast/utility/Zero.h>
19#include <xrpl/json/json_value.h>
20#include <xrpl/ledger/helpers/LendingHelpers.h>
21#include <xrpl/protocol/Asset.h>
22#include <xrpl/protocol/Feature.h>
23#include <xrpl/protocol/Indexes.h>
24#include <xrpl/protocol/Issue.h>
25#include <xrpl/protocol/Keylet.h>
26#include <xrpl/protocol/Protocol.h>
27#include <xrpl/protocol/SField.h>
28#include <xrpl/protocol/STAmount.h>
29#include <xrpl/protocol/SeqProxy.h>
30#include <xrpl/protocol/TER.h>
31#include <xrpl/protocol/TxFlags.h>
32#include <xrpl/protocol/Units.h>
33
34#include <array>
35#include <chrono>
36#include <cstdint>
37#include <optional>
38#include <ostream>
39#include <string>
40#include <tuple>
41
42namespace xrpl::test {
43
45{
46private:
47 void
49 {
50 testcase("Dust manipulation");
51
52 using namespace jtx;
53 using namespace std::chrono_literals;
54 Env env{*this, features};
55
56 // Setup: Create accounts
57 Account const issuer{"issuer"};
58 Account const lender{"lender"};
59 Account const borrower{"borrower"};
60 Account const victim{"victim"};
61
62 env.fund(XRP(1'000'000'00), issuer, lender, borrower, victim);
63 env.close();
64
65 // Step 1: Create vault with IOU asset
66 auto asset = issuer["USD"];
67 env(trust(lender, asset(100000)));
68 env(trust(borrower, asset(100000)));
69 env(trust(victim, asset(100000)));
70 env(pay(issuer, lender, asset(50000)));
71 env(pay(issuer, borrower, asset(50000)));
72 env(pay(issuer, victim, asset(50000)));
73 env.close();
74
75 BrokerParameters const brokerParams{
76 .vaultDeposit = 10000,
77 .debtMax = Number{0},
78 .coverRateMin = TenthBips32{1000},
79 .coverRateLiquidation = TenthBips32{2500}};
80
81 auto broker = createVaultAndBroker(env, asset, lender, brokerParams);
82
83 auto const loanKeyletOpt = [&]() -> std::optional<Keylet> {
84 auto const brokerSle = env.le(keylet::loanBroker(broker.brokerID));
85 if (!BEAST_EXPECT(brokerSle))
86 return std::nullopt;
87
88 // Broker has no loans
89 BEAST_EXPECT(brokerSle->at(sfOwnerCount) == 0);
90
91 // The loan keylet is based on the LoanSequence of the
92 // _LOAN_BROKER_ object.
93 auto const loanSequence = brokerSle->at(sfLoanSequence);
94 return keylet::loan(broker.brokerID, SeqProxy::rawSequence(loanSequence));
95 }();
96 if (!loanKeyletOpt)
97 return;
98
99 auto const& vaultKeylet = broker.vaultKeylet();
100
101 {
102 auto const vaultSle = env.le(vaultKeylet);
103 Number const assetsTotal = vaultSle->at(sfAssetsTotal);
104 Number const assetsAvail = vaultSle->at(sfAssetsAvailable);
105
106 log << "Before loan creation:" << std::endl;
107 log << " AssetsTotal: " << assetsTotal << std::endl;
108 log << " AssetsAvailable: " << assetsAvail << std::endl;
109 log << " Difference: " << (assetsTotal - assetsAvail) << std::endl;
110
111 // before the loan the assets total and available should be equal
112 BEAST_EXPECT(assetsAvail == assetsTotal);
113 BEAST_EXPECT(assetsAvail == broker.asset(brokerParams.vaultDeposit).number());
114 }
115
116 Keylet const& loanKeylet = *loanKeyletOpt;
117
118 LoanParameters const loanParams{
119 .account = lender,
120 .counter = borrower,
121 .principalRequest = Number{100},
122 .interest = TenthBips32{1922},
123 .payTotal = 5816,
124 .payInterval = 86400 * 6,
125 .gracePd = 86400 * 5,
126 };
127
128 env(loanParams(env, broker));
129 env.close();
130
131 // Wait for loan to be late enough to default
132 env.close(std::chrono::seconds(86400 * 40)); // 40 days
133
134 {
135 auto const vaultSle = env.le(vaultKeylet);
136 Number const assetsTotal = vaultSle->at(sfAssetsTotal);
137 Number const assetsAvail = vaultSle->at(sfAssetsAvailable);
138
139 log << "After loan creation:" << std::endl;
140 log << " AssetsTotal: " << assetsTotal << std::endl;
141 log << " AssetsAvailable: " << assetsAvail << std::endl;
142 log << " Difference: " << (assetsTotal - assetsAvail) << std::endl;
143
144 auto const loanSle = env.le(loanKeylet);
145 if (!BEAST_EXPECT(loanSle))
146 return;
147 auto const state = constructLoanState(loanSle);
148
149 log << "Loan state:" << std::endl;
150 log << " ValueOutstanding: " << state.valueOutstanding << std::endl;
151 log << " PrincipalOutstanding: " << state.principalOutstanding << std::endl;
152 log << " InterestOutstanding: " << state.interestOutstanding() << std::endl;
153 log << " InterestDue: " << state.interestDue << std::endl;
154 log << " FeeDue: " << state.managementFeeDue << std::endl;
155
156 // after loan creation the assets total and available should
157 // reflect the value of the loan
158 BEAST_EXPECT(assetsAvail < assetsTotal);
159 BEAST_EXPECT(
160 assetsAvail ==
161 broker.asset(brokerParams.vaultDeposit - loanParams.principalRequest).number());
162 BEAST_EXPECT(
163 assetsTotal ==
164 broker.asset(brokerParams.vaultDeposit + state.interestDue).number());
165 }
166
167 // Step 7: Trigger default (dust adjustment will occur)
168 env(jtx::loan::manage(lender, loanKeylet.key, tfLoanDefault));
169 env.close();
170
171 // Step 8: Verify phantom assets created
172 {
173 auto const vaultSle2 = env.le(vaultKeylet);
174 Number const assetsTotal2 = vaultSle2->at(sfAssetsTotal);
175 Number const assetsAvail2 = vaultSle2->at(sfAssetsAvailable);
176
177 log << "After default:" << std::endl;
178 log << " AssetsTotal: " << assetsTotal2 << std::endl;
179 log << " AssetsAvailable: " << assetsAvail2 << std::endl;
180 log << " Difference: " << (assetsTotal2 - assetsAvail2) << std::endl;
181
182 // after a default the assets total and available should be equal
183 BEAST_EXPECT(assetsAvail2 == assetsTotal2);
184 }
185 }
186
187 void
189 {
190 testcase("Minimum cover rounding allows undercoverage (XRP)");
191
192 using namespace jtx;
193 using namespace loan_broker;
194
195 Env env{*this, features};
196
197 Account const lender{"lender"};
198 Account const borrower{"borrower"};
199
200 env.fund(XRP(200'000), lender, borrower);
201 env.close();
202
203 // Vault with XRP asset
204 Vault const vault{env};
205 auto [vaultCreate, vaultKeylet] = vault.create({.owner = lender, .asset = xrpIssue()});
206 env(vaultCreate);
207 env.close();
208 BEAST_EXPECT(env.le(vaultKeylet));
209
210 // Seed the vault with XRP so it can fund the loan principal
211 PrettyAsset const xrpAsset{xrpIssue(), 1};
212
213 BrokerParameters const brokerParams{
214 .vaultDeposit = 1'000,
215 .debtMax = Number{0},
216 .coverRateMin = TenthBips32{10'000},
217 .coverDeposit = 82,
218 };
219
220 auto const brokerInfo = createVaultAndBroker(env, xrpAsset, lender, brokerParams);
221 // Create a loan with principal 804 XRP and 0% interest (so
222 // DebtTotal increases by exactly 804)
223 env(loan::set(borrower, brokerInfo.brokerID, xrpAsset(804).value()),
225 Sig(sfCounterpartySignature, lender),
226 Fee(env.current()->fees().base * 2));
227 BEAST_EXPECT(env.ter() == tesSUCCESS);
228 env.close();
229
230 // Verify DebtTotal is exactly 804
231 if (auto const brokerSle = env.le(keylet::loanBroker(brokerInfo.brokerID));
232 BEAST_EXPECT(brokerSle))
233 {
234 log << *brokerSle << std::endl;
235 BEAST_EXPECT(brokerSle->at(sfDebtTotal) == Number(804));
236 }
237
238 // Attempt to withdraw 2 XRP to self, leaving 80 XRP CoverAvailable.
239 // The minimum is 80.4 XRP, which rounds up to 81 XRP, so this fails.
240 env(coverWithdraw(lender, brokerInfo.brokerID, xrpAsset(2).value()),
242 BEAST_EXPECT(env.ter() == tecINSUFFICIENT_FUNDS);
243 env.close();
244
245 // Attempt to withdraw 1 XRP to self, leaving 81 XRP CoverAvailable.
246 // because that leaves sufficient cover, this succeeds
247 env(coverWithdraw(lender, brokerInfo.brokerID, xrpAsset(1).value()));
248 BEAST_EXPECT(env.ter() == tesSUCCESS);
249 env.close();
250
251 // Validate CoverAvailable == 81 XRP and DebtTotal remains 804
252 if (auto const brokerSle = env.le(keylet::loanBroker(brokerInfo.brokerID));
253 BEAST_EXPECT(brokerSle))
254 {
255 log << *brokerSle << std::endl;
256 BEAST_EXPECT(brokerSle->at(sfCoverAvailable) == xrpAsset(81).value());
257 BEAST_EXPECT(brokerSle->at(sfDebtTotal) == Number(804));
258
259 // Also demonstrate that the true minimum (804 * 10%) exceeds 80
260 auto const theoreticalMin = tenthBipsOfValue(Number(804), TenthBips32(10'000));
261 log << "Theoretical min cover: " << theoreticalMin << std::endl;
262 BEAST_EXPECT(Number(804, -1) == theoreticalMin);
263 }
264 }
265
266 void
268 {
269 testcase("Rounding manipulation does not permit yield theft");
270 using namespace jtx;
271 using namespace loan;
272
273 // 1. Setup Environment
274 Env env(*this, all_);
275 Account const issuer{"issuer"};
276 Account const lender{"lender"};
277 Account const borrower{"borrower"};
278
279 env.fund(XRP(1000), issuer, lender, borrower);
280 env.close();
281
282 // 2. Asset Selection
283 PrettyAsset const iou = issuer["USD"];
284 env(trust(lender, iou(100'000'000)));
285 env(trust(borrower, iou(100'000'000)));
286 env(pay(issuer, lender, iou(100'000'000)));
287 env(pay(issuer, borrower, iou(100'000'000)));
288 env.close();
289
290 // 3. Create Vault and Broker with High Debt Limit (100M)
291 auto const brokerInfo = createVaultAndBroker(
292 env,
293 iou,
294 lender,
295 {
296 .vaultDeposit = 5'000'000,
297 .debtMax = Number{100'000'000},
298 .coverDeposit = 500'000,
299 });
300 auto const [currentSeq, vaultKeylet] = [&]() {
301 auto const brokerSle = env.le(keylet::loanBroker(brokerInfo.brokerID));
302 if (!BEAST_EXPECT(brokerSle))
304 auto const currentSeq = brokerSle->at(sfLoanSequence);
305 auto const vaultKeylet = keylet::vault(brokerSle->at(sfVaultID));
306 return std::make_tuple(currentSeq, vaultKeylet);
307 }();
308
309 // 4. Loan Parameters (Attack Vector)
310 Number const principal = 1'000'000;
311 TenthBips32 const interestRate = TenthBips32{1}; // 0.001%
312 std::uint32_t const paymentInterval = 86400;
313 std::uint32_t const paymentTotal = 3650;
314
315 auto const loanSetFee = Fee(env.current()->fees().base * 2);
316 env(set(borrower, brokerInfo.brokerID, iou(principal).value(), flags),
317 Sig(sfCounterpartySignature, lender),
318 loan::kInterestRate(interestRate),
319 loan::kPaymentInterval(paymentInterval),
320 loan::kPaymentTotal(paymentTotal),
321 Fee(loanSetFee));
322 env.close();
323
324 // --- RETRIEVE OBJECTS & SETUP ATTACK ---
325
326 auto borrowerBalance = [&]() { return env.balance(borrower, iou); };
327 auto const borrowerScale = static_cast<STAmount const&>(borrowerBalance()).exponent();
328
329 auto const loanKeylet =
330 keylet::loan(brokerInfo.brokerID, SeqProxy::rawSequence(currentSeq));
331 auto const maybePeriodicPayment = [&]() -> std::optional<STAmount> {
332 auto const loanSle = env.le(loanKeylet);
333 if (!BEAST_EXPECT(loanSle))
334 return std::nullopt;
335 // Construct Payment
336 return STAmount{iou, loanSle->at(sfPeriodicPayment)};
337 }();
338 if (!maybePeriodicPayment)
339 return;
340 auto const periodicPayment = *maybePeriodicPayment;
341 auto const roundedPayment =
342 roundToScale(periodicPayment, borrowerScale, Number::RoundingMode::Upward);
343
344 // ATTACK: Add dust buffer (1e-9) to force 'excess' logic execution
345 STAmount const paymentBuffer{iou, Number(1, -9)};
346 STAmount const attackPayment = periodicPayment + paymentBuffer;
347
348 auto const maybeInitialVaultAssets = [&]() -> std::optional<Number> {
349 auto const vault = env.le(vaultKeylet);
350 if (!BEAST_EXPECT(vault))
351 return std::nullopt;
352 return vault->at(sfAssetsTotal);
353 }();
354 if (!maybeInitialVaultAssets)
355 return;
356 auto const initialVaultAssets = *maybeInitialVaultAssets;
357
358 // 5. Execution Loop
359 int yieldTheftCount = 0;
360 auto previousAssetsTotal = initialVaultAssets;
361
362 for (int i = 0; i < 100; ++i)
363 {
364 auto const balanceBefore = borrowerBalance();
365 env(pay(borrower, loanKeylet.key, attackPayment, flags));
366 env.close();
367 auto const borrowerDelta = balanceBefore - borrowerBalance();
368 BEAST_EXPECT(borrowerDelta.signum() == roundedPayment.signum());
369
370 auto const loanSle = env.le(loanKeylet);
371 if (!BEAST_EXPECT(loanSle))
372 break;
373 auto const updatedPayment = STAmount{iou, loanSle->at(sfPeriodicPayment)};
374 BEAST_EXPECT(
375 (roundToScale(updatedPayment, borrowerScale, Number::RoundingMode::Upward) ==
376 roundedPayment));
377 BEAST_EXPECT(
378 (updatedPayment == periodicPayment) ||
379 (flags == tfLoanOverpayment && i >= 2 && updatedPayment < periodicPayment));
380
381 auto const currentVaultSle = env.le(vaultKeylet);
382 if (!BEAST_EXPECT(currentVaultSle))
383 break;
384
385 auto const currentAssetsTotal = currentVaultSle->at(sfAssetsTotal);
386 auto const delta = currentAssetsTotal - previousAssetsTotal;
387
388 BEAST_EXPECT(
389 (delta == beast::kZero && borrowerDelta <= roundedPayment) ||
390 (delta > beast::kZero && borrowerDelta > roundedPayment));
391
392 // If tx succeeded but Assets Total didn't change, interest was
393 // stolen.
394 if (delta == beast::kZero && borrowerDelta > roundedPayment)
395 {
396 yieldTheftCount++;
397 }
398
399 previousAssetsTotal = currentAssetsTotal;
400 }
401
402 BEAST_EXPECTS(yieldTheftCount == 0, std::to_string(yieldTheftCount));
403 }
404
405 // Regression for the dual-rounding fix at coarse (integer-MPT) scale.
406 //
407 // Loan: P=1, r=50% (50000 tenth-bips), n=3, yearly interval. The
408 // amortization schedule produces a fractional principal
409 // (~0.47) which under round-to-nearest collapses to 0 in a single
410 // step, causing `doPayment`'s strict `>` assertion on principal to
411 // fire mid-loan. With fixCleanup3_2_0 enabled, principal is rounded
412 // upward (sticks at 1 across the first two periods) and only clears
413 // in the final payment.
414 //
415 // The test pays one period at a time across three LoanPay
416 // transactions and verifies the loan completes (paymentRemaining=0)
417 // with totals matching the loan's economics (1 principal + 2 interest).
418 void
420 {
421 // Without fixCleanup3_2_0, this behavior will abort the server, so
422 // don't run without it.
423 if (!features[fixCleanup3_2_0])
424 return;
425
426 testcase("edge: integer MPT principal stuck mid-loan completes via final");
427
428 using namespace jtx;
429 Env env(*this, features);
430
431 Account const issuer{"issuer"};
432 Account const lender{"lender"};
433 Account const borrower{"borrower"};
434
435 env.fund(XRP(100'000), issuer, lender, borrower);
436 env.close();
437
438 MPTTester mptt{env, issuer, kMptInitNoFund};
439 mptt.create({.maxAmt = 100'000, .flags = tfMPTCanTransfer});
440 PrettyAsset const asset{mptt.issuanceID()};
441
442 mptt.authorize({.account = lender});
443 mptt.authorize({.account = borrower});
444
445 env(pay(issuer, lender, asset(10'000)));
446 env(pay(issuer, borrower, asset(10'000)));
447 env.close();
448
449 Vault const vault{env};
450 auto [vaultTx, vaultKeylet] = vault.create({.owner = lender, .asset = asset});
451 env(vaultTx);
452 env.close();
453
454 env(vault.deposit({.depositor = lender, .id = vaultKeylet.key, .amount = asset(5'000)}));
455 env.close();
456
457 auto const brokerKeylet =
458 keylet::loanBroker(lender.id(), SeqProxy::rawSequence(env.seq(lender)));
459 env(loan_broker::set(lender, vaultKeylet.key),
461 Fee(env.current()->fees().base * 2));
462 env.close();
463
464 auto const brokerStateBefore = env.le(brokerKeylet);
465 if (!BEAST_EXPECT(brokerStateBefore))
466 return;
467 auto const loanSequence = brokerStateBefore->at(sfLoanSequence);
468 auto const loanKeylet = keylet::loan(brokerKeylet.key, SeqProxy::rawSequence(loanSequence));
469
470 env(loan::set(borrower, brokerKeylet.key, Number{1}),
471 Sig(sfCounterpartySignature, lender),
474 loan::kPaymentInterval(31'536'000),
475 Fee(env.current()->fees().base * 2));
476 env.close();
477
478 auto const borrowerStart = env.balance(borrower, asset).value();
479
480 // Three separate periodic payments of 1 each. Expected per-period
481 // evolution at integer MPT scale (TVO = PO + interestDue +
482 // managementFeeDue):
483 // start: PO=1, TVO=3, paymentRemaining=3
484 // after pay #1: PO=1, TVO=2, paymentRemaining=2 (principal sticks)
485 // after pay #2: PO=1, TVO=1, paymentRemaining=1 (principal sticks)
486 // after pay #3: PO=0, TVO=0, paymentRemaining=0 (final clears)
487 std::array<Number, 3> const expectedPO{Number{1}, Number{1}, Number{0}};
488 std::array<Number, 3> const expectedTVO{Number{2}, Number{1}, Number{0}};
489 std::array<std::uint32_t, 3> const expectedRemaining{2, 1, 0};
490
491 for (int i = 0; i < 3; ++i)
492 {
493 env(loan::pay(borrower, loanKeylet.key, asset(1)), Ter(tesSUCCESS));
494 env.close();
495
496 auto const sle = env.le(loanKeylet);
497 if (!BEAST_EXPECT(sle))
498 return;
499 BEAST_EXPECT(sle->at(sfPrincipalOutstanding) == expectedPO[i]);
500 BEAST_EXPECT(sle->at(sfTotalValueOutstanding) == expectedTVO[i]);
501 BEAST_EXPECT(sle->at(sfPaymentRemaining) == expectedRemaining[i]);
502 }
503
504 // Borrower paid 3 total regardless of fee split (1 principal + 2
505 // interest+fee, matching loan economics).
506 auto const borrowerEnd = env.balance(borrower, asset).value();
507 BEAST_EXPECT(borrowerStart - borrowerEnd == asset(3).value());
508 }
509
510#if LOAN_TODO
511 void
512 testLoanCoverMinimumRoundingExploit(FeatureBitset features)
513 {
514 auto testLoanCoverMinimumRoundingExploit = [&, this](Number const& principalRequest) {
515 testcase << "LoanBrokerCoverClawback drains cover via rounding"
516 << " principalRequested=" << to_string(principalRequest);
517
518 using namespace jtx;
519 using namespace loan;
520 using namespace loan_broker;
521
522 Env env(*this, features);
523
524 Account const issuer{"issuer"};
525 Account const lender{"lender"};
526 Account const borrower{"borrower"};
527
528 env.fund(XRP(1'000'000'000), issuer, lender, borrower);
529 env.close();
530
531 env(fset(issuer, asfAllowTrustLineClawback));
532 env.close();
533
534 PrettyAsset const asset = issuer[iouCurrency];
535 env(trust(lender, asset(2'000'0000)));
536 env(trust(borrower, asset(2'000'0000)));
537 env.close();
538
539 env(pay(issuer, lender, asset(2'000'0000)));
540 env.close();
541
542 BrokerParameters brokerParams{.debtMax = 0, .coverRateMin = TenthBips32{10'000}};
543 BrokerInfo broker{createVaultAndBroker(env, asset, lender, brokerParams)};
544
545 auto const loanSetFee = Fee(env.current()->fees().base * 2);
546 auto createTx = env.jt(
547 set(borrower, broker.brokerID, principalRequest),
548 Sig(sfCounterpartySignature, lender),
549 loanSetFee,
550 kPaymentInterval(600),
551 kPaymentTotal(1),
552 kGracePeriod(60));
553 env(createTx);
554 env.close();
555
556 auto const brokerBefore = env.le(keylet::loanBroker(broker.brokerID));
557 BEAST_EXPECT(brokerBefore);
558 if (!brokerBefore)
559 return;
560
561 Number const debtOutstanding = brokerBefore->at(sfDebtTotal);
562 Number const coverAvailableBefore = brokerBefore->at(sfCoverAvailable);
563
564 BEAST_EXPECT(debtOutstanding > Number{});
565 BEAST_EXPECT(coverAvailableBefore > Number{});
566
567 log << "debt=" << to_string(debtOutstanding)
568 << " cover_available=" << to_string(coverAvailableBefore);
569
570 env(coverClawback(issuer, 0), loanBrokerID(broker.brokerID));
571 env.close();
572
573 auto const brokerAfter = env.le(keylet::loanBroker(broker.brokerID));
574 BEAST_EXPECT(brokerAfter);
575 if (!brokerAfter)
576 return;
577
578 Number const debtAfter = brokerAfter->at(sfDebtTotal);
579 // the debt has not changed
580 BEAST_EXPECT(debtAfter == debtOutstanding);
581
582 Number const coverAvailableAfter = brokerAfter->at(sfCoverAvailable);
583
584 // since the cover rate min != 0, the cover available should not
585 // be zero
586 BEAST_EXPECT(coverAvailableAfter != Number{});
587 };
588
589 // Call the lambda with different principal values
590 testLoanCoverMinimumRoundingExploit(Number{1, -30}); // 1e-30 units
591 testLoanCoverMinimumRoundingExploit(Number{1, -20}); // 1e-20 units
592 testLoanCoverMinimumRoundingExploit(Number{1, -10}); // 1e-10 units
593 testLoanCoverMinimumRoundingExploit(Number{1, 1}); // 1e-10 units
594 }
595#endif
596
597 // A residual overpayment can reduce the stored principal by one scale-unit
598 // *less* than computeOverpaymentComponents predicts, firing the
599 // "principal change agrees" XRPL_ASSERT_PARTS in doOverpayment:
600 //
601 // trackedPrincipalDelta == principalOutstanding - newPrincipalOutstanding
602 //
603 // tryOverpayment re-amortizes the loan at the reduced principal, then
604 // re-derives the theoretical principal from the new periodic payment via
605 // (P * paymentFactor) / paymentFactor. That round-trip is not exact in
606 // Number's 19-digit arithmetic; a positive residual pushes the recomputed
607 // principal a hair above the exact grid point `oldPrincipal - delta`, and
608 // the Upward rounding in tryOverpayment then bumps it a full scale-unit
609 // higher. The principal therefore drops by `delta - 1 unit`, not `delta`.
610 //
611 // Concrete case (isolated, at the tryOverpayment level):
612 // A 100 USD loan at the minimum non-zero rate, 3 payments, loanScale -10.
613 // After one regular payment (principalOutstanding 66.6666666674) a residual overpayment of
614 // 0.049999998 yields trackedPrincipalDelta 0.048999998 but only reduces the principal by
615 // 0.0489999979 (newPrincipal 66.6176666695) — short by 1e-10.
616 //
617 // With fixCleanup3_2_0, tryOverpayment pins the new principal to the exact,
618 // on-grid reduction (oldPrincipal - trackedPrincipalDelta) instead of the
619 // lossy (P*factor)/factor round-trip, so the assertion holds and the
620 // overpayment applies cleanly. The three "principal change agrees" /
621 // "interest paid agrees" / "principal payment matches" assertions are
622 // gated behind the same amendment, so without it they are disabled (the
623 // server does not abort) and the loan keeps the pre-amendment computation.
624 //
625 // The test runs the same scenario under both amendment settings and checks
626 // the stored principal against a ground-truth value derived independently of
627 // the loan-state computation under test.
628 void
630 {
631 testcase("bug: doOverpayment asserts 'principal change agrees'");
632
633 using namespace jtx;
634 using namespace loan;
635 using namespace xrpl::detail;
636
637 struct Params
638 {
639 TenthBips32 interestRate;
640 TenthBips16 managementFeeRate;
641 std::uint32_t paymentTotal;
642 std::uint32_t paymentInterval;
643 std::int64_t principal;
644 Number overpayment;
645 TenthBips32 overpaymentInterestRate;
646 TenthBips32 overpaymentFeeRate;
647 std::optional<int> vaultScale;
648 };
649
650 struct Result
651 {
652 Number principalOutstanding; // stored principal after the LoanPay
653 Number expectedNewPrincipal; // ground truth, independent of the fix
654 Number managementFeeChange; // managementFeeOutstanding after - before
655 Number unit; // one scale-unit at the loan scale
656 };
657
658 auto runScenario = [this](FeatureBitset features, Params const& p) -> Result {
659 Env env(*this, features);
660
661 Account const issuer{"issuer"};
662 Account const lender{"vaultOwner"};
663 Account const borrower{"borrower"};
664
665 PrettyAsset const iouAsset = createFundedRippleIouAsset(env, issuer, lender, borrower);
666 Asset const asset = iouAsset.raw();
667
668 auto const broker = createVaultAndBroker(
669 env,
670 iouAsset,
671 lender,
672 {.vaultDeposit = 900'000,
673 .debtMax = 0,
674 .managementFeeRate = p.managementFeeRate,
675 .vaultScale = p.vaultScale});
676
677 auto const brokerSle = env.le(broker.brokerKeylet());
678 BEAST_EXPECT(brokerSle);
679 auto const loanSequence = brokerSle ? brokerSle->at(sfLoanSequence) : 0;
680 auto const loanKeylet =
681 keylet::loan(broker.brokerID, SeqProxy::rawSequence(loanSequence));
682
683 env(set(borrower, broker.brokerID, Number{p.principal}, tfLoanOverpayment),
684 Sig(sfCounterpartySignature, lender),
685 kInterestRate(p.interestRate),
686 kPaymentTotal(p.paymentTotal),
687 kPaymentInterval(p.paymentInterval),
688 kGracePeriod(p.paymentInterval),
689 kOverpaymentFee(p.overpaymentFeeRate),
690 kOverpaymentInterestRate(p.overpaymentInterestRate),
691 Fee(env.current()->fees().base * 2),
692 Ter(tesSUCCESS));
693 env.close();
694
695 // The single LoanPay below makes one regular payment (the overpayment
696 // is smaller than one period) and leaves the residual as an
697 // overpayment.
698 auto const s = getCurrentState(env, broker, loanKeylet);
699 auto const periodicRate = loanPeriodicRate(s.interestRate, s.paymentInterval);
700 auto const onePeriod = computePaymentComponents(
701 env.current()->rules(),
702 asset,
703 s.loanScale,
704 s.totalValue,
705 s.principalOutstanding,
706 s.managementFeeOutstanding,
707 s.periodicPayment,
708 periodicRate,
709 s.paymentRemaining,
710 p.managementFeeRate);
711
712 // Ground truth: the stored principal must drop by exactly the regular
713 // payment's principal portion plus the overpayment's principal
714 // portion. computeOverpaymentComponents depends only on the
715 // overpayment amount and rates (not on the loan-state computation
716 // under test), so it is an independent oracle. Both components are
717 // computed under the same rules as the env so the payment factor
718 // matches.
719 auto const overpaymentComponents = computeOverpaymentComponents(
720 env.current()->rules(),
721 asset,
722 s.loanScale,
723 p.overpayment,
724 p.overpaymentInterestRate,
725 p.overpaymentFeeRate,
726 p.managementFeeRate);
727 Number const expectedNewPrincipal = s.principalOutstanding -
728 onePeriod.trackedPrincipalDelta - overpaymentComponents.trackedPrincipalDelta;
729
730 Number const managementFeeBefore = s.managementFeeOutstanding;
731
732 STAmount const payAmount{asset, onePeriod.trackedValueDelta + p.overpayment};
733 env(pay(borrower, loanKeylet.key, payAmount),
734 Txflags(tfLoanOverpayment),
735 Ter(tesSUCCESS));
736 env.close();
737
738 auto const loanSle = env.le(loanKeylet);
739 BEAST_EXPECT(loanSle);
740
741 return Result{
742 .principalOutstanding = loanSle ? Number{loanSle->at(sfPrincipalOutstanding)} : 0,
743 .expectedNewPrincipal = expectedNewPrincipal,
744 .managementFeeChange =
745 (loanSle ? Number{loanSle->at(sfManagementFeeOutstanding)} : Number{0}) -
746 managementFeeBefore,
747 .unit = Number{1, s.loanScale}};
748 };
749
750 // Scenario 1: the original near-zero-rate principal reproduction
751 // (loanScale -10, no management fee). 0.049999998 is smaller than one
752 // period, so it stays a residual overpayment.
753 Params const principalCase{
754 .interestRate = TenthBips32{1},
755 .managementFeeRate = TenthBips16{0},
756 .paymentTotal = 3,
757 .paymentInterval = 60,
758 .principal = 100,
759 .overpayment = Number{49999998, -9},
760 .overpaymentInterestRate = TenthBips32{1000},
761 .overpaymentFeeRate = TenthBips32{1000},
762 .vaultScale = 1};
763
764 // With fixCleanup3_2_0 the stored principal lands exactly on the
765 // ground-truth grid point: it is reduced by exactly the overpayment's
766 // principal portion. This is the key correctness check: if the principal
767 // pin were removed (even with the assertions still gated off), the lossy
768 // (P * factor) / factor round-trip would leave the principal one
769 // scale-unit high and this would fail.
770 Result const fixed = runScenario(all_, principalCase);
771 BEAST_EXPECTS(
772 fixed.principalOutstanding == fixed.expectedNewPrincipal,
773 "fixed principal " + to_string(fixed.principalOutstanding) + " != expected " +
774 to_string(fixed.expectedNewPrincipal));
775
776 // Without the amendment the loan amortizes with the catastrophically
777 // cancelling near-zero payment factor, so its schedule (and ground truth)
778 // differ from the fixed case; the gated assertions keep the server from
779 // aborting and the overpayment still lands exactly on that schedule.
780 Result const legacy = runScenario(all_ - fixCleanup3_2_0, principalCase);
781 BEAST_EXPECTS(
782 legacy.principalOutstanding == legacy.expectedNewPrincipal,
783 "legacy principal " + to_string(legacy.principalOutstanding) + " != expected " +
784 to_string(legacy.expectedNewPrincipal));
785
786 // Scenario 2: a normal-rate loan with a 10% management fee. At a normal
787 // rate the payment factor is identical across the amendment, so toggling
788 // fixCleanup3_2_0 isolates the fix. This overpayment (found by search)
789 // lands on a state where both the principal and the management fee differ
790 // by one scale-unit between the fixed and legacy paths.
791 Params const feeCase{
792 .interestRate = TenthBips32{10000},
793 .managementFeeRate = TenthBips16{10000},
794 .paymentTotal = 6,
795 .paymentInterval = 30u * 24 * 60 * 60,
796 .principal = 1000,
797 .overpayment = Number{214367363, -10},
798 .overpaymentInterestRate = TenthBips32{0},
799 .overpaymentFeeRate = TenthBips32{0},
800 .vaultScale = std::nullopt};
801
802 Result const feeFixed = runScenario(all_, feeCase);
803 Result const feeLegacy = runScenario(all_ - fixCleanup3_2_0, feeCase);
804
805 // With the fix the principal is the exact reduction; without it the lossy
806 // (P * factor) / factor round-trip leaves it one scale-unit high.
807 BEAST_EXPECTS(
808 feeFixed.principalOutstanding == feeFixed.expectedNewPrincipal,
809 "fee-case fixed principal " + to_string(feeFixed.principalOutstanding) +
810 " != expected " + to_string(feeFixed.expectedNewPrincipal));
811 BEAST_EXPECTS(
812 feeLegacy.principalOutstanding == feeLegacy.expectedNewPrincipal + feeLegacy.unit,
813 "fee-case legacy principal " + to_string(feeLegacy.principalOutstanding) +
814 " != expected " + to_string(feeLegacy.expectedNewPrincipal + feeLegacy.unit));
815
816 // Management fee: the overpayment re-amortizes a fee-bearing loan, so the management fee
817 // outstanding drops.
818 //
819 // Unlike the principal that is already at the correct precision, the re-amortized
820 // management fee is tenthBipsOfValue of the new schedule's gross interest, which depends
821 // on the recomputed periodic payment. So the expected change below is a pinned constant
822 // captured from a passing run a magic value only because there is nothing simpler to
823 // compare against.
824 //
825 // At the integration level, toggling the amendment also changes the regular payment's
826 // rounding so a fixed-vs-legacy comparison cannot isolate the overpayment management-fee
827 // fix.
828 BEAST_EXPECT(feeFixed.managementFeeChange == feeLegacy.managementFeeChange);
829 BEAST_EXPECTS(
830 (feeFixed.managementFeeChange == Number{-8219709543, -10}),
831 "fee-case mgmt fee change " + to_string(feeFixed.managementFeeChange));
832 }
833
834 // An overpayment whose residual amount has more precision than loanScale
835 // fires the isRounded(asset, overpayment, loanScale) assertion in
836 // computeOverpaymentComponents (and a downstream "interest paid agrees"
837 // assertion in doOverpayment). fixCleanup3_2_0 rounds the residual down
838 // to loanScale before passing it in. The pre-amendment path can't be
839 // tested here because the assertion fires in Debug builds and aborts
840 // the test process — see the PR description for context.
841 void
843 {
844 testcase("bug: computeOverpaymentComponents isRounded assertion");
845
846 using namespace jtx;
847 using namespace loan;
848 Env env(*this, all_);
849
850 Account const issuer{"issuer"};
851 Account const lender{"vaultOwner"};
852 Account const borrower{"borrower"};
853
854 PrettyAsset const iouAsset = createFundedRippleIouAsset(env, issuer, lender, borrower);
855
856 auto const broker = createVaultAndBroker(
857 env,
858 iouAsset,
859 lender,
860 {.vaultDeposit = 100'000,
861 .debtMax = 5000,
862 .managementFeeRate = TenthBips16{1000},
863 .vaultScale = 1});
864
865 auto const sleBroker = env.le(broker.brokerKeylet());
866 if (!BEAST_EXPECT(sleBroker))
867 return;
868 auto const loanSequence = sleBroker->at(sfLoanSequence);
869 auto const loanKeylet = keylet::loan(broker.brokerID, SeqProxy::rawSequence(loanSequence));
870
871 using namespace loan;
872 env(set(borrower, broker.brokerID, Number{1000}, tfLoanOverpayment),
873 Sig(sfCounterpartySignature, lender),
874 kInterestRate(TenthBips32{10000}),
875 kPaymentTotal(12),
876 kPaymentInterval(60),
877 kGracePeriod(60),
878 kOverpaymentFee(TenthBips32{1000}),
879 kOverpaymentInterestRate(TenthBips32{1000}),
880 Fee(env.current()->fees().base * 2),
881 Ter(tesSUCCESS));
882 env.close();
883
884 // periodic * 1.5 at 15-sig-digit precision: 125.000154585042. This
885 // has too many digits to round cleanly to loanScale=-10, so the
886 // overpayment residual fails the isRounded check.
887 STAmount const payAmount{iouAsset.raw(), Number{125'000'154'585'042LL, -12}};
888 env(pay(borrower, loanKeylet.key, payAmount), Txflags(tfLoanOverpayment), Ter(tesSUCCESS));
889 env.close();
890 }
891
892 // A near-zero interest rate on a 100 USD loan
893 // produces total interest of ~6 units at loanScale -9. Numerical error
894 // in the amortization formula pushes the theoretical principal above
895 // the theoretical value, producing a negative theoretical interest.
896 // The payment delta then exceeds the actual outstanding interest,
897 // violating XRPL_ASSERT_PARTS in computePaymentComponents.
898 void
900 {
901 testcase("bug: LoanPay asserts 'interest due delta' on near-zero rate");
902
903 using namespace jtx;
904 using namespace std::chrono_literals;
905 Env env(*this, all_);
906
907 Account const issuer{"issuer"};
908 Account const lender{"lender"};
909 Account const borrower{"borrower"};
910
911 env.fund(XRP(1'000'000), issuer, lender, borrower);
912 env.close();
913 env(fset(issuer, asfDefaultRipple));
914 env.close();
915
916 PrettyAsset const iouAsset = issuer["USD"];
917 env(trust(lender, iouAsset(1'000'000'000)));
918 env(trust(borrower, iouAsset(1'000'000'000)));
919 env(pay(issuer, lender, iouAsset(5'000'000)));
920 env(pay(issuer, borrower, iouAsset(5'000'000)));
921 env.close();
922
923 BrokerParameters const brokerParams{
924 .vaultDeposit = 1'000'000,
925 .debtMax = 1'000'000,
926 .coverRateMin = TenthBips32{0},
927 .coverDeposit = 0,
928 .managementFeeRate = TenthBips16{0},
929 .coverRateLiquidation = TenthBips32{0}};
930
931 BrokerInfo const broker{createVaultAndBroker(env, iouAsset, lender, brokerParams)};
932
933 using namespace loan;
934
935 auto const loanSetFee = Fee(env.current()->fees().base * 2);
936 Number const principalRequest{100};
937
938 auto createJson = env.json(
939 set(borrower, broker.brokerID, principalRequest),
940 Fee(loanSetFee),
941 Json(sfCounterpartySignature, json::ValueType::Object));
942
943 createJson["InterestRate"] = 1; // minimum non-zero rate
944 createJson["PaymentTotal"] = 3;
945 createJson["PaymentInterval"] = 600;
946
947 auto const keylet = nextLoanKeylet(env, broker);
948
949 createJson = env.json(createJson, Sig(sfCounterpartySignature, lender));
950 env(createJson, Ter(tesSUCCESS));
951 env.close();
952
953 // For principal=100, n=3 the amortization schedule produces a
954 // periodic payment ≈ 33.33 USD. We pay 35 USD, which is more than
955 // one period's worth — enough for the LoanPay path to enter
956 // computePaymentComponents and reach the assertion that fires
957 // when the bug is present. With the fix, the tx applies cleanly.
958 env(pay(borrower, keylet.key, iouAsset(35)), Ter(tesSUCCESS));
959 env.close();
960 }
961
962 void
964 {
965 for (auto const flags : {0u, tfLoanOverpayment})
970 }
971
972 // Tests run under each entry in amendmentCombinations().
973 void
975 {
976 testDustManipulation(features);
979#if LOAN_TODO
980 testLoanCoverMinimumRoundingExploit(features);
981#endif
982 }
983
984public:
985 void
986 run() override
987 {
989 for (auto const& features : jtx::amendmentCombinations(
990 {fixCleanup3_1_3, fixCleanup3_2_0, featureMPTokensV2}, all_))
991 runAmendmentSensitive(features);
992 }
993};
994
995BEAST_DEFINE_TESTSUITE(LoanRounding, tx, xrpl);
996
997} // namespace xrpl::test
LogOs< char > log
Logging output stream.
Definition suite.h:150
TestcaseT testcase
Memberspace for declaring test cases.
Definition suite.h:155
Number is a floating point type that can represent a wide range of values.
Definition Number.h:351
static constexpr SeqProxy rawSequence(std::uint32_t v)
Factory function to return a sequence-based SeqProxy.
Definition SeqProxy.h:62
void testYieldTheftRounding(std::uint32_t flags)
void testDustManipulation(FeatureBitset features)
void testRoundingAllowsUndercoverage(FeatureBitset features)
void runAmendmentSensitive(FeatureBitset features)
void testIntegerScalePrincipalSticks(FeatureBitset features)
void run() override
Runs the suite.
FeatureBitset const all_
BrokerInfo createVaultAndBroker(jtx::Env &env, jtx::PrettyAsset const &asset, jtx::Account const &lender, BrokerParameters const &params=BrokerParameters::defaults())
LoanState getCurrentState(jtx::Env const &env, BrokerInfo const &broker, Keylet const &loanKeylet)
Get the state without checking anything.
Keylet nextLoanKeylet(jtx::Env const &env, BrokerInfo const &broker)
static jtx::PrettyAsset createFundedRippleIouAsset(jtx::Env &env, jtx::Account const &issuer, jtx::Account const &lender, jtx::Account const &borrower, Number const &lenderPay=1 '000 '000, Number const &borrowerPay=1 '000 '000)
Immutable cryptographic account descriptor.
Definition jtx/Account.h:21
AccountID id() const
Returns the Account ID.
A transaction testing environment.
Definition Env.h:161
bool close(NetClock::time_point closeTime, std::optional< std::chrono::milliseconds > consensusDelay=std::nullopt)
Close and advance the ledger.
Definition Env.cpp:133
TER ter() const
Return the TER for the last JTx.
Definition Env.h:842
json::Value json(JsonValue &&jv, FN const &... fN)
Create JSON from parameters.
Definition Env.h:750
SLE::const_pointer le(Account const &account) const
Return an account root.
Definition Env.cpp:311
void fund(bool setDefaultRipple, STAmount const &amount, Account const &account)
Definition Env.cpp:323
std::uint32_t seq(Account const &account) const
Returns the next sequence number on account.
Definition Env.cpp:302
PrettyAmount balance(Account const &account) const
Returns the XRP balance on an account.
Definition Env.cpp:201
std::shared_ptr< OpenView const > current() const
Returns the current ledger.
Definition Env.h:377
Set the fee on a JTx.
Definition fee.h:20
Inject raw JSON.
Definition jtx_json.h:16
Test helper for creating, mutating, and asserting MPT and confidential MPT ledger state.
Definition mpt.h:447
void create(MPTCreate const &arg=MPTCreate{})
Definition mpt.cpp:241
void authorize(MPTAuthorize const &arg=MPTAuthorize{})
Definition mpt.cpp:353
MPTID const & issuanceID() const
Definition mpt.h:641
Set the regular signature on a JTx.
Definition sig.h:19
Set the expected result code for a JTx The test will fail if the code doesn't match.
Definition ter.h:18
Set the flags on a JTx.
Definition txflags.h:14
T endl(T... args)
T make_tuple(T... args)
constexpr Zero kZero
Definition Zero.h:30
@ Object
object value (collection of name/value pairs).
Definition json_value.h:29
ExtendedPaymentComponents computeOverpaymentComponents(Rules const &rules, Asset const &asset, int32_t const loanScale, Number const &overpayment, TenthBips32 const overpaymentInterestRate, TenthBips32 const overpaymentFeeRate, TenthBips16 const managementFeeRate)
PaymentComponents computePaymentComponents(Rules const &rules, Asset const &asset, std::int32_t scale, Number const &totalValueOutstanding, Number const &principalOutstanding, Number const &managementFeeOutstanding, Number const &periodicPayment, Number const &periodicRate, std::uint32_t paymentRemaining, TenthBips16 managementFeeRate)
Keylet computation functions.
Definition Indexes.h:40
Keylet unchecked(uint256 const &key) noexcept
Any ledger entry.
Definition Indexes.cpp:367
Keylet loan(uint256 const &loanBrokerID, SeqProxy const &loanSeq) noexcept
Definition Indexes.cpp:573
Keylet vault(AccountID const &owner, SeqProxy const &seq) noexcept
Definition Indexes.cpp:561
Keylet loanBroker(AccountID const &owner, SeqProxy const &seq) noexcept
Definition Indexes.cpp:567
json::Value set(AccountID const &account, uint256 const &vaultId, uint32_t flags)
json::Value coverClawback(AccountID const &account, std::uint32_t flags)
json::Value set(AccountID const &account, uint256 const &loanBrokerID, Number principalRequested, std::uint32_t flags)
json::Value manage(AccountID const &account, uint256 const &loanID, std::uint32_t flags)
json::Value pay(AccountID const &account, uint256 const &loanID, STAmount const &amount, std::uint32_t flags)
json::Value pay(AccountID const &account, AccountID const &to, AnyAmount amount)
Create a payment.
Definition pay.cpp:14
std::vector< FeatureBitset > amendmentCombinations(std::initializer_list< uint256 > features, FeatureBitset seed)
Returns all 2^N permutations of a seed FeatureBitset with each subset of the given features excluded.
Definition Env.h:123
XrpT const XRP
Converts to XRP Issue or STAmount.
Definition amount.cpp:92
json::Value trust(Account const &account, STAmount const &amount, std::uint32_t flags)
Modify a trust line.
Definition trust.cpp:18
json::Value fset(Account const &account, std::uint32_t on, std::uint32_t off=0)
Add and/or remove flag.
Definition flags.cpp:15
static MPTInit const kMptInitNoFund
Definition mpt.h:172
BEAST_DEFINE_TESTSUITE(AMMClawback, app, xrpl)
STTx createTx(bool disabling, LedgerIndex seq, PublicKey const &txKey)
Create ttUNL_MODIFY Tx.
Use hash_* containers for keys that do not need a cryptographically secure hashing algorithm.
Definition algorithm.h:5
bool set(T &target, std::string const &name, Section const &section)
Set a value from a configuration Section If the named value is not found or doesn't parse as a T,...
Issue const & xrpIssue()
Returns an asset specifier that represents XRP.
Definition Issue.h:108
Number loanPeriodicRate(TenthBips32 interestRate, std::uint32_t paymentInterval)
constexpr T tenthBipsOfValue(T value, TenthBips< TBips > bips)
Definition Protocol.h:138
TenthBips< std::uint32_t > TenthBips32
Definition Units.h:454
static FunctionType fixed(Keylet const &keylet)
TenthBips< std::uint16_t > TenthBips16
Definition Units.h:453
boost::outcome_v2::result< T, std::error_code > Result
Definition b58_utils.h:19
std::string to_string(BaseUInt< Bits, Tag > const &a)
Definition base_uint.h:651
STAmount roundToScale(STAmount const &value, std::int32_t scale, Number::RoundingMode rounding=Number::getround())
Round an arbitrary precision Amount to the precision of an STAmount that has a given exponent.
@ tecINSUFFICIENT_FUNDS
Definition TER.h:328
LoanState constructLoanState(Number const &totalValueOutstanding, Number const &principalOutstanding, Number const &managementFeeOutstanding)
@ tesSUCCESS
Definition TER.h:245
A pair of SHAMap key and LedgerEntryType.
Definition Keylet.h:20
uint256 key
Definition Keylet.h:21
STAmount const & value() const
T to_string(T... args)