Skip to content

Commit 726aabe

Browse files
tyalymovclaude
andcommitted
test: Add LoanPay regression tests for impaired overdue loans (FN-68)
Cover the LoanPay side of the impairment fix from FN-9 (#6557): - with featureLendingProtocolV1_1, a plain LoanPay on an impaired, overdue loan is rejected with tecEXPIRED and only tfLoanLatePayment is accepted, clearing impairment with no residual vault LossUnrealized; - without the amendment, the original bypass reproduces (tesSUCCESS), proving the amendment is what closes it. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 896c83e commit 726aabe

1 file changed

Lines changed: 188 additions & 0 deletions

File tree

‎src/test/app/Loan_test.cpp‎

Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7424,6 +7424,192 @@ class Loan_test : public beast::unit_test::Suite
74247424
}
74257425
}
74267426

7427+
void
7428+
testImpairedOverdueLoanPayRequiresLateFlag()
7429+
{
7430+
using namespace jtx;
7431+
using namespace loan;
7432+
using namespace std::chrono_literals;
7433+
7434+
// FN-68: a borrower must not be able to bypass late-payment charges by
7435+
// paying an impaired, overdue loan with a plain LoanPay. Under
7436+
// featureLendingProtocolV1_1 impairment no longer moves the due date,
7437+
// so the payment logic sees the real (overdue) date: a regular payment
7438+
// is rejected with tecEXPIRED, and only a tfLoanLatePayment (which
7439+
// charges the late fee + late interest) is accepted.
7440+
testcase("Impaired overdue LoanPay requires late-payment flag");
7441+
7442+
Env env(*this, all_);
7443+
BEAST_EXPECT(env.enabled(featureLendingProtocolV1_1));
7444+
7445+
Account const lender{"lender"};
7446+
Account const borrower{"borrower"};
7447+
7448+
env.fund(XRP(100'000'000), lender, borrower);
7449+
env.close();
7450+
7451+
PrettyAsset const xrpAsset{xrpIssue(), 1'000'000};
7452+
auto const broker = createVaultAndBroker(env, xrpAsset, lender);
7453+
7454+
auto const sleBroker = env.le(keylet::loanBroker(broker.brokerID));
7455+
if (!BEAST_EXPECT(sleBroker))
7456+
return;
7457+
auto const loanKeylet = keylet::loan(broker.brokerID, sleBroker->at(sfLoanSequence));
7458+
7459+
// Loan with non-zero late-payment terms, so the late path carries a
7460+
// real penalty that the exploit would otherwise avoid.
7461+
Number const principalRequest{1, 3};
7462+
env(set(borrower, broker.brokerID, broker.asset(principalRequest).value()),
7463+
Sig(sfCounterpartySignature, lender),
7464+
kPaymentTotal(12),
7465+
kPaymentInterval(600),
7466+
kLatePaymentFee(broker.asset(3).number()),
7467+
kLateInterestRate(TenthBips32{30322}),
7468+
Fee(env.current()->fees().base * 2));
7469+
env.close();
7470+
7471+
auto const loanSle = env.le(loanKeylet);
7472+
if (!BEAST_EXPECT(loanSle))
7473+
return;
7474+
auto const originalNextDueDate = loanSle->at(sfNextPaymentDueDate);
7475+
auto const paymentsBefore = loanSle->at(sfPaymentRemaining);
7476+
BEAST_EXPECT(originalNextDueDate > 0);
7477+
7478+
// Advance past the due date so the loan is overdue, then impair it
7479+
// (impairment is only allowed once the payment is late).
7480+
env.close(NetClock::time_point{NetClock::duration{originalNextDueDate}} + 1s);
7481+
env(manage(lender, loanKeylet.key, tfLoanImpair), Ter(tesSUCCESS));
7482+
env.close();
7483+
7484+
// The loan is impaired and overdue, and its due date was NOT moved.
7485+
{
7486+
auto const loan = env.le(loanKeylet);
7487+
if (!BEAST_EXPECT(loan))
7488+
return;
7489+
BEAST_EXPECT(loan->isFlag(lsfLoanImpaired));
7490+
BEAST_EXPECT(loan->at(sfNextPaymentDueDate) == originalNextDueDate);
7491+
}
7492+
7493+
auto const payAmount = broker.asset(500).value();
7494+
7495+
// The exploit: a plain LoanPay (Flags = 0) on an impaired, overdue loan
7496+
// must be rejected. Before FN-9 the auto-unimpair pushed the due date
7497+
// into the future and this returned tesSUCCESS, letting the borrower
7498+
// skip the late fee and late interest.
7499+
env(pay(borrower, loanKeylet.key, payAmount), Ter(tecEXPIRED));
7500+
env.close();
7501+
7502+
// The failed payment changed nothing: still impaired, still overdue,
7503+
// no payment applied.
7504+
{
7505+
auto const loan = env.le(loanKeylet);
7506+
if (!BEAST_EXPECT(loan))
7507+
return;
7508+
BEAST_EXPECT(loan->isFlag(lsfLoanImpaired));
7509+
BEAST_EXPECT(loan->at(sfPaymentRemaining) == paymentsBefore);
7510+
BEAST_EXPECT(loan->at(sfNextPaymentDueDate) == originalNextDueDate);
7511+
}
7512+
7513+
// The same payment WITH the late flag is accepted, clears impairment,
7514+
// and advances the schedule by one period.
7515+
env(pay(borrower, loanKeylet.key, payAmount, tfLoanLatePayment), Ter(tesSUCCESS));
7516+
env.close();
7517+
{
7518+
auto const loan = env.le(loanKeylet);
7519+
if (!BEAST_EXPECT(loan))
7520+
return;
7521+
BEAST_EXPECT(!loan->isFlag(lsfLoanImpaired));
7522+
BEAST_EXPECT(loan->at(sfPaymentRemaining) == paymentsBefore - 1);
7523+
}
7524+
7525+
// A loan that is no longer impaired must leave no unrealized loss on
7526+
// the vault.
7527+
{
7528+
auto const vaultSle = env.le(broker.vaultKeylet());
7529+
if (!BEAST_EXPECT(vaultSle))
7530+
return;
7531+
BEAST_EXPECT(vaultSle->at(sfLossUnrealized) == 0);
7532+
}
7533+
}
7534+
7535+
void
7536+
testImpairedOverdueLoanPayBypassPreAmendment()
7537+
{
7538+
using namespace jtx;
7539+
using namespace loan;
7540+
using namespace std::chrono_literals;
7541+
7542+
// FN-68 (pre-amendment): documents the original vulnerability. Without
7543+
// featureLendingProtocolV1_1, impairing moves the due date and LoanPay
7544+
// auto-unimpair pushes it into the future before the late check, so a
7545+
// plain (Flags = 0) LoanPay on an impaired, overdue loan is accepted as
7546+
// on-time (tesSUCCESS) and the borrower dodges the late-payment charges.
7547+
// This is what testImpairedOverdueLoanPayRequiresLateFlag closes once
7548+
// the amendment is enabled.
7549+
testcase("Impaired overdue LoanPay bypass (pre-amendment)");
7550+
7551+
Env env(*this, all_ - featureLendingProtocolV1_1);
7552+
BEAST_EXPECT(!env.enabled(featureLendingProtocolV1_1));
7553+
7554+
Account const lender{"lender"};
7555+
Account const borrower{"borrower"};
7556+
7557+
env.fund(XRP(100'000'000), lender, borrower);
7558+
env.close();
7559+
7560+
PrettyAsset const xrpAsset{xrpIssue(), 1'000'000};
7561+
auto const broker = createVaultAndBroker(env, xrpAsset, lender);
7562+
7563+
auto const sleBroker = env.le(keylet::loanBroker(broker.brokerID));
7564+
if (!BEAST_EXPECT(sleBroker))
7565+
return;
7566+
auto const loanKeylet = keylet::loan(broker.brokerID, sleBroker->at(sfLoanSequence));
7567+
7568+
Number const principalRequest{1, 3};
7569+
env(set(borrower, broker.brokerID, broker.asset(principalRequest).value()),
7570+
Sig(sfCounterpartySignature, lender),
7571+
kPaymentTotal(12),
7572+
kPaymentInterval(600),
7573+
kLatePaymentFee(broker.asset(3).number()),
7574+
kLateInterestRate(TenthBips32{30322}),
7575+
Fee(env.current()->fees().base * 2));
7576+
env.close();
7577+
7578+
auto const loanSle = env.le(loanKeylet);
7579+
if (!BEAST_EXPECT(loanSle))
7580+
return;
7581+
auto const originalNextDueDate = loanSle->at(sfNextPaymentDueDate);
7582+
BEAST_EXPECT(originalNextDueDate > 0);
7583+
7584+
// Pre-amendment: a loan can be impaired before it is late, which moves
7585+
// the due date to "now".
7586+
env(manage(lender, loanKeylet.key, tfLoanImpair), Ter(tesSUCCESS));
7587+
env.close();
7588+
7589+
// Advance past the ORIGINAL due date so the loan is genuinely overdue.
7590+
env.close(NetClock::time_point{NetClock::duration{originalNextDueDate}} + 1s);
7591+
7592+
{
7593+
auto const loan = env.le(loanKeylet);
7594+
if (!BEAST_EXPECT(loan))
7595+
return;
7596+
BEAST_EXPECT(loan->isFlag(lsfLoanImpaired));
7597+
}
7598+
7599+
auto const payAmount = broker.asset(500).value();
7600+
7601+
// The bug: a plain LoanPay is accepted as on-time and clears the loan's
7602+
// impaired flag, so the late fee / late interest are never charged.
7603+
env(pay(borrower, loanKeylet.key, payAmount), Ter(tesSUCCESS));
7604+
env.close();
7605+
{
7606+
auto const loan = env.le(loanKeylet);
7607+
if (!BEAST_EXPECT(loan))
7608+
return;
7609+
BEAST_EXPECT(!loan->isFlag(lsfLoanImpaired));
7610+
}
7611+
}
7612+
74277613
void
74287614
testYieldTheftRounding(std::uint32_t flags)
74297615
{
@@ -8842,6 +9028,8 @@ class Loan_test : public beast::unit_test::Suite
88429028
testBugOverpayUnroundedAmount();
88439029
testImpairmentPaymentDateUnchanged();
88449030
testImpairmentPaymentDatePreAmendment();
9031+
testImpairedOverdueLoanPayRequiresLateFlag();
9032+
testImpairedOverdueLoanPayBypassPreAmendment();
88459033

88469034
for (auto const flags : {0u, tfLoanOverpayment})
88479035
testYieldTheftRounding(flags);

0 commit comments

Comments
 (0)