Compare commits

...

4 Commits

Author SHA1 Message Date
Vito
370be1d267 fix: add rounding check to overpayment 2026-04-29 12:12:26 +02:00
Vito
180796dc0c fix: Gate overpayment rounding behind fixCleanup3_2_0
Wrap the roundToAsset call on the overpayment residual in
loanMakePayment with a view.rules().enabled(fixCleanup3_2_0) check so
pre-amendment behavior is preserved. Update the testBugOverpayUnrounded
Amount comment to note that the pre-amendment path can't be exercised
as a regression test because the assertion fires in Debug builds and
aborts the test process.
2026-04-29 12:00:02 +02:00
Vito
2b91e84cb4 Merge remote-tracking branch 'origin/develop' into tapanito/lending-overpayment 2026-04-29 10:36:15 +02:00
Vito
00af7ba315 fix: Round overpayment residual and fix doOverpayment assertion
Two related issues on the loan overpayment path triggered spurious
assertion failures.

1. computeOverpaymentComponents requires its input amount to be
   representable at the loan's scale (`isRounded(asset, overpayment,
   loanScale)`). The residual passed in by loanMakePayment was the
   raw `amount - totalPaid` (capped at totalValueOutstanding), which
   could carry more precision than loanScale allows when the borrower
   pays an amount with extra fractional digits. Round the residual
   down to loanScale before calling computeOverpaymentComponents.

2. The "interest paid agrees" assertion in doOverpayment was missing
   the management-fee component released during re-amortization. When
   a borrower overpays and the loan is re-amortized, the management
   fee outstanding decreases, but the original assertion formula did
   not account for this delta. Add `mfeeReleased` (oldMfee - newMfee)
   to the assertion, and introduce named intermediate variables with
   a comment explaining the loan-state identity behind the derivation.

Add a regression test (testBugOverpayUnroundedAmount) that exercises
the overpayment path with a non-zero management fee rate and an
unrounded payment amount.
2026-04-28 14:36:04 +02:00
2 changed files with 152 additions and 46 deletions

View File

@@ -644,9 +644,6 @@ doOverpayment(
"xrpl::detail::doOverpayment",
"principal change agrees");
// I'm not 100% sure the following asserts are correct. If in doubt, and
// everything else works, remove any that cause trouble.
JLOG(j.debug()) << "valueChange: " << loanPaymentParts.valueChange
<< ", totalValue before: " << *totalValueOutstandingProxy
<< ", totalValue after: " << newRoundedLoanState.valueOutstanding
@@ -658,11 +655,28 @@ doOverpayment(
<< overpaymentComponents.trackedPrincipalDelta -
(totalValueOutstandingProxy - newRoundedLoanState.valueOutstanding);
// The valueChange returned by tryOverpayment satisfies
// valueChange = (newInterestDue - oldInterestDue) + untrackedInterest.
// Using the loan-state identity v = p + i + m and the adjacent
// `principal change agrees` assertion (dp = oldP - newP), this
// rearranges into three independently-computable terms:
//
// 1. TVO change beyond what principal repayment alone explains:
// newTVO - (oldTVO - dp)
// 2. Management fee released by re-amortization (positive when
// mfee decreased; zero when managementFeeRate == 0):
// oldMfee - newMfee
// 3. The overpayment's penalty interest part (= untrackedInterest
// for the overpayment path; see computeOverpaymentComponents):
// trackedInterestPart()
[[maybe_unused]] Number const tvoChange = newRoundedLoanState.valueOutstanding -
(totalValueOutstandingProxy - overpaymentComponents.trackedPrincipalDelta);
[[maybe_unused]] Number const mfeeReleased =
managementFeeOutstandingProxy - newRoundedLoanState.managementFeeDue;
[[maybe_unused]] Number const interestPart = overpaymentComponents.trackedInterestPart();
XRPL_ASSERT_PARTS(
loanPaymentParts.valueChange ==
newRoundedLoanState.valueOutstanding -
(totalValueOutstandingProxy - overpaymentComponents.trackedPrincipalDelta) +
overpaymentComponents.trackedInterestPart(),
loanPaymentParts.valueChange == tvoChange + mfeeReleased + interestPart,
"xrpl::detail::doOverpayment",
"interest paid agrees");
@@ -1867,50 +1881,57 @@ loanMakePayment(
// It shouldn't be possible for the overpayment to be greater than
// totalValueOutstanding, because that would have been processed as
// another normal payment. But cap it just in case.
Number const overpayment = std::min(amount - totalPaid, *totalValueOutstandingProxy);
Number const overpaymentRaw = std::min(amount - totalPaid, *totalValueOutstandingProxy);
Number const overpayment = view.rules().enabled(fixCleanup3_2_0)
? roundToAsset(asset, overpaymentRaw, loanScale, Number::downward)
: overpaymentRaw;
detail::ExtendedPaymentComponents const overpaymentComponents =
detail::computeOverpaymentComponents(
asset,
loanScale,
overpayment,
overpaymentInterestRate,
overpaymentFeeRate,
managementFeeRate);
// Don't process an overpayment if the whole amount (or more!)
// gets eaten by fees and interest.
if (overpaymentComponents.trackedPrincipalDelta > 0)
// Due to rounding, overpayment could be zero
if (overpayment > 0)
{
XRPL_ASSERT_PARTS(
overpaymentComponents.untrackedInterest >= beast::zero,
"xrpl::loanMakePayment",
"overpayment penalty did not reduce value of loan");
// Can't just use `periodicPayment` here, because it might
// change
auto periodicPaymentProxy = loan->at(sfPeriodicPayment);
if (auto const overResult = detail::doOverpayment(
detail::ExtendedPaymentComponents const overpaymentComponents =
detail::computeOverpaymentComponents(
asset,
loanScale,
overpaymentComponents,
totalValueOutstandingProxy,
principalOutstandingProxy,
managementFeeOutstandingProxy,
periodicPaymentProxy,
periodicRate,
paymentRemainingProxy,
managementFeeRate,
j))
overpayment,
overpaymentInterestRate,
overpaymentFeeRate,
managementFeeRate);
// Don't process an overpayment if the whole amount (or more!)
// gets eaten by fees and interest.
if (overpaymentComponents.trackedPrincipalDelta > 0)
{
totalParts += *overResult;
}
else if (overResult.error())
{
// error() will be the TER returned if a payment is not
// made. It will only evaluate to true if it's unsuccessful.
// Otherwise, tesSUCCESS means nothing was done, so
// continue.
return Unexpected(overResult.error());
XRPL_ASSERT_PARTS(
overpaymentComponents.untrackedInterest >= beast::zero,
"xrpl::loanMakePayment",
"overpayment penalty did not reduce value of loan");
// Can't just use `periodicPayment` here, because it might
// change
auto periodicPaymentProxy = loan->at(sfPeriodicPayment);
if (auto const overResult = detail::doOverpayment(
asset,
loanScale,
overpaymentComponents,
totalValueOutstandingProxy,
principalOutstandingProxy,
managementFeeOutstandingProxy,
periodicPaymentProxy,
periodicRate,
paymentRemainingProxy,
managementFeeRate,
j))
{
totalParts += *overResult;
}
else if (overResult.error())
{
// error() will be the TER returned if a payment is not
// made. It will only evaluate to true if it's unsuccessful.
// Otherwise, tesSUCCESS means nothing was done, so
// continue.
return Unexpected(overResult.error());
}
}
}
}

View File

@@ -7198,6 +7198,89 @@ protected:
attemptWithdrawShares(depositorB, sharesLpB, tesSUCCESS);
}
// An overpayment whose residual amount has more precision than loanScale
// fires the isRounded(asset, overpayment, loanScale) assertion in
// computeOverpaymentComponents (and a downstream "interest paid agrees"
// assertion in doOverpayment). fixCleanup3_2_0 rounds the residual down
// to loanScale before passing it in. The pre-amendment path can't be
// tested here because the assertion fires in Debug builds and aborts
// the test process — see the PR description for context.
void
testBugOverpayUnroundedAmount()
{
testcase("bug: computeOverpaymentComponents isRounded assertion");
using namespace jtx;
Env env(*this, all);
Account const issuer{"issuer"};
Account const vaultOwner{"vaultOwner"};
Account const depositor{"depositor"};
Account const borrower{"borrower"};
env.fund(XRP(1'000'000), issuer, vaultOwner, depositor, borrower);
env.close();
env(fset(issuer, asfDefaultRipple));
env.close();
PrettyAsset const iouAsset = issuer["USD"];
STAmount const iouLimit{iouAsset.raw(), Number{9'999'999'999'999'999LL}};
env(trust(vaultOwner, iouLimit));
env(trust(depositor, iouLimit));
env(trust(borrower, iouLimit));
env.close();
env(pay(issuer, vaultOwner, iouAsset(1'000'000)));
env(pay(issuer, depositor, iouAsset(1'000'000)));
env(pay(issuer, borrower, iouAsset(1'000'000)));
env.close();
Vault const vault{env};
auto [vaultTx, vaultKeylet] = vault.create({.owner = vaultOwner, .asset = iouAsset});
vaultTx[sfScale] = 1;
env(vaultTx);
env.close();
env(vault.deposit(
{.depositor = depositor, .id = vaultKeylet.key, .amount = iouAsset(100'000)}));
env.close();
auto const brokerKeylet = keylet::loanbroker(vaultOwner.id(), env.seq(vaultOwner));
{
using namespace loanBroker;
env(set(vaultOwner, vaultKeylet.key),
managementFeeRate(TenthBips16{1000}),
debtMaximum(Number{5000}),
fee(env.current()->fees().base * 2));
}
env.close();
auto const brokerStateBefore = env.le(brokerKeylet);
auto const loanSequence = brokerStateBefore->at(sfLoanSequence);
auto const loanKeylet = keylet::loan(brokerKeylet.key, loanSequence);
using namespace loan;
auto createJson = env.json(
set(borrower, brokerKeylet.key, Number{1000}, tfLoanOverpayment),
fee(env.current()->fees().base * 2),
json(sfCounterpartySignature, Json::objectValue));
createJson["InterestRate"] = 10000;
createJson["PaymentTotal"] = 12;
createJson["PaymentInterval"] = 60;
createJson["GracePeriod"] = 60;
createJson["OverpaymentFee"] = 1000;
createJson["OverpaymentInterestRate"] = 1000;
createJson = env.json(createJson, sig(sfCounterpartySignature, vaultOwner));
env(createJson, ter(tesSUCCESS));
env.close();
// periodic * 1.5 at 15-sig-digit precision: 125.000154585042. This
// has too many digits to round cleanly to loanScale=-10, so the
// overpayment residual fails the isRounded check.
STAmount const payAmount{iouAsset.raw(), Number{125'000'154'585'042LL, -12}};
env(pay(borrower, loanKeylet.key, payAmount), txflags(tfLoanOverpayment), ter(tesSUCCESS));
env.close();
}
public:
void
run() override
@@ -7206,6 +7289,8 @@ public:
testLoanPayLateFullPaymentBypassesPenalties();
testLoanCoverMinimumRoundingExploit();
#endif
testBugOverpayUnroundedAmount();
testWithdrawReflectsUnrealizedLoss();
testInvalidLoanSet();