@@ -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