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 // Also run under featureLendingProtocolV1_1: ValidLoan must allow the
419 // two sticking pays (TVO falls, PO does not) and the final clear
420 // (PaymentRemaining 0, NextPaymentDueDate 0).
421 void
423 {
424 // Without fixCleanup3_2_0, this behavior will abort the server, so
425 // don't run without it.
426 if (!features[fixCleanup3_2_0])
427 return;
428
429 testcase("edge: integer MPT principal stuck mid-loan completes via final");
430
431 using namespace jtx;
432 Env env(*this, features);
433
434 Account const issuer{"issuer"};
435 Account const lender{"lender"};
436 Account const borrower{"borrower"};
437
438 env.fund(XRP(100'000), issuer, lender, borrower);
439 env.close();
440
441 MPTTester mptt{env, issuer, kMptInitNoFund};
442 mptt.create({.maxAmt = 100'000, .flags = tfMPTCanTransfer});
443 PrettyAsset const asset{mptt.issuanceID()};
444
445 mptt.authorize({.account = lender});
446 mptt.authorize({.account = borrower});
447
448 env(pay(issuer, lender, asset(10'000)));
449 env(pay(issuer, borrower, asset(10'000)));
450 env.close();
451
452 // createVaultAndBroker promotes the vault to ClosedEnded under
453 // featureLendingProtocolV1_1 (LoanBrokerSet rejects open-ended).
454 BrokerParameters const params{
455 .vaultDeposit = Number{5'000},
456 .debtMax = Number{100},
457 .coverRateMin = TenthBips32{0},
458 .coverDeposit = 0,
459 .managementFeeRate = TenthBips16{0},
460 .coverRateLiquidation = TenthBips32{0}};
461 BrokerInfo const broker = createVaultAndBroker(env, asset, lender, params);
462
463 auto const loanKeylet = nextLoanKeylet(env, broker);
464 env(loan::set(borrower, broker.brokerID, Number{1}),
465 Sig(sfCounterpartySignature, lender),
468 loan::kPaymentInterval(31'536'000),
469 Fee(env.current()->fees().base * 2));
470 env.close();
471
472 auto const borrowerStart = env.balance(borrower, asset).value();
473
474 // Three separate periodic payments of 1 each. Expected per-period
475 // evolution at integer MPT scale (TVO = PO + interestDue +
476 // managementFeeDue):
477 // start: PO=1, TVO=3, paymentRemaining=3
478 // after pay #1: PO=1, TVO=2, paymentRemaining=2 (principal sticks)
479 // after pay #2: PO=1, TVO=1, paymentRemaining=1 (principal sticks)
480 // after pay #3: PO=0, TVO=0, paymentRemaining=0 (final clears)
481 std::array<Number, 3> const expectedPO{Number{1}, Number{1}, Number{0}};
482 std::array<Number, 3> const expectedTVO{Number{2}, Number{1}, Number{0}};
483 std::array<std::uint32_t, 3> const expectedRemaining{2, 1, 0};
484
485 for (int i = 0; i < 3; ++i)
486 {
487 env(loan::pay(borrower, loanKeylet.key, asset(1)), Ter(tesSUCCESS));
488 env.close();
489
490 auto const sle = env.le(loanKeylet);
491 if (!BEAST_EXPECT(sle))
492 return;
493 BEAST_EXPECT(sle->at(sfPrincipalOutstanding) == expectedPO[i]);
494 BEAST_EXPECT(sle->at(sfTotalValueOutstanding) == expectedTVO[i]);
495 BEAST_EXPECT(sle->at(sfPaymentRemaining) == expectedRemaining[i]);
496 if (expectedRemaining[i] == 0)
497 BEAST_EXPECT(sle->at(~sfNextPaymentDueDate).value_or(0) == 0);
498 }
499
500 // Borrower paid 3 total regardless of fee split (1 principal + 2
501 // interest+fee, matching loan economics).
502 auto const borrowerEnd = env.balance(borrower, asset).value();
503 BEAST_EXPECT(borrowerStart - borrowerEnd == asset(3).value());
504 }
505
506#if LOAN_TODO
507 void
508 testLoanCoverMinimumRoundingExploit(FeatureBitset features)
509 {
510 auto testLoanCoverMinimumRoundingExploit = [&, this](Number const& principalRequest) {
511 testcase << "LoanBrokerCoverClawback drains cover via rounding"
512 << " principalRequested=" << to_string(principalRequest);
513
514 using namespace jtx;
515 using namespace loan;
516 using namespace loan_broker;
517
518 Env env(*this, features);
519
520 Account const issuer{"issuer"};
521 Account const lender{"lender"};
522 Account const borrower{"borrower"};
523
524 env.fund(XRP(1'000'000'000), issuer, lender, borrower);
525 env.close();
526
527 env(fset(issuer, asfAllowTrustLineClawback));
528 env.close();
529
530 PrettyAsset const asset = issuer[iouCurrency];
531 env(trust(lender, asset(2'000'0000)));
532 env(trust(borrower, asset(2'000'0000)));
533 env.close();
534
535 env(pay(issuer, lender, asset(2'000'0000)));
536 env.close();
537
538 BrokerParameters brokerParams{.debtMax = 0, .coverRateMin = TenthBips32{10'000}};
539 BrokerInfo broker{createVaultAndBroker(env, asset, lender, brokerParams)};
540
541 auto const loanSetFee = Fee(env.current()->fees().base * 2);
542 auto createTx = env.jt(
543 set(borrower, broker.brokerID, principalRequest),
544 Sig(sfCounterpartySignature, lender),
545 loanSetFee,
546 kPaymentInterval(600),
547 kPaymentTotal(1),
548 kGracePeriod(60));
549 env(createTx);
550 env.close();
551
552 auto const brokerBefore = env.le(keylet::loanBroker(broker.brokerID));
553 BEAST_EXPECT(brokerBefore);
554 if (!brokerBefore)
555 return;
556
557 Number const debtOutstanding = brokerBefore->at(sfDebtTotal);
558 Number const coverAvailableBefore = brokerBefore->at(sfCoverAvailable);
559
560 BEAST_EXPECT(debtOutstanding > Number{});
561 BEAST_EXPECT(coverAvailableBefore > Number{});
562
563 log << "debt=" << to_string(debtOutstanding)
564 << " cover_available=" << to_string(coverAvailableBefore);
565
566 env(coverClawback(issuer, 0), loanBrokerID(broker.brokerID));
567 env.close();
568
569 auto const brokerAfter = env.le(keylet::loanBroker(broker.brokerID));
570 BEAST_EXPECT(brokerAfter);
571 if (!brokerAfter)
572 return;
573
574 Number const debtAfter = brokerAfter->at(sfDebtTotal);
575 // the debt has not changed
576 BEAST_EXPECT(debtAfter == debtOutstanding);
577
578 Number const coverAvailableAfter = brokerAfter->at(sfCoverAvailable);
579
580 // since the cover rate min != 0, the cover available should not
581 // be zero
582 BEAST_EXPECT(coverAvailableAfter != Number{});
583 };
584
585 // Call the lambda with different principal values
586 testLoanCoverMinimumRoundingExploit(Number{1, -30}); // 1e-30 units
587 testLoanCoverMinimumRoundingExploit(Number{1, -20}); // 1e-20 units
588 testLoanCoverMinimumRoundingExploit(Number{1, -10}); // 1e-10 units
589 testLoanCoverMinimumRoundingExploit(Number{1, 1}); // 1e-10 units
590 }
591#endif
592
593 // A residual overpayment can reduce the stored principal by one scale-unit
594 // *less* than computeOverpaymentComponents predicts, firing the
595 // "principal change agrees" XRPL_ASSERT_PARTS in doOverpayment:
596 //
597 // trackedPrincipalDelta == principalOutstanding - newPrincipalOutstanding
598 //
599 // tryOverpayment re-amortizes the loan at the reduced principal, then
600 // re-derives the theoretical principal from the new periodic payment via
601 // (P * paymentFactor) / paymentFactor. That round-trip is not exact in
602 // Number's 19-digit arithmetic; a positive residual pushes the recomputed
603 // principal a hair above the exact grid point `oldPrincipal - delta`, and
604 // the Upward rounding in tryOverpayment then bumps it a full scale-unit
605 // higher. The principal therefore drops by `delta - 1 unit`, not `delta`.
606 //
607 // Concrete case (isolated, at the tryOverpayment level):
608 // A 100 USD loan at the minimum non-zero rate, 3 payments, loanScale -10.
609 // After one regular payment (principalOutstanding 66.6666666674) a residual overpayment of
610 // 0.049999998 yields trackedPrincipalDelta 0.048999998 but only reduces the principal by
611 // 0.0489999979 (newPrincipal 66.6176666695) — short by 1e-10.
612 //
613 // With fixCleanup3_2_0, tryOverpayment pins the new principal to the exact,
614 // on-grid reduction (oldPrincipal - trackedPrincipalDelta) instead of the
615 // lossy (P*factor)/factor round-trip, so the assertion holds and the
616 // overpayment applies cleanly. The three "principal change agrees" /
617 // "interest paid agrees" / "principal payment matches" assertions are
618 // gated behind the same amendment, so without it they are disabled (the
619 // server does not abort) and the loan keeps the pre-amendment computation.
620 //
621 // The test runs the same scenario under both amendment settings and checks
622 // the stored principal against a ground-truth value derived independently of
623 // the loan-state computation under test.
624 void
626 {
627 testcase("bug: doOverpayment asserts 'principal change agrees'");
628
629 using namespace jtx;
630 using namespace loan;
631 using namespace xrpl::detail;
632
633 struct Params
634 {
635 TenthBips32 interestRate;
636 TenthBips16 managementFeeRate;
637 std::uint32_t paymentTotal;
638 std::uint32_t paymentInterval;
639 std::int64_t principal;
640 Number overpayment;
641 TenthBips32 overpaymentInterestRate;
642 TenthBips32 overpaymentFeeRate;
643 std::optional<int> vaultScale;
644 };
645
646 struct Result
647 {
648 Number principalOutstanding; // stored principal after the LoanPay
649 Number expectedNewPrincipal; // ground truth, independent of the fix
650 Number managementFeeChange; // managementFeeOutstanding after - before
651 Number unit; // one scale-unit at the loan scale
652 };
653
654 auto runScenario = [this](FeatureBitset features, Params const& p) -> Result {
655 Env env(*this, features);
656
657 Account const issuer{"issuer"};
658 Account const lender{"vaultOwner"};
659 Account const borrower{"borrower"};
660
661 PrettyAsset const iouAsset = createFundedRippleIouAsset(env, issuer, lender, borrower);
662 Asset const asset = iouAsset.raw();
663
664 auto const broker = createVaultAndBroker(
665 env,
666 iouAsset,
667 lender,
668 {.vaultDeposit = 900'000,
669 .debtMax = 0,
670 .managementFeeRate = p.managementFeeRate,
671 .vaultScale = p.vaultScale});
672
673 auto const brokerSle = env.le(broker.brokerKeylet());
674 BEAST_EXPECT(brokerSle);
675 auto const loanSequence = brokerSle ? brokerSle->at(sfLoanSequence) : 0;
676 auto const loanKeylet =
677 keylet::loan(broker.brokerID, SeqProxy::rawSequence(loanSequence));
678
679 env(set(borrower, broker.brokerID, Number{p.principal}, tfLoanOverpayment),
680 Sig(sfCounterpartySignature, lender),
681 kInterestRate(p.interestRate),
682 kPaymentTotal(p.paymentTotal),
683 kPaymentInterval(p.paymentInterval),
684 kGracePeriod(p.paymentInterval),
685 kOverpaymentFee(p.overpaymentFeeRate),
686 kOverpaymentInterestRate(p.overpaymentInterestRate),
687 Fee(env.current()->fees().base * 2),
688 Ter(tesSUCCESS));
689 env.close();
690
691 // The single LoanPay below makes one regular payment (the overpayment
692 // is smaller than one period) and leaves the residual as an
693 // overpayment.
694 auto const s = getCurrentState(env, broker, loanKeylet);
695 auto const periodicRate = loanPeriodicRate(s.interestRate, s.paymentInterval);
696 auto const onePeriod = computePaymentComponents(
697 env.current()->rules(),
698 asset,
699 s.loanScale,
700 s.totalValue,
701 s.principalOutstanding,
702 s.managementFeeOutstanding,
703 s.periodicPayment,
704 periodicRate,
705 s.paymentRemaining,
706 p.managementFeeRate);
707
708 // Ground truth: the stored principal must drop by exactly the regular
709 // payment's principal portion plus the overpayment's principal
710 // portion. computeOverpaymentComponents depends only on the
711 // overpayment amount and rates (not on the loan-state computation
712 // under test), so it is an independent oracle. Both components are
713 // computed under the same rules as the env so the payment factor
714 // matches.
715 auto const overpaymentComponents = computeOverpaymentComponents(
716 env.current()->rules(),
717 asset,
718 s.loanScale,
719 p.overpayment,
720 p.overpaymentInterestRate,
721 p.overpaymentFeeRate,
722 p.managementFeeRate);
723 Number const expectedNewPrincipal = s.principalOutstanding -
724 onePeriod.trackedPrincipalDelta - overpaymentComponents.trackedPrincipalDelta;
725
726 Number const managementFeeBefore = s.managementFeeOutstanding;
727
728 STAmount const payAmount{asset, onePeriod.trackedValueDelta + p.overpayment};
729 env(pay(borrower, loanKeylet.key, payAmount),
730 Txflags(tfLoanOverpayment),
731 Ter(tesSUCCESS));
732 env.close();
733
734 auto const loanSle = env.le(loanKeylet);
735 BEAST_EXPECT(loanSle);
736
737 return Result{
738 .principalOutstanding = loanSle ? Number{loanSle->at(sfPrincipalOutstanding)} : 0,
739 .expectedNewPrincipal = expectedNewPrincipal,
740 .managementFeeChange =
741 (loanSle ? Number{loanSle->at(sfManagementFeeOutstanding)} : Number{0}) -
742 managementFeeBefore,
743 .unit = Number{1, s.loanScale}};
744 };
745
746 // Scenario 1: the original near-zero-rate principal reproduction
747 // (loanScale -10, no management fee). 0.049999998 is smaller than one
748 // period, so it stays a residual overpayment.
749 Params const principalCase{
750 .interestRate = TenthBips32{1},
751 .managementFeeRate = TenthBips16{0},
752 .paymentTotal = 3,
753 .paymentInterval = 60,
754 .principal = 100,
755 .overpayment = Number{49999998, -9},
756 .overpaymentInterestRate = TenthBips32{1000},
757 .overpaymentFeeRate = TenthBips32{1000},
758 .vaultScale = 1};
759
760 // With fixCleanup3_2_0 the stored principal lands exactly on the
761 // ground-truth grid point: it is reduced by exactly the overpayment's
762 // principal portion. This is the key correctness check: if the principal
763 // pin were removed (even with the assertions still gated off), the lossy
764 // (P * factor) / factor round-trip would leave the principal one
765 // scale-unit high and this would fail.
766 Result const fixed = runScenario(all_, principalCase);
767 BEAST_EXPECTS(
768 fixed.principalOutstanding == fixed.expectedNewPrincipal,
769 "fixed principal " + to_string(fixed.principalOutstanding) + " != expected " +
770 to_string(fixed.expectedNewPrincipal));
771
772 // Without the amendment the loan amortizes with the catastrophically
773 // cancelling near-zero payment factor, so its schedule (and ground truth)
774 // differ from the fixed case; the gated assertions keep the server from
775 // aborting and the overpayment still lands exactly on that schedule.
776 Result const legacy = runScenario(all_ - fixCleanup3_2_0, principalCase);
777 BEAST_EXPECTS(
778 legacy.principalOutstanding == legacy.expectedNewPrincipal,
779 "legacy principal " + to_string(legacy.principalOutstanding) + " != expected " +
780 to_string(legacy.expectedNewPrincipal));
781
782 // Scenario 2: a normal-rate loan with a 10% management fee. At a normal
783 // rate the payment factor is identical across the amendment, so toggling
784 // fixCleanup3_2_0 isolates the fix. This overpayment (found by search)
785 // lands on a state where both the principal and the management fee differ
786 // by one scale-unit between the fixed and legacy paths.
787 Params const feeCase{
788 .interestRate = TenthBips32{10000},
789 .managementFeeRate = TenthBips16{10000},
790 .paymentTotal = 6,
791 .paymentInterval = 30u * 24 * 60 * 60,
792 .principal = 1000,
793 .overpayment = Number{214367363, -10},
794 .overpaymentInterestRate = TenthBips32{0},
795 .overpaymentFeeRate = TenthBips32{0},
796 .vaultScale = std::nullopt};
797
798 Result const feeFixed = runScenario(all_, feeCase);
799 Result const feeLegacy = runScenario(all_ - fixCleanup3_2_0, feeCase);
800
801 // With the fix the principal is the exact reduction; without it the lossy
802 // (P * factor) / factor round-trip leaves it one scale-unit high.
803 BEAST_EXPECTS(
804 feeFixed.principalOutstanding == feeFixed.expectedNewPrincipal,
805 "fee-case fixed principal " + to_string(feeFixed.principalOutstanding) +
806 " != expected " + to_string(feeFixed.expectedNewPrincipal));
807 BEAST_EXPECTS(
808 feeLegacy.principalOutstanding == feeLegacy.expectedNewPrincipal + feeLegacy.unit,
809 "fee-case legacy principal " + to_string(feeLegacy.principalOutstanding) +
810 " != expected " + to_string(feeLegacy.expectedNewPrincipal + feeLegacy.unit));
811
812 // Management fee: the overpayment re-amortizes a fee-bearing loan, so the management fee
813 // outstanding drops.
814 //
815 // Unlike the principal that is already at the correct precision, the re-amortized
816 // management fee is tenthBipsOfValue of the new schedule's gross interest, which depends
817 // on the recomputed periodic payment. So the expected change below is a pinned constant
818 // captured from a passing run a magic value only because there is nothing simpler to
819 // compare against.
820 //
821 // At the integration level, toggling the amendment also changes the regular payment's
822 // rounding so a fixed-vs-legacy comparison cannot isolate the overpayment management-fee
823 // fix.
824 BEAST_EXPECT(feeFixed.managementFeeChange == feeLegacy.managementFeeChange);
825 BEAST_EXPECTS(
826 (feeFixed.managementFeeChange == Number{-8219709543, -10}),
827 "fee-case mgmt fee change " + to_string(feeFixed.managementFeeChange));
828 }
829
830 // An overpayment whose residual amount has more precision than loanScale
831 // fires the isRounded(asset, overpayment, loanScale) assertion in
832 // computeOverpaymentComponents (and a downstream "interest paid agrees"
833 // assertion in doOverpayment). fixCleanup3_2_0 rounds the residual down
834 // to loanScale before passing it in. The pre-amendment path can't be
835 // tested here because the assertion fires in Debug builds and aborts
836 // the test process — see the PR description for context.
837 void
839 {
840 testcase("bug: computeOverpaymentComponents isRounded assertion");
841
842 using namespace jtx;
843 using namespace loan;
844 Env env(*this, all_);
845
846 Account const issuer{"issuer"};
847 Account const lender{"vaultOwner"};
848 Account const borrower{"borrower"};
849
850 PrettyAsset const iouAsset = createFundedRippleIouAsset(env, issuer, lender, borrower);
851
852 auto const broker = createVaultAndBroker(
853 env,
854 iouAsset,
855 lender,
856 {.vaultDeposit = 100'000,
857 .debtMax = 5000,
858 .managementFeeRate = TenthBips16{1000},
859 .vaultScale = 1});
860
861 auto const sleBroker = env.le(broker.brokerKeylet());
862 if (!BEAST_EXPECT(sleBroker))
863 return;
864 auto const loanSequence = sleBroker->at(sfLoanSequence);
865 auto const loanKeylet = keylet::loan(broker.brokerID, SeqProxy::rawSequence(loanSequence));
866
867 using namespace loan;
868 env(set(borrower, broker.brokerID, Number{1000}, tfLoanOverpayment),
869 Sig(sfCounterpartySignature, lender),
870 kInterestRate(TenthBips32{10000}),
871 kPaymentTotal(12),
872 kPaymentInterval(60),
873 kGracePeriod(60),
874 kOverpaymentFee(TenthBips32{1000}),
875 kOverpaymentInterestRate(TenthBips32{1000}),
876 Fee(env.current()->fees().base * 2),
877 Ter(tesSUCCESS));
878 env.close();
879
880 // periodic * 1.5 at 15-sig-digit precision: 125.000154585042. This
881 // has too many digits to round cleanly to loanScale=-10, so the
882 // overpayment residual fails the isRounded check.
883 STAmount const payAmount{iouAsset.raw(), Number{125'000'154'585'042LL, -12}};
884 env(pay(borrower, loanKeylet.key, payAmount), Txflags(tfLoanOverpayment), Ter(tesSUCCESS));
885 env.close();
886 }
887
888 // Pre-fixCleanup3_4_0 bug: VaultWithdraw for a fixed *share* amount that
889 // rounds to zero assets trips tecINVARIANT_FAILED instead of failing
890 // cleanly or succeeding, depending on why it's zero. The fixed-shares
891 // branch had no zero guard, unlike the fixed-assets branch.
892 // XRP case: pool value is nonzero (2,000,000) but 1 share's worth (0.5
893 // drops) truncates to zero drops -> real precision loss -> tecPRECISION_LOSS.
894 // IOU case: loan drew 100% of the vault and is fully impaired, so
895 // AssetsTotal == LossUnrealized exactly -> pool value is genuinely zero
896 // -> legitimate zero-value withdrawal -> tesSUCCESS.
897 void
899 {
900 testcase("bug: VaultWithdraw fixed shares round down to zero assets");
901
902 using namespace jtx;
903 using namespace loan;
904
905 bool const fixed = features[fixCleanup3_4_0];
906
907 Env env(*this, features);
908
909 Account const lender{"lender"};
910 Account const depositorB{"depositorB"};
911 Account const borrower{"borrower"};
912
913 env.fund(XRP(10'000'000), lender, depositorB, borrower);
914 env.close();
915
916 // asset(n) == n drops.
917 PrettyAsset const xrpAsset{xrpIssue(), 1};
918
919 auto const broker = createVaultAndBroker(
920 env,
921 xrpAsset,
922 lender,
923 {.vaultDeposit = 1'000'000, .debtMax = 3'000'000, .coverDeposit = 1'000'000});
924
925 Vault const v{env};
926 env(v.deposit(
927 {.depositor = depositorB,
928 .id = broker.vaultKeylet().key,
929 .amount = xrpAsset(3'000'000)}));
930 env.close();
931
932 auto const brokerSle = env.le(broker.brokerKeylet());
933 if (!BEAST_EXPECT(brokerSle))
934 return;
935 auto const loanKeylet =
936 keylet::loan(broker.brokerID, SeqProxy::rawSequence(brokerSle->at(sfLoanSequence)));
937
938 env(set(borrower, broker.brokerID, Number{2'000'000}),
939 Sig(sfCounterpartySignature, lender),
940 kPaymentTotal(2),
941 kPaymentInterval(600),
942 Fee(env.current()->fees().base * 2),
943 Ter(tesSUCCESS));
944 env.close();
945
946 // Impair the loan so LossUnrealized > 0.
947 advancePastDueDate(env, loanKeylet);
948 env(manage(lender, loanKeylet.key, tfLoanImpair), Ter(tesSUCCESS));
949 env.close();
950
951 auto const vaultSle = env.le(broker.vaultKeylet());
952 if (!BEAST_EXPECT(vaultSle))
953 return;
954 BEAST_EXPECT(vaultSle->at(sfLossUnrealized) > beast::kZero);
955
956 // (AssetsTotal 4M - LossUnrealized 2M) * 1 share / 4M shares = 0.5,
957 // rounds down to zero drops.
958 auto const shareAsset = vaultSle->at(sfShareMPTID);
959 STAmount const oneShare{MPTIssue{shareAsset}, Number(1)};
960
961 env(v.withdraw({.depositor = lender, .id = broker.vaultKeylet().key, .amount = oneShare}),
963 env.close();
964
965 // Same bug, IOU asset. Needs a 2nd, minimal depositor: a sole
966 // shareholder would waive the loss subtraction (fixCleanup3_2_0),
967 // returning full value instead of zero.
968 {
969 Account const issuer{"issuer"};
970 Account const iouLender{"iouLender"};
971 Account const iouDepositorB{"iouDepositorB"};
972 Account const iouBorrower{"iouBorrower"};
973
974 env.fund(XRP(10'000'000), issuer, iouLender, iouDepositorB, iouBorrower);
975 env.close();
976
977 PrettyAsset const iouAsset = issuer[iouCurrency_];
978 env(trust(iouLender, iouAsset(10'000'000)));
979 env(trust(iouDepositorB, iouAsset(10'000'000)));
980 env(trust(iouBorrower, iouAsset(10'000'000)));
981 // iouLender funds the vault deposit and the broker's cover deposit.
982 env(pay(issuer, iouLender, iouAsset(9'000'000)));
983 env(pay(issuer, iouDepositorB, iouAsset(1)));
984 env.close();
985
986 // No management fee -> LossUnrealized ends up == AssetsTotal.
987 auto const iouBroker = createVaultAndBroker(
988 env,
989 iouAsset,
990 iouLender,
991 {.vaultDeposit = 3'999'999,
992 .debtMax = 4'000'000,
993 .coverDeposit = 4'000'000,
994 .managementFeeRate = TenthBips16{0}});
995
996 env(v.deposit(
997 {.depositor = iouDepositorB,
998 .id = iouBroker.vaultKeylet().key,
999 .amount = iouAsset(1)}));
1000 env.close();
1001
1002 auto const iouBrokerSle = env.le(iouBroker.brokerKeylet());
1003 if (!BEAST_EXPECT(iouBrokerSle))
1004 return;
1005 auto const iouLoanKeylet = keylet::loan(
1006 iouBroker.brokerID, SeqProxy::rawSequence(iouBrokerSle->at(sfLoanSequence)));
1007
1008 // Draw the entire vault out as a single loan.
1009 env(set(iouBorrower, iouBroker.brokerID, Number{4'000'000}),
1010 Sig(sfCounterpartySignature, iouLender),
1011 kPaymentTotal(2),
1012 kPaymentInterval(600),
1013 Fee(env.current()->fees().base * 2),
1014 Ter(tesSUCCESS));
1015 env.close();
1016
1017 advancePastDueDate(env, iouLoanKeylet);
1018 env(manage(iouLender, iouLoanKeylet.key, tfLoanImpair), Ter(tesSUCCESS));
1019 env.close();
1020
1021 auto const iouVaultSle = env.le(iouBroker.vaultKeylet());
1022 if (!BEAST_EXPECT(iouVaultSle))
1023 return;
1024 BEAST_EXPECT(iouVaultSle->at(sfLossUnrealized) == iouVaultSle->at(sfAssetsTotal));
1025
1026 auto const iouShareAsset = iouVaultSle->at(sfShareMPTID);
1027 STAmount const oneIouShare{MPTIssue{iouShareAsset}, Number(1)};
1028
1029 auto const iouLenderBalanceBefore = env.balance(iouLender, iouAsset);
1030 auto const iouVaultAvailableBefore = iouVaultSle->at(sfAssetsAvailable);
1031 // Env::balance can't be used for shares: it resolves the issuer
1032 // name, and the share issuer is the vault pseudo-account, which
1033 // Env doesn't know.
1034 auto const lenderShares = [&]() -> std::uint64_t {
1035 auto const sle = env.le(keylet::mptoken(iouShareAsset, iouLender.id()));
1036 return sle ? sle->at(sfMPTAmount) : 0;
1037 };
1038 auto const iouLenderSharesBefore = lenderShares();
1039 auto const iouIssuanceBefore = env.le(keylet::mptokenIssuance(iouShareAsset));
1040 if (!BEAST_EXPECT(iouIssuanceBefore))
1041 return;
1042 auto const iouSharesOutstandingBefore = iouIssuanceBefore->at(sfOutstandingAmount);
1043 env(v.withdraw(
1044 {.depositor = iouLender,
1045 .id = iouBroker.vaultKeylet().key,
1046 .amount = oneIouShare}),
1048 env.close();
1049
1050 if (fixed)
1051 {
1052 // Confirm this was a true zero-value transfer: balances
1053 // unchanged even though a share was burned.
1054 BEAST_EXPECT(env.balance(iouLender, iouAsset) == iouLenderBalanceBefore);
1055 BEAST_EXPECT(lenderShares() == iouLenderSharesBefore - 1);
1056 auto const iouIssuanceAfter = env.le(keylet::mptokenIssuance(iouShareAsset));
1057 if (BEAST_EXPECT(iouIssuanceAfter))
1058 {
1059 BEAST_EXPECT(
1060 iouIssuanceAfter->at(sfOutstandingAmount) ==
1061 iouSharesOutstandingBefore - 1);
1062 }
1063 auto const iouVaultAfter = env.le(iouBroker.vaultKeylet());
1064 if (BEAST_EXPECT(iouVaultAfter))
1065 {
1066 BEAST_EXPECT(iouVaultAfter->at(sfAssetsAvailable) == iouVaultAvailableBefore);
1067 }
1068 }
1069 }
1070 }
1071
1072 // Companion to the Vault_test dust-debit tests, which use a single
1073 // depositor so AssetsTotal == AssetsAvailable and both debitIsNonZeroDust
1074 // operands in VaultWithdraw::doApply trip together. Here a loan draws
1075 // almost the entire vault, leaving AssetsTotal (1e7) far above
1076 // AssetsAvailable (100): redeeming 1 share moves 1e-10 assets, which is
1077 // dust against AssetsTotal but representable against AssetsAvailable, so
1078 // the AssetsTotal operand alone carries the rejection.
1079 void
1081 {
1082 testcase("bug: VaultWithdraw dust debit vs AssetsTotal only");
1083
1084 using namespace jtx;
1085 using namespace loan;
1086
1087 bool const fixed = features[fixCleanup3_4_0];
1088
1089 Env env(*this, features);
1090
1091 Account const issuer{"issuer"};
1092 Account const lender{"lender"};
1093 Account const borrower{"borrower"};
1094
1095 env.fund(XRP(10'000'000), issuer, lender, borrower);
1096 env.close();
1097
1098 PrettyAsset const iouAsset = issuer[iouCurrency_];
1099 env(trust(lender, iouAsset(100'000'000)));
1100 env(trust(borrower, iouAsset(100'000'000)));
1101 env(pay(issuer, lender, iouAsset(20'000'000)));
1102 env.close();
1103
1104 // Scale 10 so 1 share is worth 1e-10 assets against the 1e7 pool.
1105 auto const broker = createVaultAndBroker(
1106 env,
1107 iouAsset,
1108 lender,
1109 {.vaultDeposit = 10'000'000,
1110 .debtMax = 10'000'000,
1111 .coverDeposit = 1'000'000,
1112 .vaultScale = 10});
1113
1114 // Draw all but 100 units: AssetsAvailable drops to 100 while
1115 // AssetsTotal stays at 1e7 (the loan is still an asset of the vault).
1116 env(set(borrower, broker.brokerID, Number{9'999'900}),
1117 Sig(sfCounterpartySignature, lender),
1118 kPaymentTotal(2),
1119 kPaymentInterval(600),
1120 Fee(env.current()->fees().base * 2),
1121 Ter(tesSUCCESS));
1122 env.close();
1123
1124 auto const vaultSle = env.le(broker.vaultKeylet());
1125 if (!BEAST_EXPECT(vaultSle))
1126 return;
1127 BEAST_EXPECT(vaultSle->at(sfAssetsTotal) == Number{10'000'000});
1128 BEAST_EXPECT(vaultSle->at(sfAssetsAvailable) == Number{100});
1129
1130 // 1 share redeems 1e7 * 1 / 1e17 = 1e-10 assets. Subtracting that
1131 // from AssetsTotal needs 18 significant digits and canonicalizes
1132 // straight back to 1e7 (no-op), while AssetsAvailable would become
1133 // 99.9999999999 — perfectly representable.
1134 auto const shareAsset = vaultSle->at(sfShareMPTID);
1135 STAmount const oneShare{MPTIssue{shareAsset}, Number(1)};
1136
1137 Vault const v{env};
1138 env(v.withdraw({.depositor = lender, .id = broker.vaultKeylet().key, .amount = oneShare}),
1140 env.close();
1141 }
1142
1143 // A near-zero interest rate on a 100 USD loan
1144 // produces total interest of ~6 units at loanScale -9. Numerical error
1145 // in the amortization formula pushes the theoretical principal above
1146 // the theoretical value, producing a negative theoretical interest.
1147 // The payment delta then exceeds the actual outstanding interest,
1148 // violating XRPL_ASSERT_PARTS in computePaymentComponents.
1149 void
1151 {
1152 testcase("bug: LoanPay asserts 'interest due delta' on near-zero rate");
1153
1154 using namespace jtx;
1155 using namespace std::chrono_literals;
1156 Env env(*this, all_);
1157
1158 Account const issuer{"issuer"};
1159 Account const lender{"lender"};
1160 Account const borrower{"borrower"};
1161
1162 env.fund(XRP(1'000'000), issuer, lender, borrower);
1163 env.close();
1164 env(fset(issuer, asfDefaultRipple));
1165 env.close();
1166
1167 PrettyAsset const iouAsset = issuer["USD"];
1168 env(trust(lender, iouAsset(1'000'000'000)));
1169 env(trust(borrower, iouAsset(1'000'000'000)));
1170 env(pay(issuer, lender, iouAsset(5'000'000)));
1171 env(pay(issuer, borrower, iouAsset(5'000'000)));
1172 env.close();
1173
1174 BrokerParameters const brokerParams{
1175 .vaultDeposit = 1'000'000,
1176 .debtMax = 1'000'000,
1177 .coverRateMin = TenthBips32{0},
1178 .coverDeposit = 0,
1179 .managementFeeRate = TenthBips16{0},
1180 .coverRateLiquidation = TenthBips32{0}};
1181
1182 BrokerInfo const broker{createVaultAndBroker(env, iouAsset, lender, brokerParams)};
1183
1184 using namespace loan;
1185
1186 auto const loanSetFee = Fee(env.current()->fees().base * 2);
1187 Number const principalRequest{100};
1188
1189 auto createJson = env.json(
1190 set(borrower, broker.brokerID, principalRequest),
1191 Fee(loanSetFee),
1192 Json(sfCounterpartySignature, json::ValueType::Object));
1193
1194 createJson["InterestRate"] = 1; // minimum non-zero rate
1195 createJson["PaymentTotal"] = 3;
1196 createJson["PaymentInterval"] = 600;
1197
1198 auto const keylet = nextLoanKeylet(env, broker);
1199
1200 createJson = env.json(createJson, Sig(sfCounterpartySignature, lender));
1201 env(createJson, Ter(tesSUCCESS));
1202 env.close();
1203
1204 // For principal=100, n=3 the amortization schedule produces a
1205 // periodic payment ≈ 33.33 USD. We pay 35 USD, which is more than
1206 // one period's worth — enough for the LoanPay path to enter
1207 // computePaymentComponents and reach the assertion that fires
1208 // when the bug is present. With the fix, the tx applies cleanly.
1209 env(pay(borrower, keylet.key, iouAsset(35)), Ter(tesSUCCESS));
1210 env.close();
1211 }
1212
1213 void
1215 {
1216 for (auto const flags : {0u, tfLoanOverpayment})
1225 // all_ excludes V1.1; amendmentCombinations never pairs it with the
1226 // sticking schedule. Run that combination explicitly.
1227 testIntegerScalePrincipalSticks(all_ | featureLendingProtocolV1_1);
1228 }
1229
1230 // Tests run under each entry in amendmentCombinations().
1231 void
1233 {
1234 testDustManipulation(features);
1237#if LOAN_TODO
1238 testLoanCoverMinimumRoundingExploit(features);
1239#endif
1240 }
1241
1242public:
1243 void
1244 run() override
1245 {
1247 for (auto const& features : jtx::amendmentCombinations(
1248 {fixCleanup3_1_3, fixCleanup3_2_0, featureMPTokensV2}, all_))
1249 runAmendmentSensitive(features);
1250 }
1251};
1252
1253BEAST_DEFINE_TESTSUITE(LoanRounding, tx, xrpl);
1254
1255} // 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 testBugVaultWithdrawDustVsAssetsTotal(FeatureBitset features)
void testBugVaultWithdrawFixedSharesRoundsToZero(FeatureBitset features)
void run() override
Runs the suite.
Shared base for the Loan*_test family under src/test/app/lending/.
FeatureBitset const all_
void advancePastDueDate(jtx::Env &env, Keylet const &loanKeylet)
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)
std::string const iouCurrency_
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:844
json::Value json(JsonValue &&jv, FN const &... fN)
Create JSON from parameters.
Definition Env.h:752
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
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:512
MPTID const & issuanceID() const
Definition mpt.h:768
void create(MPTCreate const &arg=MPTCreate{}, std::source_location const &loc=std::source_location::current())
Definition mpt.cpp:343
void authorize(MPTAuthorize const &arg=MPTAuthorize{}, std::source_location const &loc=std::source_location::current())
Definition mpt.cpp:455
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:397
Keylet vault(AccountID const &owner, SeqProxy const &seq) noexcept
Definition Indexes.cpp:591
Keylet mptoken(MPTID const &issuanceID, AccountID const &holder) noexcept
Definition Indexes.cpp:573
Keylet loanBroker(AccountID const &owner, SeqProxy const &seq) noexcept
Definition Indexes.cpp:597
Keylet loan(UInt256 const &loanBrokerID, SeqProxy const &loanSeq) noexcept
Definition Indexes.cpp:603
Keylet mptokenIssuance(MPTID const &issuanceID) noexcept
Definition Indexes.cpp:567
json::Value coverClawback(AccountID const &account, std::uint32_t flags)
json::Value manage(AccountID const &account, UInt256 const &loanID, std::uint32_t flags)
json::Value set(AccountID const &account, UInt256 const &loanBrokerID, Number principalRequested, 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:201
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:139
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:657
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.
@ tecINVARIANT_FAILED
Definition TER.h:321
@ tecINSUFFICIENT_FUNDS
Definition TER.h:333
@ tecPRECISION_LOSS
Definition TER.h:371
LoanState constructLoanState(Number const &totalValueOutstanding, Number const &principalOutstanding, Number const &managementFeeOutstanding)
@ tesSUCCESS
Definition TER.h:250
A pair of SHAMap key and LedgerEntryType.
Definition Keylet.h:20
UInt256 key
Definition Keylet.h:21
STAmount const & value() const
static json::Value withdraw(WithdrawArgs const &args)
Definition vault.cpp:100
T to_string(T... args)