From c1a0f94c2ea8a41651645cb921e0a1acd13ef46e Mon Sep 17 00:00:00 2001 From: Vito <5780819+Tapanito@users.noreply.github.com> Date: Thu, 16 Jul 2026 18:12:42 +0200 Subject: [PATCH 01/11] feat: Implement LendingProtocolV1_1 cash-basis accounting Gate Vault AssetsTotal and LoanBroker DebtTotal to principal-only tracking under featureLendingProtocolV1_1, replacing whole-life (accrual) interest recognition. Adds Accrual/CashBasis namespaces and three public dispatchers in LendingHelpers, consumed by LoanSet, LoanPay, and LoanManage without any amendment-awareness in the transactors themselves. Pre-amendment behavior is unchanged. --- include/xrpl/ledger/helpers/LendingHelpers.h | 55 ++ src/libxrpl/ledger/helpers/LendingHelpers.cpp | 71 ++ .../tx/transactors/lending/LoanManage.cpp | 23 +- .../tx/transactors/lending/LoanPay.cpp | 43 +- .../tx/transactors/lending/LoanSet.cpp | 6 +- src/test/app/LendingHelpers_test.cpp | 218 +++++ src/test/app/Loan_test.cpp | 794 +++++++++++++++++- 7 files changed, 1159 insertions(+), 51 deletions(-) diff --git a/include/xrpl/ledger/helpers/LendingHelpers.h b/include/xrpl/ledger/helpers/LendingHelpers.h index e2605e9ab73..c6047ec9fb0 100644 --- a/include/xrpl/ledger/helpers/LendingHelpers.h +++ b/include/xrpl/ledger/helpers/LendingHelpers.h @@ -285,6 +285,61 @@ computeFullPaymentInterest( std::uint32_t startDate, TenthBips32 closeInterestRate); +// Deltas applied to Vault.AssetsTotal and LoanBroker.DebtTotal at a single +// accounting touch point (origination, payment, impair/unimpair/default). +struct AccountingDeltas +{ + Number assetsTotalDelta; + Number debtTotalDelta; +}; + +// Whole-life (pre-LendingProtocolV1_1) recognition model: interest is +// recognized into AssetsTotal/DebtTotal up front, at origination. +namespace Accrual { + +// LoanSet origination: what's added to Vault.AssetsTotal and LoanBroker.DebtTotal +AccountingDeltas +loanOriginationDeltas(Number const& principalRequested, Number const& interestDue); + +// LoanManage impair/unimpair/default: the vault's exposure to this loan +Number +loanVaultExposure(SLE::ref loanSle); + +// LoanPay: what's added to Vault.AssetsTotal and subtracted from LoanBroker.DebtTotal for a payment +AccountingDeltas +loanPaymentDeltas(LoanPaymentParts const& parts); + +} // namespace Accrual + +// Cash-basis (LendingProtocolV1_1) recognition model: AssetsTotal/DebtTotal +// are principal-only, interest is recognized only as it's actually paid. +namespace CashBasis { + +AccountingDeltas +loanOriginationDeltas(Number const& principalRequested, Number const& interestDue); + +Number +loanVaultExposure(SLE::ref loanSle); + +AccountingDeltas +loanPaymentDeltas(LoanPaymentParts const& parts); + +} // namespace CashBasis + +// Public dispatchers: pick CashBasis:: if featureLendingProtocolV1_1 is +// enabled, else Accrual::. These are the only entry points transactors call. +AccountingDeltas +loanOriginationDeltas( + Rules const& rules, + Number const& principalRequested, + Number const& interestDue); + +Number +loanVaultExposure(Rules const& rules, SLE::ref loanSle); + +AccountingDeltas +loanPaymentDeltas(Rules const& rules, LoanPaymentParts const& parts); + namespace detail { // These classes and functions should only be accessed by LendingHelper // functions and unit tests diff --git a/src/libxrpl/ledger/helpers/LendingHelpers.cpp b/src/libxrpl/ledger/helpers/LendingHelpers.cpp index f7ec8a8bc34..c9736fb4ca8 100644 --- a/src/libxrpl/ledger/helpers/LendingHelpers.cpp +++ b/src/libxrpl/ledger/helpers/LendingHelpers.cpp @@ -130,6 +130,77 @@ isRounded(Asset const& asset, Number const& value, std::int32_t scale) roundToAsset(asset, value, scale, Number::RoundingMode::Upward); } +namespace Accrual { + +AccountingDeltas +loanOriginationDeltas(Number const& principalRequested, Number const& interestDue) +{ + return {.assetsTotalDelta = interestDue, .debtTotalDelta = principalRequested + interestDue}; +} + +Number +loanVaultExposure(SLE::ref loanSle) +{ + return loanSle->at(sfTotalValueOutstanding) - loanSle->at(sfManagementFeeOutstanding); +} + +AccountingDeltas +loanPaymentDeltas(LoanPaymentParts const& parts) +{ + return { + .assetsTotalDelta = parts.valueChange, + .debtTotalDelta = (parts.principalPaid + parts.interestPaid) - parts.valueChange}; +} + +} // namespace Accrual + +namespace CashBasis { + +AccountingDeltas +loanOriginationDeltas(Number const& principalRequested, Number const&) +{ + return {.assetsTotalDelta = kNumZero, .debtTotalDelta = principalRequested}; +} + +Number +loanVaultExposure(SLE::ref loanSle) +{ + return loanSle->at(sfPrincipalOutstanding); +} + +AccountingDeltas +loanPaymentDeltas(LoanPaymentParts const& parts) +{ + return {.assetsTotalDelta = parts.interestPaid, .debtTotalDelta = parts.principalPaid}; +} + +} // namespace CashBasis + +AccountingDeltas +loanOriginationDeltas( + Rules const& rules, + Number const& principalRequested, + Number const& interestDue) +{ + return rules.enabled(featureLendingProtocolV1_1) + ? CashBasis::loanOriginationDeltas(principalRequested, interestDue) + : Accrual::loanOriginationDeltas(principalRequested, interestDue); +} + +Number +loanVaultExposure(Rules const& rules, SLE::ref loanSle) +{ + return rules.enabled(featureLendingProtocolV1_1) ? CashBasis::loanVaultExposure(loanSle) + : Accrual::loanVaultExposure(loanSle); +} + +AccountingDeltas +loanPaymentDeltas(Rules const& rules, LoanPaymentParts const& parts) +{ + return rules.enabled(featureLendingProtocolV1_1) ? CashBasis::loanPaymentDeltas(parts) + : Accrual::loanPaymentDeltas(parts); +} + namespace detail { void diff --git a/src/libxrpl/tx/transactors/lending/LoanManage.cpp b/src/libxrpl/tx/transactors/lending/LoanManage.cpp index a0aa948876b..20f3b79ae53 100644 --- a/src/libxrpl/tx/transactors/lending/LoanManage.cpp +++ b/src/libxrpl/tx/transactors/lending/LoanManage.cpp @@ -127,23 +127,6 @@ LoanManage::preclaim(PreclaimContext const& ctx) return tesSUCCESS; } -static Number -owedToVault(SLE::ref loanSle) -{ - // Spec section 3.2.3.2, defines the default amount as - // - // DefaultAmount = (Loan.PrincipalOutstanding + Loan.InterestOutstanding) - // - // Loan.InterestOutstanding is not stored directly on ledger. - // It is computed as - // - // Loan.TotalValueOutstanding - Loan.PrincipalOutstanding - - // Loan.ManagementFeeOutstanding - // - // Add that to the original formula, and you get this: - return loanSle->at(sfTotalValueOutstanding) - loanSle->at(sfManagementFeeOutstanding); -} - TER LoanManage::defaultLoan( ApplyView& view, @@ -158,7 +141,7 @@ LoanManage::defaultLoan( std::int32_t const loanScale = loanSle->at(sfLoanScale); auto brokerDebtTotalProxy = brokerSle->at(sfDebtTotal); - Number const totalDefaultAmount = owedToVault(loanSle); + Number const totalDefaultAmount = loanVaultExposure(view.rules(), loanSle); // Apply the First-Loss Capital to the Default Amount TenthBips32 const coverRateMinimum{brokerSle->at(sfCoverRateMinimum)}; @@ -304,7 +287,7 @@ LoanManage::impairLoan( Asset const& vaultAsset, beast::Journal j) { - Number const lossUnrealized = owedToVault(loanSle); + Number const lossUnrealized = loanVaultExposure(view.rules(), loanSle); // The vault may be at a different scale than the loan. Reduce rounding // errors during the accounting by rounding some of the values to that @@ -353,7 +336,7 @@ LoanManage::unimpairLoan( // Update the Vault object(clear "paper loss") auto vaultLossUnrealizedProxy = vaultSle->at(sfLossUnrealized); - Number const lossReversed = owedToVault(loanSle); + Number const lossReversed = loanVaultExposure(view.rules(), loanSle); if (vaultLossUnrealizedProxy < lossReversed) { // LCOV_EXCL_START diff --git a/src/libxrpl/tx/transactors/lending/LoanPay.cpp b/src/libxrpl/tx/transactors/lending/LoanPay.cpp index 54ee85b186b..da35ebee426 100644 --- a/src/libxrpl/tx/transactors/lending/LoanPay.cpp +++ b/src/libxrpl/tx/transactors/lending/LoanPay.cpp @@ -420,10 +420,13 @@ LoanPay::doApply() // LCOV_EXCL_STOP } + auto const [assetsTotalDelta, debtTotalDelta] = loanPaymentDeltas(view.rules(), *paymentParts); + JLOG(j_.debug()) << "Loan Pay: principal paid: " << paymentParts->principalPaid << ", interest paid: " << paymentParts->interestPaid << ", fee paid: " << paymentParts->feePaid - << ", value change: " << paymentParts->valueChange; + << ", assets total delta: " << assetsTotalDelta + << ", debt total delta: " << debtTotalDelta; //------------------------------------------------------ // LoanBroker object state changes @@ -439,13 +442,6 @@ LoanPay::doApply() !asset.integral() || totalPaidToVaultRaw == totalPaidToVaultRounded, "xrpl::LoanPay::doApply", "rounding does nothing for integral asset"); - // Account for value changes when reducing the broker's debt: - // - Positive value change (from full/late/overpayments): Subtract from the - // amount credited toward debt to avoid over-reducing the debt. - // - Negative value change (from full/overpayments): Add to the amount - // credited toward debt,effectively increasing the debt reduction. - auto const totalPaidToVaultForDebt = totalPaidToVaultRaw - paymentParts->valueChange; - auto const totalPaidToBroker = paymentParts->feePaid; XRPL_ASSERT_PARTS( @@ -455,16 +451,16 @@ LoanPay::doApply() "payments add up"); // Decrease LoanBroker Debt by the amount paid, add the Loan value change - // (which might be negative). totalPaidToVaultForDebt may be negative, - // increasing the debt + // (which might be negative). debtTotalDelta may be negative, increasing the + // debt XRPL_ASSERT_PARTS( - isRounded(asset, totalPaidToVaultForDebt, loanScale), + isRounded(asset, debtTotalDelta, loanScale), "xrpl::LoanPay::doApply", - "totalPaidToVaultForDebt rounding good"); + "debtTotalDelta rounding good"); // Despite our best efforts, it's possible for rounding errors to accumulate // in the loan broker's debt total. This is because the broker may have more // than one loan with significantly different scales. - adjustImpreciseNumber(debtTotalProxy, -totalPaidToVaultForDebt, asset, vaultScale); + adjustImpreciseNumber(debtTotalProxy, -debtTotalDelta, asset, vaultScale); //------------------------------------------------------ // Vault object state changes @@ -490,7 +486,7 @@ LoanPay::doApply() #endif assetsAvailableProxy += totalPaidToVaultRounded; - assetsTotalProxy += paymentParts->valueChange; + assetsTotalProxy += assetsTotalDelta; XRPL_ASSERT_PARTS( *assetsAvailableProxy <= *assetsTotalProxy, @@ -543,11 +539,11 @@ LoanPay::doApply() return tecPRECISION_LOSS; // LCOV_EXCL_STOP } - if (paymentParts->valueChange != beast::kZero && assetsTotalAfter == assetsTotalBefore) + if (assetsTotalDelta != beast::kZero && assetsTotalAfter == assetsTotalBefore) { - // Non-zero valueChange with an unchanged assetsTotal indicates that the - // actual value change rounded to zero. That should be impossible, but I - // can't rule it out for extreme edge cases, so fail gracefully if it + // Non-zero assetsTotalDelta with an unchanged assetsTotal indicates that + // the actual value change rounded to zero. That should be impossible, but + // I can't rule it out for extreme edge cases, so fail gracefully if it // happens. // // LCOV_EXCL_START @@ -555,20 +551,21 @@ LoanPay::doApply() << "LoanPay: Vault assets expected change, but unchanged after rounding: " // << "Before: " << assetsTotalBefore // << ", After: " << assetsTotalAfter // - << ", ValueChange: " << paymentParts->valueChange; + << ", ValueChange: " << assetsTotalDelta; return tecPRECISION_LOSS; // LCOV_EXCL_STOP } - if (paymentParts->valueChange == beast::kZero && assetsTotalAfter != assetsTotalBefore) + if (assetsTotalDelta == beast::kZero && assetsTotalAfter != assetsTotalBefore) { - // A change in assetsTotal when there was no valueChange indicates that - // something really weird happened. That should be flat out impossible. + // A change in assetsTotal when there was no assetsTotalDelta indicates + // that something really weird happened. That should be flat out + // impossible. // // LCOV_EXCL_START JLOG(j_.fatal()) << "LoanPay: Vault assets changed unexpectedly after rounding: " // << "Before: " << assetsTotalBefore // << ", After: " << assetsTotalAfter // - << ", ValueChange: " << paymentParts->valueChange; + << ", ValueChange: " << assetsTotalDelta; return tecINTERNAL; // LCOV_EXCL_STOP } diff --git a/src/libxrpl/tx/transactors/lending/LoanSet.cpp b/src/libxrpl/tx/transactors/lending/LoanSet.cpp index 694d01c69fd..34be342dd8a 100644 --- a/src/libxrpl/tx/transactors/lending/LoanSet.cpp +++ b/src/libxrpl/tx/transactors/lending/LoanSet.cpp @@ -633,8 +633,10 @@ LoanSet::doApply() view.insert(loan); // Update the balances in the vault + auto const [assetsTotalDelta, debtTotalDelta] = + loanOriginationDeltas(ctx_.view().rules(), principalRequested, state.interestDue); vaultAvailableProxy -= principalRequested; - vaultTotalProxy += state.interestDue; + vaultTotalProxy += assetsTotalDelta; XRPL_ASSERT_PARTS( *vaultAvailableProxy <= *vaultTotalProxy, "xrpl::LoanSet::doApply", @@ -642,7 +644,7 @@ LoanSet::doApply() view.update(vaultSle); // Update the balances in the loan broker - adjustImpreciseNumber(brokerSle->at(sfDebtTotal), newDebtDelta, vaultAsset, vaultScale); + adjustImpreciseNumber(brokerSle->at(sfDebtTotal), debtTotalDelta, vaultAsset, vaultScale); adjustLoanBrokerOwnerCount(view, brokerSle, 1, j_); loanSequenceProxy += 1; // The sequence should be extremely unlikely to roll over, but fail if it diff --git a/src/test/app/LendingHelpers_test.cpp b/src/test/app/LendingHelpers_test.cpp index ac8e0764fcb..af1c2294956 100644 --- a/src/test/app/LendingHelpers_test.cpp +++ b/src/test/app/LendingHelpers_test.cpp @@ -1470,6 +1470,215 @@ class LendingHelpers_test : public beast::unit_test::Suite Number{-18304, -5})); } + void + testAccrualLoanOriginationDeltas() + { + using namespace xrpl::Accrual; + + struct TestCase + { + std::string name; + Number principalRequested; + Number interestDue; + }; + + auto const testCases = std::vector{ + {.name = "Zero interest", + .principalRequested = Number{1'000}, + .interestDue = Number{0}}, + {.name = "Nonzero interest", + .principalRequested = Number{1'000}, + .interestDue = Number{75}}, + }; + + for (auto const& tc : testCases) + { + testcase("Accrual::loanOriginationDeltas: " + tc.name); + + auto const deltas = loanOriginationDeltas(tc.principalRequested, tc.interestDue); + BEAST_EXPECTS( + deltas.assetsTotalDelta == tc.interestDue, + "assetsTotalDelta mismatch: expected " + to_string(tc.interestDue) + ", got " + + to_string(deltas.assetsTotalDelta)); + BEAST_EXPECTS( + deltas.debtTotalDelta == tc.principalRequested + tc.interestDue, + "debtTotalDelta mismatch: expected " + + to_string(tc.principalRequested + tc.interestDue) + ", got " + + to_string(deltas.debtTotalDelta)); + } + } + + void + testCashBasisLoanOriginationDeltas() + { + using namespace xrpl::CashBasis; + + testcase("CashBasis::loanOriginationDeltas: interestDue is ignored"); + + Number const principalRequested{1'000}; + Number const interestDue{75}; + + auto const deltas = loanOriginationDeltas(principalRequested, interestDue); + BEAST_EXPECTS( + deltas.assetsTotalDelta == 0, + "assetsTotalDelta mismatch: expected 0, got " + to_string(deltas.assetsTotalDelta)); + BEAST_EXPECTS( + deltas.debtTotalDelta == principalRequested, + "debtTotalDelta mismatch: expected " + to_string(principalRequested) + ", got " + + to_string(deltas.debtTotalDelta)); + } + + // Constructs a minimal ltLOAN SLE with just the fields needed by + // loanVaultExposure. Mirrors the bare-SLE pattern used by + // testCanApplyToBrokerCover for ltLOAN_BROKER. + static std::shared_ptr + makeLoanSle( + Number const& totalValueOutstanding, + Number const& principalOutstanding, + Number const& managementFeeOutstanding) + { + auto sle = std::make_shared(ltLOAN, uint256{1u}); + sle->at(sfTotalValueOutstanding) = totalValueOutstanding; + sle->at(sfPrincipalOutstanding) = principalOutstanding; + sle->at(sfManagementFeeOutstanding) = managementFeeOutstanding; + return sle; + } + + void + testAccrualLoanVaultExposure() + { + testcase("Accrual::loanVaultExposure"); + + auto sle = makeLoanSle(Number{1'000}, Number{800}, Number{50}); + BEAST_EXPECT(xrpl::Accrual::loanVaultExposure(sle) == Number{950}); + } + + void + testCashBasisLoanVaultExposure() + { + testcase("CashBasis::loanVaultExposure"); + + auto sle = makeLoanSle(Number{1'000}, Number{800}, Number{50}); + BEAST_EXPECT(xrpl::CashBasis::loanVaultExposure(sle) == Number{800}); + } + + void + testLoanPaymentDeltas() + { + // principalPaid, interestPaid, feePaid, valueChange are all distinct + // and nonzero, with a nonzero valueChange simulating a late-payment + // penalty, so Accrual's formula is meaningfully exercised. + LoanPaymentParts const parts{ + .principalPaid = Number{100}, + .interestPaid = Number{20}, + .valueChange = Number{5}, + .feePaid = Number{3}}; + + { + testcase("Accrual::loanPaymentDeltas: nonzero valueChange"); + auto const deltas = xrpl::Accrual::loanPaymentDeltas(parts); + BEAST_EXPECT(deltas.assetsTotalDelta == parts.valueChange); + BEAST_EXPECT( + deltas.debtTotalDelta == + (parts.principalPaid + parts.interestPaid) - parts.valueChange); + } + + { + testcase("CashBasis::loanPaymentDeltas: nonzero valueChange ignored"); + auto const deltas = xrpl::CashBasis::loanPaymentDeltas(parts); + BEAST_EXPECT(deltas.assetsTotalDelta == parts.interestPaid); + BEAST_EXPECT(deltas.debtTotalDelta == parts.principalPaid); + } + } + + void + testLoanOriginationDeltasDispatcher() + { + using namespace jtx; + + Number const principalRequested{1'000}; + Number const interestDue{75}; + + { + testcase("loanOriginationDeltas dispatcher: amendment disabled picks Accrual"); + Env env{*this}; + env.disableFeature(featureLendingProtocolV1_1); + auto const deltas = + loanOriginationDeltas(env.current()->rules(), principalRequested, interestDue); + auto const expected = + xrpl::Accrual::loanOriginationDeltas(principalRequested, interestDue); + BEAST_EXPECT(deltas.assetsTotalDelta == expected.assetsTotalDelta); + BEAST_EXPECT(deltas.debtTotalDelta == expected.debtTotalDelta); + } + + { + testcase("loanOriginationDeltas dispatcher: amendment enabled picks CashBasis"); + Env const env{*this}; + auto const deltas = + loanOriginationDeltas(env.current()->rules(), principalRequested, interestDue); + auto const expected = + xrpl::CashBasis::loanOriginationDeltas(principalRequested, interestDue); + BEAST_EXPECT(deltas.assetsTotalDelta == expected.assetsTotalDelta); + BEAST_EXPECT(deltas.debtTotalDelta == expected.debtTotalDelta); + } + } + + void + testLoanVaultExposureDispatcher() + { + using namespace jtx; + + { + testcase("loanVaultExposure dispatcher: amendment disabled picks Accrual"); + Env env{*this}; + env.disableFeature(featureLendingProtocolV1_1); + auto sle = makeLoanSle(Number{1'000}, Number{800}, Number{50}); + BEAST_EXPECT( + loanVaultExposure(env.current()->rules(), sle) == + xrpl::Accrual::loanVaultExposure(sle)); + } + + { + testcase("loanVaultExposure dispatcher: amendment enabled picks CashBasis"); + Env const env{*this}; + auto sle = makeLoanSle(Number{1'000}, Number{800}, Number{50}); + BEAST_EXPECT( + loanVaultExposure(env.current()->rules(), sle) == + xrpl::CashBasis::loanVaultExposure(sle)); + } + } + + void + testLoanPaymentDeltasDispatcher() + { + using namespace jtx; + + LoanPaymentParts const parts{ + .principalPaid = Number{100}, + .interestPaid = Number{20}, + .valueChange = Number{5}, + .feePaid = Number{3}}; + + { + testcase("loanPaymentDeltas dispatcher: amendment disabled picks Accrual"); + Env env{*this}; + env.disableFeature(featureLendingProtocolV1_1); + auto const deltas = loanPaymentDeltas(env.current()->rules(), parts); + auto const expected = xrpl::Accrual::loanPaymentDeltas(parts); + BEAST_EXPECT(deltas.assetsTotalDelta == expected.assetsTotalDelta); + BEAST_EXPECT(deltas.debtTotalDelta == expected.debtTotalDelta); + } + + { + testcase("loanPaymentDeltas dispatcher: amendment enabled picks CashBasis"); + Env const env{*this}; + auto const deltas = loanPaymentDeltas(env.current()->rules(), parts); + auto const expected = xrpl::CashBasis::loanPaymentDeltas(parts); + BEAST_EXPECT(deltas.assetsTotalDelta == expected.assetsTotalDelta); + BEAST_EXPECT(deltas.debtTotalDelta == expected.debtTotalDelta); + } + } + public: void testCanApplyToBrokerCover() @@ -1573,6 +1782,15 @@ class LendingHelpers_test : public beast::unit_test::Suite testComputeOverpaymentComponents(); testComputeInterestAndFeeParts(); testCanApplyToBrokerCover(); + + testAccrualLoanOriginationDeltas(); + testCashBasisLoanOriginationDeltas(); + testAccrualLoanVaultExposure(); + testCashBasisLoanVaultExposure(); + testLoanPaymentDeltas(); + testLoanOriginationDeltasDispatcher(); + testLoanVaultExposureDispatcher(); + testLoanPaymentDeltasDispatcher(); } }; diff --git a/src/test/app/Loan_test.cpp b/src/test/app/Loan_test.cpp index 371fcae54ff..79390dce358 100644 --- a/src/test/app/Loan_test.cpp +++ b/src/test/app/Loan_test.cpp @@ -92,8 +92,13 @@ class Loan_test : public beast::unit_test::Suite protected: // Ensure that all the features needed for Lending Protocol are included, // even if they are set to unsupported. - - FeatureBitset const all_{jtx::testableAmendments()}; + // + // featureLendingProtocolV1_1 is excluded from the default set: it changes + // Vault/LoanBroker accounting (AssetsTotal/DebtTotal/LossUnrealized), and + // most of this file's tests assert whole-life-specific expected values + // for those fields. Tests that specifically exercise the amendment opt + // it back in explicitly (e.g. `all_ | featureLendingProtocolV1_1`). + FeatureBitset const all_{jtx::testableAmendments() - featureLendingProtocolV1_1}; std::string const iouCurrency_{"IOU"}; void @@ -363,7 +368,9 @@ class Loan_test : public beast::unit_test::Suite { TenthBips16 const managementFeeRate{brokerSle->at(sfManagementFeeRate)}; auto const brokerDebt = brokerSle->at(sfDebtTotal); - auto const expectedDebt = principalOutstanding + interestOwed; + auto const expectedDebt = env.current()->rules().enabled(featureLendingProtocolV1_1) + ? principalOutstanding + : principalOutstanding + interestOwed; env.test.BEAST_EXPECT(brokerDebt == expectedDebt); env.test.BEAST_EXPECT( env.balance(pseudoAccount, broker.asset).number() == @@ -468,7 +475,9 @@ class Loan_test : public beast::unit_test::Suite { env.test.BEAST_EXPECT( vaultSle->at(sfLossUnrealized) == - totalValue - managementFeeOutstanding); + (env.current()->rules().enabled(featureLendingProtocolV1_1) + ? principalOutstanding + : totalValue - managementFeeOutstanding)); } else { @@ -635,8 +644,10 @@ class Loan_test : public beast::unit_test::Suite // log << vaultSle->getJson() << std::endl; auto const assetsUnavailable = vaultSle->at(sfAssetsTotal) - vaultSle->at(sfAssetsAvailable); - auto const unrealizedLoss = vaultSle->at(sfLossUnrealized) + state.totalValue - - state.managementFeeOutstanding; + auto const unrealizedLoss = vaultSle->at(sfLossUnrealized) + + (env.current()->rules().enabled(featureLendingProtocolV1_1) + ? state.principalOutstanding + : state.totalValue - state.managementFeeOutstanding); if (!BEAST_EXPECT(unrealizedLoss <= assetsUnavailable)) { @@ -8547,6 +8558,772 @@ class Loan_test : public beast::unit_test::Suite }); } + // LendingProtocolV1_1 ("cash-basis" accounting) dedicated coverage. + // + // Existing tests never enable featureLendingProtocolV1_1 (see `all_` + // above), so these are the only tests in this file that exercise the + // amendment. They are called once, directly, from + // runAmendmentIndependent() -- not looped through + // runAmendmentSensitive()/amendmentCombinations(), since doing so would + // require re-deriving whole-life-specific expected values for ~15 + // unrelated regression tests. + + // 1. LoanSet origination: Vault.AssetsTotal/LoanBroker.DebtTotal deltas, + // and the AssetsMaximum/DebtMaximum guards (which always check against + // principal + interestDue, regardless of the amendment). + void + testCashBasisLoanSetOrigination() + { + testcase("cash-basis: LoanSet origination"); + + using namespace jtx; + using namespace loan; + + PrettyAsset const xrpAsset{xrpIssue(), 1'000'000}; + BrokerParameters const brokerParams{ + .vaultDeposit = 100'000, + .debtMax = 0, + .coverRateMin = TenthBips32{0}, + .coverDeposit = 0, + .managementFeeRate = TenthBips16{0}, + .coverRateLiquidation = TenthBips32{0}}; + + Number const principalRequest{10'000}; + TenthBips32 const interestRate{percentageToTenthBips(10)}; + std::uint32_t const paymentTotal = 2; + std::uint32_t const paymentInterval = 86400; + + // Creates a broker/vault, submits a single LoanSet with a nonzero + // interest rate, and returns the observed Vault.AssetsTotal / + // LoanBroker.DebtTotal deltas plus the loan's own computed + // interestDue and principalOutstanding. + auto runOrigination = [&](FeatureBitset features) { + Env env(*this, features); + + Account const lender{"lender"}; + Account const borrower{"borrower"}; + env.fund(XRP(1'000'000), lender, borrower); + env.close(); + + BrokerInfo const broker{createVaultAndBroker(env, xrpAsset, lender, brokerParams)}; + + auto const vaultBefore = env.le(broker.vaultKeylet()); + auto const brokerBefore = env.le(broker.brokerKeylet()); + BEAST_EXPECT(vaultBefore && brokerBefore); + Number const assetsTotalBefore = vaultBefore->at(sfAssetsTotal); + Number const debtTotalBefore = brokerBefore->at(sfDebtTotal); + + auto const loanSequence = brokerBefore->at(sfLoanSequence); + auto const loanKeylet = keylet::loan(broker.brokerID, loanSequence); + + env(set(borrower, broker.brokerID, xrpAsset(principalRequest).value()), + kCounterparty(lender), + kInterestRate(interestRate), + kPaymentTotal(paymentTotal), + kPaymentInterval(paymentInterval), + Sig(sfCounterpartySignature, lender), + Fee(env.current()->fees().base * 2), + Ter(tesSUCCESS)); + env.close(); + + auto const loanSle = env.le(loanKeylet); + BEAST_EXPECT(loanSle); + Number const principalOutstanding = loanSle->at(sfPrincipalOutstanding); + Number const totalValueOutstanding = loanSle->at(sfTotalValueOutstanding); + Number const interestDue = totalValueOutstanding - principalOutstanding; + BEAST_EXPECT(interestDue > beast::kZero); + BEAST_EXPECT(principalOutstanding == xrpAsset(principalRequest).value()); + + auto const vaultAfter = env.le(broker.vaultKeylet()); + auto const brokerAfter = env.le(broker.brokerKeylet()); + BEAST_EXPECT(vaultAfter && brokerAfter); + Number const assetsTotalDelta = + Number(vaultAfter->at(sfAssetsTotal)) - assetsTotalBefore; + Number const debtTotalDelta = Number(brokerAfter->at(sfDebtTotal)) - debtTotalBefore; + + return std::make_tuple( + assetsTotalDelta, debtTotalDelta, interestDue, principalOutstanding); + }; + + Number interestDueCash{}; + Number principalOutstandingCash{}; + { + auto const [assetsTotalDelta, debtTotalDelta, interestDue, principalOutstanding] = + runOrigination(all_ | featureLendingProtocolV1_1); + interestDueCash = interestDue; + principalOutstandingCash = principalOutstanding; + + BEAST_EXPECTS( + assetsTotalDelta == beast::kZero, + "cash-basis origination must not change AssetsTotal; delta=" + + to_string(assetsTotalDelta)); + BEAST_EXPECTS( + debtTotalDelta == principalOutstanding, + "cash-basis origination must add principal-only to DebtTotal; delta=" + + to_string(debtTotalDelta) + " principal=" + to_string(principalOutstanding)); + } + + { + auto const [assetsTotalDelta, debtTotalDelta, interestDue, principalOutstanding] = + runOrigination(all_); + + BEAST_EXPECTS( + assetsTotalDelta == interestDue, + "whole-life origination must add interestDue to AssetsTotal; delta=" + + to_string(assetsTotalDelta) + " interestDue=" + to_string(interestDue)); + BEAST_EXPECTS( + debtTotalDelta == principalOutstanding + interestDue, + "whole-life origination must add principal+interest to DebtTotal; delta=" + + to_string(debtTotalDelta)); + } + + // AssetsMaximum / DebtMaximum guards: unconditionally checked against + // principal + interestDue, regardless of the amendment. Verify that + // the accept/reject boundary is bit-for-bit identical in both cases. + auto runVaultGuard = [&](FeatureBitset features, Number const& slack, TER expected) { + Env env(*this, features); + + Account const lender{"lender"}; + Account const borrower{"borrower"}; + env.fund(XRP(1'000'000), lender, borrower); + env.close(); + + BrokerInfo const broker{createVaultAndBroker(env, xrpAsset, lender, brokerParams)}; + + auto const vaultSle = env.le(broker.vaultKeylet()); + BEAST_EXPECT(vaultSle); + Number const assetsTotalBefore = vaultSle->at(sfAssetsTotal); + + Vault const vault{env}; + auto tx = vault.set({.owner = lender, .id = broker.vaultID}); + tx[sfAssetsMaximum] = assetsTotalBefore + slack; + env(tx); + env.close(); + + env(set(borrower, broker.brokerID, xrpAsset(principalRequest).value()), + kCounterparty(lender), + kInterestRate(interestRate), + kPaymentTotal(paymentTotal), + kPaymentInterval(paymentInterval), + Sig(sfCounterpartySignature, lender), + Fee(env.current()->fees().base * 2), + Ter(expected)); + env.close(); + }; + + auto runBrokerGuard = [&](FeatureBitset features, Number const& debtMaximum, TER expected) { + Env env(*this, features); + + Account const lender{"lender"}; + Account const borrower{"borrower"}; + env.fund(XRP(1'000'000), lender, borrower); + env.close(); + + BrokerInfo const broker{createVaultAndBroker(env, xrpAsset, lender, brokerParams)}; + + env(loanBroker::set(lender, broker.vaultID), + loanBroker::kLoanBrokerId(broker.brokerID), + loanBroker::kDebtMaximum(debtMaximum), + Fee(env.current()->fees().base * 2)); + env.close(); + + env(set(borrower, broker.brokerID, xrpAsset(principalRequest).value()), + kCounterparty(lender), + kInterestRate(interestRate), + kPaymentTotal(paymentTotal), + kPaymentInterval(paymentInterval), + Sig(sfCounterpartySignature, lender), + Fee(env.current()->fees().base * 2), + Ter(expected)); + env.close(); + }; + + Number const oneDrop = xrpAsset(1).value(); + for (auto const features : {all_ | featureLendingProtocolV1_1, all_}) + { + testcase( + std::string("cash-basis: LoanSet AssetsMaximum guard unaffected by amendment (") + + (features[featureLendingProtocolV1_1] ? "enabled)" : "disabled)")); + // Guard rejects when there's not quite enough headroom for the + // interest, even though cash-basis never touches AssetsTotal for + // interest. + runVaultGuard(features, interestDueCash - oneDrop, tecLIMIT_EXCEEDED); + // Guard accepts at the exact boundary. + runVaultGuard(features, interestDueCash, tesSUCCESS); + + testcase( + std::string("cash-basis: LoanSet DebtMaximum guard unaffected by amendment (") + + (features[featureLendingProtocolV1_1] ? "enabled)" : "disabled)")); + Number const newDebtTotal = principalOutstandingCash + interestDueCash; + runBrokerGuard(features, newDebtTotal - oneDrop, tecLIMIT_EXCEEDED); + runBrokerGuard(features, newDebtTotal, tesSUCCESS); + } + } + + // 2. LoanPay: regular, late, overpayment, and full-payment types. + // Assert Vault.AssetsTotal/LoanBroker.DebtTotal deltas match + // interestPaid/principalPaid under cash-basis, and cross-check the + // amendment-disabled run's deltas against the documented whole-life + // formula (AssetsTotal += valueChange; DebtTotal mirrors the loan's own + // TotalValueOutstanding delta exactly, since whole-life debt recognition + // tracks total loan value). + void + testCashBasisLoanPay() + { + using namespace jtx; + using namespace loan; + using namespace std::chrono_literals; + using tp = NetClock::time_point; + + PrettyAsset const xrpAsset{xrpIssue(), 1'000'000}; + BrokerParameters const brokerParams{ + .vaultDeposit = 1'000'000, + .debtMax = 0, + .coverRateMin = TenthBips32{0}, + .coverDeposit = 0, + .managementFeeRate = TenthBips16{0}, + .coverRateLiquidation = TenthBips32{0}}; + + Number const principalRequest{12'000}; + TenthBips32 const interestRate{percentageToTenthBips(12)}; + std::uint32_t const paymentTotal = 4; + std::uint32_t const paymentInterval = 600; + std::uint32_t const gracePeriod = 300; + + struct PaymentDeltas + { + Number principalPaid; + Number assetsTotalDelta; + Number debtTotalDelta; + Number totalValueDelta; + }; + + // Sets up a fresh broker + loan, advances time, submits a single + // payment of the given type/amount, and returns the observed deltas. + auto runPayment = [&](FeatureBitset features, + std::uint32_t loanSetFlags, + std::uint32_t payFlags, + std::function const& advanceTime, + std::function const& paymentAmount) { + Env env(*this, features); + + Account const lender{"lender"}; + Account const borrower{"borrower"}; + env.fund(XRP(10'000'000), lender, borrower); + env.close(); + + BrokerInfo const broker{createVaultAndBroker(env, xrpAsset, lender, brokerParams)}; + + LoanParameters const loanParams{ + .account = borrower, + .counter = lender, + .principalRequest = principalRequest, + .interest = interestRate, + .payTotal = paymentTotal, + .payInterval = paymentInterval, + .gracePd = gracePeriod, + .flags = loanSetFlags, + }; + + auto const brokerBeforeLoan = env.le(broker.brokerKeylet()); + BEAST_EXPECT(brokerBeforeLoan); + auto const loanSequence = brokerBeforeLoan->at(sfLoanSequence); + auto const loanKeylet = keylet::loan(broker.brokerID, loanSequence); + + env(loanParams(env, broker)); + env.close(); + + LoanState const state = getCurrentState(env, broker, loanKeylet); + + advanceTime(env, state.startDate); + + auto const vaultBefore = env.le(broker.vaultKeylet()); + auto const brokerBefore = env.le(broker.brokerKeylet()); + auto const loanBefore = env.le(loanKeylet); + BEAST_EXPECT(vaultBefore && brokerBefore && loanBefore); + + Number const principalBefore = loanBefore->at(sfPrincipalOutstanding); + Number const totalValueBefore = loanBefore->at(sfTotalValueOutstanding); + Number const assetsTotalBefore = vaultBefore->at(sfAssetsTotal); + Number const debtTotalBefore = brokerBefore->at(sfDebtTotal); + + STAmount const amount = paymentAmount(state); + env(pay(borrower, loanKeylet.key, amount, payFlags), Ter(tesSUCCESS)); + env.close(); + + auto const vaultAfter = env.le(broker.vaultKeylet()); + auto const brokerAfter = env.le(broker.brokerKeylet()); + auto const loanAfter = env.le(loanKeylet); + BEAST_EXPECT(vaultAfter && brokerAfter && loanAfter); + + Number const principalAfter = loanAfter->at(sfPrincipalOutstanding); + Number const totalValueAfter = loanAfter->at(sfTotalValueOutstanding); + Number const assetsTotalAfter = vaultAfter->at(sfAssetsTotal); + Number const debtTotalAfter = brokerAfter->at(sfDebtTotal); + + return PaymentDeltas{ + .principalPaid = principalBefore - principalAfter, + .assetsTotalDelta = assetsTotalAfter - assetsTotalBefore, + .debtTotalDelta = debtTotalAfter - debtTotalBefore, + .totalValueDelta = totalValueAfter - totalValueBefore}; + }; + + // Compares the disabled (whole-life) and enabled (cash-basis) runs + // of the same payment scenario, and asserts the documented + // relationships between them. + auto checkScenario = [&](std::string const& label, + PaymentDeltas const& off, + PaymentDeltas const& on) { + testcase("cash-basis: LoanPay " + label); + + // The loan's own PrincipalOutstanding field is untouched by + // the amendment. + BEAST_EXPECTS( + off.principalPaid == on.principalPaid, + "principalPaid must be amendment-independent; off=" + to_string(off.principalPaid) + + " on=" + to_string(on.principalPaid)); + + // Whole-life structural invariant: DebtTotal (which + // recognizes a loan's full remaining value as debt) must + // change exactly as the loan's own TotalValueOutstanding + // does. + BEAST_EXPECTS( + off.debtTotalDelta == off.totalValueDelta, + "whole-life DebtTotal delta must mirror TotalValueOutstanding delta; " + "debtTotalDelta=" + + to_string(off.debtTotalDelta) + + " totalValueDelta=" + to_string(off.totalValueDelta)); + + // Derive interestPaid from the whole-life run's independent + // ledger deltas: + // assetsTotalDelta_off == valueChange + // debtTotalDelta_off == valueChange - (principalPaid + interestPaid) + // => interestPaid == assetsTotalDelta_off - debtTotalDelta_off - principalPaid + Number const interestPaid = + off.assetsTotalDelta - off.debtTotalDelta - off.principalPaid; + BEAST_EXPECTS( + interestPaid >= beast::kZero, + "derived interestPaid must be non-negative: " + to_string(interestPaid)); + + BEAST_EXPECTS( + on.assetsTotalDelta == interestPaid, + "cash-basis AssetsTotal delta must equal interestPaid; delta=" + + to_string(on.assetsTotalDelta) + " interestPaid=" + to_string(interestPaid)); + BEAST_EXPECTS( + on.debtTotalDelta == -on.principalPaid, + "cash-basis DebtTotal delta must equal -principalPaid; delta=" + + to_string(on.debtTotalDelta) + " principalPaid=" + to_string(on.principalPaid)); + }; + + // ---- Regular, on-time payment ---- + { + auto const noAdvance = [](Env& env, tp const&) { env.close(); }; + auto const regularAmount = [&](LoanState const& state) { + return STAmount{ + xrpAsset, + roundPeriodicPayment(xrpAsset, state.periodicPayment, state.loanScale) * + Number{3, -1} * 5}; // 1.5x, so only a single period is paid + }; + + auto const off = runPayment(all_, 0, 0, noAdvance, regularAmount); + auto const on = + runPayment(all_ | featureLendingProtocolV1_1, 0, 0, noAdvance, regularAmount); + + // Regular, on-time payments never change the loan's value beyond + // normal amortization (production asserts valueChange == 0), so + // AssetsTotal must be unaffected in the whole-life run. + BEAST_EXPECTS( + off.assetsTotalDelta == beast::kZero, + "regular on-time payment must not change AssetsTotal under whole-life; delta=" + + to_string(off.assetsTotalDelta)); + + checkScenario("regular payment", off, on); + } + + // ---- Late payment ---- + { + auto const advancePastDue = [&](Env& env, tp const& startDate) { + env.close(startDate + std::chrono::seconds(paymentInterval + 1)); + }; + auto const lateAmount = [&](LoanState const& state) { + return STAmount{ + xrpAsset, + roundPeriodicPayment(xrpAsset, state.periodicPayment, state.loanScale) * + Number{3}}; // generous; excess is not withdrawn + }; + + auto const off = runPayment(all_, 0, tfLoanLatePayment, advancePastDue, lateAmount); + auto const on = runPayment( + all_ | featureLendingProtocolV1_1, + 0, + tfLoanLatePayment, + advancePastDue, + lateAmount); + + checkScenario("late payment", off, on); + } + + // ---- Overpayment ---- + { + auto const noAdvance = [](Env& env, tp const&) { env.close(); }; + auto const overpayAmount = [&](LoanState const& state) { + // One regular period, plus a generous extra principal + // paydown. + return STAmount{ + xrpAsset, + roundPeriodicPayment(xrpAsset, state.periodicPayment, state.loanScale) + + xrpAsset(2'000).value()}; + }; + + auto const off = + runPayment(all_, tfLoanOverpayment, tfLoanOverpayment, noAdvance, overpayAmount); + auto const on = runPayment( + all_ | featureLendingProtocolV1_1, + tfLoanOverpayment, + tfLoanOverpayment, + noAdvance, + overpayAmount); + + checkScenario("overpayment", off, on); + } + + // ---- Full payment ---- + { + auto const noAdvance = [](Env& env, tp const&) { env.close(); }; + auto const fullAmount = [&](LoanState const&) { + // Generously large: full payment only ever consumes exactly + // what's due (principal + accrued interest; close fee/ + // prepayment penalty are 0 here), excess is not withdrawn. + return STAmount{xrpAsset, xrpAsset(principalRequest).value() * Number{2}}; + }; + + auto const off = runPayment(all_, 0, tfLoanFullPayment, noAdvance, fullAmount); + auto const on = runPayment( + all_ | featureLendingProtocolV1_1, 0, tfLoanFullPayment, noAdvance, fullAmount); + + checkScenario("full payment", off, on); + } + } + + // 3. LoanManage: impair, unimpair, and default. + void + testCashBasisLoanManage() + { + using namespace jtx; + using namespace loan; + using namespace std::chrono_literals; + + PrettyAsset const xrpAsset{xrpIssue(), 1'000'000}; + BrokerParameters const brokerParams{ + .vaultDeposit = 1'000'000, + .debtMax = 0, + .coverRateMin = TenthBips32{percentageToTenthBips(10)}, + .coverDeposit = 5'000, + .managementFeeRate = TenthBips16{0}, + .coverRateLiquidation = TenthBips32{percentageToTenthBips(25)}}; + + Number const principalRequest{10'000}; + TenthBips32 const interestRate{percentageToTenthBips(12)}; + std::uint32_t const paymentTotal = 4; + std::uint32_t const paymentInterval = 600; + std::uint32_t const gracePeriod = 60; + + auto setupLoan = [&](Env& env) { + Account const lender{"lender"}; + Account const borrower{"borrower"}; + env.fund(XRP(10'000'000), lender, borrower); + env.close(); + + BrokerInfo const broker{createVaultAndBroker(env, xrpAsset, lender, brokerParams)}; + + LoanParameters const loanParams{ + .account = borrower, + .counter = lender, + .principalRequest = principalRequest, + .interest = interestRate, + .payTotal = paymentTotal, + .payInterval = paymentInterval, + .gracePd = gracePeriod, + }; + + auto const brokerBeforeLoan = env.le(broker.brokerKeylet()); + BEAST_EXPECT(brokerBeforeLoan); + auto const loanSequence = brokerBeforeLoan->at(sfLoanSequence); + auto const loanKeylet = keylet::loan(broker.brokerID, loanSequence); + + env(loanParams(env, broker)); + env.close(); + + return std::make_tuple(broker, loanKeylet, lender, borrower); + }; + + // ---- impair / unimpair ---- + auto runImpairUnimpair = [&](FeatureBitset features) { + Env env(*this, features); + auto const [broker, loanKeylet, lender, borrower] = setupLoan(env); + + auto const loanBefore = env.le(loanKeylet); + BEAST_EXPECT(loanBefore); + Number const principalOutstanding = loanBefore->at(sfPrincipalOutstanding); + Number const totalValueOutstanding = loanBefore->at(sfTotalValueOutstanding); + Number const managementFeeOutstanding = loanBefore->at(sfManagementFeeOutstanding); + + Number const expectedExposure = + env.current()->rules().enabled(featureLendingProtocolV1_1) + ? principalOutstanding + : totalValueOutstanding - managementFeeOutstanding; + + auto const vaultBeforeImpair = env.le(broker.vaultKeylet()); + BEAST_EXPECT(vaultBeforeImpair); + Number const lossBefore = vaultBeforeImpair->at(sfLossUnrealized); + + env(manage(lender, loanKeylet.key, tfLoanImpair), Ter(tesSUCCESS)); + env.close(); + + auto const vaultAfterImpair = env.le(broker.vaultKeylet()); + BEAST_EXPECT(vaultAfterImpair); + Number const impairDelta = Number(vaultAfterImpair->at(sfLossUnrealized)) - lossBefore; + + env(manage(lender, loanKeylet.key, tfLoanUnimpair), Ter(tesSUCCESS)); + env.close(); + + auto const vaultAfterUnimpair = env.le(broker.vaultKeylet()); + BEAST_EXPECT(vaultAfterUnimpair); + Number const netDelta = Number(vaultAfterUnimpair->at(sfLossUnrealized)) - lossBefore; + + return std::make_tuple(expectedExposure, impairDelta, netDelta); + }; + + for (auto const features : {all_ | featureLendingProtocolV1_1, all_}) + { + testcase( + std::string("cash-basis: LoanManage impair/unimpair (") + + (features[featureLendingProtocolV1_1] ? "enabled)" : "disabled)")); + auto const [expectedExposure, impairDelta, netDelta] = runImpairUnimpair(features); + + BEAST_EXPECTS( + impairDelta == expectedExposure, + "impair must add loanVaultExposure to LossUnrealized; delta=" + + to_string(impairDelta) + " expected=" + to_string(expectedExposure)); + BEAST_EXPECTS( + netDelta == beast::kZero, + "unimpair must be an exact reversal of impair; net=" + to_string(netDelta)); + } + + // ---- impair, then default ---- + auto runDefault = [&](FeatureBitset features) { + Env env(*this, features); + auto const [broker, loanKeylet, lender, borrower] = setupLoan(env); + + auto const loanBeforeImpair = env.le(loanKeylet); + BEAST_EXPECT(loanBeforeImpair); + Number const principalOutstanding = loanBeforeImpair->at(sfPrincipalOutstanding); + Number const totalValueOutstanding = loanBeforeImpair->at(sfTotalValueOutstanding); + Number const managementFeeOutstanding = + loanBeforeImpair->at(sfManagementFeeOutstanding); + + Number const expectedExposure = + env.current()->rules().enabled(featureLendingProtocolV1_1) + ? principalOutstanding + : totalValueOutstanding - managementFeeOutstanding; + + env(manage(lender, loanKeylet.key, tfLoanImpair), Ter(tesSUCCESS)); + env.close(); + + LoanState const state = getCurrentState(env, broker, loanKeylet); + env.close( + state.startDate + std::chrono::seconds(paymentInterval) + + std::chrono::seconds(gracePeriod) + 60s); + + auto const vaultBefore = env.le(broker.vaultKeylet()); + auto const brokerBefore = env.le(broker.brokerKeylet()); + BEAST_EXPECT(vaultBefore && brokerBefore); + Number const assetsTotalBefore = vaultBefore->at(sfAssetsTotal); + Number const debtTotalBefore = brokerBefore->at(sfDebtTotal); + Number const lossBefore = vaultBefore->at(sfLossUnrealized); + Number const coverAvailableBefore = brokerBefore->at(sfCoverAvailable); + + env(manage(lender, loanKeylet.key, tfLoanDefault), Ter(tesSUCCESS)); + env.close(); + + auto const vaultAfter = env.le(broker.vaultKeylet()); + auto const brokerAfter = env.le(broker.brokerKeylet()); + BEAST_EXPECT(vaultAfter && brokerAfter); + Number const assetsTotalDelta = + Number(vaultAfter->at(sfAssetsTotal)) - assetsTotalBefore; + Number const debtTotalDelta = Number(brokerAfter->at(sfDebtTotal)) - debtTotalBefore; + Number const lossDelta = Number(vaultAfter->at(sfLossUnrealized)) - lossBefore; + Number const coverAvailableDelta = + Number(brokerAfter->at(sfCoverAvailable)) - coverAvailableBefore; + + Number const defaultCovered = -coverAvailableDelta; + Number const vaultDefaultAmount = expectedExposure - defaultCovered; + + return std::make_tuple( + expectedExposure, assetsTotalDelta, debtTotalDelta, lossDelta, vaultDefaultAmount); + }; + + for (auto const features : {all_ | featureLendingProtocolV1_1, all_}) + { + testcase( + std::string("cash-basis: LoanManage default (") + + (features[featureLendingProtocolV1_1] ? "enabled)" : "disabled)")); + auto const + [expectedExposure, + assetsTotalDelta, + debtTotalDelta, + lossDelta, + vaultDefaultAmount] = runDefault(features); + + BEAST_EXPECTS( + debtTotalDelta == -expectedExposure, + "default must reduce DebtTotal by the unified default amount; delta=" + + to_string(debtTotalDelta) + " expected=" + to_string(expectedExposure)); + BEAST_EXPECTS( + lossDelta == -expectedExposure, + "default must reverse the earlier impair's LossUnrealized exactly; delta=" + + to_string(lossDelta) + " expected=" + to_string(expectedExposure)); + BEAST_EXPECTS( + assetsTotalDelta == -vaultDefaultAmount, + "default must reduce AssetsTotal by (defaultAmount - defaultCovered); delta=" + + to_string(assetsTotalDelta) + " expected=" + to_string(-vaultDefaultAmount)); + } + } + + // 4. End-to-end trajectory: LoanSet -> 2 LoanPays -> LoanManage(default), + // entirely under the amendment, with independently hand-computed + // expected AssetsTotal/DebtTotal/LossUnrealized/CoverAvailable values at + // each step. 0% interest keeps the arithmetic exact and tractable; the + // divergence from whole-life accounting is already covered directly by + // testCashBasisLoanSetOrigination/LoanPay/LoanManage above, so this test + // focuses purely on an independent, from-scratch trajectory check. + void + testCashBasisEndToEndTrajectory() + { + testcase("cash-basis: end-to-end trajectory"); + + using namespace jtx; + using namespace loan; + using namespace std::chrono_literals; + + PrettyAsset const xrpAsset{xrpIssue(), 1'000'000}; + BrokerParameters const brokerParams{ + .vaultDeposit = 100'000, .managementFeeRate = TenthBips16{0}}; + + Env env(*this, all_ | featureLendingProtocolV1_1); + + Account const lender{"lender"}; + Account const borrower{"borrower"}; + env.fund(XRP(10'000'000), lender, borrower); + env.close(); + + BrokerInfo const broker{createVaultAndBroker(env, xrpAsset, lender, brokerParams)}; + + // Hand computation (all values in XRP, drops == 1e-6 XRP): + // Vault: AssetsTotal starts at 100'000 (the deposit). + // Broker: DebtTotal starts at 0, CoverAvailable starts at 1'000 + // (BrokerParameters::defaults().coverDeposit). + auto const vaultKeylet = broker.vaultKeylet(); + auto const brokerKeylet = broker.brokerKeylet(); + + // All the "human XRP unit" constants below (e.g. `100'000`) are + // converted to raw native (drops) values via xrpAsset(...), since + // that's how the ledger fields are actually denominated. + auto const checkVaultBroker = [&](Number const& assetsTotalUnits, + Number const& debtTotalUnits, + Number const& lossUnrealizedUnits, + Number const& coverAvailableUnits, + char const* step) { + Number const assetsTotal = xrpAsset(assetsTotalUnits).value(); + Number const debtTotal = xrpAsset(debtTotalUnits).value(); + Number const lossUnrealized = xrpAsset(lossUnrealizedUnits).value(); + Number const coverAvailable = xrpAsset(coverAvailableUnits).value(); + + auto const vaultSle = env.le(vaultKeylet); + auto const brokerSle = env.le(brokerKeylet); + BEAST_EXPECT(vaultSle && brokerSle); + BEAST_EXPECTS( + vaultSle->at(sfAssetsTotal) == assetsTotal, + std::string(step) + ": AssetsTotal expected " + to_string(assetsTotal) + " got " + + to_string(Number(vaultSle->at(sfAssetsTotal)))); + BEAST_EXPECTS( + brokerSle->at(sfDebtTotal) == debtTotal, + std::string(step) + ": DebtTotal expected " + to_string(debtTotal) + " got " + + to_string(Number(brokerSle->at(sfDebtTotal)))); + BEAST_EXPECTS( + vaultSle->at(sfLossUnrealized) == lossUnrealized, + std::string(step) + ": LossUnrealized expected " + to_string(lossUnrealized) + + " got " + to_string(Number(vaultSle->at(sfLossUnrealized)))); + BEAST_EXPECTS( + brokerSle->at(sfCoverAvailable) == coverAvailable, + std::string(step) + ": CoverAvailable expected " + to_string(coverAvailable) + + " got " + to_string(Number(brokerSle->at(sfCoverAvailable)))); + }; + + checkVaultBroker(100'000, 0, 0, 1'000, "before LoanSet"); + + // Loan: principal=1200, 0% interest, 12 payments of 100 each, no fees. + Number const principalRequest{1'200}; + std::uint32_t const paymentTotal = 12; + std::uint32_t const paymentInterval = 600; + std::uint32_t const gracePeriod = 60; + + auto const brokerBeforeLoan = env.le(brokerKeylet); + BEAST_EXPECT(brokerBeforeLoan); + auto const loanSequence = brokerBeforeLoan->at(sfLoanSequence); + auto const loanKeylet = keylet::loan(broker.brokerID, loanSequence); + + LoanParameters const loanParams{ + .account = borrower, + .counter = lender, + .principalRequest = principalRequest, + .interest = TenthBips32{0}, + .payTotal = paymentTotal, + .payInterval = paymentInterval, + .gracePd = gracePeriod, + }; + env(loanParams(env, broker)); + env.close(); + + // Origination (cash-basis): AssetsTotal += 0, DebtTotal += principal. + checkVaultBroker(100'000, 1'200, 0, 1'000, "after LoanSet"); + + LoanState const state = getCurrentState(env, broker, loanKeylet); + BEAST_EXPECT(state.periodicPayment == xrpAsset(100).value()); + + // Payment 1: principalPaid=100, interestPaid=0. + // AssetsTotal += 0; DebtTotal -= 100. + env(pay(borrower, loanKeylet.key, xrpAsset(100).value()), Ter(tesSUCCESS)); + env.close(); + checkVaultBroker(100'000, 1'100, 0, 1'000, "after payment 1"); + + // Payment 2: same as above. + env(pay(borrower, loanKeylet.key, xrpAsset(100).value()), Ter(tesSUCCESS)); + env.close(); + checkVaultBroker(100'000, 1'000, 0, 1'000, "after payment 2"); + + // Default (no impair): principalOutstanding remaining is 1'000. + // totalDefaultAmount (cash-basis) = PrincipalOutstanding = 1'000. + // minimumCover = DebtTotal(1'000) * coverRateMin(10%) = 100. + // covered = min(minimumCover * coverRateLiquidation(25%), totalDefaultAmount) + // = min(25, 1'000) = 25. + // defaultCovered = min(covered, CoverAvailable(1'000)) = 25. + // vaultDefaultAmount = 1'000 - 25 = 975. + // DebtTotal -= 1'000 -> 0. CoverAvailable -= 25 -> 975. + // AssetsTotal -= 975 -> 99'025. LossUnrealized unaffected (never impaired). + auto const loanBeforeDefault = env.le(loanKeylet); + BEAST_EXPECT(loanBeforeDefault); + BEAST_EXPECT( + Number(loanBeforeDefault->at(sfPrincipalOutstanding)) == xrpAsset(1'000).value()); + + env.close(state.startDate + std::chrono::seconds((3 * paymentInterval) + gracePeriod) + 1s); + + env(manage(lender, loanKeylet.key, tfLoanDefault), Ter(tesSUCCESS)); + env.close(); + + checkVaultBroker(99'025, 0, 0, 975, "after LoanManage(default)"); + } + void runAmendmentIndependent() { @@ -8570,6 +9347,11 @@ class Loan_test : public beast::unit_test::Suite testBugInterestDueDeltaCrash(); testFullLifecycleVaultPnLNearZeroRate(); testLoanSetNearZeroInterestRateSucceeds(); + + testCashBasisLoanSetOrigination(); + testCashBasisLoanPay(); + testCashBasisLoanManage(); + testCashBasisEndToEndTrajectory(); } // Tests run under each entry in amendmentCombinations(). From cc03abd79bf82718e806727dbb29bb0f147f720e Mon Sep 17 00:00:00 2001 From: Vito <5780819+Tapanito@users.noreply.github.com> Date: Tue, 21 Jul 2026 15:25:25 +0200 Subject: [PATCH 02/11] adds LEVersion field to Vault --- include/xrpl/ledger/helpers/LendingHelpers.h | 15 +- include/xrpl/ledger/helpers/VaultHelpers.h | 14 ++ include/xrpl/protocol/Protocol.h | 12 + .../xrpl/protocol/detail/ledger_entries.macro | 1 + include/xrpl/protocol/detail/sfields.macro | 1 + src/libxrpl/ledger/helpers/LendingHelpers.cpp | 39 +++- src/libxrpl/ledger/helpers/VaultHelpers.cpp | 19 ++ .../tx/transactors/lending/LoanManage.cpp | 6 +- .../tx/transactors/lending/LoanPay.cpp | 3 +- .../tx/transactors/lending/LoanSet.cpp | 2 +- .../tx/transactors/vault/VaultCreate.cpp | 3 + src/test/app/LendingHelpers_test.cpp | 86 +++++-- src/test/app/Loan_test.cpp | 218 +++++++++++++++++- src/test/app/Vault_test.cpp | 76 ++++++ 14 files changed, 450 insertions(+), 45 deletions(-) diff --git a/include/xrpl/ledger/helpers/LendingHelpers.h b/include/xrpl/ledger/helpers/LendingHelpers.h index 9008c528a58..6e1299687bb 100644 --- a/include/xrpl/ledger/helpers/LendingHelpers.h +++ b/include/xrpl/ledger/helpers/LendingHelpers.h @@ -304,7 +304,7 @@ loanOriginationDeltas(Number const& principalRequested, Number const& interestDu // LoanManage impair/unimpair/default: the vault's exposure to this loan Number -loanVaultExposure(SLE::ref loanSle); +loanVaultExposure(SLE::const_ref loanSle); // LoanPay: what's added to Vault.AssetsTotal and subtracted from LoanBroker.DebtTotal for a payment AccountingDeltas @@ -317,10 +317,10 @@ loanPaymentDeltas(LoanPaymentParts const& parts); namespace CashBasis { AccountingDeltas -loanOriginationDeltas(Number const& principalRequested, Number const& interestDue); +loanOriginationDeltas(Number const& principalRequested); Number -loanVaultExposure(SLE::ref loanSle); +loanVaultExposure(SLE::const_ref loanSle); AccountingDeltas loanPaymentDeltas(LoanPaymentParts const& parts); @@ -328,18 +328,21 @@ loanPaymentDeltas(LoanPaymentParts const& parts); } // namespace CashBasis // Public dispatchers: pick CashBasis:: if featureLendingProtocolV1_1 is -// enabled, else Accrual::. These are the only entry points transactors call. +// enabled AND the Vault's LEVersion (VaultHelpers::getVaultVersion) is +// VaultVersion::CashBasis, else Accrual::. These are the only entry points +// transactors call. AccountingDeltas loanOriginationDeltas( Rules const& rules, + SLE::const_ref vaultSle, Number const& principalRequested, Number const& interestDue); Number -loanVaultExposure(Rules const& rules, SLE::ref loanSle); +loanVaultExposure(Rules const& rules, SLE::const_ref vaultSle, SLE::const_ref loanSle); AccountingDeltas -loanPaymentDeltas(Rules const& rules, LoanPaymentParts const& parts); +loanPaymentDeltas(Rules const& rules, SLE::const_ref vaultSle, LoanPaymentParts const& parts); namespace detail { // These classes and functions should only be accessed by LendingHelper diff --git a/include/xrpl/ledger/helpers/VaultHelpers.h b/include/xrpl/ledger/helpers/VaultHelpers.h index 1bd16633148..e0101bd742b 100644 --- a/include/xrpl/ledger/helpers/VaultHelpers.h +++ b/include/xrpl/ledger/helpers/VaultHelpers.h @@ -2,6 +2,7 @@ #include #include +#include #include #include @@ -107,4 +108,17 @@ sharesToAssetsWithdraw( [[nodiscard]] bool isSoleShareholder(ReadView const& view, AccountID const& account, SLE::const_ref issuance); +/** + * Resolves a Vault's LEVersion, the single point every accounting touch + * point should call to determine which recognition model (accrual vs. + * cash-basis) a Vault uses. Vaults created before featureLendingProtocolV1_1 + * activated never have sfLEVersion set, which resolves here to 0 (legacy). + * + * @param vault The vault SLE. + * + * @return The Vault's LEVersion, or 0 if the field is absent. + */ +[[nodiscard]] VaultVersion +getVaultVersion(SLE::const_ref vault); + } // namespace xrpl diff --git a/include/xrpl/protocol/Protocol.h b/include/xrpl/protocol/Protocol.h index e83e1c97b62..f387ea56de8 100644 --- a/include/xrpl/protocol/Protocol.h +++ b/include/xrpl/protocol/Protocol.h @@ -316,6 +316,18 @@ constexpr std::uint8_t kVaultDefaultIouScale = 6; */ constexpr std::uint8_t kVaultMaximumIouScale = 18; +/** + * Vault ledger-entry schema versions. Assigned to newly created + * Vaults once featureLendingProtocolV1_1 is enabled. Vaults created before + * activation are left without LEVersion (implicit legacy version 0, + * accrual-basis accounting). + */ +enum class VaultVersion : uint8_t { + Legacy = 0, + Invalid, + CashBasis, +}; + /** * Maximum recursion depth for vault shares being put as an asset inside * another vault; counted from 0 diff --git a/include/xrpl/protocol/detail/ledger_entries.macro b/include/xrpl/protocol/detail/ledger_entries.macro index 90810e06d2d..b6408581a90 100644 --- a/include/xrpl/protocol/detail/ledger_entries.macro +++ b/include/xrpl/protocol/detail/ledger_entries.macro @@ -505,6 +505,7 @@ LEDGER_ENTRY(ltVAULT, 0x0084, Vault, vault, ({ {sfShareMPTID, SoeRequired}, {sfWithdrawalPolicy, SoeRequired}, {sfScale, SoeDefault}, + {sfLEVersion, SoeDefault}, // no SharesTotal ever (use MPTIssuance.sfOutstandingAmount) // no PermissionedDomainID ever (use MPTIssuance.sfDomainID) })) diff --git a/include/xrpl/protocol/detail/sfields.macro b/include/xrpl/protocol/detail/sfields.macro index 4ef76c8b759..16defe3ba3f 100644 --- a/include/xrpl/protocol/detail/sfields.macro +++ b/include/xrpl/protocol/detail/sfields.macro @@ -18,6 +18,7 @@ TYPED_SFIELD(sfMethod, UINT8, 2) TYPED_SFIELD(sfTransactionResult, UINT8, 3) TYPED_SFIELD(sfScale, UINT8, 4) TYPED_SFIELD(sfAssetScale, UINT8, 5) +TYPED_SFIELD(sfLEVersion, UINT8, 6) // 8-bit integers (uncommon) TYPED_SFIELD(sfTickSize, UINT8, 16) diff --git a/src/libxrpl/ledger/helpers/LendingHelpers.cpp b/src/libxrpl/ledger/helpers/LendingHelpers.cpp index 1bc8e3bfe4c..988c9cbcc1d 100644 --- a/src/libxrpl/ledger/helpers/LendingHelpers.cpp +++ b/src/libxrpl/ledger/helpers/LendingHelpers.cpp @@ -9,6 +9,7 @@ #include #include #include +#include #include #include #include @@ -139,7 +140,7 @@ loanOriginationDeltas(Number const& principalRequested, Number const& interestDu } Number -loanVaultExposure(SLE::ref loanSle) +loanVaultExposure(SLE::const_ref loanSle) { return loanSle->at(sfTotalValueOutstanding) - loanSle->at(sfManagementFeeOutstanding); } @@ -157,13 +158,13 @@ loanPaymentDeltas(LoanPaymentParts const& parts) namespace CashBasis { AccountingDeltas -loanOriginationDeltas(Number const& principalRequested, Number const&) +loanOriginationDeltas(Number const& principalRequested) { return {.assetsTotalDelta = kNumZero, .debtTotalDelta = principalRequested}; } Number -loanVaultExposure(SLE::ref loanSle) +loanVaultExposure(SLE::const_ref loanSle) { return loanSle->at(sfPrincipalOutstanding); } @@ -176,29 +177,45 @@ loanPaymentDeltas(LoanPaymentParts const& parts) } // namespace CashBasis +namespace { + +// Cash-basis accounting applies only when featureLendingProtocolV1_1 is +// enabled AND the specific Vault was created under it (LEVersion == +// VaultVersion::CashBasis). Vaults created before activation keep accrual-basis +// accounting forever, even after the amendment later turns on. +bool +cashBasisEnabled(Rules const& rules, SLE::const_ref vaultSle) +{ + return rules.enabled(featureLendingProtocolV1_1) && + getVaultVersion(vaultSle) == VaultVersion::CashBasis; +} + +} // namespace + AccountingDeltas loanOriginationDeltas( Rules const& rules, + SLE::const_ref vaultSle, Number const& principalRequested, Number const& interestDue) { - return rules.enabled(featureLendingProtocolV1_1) - ? CashBasis::loanOriginationDeltas(principalRequested, interestDue) + return cashBasisEnabled(rules, vaultSle) + ? CashBasis::loanOriginationDeltas(principalRequested) : Accrual::loanOriginationDeltas(principalRequested, interestDue); } Number -loanVaultExposure(Rules const& rules, SLE::ref loanSle) +loanVaultExposure(Rules const& rules, SLE::const_ref vaultSle, SLE::const_ref loanSle) { - return rules.enabled(featureLendingProtocolV1_1) ? CashBasis::loanVaultExposure(loanSle) - : Accrual::loanVaultExposure(loanSle); + return cashBasisEnabled(rules, vaultSle) ? CashBasis::loanVaultExposure(loanSle) + : Accrual::loanVaultExposure(loanSle); } AccountingDeltas -loanPaymentDeltas(Rules const& rules, LoanPaymentParts const& parts) +loanPaymentDeltas(Rules const& rules, SLE::const_ref vaultSle, LoanPaymentParts const& parts) { - return rules.enabled(featureLendingProtocolV1_1) ? CashBasis::loanPaymentDeltas(parts) - : Accrual::loanPaymentDeltas(parts); + return cashBasisEnabled(rules, vaultSle) ? CashBasis::loanPaymentDeltas(parts) + : Accrual::loanPaymentDeltas(parts); } namespace detail { diff --git a/src/libxrpl/ledger/helpers/VaultHelpers.cpp b/src/libxrpl/ledger/helpers/VaultHelpers.cpp index b5b076d1cb6..5b2694250d3 100644 --- a/src/libxrpl/ledger/helpers/VaultHelpers.cpp +++ b/src/libxrpl/ledger/helpers/VaultHelpers.cpp @@ -6,6 +6,7 @@ #include #include #include // IWYU pragma: keep +#include #include #include #include @@ -137,4 +138,22 @@ isSoleShareholder(ReadView const& view, AccountID const& account, SLE::const_ref return sleToken->getFieldU64(sfMPTAmount) == outstanding; } +[[nodiscard]] VaultVersion +getVaultVersion(SLE::const_ref vault) +{ + XRPL_ASSERT(vault && vault->getType() == ltVAULT, "xrpl::getVaultVersion : valid Vault sle"); + if (!vault->isFieldPresent(sfLEVersion)) + return VaultVersion::Legacy; + + switch (vault->at(sfLEVersion)) + { + case 0: + return VaultVersion::Legacy; + case 2: + return VaultVersion::CashBasis; + default: + return VaultVersion::Invalid; + } +} + } // namespace xrpl diff --git a/src/libxrpl/tx/transactors/lending/LoanManage.cpp b/src/libxrpl/tx/transactors/lending/LoanManage.cpp index 20f3b79ae53..5c71843129a 100644 --- a/src/libxrpl/tx/transactors/lending/LoanManage.cpp +++ b/src/libxrpl/tx/transactors/lending/LoanManage.cpp @@ -141,7 +141,7 @@ LoanManage::defaultLoan( std::int32_t const loanScale = loanSle->at(sfLoanScale); auto brokerDebtTotalProxy = brokerSle->at(sfDebtTotal); - Number const totalDefaultAmount = loanVaultExposure(view.rules(), loanSle); + Number const totalDefaultAmount = loanVaultExposure(view.rules(), vaultSle, loanSle); // Apply the First-Loss Capital to the Default Amount TenthBips32 const coverRateMinimum{brokerSle->at(sfCoverRateMinimum)}; @@ -287,7 +287,7 @@ LoanManage::impairLoan( Asset const& vaultAsset, beast::Journal j) { - Number const lossUnrealized = loanVaultExposure(view.rules(), loanSle); + Number const lossUnrealized = loanVaultExposure(view.rules(), vaultSle, loanSle); // The vault may be at a different scale than the loan. Reduce rounding // errors during the accounting by rounding some of the values to that @@ -336,7 +336,7 @@ LoanManage::unimpairLoan( // Update the Vault object(clear "paper loss") auto vaultLossUnrealizedProxy = vaultSle->at(sfLossUnrealized); - Number const lossReversed = loanVaultExposure(view.rules(), loanSle); + Number const lossReversed = loanVaultExposure(view.rules(), vaultSle, loanSle); if (vaultLossUnrealizedProxy < lossReversed) { // LCOV_EXCL_START diff --git a/src/libxrpl/tx/transactors/lending/LoanPay.cpp b/src/libxrpl/tx/transactors/lending/LoanPay.cpp index da35ebee426..6566a7155da 100644 --- a/src/libxrpl/tx/transactors/lending/LoanPay.cpp +++ b/src/libxrpl/tx/transactors/lending/LoanPay.cpp @@ -420,7 +420,8 @@ LoanPay::doApply() // LCOV_EXCL_STOP } - auto const [assetsTotalDelta, debtTotalDelta] = loanPaymentDeltas(view.rules(), *paymentParts); + auto const [assetsTotalDelta, debtTotalDelta] = + loanPaymentDeltas(view.rules(), vaultSle, *paymentParts); JLOG(j_.debug()) << "Loan Pay: principal paid: " << paymentParts->principalPaid << ", interest paid: " << paymentParts->interestPaid diff --git a/src/libxrpl/tx/transactors/lending/LoanSet.cpp b/src/libxrpl/tx/transactors/lending/LoanSet.cpp index 34be342dd8a..52d3361db75 100644 --- a/src/libxrpl/tx/transactors/lending/LoanSet.cpp +++ b/src/libxrpl/tx/transactors/lending/LoanSet.cpp @@ -634,7 +634,7 @@ LoanSet::doApply() // Update the balances in the vault auto const [assetsTotalDelta, debtTotalDelta] = - loanOriginationDeltas(ctx_.view().rules(), principalRequested, state.interestDue); + loanOriginationDeltas(ctx_.view().rules(), vaultSle, principalRequested, state.interestDue); vaultAvailableProxy -= principalRequested; vaultTotalProxy += assetsTotalDelta; XRPL_ASSERT_PARTS( diff --git a/src/libxrpl/tx/transactors/vault/VaultCreate.cpp b/src/libxrpl/tx/transactors/vault/VaultCreate.cpp index e1f5873a897..a522f62788e 100644 --- a/src/libxrpl/tx/transactors/vault/VaultCreate.cpp +++ b/src/libxrpl/tx/transactors/vault/VaultCreate.cpp @@ -30,6 +30,7 @@ #include #include #include +#include namespace xrpl { @@ -241,6 +242,8 @@ VaultCreate::doApply() } if (scale != 0u) vault->at(sfScale) = scale; + if (view().rules().enabled(featureLendingProtocolV1_1)) + vault->at(sfLEVersion) = std::to_underlying(VaultVersion::CashBasis); view().insert(vault); // Explicitly create MPToken for the vault owner diff --git a/src/test/app/LendingHelpers_test.cpp b/src/test/app/LendingHelpers_test.cpp index af1c2294956..b27c976646c 100644 --- a/src/test/app/LendingHelpers_test.cpp +++ b/src/test/app/LendingHelpers_test.cpp @@ -9,6 +9,7 @@ #include #include #include +#include #include #include #include @@ -19,6 +20,7 @@ #include #include #include +#include #include namespace xrpl::test { @@ -1518,7 +1520,7 @@ class LendingHelpers_test : public beast::unit_test::Suite Number const principalRequested{1'000}; Number const interestDue{75}; - auto const deltas = loanOriginationDeltas(principalRequested, interestDue); + auto const deltas = loanOriginationDeltas(principalRequested); BEAST_EXPECTS( deltas.assetsTotalDelta == 0, "assetsTotalDelta mismatch: expected 0, got " + to_string(deltas.assetsTotalDelta)); @@ -1544,6 +1546,17 @@ class LendingHelpers_test : public beast::unit_test::Suite return sle; } + // Constructs a minimal ltVAULT SLE with just LEVersion set (or left + // absent), for exercising the dispatchers' per-Vault gating. + static std::shared_ptr + makeVaultSle(std::optional leVersion = std::nullopt) + { + auto sle = std::make_shared(ltVAULT, uint256{2u}); + if (leVersion) + sle->at(sfLEVersion) = std::to_underlying(*leVersion); + return sle; + } + void testAccrualLoanVaultExposure() { @@ -1599,12 +1612,15 @@ class LendingHelpers_test : public beast::unit_test::Suite Number const principalRequested{1'000}; Number const interestDue{75}; + auto const legacyVault = makeVaultSle(); + auto const cashBasisVault = makeVaultSle(VaultVersion::CashBasis); + { testcase("loanOriginationDeltas dispatcher: amendment disabled picks Accrual"); Env env{*this}; env.disableFeature(featureLendingProtocolV1_1); - auto const deltas = - loanOriginationDeltas(env.current()->rules(), principalRequested, interestDue); + auto const deltas = loanOriginationDeltas( + env.current()->rules(), cashBasisVault, principalRequested, interestDue); auto const expected = xrpl::Accrual::loanOriginationDeltas(principalRequested, interestDue); BEAST_EXPECT(deltas.assetsTotalDelta == expected.assetsTotalDelta); @@ -1612,12 +1628,26 @@ class LendingHelpers_test : public beast::unit_test::Suite } { - testcase("loanOriginationDeltas dispatcher: amendment enabled picks CashBasis"); + testcase( + "loanOriginationDeltas dispatcher: amendment enabled, legacy vault picks " + "Accrual"); Env const env{*this}; - auto const deltas = - loanOriginationDeltas(env.current()->rules(), principalRequested, interestDue); + auto const deltas = loanOriginationDeltas( + env.current()->rules(), legacyVault, principalRequested, interestDue); auto const expected = - xrpl::CashBasis::loanOriginationDeltas(principalRequested, interestDue); + xrpl::Accrual::loanOriginationDeltas(principalRequested, interestDue); + BEAST_EXPECT(deltas.assetsTotalDelta == expected.assetsTotalDelta); + BEAST_EXPECT(deltas.debtTotalDelta == expected.debtTotalDelta); + } + + { + testcase( + "loanOriginationDeltas dispatcher: amendment enabled, LEVersion == " + "VaultVersion::CashBasis picks CashBasis"); + Env const env{*this}; + auto const deltas = loanOriginationDeltas( + env.current()->rules(), cashBasisVault, principalRequested, interestDue); + auto const expected = xrpl::CashBasis::loanOriginationDeltas(principalRequested); BEAST_EXPECT(deltas.assetsTotalDelta == expected.assetsTotalDelta); BEAST_EXPECT(deltas.debtTotalDelta == expected.debtTotalDelta); } @@ -1628,22 +1658,37 @@ class LendingHelpers_test : public beast::unit_test::Suite { using namespace jtx; + auto const legacyVault = makeVaultSle(); + auto const cashBasisVault = makeVaultSle(VaultVersion::CashBasis); + { testcase("loanVaultExposure dispatcher: amendment disabled picks Accrual"); Env env{*this}; env.disableFeature(featureLendingProtocolV1_1); auto sle = makeLoanSle(Number{1'000}, Number{800}, Number{50}); BEAST_EXPECT( - loanVaultExposure(env.current()->rules(), sle) == + loanVaultExposure(env.current()->rules(), cashBasisVault, sle) == xrpl::Accrual::loanVaultExposure(sle)); } { - testcase("loanVaultExposure dispatcher: amendment enabled picks CashBasis"); + testcase("loanVaultExposure dispatcher: amendment enabled, legacy vault picks Accrual"); Env const env{*this}; auto sle = makeLoanSle(Number{1'000}, Number{800}, Number{50}); BEAST_EXPECT( - loanVaultExposure(env.current()->rules(), sle) == + loanVaultExposure(env.current()->rules(), legacyVault, sle) == + xrpl::Accrual::loanVaultExposure(sle)); + } + + { + testcase( + "loanVaultExposure dispatcher: amendment enabled, LEVersion == " + "VaultVersion::CashBasis " + "picks CashBasis"); + Env const env{*this}; + auto sle = makeLoanSle(Number{1'000}, Number{800}, Number{50}); + BEAST_EXPECT( + loanVaultExposure(env.current()->rules(), cashBasisVault, sle) == xrpl::CashBasis::loanVaultExposure(sle)); } } @@ -1659,20 +1704,35 @@ class LendingHelpers_test : public beast::unit_test::Suite .valueChange = Number{5}, .feePaid = Number{3}}; + auto const legacyVault = makeVaultSle(); + auto const cashBasisVault = makeVaultSle(VaultVersion::CashBasis); + { testcase("loanPaymentDeltas dispatcher: amendment disabled picks Accrual"); Env env{*this}; env.disableFeature(featureLendingProtocolV1_1); - auto const deltas = loanPaymentDeltas(env.current()->rules(), parts); + auto const deltas = loanPaymentDeltas(env.current()->rules(), cashBasisVault, parts); auto const expected = xrpl::Accrual::loanPaymentDeltas(parts); BEAST_EXPECT(deltas.assetsTotalDelta == expected.assetsTotalDelta); BEAST_EXPECT(deltas.debtTotalDelta == expected.debtTotalDelta); } { - testcase("loanPaymentDeltas dispatcher: amendment enabled picks CashBasis"); + testcase("loanPaymentDeltas dispatcher: amendment enabled, legacy vault picks Accrual"); + Env const env{*this}; + auto const deltas = loanPaymentDeltas(env.current()->rules(), legacyVault, parts); + auto const expected = xrpl::Accrual::loanPaymentDeltas(parts); + BEAST_EXPECT(deltas.assetsTotalDelta == expected.assetsTotalDelta); + BEAST_EXPECT(deltas.debtTotalDelta == expected.debtTotalDelta); + } + + { + testcase( + "loanPaymentDeltas dispatcher: amendment enabled, LEVersion == " + "VaultVersion::CashBasis " + "picks CashBasis"); Env const env{*this}; - auto const deltas = loanPaymentDeltas(env.current()->rules(), parts); + auto const deltas = loanPaymentDeltas(env.current()->rules(), cashBasisVault, parts); auto const expected = xrpl::CashBasis::loanPaymentDeltas(parts); BEAST_EXPECT(deltas.assetsTotalDelta == expected.assetsTotalDelta); BEAST_EXPECT(deltas.debtTotalDelta == expected.debtTotalDelta); diff --git a/src/test/app/Loan_test.cpp b/src/test/app/Loan_test.cpp index 96ec716c7fe..3d124b9dc43 100644 --- a/src/test/app/Loan_test.cpp +++ b/src/test/app/Loan_test.cpp @@ -39,6 +39,7 @@ #include #include #include +#include #include #include #include @@ -368,18 +369,21 @@ class Loan_test : public beast::unit_test::Suite { TenthBips16 const managementFeeRate{brokerSle->at(sfManagementFeeRate)}; auto const brokerDebt = brokerSle->at(sfDebtTotal); - auto const expectedDebt = env.current()->rules().enabled(featureLendingProtocolV1_1) - ? principalOutstanding - : principalOutstanding + interestOwed; - env.test.BEAST_EXPECT(brokerDebt == expectedDebt); - env.test.BEAST_EXPECT( - env.balance(pseudoAccount, broker.asset).number() == - brokerSle->at(sfCoverAvailable)); - env.test.BEAST_EXPECT(brokerSle->at(sfOwnerCount) == ownerCount); if (auto vaultSle = env.le(keylet::vault(brokerSle->at(sfVaultID))); env.test.BEAST_EXPECT(vaultSle)) { + auto const expectedDebt = + env.current()->rules().enabled(featureLendingProtocolV1_1) && + getVaultVersion(vaultSle) == VaultVersion::CashBasis + ? principalOutstanding + : principalOutstanding + interestOwed; + env.test.BEAST_EXPECT(brokerDebt == expectedDebt); + env.test.BEAST_EXPECT( + env.balance(pseudoAccount, broker.asset).number() == + brokerSle->at(sfCoverAvailable)); + env.test.BEAST_EXPECT(brokerSle->at(sfOwnerCount) == ownerCount); + Account const vaultPseudo{"vaultPseudoAccount", vaultSle->at(sfAccount)}; env.test.BEAST_EXPECT( vaultSle->at(sfAssetsAvailable) == @@ -475,7 +479,8 @@ class Loan_test : public beast::unit_test::Suite { env.test.BEAST_EXPECT( vaultSle->at(sfLossUnrealized) == - (env.current()->rules().enabled(featureLendingProtocolV1_1) + (env.current()->rules().enabled(featureLendingProtocolV1_1) && + getVaultVersion(vaultSle) == VaultVersion::CashBasis ? principalOutstanding : totalValue - managementFeeOutstanding)); } @@ -645,7 +650,8 @@ class Loan_test : public beast::unit_test::Suite auto const assetsUnavailable = vaultSle->at(sfAssetsTotal) - vaultSle->at(sfAssetsAvailable); auto const unrealizedLoss = vaultSle->at(sfLossUnrealized) + - (env.current()->rules().enabled(featureLendingProtocolV1_1) + (env.current()->rules().enabled(featureLendingProtocolV1_1) && + getVaultVersion(vaultSle) == VaultVersion::CashBasis ? state.principalOutstanding : state.totalValue - state.managementFeeOutstanding); @@ -9190,6 +9196,197 @@ class Loan_test : public beast::unit_test::Suite } } + // 3b. LEVersion regression: a Vault created before featureLendingProtocolV1_1 + // activates (LEVersion absent) must keep whole-life (accrual) accounting + // forever, even after the amendment is later enabled -- the switch is + // per-Vault (LEVersion == VaultVersion::CashBasis), not a single global amendment + // flag. + void + testLegacyVaultKeepsAccrualAfterAmendmentEnabled() + { + testcase("LEVersion: legacy vault keeps accrual after amendment enabled"); + + using namespace jtx; + using namespace loan; + using namespace std::chrono_literals; + + PrettyAsset const xrpAsset{xrpIssue(), 1'000'000}; + BrokerParameters const brokerParams{ + .vaultDeposit = 1'000'000, + .debtMax = 0, + .coverRateMin = TenthBips32{percentageToTenthBips(10)}, + .coverDeposit = 5'000, + .managementFeeRate = TenthBips16{0}, + .coverRateLiquidation = TenthBips32{percentageToTenthBips(25)}}; + + Number const principalRequest{10'000}; + TenthBips32 const interestRate{percentageToTenthBips(12)}; + std::uint32_t const paymentTotal = 4; + std::uint32_t const paymentInterval = 600; + std::uint32_t const gracePeriod = 60; + + // Amendment disabled at Vault creation time: LEVersion stays absent. + Env env(*this, all_); + + Account const lender{"lender"}; + Account const borrower{"borrower"}; + env.fund(XRP(10'000'000), lender, borrower); + env.close(); + + BrokerInfo const broker{createVaultAndBroker(env, xrpAsset, lender, brokerParams)}; + + { + auto const vaultSle = env.le(broker.vaultKeylet()); + BEAST_EXPECT(vaultSle); + BEAST_EXPECT(!vaultSle->isFieldPresent(sfLEVersion)); + } + + // Now enable the amendment -- production dispatch must still treat + // this specific Vault as accrual-basis, since its LEVersion is + // (and remains) absent. + env.enableFeature(featureLendingProtocolV1_1); + env.close(); + + LoanParameters const loanParams{ + .account = borrower, + .counter = lender, + .principalRequest = principalRequest, + .interest = interestRate, + .payTotal = paymentTotal, + .payInterval = paymentInterval, + .gracePd = gracePeriod, + }; + + auto const brokerBeforeLoan = env.le(broker.brokerKeylet()); + BEAST_EXPECT(brokerBeforeLoan); + auto const loanSequence = brokerBeforeLoan->at(sfLoanSequence); + auto const loanKeylet = keylet::loan(broker.brokerID, loanSequence); + + // ---- LoanSet origination: whole-life formulas expected ---- + auto const vaultBeforeSet = env.le(broker.vaultKeylet()); + auto const brokerBeforeSet = env.le(broker.brokerKeylet()); + BEAST_EXPECT(vaultBeforeSet && brokerBeforeSet); + Number const assetsTotalBeforeSet = vaultBeforeSet->at(sfAssetsTotal); + Number const debtTotalBeforeSet = brokerBeforeSet->at(sfDebtTotal); + + env(loanParams(env, broker)); + env.close(); + + auto const loanAfterSet = env.le(loanKeylet); + BEAST_EXPECT(loanAfterSet); + Number const principalOutstanding = loanAfterSet->at(sfPrincipalOutstanding); + Number const totalValueOutstanding = loanAfterSet->at(sfTotalValueOutstanding); + Number const interestDue = totalValueOutstanding - principalOutstanding; + BEAST_EXPECT(interestDue > beast::kZero); + + auto const vaultAfterSet = env.le(broker.vaultKeylet()); + auto const brokerAfterSet = env.le(broker.brokerKeylet()); + BEAST_EXPECT(vaultAfterSet && brokerAfterSet); + Number const assetsTotalDeltaSet = + Number(vaultAfterSet->at(sfAssetsTotal)) - assetsTotalBeforeSet; + Number const debtTotalDeltaSet = + Number(brokerAfterSet->at(sfDebtTotal)) - debtTotalBeforeSet; + + BEAST_EXPECTS( + assetsTotalDeltaSet == interestDue, + "legacy vault origination must still add interestDue to AssetsTotal; delta=" + + to_string(assetsTotalDeltaSet) + " interestDue=" + to_string(interestDue)); + BEAST_EXPECTS( + debtTotalDeltaSet == principalOutstanding + interestDue, + "legacy vault origination must still add principal+interest to DebtTotal; delta=" + + to_string(debtTotalDeltaSet)); + + LoanState const state = getCurrentState(env, broker, loanKeylet); + env.close(); + + // ---- LoanPay: whole-life formulas expected ---- + auto const vaultBeforePay = env.le(broker.vaultKeylet()); + auto const brokerBeforePay = env.le(broker.brokerKeylet()); + auto const loanBeforePay = env.le(loanKeylet); + BEAST_EXPECT(vaultBeforePay && brokerBeforePay && loanBeforePay); + Number const totalValueBeforePay = loanBeforePay->at(sfTotalValueOutstanding); + Number const assetsTotalBeforePay = vaultBeforePay->at(sfAssetsTotal); + Number const debtTotalBeforePay = brokerBeforePay->at(sfDebtTotal); + + STAmount const paymentAmount{ + xrpAsset, roundPeriodicPayment(xrpAsset, state.periodicPayment, state.loanScale)}; + env(pay(borrower, loanKeylet.key, paymentAmount), Ter(tesSUCCESS)); + env.close(); + + auto const vaultAfterPay = env.le(broker.vaultKeylet()); + auto const brokerAfterPay = env.le(broker.brokerKeylet()); + auto const loanAfterPay = env.le(loanKeylet); + BEAST_EXPECT(vaultAfterPay && brokerAfterPay && loanAfterPay); + Number const totalValueAfterPay = loanAfterPay->at(sfTotalValueOutstanding); + Number const assetsTotalDeltaPay = + Number(vaultAfterPay->at(sfAssetsTotal)) - assetsTotalBeforePay; + Number const debtTotalDeltaPay = + Number(brokerAfterPay->at(sfDebtTotal)) - debtTotalBeforePay; + Number const totalValueDeltaPay = totalValueAfterPay - totalValueBeforePay; + + // A regular, on-time payment has valueChange == 0, so whole-life + // AssetsTotal is untouched and DebtTotal mirrors TotalValueOutstanding. + BEAST_EXPECTS( + assetsTotalDeltaPay == beast::kZero, + "legacy vault regular payment must not change AssetsTotal; delta=" + + to_string(assetsTotalDeltaPay)); + BEAST_EXPECTS( + debtTotalDeltaPay == totalValueDeltaPay, + "legacy vault DebtTotal delta must mirror TotalValueOutstanding delta; " + "debtTotalDelta=" + + to_string(debtTotalDeltaPay) + " totalValueDelta=" + to_string(totalValueDeltaPay)); + + // ---- LoanManage: impair, then default -- whole-life exposure expected ---- + auto const loanBeforeImpair = env.le(loanKeylet); + BEAST_EXPECT(loanBeforeImpair); + Number const totalValueBeforeImpair = loanBeforeImpair->at(sfTotalValueOutstanding); + Number const managementFeeBeforeImpair = loanBeforeImpair->at(sfManagementFeeOutstanding); + Number const expectedExposure = totalValueBeforeImpair - managementFeeBeforeImpair; + + env(manage(lender, loanKeylet.key, tfLoanImpair), Ter(tesSUCCESS)); + env.close(); + + LoanState const stateAtImpair = getCurrentState(env, broker, loanKeylet); + env.close( + stateAtImpair.startDate + std::chrono::seconds(paymentInterval) + + std::chrono::seconds(gracePeriod) + 60s); + + auto const vaultBeforeDefault = env.le(broker.vaultKeylet()); + auto const brokerBeforeDefault = env.le(broker.brokerKeylet()); + BEAST_EXPECT(vaultBeforeDefault && brokerBeforeDefault); + Number const debtTotalBeforeDefault = brokerBeforeDefault->at(sfDebtTotal); + Number const lossBeforeDefault = vaultBeforeDefault->at(sfLossUnrealized); + + env(manage(lender, loanKeylet.key, tfLoanDefault), Ter(tesSUCCESS)); + env.close(); + + auto const vaultAfterDefault = env.le(broker.vaultKeylet()); + auto const brokerAfterDefault = env.le(broker.brokerKeylet()); + BEAST_EXPECT(vaultAfterDefault && brokerAfterDefault); + Number const debtTotalDeltaDefault = + Number(brokerAfterDefault->at(sfDebtTotal)) - debtTotalBeforeDefault; + Number const lossDeltaDefault = + Number(vaultAfterDefault->at(sfLossUnrealized)) - lossBeforeDefault; + + BEAST_EXPECTS( + debtTotalDeltaDefault == -expectedExposure, + "legacy vault default must reduce DebtTotal by whole-life exposure; delta=" + + to_string(debtTotalDeltaDefault) + " expected=" + to_string(expectedExposure)); + BEAST_EXPECTS( + lossDeltaDefault == -expectedExposure, + "legacy vault default must reverse the earlier impair's LossUnrealized exactly; " + "delta=" + + to_string(lossDeltaDefault) + " expected=" + to_string(expectedExposure)); + + // Confirm the Vault's LEVersion truly never got set, throughout. + { + auto const vaultSle = env.le(broker.vaultKeylet()); + BEAST_EXPECT(vaultSle); + BEAST_EXPECT(!vaultSle->isFieldPresent(sfLEVersion)); + BEAST_EXPECT(getVaultVersion(vaultSle) == VaultVersion::Legacy); + } + } + // 4. End-to-end trajectory: LoanSet -> 2 LoanPays -> LoanManage(default), // entirely under the amendment, with independently hand-computed // expected AssetsTotal/DebtTotal/LossUnrealized/CoverAvailable values at @@ -9351,6 +9548,7 @@ class Loan_test : public beast::unit_test::Suite testCashBasisLoanSetOrigination(); testCashBasisLoanPay(); testCashBasisLoanManage(); + testLegacyVaultKeepsAccrualAfterAmendmentEnabled(); testCashBasisEndToEndTrajectory(); } diff --git a/src/test/app/Vault_test.cpp b/src/test/app/Vault_test.cpp index 617820c89c2..0fbfd1a52fd 100644 --- a/src/test/app/Vault_test.cpp +++ b/src/test/app/Vault_test.cpp @@ -7646,6 +7646,81 @@ class Vault_test : public beast::unit_test::Suite } } + void + testVaultCreateLEVersion() + { + using namespace test::jtx; + + Account const owner{"owner"}; + PrettyAsset const xrpAsset = xrpIssue(); + + { + testcase("VaultCreate LEVersion: featureLendingProtocolV1_1 disabled, field absent"); + Env env{*this}; + env.disableFeature(featureLendingProtocolV1_1); + env.fund(XRP(1'000'000), owner); + env.close(); + + Vault const vault{env}; + auto const [tx, keylet] = vault.create({.owner = owner, .asset = xrpAsset}); + env(tx, Ter(tesSUCCESS)); + env.close(); + + auto const sleVault = env.le(keylet); + BEAST_EXPECT(sleVault); + BEAST_EXPECT(!sleVault->isFieldPresent(sfLEVersion)); + } + + { + testcase("VaultCreate LEVersion: featureLendingProtocolV1_1 enabled, LEVersion == 2"); + Env env{*this}; + env.fund(XRP(1'000'000), owner); + env.close(); + + Vault const vault{env}; + auto const [tx, keylet] = vault.create({.owner = owner, .asset = xrpAsset}); + env(tx, Ter(tesSUCCESS)); + env.close(); + + auto const sleVault = env.le(keylet); + BEAST_EXPECT(sleVault); + BEAST_EXPECT(sleVault->isFieldPresent(sfLEVersion)); + BEAST_EXPECT(sleVault->at(sfLEVersion) == std::to_underlying(VaultVersion::CashBasis)); + } + + { + testcase("VaultCreate rejects LEVersion set in the transaction"); + Env env{*this}; + env.fund(XRP(1'000'000), owner); + env.close(); + + Vault const vault{env}; + auto [tx, keylet] = vault.create({.owner = owner, .asset = xrpAsset}); + tx[sfLEVersion] = 2; + env(tx, Ter(temMALFORMED)); + env.close(); + + BEAST_EXPECT(!env.le(keylet)); + } + + { + testcase("VaultSet rejects LEVersion set in the transaction"); + Env env{*this}; + env.fund(XRP(1'000'000), owner); + env.close(); + + Vault const vault{env}; + auto const [createTx, keylet] = vault.create({.owner = owner, .asset = xrpAsset}); + env(createTx, Ter(tesSUCCESS)); + env.close(); + + auto setTx = vault.set({.owner = owner, .id = keylet.key}); + setTx[sfLEVersion] = 2; + env(setTx, Ter(temMALFORMED)); + env.close(); + } + } + void testVaultDepositFreezeIOU() { @@ -8318,6 +8393,7 @@ class Vault_test : public beast::unit_test::Suite testVaultEscrowedMPT(); testAssetsMaximum(); testVaultDeleteMemoData(); + testVaultCreateLEVersion(); testBug6LimitBypassWithShares(); testRemoveEmptyHoldingLockedAmount(); testRemoveEmptyHoldingConfidentialBalances(); From e5d6f256cdabaece16c68dd41148885567ff5da2 Mon Sep 17 00:00:00 2001 From: Vito <5780819+Tapanito@users.noreply.github.com> Date: Tue, 21 Jul 2026 18:21:57 +0200 Subject: [PATCH 03/11] fix: autogen files --- .../protocol_autogen/ledger_entries/Vault.h | 35 +++++++++++++++++++ .../ledger_entries/VaultTests.cpp | 27 ++++++++++++++ 2 files changed, 62 insertions(+) diff --git a/include/xrpl/protocol_autogen/ledger_entries/Vault.h b/include/xrpl/protocol_autogen/ledger_entries/Vault.h index 2bf92b4f5db..a6ab54cb0a4 100644 --- a/include/xrpl/protocol_autogen/ledger_entries/Vault.h +++ b/include/xrpl/protocol_autogen/ledger_entries/Vault.h @@ -287,6 +287,30 @@ class Vault : public LedgerEntryBase { return this->sle_->isFieldPresent(sfScale); } + + /** + * @brief Get sfLEVersion (SoeDefault) + * @return The field value, or std::nullopt if not present. + */ + [[nodiscard]] + protocol_autogen::Optional + getLEVersion() const + { + if (hasLEVersion()) + return this->sle_->at(sfLEVersion); + return std::nullopt; + } + + /** + * @brief Check if sfLEVersion is present. + * @return True if the field is present, false otherwise. + */ + [[nodiscard]] + bool + hasLEVersion() const + { + return this->sle_->isFieldPresent(sfLEVersion); + } }; /** @@ -508,6 +532,17 @@ class VaultBuilder : public LedgerEntryBuilderBase return *this; } + /** + * @brief Set sfLEVersion (SoeDefault) + * @return Reference to this builder for method chaining. + */ + VaultBuilder& + setLEVersion(std::decay_t const& value) + { + object_[sfLEVersion] = value; + return *this; + } + /** * @brief Build and return the completed Vault wrapper. * @param index The ledger entry index. diff --git a/src/tests/libxrpl/protocol_autogen/ledger_entries/VaultTests.cpp b/src/tests/libxrpl/protocol_autogen/ledger_entries/VaultTests.cpp index 2697924d374..f55d01f6062 100644 --- a/src/tests/libxrpl/protocol_autogen/ledger_entries/VaultTests.cpp +++ b/src/tests/libxrpl/protocol_autogen/ledger_entries/VaultTests.cpp @@ -35,6 +35,7 @@ TEST(VaultTests, BuilderSettersRoundTrip) auto const shareMPTIDValue = canonical_UINT192(); auto const withdrawalPolicyValue = canonical_UINT8(); auto const scaleValue = canonical_UINT8(); + auto const lEVersionValue = canonical_UINT8(); VaultBuilder builder{ previousTxnIDValue, @@ -54,6 +55,7 @@ TEST(VaultTests, BuilderSettersRoundTrip) builder.setAssetsMaximum(assetsMaximumValue); builder.setLossUnrealized(lossUnrealizedValue); builder.setScale(scaleValue); + builder.setLEVersion(lEVersionValue); builder.setLedgerIndex(index); builder.setFlags(0x1u); @@ -166,6 +168,14 @@ TEST(VaultTests, BuilderSettersRoundTrip) EXPECT_TRUE(entry.hasScale()); } + { + auto const& expected = lEVersionValue; + auto const actualOpt = entry.getLEVersion(); + ASSERT_TRUE(actualOpt.has_value()); + expectEqualField(expected, *actualOpt, "sfLEVersion"); + EXPECT_TRUE(entry.hasLEVersion()); + } + EXPECT_TRUE(entry.hasLedgerIndex()); auto const ledgerIndex = entry.getLedgerIndex(); ASSERT_TRUE(ledgerIndex.has_value()); @@ -194,6 +204,7 @@ TEST(VaultTests, BuilderFromSleRoundTrip) auto const shareMPTIDValue = canonical_UINT192(); auto const withdrawalPolicyValue = canonical_UINT8(); auto const scaleValue = canonical_UINT8(); + auto const lEVersionValue = canonical_UINT8(); auto sle = std::make_shared(Vault::entryType, index); @@ -212,6 +223,7 @@ TEST(VaultTests, BuilderFromSleRoundTrip) sle->at(sfShareMPTID) = shareMPTIDValue; sle->at(sfWithdrawalPolicy) = withdrawalPolicyValue; sle->at(sfScale) = scaleValue; + sle->at(sfLEVersion) = lEVersionValue; VaultBuilder builderFromSle{sle}; EXPECT_TRUE(builderFromSle.validate()); @@ -390,6 +402,19 @@ TEST(VaultTests, BuilderFromSleRoundTrip) expectEqualField(expected, *fromBuilderOpt, "sfScale"); } + { + auto const& expected = lEVersionValue; + + auto const fromSleOpt = entryFromSle.getLEVersion(); + auto const fromBuilderOpt = entryFromBuilder.getLEVersion(); + + ASSERT_TRUE(fromSleOpt.has_value()); + ASSERT_TRUE(fromBuilderOpt.has_value()); + + expectEqualField(expected, *fromSleOpt, "sfLEVersion"); + expectEqualField(expected, *fromBuilderOpt, "sfLEVersion"); + } + EXPECT_EQ(entryFromSle.getKey(), index); EXPECT_EQ(entryFromBuilder.getKey(), index); } @@ -472,5 +497,7 @@ TEST(VaultTests, OptionalFieldsReturnNullopt) EXPECT_FALSE(entry.getLossUnrealized().has_value()); EXPECT_FALSE(entry.hasScale()); EXPECT_FALSE(entry.getScale().has_value()); + EXPECT_FALSE(entry.hasLEVersion()); + EXPECT_FALSE(entry.getLEVersion().has_value()); } } From fac3dfccc873d85937b2e7da9c459d83dcbb5078 Mon Sep 17 00:00:00 2001 From: Vito <5780819+Tapanito@users.noreply.github.com> Date: Wed, 22 Jul 2026 11:58:17 +0200 Subject: [PATCH 04/11] fix: VaultVersion --- include/xrpl/protocol/Protocol.h | 1 - src/libxrpl/ledger/helpers/VaultHelpers.cpp | 7 +++++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/include/xrpl/protocol/Protocol.h b/include/xrpl/protocol/Protocol.h index f387ea56de8..9938a9b768b 100644 --- a/include/xrpl/protocol/Protocol.h +++ b/include/xrpl/protocol/Protocol.h @@ -324,7 +324,6 @@ constexpr std::uint8_t kVaultMaximumIouScale = 18; */ enum class VaultVersion : uint8_t { Legacy = 0, - Invalid, CashBasis, }; diff --git a/src/libxrpl/ledger/helpers/VaultHelpers.cpp b/src/libxrpl/ledger/helpers/VaultHelpers.cpp index 5b2694250d3..1ac4abdca33 100644 --- a/src/libxrpl/ledger/helpers/VaultHelpers.cpp +++ b/src/libxrpl/ledger/helpers/VaultHelpers.cpp @@ -149,10 +149,13 @@ getVaultVersion(SLE::const_ref vault) { case 0: return VaultVersion::Legacy; - case 2: + case 1: return VaultVersion::CashBasis; default: - return VaultVersion::Invalid; + // LCOV_EXCL_START + UNREACHABLE("xrpl::getVaultVersion : invalid vault version"); + return VaultVersion::Legacy; + // LCOV_EXCL_STOP } } From 209ee243d3eb7d4f28ab7af9e1c00309f7221210 Mon Sep 17 00:00:00 2001 From: Vito <5780819+Tapanito@users.noreply.github.com> Date: Wed, 22 Jul 2026 12:58:37 +0200 Subject: [PATCH 05/11] fix: Address PR review comments on LEVersion naming Reference VaultVersion::Legacy instead of raw 0 in getVaultVersion's docstring, correct a stale test label, and rename LoanPay's precision log fields from ValueChange to AssetsTotalDelta to match what they actually print --- include/xrpl/ledger/helpers/VaultHelpers.h | 6 ++++-- src/libxrpl/tx/transactors/lending/LoanPay.cpp | 4 ++-- src/test/app/Vault_test.cpp | 4 +++- 3 files changed, 9 insertions(+), 5 deletions(-) diff --git a/include/xrpl/ledger/helpers/VaultHelpers.h b/include/xrpl/ledger/helpers/VaultHelpers.h index e0101bd742b..5681cc57e86 100644 --- a/include/xrpl/ledger/helpers/VaultHelpers.h +++ b/include/xrpl/ledger/helpers/VaultHelpers.h @@ -112,11 +112,13 @@ isSoleShareholder(ReadView const& view, AccountID const& account, SLE::const_ref * Resolves a Vault's LEVersion, the single point every accounting touch * point should call to determine which recognition model (accrual vs. * cash-basis) a Vault uses. Vaults created before featureLendingProtocolV1_1 - * activated never have sfLEVersion set, which resolves here to 0 (legacy). + * activated never have sfLEVersion set, which resolves here to + * VaultVersion::Legacy. * * @param vault The vault SLE. * - * @return The Vault's LEVersion, or 0 if the field is absent. + * @return The Vault's LEVersion, or VaultVersion::Legacy if the field is + * absent. */ [[nodiscard]] VaultVersion getVaultVersion(SLE::const_ref vault); diff --git a/src/libxrpl/tx/transactors/lending/LoanPay.cpp b/src/libxrpl/tx/transactors/lending/LoanPay.cpp index 6566a7155da..5af6107edac 100644 --- a/src/libxrpl/tx/transactors/lending/LoanPay.cpp +++ b/src/libxrpl/tx/transactors/lending/LoanPay.cpp @@ -552,7 +552,7 @@ LoanPay::doApply() << "LoanPay: Vault assets expected change, but unchanged after rounding: " // << "Before: " << assetsTotalBefore // << ", After: " << assetsTotalAfter // - << ", ValueChange: " << assetsTotalDelta; + << ", AssetsTotalDelta: " << assetsTotalDelta; return tecPRECISION_LOSS; // LCOV_EXCL_STOP } @@ -566,7 +566,7 @@ LoanPay::doApply() JLOG(j_.fatal()) << "LoanPay: Vault assets changed unexpectedly after rounding: " // << "Before: " << assetsTotalBefore // << ", After: " << assetsTotalAfter // - << ", ValueChange: " << assetsTotalDelta; + << ", AssetsTotalDelta: " << assetsTotalDelta; return tecINTERNAL; // LCOV_EXCL_STOP } diff --git a/src/test/app/Vault_test.cpp b/src/test/app/Vault_test.cpp index 0fbfd1a52fd..d4bab0fb80b 100644 --- a/src/test/app/Vault_test.cpp +++ b/src/test/app/Vault_test.cpp @@ -7672,7 +7672,9 @@ class Vault_test : public beast::unit_test::Suite } { - testcase("VaultCreate LEVersion: featureLendingProtocolV1_1 enabled, LEVersion == 2"); + testcase( + "VaultCreate LEVersion: featureLendingProtocolV1_1 enabled, LEVersion == " + "VaultVersion::CashBasis"); Env env{*this}; env.fund(XRP(1'000'000), owner); env.close(); From b9b3aedc88bd00b7f24049517d36de3b302560d6 Mon Sep 17 00:00:00 2001 From: Vito <5780819+Tapanito@users.noreply.github.com> Date: Fri, 24 Jul 2026 10:44:28 +0200 Subject: [PATCH 06/11] fix: Skip AssetsMaximum interest check under cash-basis accounting Cash-basis origination never recognizes interest into Vault.AssetsTotal, so checking interestDue against remaining AssetsMaximum headroom rejects loans for no reason. Split the check into Accrual/CashBasis namespaces, mirroring the existing loanOriginationDeltas/loanPaymentDeltas dispatch. --- include/xrpl/ledger/helpers/LendingHelpers.h | 15 +++ src/libxrpl/ledger/helpers/LendingHelpers.cpp | 25 +++++ .../tx/transactors/lending/LoanSet.cpp | 4 +- src/test/app/LendingHelpers_test.cpp | 106 +++++++++++++++++- src/test/app/Loan_test.cpp | 26 +++-- 5 files changed, 164 insertions(+), 12 deletions(-) diff --git a/include/xrpl/ledger/helpers/LendingHelpers.h b/include/xrpl/ledger/helpers/LendingHelpers.h index 6e1299687bb..5503fe5b971 100644 --- a/include/xrpl/ledger/helpers/LendingHelpers.h +++ b/include/xrpl/ledger/helpers/LendingHelpers.h @@ -302,6 +302,14 @@ namespace Accrual { AccountingDeltas loanOriginationDeltas(Number const& principalRequested, Number const& interestDue); +// LoanSet origination: would recognizing this loan's interest push +// Vault.AssetsTotal past Vault.AssetsMaximum? +bool +loanOriginationExceedsVaultMaximum( + Number const& vaultMaximum, + Number const& vaultTotal, + Number const& interestDue); + // LoanManage impair/unimpair/default: the vault's exposure to this loan Number loanVaultExposure(SLE::const_ref loanSle); @@ -338,6 +346,13 @@ loanOriginationDeltas( Number const& principalRequested, Number const& interestDue); +bool +loanOriginationExceedsVaultMaximum( + Rules const& rules, + SLE::const_ref vaultSle, + Number const& vaultTotal, + Number const& interestDue); + Number loanVaultExposure(Rules const& rules, SLE::const_ref vaultSle, SLE::const_ref loanSle); diff --git a/src/libxrpl/ledger/helpers/LendingHelpers.cpp b/src/libxrpl/ledger/helpers/LendingHelpers.cpp index 988c9cbcc1d..4259b8eee99 100644 --- a/src/libxrpl/ledger/helpers/LendingHelpers.cpp +++ b/src/libxrpl/ledger/helpers/LendingHelpers.cpp @@ -139,6 +139,15 @@ loanOriginationDeltas(Number const& principalRequested, Number const& interestDu return {.assetsTotalDelta = interestDue, .debtTotalDelta = principalRequested + interestDue}; } +bool +loanOriginationExceedsVaultMaximum( + Number const& vaultMaximum, + Number const& vaultTotal, + Number const& interestDue) +{ + return vaultMaximum != 0 && interestDue > vaultMaximum - vaultTotal; +} + Number loanVaultExposure(SLE::const_ref loanSle) { @@ -204,6 +213,22 @@ loanOriginationDeltas( : Accrual::loanOriginationDeltas(principalRequested, interestDue); } +bool +loanOriginationExceedsVaultMaximum( + Rules const& rules, + SLE::const_ref vaultSle, + Number const& vaultTotal, + Number const& interestDue) +{ + // Cash-basis origination doesn't recognize interest into AssetsTotal, so + // interest due can never push the vault past AssetsMaximum at origination. + if (cashBasisEnabled(rules, vaultSle)) + return false; + + auto const vaultMaximum = vaultSle->at(sfAssetsMaximum); + return Accrual::loanOriginationExceedsVaultMaximum(vaultMaximum, vaultTotal, interestDue); +} + Number loanVaultExposure(Rules const& rules, SLE::const_ref vaultSle, SLE::const_ref loanSle) { diff --git a/src/libxrpl/tx/transactors/lending/LoanSet.cpp b/src/libxrpl/tx/transactors/lending/LoanSet.cpp index 52d3361db75..c5b9c420a9c 100644 --- a/src/libxrpl/tx/transactors/lending/LoanSet.cpp +++ b/src/libxrpl/tx/transactors/lending/LoanSet.cpp @@ -444,7 +444,9 @@ LoanSet::doApply() vaultMaximum == 0 || vaultMaximum > *vaultTotalProxy, "xrpl::LoanSet::doApply", "Vault is below maximum limit"); - if (vaultMaximum != 0 && state.interestDue > vaultMaximum - vaultTotalProxy) + + if (loanOriginationExceedsVaultMaximum( + ctx_.view().rules(), vaultSle, vaultTotalProxy, state.interestDue)) { JLOG(j_.warn()) << "Loan would exceed the maximum assets of the vault"; return tecLIMIT_EXCEEDED; diff --git a/src/test/app/LendingHelpers_test.cpp b/src/test/app/LendingHelpers_test.cpp index b27c976646c..82f455ce6b0 100644 --- a/src/test/app/LendingHelpers_test.cpp +++ b/src/test/app/LendingHelpers_test.cpp @@ -1530,6 +1530,52 @@ class LendingHelpers_test : public beast::unit_test::Suite to_string(deltas.debtTotalDelta)); } + void + testAccrualLoanOriginationExceedsVaultMaximum() + { + using namespace xrpl::Accrual; + + struct TestCase + { + std::string name; + Number vaultMaximum; + Number vaultTotal; + Number interestDue; + bool expected; + }; + + auto const testCases = std::vector{ + {.name = "No maximum configured", + .vaultMaximum = Number{0}, + .vaultTotal = Number{900}, + .interestDue = Number{1'000}, + .expected = false}, + {.name = "Interest fits under headroom", + .vaultMaximum = Number{1'000}, + .vaultTotal = Number{900}, + .interestDue = Number{50}, + .expected = false}, + {.name = "Interest exactly fills headroom", + .vaultMaximum = Number{1'000}, + .vaultTotal = Number{900}, + .interestDue = Number{100}, + .expected = false}, + {.name = "Interest exceeds headroom", + .vaultMaximum = Number{1'000}, + .vaultTotal = Number{900}, + .interestDue = Number{101}, + .expected = true}, + }; + + for (auto const& tc : testCases) + { + testcase("Accrual::loanOriginationExceedsVaultMaximum: " + tc.name); + BEAST_EXPECT( + loanOriginationExceedsVaultMaximum( + tc.vaultMaximum, tc.vaultTotal, tc.interestDue) == tc.expected); + } + } + // Constructs a minimal ltLOAN SLE with just the fields needed by // loanVaultExposure. Mirrors the bare-SLE pattern used by // testCanApplyToBrokerCover for ltLOAN_BROKER. @@ -1549,11 +1595,18 @@ class LendingHelpers_test : public beast::unit_test::Suite // Constructs a minimal ltVAULT SLE with just LEVersion set (or left // absent), for exercising the dispatchers' per-Vault gating. static std::shared_ptr - makeVaultSle(std::optional leVersion = std::nullopt) + makeVaultSle( + std::optional leVersion = std::nullopt, + std::optional assetsMaximum = std::nullopt, + std::optional assetsTotal = std::nullopt) { auto sle = std::make_shared(ltVAULT, uint256{2u}); if (leVersion) sle->at(sfLEVersion) = std::to_underlying(*leVersion); + if (assetsMaximum) + sle->at(sfAssetsMaximum) = *assetsMaximum; + if (assetsTotal) + sle->at(sfAssetsTotal) = *assetsTotal; return sle; } @@ -1653,6 +1706,55 @@ class LendingHelpers_test : public beast::unit_test::Suite } } + void + testLoanOriginationExceedsVaultMaximumDispatcher() + { + using namespace jtx; + + Number const vaultMaximum{1'000}; + Number const vaultTotal{900}; + // Exceeds Accrual's headroom (100), but must never trip CashBasis. + Number const interestDue{101}; + + auto const legacyVault = makeVaultSle(std::nullopt, vaultMaximum, vaultTotal); + auto const cashBasisVault = makeVaultSle(VaultVersion::CashBasis, vaultMaximum, vaultTotal); + + { + testcase( + "loanOriginationExceedsVaultMaximum dispatcher: amendment disabled picks " + "Accrual"); + Env env{*this}; + env.disableFeature(featureLendingProtocolV1_1); + BEAST_EXPECT( + loanOriginationExceedsVaultMaximum( + env.current()->rules(), cashBasisVault, vaultTotal, interestDue) == + xrpl::Accrual::loanOriginationExceedsVaultMaximum( + vaultMaximum, vaultTotal, interestDue)); + } + + { + testcase( + "loanOriginationExceedsVaultMaximum dispatcher: amendment enabled, legacy vault " + "picks Accrual"); + Env const env{*this}; + BEAST_EXPECT( + loanOriginationExceedsVaultMaximum( + env.current()->rules(), legacyVault, vaultTotal, interestDue) == + xrpl::Accrual::loanOriginationExceedsVaultMaximum( + vaultMaximum, vaultTotal, interestDue)); + } + + { + testcase( + "loanOriginationExceedsVaultMaximum dispatcher: amendment enabled, LEVersion == " + "VaultVersion::CashBasis picks CashBasis"); + Env const env{*this}; + BEAST_EXPECT( + loanOriginationExceedsVaultMaximum( + env.current()->rules(), cashBasisVault, vaultTotal, interestDue) == false); + } + } + void testLoanVaultExposureDispatcher() { @@ -1845,10 +1947,12 @@ class LendingHelpers_test : public beast::unit_test::Suite testAccrualLoanOriginationDeltas(); testCashBasisLoanOriginationDeltas(); + testAccrualLoanOriginationExceedsVaultMaximum(); testAccrualLoanVaultExposure(); testCashBasisLoanVaultExposure(); testLoanPaymentDeltas(); testLoanOriginationDeltasDispatcher(); + testLoanOriginationExceedsVaultMaximumDispatcher(); testLoanVaultExposureDispatcher(); testLoanPaymentDeltasDispatcher(); } diff --git a/src/test/app/Loan_test.cpp b/src/test/app/Loan_test.cpp index 3d124b9dc43..ad21a82462b 100644 --- a/src/test/app/Loan_test.cpp +++ b/src/test/app/Loan_test.cpp @@ -8683,9 +8683,9 @@ class Loan_test : public beast::unit_test::Suite to_string(debtTotalDelta)); } - // AssetsMaximum / DebtMaximum guards: unconditionally checked against - // principal + interestDue, regardless of the amendment. Verify that - // the accept/reject boundary is bit-for-bit identical in both cases. + // AssetsMaximum guard checks interestDue headroom only under + // whole-life accounting (cash-basis never adds interest to + // AssetsTotal). DebtMaximum guard is unconditional either way. auto runVaultGuard = [&](FeatureBitset features, Number const& slack, TER expected) { Env env(*this, features); @@ -8745,18 +8745,24 @@ class Loan_test : public beast::unit_test::Suite }; Number const oneDrop = xrpAsset(1).value(); - for (auto const features : {all_ | featureLendingProtocolV1_1, all_}) { - testcase( - std::string("cash-basis: LoanSet AssetsMaximum guard unaffected by amendment (") + - (features[featureLendingProtocolV1_1] ? "enabled)" : "disabled)")); + testcase("whole-life: LoanSet AssetsMaximum guard checks interestDue headroom"); // Guard rejects when there's not quite enough headroom for the - // interest, even though cash-basis never touches AssetsTotal for // interest. - runVaultGuard(features, interestDueCash - oneDrop, tecLIMIT_EXCEEDED); + runVaultGuard(all_, interestDueCash - oneDrop, tecLIMIT_EXCEEDED); // Guard accepts at the exact boundary. - runVaultGuard(features, interestDueCash, tesSUCCESS); + runVaultGuard(all_, interestDueCash, tesSUCCESS); + } + { + testcase("cash-basis: LoanSet AssetsMaximum guard ignores interestDue headroom"); + // Even far less headroom than interestDue still succeeds, since + // cash-basis origination never adds interest to AssetsTotal. + runVaultGuard(all_ | featureLendingProtocolV1_1, oneDrop, tesSUCCESS); + } + + for (auto const features : {all_ | featureLendingProtocolV1_1, all_}) + { testcase( std::string("cash-basis: LoanSet DebtMaximum guard unaffected by amendment (") + (features[featureLendingProtocolV1_1] ? "enabled)" : "disabled)")); From 20358587ce1edcb4ed7502f5e1b83e9dc68098bc Mon Sep 17 00:00:00 2001 From: Vito <5780819+Tapanito@users.noreply.github.com> Date: Fri, 24 Jul 2026 11:55:58 +0200 Subject: [PATCH 07/11] fix: Use cash-basis-aware debt delta for LoanSet DebtMaximum guard Remove unused vaultMaximum local left over from the AssetsMaximum guard refactor, and stop the DebtMaximum guard from unconditionally adding interestDue to the projected DebtTotal. Reuse the existing loanOriginationDeltas dispatcher, which already excludes interestDue under cash-basis accounting. --- src/libxrpl/tx/transactors/lending/LoanSet.cpp | 10 ++++------ src/test/app/Loan_test.cpp | 15 +++++++++------ 2 files changed, 13 insertions(+), 12 deletions(-) diff --git a/src/libxrpl/tx/transactors/lending/LoanSet.cpp b/src/libxrpl/tx/transactors/lending/LoanSet.cpp index c5b9c420a9c..a4575e40b0e 100644 --- a/src/libxrpl/tx/transactors/lending/LoanSet.cpp +++ b/src/libxrpl/tx/transactors/lending/LoanSet.cpp @@ -439,9 +439,8 @@ LoanSet::doApply() principalRequested, properties.loanState.managementFeeDue); - auto const vaultMaximum = *vaultSle->at(sfAssetsMaximum); XRPL_ASSERT_PARTS( - vaultMaximum == 0 || vaultMaximum > *vaultTotalProxy, + *vaultSle->at(sfAssetsMaximum) == 0 || *vaultSle->at(sfAssetsMaximum) > *vaultTotalProxy, "xrpl::LoanSet::doApply", "Vault is below maximum limit"); @@ -492,8 +491,9 @@ LoanSet::doApply() auto const loanAssetsToBorrower = principalRequested - originationFee; - auto const newDebtDelta = principalRequested + state.interestDue; - auto const newDebtTotal = brokerSle->at(sfDebtTotal) + newDebtDelta; + auto const [assetsTotalDelta, debtTotalDelta] = + loanOriginationDeltas(ctx_.view().rules(), vaultSle, principalRequested, state.interestDue); + auto const newDebtTotal = brokerSle->at(sfDebtTotal) + debtTotalDelta; if (auto const debtMaximum = brokerSle->at(sfDebtMaximum); debtMaximum != 0 && debtMaximum < newDebtTotal) { @@ -635,8 +635,6 @@ LoanSet::doApply() view.insert(loan); // Update the balances in the vault - auto const [assetsTotalDelta, debtTotalDelta] = - loanOriginationDeltas(ctx_.view().rules(), vaultSle, principalRequested, state.interestDue); vaultAvailableProxy -= principalRequested; vaultTotalProxy += assetsTotalDelta; XRPL_ASSERT_PARTS( diff --git a/src/test/app/Loan_test.cpp b/src/test/app/Loan_test.cpp index ad21a82462b..8a6f1669df8 100644 --- a/src/test/app/Loan_test.cpp +++ b/src/test/app/Loan_test.cpp @@ -8684,8 +8684,7 @@ class Loan_test : public beast::unit_test::Suite } // AssetsMaximum guard checks interestDue headroom only under - // whole-life accounting (cash-basis never adds interest to - // AssetsTotal). DebtMaximum guard is unconditional either way. + // whole-life accounting; DebtMaximum guard also varies by model. auto runVaultGuard = [&](FeatureBitset features, Number const& slack, TER expected) { Env env(*this, features); @@ -8761,12 +8760,16 @@ class Loan_test : public beast::unit_test::Suite runVaultGuard(all_ | featureLendingProtocolV1_1, oneDrop, tesSUCCESS); } - for (auto const features : {all_ | featureLendingProtocolV1_1, all_}) + // DebtMaximum guard: cash-basis projects principal-only DebtTotal; + // whole-life projects principal + interestDue. + for (auto const cashBasis : {true, false}) { testcase( - std::string("cash-basis: LoanSet DebtMaximum guard unaffected by amendment (") + - (features[featureLendingProtocolV1_1] ? "enabled)" : "disabled)")); - Number const newDebtTotal = principalOutstandingCash + interestDueCash; + std::string("LoanSet DebtMaximum guard (") + + (cashBasis ? "cash-basis)" : "whole-life)")); + auto const features = cashBasis ? all_ | featureLendingProtocolV1_1 : all_; + Number const newDebtTotal = + principalOutstandingCash + (cashBasis ? Number{} : interestDueCash); runBrokerGuard(features, newDebtTotal - oneDrop, tecLIMIT_EXCEEDED); runBrokerGuard(features, newDebtTotal, tesSUCCESS); } From 27df5430e1192f3270856b551e14dabe0c24f16c Mon Sep 17 00:00:00 2001 From: Vito <5780819+Tapanito@users.noreply.github.com> Date: Fri, 24 Jul 2026 16:39:02 +0200 Subject: [PATCH 08/11] test: Split Loan_test.cpp into topical suites Loan_test.cpp had grown to ~9700 lines covering lifecycle, invariant regressions, RIPD/bug repros, and cash-basis accounting in one class. Split into Loan (core transactor tests), LoanInvariant (fuzzer/invariant repros), LoanRegression (RIPD-*/bug:/edge: repros), and LoanCashBasis (cash-basis accounting), sharing fixtures and helpers via a new LoanTestBase. LoanBatch_test/LoanArbitrary_test are rebased onto LoanTestBase instead of Loan_test for the same reason. Pure reorganization: combined case/assertion counts across the four new suites match the pre-split totals exactly (1070 cases, 116951 assertions, 0 failures). --- src/test/app/LoanCashBasis_test.cpp | 987 ++ src/test/app/LoanInvariant_test.cpp | 1903 ++++ src/test/app/LoanRegression_test.cpp | 974 ++ src/test/app/LoanTestBase.h | 1631 ++++ src/test/app/Loan_test.cpp | 12291 +++++++------------------ 5 files changed, 8914 insertions(+), 8872 deletions(-) create mode 100644 src/test/app/LoanCashBasis_test.cpp create mode 100644 src/test/app/LoanInvariant_test.cpp create mode 100644 src/test/app/LoanRegression_test.cpp create mode 100644 src/test/app/LoanTestBase.h diff --git a/src/test/app/LoanCashBasis_test.cpp b/src/test/app/LoanCashBasis_test.cpp new file mode 100644 index 00000000000..2c437efb25e --- /dev/null +++ b/src/test/app/LoanCashBasis_test.cpp @@ -0,0 +1,987 @@ +#include + +namespace xrpl::test { + +// LendingProtocolV1_1 ("cash-basis" accounting) dedicated coverage. +// +// Existing tests never enable featureLendingProtocolV1_1 (see `all_` +// above), so these are the only tests in this file that exercise the +// amendment. They are called once, directly, from +// runAmendmentIndependent() -- not looped through +// runAmendmentSensitive()/amendmentCombinations(), since doing so would +// require re-deriving whole-life-specific expected values for ~15 +// unrelated regression tests. +class LoanCashBasis_test : public LoanTestBase +{ +private: + // 1. LoanSet origination: Vault.AssetsTotal/LoanBroker.DebtTotal deltas, + // and the AssetsMaximum/DebtMaximum guards (which always check against + // principal + interestDue, regardless of the amendment). + void + testCashBasisLoanSetOrigination() + { + testcase("cash-basis: LoanSet origination"); + + using namespace jtx; + using namespace loan; + + PrettyAsset const xrpAsset{xrpIssue(), 1'000'000}; + BrokerParameters const brokerParams{ + .vaultDeposit = 100'000, + .debtMax = 0, + .coverRateMin = TenthBips32{0}, + .coverDeposit = 0, + .managementFeeRate = TenthBips16{0}, + .coverRateLiquidation = TenthBips32{0}}; + + Number const principalRequest{10'000}; + TenthBips32 const interestRate{percentageToTenthBips(10)}; + std::uint32_t const paymentTotal = 2; + std::uint32_t const paymentInterval = 86400; + + // Creates a broker/vault, submits a single LoanSet with a nonzero + // interest rate, and returns the observed Vault.AssetsTotal / + // LoanBroker.DebtTotal deltas plus the loan's own computed + // interestDue and principalOutstanding. + auto runOrigination = [&](FeatureBitset features) { + Env env(*this, features); + + Account const lender{"lender"}; + Account const borrower{"borrower"}; + env.fund(XRP(1'000'000), lender, borrower); + env.close(); + + BrokerInfo const broker{createVaultAndBroker(env, xrpAsset, lender, brokerParams)}; + + auto const vaultBefore = env.le(broker.vaultKeylet()); + auto const brokerBefore = env.le(broker.brokerKeylet()); + BEAST_EXPECT(vaultBefore && brokerBefore); + Number const assetsTotalBefore = vaultBefore->at(sfAssetsTotal); + Number const debtTotalBefore = brokerBefore->at(sfDebtTotal); + + auto const loanSequence = brokerBefore->at(sfLoanSequence); + auto const loanKeylet = keylet::loan(broker.brokerID, loanSequence); + + env(set(borrower, broker.brokerID, xrpAsset(principalRequest).value()), + kCounterparty(lender), + kInterestRate(interestRate), + kPaymentTotal(paymentTotal), + kPaymentInterval(paymentInterval), + Sig(sfCounterpartySignature, lender), + Fee(env.current()->fees().base * 2), + Ter(tesSUCCESS)); + env.close(); + + auto const loanSle = env.le(loanKeylet); + BEAST_EXPECT(loanSle); + Number const principalOutstanding = loanSle->at(sfPrincipalOutstanding); + Number const totalValueOutstanding = loanSle->at(sfTotalValueOutstanding); + Number const interestDue = totalValueOutstanding - principalOutstanding; + BEAST_EXPECT(interestDue > beast::kZero); + BEAST_EXPECT(principalOutstanding == xrpAsset(principalRequest).value()); + + auto const vaultAfter = env.le(broker.vaultKeylet()); + auto const brokerAfter = env.le(broker.brokerKeylet()); + BEAST_EXPECT(vaultAfter && brokerAfter); + Number const assetsTotalDelta = + Number(vaultAfter->at(sfAssetsTotal)) - assetsTotalBefore; + Number const debtTotalDelta = Number(brokerAfter->at(sfDebtTotal)) - debtTotalBefore; + + return std::make_tuple( + assetsTotalDelta, debtTotalDelta, interestDue, principalOutstanding); + }; + + Number interestDueCash{}; + Number principalOutstandingCash{}; + { + auto const [assetsTotalDelta, debtTotalDelta, interestDue, principalOutstanding] = + runOrigination(all_ | featureLendingProtocolV1_1); + interestDueCash = interestDue; + principalOutstandingCash = principalOutstanding; + + BEAST_EXPECTS( + assetsTotalDelta == beast::kZero, + "cash-basis origination must not change AssetsTotal; delta=" + + to_string(assetsTotalDelta)); + BEAST_EXPECTS( + debtTotalDelta == principalOutstanding, + "cash-basis origination must add principal-only to DebtTotal; delta=" + + to_string(debtTotalDelta) + " principal=" + to_string(principalOutstanding)); + } + + { + auto const [assetsTotalDelta, debtTotalDelta, interestDue, principalOutstanding] = + runOrigination(all_); + + BEAST_EXPECTS( + assetsTotalDelta == interestDue, + "whole-life origination must add interestDue to AssetsTotal; delta=" + + to_string(assetsTotalDelta) + " interestDue=" + to_string(interestDue)); + BEAST_EXPECTS( + debtTotalDelta == principalOutstanding + interestDue, + "whole-life origination must add principal+interest to DebtTotal; delta=" + + to_string(debtTotalDelta)); + } + + // AssetsMaximum guard checks interestDue headroom only under + // whole-life accounting; DebtMaximum guard also varies by model. + auto runVaultGuard = [&](FeatureBitset features, Number const& slack, TER expected) { + Env env(*this, features); + + Account const lender{"lender"}; + Account const borrower{"borrower"}; + env.fund(XRP(1'000'000), lender, borrower); + env.close(); + + BrokerInfo const broker{createVaultAndBroker(env, xrpAsset, lender, brokerParams)}; + + auto const vaultSle = env.le(broker.vaultKeylet()); + BEAST_EXPECT(vaultSle); + Number const assetsTotalBefore = vaultSle->at(sfAssetsTotal); + + Vault const vault{env}; + auto tx = vault.set({.owner = lender, .id = broker.vaultID}); + tx[sfAssetsMaximum] = assetsTotalBefore + slack; + env(tx); + env.close(); + + env(set(borrower, broker.brokerID, xrpAsset(principalRequest).value()), + kCounterparty(lender), + kInterestRate(interestRate), + kPaymentTotal(paymentTotal), + kPaymentInterval(paymentInterval), + Sig(sfCounterpartySignature, lender), + Fee(env.current()->fees().base * 2), + Ter(expected)); + env.close(); + }; + + auto runBrokerGuard = [&](FeatureBitset features, Number const& debtMaximum, TER expected) { + Env env(*this, features); + + Account const lender{"lender"}; + Account const borrower{"borrower"}; + env.fund(XRP(1'000'000), lender, borrower); + env.close(); + + BrokerInfo const broker{createVaultAndBroker(env, xrpAsset, lender, brokerParams)}; + + env(loanBroker::set(lender, broker.vaultID), + loanBroker::kLoanBrokerId(broker.brokerID), + loanBroker::kDebtMaximum(debtMaximum), + Fee(env.current()->fees().base * 2)); + env.close(); + + env(set(borrower, broker.brokerID, xrpAsset(principalRequest).value()), + kCounterparty(lender), + kInterestRate(interestRate), + kPaymentTotal(paymentTotal), + kPaymentInterval(paymentInterval), + Sig(sfCounterpartySignature, lender), + Fee(env.current()->fees().base * 2), + Ter(expected)); + env.close(); + }; + + Number const oneDrop = xrpAsset(1).value(); + { + testcase("whole-life: LoanSet AssetsMaximum guard checks interestDue headroom"); + // Guard rejects when there's not quite enough headroom for the + // interest. + runVaultGuard(all_, interestDueCash - oneDrop, tecLIMIT_EXCEEDED); + // Guard accepts at the exact boundary. + runVaultGuard(all_, interestDueCash, tesSUCCESS); + } + + { + testcase("cash-basis: LoanSet AssetsMaximum guard ignores interestDue headroom"); + // Even far less headroom than interestDue still succeeds, since + // cash-basis origination never adds interest to AssetsTotal. + runVaultGuard(all_ | featureLendingProtocolV1_1, oneDrop, tesSUCCESS); + } + + // DebtMaximum guard: cash-basis projects principal-only DebtTotal; + // whole-life projects principal + interestDue. + for (auto const cashBasis : {true, false}) + { + testcase( + std::string("LoanSet DebtMaximum guard (") + + (cashBasis ? "cash-basis)" : "whole-life)")); + auto const features = cashBasis ? all_ | featureLendingProtocolV1_1 : all_; + Number const newDebtTotal = + principalOutstandingCash + (cashBasis ? Number{} : interestDueCash); + runBrokerGuard(features, newDebtTotal - oneDrop, tecLIMIT_EXCEEDED); + runBrokerGuard(features, newDebtTotal, tesSUCCESS); + } + } + + // 2. LoanPay: regular, late, overpayment, and full-payment types. + // Assert Vault.AssetsTotal/LoanBroker.DebtTotal deltas match + // interestPaid/principalPaid under cash-basis, and cross-check the + // amendment-disabled run's deltas against the documented whole-life + // formula (AssetsTotal += valueChange; DebtTotal mirrors the loan's own + // TotalValueOutstanding delta exactly, since whole-life debt recognition + // tracks total loan value). + void + testCashBasisLoanPay() + { + using namespace jtx; + using namespace loan; + using namespace std::chrono_literals; + using tp = NetClock::time_point; + + PrettyAsset const xrpAsset{xrpIssue(), 1'000'000}; + BrokerParameters const brokerParams{ + .vaultDeposit = 1'000'000, + .debtMax = 0, + .coverRateMin = TenthBips32{0}, + .coverDeposit = 0, + .managementFeeRate = TenthBips16{0}, + .coverRateLiquidation = TenthBips32{0}}; + + Number const principalRequest{12'000}; + TenthBips32 const interestRate{percentageToTenthBips(12)}; + std::uint32_t const paymentTotal = 4; + std::uint32_t const paymentInterval = 600; + std::uint32_t const gracePeriod = 300; + + struct PaymentDeltas + { + Number principalPaid; + Number assetsTotalDelta; + Number debtTotalDelta; + Number totalValueDelta; + }; + + // Sets up a fresh broker + loan, advances time, submits a single + // payment of the given type/amount, and returns the observed deltas. + auto runPayment = [&](FeatureBitset features, + std::uint32_t loanSetFlags, + std::uint32_t payFlags, + std::function const& advanceTime, + std::function const& paymentAmount) { + Env env(*this, features); + + Account const lender{"lender"}; + Account const borrower{"borrower"}; + env.fund(XRP(10'000'000), lender, borrower); + env.close(); + + BrokerInfo const broker{createVaultAndBroker(env, xrpAsset, lender, brokerParams)}; + + LoanParameters const loanParams{ + .account = borrower, + .counter = lender, + .principalRequest = principalRequest, + .interest = interestRate, + .payTotal = paymentTotal, + .payInterval = paymentInterval, + .gracePd = gracePeriod, + .flags = loanSetFlags, + }; + + auto const brokerBeforeLoan = env.le(broker.brokerKeylet()); + BEAST_EXPECT(brokerBeforeLoan); + auto const loanSequence = brokerBeforeLoan->at(sfLoanSequence); + auto const loanKeylet = keylet::loan(broker.brokerID, loanSequence); + + env(loanParams(env, broker)); + env.close(); + + LoanState const state = getCurrentState(env, broker, loanKeylet); + + advanceTime(env, state.startDate); + + auto const vaultBefore = env.le(broker.vaultKeylet()); + auto const brokerBefore = env.le(broker.brokerKeylet()); + auto const loanBefore = env.le(loanKeylet); + BEAST_EXPECT(vaultBefore && brokerBefore && loanBefore); + + Number const principalBefore = loanBefore->at(sfPrincipalOutstanding); + Number const totalValueBefore = loanBefore->at(sfTotalValueOutstanding); + Number const assetsTotalBefore = vaultBefore->at(sfAssetsTotal); + Number const debtTotalBefore = brokerBefore->at(sfDebtTotal); + + STAmount const amount = paymentAmount(state); + env(pay(borrower, loanKeylet.key, amount, payFlags), Ter(tesSUCCESS)); + env.close(); + + auto const vaultAfter = env.le(broker.vaultKeylet()); + auto const brokerAfter = env.le(broker.brokerKeylet()); + auto const loanAfter = env.le(loanKeylet); + BEAST_EXPECT(vaultAfter && brokerAfter && loanAfter); + + Number const principalAfter = loanAfter->at(sfPrincipalOutstanding); + Number const totalValueAfter = loanAfter->at(sfTotalValueOutstanding); + Number const assetsTotalAfter = vaultAfter->at(sfAssetsTotal); + Number const debtTotalAfter = brokerAfter->at(sfDebtTotal); + + return PaymentDeltas{ + .principalPaid = principalBefore - principalAfter, + .assetsTotalDelta = assetsTotalAfter - assetsTotalBefore, + .debtTotalDelta = debtTotalAfter - debtTotalBefore, + .totalValueDelta = totalValueAfter - totalValueBefore}; + }; + + // Compares the disabled (whole-life) and enabled (cash-basis) runs + // of the same payment scenario, and asserts the documented + // relationships between them. + auto checkScenario = [&](std::string const& label, + PaymentDeltas const& off, + PaymentDeltas const& on) { + testcase("cash-basis: LoanPay " + label); + + // The loan's own PrincipalOutstanding field is untouched by + // the amendment. + BEAST_EXPECTS( + off.principalPaid == on.principalPaid, + "principalPaid must be amendment-independent; off=" + to_string(off.principalPaid) + + " on=" + to_string(on.principalPaid)); + + // Whole-life structural invariant: DebtTotal (which + // recognizes a loan's full remaining value as debt) must + // change exactly as the loan's own TotalValueOutstanding + // does. + BEAST_EXPECTS( + off.debtTotalDelta == off.totalValueDelta, + "whole-life DebtTotal delta must mirror TotalValueOutstanding delta; " + "debtTotalDelta=" + + to_string(off.debtTotalDelta) + + " totalValueDelta=" + to_string(off.totalValueDelta)); + + // Derive interestPaid from the whole-life run's independent + // ledger deltas: + // assetsTotalDelta_off == valueChange + // debtTotalDelta_off == valueChange - (principalPaid + interestPaid) + // => interestPaid == assetsTotalDelta_off - debtTotalDelta_off - principalPaid + Number const interestPaid = + off.assetsTotalDelta - off.debtTotalDelta - off.principalPaid; + BEAST_EXPECTS( + interestPaid >= beast::kZero, + "derived interestPaid must be non-negative: " + to_string(interestPaid)); + + BEAST_EXPECTS( + on.assetsTotalDelta == interestPaid, + "cash-basis AssetsTotal delta must equal interestPaid; delta=" + + to_string(on.assetsTotalDelta) + " interestPaid=" + to_string(interestPaid)); + BEAST_EXPECTS( + on.debtTotalDelta == -on.principalPaid, + "cash-basis DebtTotal delta must equal -principalPaid; delta=" + + to_string(on.debtTotalDelta) + " principalPaid=" + to_string(on.principalPaid)); + }; + + // ---- Regular, on-time payment ---- + { + auto const noAdvance = [](Env& env, tp const&) { env.close(); }; + auto const regularAmount = [&](LoanState const& state) { + return STAmount{ + xrpAsset, + roundPeriodicPayment(xrpAsset, state.periodicPayment, state.loanScale) * + Number{3, -1} * 5}; // 1.5x, so only a single period is paid + }; + + auto const off = runPayment(all_, 0, 0, noAdvance, regularAmount); + auto const on = + runPayment(all_ | featureLendingProtocolV1_1, 0, 0, noAdvance, regularAmount); + + // Regular, on-time payments never change the loan's value beyond + // normal amortization (production asserts valueChange == 0), so + // AssetsTotal must be unaffected in the whole-life run. + BEAST_EXPECTS( + off.assetsTotalDelta == beast::kZero, + "regular on-time payment must not change AssetsTotal under whole-life; delta=" + + to_string(off.assetsTotalDelta)); + + checkScenario("regular payment", off, on); + } + + // ---- Late payment ---- + { + auto const advancePastDue = [&](Env& env, tp const& startDate) { + env.close(startDate + std::chrono::seconds(paymentInterval + 1)); + }; + auto const lateAmount = [&](LoanState const& state) { + return STAmount{ + xrpAsset, + roundPeriodicPayment(xrpAsset, state.periodicPayment, state.loanScale) * + Number{3}}; // generous; excess is not withdrawn + }; + + auto const off = runPayment(all_, 0, tfLoanLatePayment, advancePastDue, lateAmount); + auto const on = runPayment( + all_ | featureLendingProtocolV1_1, + 0, + tfLoanLatePayment, + advancePastDue, + lateAmount); + + checkScenario("late payment", off, on); + } + + // ---- Overpayment ---- + { + auto const noAdvance = [](Env& env, tp const&) { env.close(); }; + auto const overpayAmount = [&](LoanState const& state) { + // One regular period, plus a generous extra principal + // paydown. + return STAmount{ + xrpAsset, + roundPeriodicPayment(xrpAsset, state.periodicPayment, state.loanScale) + + xrpAsset(2'000).value()}; + }; + + auto const off = + runPayment(all_, tfLoanOverpayment, tfLoanOverpayment, noAdvance, overpayAmount); + auto const on = runPayment( + all_ | featureLendingProtocolV1_1, + tfLoanOverpayment, + tfLoanOverpayment, + noAdvance, + overpayAmount); + + checkScenario("overpayment", off, on); + } + + // ---- Full payment ---- + { + auto const noAdvance = [](Env& env, tp const&) { env.close(); }; + auto const fullAmount = [&](LoanState const&) { + // Generously large: full payment only ever consumes exactly + // what's due (principal + accrued interest; close fee/ + // prepayment penalty are 0 here), excess is not withdrawn. + return STAmount{xrpAsset, xrpAsset(principalRequest).value() * Number{2}}; + }; + + auto const off = runPayment(all_, 0, tfLoanFullPayment, noAdvance, fullAmount); + auto const on = runPayment( + all_ | featureLendingProtocolV1_1, 0, tfLoanFullPayment, noAdvance, fullAmount); + + checkScenario("full payment", off, on); + } + } + + // 3. LoanManage: impair, unimpair, and default. + void + testCashBasisLoanManage() + { + using namespace jtx; + using namespace loan; + using namespace std::chrono_literals; + + PrettyAsset const xrpAsset{xrpIssue(), 1'000'000}; + BrokerParameters const brokerParams{ + .vaultDeposit = 1'000'000, + .debtMax = 0, + .coverRateMin = TenthBips32{percentageToTenthBips(10)}, + .coverDeposit = 5'000, + .managementFeeRate = TenthBips16{0}, + .coverRateLiquidation = TenthBips32{percentageToTenthBips(25)}}; + + Number const principalRequest{10'000}; + TenthBips32 const interestRate{percentageToTenthBips(12)}; + std::uint32_t const paymentTotal = 4; + std::uint32_t const paymentInterval = 600; + std::uint32_t const gracePeriod = 60; + + auto setupLoan = [&](Env& env) { + Account const lender{"lender"}; + Account const borrower{"borrower"}; + env.fund(XRP(10'000'000), lender, borrower); + env.close(); + + BrokerInfo const broker{createVaultAndBroker(env, xrpAsset, lender, brokerParams)}; + + LoanParameters const loanParams{ + .account = borrower, + .counter = lender, + .principalRequest = principalRequest, + .interest = interestRate, + .payTotal = paymentTotal, + .payInterval = paymentInterval, + .gracePd = gracePeriod, + }; + + auto const brokerBeforeLoan = env.le(broker.brokerKeylet()); + BEAST_EXPECT(brokerBeforeLoan); + auto const loanSequence = brokerBeforeLoan->at(sfLoanSequence); + auto const loanKeylet = keylet::loan(broker.brokerID, loanSequence); + + env(loanParams(env, broker)); + env.close(); + + return std::make_tuple(broker, loanKeylet, lender, borrower); + }; + + // ---- impair / unimpair ---- + auto runImpairUnimpair = [&](FeatureBitset features) { + Env env(*this, features); + auto const [broker, loanKeylet, lender, borrower] = setupLoan(env); + + auto const loanBefore = env.le(loanKeylet); + BEAST_EXPECT(loanBefore); + Number const principalOutstanding = loanBefore->at(sfPrincipalOutstanding); + Number const totalValueOutstanding = loanBefore->at(sfTotalValueOutstanding); + Number const managementFeeOutstanding = loanBefore->at(sfManagementFeeOutstanding); + + Number const expectedExposure = + env.current()->rules().enabled(featureLendingProtocolV1_1) + ? principalOutstanding + : totalValueOutstanding - managementFeeOutstanding; + + auto const vaultBeforeImpair = env.le(broker.vaultKeylet()); + BEAST_EXPECT(vaultBeforeImpair); + Number const lossBefore = vaultBeforeImpair->at(sfLossUnrealized); + + env(manage(lender, loanKeylet.key, tfLoanImpair), Ter(tesSUCCESS)); + env.close(); + + auto const vaultAfterImpair = env.le(broker.vaultKeylet()); + BEAST_EXPECT(vaultAfterImpair); + Number const impairDelta = Number(vaultAfterImpair->at(sfLossUnrealized)) - lossBefore; + + env(manage(lender, loanKeylet.key, tfLoanUnimpair), Ter(tesSUCCESS)); + env.close(); + + auto const vaultAfterUnimpair = env.le(broker.vaultKeylet()); + BEAST_EXPECT(vaultAfterUnimpair); + Number const netDelta = Number(vaultAfterUnimpair->at(sfLossUnrealized)) - lossBefore; + + return std::make_tuple(expectedExposure, impairDelta, netDelta); + }; + + for (auto const features : {all_ | featureLendingProtocolV1_1, all_}) + { + testcase( + std::string("cash-basis: LoanManage impair/unimpair (") + + (features[featureLendingProtocolV1_1] ? "enabled)" : "disabled)")); + auto const [expectedExposure, impairDelta, netDelta] = runImpairUnimpair(features); + + BEAST_EXPECTS( + impairDelta == expectedExposure, + "impair must add loanVaultExposure to LossUnrealized; delta=" + + to_string(impairDelta) + " expected=" + to_string(expectedExposure)); + BEAST_EXPECTS( + netDelta == beast::kZero, + "unimpair must be an exact reversal of impair; net=" + to_string(netDelta)); + } + + // ---- impair, then default ---- + auto runDefault = [&](FeatureBitset features) { + Env env(*this, features); + auto const [broker, loanKeylet, lender, borrower] = setupLoan(env); + + auto const loanBeforeImpair = env.le(loanKeylet); + BEAST_EXPECT(loanBeforeImpair); + Number const principalOutstanding = loanBeforeImpair->at(sfPrincipalOutstanding); + Number const totalValueOutstanding = loanBeforeImpair->at(sfTotalValueOutstanding); + Number const managementFeeOutstanding = + loanBeforeImpair->at(sfManagementFeeOutstanding); + + Number const expectedExposure = + env.current()->rules().enabled(featureLendingProtocolV1_1) + ? principalOutstanding + : totalValueOutstanding - managementFeeOutstanding; + + env(manage(lender, loanKeylet.key, tfLoanImpair), Ter(tesSUCCESS)); + env.close(); + + LoanState const state = getCurrentState(env, broker, loanKeylet); + env.close( + state.startDate + std::chrono::seconds(paymentInterval) + + std::chrono::seconds(gracePeriod) + 60s); + + auto const vaultBefore = env.le(broker.vaultKeylet()); + auto const brokerBefore = env.le(broker.brokerKeylet()); + BEAST_EXPECT(vaultBefore && brokerBefore); + Number const assetsTotalBefore = vaultBefore->at(sfAssetsTotal); + Number const debtTotalBefore = brokerBefore->at(sfDebtTotal); + Number const lossBefore = vaultBefore->at(sfLossUnrealized); + Number const coverAvailableBefore = brokerBefore->at(sfCoverAvailable); + + env(manage(lender, loanKeylet.key, tfLoanDefault), Ter(tesSUCCESS)); + env.close(); + + auto const vaultAfter = env.le(broker.vaultKeylet()); + auto const brokerAfter = env.le(broker.brokerKeylet()); + BEAST_EXPECT(vaultAfter && brokerAfter); + Number const assetsTotalDelta = + Number(vaultAfter->at(sfAssetsTotal)) - assetsTotalBefore; + Number const debtTotalDelta = Number(brokerAfter->at(sfDebtTotal)) - debtTotalBefore; + Number const lossDelta = Number(vaultAfter->at(sfLossUnrealized)) - lossBefore; + Number const coverAvailableDelta = + Number(brokerAfter->at(sfCoverAvailable)) - coverAvailableBefore; + + Number const defaultCovered = -coverAvailableDelta; + Number const vaultDefaultAmount = expectedExposure - defaultCovered; + + return std::make_tuple( + expectedExposure, assetsTotalDelta, debtTotalDelta, lossDelta, vaultDefaultAmount); + }; + + for (auto const features : {all_ | featureLendingProtocolV1_1, all_}) + { + testcase( + std::string("cash-basis: LoanManage default (") + + (features[featureLendingProtocolV1_1] ? "enabled)" : "disabled)")); + auto const + [expectedExposure, + assetsTotalDelta, + debtTotalDelta, + lossDelta, + vaultDefaultAmount] = runDefault(features); + + BEAST_EXPECTS( + debtTotalDelta == -expectedExposure, + "default must reduce DebtTotal by the unified default amount; delta=" + + to_string(debtTotalDelta) + " expected=" + to_string(expectedExposure)); + BEAST_EXPECTS( + lossDelta == -expectedExposure, + "default must reverse the earlier impair's LossUnrealized exactly; delta=" + + to_string(lossDelta) + " expected=" + to_string(expectedExposure)); + BEAST_EXPECTS( + assetsTotalDelta == -vaultDefaultAmount, + "default must reduce AssetsTotal by (defaultAmount - defaultCovered); delta=" + + to_string(assetsTotalDelta) + " expected=" + to_string(-vaultDefaultAmount)); + } + } + + // 3b. LEVersion regression: a Vault created before featureLendingProtocolV1_1 + // activates (LEVersion absent) must keep whole-life (accrual) accounting + // forever, even after the amendment is later enabled -- the switch is + // per-Vault (LEVersion == VaultVersion::CashBasis), not a single global amendment + // flag. + void + testLegacyVaultKeepsAccrualAfterAmendmentEnabled() + { + testcase("LEVersion: legacy vault keeps accrual after amendment enabled"); + + using namespace jtx; + using namespace loan; + using namespace std::chrono_literals; + + PrettyAsset const xrpAsset{xrpIssue(), 1'000'000}; + BrokerParameters const brokerParams{ + .vaultDeposit = 1'000'000, + .debtMax = 0, + .coverRateMin = TenthBips32{percentageToTenthBips(10)}, + .coverDeposit = 5'000, + .managementFeeRate = TenthBips16{0}, + .coverRateLiquidation = TenthBips32{percentageToTenthBips(25)}}; + + Number const principalRequest{10'000}; + TenthBips32 const interestRate{percentageToTenthBips(12)}; + std::uint32_t const paymentTotal = 4; + std::uint32_t const paymentInterval = 600; + std::uint32_t const gracePeriod = 60; + + // Amendment disabled at Vault creation time: LEVersion stays absent. + Env env(*this, all_); + + Account const lender{"lender"}; + Account const borrower{"borrower"}; + env.fund(XRP(10'000'000), lender, borrower); + env.close(); + + BrokerInfo const broker{createVaultAndBroker(env, xrpAsset, lender, brokerParams)}; + + { + auto const vaultSle = env.le(broker.vaultKeylet()); + BEAST_EXPECT(vaultSle); + BEAST_EXPECT(!vaultSle->isFieldPresent(sfLEVersion)); + } + + // Now enable the amendment -- production dispatch must still treat + // this specific Vault as accrual-basis, since its LEVersion is + // (and remains) absent. + env.enableFeature(featureLendingProtocolV1_1); + env.close(); + + LoanParameters const loanParams{ + .account = borrower, + .counter = lender, + .principalRequest = principalRequest, + .interest = interestRate, + .payTotal = paymentTotal, + .payInterval = paymentInterval, + .gracePd = gracePeriod, + }; + + auto const brokerBeforeLoan = env.le(broker.brokerKeylet()); + BEAST_EXPECT(brokerBeforeLoan); + auto const loanSequence = brokerBeforeLoan->at(sfLoanSequence); + auto const loanKeylet = keylet::loan(broker.brokerID, loanSequence); + + // ---- LoanSet origination: whole-life formulas expected ---- + auto const vaultBeforeSet = env.le(broker.vaultKeylet()); + auto const brokerBeforeSet = env.le(broker.brokerKeylet()); + BEAST_EXPECT(vaultBeforeSet && brokerBeforeSet); + Number const assetsTotalBeforeSet = vaultBeforeSet->at(sfAssetsTotal); + Number const debtTotalBeforeSet = brokerBeforeSet->at(sfDebtTotal); + + env(loanParams(env, broker)); + env.close(); + + auto const loanAfterSet = env.le(loanKeylet); + BEAST_EXPECT(loanAfterSet); + Number const principalOutstanding = loanAfterSet->at(sfPrincipalOutstanding); + Number const totalValueOutstanding = loanAfterSet->at(sfTotalValueOutstanding); + Number const interestDue = totalValueOutstanding - principalOutstanding; + BEAST_EXPECT(interestDue > beast::kZero); + + auto const vaultAfterSet = env.le(broker.vaultKeylet()); + auto const brokerAfterSet = env.le(broker.brokerKeylet()); + BEAST_EXPECT(vaultAfterSet && brokerAfterSet); + Number const assetsTotalDeltaSet = + Number(vaultAfterSet->at(sfAssetsTotal)) - assetsTotalBeforeSet; + Number const debtTotalDeltaSet = + Number(brokerAfterSet->at(sfDebtTotal)) - debtTotalBeforeSet; + + BEAST_EXPECTS( + assetsTotalDeltaSet == interestDue, + "legacy vault origination must still add interestDue to AssetsTotal; delta=" + + to_string(assetsTotalDeltaSet) + " interestDue=" + to_string(interestDue)); + BEAST_EXPECTS( + debtTotalDeltaSet == principalOutstanding + interestDue, + "legacy vault origination must still add principal+interest to DebtTotal; delta=" + + to_string(debtTotalDeltaSet)); + + LoanState const state = getCurrentState(env, broker, loanKeylet); + env.close(); + + // ---- LoanPay: whole-life formulas expected ---- + auto const vaultBeforePay = env.le(broker.vaultKeylet()); + auto const brokerBeforePay = env.le(broker.brokerKeylet()); + auto const loanBeforePay = env.le(loanKeylet); + BEAST_EXPECT(vaultBeforePay && brokerBeforePay && loanBeforePay); + Number const totalValueBeforePay = loanBeforePay->at(sfTotalValueOutstanding); + Number const assetsTotalBeforePay = vaultBeforePay->at(sfAssetsTotal); + Number const debtTotalBeforePay = brokerBeforePay->at(sfDebtTotal); + + STAmount const paymentAmount{ + xrpAsset, roundPeriodicPayment(xrpAsset, state.periodicPayment, state.loanScale)}; + env(pay(borrower, loanKeylet.key, paymentAmount), Ter(tesSUCCESS)); + env.close(); + + auto const vaultAfterPay = env.le(broker.vaultKeylet()); + auto const brokerAfterPay = env.le(broker.brokerKeylet()); + auto const loanAfterPay = env.le(loanKeylet); + BEAST_EXPECT(vaultAfterPay && brokerAfterPay && loanAfterPay); + Number const totalValueAfterPay = loanAfterPay->at(sfTotalValueOutstanding); + Number const assetsTotalDeltaPay = + Number(vaultAfterPay->at(sfAssetsTotal)) - assetsTotalBeforePay; + Number const debtTotalDeltaPay = + Number(brokerAfterPay->at(sfDebtTotal)) - debtTotalBeforePay; + Number const totalValueDeltaPay = totalValueAfterPay - totalValueBeforePay; + + // A regular, on-time payment has valueChange == 0, so whole-life + // AssetsTotal is untouched and DebtTotal mirrors TotalValueOutstanding. + BEAST_EXPECTS( + assetsTotalDeltaPay == beast::kZero, + "legacy vault regular payment must not change AssetsTotal; delta=" + + to_string(assetsTotalDeltaPay)); + BEAST_EXPECTS( + debtTotalDeltaPay == totalValueDeltaPay, + "legacy vault DebtTotal delta must mirror TotalValueOutstanding delta; " + "debtTotalDelta=" + + to_string(debtTotalDeltaPay) + " totalValueDelta=" + to_string(totalValueDeltaPay)); + + // ---- LoanManage: impair, then default -- whole-life exposure expected ---- + auto const loanBeforeImpair = env.le(loanKeylet); + BEAST_EXPECT(loanBeforeImpair); + Number const totalValueBeforeImpair = loanBeforeImpair->at(sfTotalValueOutstanding); + Number const managementFeeBeforeImpair = loanBeforeImpair->at(sfManagementFeeOutstanding); + Number const expectedExposure = totalValueBeforeImpair - managementFeeBeforeImpair; + + env(manage(lender, loanKeylet.key, tfLoanImpair), Ter(tesSUCCESS)); + env.close(); + + LoanState const stateAtImpair = getCurrentState(env, broker, loanKeylet); + env.close( + stateAtImpair.startDate + std::chrono::seconds(paymentInterval) + + std::chrono::seconds(gracePeriod) + 60s); + + auto const vaultBeforeDefault = env.le(broker.vaultKeylet()); + auto const brokerBeforeDefault = env.le(broker.brokerKeylet()); + BEAST_EXPECT(vaultBeforeDefault && brokerBeforeDefault); + Number const debtTotalBeforeDefault = brokerBeforeDefault->at(sfDebtTotal); + Number const lossBeforeDefault = vaultBeforeDefault->at(sfLossUnrealized); + + env(manage(lender, loanKeylet.key, tfLoanDefault), Ter(tesSUCCESS)); + env.close(); + + auto const vaultAfterDefault = env.le(broker.vaultKeylet()); + auto const brokerAfterDefault = env.le(broker.brokerKeylet()); + BEAST_EXPECT(vaultAfterDefault && brokerAfterDefault); + Number const debtTotalDeltaDefault = + Number(brokerAfterDefault->at(sfDebtTotal)) - debtTotalBeforeDefault; + Number const lossDeltaDefault = + Number(vaultAfterDefault->at(sfLossUnrealized)) - lossBeforeDefault; + + BEAST_EXPECTS( + debtTotalDeltaDefault == -expectedExposure, + "legacy vault default must reduce DebtTotal by whole-life exposure; delta=" + + to_string(debtTotalDeltaDefault) + " expected=" + to_string(expectedExposure)); + BEAST_EXPECTS( + lossDeltaDefault == -expectedExposure, + "legacy vault default must reverse the earlier impair's LossUnrealized exactly; " + "delta=" + + to_string(lossDeltaDefault) + " expected=" + to_string(expectedExposure)); + + // Confirm the Vault's LEVersion truly never got set, throughout. + { + auto const vaultSle = env.le(broker.vaultKeylet()); + BEAST_EXPECT(vaultSle); + BEAST_EXPECT(!vaultSle->isFieldPresent(sfLEVersion)); + BEAST_EXPECT(getVaultVersion(vaultSle) == VaultVersion::Legacy); + } + } + + // 4. End-to-end trajectory: LoanSet -> 2 LoanPays -> LoanManage(default), + // entirely under the amendment, with independently hand-computed + // expected AssetsTotal/DebtTotal/LossUnrealized/CoverAvailable values at + // each step. 0% interest keeps the arithmetic exact and tractable; the + // divergence from whole-life accounting is already covered directly by + // testCashBasisLoanSetOrigination/LoanPay/LoanManage above, so this test + // focuses purely on an independent, from-scratch trajectory check. + void + testCashBasisEndToEndTrajectory() + { + testcase("cash-basis: end-to-end trajectory"); + + using namespace jtx; + using namespace loan; + using namespace std::chrono_literals; + + PrettyAsset const xrpAsset{xrpIssue(), 1'000'000}; + BrokerParameters const brokerParams{ + .vaultDeposit = 100'000, .managementFeeRate = TenthBips16{0}}; + + Env env(*this, all_ | featureLendingProtocolV1_1); + + Account const lender{"lender"}; + Account const borrower{"borrower"}; + env.fund(XRP(10'000'000), lender, borrower); + env.close(); + + BrokerInfo const broker{createVaultAndBroker(env, xrpAsset, lender, brokerParams)}; + + // Hand computation (all values in XRP, drops == 1e-6 XRP): + // Vault: AssetsTotal starts at 100'000 (the deposit). + // Broker: DebtTotal starts at 0, CoverAvailable starts at 1'000 + // (BrokerParameters::defaults().coverDeposit). + auto const vaultKeylet = broker.vaultKeylet(); + auto const brokerKeylet = broker.brokerKeylet(); + + // All the "human XRP unit" constants below (e.g. `100'000`) are + // converted to raw native (drops) values via xrpAsset(...), since + // that's how the ledger fields are actually denominated. + auto const checkVaultBroker = [&](Number const& assetsTotalUnits, + Number const& debtTotalUnits, + Number const& lossUnrealizedUnits, + Number const& coverAvailableUnits, + char const* step) { + Number const assetsTotal = xrpAsset(assetsTotalUnits).value(); + Number const debtTotal = xrpAsset(debtTotalUnits).value(); + Number const lossUnrealized = xrpAsset(lossUnrealizedUnits).value(); + Number const coverAvailable = xrpAsset(coverAvailableUnits).value(); + + auto const vaultSle = env.le(vaultKeylet); + auto const brokerSle = env.le(brokerKeylet); + BEAST_EXPECT(vaultSle && brokerSle); + BEAST_EXPECTS( + vaultSle->at(sfAssetsTotal) == assetsTotal, + std::string(step) + ": AssetsTotal expected " + to_string(assetsTotal) + " got " + + to_string(Number(vaultSle->at(sfAssetsTotal)))); + BEAST_EXPECTS( + brokerSle->at(sfDebtTotal) == debtTotal, + std::string(step) + ": DebtTotal expected " + to_string(debtTotal) + " got " + + to_string(Number(brokerSle->at(sfDebtTotal)))); + BEAST_EXPECTS( + vaultSle->at(sfLossUnrealized) == lossUnrealized, + std::string(step) + ": LossUnrealized expected " + to_string(lossUnrealized) + + " got " + to_string(Number(vaultSle->at(sfLossUnrealized)))); + BEAST_EXPECTS( + brokerSle->at(sfCoverAvailable) == coverAvailable, + std::string(step) + ": CoverAvailable expected " + to_string(coverAvailable) + + " got " + to_string(Number(brokerSle->at(sfCoverAvailable)))); + }; + + checkVaultBroker(100'000, 0, 0, 1'000, "before LoanSet"); + + // Loan: principal=1200, 0% interest, 12 payments of 100 each, no fees. + Number const principalRequest{1'200}; + std::uint32_t const paymentTotal = 12; + std::uint32_t const paymentInterval = 600; + std::uint32_t const gracePeriod = 60; + + auto const brokerBeforeLoan = env.le(brokerKeylet); + BEAST_EXPECT(brokerBeforeLoan); + auto const loanSequence = brokerBeforeLoan->at(sfLoanSequence); + auto const loanKeylet = keylet::loan(broker.brokerID, loanSequence); + + LoanParameters const loanParams{ + .account = borrower, + .counter = lender, + .principalRequest = principalRequest, + .interest = TenthBips32{0}, + .payTotal = paymentTotal, + .payInterval = paymentInterval, + .gracePd = gracePeriod, + }; + env(loanParams(env, broker)); + env.close(); + + // Origination (cash-basis): AssetsTotal += 0, DebtTotal += principal. + checkVaultBroker(100'000, 1'200, 0, 1'000, "after LoanSet"); + + LoanState const state = getCurrentState(env, broker, loanKeylet); + BEAST_EXPECT(state.periodicPayment == xrpAsset(100).value()); + + // Payment 1: principalPaid=100, interestPaid=0. + // AssetsTotal += 0; DebtTotal -= 100. + env(pay(borrower, loanKeylet.key, xrpAsset(100).value()), Ter(tesSUCCESS)); + env.close(); + checkVaultBroker(100'000, 1'100, 0, 1'000, "after payment 1"); + + // Payment 2: same as above. + env(pay(borrower, loanKeylet.key, xrpAsset(100).value()), Ter(tesSUCCESS)); + env.close(); + checkVaultBroker(100'000, 1'000, 0, 1'000, "after payment 2"); + + // Default (no impair): principalOutstanding remaining is 1'000. + // totalDefaultAmount (cash-basis) = PrincipalOutstanding = 1'000. + // minimumCover = DebtTotal(1'000) * coverRateMin(10%) = 100. + // covered = min(minimumCover * coverRateLiquidation(25%), totalDefaultAmount) + // = min(25, 1'000) = 25. + // defaultCovered = min(covered, CoverAvailable(1'000)) = 25. + // vaultDefaultAmount = 1'000 - 25 = 975. + // DebtTotal -= 1'000 -> 0. CoverAvailable -= 25 -> 975. + // AssetsTotal -= 975 -> 99'025. LossUnrealized unaffected (never impaired). + auto const loanBeforeDefault = env.le(loanKeylet); + BEAST_EXPECT(loanBeforeDefault); + BEAST_EXPECT( + Number(loanBeforeDefault->at(sfPrincipalOutstanding)) == xrpAsset(1'000).value()); + + env.close(state.startDate + std::chrono::seconds((3 * paymentInterval) + gracePeriod) + 1s); + + env(manage(lender, loanKeylet.key, tfLoanDefault), Ter(tesSUCCESS)); + env.close(); + + checkVaultBroker(99'025, 0, 0, 975, "after LoanManage(default)"); + } + +public: + void + run() override + { + testCashBasisLoanSetOrigination(); + testCashBasisLoanPay(); + testCashBasisLoanManage(); + testLegacyVaultKeepsAccrualAfterAmendmentEnabled(); + testCashBasisEndToEndTrajectory(); + } +}; + +BEAST_DEFINE_TESTSUITE(LoanCashBasis, tx, xrpl); + +} // namespace xrpl::test diff --git a/src/test/app/LoanInvariant_test.cpp b/src/test/app/LoanInvariant_test.cpp new file mode 100644 index 00000000000..fac71a55fa7 --- /dev/null +++ b/src/test/app/LoanInvariant_test.cpp @@ -0,0 +1,1903 @@ +#include + +namespace xrpl::test { + +class LoanInvariant_test : public LoanTestBase +{ +private: + // Each of these regression tests reproduces a single fuzzer-found (FIND-*) + // scenario against xrpl::detail::computePeriodicPayment / + // loanComputePaymentParts. They're merged into one function, one block + // per finding, because each is a narrow, self-contained repro that + // shares little beyond the surrounding scaffold. + void + testLoanPayComputePeriodicPaymentInvariants(FeatureBitset features) + { + using namespace jtx; + using namespace std::chrono_literals; + using namespace Lending; + + // From FIND-012 + { + testcase << "LoanPay xrpl::detail::computePeriodicPayment : " + "valid rate"; + + Env env(*this, features); + + Account const issuer{"issuer"}; + Account const lender{"lender"}; + Account const borrower{"borrower"}; + + BrokerParameters const brokerParams; + env.fund(XRP(brokerParams.vaultDeposit * 100), issuer, lender, borrower); + env.close(); + + PrettyAsset const xrpAsset{xrpIssue(), 1'000'000}; + BrokerInfo const broker{createVaultAndBroker(env, xrpAsset, lender, brokerParams)}; + + using namespace loan; + + auto const loanSetFee = Fee(env.current()->fees().base * 2); + Number const principalRequest{640562, -5}; + + Number const serviceFee{2462611968}; + std::uint32_t const numPayments{4294967295 / 800}; + + auto createJson = env.json( + set(borrower, broker.brokerID, principalRequest), + Fee(loanSetFee), + kLoanServiceFee(serviceFee), + kPaymentTotal(numPayments), + Json(sfCounterpartySignature, json::ValueType::Object)); + + createJson["CloseInterestRate"] = 55374; + createJson["ClosePaymentFee"] = "3825205248"; + createJson["LatePaymentFee"] = "237"; + createJson["LoanOriginationFee"] = "0"; + createJson["OverpaymentFee"] = 35167; + createJson["OverpaymentInterestRate"] = 1360; + createJson["PaymentInterval"] = 727; + + auto const keylet = nextLoanKeylet(env, broker); + + createJson = env.json(createJson, Sig(sfCounterpartySignature, lender)); + // Fails in preclaim because principal requested can't be + // represented as XRP + env(createJson, Ter(tecPRECISION_LOSS)); + env.close(); + + BEAST_EXPECT(!env.le(keylet)); + + Number const actualPrincipal{6}; + + createJson[sfPrincipalRequested] = actualPrincipal; + createJson.removeMember(sfSequence.jsonName); + createJson = env.json(createJson, Sig(sfCounterpartySignature, lender)); + // Fails in doApply because the payment is too small to be + // represented as XRP. + env(createJson, Ter(tecPRECISION_LOSS)); + env.close(); + } + + // From FIND-010 + { + testcase << "xrpl::loanComputePaymentParts : valid total interest"; + + Env env(*this, features); + + Account const issuer{"issuer"}; + Account const lender{"lender"}; + Account const borrower{"borrower"}; + + PrettyAsset const iouAsset = createFundedIouAsset(env, issuer, lender, borrower); + + BrokerInfo const broker{createVaultAndBroker(env, iouAsset, lender)}; + + using namespace loan; + + auto const loanSetFee = Fee(env.current()->fees().base * 2); + Number const principalRequest{1, 3}; + + auto createJson = env.json( + set(borrower, broker.brokerID, principalRequest), + Fee(loanSetFee), + Json(sfCounterpartySignature, json::ValueType::Object)); + + createJson["CloseInterestRate"] = 47299; + createJson["ClosePaymentFee"] = "3985819770"; + createJson["InterestRate"] = 92; + createJson["LatePaymentFee"] = "3866894865"; + createJson["LoanOriginationFee"] = "0"; + createJson["LoanServiceFee"] = "2348810240"; + createJson["OverpaymentFee"] = 58545; + createJson["PaymentInterval"] = 60; + createJson["PaymentTotal"] = 1; + createJson["PrincipalRequested"] = "0.000763058"; + + auto const keylet = nextLoanKeylet(env, broker); + + createJson = env.json(createJson, Sig(sfCounterpartySignature, lender)); + env(createJson); + env.close(); + + auto loanPayTx = env.json(pay(borrower, keylet.key, STAmount{broker.asset, Number{}})); + loanPayTx["Amount"]["value"] = "0.000281284125490196"; + env(loanPayTx, Ter(tecINSUFFICIENT_PAYMENT)); + env.close(); + } + + // From FIND-009 + { + testcase << "xrpl::loanComputePaymentParts : totalPrincipalPaid " + "rounded"; + + Env env(*this, features); + + Account const issuer{"issuer"}; + Account const lender{"lender"}; + Account const borrower{"borrower"}; + + PrettyAsset const iouAsset = createFundedIouAsset(env, issuer, lender, borrower); + + BrokerInfo const broker{createVaultAndBroker(env, iouAsset, lender)}; + + using namespace loan; + + auto const loanSetFee = Fee(env.current()->fees().base * 2); + Number const principalRequest{1, 3}; + + auto createJson = env.json( + set(borrower, broker.brokerID, principalRequest), + Fee(loanSetFee), + Json(sfCounterpartySignature, json::ValueType::Object)); + + createJson["ClosePaymentFee"] = "0"; + createJson["InterestRate"] = 24346; + createJson["LateInterestRate"] = 65535; + createJson["LatePaymentFee"] = "0"; + createJson["LoanOriginationFee"] = "218"; + createJson["LoanServiceFee"] = "0"; + createJson["PaymentInterval"] = 60; + createJson["PaymentTotal"] = 5678; + createJson["PrincipalRequested"] = "9924.81"; + + auto const keylet = nextLoanKeylet(env, broker); + + createJson = env.json(createJson, Sig(sfCounterpartySignature, lender)); + env(createJson, Ter(tesSUCCESS)); + env.close(); + + auto const baseFee = env.current()->fees().base; + + auto const stateBefore = getCurrentState(env, broker, keylet); + + { + auto loanPayTx = + env.json(pay(borrower, keylet.key, STAmount{broker.asset, Number{}})); + Number const amount{3074'745'058'823'529, -12}; + BEAST_EXPECT(to_string(amount) == "3074.745058823529"); + XRPAmount const payFee{ + baseFee * + (amount / stateBefore.periodicPayment / kLoanPaymentsPerFeeIncrement + 1)}; + loanPayTx["Amount"]["value"] = to_string(amount); + env(loanPayTx, Fee(payFee), Ter(tesSUCCESS)); + env.close(); + } + + { + auto loanPayTx = + env.json(pay(borrower, keylet.key, STAmount{broker.asset, Number{}})); + Number const amount{6732'118'170'944'051, -12}; + BEAST_EXPECT(to_string(amount) == "6732.118170944051"); + XRPAmount const payFee{ + baseFee * + (amount / stateBefore.periodicPayment / kLoanPaymentsPerFeeIncrement + 1)}; + loanPayTx["Amount"]["value"] = to_string(amount); + env(loanPayTx, Fee(payFee), Ter(tesSUCCESS)); + env.close(); + } + + auto const stateAfter = getCurrentState(env, broker, keylet); + // Total interest outstanding is non-negative + BEAST_EXPECT(stateAfter.totalValue >= stateAfter.principalOutstanding); + // Principal paid is non-negative + BEAST_EXPECT(stateBefore.principalOutstanding >= stateAfter.principalOutstanding); + // Total value change is non-negative + BEAST_EXPECT(stateBefore.totalValue >= stateAfter.totalValue); + // Value delta is larger or same as principal delta (meaning + // non-negative interest paid) + BEAST_EXPECT( + (stateBefore.totalValue - stateAfter.totalValue) >= + (stateBefore.principalOutstanding - stateAfter.principalOutstanding)); + } + + // From FIND-008 + { + testcase << "xrpl::loanComputePaymentParts : loanValueChange rounded"; + + Env env(*this, features); + + Account const issuer{"issuer"}; + Account const lender{"lender"}; + Account const borrower{"borrower"}; + + PrettyAsset const iouAsset = + createFundedIouAsset(env, issuer, lender, borrower, 100'000'000, 10'000'000); + + BrokerInfo const broker{createVaultAndBroker(env, iouAsset, lender)}; + { + auto const coverDepositValue = + broker.asset(broker.params.coverDeposit * 10).value(); + env(loanBroker::coverDeposit(lender, broker.brokerID, coverDepositValue)); + env.close(); + } + + using namespace loan; + + auto const loanSetFee = Fee(env.current()->fees().base * 2); + Number const principalRequest{1, 3}; + + auto createJson = env.json( + set(borrower, broker.brokerID, principalRequest), + Fee(loanSetFee), + Json(sfCounterpartySignature, json::ValueType::Object)); + + createJson["ClosePaymentFee"] = "0"; + createJson["InterestRate"] = 12833; + createJson["LateInterestRate"] = 77048; + createJson["LatePaymentFee"] = "0"; + createJson["LoanOriginationFee"] = "218"; + createJson["LoanServiceFee"] = "0"; + createJson["PaymentInterval"] = 752; + createJson["PaymentTotal"] = 5678; + createJson["PrincipalRequested"] = "9924.81"; + + auto const keylet = nextLoanKeylet(env, broker); + + createJson = env.json(createJson, Sig(sfCounterpartySignature, lender)); + env(createJson, Ter(tesSUCCESS)); + env.close(); + + auto const baseFee = env.current()->fees().base; + + auto const stateBefore = getCurrentState(env, broker, keylet); + BEAST_EXPECT(stateBefore.paymentRemaining == 5678); + BEAST_EXPECT(stateBefore.paymentRemaining > kLoanMaximumPaymentsPerTransaction); + + auto loanPayTx = env.json(pay(borrower, keylet.key, STAmount{broker.asset, Number{}})); + Number const amount{9924'81, -2}; + BEAST_EXPECT(to_string(amount) == "9924.81"); + XRPAmount const payFee{ + baseFee * + (amount / stateBefore.periodicPayment / kLoanPaymentsPerFeeIncrement + 1)}; + loanPayTx["Amount"]["value"] = to_string(amount); + env(loanPayTx, Fee(payFee), Ter(tesSUCCESS)); + env.close(); + + auto const stateAfter = getCurrentState(env, broker, keylet); + BEAST_EXPECT( + stateAfter.paymentRemaining == + stateBefore.paymentRemaining - kLoanMaximumPaymentsPerTransaction); + } + } + + void + testAccountSendMptMinAmountInvariant(FeatureBitset features) + { + // (From FIND-006) + testcase << "LoanSet trigger xrpl::accountSendMPT : minimum amount " + "and MPT"; + + using namespace jtx; + using namespace std::chrono_literals; + Env env(*this, features); + + Account const issuer{"issuer"}; + Account const lender{"lender"}; + Account const borrower{"borrower"}; + + env.fund(XRP(1'000'000), issuer, lender, borrower); + env.close(); + + MPTTester mptt{env, issuer, kMptInitNoFund}; + mptt.create({.flags = tfMPTCanClawback | tfMPTCanTransfer | tfMPTCanLock}); + PrettyAsset const mptAsset = mptt.issuanceID(); + mptt.authorize({.account = lender}); + mptt.authorize({.account = borrower}); + env(pay(issuer, lender, mptAsset(2'000'000))); + env(pay(issuer, borrower, mptAsset(1'000))); + env.close(); + + BrokerInfo const broker{createVaultAndBroker(env, mptAsset, lender)}; + + using namespace loan; + + auto const loanSetFee = Fee(env.current()->fees().base * 2); + Number const principalRequest{1, 3}; + + auto createJson = env.json( + set(borrower, broker.brokerID, principalRequest), + Fee(loanSetFee), + Json(sfCounterpartySignature, json::ValueType::Object)); + + createJson["CloseInterestRate"] = 76671; + createJson["ClosePaymentFee"] = "2061925410"; + createJson["GracePeriod"] = 434; + createJson["InterestRate"] = 50302; + createJson["LateInterestRate"] = 30322; + createJson["LatePaymentFee"] = "294427911"; + createJson["LoanOriginationFee"] = "3250635102"; + createJson["LoanServiceFee"] = "9557386"; + createJson["OverpaymentFee"] = 51249; + createJson["OverpaymentInterestRate"] = 14304; + createJson["PaymentInterval"] = 434; + createJson["PaymentTotal"] = "2891743748"; + createJson["PrincipalRequested"] = "8516.98"; + + auto const brokerStateBefore = env.le(keylet::loanBroker(broker.brokerID)); + + createJson = env.json(createJson, Sig(sfCounterpartySignature, lender)); + env(createJson, Ter(temINVALID)); + env.close(); + } + + void + testLoanPayDebtDecreaseInvariant(FeatureBitset features) + { + // From FIND-007 + testcase << "LoanPay xrpl::LoanPay::doApply : debtDecrease " + "rounding good"; + + using namespace jtx; + using namespace std::chrono_literals; + using namespace Lending; + Env env(*this, features); + + Account const issuer{"issuer"}; + Account const lender{"lender"}; + Account const borrower{"borrower"}; + + PrettyAsset const iouAsset = createFundedIouAsset(env, issuer, lender, borrower); + + BrokerInfo broker{createVaultAndBroker(env, iouAsset, lender)}; + + using namespace loan; + + auto const baseFee = env.current()->fees().base; + auto const loanSetFee = Fee(baseFee * 2); + Number const principalRequest{1, 3}; + + auto createJson = env.json( + set(borrower, broker.brokerID, principalRequest), + Fee(loanSetFee), + Json(sfCounterpartySignature, json::ValueType::Object)); + + createJson["ClosePaymentFee"] = "0"; + createJson["GracePeriod"] = 60; + createJson["InterestRate"] = 24346; + createJson["LateInterestRate"] = 65535; + createJson["LatePaymentFee"] = "0"; + createJson["LoanOriginationFee"] = "218"; + createJson["LoanServiceFee"] = "0"; + createJson["PaymentInterval"] = 60; + createJson["PaymentTotal"] = 5678; + createJson["PrincipalRequested"] = "9924.81"; + + auto const keylet = nextLoanKeylet(env, broker); + + createJson = env.json(createJson, Sig(sfCounterpartySignature, lender)); + env(createJson, Ter(tesSUCCESS)); + env.close(); + + auto const pseudoAcct = brokerPseudoAccount(env, broker, lender); + + VerifyLoanStatus const verifyLoanStatus(env, broker, pseudoAcct, keylet); + auto const originalState = getCurrentState(env, broker, keylet); + verifyLoanStatus(originalState); + + Number const payment{3'269'349'176'470'588, -12}; + XRPAmount const payFee{ + baseFee * + ((payment / originalState.periodicPayment) / kLoanPaymentsPerFeeIncrement + 1)}; + auto loanPayTx = + env.json(pay(borrower, keylet.key, STAmount{broker.asset, payment}), Fee(payFee)); + BEAST_EXPECT(to_string(payment) == "3269.349176470588"); + env(loanPayTx, Ter(tesSUCCESS)); + env.close(); + + auto const newState = getCurrentState(env, broker, keylet); + BEAST_EXPECT( + isRounded(broker.asset, newState.managementFeeOutstanding, originalState.loanScale)); + BEAST_EXPECT(newState.managementFeeOutstanding < originalState.managementFeeOutstanding); + BEAST_EXPECT(isRounded(broker.asset, newState.totalValue, originalState.loanScale)); + BEAST_EXPECT( + isRounded(broker.asset, newState.principalOutstanding, originalState.loanScale)); + } + + void + testDosLoanPay(FeatureBitset features) + { + bool const feeCapped = features[fixCleanup3_1_3]; + + // From FIND-005 + testcase << "DoS LoanPay: fee calculation " << (feeCapped ? "capped" : "uncapped"); + + using namespace jtx; + using namespace std::chrono_literals; + using namespace Lending; + Env env(*this, features); + + Account const issuer{"issuer"}; + Account const lender{"lender"}; + Account const borrower{"borrower"}; + + env.fund(XRP(1'000'000), issuer, lender, borrower); + env.close(); + + BEAST_EXPECT(feeCapped == env.current()->rules().enabled(fixCleanup3_1_3)); + + PrettyAsset const iouAsset = issuer[iouCurrency_]; + env(trust(lender, iouAsset(100'000'000))); + env(trust(borrower, iouAsset(100'000'000))); + env(pay(issuer, lender, iouAsset(10'000'000))); + env(pay(issuer, borrower, iouAsset(1'000))); + env.close(); + + BrokerInfo const broker{createVaultAndBroker(env, iouAsset, lender)}; + + using namespace loan; + + auto const loanSetFee = Fee(env.current()->fees().base * 2); + Number const principalRequest{3959'37, -2}; + auto const baseFee = env.current()->fees().base; + + auto const createJson = env.json( + set(borrower, broker.brokerID, principalRequest), + Fee(loanSetFee), + Json(sfCounterpartySignature, json::ValueType::Object), + kClosePaymentFee(0), + kGracePeriod(60), + kInterestRate(TenthBips32(20930)), + kLateInterestRate(TenthBips32(77049)), + kLatePaymentFee(0), + kLoanServiceFee(0), + kOverpaymentFee(TenthBips32(7)), + kOverpaymentInterestRate(TenthBips32(66653)), + kPaymentInterval(60), + kPaymentTotal(3239184)); + + // There are enough payments due on this loan that it only needs to be + // created once, and can be paid on multiple times. Just don't create a + // gazillion test cases. + auto const keylet = nextLoanKeylet(env, broker); + + env(createJson, Sig(sfCounterpartySignature, lender)); + env.close(); + + auto const roundedPayment = [&]() { + auto const stateBefore = getCurrentState(env, broker, keylet); + BEAST_EXPECT(stateBefore.paymentRemaining == 3239184); + BEAST_EXPECT(stateBefore.paymentRemaining > kLoanMaximumPaymentsPerTransaction); + + return roundToAsset( + iouAsset, + stateBefore.periodicPayment, + stateBefore.loanScale, + Number::RoundingMode::Upward); + }(); + + auto test = [&](int const payFactor, + int const feeFactor, + TER const expectedTer = tesSUCCESS) { + auto const stateBefore = getCurrentState(env, broker, keylet); + BEAST_EXPECT(stateBefore.paymentRemaining <= 3239184); + BEAST_EXPECT(stateBefore.paymentRemaining > kLoanMaximumPaymentsPerTransaction); + + Number const amount = roundedPayment * payFactor; + auto loanPayTx = env.json(pay(borrower, keylet.key, STAmount{broker.asset, amount})); + XRPAmount const payFee{baseFee * feeFactor}; + env(loanPayTx, Ter(expectedTer), Fee(payFee)); + env.close(); + auto const expectedChange = isTesSuccess(expectedTer) + ? std::min(kLoanMaximumPaymentsPerTransaction, payFactor) + : 0; + + auto const stateAfter = getCurrentState(env, broker, keylet); + BEAST_EXPECT( + stateAfter.paymentRemaining == stateBefore.paymentRemaining - expectedChange); + }; + + static constexpr std::int64_t kMaxFeeIncrements = + kLoanMaximumPaymentsPerTransaction / kLoanPaymentsPerFeeIncrement; + + TER const failWithoutFix = feeCapped ? (TER)tesSUCCESS : (TER)telINSUF_FEE_P; + + // * Amount well above threshold -> capped fee + // The original test case - way over the limit - more fee is always ok + test(1819878, 363976); + // The capped fee is only sufficient if the amendment is enabled. + test(1819878, kMaxFeeIncrements, failWithoutFix); + + // * Amount exactly at threshold -> capped fee + test(kLoanMaximumPaymentsPerTransaction, kMaxFeeIncrements); + // More fee is always ok + test(kLoanMaximumPaymentsPerTransaction, kMaxFeeIncrements + 10); + + // * Amount below threshold -> normal calculation + test(1, 1); + test(kLoanPaymentsPerFeeIncrement * 2, 2); + test(0, 0, temBAD_AMOUNT); + test(0, 1, temBAD_AMOUNT); + // Fee difference rounds evenly + test( + kLoanMaximumPaymentsPerTransaction - 10, + ((kLoanMaximumPaymentsPerTransaction - 10) / kLoanPaymentsPerFeeIncrement) - 1, + telINSUF_FEE_P); + test( + kLoanMaximumPaymentsPerTransaction - 10, + ((kLoanMaximumPaymentsPerTransaction - 10) / kLoanPaymentsPerFeeIncrement)); + // More fee is always ok + test( + kLoanMaximumPaymentsPerTransaction - 10, + ((kLoanMaximumPaymentsPerTransaction - 10) / kLoanPaymentsPerFeeIncrement) + 3); + // Fee rounds up + for (int under = 1; under < kLoanPaymentsPerFeeIncrement; ++under) + { + test(kLoanMaximumPaymentsPerTransaction - under, kMaxFeeIncrements - 1, telINSUF_FEE_P); + test(kLoanMaximumPaymentsPerTransaction - under, kMaxFeeIncrements); + } + // Only when you get one less fee increment can you pay less + test( + kLoanMaximumPaymentsPerTransaction - kLoanPaymentsPerFeeIncrement, + kMaxFeeIncrements - 1); + // And again, more fee is always ok. + test(kLoanMaximumPaymentsPerTransaction - kLoanPaymentsPerFeeIncrement, kMaxFeeIncrements); + } + + void + testLoanNextPaymentDueDateOverflow(FeatureBitset features) + { + // For FIND-013 + testcase << "Prevent nextPaymentDueDate overflow"; + + using namespace jtx; + using namespace std::chrono_literals; + using namespace Lending; + Env env{*this, features}; + + Account const issuer{"issuer"}; + Account const lender{"lender"}; + Account const borrower{"borrower"}; + + PrettyAsset const iouAsset = + createFundedIouAsset(env, issuer, lender, borrower, 100'000'000, 10'000'000); + + BrokerParameters const brokerParams{.debtMax = Number{0}, .coverRateMin = TenthBips32{1}}; + BrokerInfo broker{createVaultAndBroker(env, iouAsset, lender, brokerParams)}; + + using namespace loan; + + auto const loanSetFee = Fee(env.current()->fees().base * 2); + + using timeType = decltype(sfNextPaymentDueDate)::type::value_type; + static_assert(std::is_same_v); + constexpr timeType kMaxTime = std::numeric_limits::max(); + static_assert(kMaxTime == 4'294'967'295); + + auto const baseJson = [&]() { + auto createJson = env.json( + set(borrower, broker.brokerID, Number{55524'81, -2}), + Fee(loanSetFee), + kClosePaymentFee(0), + kGracePeriod(LoanSet::kDefaultGracePeriod), + kInterestRate(TenthBips32(12833)), + kLateInterestRate(TenthBips32(77048)), + kLatePaymentFee(0), + kLoanOriginationFee(218), + Json(sfCounterpartySignature, json::ValueType::Object)); + + createJson.removeMember(sfSequence.getJsonName()); + + return createJson; + }(); + + auto const baseFee = env.current()->fees().base; + + auto parentCloseTime = [&]() { + return env.current()->parentCloseTime().time_since_epoch().count(); + }; + auto maxLoanTime = [&]() { + auto const startDate = parentCloseTime(); + + BEAST_EXPECT(startDate >= 50); + + return kMaxTime - startDate; + }; + + { + // straight-up overflow: interval + auto const interval = maxLoanTime() + 1; + auto const total = 1; + auto createJson = env.json(baseJson, kPaymentInterval(interval), kPaymentTotal(total)); + + env(createJson, Sig(sfCounterpartySignature, lender), Ter(tecKILLED)); + env.close(); + } + { + // straight-up overflow: total + // min interval is 60 + auto const interval = 60; + auto const total = maxLoanTime() + 1; + auto createJson = env.json(baseJson, kPaymentInterval(interval), kPaymentTotal(total)); + + env(createJson, Sig(sfCounterpartySignature, lender), Ter(tecKILLED)); + env.close(); + } + { + // straight-up overflow: grace period + // min interval is 60 + auto const interval = maxLoanTime() + 1; + auto const total = 1; + auto const grace = interval; + auto createJson = env.json( + baseJson, kPaymentInterval(interval), kPaymentTotal(total), kGracePeriod(grace)); + + // The grace period can't be larger than the interval. + env(createJson, Sig(sfCounterpartySignature, lender), Ter(tecKILLED)); + env.close(); + } + { + // Overflow with multiplication of a few large intervals + auto const interval = 1'000'000'000; + auto const total = 10; + auto createJson = env.json(baseJson, kPaymentInterval(interval), kPaymentTotal(total)); + + env(createJson, Sig(sfCounterpartySignature, lender), Ter(tecKILLED)); + env.close(); + } + { + // Overflow with multiplication of many small payments + // min interval is 60 + auto const interval = 60; + auto const total = 1'000'000'000; + auto createJson = env.json(baseJson, kPaymentInterval(interval), kPaymentTotal(total)); + + env(createJson, Sig(sfCounterpartySignature, lender), Ter(tecKILLED)); + env.close(); + } + { + // Overflow with an absurdly large grace period + // min interval is 60 + auto const total = 60; + auto const interval = (maxLoanTime() - total) / total; + auto const grace = interval; + auto createJson = env.json( + baseJson, kPaymentInterval(interval), kPaymentTotal(total), kGracePeriod(grace)); + + env(createJson, Sig(sfCounterpartySignature, lender), Ter(tecKILLED)); + env.close(); + } + { + // Start date when the ledger is closed will be larger + auto const keylet = nextLoanKeylet(env, broker); + + auto const grace = 100; + auto const interval = maxLoanTime() - grace; + auto const total = 1; + auto createJson = env.json( + baseJson, kPaymentInterval(interval), kPaymentTotal(total), kGracePeriod(grace)); + + env(createJson, Sig(sfCounterpartySignature, lender), Ter(tesSUCCESS)); + env.close(); + + // The transaction is killed in the closed ledger + auto const meta = env.meta(); + if (BEAST_EXPECT(meta)) + { + BEAST_EXPECT(meta->at(sfTransactionResult) == tecKILLED); + } + + // If the transaction had succeeded, the loan would exist + auto const loanSle = env.le(keylet); + // but it doesn't + BEAST_EXPECT(!loanSle); + } + { + // Start date when the ledger is closed will be larger + auto const keylet = nextLoanKeylet(env, broker); + + auto const closeStartDate = ((parentCloseTime() / 10) + 1) * 10; + auto const grace = 5'000; + auto const interval = kMaxTime - closeStartDate - grace; + auto const total = 1; + auto createJson = env.json( + baseJson, kPaymentInterval(interval), kPaymentTotal(total), kGracePeriod(grace)); + + env(createJson, Sig(sfCounterpartySignature, lender), Ter(tesSUCCESS)); + env.close(); + + // The transaction succeeds in the closed ledger + auto const meta = env.meta(); + if (BEAST_EXPECT(meta)) + { + BEAST_EXPECT(meta->at(sfTransactionResult) == tesSUCCESS); + } + + // This loan exists + auto const afterState = getCurrentState(env, broker, keylet); + BEAST_EXPECT(afterState.nextPaymentDate == kMaxTime - grace); + BEAST_EXPECT(afterState.previousPaymentDate == 0); + BEAST_EXPECT(afterState.paymentRemaining == 1); + } + + { + // Ensure the borrower has funds to pay back the loan + env(pay(issuer, borrower, iouAsset(Number{1'055'524'81, -2}))); + + // Start date when the ledger is closed will be larger + auto const closeStartDate = ((parentCloseTime() / 10) + 1) * 10; + auto const grace = 5'000; + auto const maxLoanTime = kMaxTime - closeStartDate - grace; + auto const total = [&]() { + if (maxLoanTime % 5 == 0) + return 5; + if (maxLoanTime % 3 == 0) + return 3; + if (maxLoanTime % 2 == 0) + return 2; + return 0; + }(); + if (!BEAST_EXPECT(total != 0)) + return; + + auto const brokerState = env.le(keylet::loanBroker(broker.brokerID)); + // Intentionally shadow the outer values + auto const loanSequence = brokerState->at(sfLoanSequence); + auto const keylet = keylet::loan(broker.brokerID, loanSequence); + + auto const interval = maxLoanTime / total; + auto createJson = env.json( + baseJson, kPaymentInterval(interval), kPaymentTotal(total), kGracePeriod(grace)); + + env(createJson, Sig(sfCounterpartySignature, lender), Ter(tesSUCCESS)); + env.close(); + + // This loan exists + auto const beforeState = getCurrentState(env, broker, keylet); + BEAST_EXPECT(beforeState.nextPaymentDate == closeStartDate + interval); + BEAST_EXPECT(beforeState.previousPaymentDate == 0); + BEAST_EXPECT(beforeState.paymentRemaining == total); + BEAST_EXPECT(beforeState.periodicPayment > 0); + + // pay all but the last payment + { + NumberRoundModeGuard const mg{Number::RoundingMode::Upward}; + Number const payment = beforeState.periodicPayment * (total - 1); + XRPAmount const payFee{baseFee * ((total - 1) / kLoanPaymentsPerFeeIncrement + 1)}; + STAmount const paymentAmount = + roundToScale(STAmount{broker.asset, payment}, beforeState.loanScale); + auto loanPayTx = env.json(pay(borrower, keylet.key, paymentAmount), Fee(payFee)); + env(loanPayTx, Ter(tesSUCCESS)); + env.close(); + } + + // The loan is on the last payment + auto const afterState = getCurrentState(env, broker, keylet); + BEAST_EXPECT(afterState.paymentRemaining == 1); + BEAST_EXPECT(afterState.nextPaymentDate == kMaxTime - grace); + BEAST_EXPECT(afterState.previousPaymentDate == kMaxTime - grace - interval); + } + } + +#if LOAN_TODO + void + testLoanPayLateFullPaymentBypassesPenalties(FeatureBitset features) + { + testcase("LoanPay full payment skips late penalties"); + using namespace jtx; + using namespace loan; + using namespace std::chrono_literals; + + Env env(*this, features); + + Account const issuer{"issuer"}; + Account const lender{"lender"}; + Account const borrower{"borrower"}; + + env.fund(XRP(1'000'000), issuer, lender, borrower); + env.close(); + + PrettyAsset const asset = issuer[iouCurrency]; + env(trust(lender, asset(100'000'000))); + env(trust(borrower, asset(100'000'000))); + env(pay(issuer, lender, asset(50'000'000))); + env(pay(issuer, borrower, asset(5'000'000))); + env.close(); + + BrokerInfo broker{createVaultAndBroker(env, asset, lender)}; + + auto const loanSetFee = Fee(env.current()->fees().base * 2); + + auto const brokerPreLoan = env.le(keylet::loanBroker(broker.brokerID)); + if (BEAST_EXPECT(brokerPreLoan); !brokerPreLoan.has_value()) + return; + + auto const loanSequence = brokerPreLoan->at(sfLoanSequence); + auto const loanKeylet = keylet::loan(broker.brokerID, loanSequence); + + Number const principal = asset(1'000).value(); + Number const serviceFee = asset(2).value(); + Number const lateFee = asset(5).value(); + Number const closeFee = asset(4).value(); + + env(set(borrower, broker.brokerID, principal), + Sig(sfCounterpartySignature, lender), + kLoanServiceFee(serviceFee), + kLatePaymentFee(lateFee), + kClosePaymentFee(closeFee), + kInterestRate(percentageToTenthBips(12)), + kLateInterestRate(percentageToTenthBips(24) / 10), + kCloseInterestRate(percentageToTenthBips(5)), + kPaymentTotal(12), + kPaymentInterval(600), + kGracePeriod(0), + Fee(loanSetFee)); + env.close(); + + auto state1 = getCurrentState(env, broker, loanKeylet); + if (!BEAST_EXPECT(state1.paymentRemaining > 1)) + return; + + using d = NetClock::duration; + using tp = NetClock::time_point; + auto const overdueClose = tp{d{state1.nextPaymentDate + state1.paymentInterval}}; + env.close(overdueClose); + + auto const brokerSle = env.le(keylet::loanBroker(broker.brokerID)); + auto const loanSle = env.le(loanKeylet); + if (!BEAST_EXPECT(brokerSle && loanSle)) + return; + + auto state = getCurrentState(env, broker, loanKeylet); + + TenthBips16 const managementFeeRate{brokerSle->at(sfManagementFeeRate)}; + TenthBips32 const interestRateValue{loanSle->at(sfInterestRate)}; + TenthBips32 const lateInterestRateValue{loanSle->at(sfLateInterestRate)}; + TenthBips32 const closeInterestRateValue{loanSle->at(sfCloseInterestRate)}; + + Number const closePaymentFeeRounded = + roundToAsset(broker.asset, loanSle->at(sfClosePaymentFee), state.loanScale); + Number const latePaymentFeeRounded = + roundToAsset(broker.asset, loanSle->at(sfLatePaymentFee), state.loanScale); + + auto const roundedLoanState = constructLoanState( + state.totalValue, state.principalOutstanding, state.managementFeeOutstanding); + Number const totalInterestOutstanding = roundedLoanState.interestDue; + + auto const periodicRate = loanPeriodicRate(interestRateValue, state.paymentInterval); + auto const rawLoanState = computeTheoreticalLoanState( + env.current()->rules(), + state.periodicPayment, + periodicRate, + state.paymentRemaining, + managementFeeRate); + + auto const parentCloseTime = env.current()->parentCloseTime(); + auto const startDateSeconds = + static_cast(state.startDate.time_since_epoch().count()); + + Number const fullPaymentInterest = computeFullPaymentInterest( + rawLoanState.principalOutstanding, + periodicRate, + parentCloseTime, + state.paymentInterval, + state.previousPaymentDate, + startDateSeconds, + closeInterestRateValue); + + Number const roundedFullInterestAmount = + roundToAsset(broker.asset, fullPaymentInterest, state.loanScale); + Number const roundedFullManagementFee = computeManagementFee( + broker.asset, roundedFullInterestAmount, managementFeeRate, state.loanScale); + Number const roundedFullInterest = roundedFullInterestAmount - roundedFullManagementFee; + + Number const trackedValueDelta = + state.principalOutstanding + totalInterestOutstanding + state.managementFeeOutstanding; + Number const untrackedManagementFee = + closePaymentFeeRounded + roundedFullManagementFee - state.managementFeeOutstanding; + Number const untrackedInterest = roundedFullInterest - totalInterestOutstanding; + + Number const baseFullDue = trackedValueDelta + untrackedInterest + untrackedManagementFee; + BEAST_EXPECT(baseFullDue == roundToAsset(broker.asset, baseFullDue, state.loanScale)); + + auto const overdueSeconds = + parentCloseTime.time_since_epoch().count() - state.nextPaymentDate; + if (!BEAST_EXPECT(overdueSeconds > 0)) + return; + + Number const overdueRate = loanPeriodicRate(lateInterestRateValue, overdueSeconds); + Number const lateInterestRaw = state.principalOutstanding * overdueRate; + Number const lateInterestRounded = + roundToAsset(broker.asset, lateInterestRaw, state.loanScale); + Number const lateManagementFeeRounded = computeManagementFee( + broker.asset, lateInterestRounded, managementFeeRate, state.loanScale); + Number const penaltyDue = + lateInterestRounded + lateManagementFeeRounded + latePaymentFeeRounded; + BEAST_EXPECT(penaltyDue > Number{}); + + auto const balanceBefore = env.balance(borrower, broker.asset).number(); + + STAmount const paymentAmount{broker.asset.raw(), baseFullDue}; + env(pay(borrower, loanKeylet.key, paymentAmount, tfLoanFullPayment)); + env.close(); + + if (auto const meta = env.meta(); BEAST_EXPECT(meta)) + BEAST_EXPECT(meta->at(sfTransactionResult) == tesSUCCESS); + + auto const balanceAfter = env.balance(borrower, broker.asset).number(); + Number const actualPaid = balanceBefore - balanceAfter; + BEAST_EXPECT(actualPaid == baseFullDue); + + Number const expectedWithPenalty = baseFullDue + penaltyDue; + BEAST_EXPECT(expectedWithPenalty > actualPaid); + BEAST_EXPECT(expectedWithPenalty - actualPaid == penaltyDue); + } + + void + testLoanCoverMinimumRoundingExploit(FeatureBitset features) + { + auto testLoanCoverMinimumRoundingExploit = [&, this](Number const& principalRequest) { + testcase << "LoanBrokerCoverClawback drains cover via rounding" + << " principalRequested=" << to_string(principalRequest); + + using namespace jtx; + using namespace loan; + using namespace loanBroker; + + Env env(*this, features); + + Account const issuer{"issuer"}; + Account const lender{"lender"}; + Account const borrower{"borrower"}; + + env.fund(XRP(1'000'000'000), issuer, lender, borrower); + env.close(); + + env(fset(issuer, asfAllowTrustLineClawback)); + env.close(); + + PrettyAsset const asset = issuer[iouCurrency]; + env(trust(lender, asset(2'000'0000))); + env(trust(borrower, asset(2'000'0000))); + env.close(); + + env(pay(issuer, lender, asset(2'000'0000))); + env.close(); + + BrokerParameters brokerParams{.debtMax = 0, .coverRateMin = TenthBips32{10'000}}; + BrokerInfo broker{createVaultAndBroker(env, asset, lender, brokerParams)}; + + auto const loanSetFee = Fee(env.current()->fees().base * 2); + auto createTx = env.jt( + set(borrower, broker.brokerID, principalRequest), + Sig(sfCounterpartySignature, lender), + loanSetFee, + kPaymentInterval(600), + kPaymentTotal(1), + kGracePeriod(60)); + env(createTx); + env.close(); + + auto const brokerBefore = env.le(keylet::loanBroker(broker.brokerID)); + BEAST_EXPECT(brokerBefore); + if (!brokerBefore) + return; + + Number const debtOutstanding = brokerBefore->at(sfDebtTotal); + Number const coverAvailableBefore = brokerBefore->at(sfCoverAvailable); + + BEAST_EXPECT(debtOutstanding > Number{}); + BEAST_EXPECT(coverAvailableBefore > Number{}); + + log << "debt=" << to_string(debtOutstanding) + << " cover_available=" << to_string(coverAvailableBefore); + + env(coverClawback(issuer, 0), loanBrokerID(broker.brokerID)); + env.close(); + + auto const brokerAfter = env.le(keylet::loanBroker(broker.brokerID)); + BEAST_EXPECT(brokerAfter); + if (!brokerAfter) + return; + + Number const debtAfter = brokerAfter->at(sfDebtTotal); + // the debt has not changed + BEAST_EXPECT(debtAfter == debtOutstanding); + + Number const coverAvailableAfter = brokerAfter->at(sfCoverAvailable); + + // since the cover rate min != 0, the cover available should not + // be zero + BEAST_EXPECT(coverAvailableAfter != Number{}); + }; + + // Call the lambda with different principal values + testLoanCoverMinimumRoundingExploit(Number{1, -30}); // 1e-30 units + testLoanCoverMinimumRoundingExploit(Number{1, -20}); // 1e-20 units + testLoanCoverMinimumRoundingExploit(Number{1, -10}); // 1e-10 units + testLoanCoverMinimumRoundingExploit(Number{1, 1}); // 1e-10 units + } +#endif + + void + testPoCUnsignedUnderflowOnFullPayAfterEarlyPeriodic(FeatureBitset features) + { + // --- PoC Summary ---------------------------------------------------- + // Scenario: Borrower makes one periodic payment early (before next due) + // so doPayment sets sfPreviousPaymentDueDate to the (future) + // sfNextPaymentDueDate and advances sfNextPaymentDueDate by one + // interval. Borrower then immediately performs a full-payment + // (tfLoanFullPayment). Why it matters: Full-payment interest accrual + // uses + // delta = now - max(prevPaymentDate, startDate) + // with an unsigned clock representation (uint32). If prevPaymentDate is + // in the future, the subtraction underflows to a very large positive + // number. This inflates roundedFullInterest and total full-close due, + // and LoanPay applies the inflated valueChange to the vault + // (sfAssetsTotal), increasing NAV. + // -------------------------------------------------------------------- + testcase("PoC: Unsigned-underflow full-pay accrual after early periodic"); + + using namespace jtx; + using namespace loan; + using namespace std::chrono_literals; + + Env env{*this, features}; + + Account const lender{"poc_lender4"}; + Account const borrower{"poc_borrower4"}; + env.fund(XRP(3'000'000), lender, borrower); + env.close(); + + PrettyAsset const asset{xrpIssue(), 1'000'000}; + BrokerParameters const brokerParams{}; + auto const broker = createVaultAndBroker(env, asset, lender, brokerParams); + + // Create a 3-payment loan so full-payment path is enabled after 1 + // periodic payment. + auto const loanSetFee = Fee(env.current()->fees().base * 2); + Number const principalRequest = asset(1000).value(); + auto const originationFee = asset(0).value(); + auto const serviceFee = asset(1).value(); + auto const serviceFeePA = asset(1); + auto const lateFee = asset(0).value(); + auto const closeFee = asset(0).value(); + auto const interest = percentageToTenthBips(12); + auto const lateInterest = percentageToTenthBips(12) / 10; + auto const closeInterest = percentageToTenthBips(12) / 10; + auto const overpaymentInterest = percentageToTenthBips(12) / 10; + auto const total = 3u; + auto const interval = 600u; + auto const grace = 60u; + + auto createJtx = env.jt( + set(borrower, broker.brokerID, principalRequest, 0), + Sig(sfCounterpartySignature, lender), + kLoanOriginationFee(originationFee), + kLoanServiceFee(serviceFee), + kLatePaymentFee(lateFee), + kClosePaymentFee(closeFee), + kOverpaymentFee(percentageToTenthBips(5) / 10), + kInterestRate(interest), + kLateInterestRate(lateInterest), + kCloseInterestRate(closeInterest), + kOverpaymentInterestRate(overpaymentInterest), + kPaymentTotal(total), + kPaymentInterval(interval), + kGracePeriod(grace), + Fee(loanSetFee)); + + auto const brokerSle = env.le(keylet::loanBroker(broker.brokerID)); + BEAST_EXPECT(brokerSle); + auto const loanSequence = brokerSle ? brokerSle->at(sfLoanSequence) : 0; + auto const loanKeylet = keylet::loan(broker.brokerID, loanSequence); + + env(createJtx); + env.close(); + + // Compute a regular periodic due and pay it early (before next due). + auto state = getCurrentState(env, broker, loanKeylet); + Number const periodicRate = loanPeriodicRate(state.interestRate, state.paymentInterval); + auto const components = xrpl::detail::computePaymentComponents( + env.current()->rules(), + asset.raw(), + state.loanScale, + state.totalValue, + state.principalOutstanding, + state.managementFeeOutstanding, + state.periodicPayment, + periodicRate, + state.paymentRemaining, + brokerParams.managementFeeRate); + STAmount const regularDue{asset, components.trackedValueDelta + serviceFeePA.number()}; + // now < nextDue immediately after creation, so this is an early pay. + env(pay(borrower, loanKeylet.key, regularDue)); + env.close(); + + // Immediately attempt a full payoff. Compute the exact full-payment + // due to ensure the tx applies. + auto after = getCurrentState(env, broker, loanKeylet); + auto const loanSle = env.le(loanKeylet); + BEAST_EXPECT(loanSle); + auto const brokerSle2 = env.le(keylet::loanBroker(broker.brokerID)); + BEAST_EXPECT(brokerSle2); + + auto const closePaymentFee = loanSle ? loanSle->at(sfClosePaymentFee) : Number{}; + auto const closeInterestRate = + loanSle ? TenthBips32{loanSle->at(sfCloseInterestRate)} : TenthBips32{}; + auto const managementFeeRate = + brokerSle2 ? TenthBips16{brokerSle2->at(sfManagementFeeRate)} : TenthBips16{}; + + Number const periodicRate2 = loanPeriodicRate(after.interestRate, after.paymentInterval); + // Accrued + prepayment-penalty interest based on current periodic + // schedule + auto const fullPaymentInterest = computeFullPaymentInterest( + xrpl::detail::loanPrincipalFromPeriodicPayment( + env.current()->rules(), + after.periodicPayment, + periodicRate2, + after.paymentRemaining), + periodicRate2, + env.current()->parentCloseTime(), + after.paymentInterval, + after.previousPaymentDate, + static_cast(after.startDate.time_since_epoch().count()), + closeInterestRate); + + // Round to asset scale and split interest/fee parts + auto const roundedInterest = + roundToAsset(asset.raw(), fullPaymentInterest, after.loanScale); + Number const roundedFullMgmtFee = + computeManagementFee(asset.raw(), roundedInterest, managementFeeRate, after.loanScale); + Number const roundedFullInterest = roundedInterest - roundedFullMgmtFee; + + // Show both signed and unsigned deltas to highlight the underflow. + auto const nowSecs = + static_cast(env.current()->parentCloseTime().time_since_epoch().count()); + auto const startSecs = + static_cast(after.startDate.time_since_epoch().count()); + auto const lastPaymentDate = std::max(after.previousPaymentDate, startSecs); + auto const signedDelta = + static_cast(nowSecs) - static_cast(lastPaymentDate); + auto const unsignedDelta = static_cast(nowSecs - lastPaymentDate); + log << "PoC window: prev=" << after.previousPaymentDate << " start=" << startSecs + << " now=" << nowSecs << " signedDelta=" << signedDelta + << " unsignedDelta=" << unsignedDelta << std::endl; + + // Reference (clamped) computation: emulate a non-negative accrual + // window by clamping prevPaymentDate to 'now' for the full-pay path. + auto const prevClamped = std::min(after.previousPaymentDate, nowSecs); + auto const fullPaymentInterestClamped = computeFullPaymentInterest( + xrpl::detail::loanPrincipalFromPeriodicPayment( + env.current()->rules(), + after.periodicPayment, + periodicRate2, + after.paymentRemaining), + periodicRate2, + env.current()->parentCloseTime(), + after.paymentInterval, + prevClamped, + startSecs, + closeInterestRate); + auto const roundedInterestClamped = + roundToAsset(asset.raw(), fullPaymentInterestClamped, after.loanScale); + Number const roundedFullMgmtFeeClamped = computeManagementFee( + asset.raw(), roundedInterestClamped, managementFeeRate, after.loanScale); + Number const roundedFullInterestClamped = + roundedInterestClamped - roundedFullMgmtFeeClamped; + STAmount const fullDueClamped{ + asset, + after.principalOutstanding + roundedFullInterestClamped + roundedFullMgmtFeeClamped + + closePaymentFee}; + + // Collect vault NAV before closing payment + auto const vaultId2 = brokerSle2 ? brokerSle2->at(sfVaultID) : uint256{}; + auto const vaultKey2 = keylet::vault(vaultId2); + auto const vaultBefore = env.le(vaultKey2); + BEAST_EXPECT(vaultBefore); + Number const assetsTotalBefore = vaultBefore ? vaultBefore->at(sfAssetsTotal) : Number{}; + + STAmount const fullDue{ + asset, + after.principalOutstanding + roundedFullInterest + roundedFullMgmtFee + + closePaymentFee}; + + log << "PoC payoff: principalOutstanding=" << after.principalOutstanding + << " roundedFullInterest=" << roundedFullInterest + << " roundedFullMgmtFee=" << roundedFullMgmtFee << " closeFee=" << closePaymentFee + << " fullDue=" << to_string(fullDue.getJson()) << std::endl; + log << "PoC reference (clamped): roundedFullInterestClamped=" << roundedFullInterestClamped + << " roundedFullMgmtFeeClamped=" << roundedFullMgmtFeeClamped + << " fullDueClamped=" << to_string(fullDueClamped.getJson()) << std::endl; + + env(pay(borrower, loanKeylet.key, fullDue), Txflags(tfLoanFullPayment)); + env.close(); + + // Sanity: underflow present (unsigned delta very large relative to + // interval) + BEAST_EXPECT(unsignedDelta > after.paymentInterval); + + // Compare vault NAV before/after the full close + auto const vaultAfter = env.le(vaultKey2); + BEAST_EXPECT(vaultAfter); + if (vaultAfter) + { + auto const assetsTotalAfter = vaultAfter->at(sfAssetsTotal); + log << "PoC NAV: assetsTotalBefore=" << assetsTotalBefore + << " assetsTotalAfter=" << assetsTotalAfter + << " delta=" << (assetsTotalAfter - assetsTotalBefore) << std::endl; + + // Value-based proof: underflowed window yields a payoff larger than + // the clamped (non-underflow) reference. + BEAST_EXPECT(fullDue == fullDueClamped); + if (fullDue > fullDueClamped) + log << "PoC delta: overcharge (fullDue > clamped)" << std::endl; + } + + // Loan should be paid off + auto const finalLoan = env.le(loanKeylet); + BEAST_EXPECT(finalLoan); + if (finalLoan) + { + BEAST_EXPECT(finalLoan->at(sfPaymentRemaining) == 0); + BEAST_EXPECT(finalLoan->at(sfPrincipalOutstanding) == 0); + } + } + + void + testDustManipulation(FeatureBitset features) + { + testcase("Dust manipulation"); + + using namespace jtx; + using namespace std::chrono_literals; + Env env{*this, features}; + + // Setup: Create accounts + Account const issuer{"issuer"}; + Account const lender{"lender"}; + Account const borrower{"borrower"}; + Account const victim{"victim"}; + + env.fund(XRP(1'000'000'00), issuer, lender, borrower, victim); + env.close(); + + // Step 1: Create vault with IOU asset + auto asset = issuer["USD"]; + env(trust(lender, asset(100000))); + env(trust(borrower, asset(100000))); + env(trust(victim, asset(100000))); + env(pay(issuer, lender, asset(50000))); + env(pay(issuer, borrower, asset(50000))); + env(pay(issuer, victim, asset(50000))); + env.close(); + + BrokerParameters const brokerParams{ + .vaultDeposit = 10000, + .debtMax = Number{0}, + .coverRateMin = TenthBips32{1000}, + .coverRateLiquidation = TenthBips32{2500}}; + + auto broker = createVaultAndBroker(env, asset, lender, brokerParams); + + auto const loanKeyletOpt = [&]() -> std::optional { + auto const brokerSle = env.le(keylet::loanBroker(broker.brokerID)); + if (!BEAST_EXPECT(brokerSle)) + return std::nullopt; + + // Broker has no loans + BEAST_EXPECT(brokerSle->at(sfOwnerCount) == 0); + + // The loan keylet is based on the LoanSequence of the + // _LOAN_BROKER_ object. + auto const loanSequence = brokerSle->at(sfLoanSequence); + return keylet::loan(broker.brokerID, loanSequence); + }(); + if (!loanKeyletOpt) + return; + + auto const& vaultKeylet = broker.vaultKeylet(); + + { + auto const vaultSle = env.le(vaultKeylet); + Number const assetsTotal = vaultSle->at(sfAssetsTotal); + Number const assetsAvail = vaultSle->at(sfAssetsAvailable); + + log << "Before loan creation:" << std::endl; + log << " AssetsTotal: " << assetsTotal << std::endl; + log << " AssetsAvailable: " << assetsAvail << std::endl; + log << " Difference: " << (assetsTotal - assetsAvail) << std::endl; + + // before the loan the assets total and available should be equal + BEAST_EXPECT(assetsAvail == assetsTotal); + BEAST_EXPECT(assetsAvail == broker.asset(brokerParams.vaultDeposit).number()); + } + + Keylet const& loanKeylet = *loanKeyletOpt; + + LoanParameters const loanParams{ + .account = lender, + .counter = borrower, + .principalRequest = Number{100}, + .interest = TenthBips32{1922}, + .payTotal = 5816, + .payInterval = 86400 * 6, + .gracePd = 86400 * 5, + }; + + env(loanParams(env, broker)); + env.close(); + + // Wait for loan to be late enough to default + env.close(std::chrono::seconds(86400 * 40)); // 40 days + + { + auto const vaultSle = env.le(vaultKeylet); + Number const assetsTotal = vaultSle->at(sfAssetsTotal); + Number const assetsAvail = vaultSle->at(sfAssetsAvailable); + + log << "After loan creation:" << std::endl; + log << " AssetsTotal: " << assetsTotal << std::endl; + log << " AssetsAvailable: " << assetsAvail << std::endl; + log << " Difference: " << (assetsTotal - assetsAvail) << std::endl; + + auto const loanSle = env.le(loanKeylet); + if (!BEAST_EXPECT(loanSle)) + return; + auto const state = constructLoanState(loanSle); + + log << "Loan state:" << std::endl; + log << " ValueOutstanding: " << state.valueOutstanding << std::endl; + log << " PrincipalOutstanding: " << state.principalOutstanding << std::endl; + log << " InterestOutstanding: " << state.interestOutstanding() << std::endl; + log << " InterestDue: " << state.interestDue << std::endl; + log << " FeeDue: " << state.managementFeeDue << std::endl; + + // after loan creation the assets total and available should + // reflect the value of the loan + BEAST_EXPECT(assetsAvail < assetsTotal); + BEAST_EXPECT( + assetsAvail == + broker.asset(brokerParams.vaultDeposit - loanParams.principalRequest).number()); + BEAST_EXPECT( + assetsTotal == + broker.asset(brokerParams.vaultDeposit + state.interestDue).number()); + } + + // Step 7: Trigger default (dust adjustment will occur) + env(jtx::loan::manage(lender, loanKeylet.key, tfLoanDefault)); + env.close(); + + // Step 8: Verify phantom assets created + { + auto const vaultSle2 = env.le(vaultKeylet); + Number const assetsTotal2 = vaultSle2->at(sfAssetsTotal); + Number const assetsAvail2 = vaultSle2->at(sfAssetsAvailable); + + log << "After default:" << std::endl; + log << " AssetsTotal: " << assetsTotal2 << std::endl; + log << " AssetsAvailable: " << assetsAvail2 << std::endl; + log << " Difference: " << (assetsTotal2 - assetsAvail2) << std::endl; + + // after a default the assets total and available should be equal + BEAST_EXPECT(assetsAvail2 == assetsTotal2); + } + } + + void + testRoundingAllowsUndercoverage(FeatureBitset features) + { + testcase("Minimum cover rounding allows undercoverage (XRP)"); + + using namespace jtx; + using namespace loanBroker; + + Env env{*this, features}; + + Account const lender{"lender"}; + Account const borrower{"borrower"}; + + env.fund(XRP(200'000), lender, borrower); + env.close(); + + // Vault with XRP asset + Vault const vault{env}; + auto [vaultCreate, vaultKeylet] = vault.create({.owner = lender, .asset = xrpIssue()}); + env(vaultCreate); + env.close(); + BEAST_EXPECT(env.le(vaultKeylet)); + + // Seed the vault with XRP so it can fund the loan principal + PrettyAsset const xrpAsset{xrpIssue(), 1}; + + BrokerParameters const brokerParams{ + .vaultDeposit = 1'000, + .debtMax = Number{0}, + .coverRateMin = TenthBips32{10'000}, + .coverDeposit = 82, + }; + + auto const brokerInfo = createVaultAndBroker(env, xrpAsset, lender, brokerParams); + // Create a loan with principal 804 XRP and 0% interest (so + // DebtTotal increases by exactly 804) + env(loan::set(borrower, brokerInfo.brokerID, xrpAsset(804).value()), + loan::kInterestRate(TenthBips32(0)), + Sig(sfCounterpartySignature, lender), + Fee(env.current()->fees().base * 2)); + BEAST_EXPECT(env.ter() == tesSUCCESS); + env.close(); + + // Verify DebtTotal is exactly 804 + if (auto const brokerSle = env.le(keylet::loanBroker(brokerInfo.brokerID)); + BEAST_EXPECT(brokerSle)) + { + log << *brokerSle << std::endl; + BEAST_EXPECT(brokerSle->at(sfDebtTotal) == Number(804)); + } + + // Attempt to withdraw 2 XRP to self, leaving 80 XRP CoverAvailable. + // The minimum is 80.4 XRP, which rounds up to 81 XRP, so this fails. + env(coverWithdraw(lender, brokerInfo.brokerID, xrpAsset(2).value()), + Ter(tecINSUFFICIENT_FUNDS)); + BEAST_EXPECT(env.ter() == tecINSUFFICIENT_FUNDS); + env.close(); + + // Attempt to withdraw 1 XRP to self, leaving 81 XRP CoverAvailable. + // because that leaves sufficient cover, this succeeds + env(coverWithdraw(lender, brokerInfo.brokerID, xrpAsset(1).value())); + BEAST_EXPECT(env.ter() == tesSUCCESS); + env.close(); + + // Validate CoverAvailable == 80 XRP and DebtTotal remains 804 + if (auto const brokerSle = env.le(keylet::loanBroker(brokerInfo.brokerID)); + BEAST_EXPECT(brokerSle)) + { + log << *brokerSle << std::endl; + BEAST_EXPECT(brokerSle->at(sfCoverAvailable) == xrpAsset(81).value()); + BEAST_EXPECT(brokerSle->at(sfDebtTotal) == Number(804)); + + // Also demonstrate that the true minimum (804 * 10%) exceeds 80 + auto const theoreticalMin = tenthBipsOfValue(Number(804), TenthBips32(10'000)); + log << "Theoretical min cover: " << theoreticalMin << std::endl; + BEAST_EXPECT(Number(804, -1) == theoreticalMin); + } + } + + void + testSequentialFLCDepletion(FeatureBitset features) + { + testcase << "First-Loss Capital Depletion on Sequential Defaults"; + + using namespace jtx; + using namespace loan; + using namespace loanBroker; + + Env env{*this, features}; + + Account const issuer{"issuer"}; + Account const lender{"lender"}; + Account const borrowerA{"borrowerA"}; + Account const borrowerB{"borrowerB"}; + + env.fund(XRP(1'000'000), issuer, lender, borrowerA, borrowerB); + env.close(); + + PrettyAsset const asset = xrpIssue(); + auto const vaultDepositAmount = + asset(200'000); // Enough for 2 x 50k loans plus interest/fees + + auto const brokerInfo = createVaultAndBroker( + env, + asset, + lender, + { + .vaultDeposit = vaultDepositAmount.value(), + .debtMax = 0, + .coverRateMin = TenthBips32(20000), // 20% + .coverDeposit = 21'000, + .managementFeeRate = TenthBips16(100), // 0.1% + .coverRateLiquidation = TenthBips32(100000), + }); + auto const brokerKeylet = brokerInfo.brokerKeylet(); + + // Create two identical loans: each 50,000 XRP principal (scaled down to + // avoid funding issues) Total DebtTotal will be ~100,000 XRP (principal + // + interest) Formula will calculate cover as: 100% × (20% × 100,000) = + // 20,000 XRP So we need FLC = 20,000 XRP to be fully consumed by first + // default + auto const principalAmount = Number(50'000); + auto const loanPaymentInterval = 2592000; // 30 days + auto const loanGracePeriod = 604800; // 7 days + + // Create Loan A + auto loanATx = env.jt( + set(borrowerA, brokerKeylet.key, principalAmount), + Sig(sfCounterpartySignature, lender), + kInterestRate(TenthBips32(500)), // 5% + kPaymentTotal(12), + loan::kPaymentInterval(loanPaymentInterval), + loan::kGracePeriod(loanGracePeriod), + Fee(XRP(10))); // Sufficient fee for multi-sig transaction + env(loanATx); + env.close(); + + auto const loanAKeylet = keylet::loan(brokerKeylet.key, 1); + + // Create Loan B + auto loanBTx = env.jt( + set(borrowerB, brokerKeylet.key, principalAmount), + Sig(sfCounterpartySignature, lender), + kInterestRate(TenthBips32(500)), // 5% + kPaymentTotal(12), + loan::kPaymentInterval(loanPaymentInterval), + loan::kGracePeriod(loanGracePeriod), + Fee(XRP(10))); // Sufficient fee for multi-sig transaction + env(loanBTx); + env.close(); + + auto const loanBKeylet = keylet::loan(brokerKeylet.key, 2); + + auto loanASle = env.le(loanAKeylet); + if (!BEAST_EXPECT(loanASle)) + return; + + // Advance time past grace period for both loans to be defaultable + auto const loanANextDue = loanASle->at(sfNextPaymentDueDate); + auto const loanAGrace = loanASle->at(sfGracePeriod); + env.close(std::chrono::seconds{loanANextDue + loanAGrace + 60}); + + env(manage(lender, loanAKeylet.key, tfLoanDefault), Ter(tesSUCCESS)); + env.close(); + + // Verify Loan A is defaulted + loanASle = env.le(loanAKeylet); + if (!BEAST_EXPECT(loanASle)) + return; + BEAST_EXPECT(loanASle->isFlag(lsfLoanDefault)); + BEAST_EXPECT(loanASle->at(sfPaymentRemaining) == 0); + + // Check broker state after first default (from committed ledger) + auto brokerSle = env.le(brokerKeylet); + if (!BEAST_EXPECT(brokerSle)) + return; + auto const afterFirstDebtTotal = brokerSle->at(sfDebtTotal); + auto const afterFirstCoverAvailable = brokerSle->at(sfCoverAvailable); + + // DebtTotal should have decreased by Loan A's debt + BEAST_EXPECT(afterFirstDebtTotal == 50'134); + + // CoverAvailable should have decreased significantly + BEAST_EXPECT(afterFirstCoverAvailable == 946); + + env(manage(lender, loanBKeylet.key, tfLoanDefault), Ter(tesSUCCESS)); + + brokerSle = env.le(brokerKeylet); + if (!BEAST_EXPECT(brokerSle)) + return; + auto const afterSecondDebtTotal = brokerSle->at(sfDebtTotal); + auto const afterSecondCoverAvailable = brokerSle->at(sfCoverAvailable); + + BEAST_EXPECT(afterSecondDebtTotal == 0); + + BEAST_EXPECT(afterSecondCoverAvailable == 0); + } + + void + testYieldTheftRounding(std::uint32_t flags) + { + testcase("Rounding manipulation does not permit yield theft"); + using namespace jtx; + using namespace loan; + + // 1. Setup Environment + Env env(*this, all_); + Account const issuer{"issuer"}; + Account const lender{"lender"}; + Account const borrower{"borrower"}; + + env.fund(XRP(1000), issuer, lender, borrower); + env.close(); + + // 2. Asset Selection + PrettyAsset const iou = issuer["USD"]; + env(trust(lender, iou(100'000'000))); + env(trust(borrower, iou(100'000'000))); + env(pay(issuer, lender, iou(100'000'000))); + env(pay(issuer, borrower, iou(100'000'000))); + env.close(); + + // 3. Create Vault and Broker with High Debt Limit (100M) + auto const brokerInfo = createVaultAndBroker( + env, + iou, + lender, + { + .vaultDeposit = 5'000'000, + .debtMax = Number{100'000'000}, + .coverDeposit = 500'000, + }); + auto const [currentSeq, vaultKeylet] = [&]() { + auto const brokerSle = env.le(keylet::loanBroker(brokerInfo.brokerID)); + if (!BEAST_EXPECT(brokerSle)) + return std::make_tuple(0u, keylet::unchecked(beast::kZero)); + auto const currentSeq = brokerSle->at(sfLoanSequence); + auto const vaultKeylet = keylet::vault(brokerSle->at(sfVaultID)); + return std::make_tuple(currentSeq, vaultKeylet); + }(); + + // 4. Loan Parameters (Attack Vector) + Number const principal = 1'000'000; + TenthBips32 const interestRate = TenthBips32{1}; // 0.001% + std::uint32_t const paymentInterval = 86400; + std::uint32_t const paymentTotal = 3650; + + auto const loanSetFee = Fee(env.current()->fees().base * 2); + env(set(borrower, brokerInfo.brokerID, iou(principal).value(), flags), + Sig(sfCounterpartySignature, lender), + loan::kInterestRate(interestRate), + loan::kPaymentInterval(paymentInterval), + loan::kPaymentTotal(paymentTotal), + Fee(loanSetFee)); + env.close(); + + // --- RETRIEVE OBJECTS & SETUP ATTACK --- + + auto borrowerBalance = [&]() { return env.balance(borrower, iou); }; + auto const borrowerScale = static_cast(borrowerBalance()).exponent(); + + auto const loanKeylet = keylet::loan(brokerInfo.brokerID, currentSeq); + auto const maybePeriodicPayment = [&]() -> std::optional { + auto const loanSle = env.le(loanKeylet); + if (!BEAST_EXPECT(loanSle)) + return std::nullopt; + // Construct Payment + return STAmount{iou, loanSle->at(sfPeriodicPayment)}; + }(); + if (!maybePeriodicPayment) + return; + auto const periodicPayment = *maybePeriodicPayment; + auto const roundedPayment = + roundToScale(periodicPayment, borrowerScale, Number::RoundingMode::Upward); + + // ATTACK: Add dust buffer (1e-9) to force 'excess' logic execution + STAmount const paymentBuffer{iou, Number(1, -9)}; + STAmount const attackPayment = periodicPayment + paymentBuffer; + + auto const maybeInitialVaultAssets = [&]() -> std::optional { + auto const vault = env.le(vaultKeylet); + if (!BEAST_EXPECT(vault)) + return std::nullopt; + return vault->at(sfAssetsTotal); + }(); + if (!maybeInitialVaultAssets) + return; + auto const initialVaultAssets = *maybeInitialVaultAssets; + + // 5. Execution Loop + int yieldTheftCount = 0; + auto previousAssetsTotal = initialVaultAssets; + + for (int i = 0; i < 100; ++i) + { + auto const balanceBefore = borrowerBalance(); + env(pay(borrower, loanKeylet.key, attackPayment, flags)); + env.close(); + auto const borrowerDelta = balanceBefore - borrowerBalance(); + BEAST_EXPECT(borrowerDelta.signum() == roundedPayment.signum()); + + auto const loanSle = env.le(loanKeylet); + if (!BEAST_EXPECT(loanSle)) + break; + auto const updatedPayment = STAmount{iou, loanSle->at(sfPeriodicPayment)}; + BEAST_EXPECT( + (roundToScale(updatedPayment, borrowerScale, Number::RoundingMode::Upward) == + roundedPayment)); + BEAST_EXPECT( + (updatedPayment == periodicPayment) || + (flags == tfLoanOverpayment && i >= 2 && updatedPayment < periodicPayment)); + + auto const currentVaultSle = env.le(vaultKeylet); + if (!BEAST_EXPECT(currentVaultSle)) + break; + + auto const currentAssetsTotal = currentVaultSle->at(sfAssetsTotal); + auto const delta = currentAssetsTotal - previousAssetsTotal; + + BEAST_EXPECT( + (delta == beast::kZero && borrowerDelta <= roundedPayment) || + (delta > beast::kZero && borrowerDelta > roundedPayment)); + + // If tx succeeded but Assets Total didn't change, interest was + // stolen. + if (delta == beast::kZero && borrowerDelta > roundedPayment) + { + yieldTheftCount++; + } + + previousAssetsTotal = currentAssetsTotal; + } + + BEAST_EXPECTS(yieldTheftCount == 0, std::to_string(yieldTheftCount)); + } + + // Tests that vault withdrawals work correctly when the vault has unrealized + // loss from an impaired loan, ensuring the invariant check properly + // accounts for the loss. + void + testWithdrawReflectsUnrealizedLoss(FeatureBitset features) + { + using namespace jtx; + using namespace loan; + using namespace std::chrono_literals; + + testcase("Vault withdraw reflects sfLossUnrealized"); + + // Test constants + static constexpr std::int64_t kInitialFunding = 1'000'000; + static constexpr std::int64_t kLenderInitialIou = 5'000'000; + static constexpr std::int64_t kDepositorInitialIou = 1'000'000; + static constexpr std::int64_t kBorrowerInitialIou = 100'000; + static constexpr std::int64_t kDepositAmount = 5'000; + static constexpr std::int64_t kPrincipalAmount = 99; + static constexpr std::uint64_t kExpectedSharesPerDepositor = 5'000'000'000; + static constexpr std::uint32_t kLocalPaymentInterval = 600; + static constexpr std::uint32_t kLocalPaymentTotal = 2; + + Env env{*this, features}; + + // Setup accounts + Account const issuer{"issuer"}; + Account const lender{"lender"}; + Account const depositorA{"lpA"}; + Account const depositorB{"lpB"}; + Account const borrower{"borrowerA"}; + + env.fund(XRP(kInitialFunding), issuer, lender, depositorA, depositorB, borrower); + env.close(); + + // Setup trust lines + PrettyAsset const iouAsset = issuer[iouCurrency_]; + env(trust(lender, iouAsset(10'000'000))); + env(trust(depositorA, iouAsset(10'000'000))); + env(trust(depositorB, iouAsset(10'000'000))); + env(trust(borrower, iouAsset(10'000'000))); + env.close(); + + // Fund accounts with IOUs + env(pay(issuer, lender, iouAsset(kLenderInitialIou))); + env(pay(issuer, depositorA, iouAsset(kDepositorInitialIou))); + env(pay(issuer, depositorB, iouAsset(kDepositorInitialIou))); + env(pay(issuer, borrower, iouAsset(kBorrowerInitialIou))); + env.close(); + + // Create vault and broker, then add deposits from two depositors + auto const broker = createVaultAndBroker(env, iouAsset, lender); + Vault v{env}; + + env(v.deposit({ + .depositor = depositorA, + .id = broker.vaultKeylet().key, + .amount = iouAsset(kDepositAmount), + }), + Ter(tesSUCCESS)); + env(v.deposit({ + .depositor = depositorB, + .id = broker.vaultKeylet().key, + .amount = iouAsset(kDepositAmount), + }), + Ter(tesSUCCESS)); + env.close(); + + // Create a loan + auto const sleBroker = env.le(keylet::loanBroker(broker.brokerID)); + if (!BEAST_EXPECT(sleBroker)) + return; + + auto const loanKeylet = keylet::loan(broker.brokerID, sleBroker->at(sfLoanSequence)); + + env(set(borrower, broker.brokerID, kPrincipalAmount), + Sig(sfCounterpartySignature, lender), + kPaymentTotal(kLocalPaymentTotal), + kPaymentInterval(kLocalPaymentInterval), + Fee(env.current()->fees().base * 2), + Ter(tesSUCCESS)); + env.close(); + + // Impair the loan to create unrealized loss + env(manage(lender, loanKeylet.key, tfLoanImpair), Ter(tesSUCCESS)); + env.close(); + + // Verify unrealized loss is recorded in the vault + auto const vaultAfterImpair = env.le(broker.vaultKeylet()); + if (!BEAST_EXPECT(vaultAfterImpair)) + return; + + BEAST_EXPECT( + vaultAfterImpair->at(sfLossUnrealized) == broker.asset(kPrincipalAmount).value()); + + // Helper to get share balance for a depositor + auto const shareAsset = vaultAfterImpair->at(sfShareMPTID); + auto const getShareBalance = [&](Account const& depositor) -> std::uint64_t { + auto const token = env.le(keylet::mptoken(shareAsset, depositor.id())); + return token ? token->getFieldU64(sfMPTAmount) : 0; + }; + + // Verify both depositors have equal shares + auto const sharesLpA = getShareBalance(depositorA); + auto const sharesLpB = getShareBalance(depositorB); + BEAST_EXPECT(sharesLpA == kExpectedSharesPerDepositor); + BEAST_EXPECT(sharesLpB == kExpectedSharesPerDepositor); + BEAST_EXPECT(sharesLpA == sharesLpB); + + // Helper to attempt withdrawal + auto const attemptWithdrawShares = [&](Account const& depositor, + std::uint64_t shareAmount, + TER expected) { + STAmount const shareAmt{MPTIssue{shareAsset}, Number(shareAmount)}; + env(v.withdraw( + {.depositor = depositor, .id = broker.vaultKeylet().key, .amount = shareAmt}), + Ter(expected)); + env.close(); + }; + + // Regression test: Both depositors should successfully withdraw despite + // unrealized loss. Previously failed with invariant violation: + // "withdrawal must change vault and destination balance by equal + // amount". This was caused by sharesToAssetsWithdraw rounding down, + // creating a mismatch where vaultDeltaAssets * -1 != destinationDelta + // when unrealized loss exists. + attemptWithdrawShares(depositorA, sharesLpA, tesSUCCESS); + attemptWithdrawShares(depositorB, sharesLpB, tesSUCCESS); + } + + void + runAmendmentIndependent() + { + for (auto const flags : {0u, tfLoanOverpayment}) + testYieldTheftRounding(flags); + } + + // Tests run under each entry in amendmentCombinations(). + void + runAmendmentSensitive(FeatureBitset features) + { +#if LOAN_TODO + testLoanPayLateFullPaymentBypassesPenalties(features); + testLoanCoverMinimumRoundingExploit(features); +#endif + testDosLoanPay(features); + testWithdrawReflectsUnrealizedLoss(features); + testPoCUnsignedUnderflowOnFullPayAfterEarlyPeriodic(features); + testLoanNextPaymentDueDateOverflow(features); + testSequentialFLCDepletion(features); + testLoanPayComputePeriodicPaymentInvariants(features); + testAccountSendMptMinAmountInvariant(features); + testLoanPayDebtDecreaseInvariant(features); + testDustManipulation(features); + testRoundingAllowsUndercoverage(features); + } + +public: + void + run() override + { + runAmendmentIndependent(); + for (auto const& features : jtx::amendmentCombinations( + {fixCleanup3_1_3, fixCleanup3_2_0, featureMPTokensV2}, all_)) + runAmendmentSensitive(features); + } +}; + +BEAST_DEFINE_TESTSUITE(LoanInvariant, tx, xrpl); + +} // namespace xrpl::test diff --git a/src/test/app/LoanRegression_test.cpp b/src/test/app/LoanRegression_test.cpp new file mode 100644 index 00000000000..009c58fc5b3 --- /dev/null +++ b/src/test/app/LoanRegression_test.cpp @@ -0,0 +1,974 @@ +#include + +namespace xrpl::test { + +class LoanRegression_test : public LoanTestBase +{ +private: + void + testRIPD3831(FeatureBitset features) + { + using namespace jtx; + + testcase("RIPD-3831"); + + Account const issuer("issuer"); + Account const lender("lender"); + Account const borrower("borrower"); + + BrokerParameters const brokerParams{ + .vaultDeposit = 100000, + .debtMax = 0, + .coverRateMin = TenthBips32{0}, + // .managementFeeRate = TenthBips16{5919}, + .coverRateLiquidation = TenthBips32{0}}; + LoanParameters const loanParams{ + .account = lender, + .counter = borrower, + .principalRequest = Number{200'000, -6}, + .lateFee = Number{200, -6}, + .interest = TenthBips32{50'000}, + .payTotal = 10, + .payInterval = 150}; + + auto const assetType = AssetType::XRP; + + Env env{*this, features}; + + auto loanResult = + createLoan(env, assetType, brokerParams, loanParams, issuer, lender, borrower); + + if (BEAST_EXPECT(loanResult); !loanResult.has_value()) + return; + + auto broker = std::get(*loanResult); + auto loanKeylet = std::get(*loanResult); + + using tp = NetClock::time_point; + using d = NetClock::duration; + + auto state = getCurrentState(env, broker, loanKeylet); + if (auto loan = env.le(loanKeylet); BEAST_EXPECT(loan)) + { + env.close(tp{d{loan->at(sfNextPaymentDueDate) + loan->at(sfGracePeriod) + 1}}); + } + + topUpBorrower(env, broker, issuer, borrower, state, loanParams.serviceFee); + + using namespace jtx::loan; + + auto jv = pay(borrower, loanKeylet.key, drops(XRPAmount(state.totalValue))); + + { + auto const submitParam = to_string(jv); + auto const jr = env.rpc("submit", borrower.name(), submitParam); + + BEAST_EXPECT(jr.isMember(jss::result)); + auto const jResult = jr[jss::result]; + } + + env.close(); + + // Make sure the system keeps responding + env(noop(borrower)); + env.close(); + env(noop(issuer)); + env.close(); + env(noop(lender)); + env.close(); + } + + void + testRIPD3459(FeatureBitset features) + { + testcase("RIPD-3459 - LoanBroker incorrect debt total"); + + using namespace jtx; + + Account const issuer("issuer"); + Account const lender("lender"); + Account const borrower("borrower"); + + BrokerParameters const brokerParams{ + .vaultDeposit = 200'000, + .debtMax = 0, + .coverRateMin = TenthBips32{0}, + .managementFeeRate = TenthBips16{500}, + .coverRateLiquidation = TenthBips32{0}}; + LoanParameters const loanParams{ + .account = lender, + .counter = borrower, + .principalRequest = Number{100'000, -4}, + .interest = TenthBips32{100'000}, + .payTotal = 10}; + + auto const assetType = AssetType::MPT; + + Env env{*this, features}; + + auto loanResult = + createLoan(env, assetType, brokerParams, loanParams, issuer, lender, borrower); + + if (BEAST_EXPECT(loanResult); !loanResult.has_value()) + return; + + auto broker = std::get(*loanResult); + auto loanKeylet = std::get(*loanResult); + auto pseudoAcct = std::get(*loanResult); + + VerifyLoanStatus const verifyLoanStatus(env, broker, pseudoAcct, loanKeylet); + + if (auto const brokerSle = env.le(broker.brokerKeylet()); BEAST_EXPECT(brokerSle)) + { + if (auto const loanSle = env.le(loanKeylet); BEAST_EXPECT(loanSle)) + { + BEAST_EXPECT(brokerSle->at(sfDebtTotal) == loanSle->at(sfTotalValueOutstanding)); + } + } + + makeLoanPayments( + env, + broker, + loanParams, + loanKeylet, + verifyLoanStatus, + issuer, + lender, + borrower, + PaymentParameters{.showStepBalances = true}); + + if (auto const brokerSle = env.le(broker.brokerKeylet()); BEAST_EXPECT(brokerSle)) + { + if (auto const loanSle = env.le(loanKeylet); BEAST_EXPECT(loanSle)) + { + BEAST_EXPECT(brokerSle->at(sfDebtTotal) == loanSle->at(sfTotalValueOutstanding)); + BEAST_EXPECT(brokerSle->at(sfDebtTotal) == beast::kZero); + } + } + } + + void + testRIPD3901() + { + testcase("Crash with tfLoanOverpayment"); + using namespace jtx; + using namespace loan; + Account const lender{"lender"}; + Account const issuer{"issuer"}; + Account const borrower{"borrower"}; + Account const depositor{"depositor"}; + auto const txFee = Fee(XRP(100)); + + Env env(*this); + Vault const vault(env); + + env.fund(XRP(10'000), lender, issuer, borrower, depositor); + env.close(); + + auto [tx, vaultKeyLet] = vault.create({.owner = lender, .asset = xrpIssue()}); + env(tx, txFee); + env.close(); + + env(vault.deposit({.depositor = depositor, .id = vaultKeyLet.key, .amount = XRP(1'000)}), + txFee); + env.close(); + + auto const brokerKeyLet = keylet::loanBroker(lender.id(), env.seq(lender)); + + env(loanBroker::set(lender, vaultKeyLet.key), txFee); + env.close(); + + // BrokerInfo brokerInfo{xrpIssue(), keylet, vaultKeyLet, {}}; + + STAmount const debtMaximumRequest = XRPAmount(200'000); + + env(set(borrower, brokerKeyLet.key, debtMaximumRequest), + Sig(sfCounterpartySignature, lender), + kInterestRate(TenthBips32(50'000)), + kPaymentTotal(2), + kPaymentInterval(150), + Txflags(tfLoanOverpayment), + txFee); + env.close(); + + std::uint32_t const loanSequence = 1; + auto const loanKeylet = keylet::loan(brokerKeyLet.key, loanSequence); + + if (auto loan = env.le(loanKeylet); env.test.BEAST_EXPECT(loan)) + { + env(loan::pay(borrower, loanKeylet.key, XRPAmount(150'001)), + Txflags(tfLoanOverpayment), + txFee); + env.close(); + } + } + + void + testRIPD3902(FeatureBitset features) + { + testcase("RIPD-3902 - 1 IOU loan payments"); + + using namespace jtx; + + Account const issuer("issuer"); + Account const lender("lender"); + Account const borrower("borrower"); + + BrokerParameters const brokerParams{ + .vaultDeposit = 10, + .debtMax = 0, + .coverRateMin = TenthBips32{0}, + .managementFeeRate = TenthBips16{0}, + .coverRateLiquidation = TenthBips32{0}}; + LoanParameters const loanParams{ + .account = lender, + .counter = borrower, + .principalRequest = Number{1, 0}, + .interest = TenthBips32{100'000}, + .payTotal = 5, + .payInterval = 150, + .gracePd = 60}; + + auto const assetType = AssetType::IOU; + + Env env{*this, features}; + + auto loanResult = + createLoan(env, assetType, brokerParams, loanParams, issuer, lender, borrower); + + if (BEAST_EXPECT(loanResult); !loanResult.has_value()) + return; + + auto broker = std::get(*loanResult); + auto loanKeylet = std::get(*loanResult); + auto pseudoAcct = std::get(*loanResult); + + VerifyLoanStatus const verifyLoanStatus(env, broker, pseudoAcct, loanKeylet); + + makeLoanPayments( + env, + broker, + loanParams, + loanKeylet, + verifyLoanStatus, + issuer, + lender, + borrower, + PaymentParameters{.showStepBalances = true}); + } + + // A residual overpayment can reduce the stored principal by one scale-unit + // *less* than computeOverpaymentComponents predicts, firing the + // "principal change agrees" XRPL_ASSERT_PARTS in doOverpayment: + // + // trackedPrincipalDelta == principalOutstanding - newPrincipalOutstanding + // + // tryOverpayment re-amortizes the loan at the reduced principal, then + // re-derives the theoretical principal from the new periodic payment via + // (P * paymentFactor) / paymentFactor. That round-trip is not exact in + // Number's 19-digit arithmetic; a positive residual pushes the recomputed + // principal a hair above the exact grid point `oldPrincipal - delta`, and + // the Upward rounding in tryOverpayment then bumps it a full scale-unit + // higher. The principal therefore drops by `delta - 1 unit`, not `delta`. + // + // Concrete case (isolated, at the tryOverpayment level): + // A 100 USD loan at the minimum non-zero rate, 3 payments, loanScale -10. + // After one regular payment (principalOutstanding 66.6666666674) a residual overpayment of + // 0.049999998 yields trackedPrincipalDelta 0.048999998 but only reduces the principal by + // 0.0489999979 (newPrincipal 66.6176666695) — short by 1e-10. + // + // With fixCleanup3_2_0, tryOverpayment pins the new principal to the exact, + // on-grid reduction (oldPrincipal - trackedPrincipalDelta) instead of the + // lossy (P*factor)/factor round-trip, so the assertion holds and the + // overpayment applies cleanly. The three "principal change agrees" / + // "interest paid agrees" / "principal payment matches" assertions are + // gated behind the same amendment, so without it they are disabled (the + // server does not abort) and the loan keeps the pre-amendment computation. + // + // The test runs the same scenario under both amendment settings and checks + // the stored principal against a ground-truth value derived independently of + // the loan-state computation under test. + void + testBugOverpaymentPrincipalChange() + { + testcase("bug: doOverpayment asserts 'principal change agrees'"); + + using namespace jtx; + using namespace loan; + using namespace xrpl::detail; + + struct Params + { + TenthBips32 interestRate; + TenthBips16 managementFeeRate; + std::uint32_t paymentTotal; + std::uint32_t paymentInterval; + std::int64_t principal; + Number overpayment; + TenthBips32 overpaymentInterestRate; + TenthBips32 overpaymentFeeRate; + std::optional vaultScale; + }; + + struct Result + { + Number principalOutstanding; // stored principal after the LoanPay + Number expectedNewPrincipal; // ground truth, independent of the fix + Number managementFeeChange; // managementFeeOutstanding after - before + Number unit; // one scale-unit at the loan scale + }; + + auto runScenario = [this](FeatureBitset features, Params const& p) -> Result { + Env env(*this, features); + + Account const issuer{"issuer"}; + Account const lender{"vaultOwner"}; + Account const borrower{"borrower"}; + + PrettyAsset const iouAsset = createFundedRippleIouAsset(env, issuer, lender, borrower); + Asset const asset = iouAsset.raw(); + + auto const broker = createVaultAndBroker( + env, + iouAsset, + lender, + {.vaultDeposit = 900'000, + .debtMax = 0, + .managementFeeRate = p.managementFeeRate, + .vaultScale = p.vaultScale}); + + auto const brokerSle = env.le(broker.brokerKeylet()); + BEAST_EXPECT(brokerSle); + auto const loanSequence = brokerSle ? brokerSle->at(sfLoanSequence) : 0; + auto const loanKeylet = keylet::loan(broker.brokerID, loanSequence); + + env(set(borrower, broker.brokerID, Number{p.principal}, tfLoanOverpayment), + Sig(sfCounterpartySignature, lender), + kInterestRate(p.interestRate), + kPaymentTotal(p.paymentTotal), + kPaymentInterval(p.paymentInterval), + kGracePeriod(p.paymentInterval), + kOverpaymentFee(p.overpaymentFeeRate), + kOverpaymentInterestRate(p.overpaymentInterestRate), + Fee(env.current()->fees().base * 2), + Ter(tesSUCCESS)); + env.close(); + + // The single LoanPay below makes one regular payment (the overpayment + // is smaller than one period) and leaves the residual as an + // overpayment. + auto const s = getCurrentState(env, broker, loanKeylet); + auto const periodicRate = loanPeriodicRate(s.interestRate, s.paymentInterval); + auto const onePeriod = computePaymentComponents( + env.current()->rules(), + asset, + s.loanScale, + s.totalValue, + s.principalOutstanding, + s.managementFeeOutstanding, + s.periodicPayment, + periodicRate, + s.paymentRemaining, + p.managementFeeRate); + + // Ground truth: the stored principal must drop by exactly the regular + // payment's principal portion plus the overpayment's principal + // portion. computeOverpaymentComponents depends only on the + // overpayment amount and rates (not on the loan-state computation + // under test), so it is an independent oracle. Both components are + // computed under the same rules as the env so the payment factor + // matches. + auto const overpaymentComponents = computeOverpaymentComponents( + env.current()->rules(), + asset, + s.loanScale, + p.overpayment, + p.overpaymentInterestRate, + p.overpaymentFeeRate, + p.managementFeeRate); + Number const expectedNewPrincipal = s.principalOutstanding - + onePeriod.trackedPrincipalDelta - overpaymentComponents.trackedPrincipalDelta; + + Number const managementFeeBefore = s.managementFeeOutstanding; + + STAmount const payAmount{asset, onePeriod.trackedValueDelta + p.overpayment}; + env(pay(borrower, loanKeylet.key, payAmount), + Txflags(tfLoanOverpayment), + Ter(tesSUCCESS)); + env.close(); + + auto const loanSle = env.le(loanKeylet); + BEAST_EXPECT(loanSle); + + return Result{ + .principalOutstanding = loanSle ? Number{loanSle->at(sfPrincipalOutstanding)} : 0, + .expectedNewPrincipal = expectedNewPrincipal, + .managementFeeChange = + (loanSle ? Number{loanSle->at(sfManagementFeeOutstanding)} : Number{0}) - + managementFeeBefore, + .unit = Number{1, s.loanScale}}; + }; + + // Scenario 1: the original near-zero-rate principal reproduction + // (loanScale -10, no management fee). 0.049999998 is smaller than one + // period, so it stays a residual overpayment. + Params const principalCase{ + .interestRate = TenthBips32{1}, + .managementFeeRate = TenthBips16{0}, + .paymentTotal = 3, + .paymentInterval = 60, + .principal = 100, + .overpayment = Number{49999998, -9}, + .overpaymentInterestRate = TenthBips32{1000}, + .overpaymentFeeRate = TenthBips32{1000}, + .vaultScale = 1}; + + // With fixCleanup3_2_0 the stored principal lands exactly on the + // ground-truth grid point: it is reduced by exactly the overpayment's + // principal portion. This is the key correctness check: if the principal + // pin were removed (even with the assertions still gated off), the lossy + // (P * factor) / factor round-trip would leave the principal one + // scale-unit high and this would fail. + Result const fixed = runScenario(all_, principalCase); + BEAST_EXPECTS( + fixed.principalOutstanding == fixed.expectedNewPrincipal, + "fixed principal " + to_string(fixed.principalOutstanding) + " != expected " + + to_string(fixed.expectedNewPrincipal)); + + // Without the amendment the loan amortizes with the catastrophically + // cancelling near-zero payment factor, so its schedule (and ground truth) + // differ from the fixed case; the gated assertions keep the server from + // aborting and the overpayment still lands exactly on that schedule. + Result const legacy = runScenario(all_ - fixCleanup3_2_0, principalCase); + BEAST_EXPECTS( + legacy.principalOutstanding == legacy.expectedNewPrincipal, + "legacy principal " + to_string(legacy.principalOutstanding) + " != expected " + + to_string(legacy.expectedNewPrincipal)); + + // Scenario 2: a normal-rate loan with a 10% management fee. At a normal + // rate the payment factor is identical across the amendment, so toggling + // fixCleanup3_2_0 isolates the fix. This overpayment (found by search) + // lands on a state where both the principal and the management fee differ + // by one scale-unit between the fixed and legacy paths. + Params const feeCase{ + .interestRate = TenthBips32{10000}, + .managementFeeRate = TenthBips16{10000}, + .paymentTotal = 6, + .paymentInterval = 30u * 24 * 60 * 60, + .principal = 1000, + .overpayment = Number{214367363, -10}, + .overpaymentInterestRate = TenthBips32{0}, + .overpaymentFeeRate = TenthBips32{0}, + .vaultScale = std::nullopt}; + + Result const feeFixed = runScenario(all_, feeCase); + Result const feeLegacy = runScenario(all_ - fixCleanup3_2_0, feeCase); + + // With the fix the principal is the exact reduction; without it the lossy + // (P * factor) / factor round-trip leaves it one scale-unit high. + BEAST_EXPECTS( + feeFixed.principalOutstanding == feeFixed.expectedNewPrincipal, + "fee-case fixed principal " + to_string(feeFixed.principalOutstanding) + + " != expected " + to_string(feeFixed.expectedNewPrincipal)); + BEAST_EXPECTS( + feeLegacy.principalOutstanding == feeLegacy.expectedNewPrincipal + feeLegacy.unit, + "fee-case legacy principal " + to_string(feeLegacy.principalOutstanding) + + " != expected " + to_string(feeLegacy.expectedNewPrincipal + feeLegacy.unit)); + + // Management fee: the overpayment re-amortizes a fee-bearing loan, so the management fee + // outstanding drops. + // + // Unlike the principal that is already at the correct precision, the re-amortized + // management fee is tenthBipsOfValue of the new schedule's gross interest, which depends + // on the recomputed periodic payment. So the expected change below is a pinned constant + // captured from a passing run a magic value only because there is nothing simpler to + // compare against. + // + // At the integration level, toggling the amendment also changes the regular payment's + // rounding so a fixed-vs-legacy comparison cannot isolate the overpayment management-fee + // fix. + BEAST_EXPECT(feeFixed.managementFeeChange == feeLegacy.managementFeeChange); + BEAST_EXPECTS( + (feeFixed.managementFeeChange == Number{-8219709543, -10}), + "fee-case mgmt fee change " + to_string(feeFixed.managementFeeChange)); + } + + // A LoanSet with InterestRate = 1 (0.001% annualized, the minimum non-zero + // rate). At such a near-zero rate the closed-form payment factor + // (1 + r)^n - 1 cancels catastrophically. + // + // Without fixCleanup3_2_0 the resulting amortization is degenerate and the + // LoanSet is rejected with tecPRECISION_LOSS (no loan created). With the + // amendment, computePowerMinusOneHybrid uses a numerically-stable series + // expansion, so the loan is created and the scheduled payments + // (2 * periodicPayment) cover the principal — no economic underpayment + // (yield theft). + // + // The test runs the same LoanSet under both amendment settings and pins the + // exact outcome for each. + void + testLoanSetNearZeroInterestRateSucceeds() + { + testcase("LoanSet near-zero interest rate covers principal"); + + using namespace jtx; + using namespace loan; + + Number const principalRequested{1000}; + + struct Result + { + TER ter = tesSUCCESS; + bool created = false; + std::int32_t loanScale = 0; + Number principal; + Number totalValue; + Number managementFee; + Number periodicPayment; + }; + + auto runScenario = [&](FeatureBitset features, TER expectedTer) -> Result { + Env env(*this, features); + + Account const issuer{"issuer"}; + Account const lender{"vaultOwner"}; + Account const borrower{"borrower"}; + + PrettyAsset const iouAsset = createFundedRippleIouAsset(env, issuer, lender, borrower); + + auto const broker = createVaultAndBroker( + env, + iouAsset, + lender, + {.vaultDeposit = 100'000, .debtMax = 0, .managementFeeRate = TenthBips16{0}}); + + auto const brokerSle = env.le(broker.brokerKeylet()); + BEAST_EXPECT(brokerSle); + auto const loanSequence = brokerSle ? brokerSle->at(sfLoanSequence) : 0; + auto const loanKeylet = keylet::loan(broker.brokerID, loanSequence); + + env(set(borrower, broker.brokerID, principalRequested), + Sig(sfCounterpartySignature, lender), + kInterestRate(TenthBips32{1}), + kPaymentTotal(2), + kPaymentInterval(400), + Fee(env.current()->fees().base * 2), + Ter(expectedTer)); + env.close(); + + Result r; + r.ter = env.ter(); + if (auto const loanSle = env.le(loanKeylet)) + { + r.created = true; + r.loanScale = loanSle->at(sfLoanScale); + r.principal = loanSle->at(sfPrincipalOutstanding); + r.totalValue = loanSle->at(sfTotalValueOutstanding); + r.managementFee = loanSle->at(sfManagementFeeOutstanding); + r.periodicPayment = loanSle->at(sfPeriodicPayment); + } + return r; + }; + + Result const fixed = runScenario(all_, tesSUCCESS); + Result const legacy = runScenario(all_ - fixCleanup3_2_0, tecPRECISION_LOSS); + + // Without the amendment, the catastrophically-cancelling closed-form + // payment factor produces a degenerate amortization that fails + // checkLoanGuards: the LoanSet is rejected with tecPRECISION_LOSS and no + // loan is created. + BEAST_EXPECT(legacy.ter == tecPRECISION_LOSS); + BEAST_EXPECT(!legacy.created); + + // With the amendment the stable series expansion produces a valid loan + // at loanScale -10. + BEAST_EXPECT(fixed.ter == tesSUCCESS); + BEAST_EXPECT(fixed.created); + BEAST_EXPECT(fixed.loanScale == -10); + BEAST_EXPECT(fixed.principal == principalRequested); + BEAST_EXPECT((fixed.totalValue == Number{10000000001903, -10})); + BEAST_EXPECT(fixed.managementFee == beast::kZero); + + // Periodic payment from the numerically-stable series expansion, and the + // scheduled total (2 * periodicPayment) which exceeds the 1000 principal + // — no economic underpayment / yield theft. + BEAST_EXPECT((fixed.periodicPayment == Number{5000000000951293762, -16})); + BEAST_EXPECT((fixed.periodicPayment * 2 == Number{1000000000190258752, -15})); + BEAST_EXPECT(fixed.periodicPayment * 2 > principalRequested); + } + + // 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; + using namespace loan; + Env env(*this, all_); + + Account const issuer{"issuer"}; + Account const lender{"vaultOwner"}; + Account const borrower{"borrower"}; + + PrettyAsset const iouAsset = createFundedRippleIouAsset(env, issuer, lender, borrower); + + auto const broker = createVaultAndBroker( + env, + iouAsset, + lender, + {.vaultDeposit = 100'000, + .debtMax = 5000, + .managementFeeRate = TenthBips16{1000}, + .vaultScale = 1}); + + auto const sleBroker = env.le(broker.brokerKeylet()); + if (!BEAST_EXPECT(sleBroker)) + return; + auto const loanSequence = sleBroker->at(sfLoanSequence); + auto const loanKeylet = keylet::loan(broker.brokerID, loanSequence); + + using namespace loan; + env(set(borrower, broker.brokerID, Number{1000}, tfLoanOverpayment), + Sig(sfCounterpartySignature, lender), + kInterestRate(TenthBips32{10000}), + kPaymentTotal(12), + kPaymentInterval(60), + kGracePeriod(60), + kOverpaymentFee(TenthBips32{1000}), + kOverpaymentInterestRate(TenthBips32{1000}), + Fee(env.current()->fees().base * 2), + 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(); + } + + // Regression for the dual-rounding fix at coarse (integer-MPT) scale. + // + // Loan: P=1, r=50% (50000 tenth-bips), n=3, yearly interval. The + // amortization schedule produces a fractional principal + // (~0.47) which under round-to-nearest collapses to 0 in a single + // step, causing `doPayment`'s strict `>` assertion on principal to + // fire mid-loan. With fixCleanup3_2_0 enabled, principal is rounded + // upward (sticks at 1 across the first two periods) and only clears + // in the final payment. + // + // The test pays one period at a time across three LoanPay + // transactions and verifies the loan completes (paymentRemaining=0) + // with totals matching the loan's economics (1 principal + 2 interest). + void + testIntegerScalePrincipalSticks(FeatureBitset features) + { + // Without fixCleanup3_2_0, this behavior will abort the server, so + // don't run without it. + if (!features[fixCleanup3_2_0]) + return; + + testcase("edge: integer MPT principal stuck mid-loan completes via final"); + + using namespace jtx; + Env env(*this, features); + + Account const issuer{"issuer"}; + Account const lender{"lender"}; + Account const borrower{"borrower"}; + + env.fund(XRP(100'000), issuer, lender, borrower); + env.close(); + + MPTTester mptt{env, issuer, kMptInitNoFund}; + mptt.create({.maxAmt = 100'000, .flags = tfMPTCanTransfer}); + PrettyAsset const asset{mptt.issuanceID()}; + + mptt.authorize({.account = lender}); + mptt.authorize({.account = borrower}); + + env(pay(issuer, lender, asset(10'000))); + env(pay(issuer, borrower, asset(10'000))); + env.close(); + + Vault const vault{env}; + auto [vaultTx, vaultKeylet] = vault.create({.owner = lender, .asset = asset}); + env(vaultTx); + env.close(); + + env(vault.deposit({.depositor = lender, .id = vaultKeylet.key, .amount = asset(5'000)})); + env.close(); + + auto const brokerKeylet = keylet::loanBroker(lender.id(), env.seq(lender)); + env(loanBroker::set(lender, vaultKeylet.key), + loanBroker::kDebtMaximum(Number{100}), + Fee(env.current()->fees().base * 2)); + env.close(); + + auto const brokerStateBefore = env.le(brokerKeylet); + if (!BEAST_EXPECT(brokerStateBefore)) + return; + auto const loanSequence = brokerStateBefore->at(sfLoanSequence); + auto const loanKeylet = keylet::loan(brokerKeylet.key, loanSequence); + + env(loan::set(borrower, brokerKeylet.key, Number{1}), + Sig(sfCounterpartySignature, lender), + loan::kInterestRate(TenthBips32{50'000}), + loan::kPaymentTotal(3), + loan::kPaymentInterval(31'536'000), + Fee(env.current()->fees().base * 2)); + env.close(); + + auto const borrowerStart = env.balance(borrower, asset).value(); + + // Three separate periodic payments of 1 each. Expected per-period + // evolution at integer MPT scale (TVO = PO + interestDue + + // managementFeeDue): + // start: PO=1, TVO=3, paymentRemaining=3 + // after pay #1: PO=1, TVO=2, paymentRemaining=2 (principal sticks) + // after pay #2: PO=1, TVO=1, paymentRemaining=1 (principal sticks) + // after pay #3: PO=0, TVO=0, paymentRemaining=0 (final clears) + std::array const expectedPO{Number{1}, Number{1}, Number{0}}; + std::array const expectedTVO{Number{2}, Number{1}, Number{0}}; + std::array const expectedRemaining{2, 1, 0}; + + for (int i = 0; i < 3; ++i) + { + env(loan::pay(borrower, loanKeylet.key, asset(1)), Ter(tesSUCCESS)); + env.close(); + + auto const sle = env.le(loanKeylet); + if (!BEAST_EXPECT(sle)) + return; + BEAST_EXPECT(sle->at(sfPrincipalOutstanding) == expectedPO[i]); + BEAST_EXPECT(sle->at(sfTotalValueOutstanding) == expectedTVO[i]); + BEAST_EXPECT(sle->at(sfPaymentRemaining) == expectedRemaining[i]); + } + + // Borrower paid 3 total regardless of fee split (1 principal + 2 + // interest+fee, matching loan economics). + auto const borrowerEnd = env.balance(borrower, asset).value(); + BEAST_EXPECT(borrowerStart - borrowerEnd == asset(3).value()); + } + + // A near-zero interest rate on a 100 USD loan + // produces total interest of ~6 units at loanScale -9. Numerical error + // in the amortization formula pushes the theoretical principal above + // the theoretical value, producing a negative theoretical interest. + // The payment delta then exceeds the actual outstanding interest, + // violating XRPL_ASSERT_PARTS in computePaymentComponents. + void + testBugInterestDueDeltaCrash() + { + testcase("bug: LoanPay asserts 'interest due delta' on near-zero rate"); + + using namespace jtx; + using namespace std::chrono_literals; + Env env(*this, all_); + + Account const issuer{"issuer"}; + Account const lender{"lender"}; + Account const borrower{"borrower"}; + + env.fund(XRP(1'000'000), issuer, lender, borrower); + env.close(); + env(fset(issuer, asfDefaultRipple)); + env.close(); + + PrettyAsset const iouAsset = issuer["USD"]; + env(trust(lender, iouAsset(1'000'000'000))); + env(trust(borrower, iouAsset(1'000'000'000))); + env(pay(issuer, lender, iouAsset(5'000'000))); + env(pay(issuer, borrower, iouAsset(5'000'000))); + env.close(); + + BrokerParameters const brokerParams{ + .vaultDeposit = 1'000'000, + .debtMax = 1'000'000, + .coverRateMin = TenthBips32{0}, + .coverDeposit = 0, + .managementFeeRate = TenthBips16{0}, + .coverRateLiquidation = TenthBips32{0}}; + + BrokerInfo const broker{createVaultAndBroker(env, iouAsset, lender, brokerParams)}; + + using namespace loan; + + auto const loanSetFee = Fee(env.current()->fees().base * 2); + Number const principalRequest{100}; + + auto createJson = env.json( + set(borrower, broker.brokerID, principalRequest), + Fee(loanSetFee), + Json(sfCounterpartySignature, json::ValueType::Object)); + + createJson["InterestRate"] = 1; // minimum non-zero rate + createJson["PaymentTotal"] = 3; + createJson["PaymentInterval"] = 600; + + auto const keylet = nextLoanKeylet(env, broker); + + createJson = env.json(createJson, Sig(sfCounterpartySignature, lender)); + env(createJson, Ter(tesSUCCESS)); + env.close(); + + // For principal=100, n=3 the amortization schedule produces a + // periodic payment ≈ 33.33 USD. We pay 35 USD, which is more than + // one period's worth — enough for the LoanPay path to enter + // computePaymentComponents and reach the assertion that fires + // when the bug is present. With the fix, the tx applies cleanly. + env(pay(borrower, keylet.key, iouAsset(35)), Ter(tesSUCCESS)); + env.close(); + } + + // Integration test: full lifecycle of a $1B loan in the bug regime. + // Verifies that the vault collects the economically-correct interest + // income and that conservation holds at the trust-line level. + // + // Pre-fix (closed-form `power(1+r, n) - 1`): vault collected only + // ~$0.058 per $1B due to cancellation of `(1+r)^n - 1` at r*n ~ 5.7e-10. + // Post-fix (hybrid binomial path): vault collects ~$0.38 per $1B, + // matching the value computed independently with arbitrary-precision + // Decimal arithmetic. + void + testFullLifecycleVaultPnLNearZeroRate() + { + testcase("integration: full loan lifecycle, vault interest at near-zero rate"); + + using namespace jtx; + using namespace jtx::loan; + using namespace std::chrono_literals; + Env env(*this, all_); + + Account const issuer{"issuer"}; + Account const lender{"lender"}; + Account const borrower{"borrower"}; + + env.fund(XRP(1'000'000), issuer, lender, borrower); + env.close(); + env(fset(issuer, asfDefaultRipple)); + env.close(); + + PrettyAsset const iouAsset = issuer["USD"]; + STAmount const trustLimit{iouAsset.raw(), Number{1, 17}}; + env(trust(lender, trustLimit)); + env(trust(borrower, trustLimit)); + env.close(); + env(pay(issuer, lender, iouAsset(5'000'000'000LL))); + env(pay(issuer, borrower, iouAsset(5'000'000'000LL))); + env.close(); + + auto usdBalance = [&](Account const& a) { + return env.balance(a, iouAsset.raw().get()).value(); + }; + STAmount const borrowerStartBal = usdBalance(borrower); + + BrokerParameters const brokerParams{ + .vaultDeposit = Number{2, 9}, + .debtMax = Number{0}, + .coverRateMin = TenthBips32{0}, + .coverDeposit = 0, + .managementFeeRate = TenthBips16{0}, + .coverRateLiquidation = TenthBips32{0}}; + BrokerInfo const broker{createVaultAndBroker(env, iouAsset, lender, brokerParams)}; + + auto const vaultBefore = env.le(broker.vaultKeylet()); + BEAST_EXPECT(vaultBefore); + Number const vaultAvailableBefore = vaultBefore->at(sfAssetsAvailable); + + // Loan: $1B principal, 3 payments, 600s interval, rate=1 TenthBips32. + auto const loanSetFee = Fee(env.current()->fees().base * 2); + Number const principalRequest{1, 9}; + auto createJson = env.json( + set(borrower, broker.brokerID, principalRequest), + Fee(loanSetFee), + Json(sfCounterpartySignature, json::ValueType::Object)); + createJson["InterestRate"] = 1; + createJson["PaymentTotal"] = 3; + createJson["PaymentInterval"] = 600; + + auto const loanKeylet = nextLoanKeylet(env, broker); + createJson = env.json(createJson, Sig(sfCounterpartySignature, lender)); + env(createJson, Ter(tesSUCCESS)); + env.close(); + + auto const loanSle = env.le(loanKeylet); + BEAST_EXPECT(loanSle); + Number const expectedTotalInterest = + loanSle->at(sfTotalValueOutstanding) - loanSle->at(sfPrincipalOutstanding); + + env(pay(borrower, loanKeylet.key, iouAsset(1'500'000'000LL)), Ter(tesSUCCESS)); + env.close(); + + auto const vaultAfter = env.le(broker.vaultKeylet()); + Number const vaultAvailableAfter = vaultAfter->at(sfAssetsAvailable); + Number const vaultGain = vaultAvailableAfter - vaultAvailableBefore; + + STAmount const borrowerEndBal = usdBalance(borrower); + STAmount const borrowerNetOut = borrowerStartBal - borrowerEndBal; + + // Self-consistency: vault gained exactly the expected interest + // computed at LoanSet, and the borrower's outflow matches. + BEAST_EXPECT(vaultGain == expectedTotalInterest); + BEAST_EXPECT(Number(borrowerNetOut) == expectedTotalInterest); + + // Mathematical correctness: the total interest for this loan + // configuration is 0.38051750382930729983, calculated + // independently using 50-digit Decimal arithmetic (no + // cancellation possible at that precision). At Number's 19-digit + // mantissa this rounds to 0.38051750382930729 — the literal + // below. The vault's actual gain must agree to within + // sub-microcent precision. + Number const decimalReference{38051750382930729LL, -17}; + Number const tolerance{1, -6}; // 1e-6 USD = sub-microcent + Number const error = abs(vaultGain - decimalReference); + BEAST_EXPECTS( + error < tolerance, + "vault gain " + to_string(vaultGain) + " differs from Decimal reference " + + to_string(decimalReference) + " by " + to_string(error) + " — exceeds tolerance " + + to_string(tolerance)); + } + + void + runAmendmentIndependent() + { + testRIPD3901(); + testBugOverpaymentPrincipalChange(); + testBugOverpayUnroundedAmount(); + testBugInterestDueDeltaCrash(); + testFullLifecycleVaultPnLNearZeroRate(); + testLoanSetNearZeroInterestRateSucceeds(); + } + + // Tests run under each entry in amendmentCombinations(). + void + runAmendmentSensitive(FeatureBitset features) + { + testIntegerScalePrincipalSticks(features); + testRIPD3831(features); + testRIPD3459(features); + testRIPD3902(features); + } + +public: + void + run() override + { + runAmendmentIndependent(); + for (auto const& features : jtx::amendmentCombinations( + {fixCleanup3_1_3, fixCleanup3_2_0, featureMPTokensV2}, all_)) + runAmendmentSensitive(features); + } +}; + +BEAST_DEFINE_TESTSUITE(LoanRegression, tx, xrpl); + +} // namespace xrpl::test diff --git a/src/test/app/LoanTestBase.h b/src/test/app/LoanTestBase.h new file mode 100644 index 00000000000..b0ae00608b0 --- /dev/null +++ b/src/test/app/LoanTestBase.h @@ -0,0 +1,1631 @@ +#pragma once + +#include +// +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace xrpl::test { + +class LoanTestBase : public beast::unit_test::Suite +{ +protected: + // Ensure that all the features needed for Lending Protocol are included, + // even if they are set to unsupported. + // + // featureLendingProtocolV1_1 is excluded from the default set: it changes + // Vault/LoanBroker accounting (AssetsTotal/DebtTotal/LossUnrealized), and + // most of this file's tests assert whole-life-specific expected values + // for those fields. Tests that specifically exercise the amendment opt + // it back in explicitly (e.g. `all_ | featureLendingProtocolV1_1`). + FeatureBitset const all_{jtx::testableAmendments() - featureLendingProtocolV1_1}; + std::string const iouCurrency_{"IOU"}; + + struct BrokerParameters + { + Number vaultDeposit = 1'000'000; + Number debtMax = 25'000; + TenthBips32 coverRateMin = percentageToTenthBips(10); + int coverDeposit = 1000; + TenthBips16 managementFeeRate{100}; + TenthBips32 coverRateLiquidation = percentageToTenthBips(25); + std::string data = {}; // NOLINT(readability-redundant-member-init) + std::uint32_t flags = 0; + // If set, the vault is created with this sfScale value. Useful for + // tests that need finer loanScale to exercise rounding edge cases. + std::optional vaultScale = + std::nullopt; // NOLINT(readability-redundant-member-init) + + [[nodiscard]] Number + maxCoveredLoanValue(Number const& currentDebt) const + { + NumberRoundModeGuard const mg(Number::RoundingMode::Downward); + auto debtLimit = coverDeposit * kTenthBipsPerUnity.value() / coverRateMin.value(); + + return debtLimit - currentDebt; + } + + static BrokerParameters const& + defaults() + { + static BrokerParameters const kResult{}; + return kResult; + } + + // TODO: create an operator() which returns a transaction similar to + // LoanParameters + }; + + struct BrokerInfo + { + jtx::PrettyAsset asset; + uint256 brokerID; + uint256 vaultID; + BrokerParameters params; + BrokerInfo( + jtx::PrettyAsset const& asset, + Keylet const& brokerKeylet, + Keylet const& vaultKeylet, + BrokerParameters p) + : asset(asset) + , brokerID(brokerKeylet.key) + , vaultID(vaultKeylet.key) + , params(std::move(p)) + { + } + + [[nodiscard]] Keylet + brokerKeylet() const + { + return keylet::loanBroker(brokerID); + } + [[nodiscard]] Keylet + vaultKeylet() const + { + return keylet::vault(vaultID); + } + + [[nodiscard]] int + vaultScale(jtx::Env const& env) const + { + using namespace jtx; + + auto const vaultSle = env.le(keylet::vault(vaultID)); + return getAssetsTotalScale(vaultSle); + } + }; + + struct LoanParameters + { + // The account submitting the transaction. May be borrower or broker. + jtx::Account account; + // The counterparty. Should be the other of borrower or broker. + jtx::Account counter; + // Whether the counterparty is specified in the `counterparty` field, or + // only signs. + bool counterpartyExplicit = true; + Number principalRequest; + // NOLINTBEGIN(readability-redundant-member-init) + std::optional setFee = std::nullopt; + std::optional originationFee = std::nullopt; + std::optional serviceFee = std::nullopt; + std::optional lateFee = std::nullopt; + std::optional closeFee = std::nullopt; + std::optional overFee = std::nullopt; + std::optional interest = std::nullopt; + std::optional lateInterest = std::nullopt; + std::optional closeInterest = std::nullopt; + std::optional overpaymentInterest = std::nullopt; + std::optional payTotal = std::nullopt; + std::optional payInterval = std::nullopt; + std::optional gracePd = std::nullopt; + std::optional flags = std::nullopt; + // NOLINTEND(readability-redundant-member-init) + + template + jtx::JTx + operator()(jtx::Env& env, BrokerInfo const& broker, FN const&... fN) const + { + using namespace jtx; + using namespace jtx::loan; + + JTx jt{loan::set( + account, + broker.brokerID, + broker.asset(principalRequest).number(), + flags.value_or(0))}; + + Sig(sfCounterpartySignature, counter)(env, jt); + + Fee{setFee.value_or(env.current()->fees().base * 2)}(env, jt); + + if (counterpartyExplicit) + kCounterparty(counter)(env, jt); + if (originationFee) + kLoanOriginationFee(broker.asset(*originationFee).number())(env, jt); + if (serviceFee) + kLoanServiceFee(broker.asset(*serviceFee).number())(env, jt); + if (lateFee) + kLatePaymentFee(broker.asset(*lateFee).number())(env, jt); + if (closeFee) + kClosePaymentFee(broker.asset(*closeFee).number())(env, jt); + if (overFee) + kOverpaymentFee (*overFee)(env, jt); + if (interest) + kInterestRate (*interest)(env, jt); + if (lateInterest) + kLateInterestRate (*lateInterest)(env, jt); + if (closeInterest) + kCloseInterestRate (*closeInterest)(env, jt); + if (overpaymentInterest) + kOverpaymentInterestRate (*overpaymentInterest)(env, jt); + if (payTotal) + kPaymentTotal (*payTotal)(env, jt); + if (payInterval) + kPaymentInterval (*payInterval)(env, jt); + if (gracePd) + kGracePeriod (*gracePd)(env, jt); + + return env.jt(jt, fN...); + } + }; + + struct PaymentParameters + { + Number overpaymentFactor = Number{1}; + std::optional overpaymentExtra = std::nullopt; + std::uint32_t flags = 0; + bool showStepBalances = false; + bool validateBalances = true; + + static PaymentParameters const& + defaults() + { + static PaymentParameters const kResult{}; + return kResult; + } + }; + + struct LoanState + { + std::uint32_t previousPaymentDate = 0; + NetClock::time_point startDate; + std::uint32_t nextPaymentDate = 0; + std::uint32_t paymentRemaining = 0; + std::int32_t const loanScale = 0; + Number totalValue = 0; + Number principalOutstanding = 0; + Number managementFeeOutstanding = 0; + Number periodicPayment = 0; + std::uint32_t flags = 0; + std::uint32_t const paymentInterval = 0; + TenthBips32 const interestRate{}; + }; + + /** + * Helper class to compare the expected state of a loan and loan broker + * against the data in the ledger. + */ + struct VerifyLoanStatus + { + public: + jtx::Env const& env; + BrokerInfo const& broker; + jtx::Account const& pseudoAccount; + Keylet const& loanKeylet; + + VerifyLoanStatus( + jtx::Env const& env, + BrokerInfo const& broker, + jtx::Account const& pseudo, + Keylet const& keylet) + : env(env), broker(broker), pseudoAccount(pseudo), loanKeylet(keylet) + { + } + + /** + * Checks the expected broker state against the ledger + */ + void + checkBroker( + Number const& principalOutstanding, + Number const& interestOwed, + TenthBips32 interestRate, + std::uint32_t paymentInterval, + std::uint32_t paymentsRemaining, + std::uint32_t ownerCount) const + { + using namespace jtx; + if (auto brokerSle = env.le(keylet::loanBroker(broker.brokerID)); + env.test.BEAST_EXPECT(brokerSle)) + { + TenthBips16 const managementFeeRate{brokerSle->at(sfManagementFeeRate)}; + auto const brokerDebt = brokerSle->at(sfDebtTotal); + + if (auto vaultSle = env.le(keylet::vault(brokerSle->at(sfVaultID))); + env.test.BEAST_EXPECT(vaultSle)) + { + auto const expectedDebt = + env.current()->rules().enabled(featureLendingProtocolV1_1) && + getVaultVersion(vaultSle) == VaultVersion::CashBasis + ? principalOutstanding + : principalOutstanding + interestOwed; + env.test.BEAST_EXPECT(brokerDebt == expectedDebt); + env.test.BEAST_EXPECT( + env.balance(pseudoAccount, broker.asset).number() == + brokerSle->at(sfCoverAvailable)); + env.test.BEAST_EXPECT(brokerSle->at(sfOwnerCount) == ownerCount); + + Account const vaultPseudo{"vaultPseudoAccount", vaultSle->at(sfAccount)}; + env.test.BEAST_EXPECT( + vaultSle->at(sfAssetsAvailable) == + env.balance(vaultPseudo, broker.asset).number()); + if (ownerCount == 0) + { + // The Vault must be perfectly balanced if there + // are no loans outstanding + auto const total = vaultSle->at(sfAssetsTotal); + auto const available = vaultSle->at(sfAssetsAvailable); + env.test.BEAST_EXPECT(total == available); + env.test.BEAST_EXPECT(vaultSle->at(sfLossUnrealized) == 0); + } + } + } + } + + void + checkPayment( + std::int32_t loanScale, + jtx::Account const& account, + jtx::PrettyAmount const& balanceBefore, + STAmount const& expectedPayment, + jtx::PrettyAmount const& adjustment) const + { + auto const borrowerScale = std::max(loanScale, balanceBefore.number().exponent()); + + STAmount const balanceChangeAmount{ + broker.asset, + roundToAsset(broker.asset, expectedPayment + adjustment, borrowerScale)}; + { + auto const difference = roundToScale( + env.balance(account, broker.asset) - (balanceBefore - balanceChangeAmount), + borrowerScale); + env.test.expect( + roundToScale(difference, loanScale) >= beast::kZero, + "Balance before: " + to_string(balanceBefore.value()) + + ", expected change: " + to_string(balanceChangeAmount) + + ", difference (balance after - expected): " + to_string(difference), + __FILE__, + __LINE__); + } + } + + /** + * Checks both the loan and broker expect states against the ledger + */ + void + operator()( + std::uint32_t previousPaymentDate, + std::uint32_t nextPaymentDate, + std::uint32_t paymentRemaining, + Number const& loanScale, + Number const& totalValue, + Number const& principalOutstanding, + Number const& managementFeeOutstanding, + Number const& periodicPayment, + std::uint32_t flags) const + { + using namespace jtx; + if (auto loan = env.le(loanKeylet); env.test.BEAST_EXPECT(loan)) + { + env.test.BEAST_EXPECT(loan->at(sfPreviousPaymentDueDate) == previousPaymentDate); + env.test.BEAST_EXPECT(loan->at(sfPaymentRemaining) == paymentRemaining); + env.test.BEAST_EXPECT(loan->at(sfNextPaymentDueDate) == nextPaymentDate); + env.test.BEAST_EXPECT(loan->at(sfLoanScale) == loanScale); + env.test.BEAST_EXPECT(loan->at(sfTotalValueOutstanding) == totalValue); + env.test.BEAST_EXPECT(loan->at(sfPrincipalOutstanding) == principalOutstanding); + env.test.BEAST_EXPECT( + loan->at(sfManagementFeeOutstanding) == managementFeeOutstanding); + env.test.BEAST_EXPECT(loan->at(sfPeriodicPayment) == periodicPayment); + env.test.BEAST_EXPECT(loan->at(sfFlags) == flags); + + auto const ls = constructLoanState(loan); + + auto const interestRate = TenthBips32{loan->at(sfInterestRate)}; + auto const paymentInterval = loan->at(sfPaymentInterval); + checkBroker( + principalOutstanding, + ls.interestDue, + interestRate, + paymentInterval, + paymentRemaining, + 1); + + if (auto brokerSle = env.le(keylet::loanBroker(broker.brokerID)); + env.test.BEAST_EXPECT(brokerSle)) + { + if (auto vaultSle = env.le(keylet::vault(brokerSle->at(sfVaultID))); + env.test.BEAST_EXPECT(vaultSle)) + { + if (((flags & lsfLoanImpaired) != 0u) && ((flags & lsfLoanDefault) == 0u)) + { + env.test.BEAST_EXPECT( + vaultSle->at(sfLossUnrealized) == + (env.current()->rules().enabled(featureLendingProtocolV1_1) && + getVaultVersion(vaultSle) == VaultVersion::CashBasis + ? principalOutstanding + : totalValue - managementFeeOutstanding)); + } + else + { + env.test.BEAST_EXPECT(vaultSle->at(sfLossUnrealized) == 0); + } + } + } + } + } + + /** + * Checks both the loan and broker expect states against the ledger + */ + void + operator()(LoanState const& state) const + { + operator()( + state.previousPaymentDate, + state.nextPaymentDate, + state.paymentRemaining, + state.loanScale, + state.totalValue, + state.principalOutstanding, + state.managementFeeOutstanding, + state.periodicPayment, + state.flags); + }; + }; + + BrokerInfo + createVaultAndBroker( + jtx::Env& env, + jtx::PrettyAsset const& asset, + jtx::Account const& lender, + BrokerParameters const& params = BrokerParameters::defaults()) + { + using namespace jtx; + + Vault const vault{env}; + + auto const deposit = asset(params.vaultDeposit); + auto const debtMaximumValue = asset(params.debtMax).value(); + auto const coverDepositValue = asset(params.coverDeposit).value(); + + auto const coverRateMinValue = params.coverRateMin; + + auto [tx, vaultKeylet] = vault.create({.owner = lender, .asset = asset}); + if (params.vaultScale) + tx[sfScale] = *params.vaultScale; + env(tx); + env.close(); + BEAST_EXPECT(env.le(vaultKeylet)); + + env(vault.deposit({.depositor = lender, .id = vaultKeylet.key, .amount = deposit})); + env.close(); + if (auto const vault = env.le(keylet::vault(vaultKeylet.key)); BEAST_EXPECT(vault)) + { + BEAST_EXPECT(vault->at(sfAssetsAvailable) == deposit.value()); + } + + auto const keylet = keylet::loanBroker(lender.id(), env.seq(lender)); + + using namespace loanBroker; + env(set(lender, vaultKeylet.key, params.flags), + kData(params.data), + kManagementFeeRate(params.managementFeeRate), + kDebtMaximum(debtMaximumValue), + kCoverRateMinimum(coverRateMinValue), + kCoverRateLiquidation(TenthBips32(params.coverRateLiquidation))); + + if (coverDepositValue != beast::kZero) + env(coverDeposit(lender, keylet.key, coverDepositValue)); + + env.close(); + + return {asset, keylet, vaultKeylet, params}; + } + + /** + * Get the state without checking anything + */ + LoanState + getCurrentState(jtx::Env const& env, BrokerInfo const& broker, Keylet const& loanKeylet) + { + using d = NetClock::duration; + using tp = NetClock::time_point; + + // Lookup the current loan state + if (auto loan = env.le(loanKeylet); BEAST_EXPECT(loan)) + { + return LoanState{ + .previousPaymentDate = loan->at(sfPreviousPaymentDueDate), + .startDate = tp{d{loan->at(sfStartDate)}}, + .nextPaymentDate = loan->at(sfNextPaymentDueDate), + .paymentRemaining = loan->at(sfPaymentRemaining), + .loanScale = loan->at(sfLoanScale), + .totalValue = loan->at(sfTotalValueOutstanding), + .principalOutstanding = loan->at(sfPrincipalOutstanding), + .managementFeeOutstanding = loan->at(sfManagementFeeOutstanding), + .periodicPayment = loan->at(sfPeriodicPayment), + .flags = loan->at(sfFlags), + .paymentInterval = loan->at(sfPaymentInterval), + .interestRate = TenthBips32{loan->at(sfInterestRate)}, + }; + } + return LoanState{}; + } + + /** + * Get the state and check the values against the parameters used in + * `lifecycle` + */ + LoanState + getCurrentState( + jtx::Env const& env, + BrokerInfo const& broker, + Keylet const& loanKeylet, + VerifyLoanStatus const& verifyLoanStatus) + { + using namespace std::chrono_literals; + using d = NetClock::duration; + using tp = NetClock::time_point; + + auto const state = getCurrentState(env, broker, loanKeylet); + BEAST_EXPECT(state.previousPaymentDate == 0); + BEAST_EXPECT(tp{d{state.nextPaymentDate}} == state.startDate + 600s); + BEAST_EXPECT(state.paymentRemaining == 12); + BEAST_EXPECT(state.principalOutstanding == broker.asset(1000).value()); + BEAST_EXPECT( + state.loanScale >= + (broker.asset.integral() + ? 0 + : std::max(broker.vaultScale(env), state.principalOutstanding.exponent()))); + BEAST_EXPECT(state.paymentInterval == 600); + { + NumberRoundModeGuard const mg(Number::RoundingMode::Upward); + BEAST_EXPECT( + state.totalValue == + roundToAsset( + broker.asset, state.periodicPayment * state.paymentRemaining, state.loanScale)); + } + BEAST_EXPECT( + state.managementFeeOutstanding == + computeManagementFee( + broker.asset, + state.totalValue - state.principalOutstanding, + broker.params.managementFeeRate, + state.loanScale)); + + verifyLoanStatus(state); + + return state; + } + + bool + canImpairLoan(jtx::Env const& env, BrokerInfo const& broker, LoanState const& state) + { + if (auto const brokerSle = env.le(keylet::loanBroker(broker.brokerID)); + BEAST_EXPECT(brokerSle)) + { + if (auto const vaultSle = env.le(keylet::vault(brokerSle->at(sfVaultID))); + BEAST_EXPECT(vaultSle)) + { + // log << vaultSle->getJson() << std::endl; + auto const assetsUnavailable = + vaultSle->at(sfAssetsTotal) - vaultSle->at(sfAssetsAvailable); + auto const unrealizedLoss = vaultSle->at(sfLossUnrealized) + + (env.current()->rules().enabled(featureLendingProtocolV1_1) && + getVaultVersion(vaultSle) == VaultVersion::CashBasis + ? state.principalOutstanding + : state.totalValue - state.managementFeeOutstanding); + + if (!BEAST_EXPECT(unrealizedLoss <= assetsUnavailable)) + { + return false; + } + } + } + return true; + } + + enum class AssetType { XRP = 0, IOU = 1, MPT = 2 }; + + // Specify the accounts as params to allow other accounts to be used + jtx::PrettyAsset + createAsset( + jtx::Env& env, + AssetType assetType, + BrokerParameters const& brokerParams, + jtx::Account const& issuer, + jtx::Account const& lender, + jtx::Account const& borrower) + { + using namespace jtx; + + switch (assetType) + { + case AssetType::XRP: + // TODO: remove the factor, and set up loans in drops + return PrettyAsset{xrpIssue(), 1'000'000}; + + case AssetType::IOU: { + PrettyAsset const asset{issuer[iouCurrency_]}; + + auto const limit = + asset(100 * (brokerParams.vaultDeposit + brokerParams.coverDeposit)); + if (lender != issuer) + env(trust(lender, limit)); + if (borrower != issuer) + env(trust(borrower, limit)); + + return asset; + } + + case AssetType::MPT: { + // Enough to cover initial fees + if (!env.le(keylet::account(issuer))) + env.fund(env.current()->fees().accountReserve(10, 1) * 10, issuer); + if (!env.le(keylet::account(lender))) + env.fund(env.current()->fees().accountReserve(10, 1) * 10, noripple(lender)); + if (!env.le(keylet::account(borrower))) + env.fund(env.current()->fees().accountReserve(10, 1) * 10, noripple(borrower)); + + MPTTester mptt{env, issuer, kMptInitNoFund}; + mptt.create({.flags = tfMPTCanClawback | tfMPTCanTransfer | tfMPTCanLock}); + // Scale the MPT asset so interest is interesting + PrettyAsset const asset{mptt.issuanceID(), 10'000}; + // Need to do the authorization here because mptt isn't + // accessible outside + if (lender != issuer) + mptt.authorize({.account = lender}); + if (borrower != issuer) + mptt.authorize({.account = borrower}); + + env.close(); + + return asset; + } + + default: + throw std::runtime_error("Unknown asset type"); + } + } + + // Predicts the keylet of the next loan `broker` will originate, before + // that loan exists, by reading the broker's current LoanSequence. + static Keylet + nextLoanKeylet(jtx::Env const& env, BrokerInfo const& broker) + { + auto const brokerStateBefore = env.le(keylet::loanBroker(broker.brokerID)); + auto const loanSequence = brokerStateBefore->at(sfLoanSequence); + return keylet::loan(broker.brokerID, loanSequence); + } + + // Funds issuer/lender/borrower with XRP, creates an IOU asset issued by + // `issuer`, establishes trustlines for lender and borrower, and pays + // them starting balances. This is the exact setup shared by several of + // the fuzzer-derived regression tests below. + jtx::PrettyAsset + createFundedIouAsset( + jtx::Env& env, + jtx::Account const& issuer, + jtx::Account const& lender, + jtx::Account const& borrower, + Number const& lenderPay = 100'000'000, + Number const& borrowerPay = 1'000'000) + { + using namespace jtx; + + env.fund(XRP(1'000'000), issuer, lender, borrower); + env.close(); + + PrettyAsset const iouAsset = issuer[iouCurrency_]; + auto trustLenderTx = env.json(trust(lender, iouAsset(1'000'000'000))); + env(trustLenderTx); + auto trustBorrowerTx = env.json(trust(borrower, iouAsset(1'000'000'000))); + env(trustBorrowerTx); + auto payLenderTx = pay(issuer, lender, iouAsset(lenderPay)); + env(payLenderTx); + auto payIssuerTx = pay(issuer, borrower, iouAsset(borrowerPay)); + env(payIssuerTx); + env.close(); + + return iouAsset; + } + + // Funds issuer/lender/borrower with XRP, sets DefaultRipple on the + // issuer, creates a "USD" IOU asset with a large trust limit, and pays + // lender/borrower starting balances. Shared setup for several + // overpayment/rounding regression tests below. + 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) + { + using namespace jtx; + + env.fund(XRP(1'000'000), issuer, lender, borrower); + 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(lender, iouLimit)); + env(trust(borrower, iouLimit)); + env(pay(issuer, lender, iouAsset(lenderPay))); + env(pay(issuer, borrower, iouAsset(borrowerPay))); + env.close(); + + return iouAsset; + } + + // Returns the broker's pseudo-account, or `fallback` if the broker's + // ledger entry cannot be read. + jtx::Account + brokerPseudoAccount(jtx::Env const& env, BrokerInfo const& broker, jtx::Account const& fallback) + { + auto const brokerSle = env.le(keylet::loanBroker(broker.brokerID)); + if (!BEAST_EXPECT(brokerSle)) + return fallback; + auto const brokerPseudo = brokerSle->at(sfAccount); + return jtx::Account("Broker pseudo-account", brokerPseudo); + } + + void + describeLoan( + jtx::Env& env, + BrokerParameters const& brokerParams, + LoanParameters const& loanParams, + AssetType assetType, + jtx::Account const& issuer, + jtx::Account const& lender, + jtx::Account const& borrower) + { + using namespace jtx; + + auto const asset = createAsset(env, assetType, brokerParams, issuer, lender, borrower); + auto const principal = asset(loanParams.principalRequest).number(); + auto const interest = loanParams.interest.value_or(TenthBips32{}); + auto const interval = loanParams.payInterval.value_or(LoanSet::kDefaultPaymentInterval); + auto const total = loanParams.payTotal.value_or(LoanSet::kDefaultPaymentTotal); + auto const feeRate = brokerParams.managementFeeRate; + auto const props = computeLoanProperties( + env.current()->rules(), + asset, + principal, + interest, + interval, + total, + feeRate, + asset(brokerParams.vaultDeposit).number().exponent()); + log << "Loan properties:\n" + << "\tPrincipal: " << principal << std::endl + << "\tInterest rate: " << interest << std::endl + << "\tPayment interval: " << interval << std::endl + << "\tManagement Fee Rate: " << feeRate << std::endl + << "\tTotal Payments: " << total << std::endl + << "\tPeriodic Payment: " << props.periodicPayment << std::endl + << "\tTotal Value: " << props.loanState.valueOutstanding << std::endl + << "\tManagement Fee: " << props.loanState.managementFeeDue << std::endl + << "\tLoan Scale: " << props.loanScale << std::endl + << "\tFirst payment principal: " << props.firstPaymentPrincipal << std::endl; + + // checkGuards returns a TER, so success is 0 + BEAST_EXPECT(!checkLoanGuards( + asset, + asset(loanParams.principalRequest).number(), + loanParams.interest.value_or(TenthBips32{}) != beast::kZero, + loanParams.payTotal.value_or(LoanSet::kDefaultPaymentTotal), + props, + env.journal)); + } + + std::optional> + createLoan( + jtx::Env& env, + AssetType assetType, + BrokerParameters const& brokerParams, + LoanParameters const& loanParams, + jtx::Account const& issuer, + jtx::Account const& lender, + jtx::Account const& borrower) + { + using namespace jtx; + + // Enough to cover initial fees + env.fund(env.current()->fees().accountReserve(10, 1) * 10, issuer); + if (lender != issuer) + env.fund(env.current()->fees().accountReserve(10, 1) * 10, noripple(lender)); + if (borrower != issuer && borrower != lender) + env.fund(env.current()->fees().accountReserve(10, 1) * 10, noripple(borrower)); + + describeLoan(env, brokerParams, loanParams, assetType, issuer, lender, borrower); + + // Make the asset + auto const asset = createAsset(env, assetType, brokerParams, issuer, lender, borrower); + + env.close(); + if (asset.native() || lender != issuer) + { + env( + pay((asset.native() ? env.master : issuer), + lender, + asset(brokerParams.vaultDeposit + brokerParams.coverDeposit))); + } + // Fund the borrower later once we know the total loan + // size + + BrokerInfo const broker = createVaultAndBroker(env, asset, lender, brokerParams); + + auto const pseudoAcctOpt = [&]() -> std::optional { + auto const brokerSle = env.le(keylet::loanBroker(broker.brokerID)); + if (!BEAST_EXPECT(brokerSle)) + return std::nullopt; + auto const brokerPseudo = brokerSle->at(sfAccount); + return Account("Broker pseudo-account", brokerPseudo); + }(); + if (!pseudoAcctOpt) + return std::nullopt; + Account const& pseudoAcct = *pseudoAcctOpt; + + auto const loanKeyletOpt = [&]() -> std::optional { + auto const brokerSle = env.le(keylet::loanBroker(broker.brokerID)); + if (!BEAST_EXPECT(brokerSle)) + return std::nullopt; + + // Broker has no loans + BEAST_EXPECT(brokerSle->at(sfOwnerCount) == 0); + + // The loan keylet is based on the LoanSequence of the + // _LOAN_BROKER_ object. + auto const loanSequence = brokerSle->at(sfLoanSequence); + return keylet::loan(broker.brokerID, loanSequence); + }(); + if (!loanKeyletOpt) + return std::nullopt; + Keylet const& loanKeylet = *loanKeyletOpt; + + env(loanParams(env, broker)); + + env.close(); + + return std::make_tuple(broker, loanKeylet, pseudoAcct); + } + + static void + topUpBorrower( + jtx::Env& env, + BrokerInfo const& broker, + jtx::Account const& issuer, + jtx::Account const& borrower, + LoanState const& state, + std::optional const& servFee) + { + using namespace jtx; + + STAmount const serviceFee = broker.asset(servFee.value_or(0)); + + // Ensure the borrower has enough funds to make the payments + // (including tx fees, if necessary) + auto const borrowerBalance = env.balance(borrower, broker.asset); + + auto const baseFee = env.current()->fees().base; + + // Add extra for transaction fees and reserves, if appropriate, or a + // tiny amount for the extra paid in each transaction + auto const totalNeeded = state.totalValue + (serviceFee * state.paymentRemaining) + + (broker.asset.native() ? Number( + baseFee * state.paymentRemaining + + accountReserve(*env.current(), borrower.id(), env.journal)) + : broker.asset(15).number()); + + auto const shortage = totalNeeded - borrowerBalance.number(); + + if (shortage > beast::kZero && (broker.asset.native() || issuer != borrower)) + { + env( + pay((broker.asset.native() ? env.master : issuer), + borrower, + STAmount{broker.asset, shortage})); + } + } + + void + makeLoanPayments( + jtx::Env& env, + BrokerInfo const& broker, + LoanParameters const& loanParams, + Keylet const& loanKeylet, + VerifyLoanStatus const& verifyLoanStatus, + jtx::Account const& issuer, + jtx::Account const& lender, + jtx::Account const& borrower, + PaymentParameters const& paymentParams = PaymentParameters::defaults()) + { + // Make all the individual payments + using namespace jtx; + using namespace jtx::loan; + using namespace std::chrono_literals; + using d = NetClock::duration; + + bool const showStepBalances = paymentParams.showStepBalances; + + auto const currencyLabel = getCurrencyLabel(broker.asset); + + auto const baseFee = env.current()->fees().base; + + env.close(); + auto state = getCurrentState(env, broker, loanKeylet); + + verifyLoanStatus(state); + + STAmount const serviceFee = broker.asset(loanParams.serviceFee.value_or(0)); + + topUpBorrower(env, broker, issuer, borrower, state, loanParams.serviceFee); + + // Periodic payment amount will consist of + // 1. principal outstanding (1000) + // 2. interest interest rate (at 12%) + // 3. payment interval (600s) + // 4. loan service fee (2) + // Calculate these values without the helper functions + // to verify they're working correctly The numbers in + // the below BEAST_EXPECTs may not hold across assets. + auto const periodicRate = loanPeriodicRate(state.interestRate, state.paymentInterval); + STAmount const roundedPeriodicPayment{ + broker.asset, + roundPeriodicPayment(broker.asset, state.periodicPayment, state.loanScale)}; + + if (!showStepBalances) + { + log << currencyLabel << " Payment components: " + << "Payments remaining, " + << "rawInterest, rawPrincipal, " + "rawMFee, " + << "trackedValueDelta, trackedPrincipalDelta, " + "trackedInterestDelta, trackedMgmtFeeDelta, special" + << std::endl; + } + + // Include the service fee + STAmount const totalDue = roundToScale( + roundedPeriodicPayment + serviceFee, state.loanScale, Number::RoundingMode::Upward); + + auto currentRoundedState = constructLoanState( + state.totalValue, state.principalOutstanding, state.managementFeeOutstanding); + { + auto const raw = computeTheoreticalLoanState( + env.current()->rules(), + state.periodicPayment, + periodicRate, + state.paymentRemaining, + broker.params.managementFeeRate); + + if (showStepBalances) + { + log << currencyLabel << " Starting loan balances: " + << "\n\tTotal value: " << currentRoundedState.valueOutstanding + << "\n\tPrincipal: " << currentRoundedState.principalOutstanding + << "\n\tInterest: " << currentRoundedState.interestDue + << "\n\tMgmt fee: " << currentRoundedState.managementFeeDue + << "\n\tPayments remaining " << state.paymentRemaining << std::endl; + } + else + { + log << currencyLabel << " Loan starting state: " << state.paymentRemaining << ", " + << raw.interestDue << ", " << raw.principalOutstanding << ", " + << raw.managementFeeDue << ", " << currentRoundedState.valueOutstanding << ", " + << currentRoundedState.principalOutstanding << ", " + << currentRoundedState.interestDue << ", " + << currentRoundedState.managementFeeDue << std::endl; + } + } + + // Try to pay a little extra to show that it's _not_ + // taken + auto const extraAmount = paymentParams.overpaymentExtra + ? broker.asset(*paymentParams.overpaymentExtra).value() + : std::min(broker.asset(10).value(), STAmount{broker.asset, totalDue / 20}); + + STAmount const transactionAmount = + STAmount{broker.asset, totalDue * paymentParams.overpaymentFactor} + extraAmount; + + auto const borrowerInitialBalance = env.balance(borrower, broker.asset).number(); + auto const initialState = state; + xrpl::detail::PaymentComponents totalPaid{ + .trackedValueDelta = 0, .trackedPrincipalDelta = 0, .trackedManagementFeeDelta = 0}; + Number totalInterestPaid = 0; + Number totalFeesPaid = 0; + std::size_t totalPaymentsMade = 0; + + xrpl::LoanState currentTrueState = computeTheoreticalLoanState( + env.current()->rules(), + state.periodicPayment, + periodicRate, + state.paymentRemaining, + broker.params.managementFeeRate); + + auto validateBorrowerBalance = [&]() { + if (borrower == issuer || !paymentParams.validateBalances) + return; + auto const totalSpent = + (totalPaid.trackedValueDelta + totalFeesPaid + + (broker.asset.native() ? Number(baseFee) * totalPaymentsMade : kNumZero)); + BEAST_EXPECT( + env.balance(borrower, broker.asset).number() == + borrowerInitialBalance - totalSpent); + }; + + auto const defaultRound = broker.asset.integral() ? 3 : 0; + auto truncate = [defaultRound](Number const& n, std::optional places = std::nullopt) { + auto const p = places.value_or(defaultRound); + if (p == 0) + return n; + auto const factor = Number{1, p}; + return (n * factor).truncate() / factor; + }; + while (state.paymentRemaining > 0) + { + validateBorrowerBalance(); + // Compute the expected principal amount + auto const paymentComponents = xrpl::detail::computePaymentComponents( + env.current()->rules(), + broker.asset.raw(), + state.loanScale, + state.totalValue, + state.principalOutstanding, + state.managementFeeOutstanding, + state.periodicPayment, + periodicRate, + state.paymentRemaining, + broker.params.managementFeeRate); + + BEAST_EXPECT( + paymentComponents.trackedValueDelta <= roundedPeriodicPayment || + (paymentComponents.specialCase == xrpl::detail::PaymentSpecialCase::Final && + paymentComponents.trackedValueDelta >= roundedPeriodicPayment)); + BEAST_EXPECT( + paymentComponents.trackedValueDelta == + paymentComponents.trackedPrincipalDelta + paymentComponents.trackedInterestPart() + + paymentComponents.trackedManagementFeeDelta); + + xrpl::LoanState const nextTrueState = computeTheoreticalLoanState( + env.current()->rules(), + state.periodicPayment, + periodicRate, + state.paymentRemaining - 1, + broker.params.managementFeeRate); + xrpl::detail::LoanStateDeltas const deltas = currentTrueState - nextTrueState; + BEAST_EXPECT( + deltas.total() == deltas.principal + deltas.interest + deltas.managementFee); + BEAST_EXPECT( + paymentComponents.specialCase == xrpl::detail::PaymentSpecialCase::Final || + deltas.total() == state.periodicPayment || + (state.loanScale - (deltas.total() - state.periodicPayment).exponent()) > 14); + + if (!showStepBalances) + { + log << currencyLabel << " Payment components: " << state.paymentRemaining << ", " + + << deltas.interest << ", " << deltas.principal << ", " << deltas.managementFee + << ", " << paymentComponents.trackedValueDelta << ", " + << paymentComponents.trackedPrincipalDelta << ", " + << paymentComponents.trackedInterestPart() << ", " + << paymentComponents.trackedManagementFeeDelta << ", " << [&]() -> char const* { + if (paymentComponents.specialCase == ::xrpl::detail::PaymentSpecialCase::Final) + return "final"; + if (paymentComponents.specialCase == ::xrpl::detail::PaymentSpecialCase::Extra) + return "extra"; + return "none"; + }() << std::endl; + } + + auto const totalDueAmount = + STAmount{broker.asset, paymentComponents.trackedValueDelta + serviceFee}; + + if (paymentParams.validateBalances) + { + // Due to the rounding algorithms to keep the interest and + // principal in sync with "true" values, the computed amount + // may be a little less than the rounded fixed payment + // amount. For integral types, the difference should be < 3 + // (1 unit for each of the interest and management fee). For + // IOUs, the difference should be dust. + Number const diff = totalDue - totalDueAmount; + BEAST_EXPECT( + paymentComponents.specialCase == xrpl::detail::PaymentSpecialCase::Final || + diff == beast::kZero || + (diff > beast::kZero && + ((broker.asset.integral() && (static_cast(diff) < 3)) || + (state.loanScale - diff.exponent() > 13)))); + + BEAST_EXPECT( + paymentComponents.trackedPrincipalDelta >= beast::kZero && + paymentComponents.trackedPrincipalDelta <= state.principalOutstanding); + BEAST_EXPECT( + paymentComponents.specialCase != xrpl::detail::PaymentSpecialCase::Final || + paymentComponents.trackedPrincipalDelta == state.principalOutstanding); + } + + auto const borrowerBalanceBeforePayment = env.balance(borrower, broker.asset); + + // Make the payment + env(pay(borrower, loanKeylet.key, transactionAmount, paymentParams.flags)); + + env.close(d{state.paymentInterval / 2}); + + if (paymentParams.validateBalances) + { + // Need to account for fees if the loan is in XRP + PrettyAmount adjustment = broker.asset(0); + if (broker.asset.native()) + { + adjustment = env.current()->fees().base; + } + + // Check the result + verifyLoanStatus.checkPayment( + state.loanScale, + borrower, + borrowerBalanceBeforePayment, + totalDueAmount, + adjustment); + } + + if (showStepBalances) + { + auto const loanSle = env.le(loanKeylet); + if (!BEAST_EXPECT(loanSle)) + { + // No reason for this not to exist + return; + } + auto const current = constructLoanState(loanSle); + auto const errors = nextTrueState - current; + log << currencyLabel << " Loan balances: " + << "\n\tAmount taken: " << paymentComponents.trackedValueDelta + << "\n\tTotal value: " << current.valueOutstanding + << " (true: " << truncate(nextTrueState.valueOutstanding) + << ", error: " << truncate(errors.total()) + << ")\n\tPrincipal: " << current.principalOutstanding + << " (true: " << truncate(nextTrueState.principalOutstanding) + << ", error: " << truncate(errors.principal) + << ")\n\tInterest: " << current.interestDue + << " (true: " << truncate(nextTrueState.interestDue) + << ", error: " << truncate(errors.interest) + << ")\n\tMgmt fee: " << current.managementFeeDue + << " (true: " << truncate(nextTrueState.managementFeeDue) + << ", error: " << truncate(errors.managementFee) << ")\n\tPayments remaining " + << loanSle->at(sfPaymentRemaining) << std::endl; + + currentRoundedState = current; + } + + --state.paymentRemaining; + state.previousPaymentDate = state.nextPaymentDate; + if (paymentComponents.specialCase == xrpl::detail::PaymentSpecialCase::Final) + { + state.paymentRemaining = 0; + state.nextPaymentDate = 0; + } + else + { + state.nextPaymentDate += state.paymentInterval; + } + state.principalOutstanding -= paymentComponents.trackedPrincipalDelta; + state.managementFeeOutstanding -= paymentComponents.trackedManagementFeeDelta; + state.totalValue -= paymentComponents.trackedValueDelta; + + if (paymentParams.validateBalances) + verifyLoanStatus(state); + + totalPaid.trackedValueDelta += paymentComponents.trackedValueDelta; + totalPaid.trackedPrincipalDelta += paymentComponents.trackedPrincipalDelta; + totalPaid.trackedManagementFeeDelta += paymentComponents.trackedManagementFeeDelta; + totalInterestPaid += paymentComponents.trackedInterestPart(); + totalFeesPaid += serviceFee; + ++totalPaymentsMade; + + currentTrueState = nextTrueState; + } + validateBorrowerBalance(); + + // Loan is paid off + BEAST_EXPECT(state.paymentRemaining == 0); + BEAST_EXPECT(state.principalOutstanding == 0); + + auto const initialInterestDue = initialState.totalValue - + (initialState.principalOutstanding + initialState.managementFeeOutstanding); + if (paymentParams.validateBalances) + { + // Make sure all the payments add up + BEAST_EXPECT(totalPaid.trackedValueDelta == initialState.totalValue); + BEAST_EXPECT(totalPaid.trackedPrincipalDelta == initialState.principalOutstanding); + BEAST_EXPECT( + totalPaid.trackedManagementFeeDelta == initialState.managementFeeOutstanding); + // This is almost a tautology given the previous checks, but + // check it anyway for completeness. + BEAST_EXPECT(totalInterestPaid == initialInterestDue); + BEAST_EXPECT(totalPaymentsMade == initialState.paymentRemaining); + } + + if (showStepBalances) + { + auto const loanSle = env.le(loanKeylet); + if (!BEAST_EXPECT(loanSle)) + { + // No reason for this not to exist + return; + } + log << currencyLabel << " Total amounts paid: " + << "\n\tTotal value: " << totalPaid.trackedValueDelta + << " (initial: " << truncate(initialState.totalValue) + << ", error: " << truncate(initialState.totalValue - totalPaid.trackedValueDelta) + << ")\n\tPrincipal: " << totalPaid.trackedPrincipalDelta + << " (initial: " << truncate(initialState.principalOutstanding) << ", error: " + << truncate(initialState.principalOutstanding - totalPaid.trackedPrincipalDelta) + << ")\n\tInterest: " << totalInterestPaid + << " (initial: " << truncate(initialInterestDue) + << ", error: " << truncate(initialInterestDue - totalInterestPaid) + << ")\n\tMgmt fee: " << totalPaid.trackedManagementFeeDelta + << " (initial: " << truncate(initialState.managementFeeOutstanding) << ", error: " + << truncate( + initialState.managementFeeOutstanding - totalPaid.trackedManagementFeeDelta) + << ")\n\tTotal payments made: " << totalPaymentsMade << std::endl; + } + } + + void + runLoan( + AssetType assetType, + BrokerParameters const& brokerParams, + LoanParameters const& loanParams, + FeatureBitset features) + { + using namespace jtx; + + Account const issuer("issuer"); + Account const lender("lender"); + Account const borrower("borrower"); + + Env env(*this, features); + + auto loanResult = + createLoan(env, assetType, brokerParams, loanParams, issuer, lender, borrower); + if (BEAST_EXPECT(loanResult); !loanResult.has_value()) + return; + + auto broker = std::get(*loanResult); + auto loanKeylet = std::get(*loanResult); + auto pseudoAcct = std::get(*loanResult); + + VerifyLoanStatus const verifyLoanStatus(env, broker, pseudoAcct, loanKeylet); + + makeLoanPayments( + env, + broker, + loanParams, + loanKeylet, + verifyLoanStatus, + issuer, + lender, + borrower, + PaymentParameters{.showStepBalances = true}); + } + + /** + * Runs through the complete lifecycle of a loan + * + * 1. Create a loan. + * 2. Test a bunch of transaction failure conditions. + * 3. Use the `toEndOfLife` callback to take the loan to 0. How that is done + * depends on the callback. e.g. Default, Early payoff, make all the + * normal payments, etc. + * 4. Delete the loan. The loan will alternate between being deleted by the + * lender and the borrower. + */ + void + lifecycle( + std::string const& caseLabel, + char const* label, + jtx::Env& env, + Number const& loanAmount, + int interestExponent, + jtx::Account const& lender, + jtx::Account const& borrower, + jtx::Account const& evan, + BrokerInfo const& broker, + jtx::Account const& pseudoAcct, + std::uint32_t flags, + // The end of life callback is expected to take the loan to 0 payments + // remaining, one way or another + std::function + toEndOfLife) + { + auto const [keylet, loanSequence] = [&]() { + auto const brokerSle = env.le(keylet::loanBroker(broker.brokerID)); + if (!BEAST_EXPECT(brokerSle)) + { + // will be invalid + return std::make_pair(keylet::loan(broker.brokerID), std::uint32_t(0)); + } + + // Broker has no loans + BEAST_EXPECT(brokerSle->at(sfOwnerCount) == 0); + + // The loan keylet is based on the LoanSequence of the _LOAN_BROKER_ + // object. + auto const loanSequence = brokerSle->at(sfLoanSequence); + return std::make_pair(keylet::loan(broker.brokerID, loanSequence), loanSequence); + }(); + + VerifyLoanStatus const verifyLoanStatus(env, broker, pseudoAcct, keylet); + + // No loans yet + verifyLoanStatus.checkBroker(0, 0, TenthBips32{0}, 1, 0, 0); + + if (!BEAST_EXPECT(loanSequence != 0)) + return; + + testcase << caseLabel << " " << label; + + using namespace jtx; + using namespace loan; + using namespace std::chrono_literals; + + auto applyExponent = [interestExponent, this](TenthBips32 value) mutable { + BEAST_EXPECT(value > TenthBips32(0)); + while (interestExponent > 0) + { + auto const oldValue = value; + value *= 10; + --interestExponent; + BEAST_EXPECT(value / 10 == oldValue); + } + while (interestExponent < 0) + { + auto const oldValue = value; + value /= 10; + ++interestExponent; + BEAST_EXPECT(value * 10 == oldValue); + } + return value; + }; + + auto const borrowerOwnerCount = env.ownerCount(borrower); + + auto const loanSetFee = env.current()->fees().base * 2; + LoanParameters const loanParams{ + .account = borrower, + .counter = lender, + .counterpartyExplicit = false, + .principalRequest = loanAmount, + .setFee = loanSetFee, + .originationFee = 1, + .serviceFee = 2, + .lateFee = 3, + .closeFee = 4, + .overFee = applyExponent(percentageToTenthBips(5) / 10), + .interest = applyExponent(percentageToTenthBips(12)), + // 2.4% + .lateInterest = applyExponent(percentageToTenthBips(24) / 10), + .closeInterest = applyExponent(percentageToTenthBips(36) / 10), + .overpaymentInterest = applyExponent(percentageToTenthBips(48) / 10), + .payTotal = 12, + .payInterval = 600, + .gracePd = 60, + .flags = flags, + }; + Number const principalRequestAmount = broker.asset(loanParams.principalRequest).value(); + auto const originationFeeAmount = broker.asset(*loanParams.originationFee).value(); + auto const serviceFeeAmount = broker.asset(*loanParams.serviceFee).value(); + auto const lateFeeAmount = broker.asset(*loanParams.lateFee).value(); + auto const closeFeeAmount = broker.asset(*loanParams.closeFee).value(); + + auto const borrowerStartbalance = env.balance(borrower, broker.asset); + + auto createJtx = loanParams(env, broker); + // Successfully create a Loan + env(createJtx); + + env.close(); + + auto const startDate = env.current()->header().parentCloseTime.time_since_epoch().count(); + + if (auto const brokerSle = env.le(keylet::loanBroker(broker.brokerID)); + BEAST_EXPECT(brokerSle)) + { + BEAST_EXPECT(brokerSle->at(sfOwnerCount) == 1); + } + + { + // Need to account for fees if the loan is in XRP + PrettyAmount adjustment = broker.asset(0); + if (broker.asset.native()) + { + adjustment = 2 * env.current()->fees().base; + } + + BEAST_EXPECT( + env.balance(borrower, broker.asset).value() == + borrowerStartbalance.value() + principalRequestAmount - originationFeeAmount - + adjustment.value()); + } + + auto const loanFlags = + createJtx.stx->isFlag(tfLoanOverpayment) ? lsfLoanOverpayment : LedgerSpecificFlags(0); + + if (auto loan = env.le(keylet); BEAST_EXPECT(loan)) + { + // log << "loan after create: " << to_string(loan->getJson()) + // << std::endl; + BEAST_EXPECT( + loan->isFlag(lsfLoanOverpayment) == createJtx.stx->isFlag(tfLoanOverpayment)); + BEAST_EXPECT(loan->at(sfLoanSequence) == loanSequence); + BEAST_EXPECT(loan->at(sfBorrower) == borrower.id()); + BEAST_EXPECT(loan->at(sfLoanBrokerID) == broker.brokerID); + BEAST_EXPECT(loan->at(sfLoanOriginationFee) == originationFeeAmount); + BEAST_EXPECT(loan->at(sfLoanServiceFee) == serviceFeeAmount); + BEAST_EXPECT(loan->at(sfLatePaymentFee) == lateFeeAmount); + BEAST_EXPECT(loan->at(sfClosePaymentFee) == closeFeeAmount); + BEAST_EXPECT(loan->at(sfOverpaymentFee) == *loanParams.overFee); + BEAST_EXPECT(loan->at(sfInterestRate) == *loanParams.interest); + BEAST_EXPECT(loan->at(sfLateInterestRate) == *loanParams.lateInterest); + BEAST_EXPECT(loan->at(sfCloseInterestRate) == *loanParams.closeInterest); + BEAST_EXPECT(loan->at(sfOverpaymentInterestRate) == *loanParams.overpaymentInterest); + BEAST_EXPECT(loan->at(sfStartDate) == startDate); + BEAST_EXPECT(loan->at(sfPaymentInterval) == *loanParams.payInterval); + BEAST_EXPECT(loan->at(sfGracePeriod) == *loanParams.gracePd); + BEAST_EXPECT(loan->at(sfPreviousPaymentDueDate) == 0); + BEAST_EXPECT(loan->at(sfNextPaymentDueDate) == startDate + *loanParams.payInterval); + BEAST_EXPECT(loan->at(sfPaymentRemaining) == *loanParams.payTotal); + BEAST_EXPECT( + loan->at(sfLoanScale) >= + (broker.asset.integral() + ? 0 + : std::max(broker.vaultScale(env), principalRequestAmount.exponent()))); + BEAST_EXPECT(loan->at(sfPrincipalOutstanding) == principalRequestAmount); + } + + auto state = getCurrentState(env, broker, keylet, verifyLoanStatus); + + auto const loanProperties = computeLoanProperties( + env.current()->rules(), + broker.asset.raw(), + state.principalOutstanding, + state.interestRate, + state.paymentInterval, + state.paymentRemaining, + broker.params.managementFeeRate, + state.loanScale); + + verifyLoanStatus( + 0, + startDate + *loanParams.payInterval, + *loanParams.payTotal, + state.loanScale, + loanProperties.loanState.valueOutstanding, + principalRequestAmount, + loanProperties.loanState.managementFeeDue, + loanProperties.periodicPayment, + loanFlags | 0); + + // Manage the loan + // no-op + env(manage(lender, keylet.key, 0)); + { + // no flags + auto jt = manage(lender, keylet.key, 0); + jt.removeMember(sfFlags.getName()); + env(jt); + } + // Only the lender can manage + env(manage(evan, keylet.key, 0), Ter(tecNO_PERMISSION)); + // unknown flags + env(manage(lender, keylet.key, tfLoanManageMask), Ter(temINVALID_FLAG)); + // combinations of flags are not allowed + env(manage(lender, keylet.key, tfLoanUnimpair | tfLoanImpair), Ter(temINVALID_FLAG)); + env(manage(lender, keylet.key, tfLoanImpair | tfLoanDefault), Ter(temINVALID_FLAG)); + env(manage(lender, keylet.key, tfLoanUnimpair | tfLoanDefault), Ter(temINVALID_FLAG)); + env(manage(lender, keylet.key, tfLoanUnimpair | tfLoanImpair | tfLoanDefault), + Ter(temINVALID_FLAG)); + // invalid loan ID + env(manage(lender, broker.brokerID, tfLoanImpair), Ter(tecNO_ENTRY)); + // Loan is unimpaired, can't unimpair it again + env(manage(lender, keylet.key, tfLoanUnimpair), Ter(tecNO_PERMISSION)); + // Loan is unimpaired, it can go into default, but only after it's past + // due + env(manage(lender, keylet.key, tfLoanDefault), Ter(tecTOO_SOON)); + + // Check the vault + bool const canImpair = canImpairLoan(env, broker, state); + // Impair the loan, if possible + env(manage(lender, keylet.key, tfLoanImpair), + canImpair ? Ter(tesSUCCESS) : Ter(tecLIMIT_EXCEEDED)); + // Unimpair the loan + env(manage(lender, keylet.key, tfLoanUnimpair), + canImpair ? Ter(tesSUCCESS) : Ter(tecNO_PERMISSION)); + + auto const nextDueDate = startDate + *loanParams.payInterval; + + env.close(); + + verifyLoanStatus( + 0, + nextDueDate, + *loanParams.payTotal, + loanProperties.loanScale, + loanProperties.loanState.valueOutstanding, + principalRequestAmount, + loanProperties.loanState.managementFeeDue, + loanProperties.periodicPayment, + loanFlags | 0); + + // Can't delete the loan yet. It has payments remaining. + env(del(lender, keylet.key), Ter(tecHAS_OBLIGATIONS)); + + if (BEAST_EXPECT(toEndOfLife)) + toEndOfLife(keylet, verifyLoanStatus); + env.close(); + + // Verify the loan is at EOL + if (auto loan = env.le(keylet); BEAST_EXPECT(loan)) + { + BEAST_EXPECT(loan->at(sfPaymentRemaining) == 0); + BEAST_EXPECT(loan->at(sfPrincipalOutstanding) == 0); + } + auto const borrowerStartingBalance = env.balance(borrower, broker.asset); + + // Try to delete the loan broker with an active loan + env(loanBroker::del(lender, broker.brokerID), Ter(tecHAS_OBLIGATIONS)); + // Ensure the above tx doesn't get ordered after the LoanDelete and + // delete our broker! + env.close(); + + // Test failure cases + env(del(lender, keylet.key, tfLoanOverpayment), Ter(temINVALID_FLAG)); + env(del(evan, keylet.key), Ter(tecNO_PERMISSION)); + env(del(lender, broker.brokerID), Ter(tecNO_ENTRY)); + + // Delete the loan + // Either the borrower or the lender can delete the loan. Alternate + // between who does it across tests. + static unsigned kDeleteCounter = 0; + auto const deleter = ((++kDeleteCounter % 2) != 0u) ? lender : borrower; + env(del(deleter, keylet.key)); + env.close(); + + PrettyAmount adjustment = broker.asset(0); + if (deleter == borrower) + { + // Need to account for fees if the loan is in XRP + if (broker.asset.native()) + { + adjustment = env.current()->fees().base; + } + } + + // No loans left + verifyLoanStatus.checkBroker(0, 0, *loanParams.interest, 1, 0, 0); + + BEAST_EXPECT( + env.balance(borrower, broker.asset).value() == + borrowerStartingBalance.value() - adjustment); + BEAST_EXPECT(env.ownerCount(borrower) == borrowerOwnerCount); + + if (auto const brokerSle = env.le(keylet::loanBroker(broker.brokerID)); + BEAST_EXPECT(brokerSle)) + { + BEAST_EXPECT(brokerSle->at(sfOwnerCount) == 0); + } + } + + static std::string + getCurrencyLabel(Asset const& asset) + { + if (asset.native()) + return "XRP"; + if (asset.holds()) + return "IOU"; + if (asset.holds()) + return "MPT"; + return "Unknown"; + } +}; + +} // namespace xrpl::test diff --git a/src/test/app/Loan_test.cpp b/src/test/app/Loan_test.cpp index 8a6f1669df8..55c79e45bb8 100644 --- a/src/test/app/Loan_test.cpp +++ b/src/test/app/Loan_test.cpp @@ -1,107 +1,10 @@ -#include -// -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -#include - -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include +#include namespace xrpl::test { -class Loan_test : public beast::unit_test::Suite +class Loan_test : public LoanTestBase { -protected: - // Ensure that all the features needed for Lending Protocol are included, - // even if they are set to unsupported. - // - // featureLendingProtocolV1_1 is excluded from the default set: it changes - // Vault/LoanBroker accounting (AssetsTotal/DebtTotal/LossUnrealized), and - // most of this file's tests assert whole-life-specific expected values - // for those fields. Tests that specifically exercise the amendment opt - // it back in explicitly (e.g. `all_ | featureLendingProtocolV1_1`). - FeatureBitset const all_{jtx::testableAmendments() - featureLendingProtocolV1_1}; - std::string const iouCurrency_{"IOU"}; - +private: void testDisabled() { @@ -149,7920 +52,2999 @@ class Loan_test : public beast::unit_test::Suite failAll(all_ - featureLendingProtocol); } - struct BrokerParameters - { - Number vaultDeposit = 1'000'000; - Number debtMax = 25'000; - TenthBips32 coverRateMin = percentageToTenthBips(10); - int coverDeposit = 1000; - TenthBips16 managementFeeRate{100}; - TenthBips32 coverRateLiquidation = percentageToTenthBips(25); - std::string data = {}; // NOLINT(readability-redundant-member-init) - std::uint32_t flags = 0; - // If set, the vault is created with this sfScale value. Useful for - // tests that need finer loanScale to exercise rounding edge cases. - std::optional vaultScale = - std::nullopt; // NOLINT(readability-redundant-member-init) - - [[nodiscard]] Number - maxCoveredLoanValue(Number const& currentDebt) const - { - NumberRoundModeGuard const mg(Number::RoundingMode::Downward); - auto debtLimit = coverDeposit * kTenthBipsPerUnity.value() / coverRateMin.value(); - - return debtLimit - currentDebt; - } - - static BrokerParameters const& - defaults() - { - static BrokerParameters const kResult{}; - return kResult; - } - - // TODO: create an operator() which returns a transaction similar to - // LoanParameters - }; - - struct BrokerInfo - { - jtx::PrettyAsset asset; - uint256 brokerID; - uint256 vaultID; - BrokerParameters params; - BrokerInfo( - jtx::PrettyAsset const& asset, - Keylet const& brokerKeylet, - Keylet const& vaultKeylet, - BrokerParameters p) - : asset(asset) - , brokerID(brokerKeylet.key) - , vaultID(vaultKeylet.key) - , params(std::move(p)) - { - } - - [[nodiscard]] Keylet - brokerKeylet() const - { - return keylet::loanBroker(brokerID); - } - [[nodiscard]] Keylet - vaultKeylet() const - { - return keylet::vault(vaultID); - } - - [[nodiscard]] int - vaultScale(jtx::Env const& env) const - { - using namespace jtx; - - auto const vaultSle = env.le(keylet::vault(vaultID)); - return getAssetsTotalScale(vaultSle); - } - }; - - struct LoanParameters - { - // The account submitting the transaction. May be borrower or broker. - jtx::Account account; - // The counterparty. Should be the other of borrower or broker. - jtx::Account counter; - // Whether the counterparty is specified in the `counterparty` field, or - // only signs. - bool counterpartyExplicit = true; - Number principalRequest; - // NOLINTBEGIN(readability-redundant-member-init) - std::optional setFee = std::nullopt; - std::optional originationFee = std::nullopt; - std::optional serviceFee = std::nullopt; - std::optional lateFee = std::nullopt; - std::optional closeFee = std::nullopt; - std::optional overFee = std::nullopt; - std::optional interest = std::nullopt; - std::optional lateInterest = std::nullopt; - std::optional closeInterest = std::nullopt; - std::optional overpaymentInterest = std::nullopt; - std::optional payTotal = std::nullopt; - std::optional payInterval = std::nullopt; - std::optional gracePd = std::nullopt; - std::optional flags = std::nullopt; - // NOLINTEND(readability-redundant-member-init) - - template - jtx::JTx - operator()(jtx::Env& env, BrokerInfo const& broker, FN const&... fN) const - { - using namespace jtx; - using namespace jtx::loan; - - JTx jt{loan::set( - account, - broker.brokerID, - broker.asset(principalRequest).number(), - flags.value_or(0))}; - - Sig(sfCounterpartySignature, counter)(env, jt); - - Fee{setFee.value_or(env.current()->fees().base * 2)}(env, jt); - - if (counterpartyExplicit) - kCounterparty(counter)(env, jt); - if (originationFee) - kLoanOriginationFee(broker.asset(*originationFee).number())(env, jt); - if (serviceFee) - kLoanServiceFee(broker.asset(*serviceFee).number())(env, jt); - if (lateFee) - kLatePaymentFee(broker.asset(*lateFee).number())(env, jt); - if (closeFee) - kClosePaymentFee(broker.asset(*closeFee).number())(env, jt); - if (overFee) - kOverpaymentFee (*overFee)(env, jt); - if (interest) - kInterestRate (*interest)(env, jt); - if (lateInterest) - kLateInterestRate (*lateInterest)(env, jt); - if (closeInterest) - kCloseInterestRate (*closeInterest)(env, jt); - if (overpaymentInterest) - kOverpaymentInterestRate (*overpaymentInterest)(env, jt); - if (payTotal) - kPaymentTotal (*payTotal)(env, jt); - if (payInterval) - kPaymentInterval (*payInterval)(env, jt); - if (gracePd) - kGracePeriod (*gracePd)(env, jt); - - return env.jt(jt, fN...); - } - }; - - struct PaymentParameters - { - Number overpaymentFactor = Number{1}; - std::optional overpaymentExtra = std::nullopt; - std::uint32_t flags = 0; - bool showStepBalances = false; - bool validateBalances = true; - - static PaymentParameters const& - defaults() - { - static PaymentParameters const kResult{}; - return kResult; - } - }; - - struct LoanState - { - std::uint32_t previousPaymentDate = 0; - NetClock::time_point startDate; - std::uint32_t nextPaymentDate = 0; - std::uint32_t paymentRemaining = 0; - std::int32_t const loanScale = 0; - Number totalValue = 0; - Number principalOutstanding = 0; - Number managementFeeOutstanding = 0; - Number periodicPayment = 0; - std::uint32_t flags = 0; - std::uint32_t const paymentInterval = 0; - TenthBips32 const interestRate{}; - }; - /** - * Helper class to compare the expected state of a loan and loan broker - * against the data in the ledger. + * Wrapper to run a series of lifecycle tests for a given asset and loan + * amount + * + * Will be used in the future to vary the loan parameters. For now, it is + * only called once. + * + * Tests a bunch of LoanSet failure conditions before lifecycle. */ - struct VerifyLoanStatus - { - public: - jtx::Env const& env; - BrokerInfo const& broker; - jtx::Account const& pseudoAccount; - Keylet const& loanKeylet; - - VerifyLoanStatus( - jtx::Env const& env, - BrokerInfo const& broker, - jtx::Account const& pseudo, - Keylet const& keylet) - : env(env), broker(broker), pseudoAccount(pseudo), loanKeylet(keylet) - { - } - - /** - * Checks the expected broker state against the ledger - */ - void - checkBroker( - Number const& principalOutstanding, - Number const& interestOwed, - TenthBips32 interestRate, - std::uint32_t paymentInterval, - std::uint32_t paymentsRemaining, - std::uint32_t ownerCount) const - { - using namespace jtx; - if (auto brokerSle = env.le(keylet::loanBroker(broker.brokerID)); - env.test.BEAST_EXPECT(brokerSle)) - { - TenthBips16 const managementFeeRate{brokerSle->at(sfManagementFeeRate)}; - auto const brokerDebt = brokerSle->at(sfDebtTotal); - - if (auto vaultSle = env.le(keylet::vault(brokerSle->at(sfVaultID))); - env.test.BEAST_EXPECT(vaultSle)) - { - auto const expectedDebt = - env.current()->rules().enabled(featureLendingProtocolV1_1) && - getVaultVersion(vaultSle) == VaultVersion::CashBasis - ? principalOutstanding - : principalOutstanding + interestOwed; - env.test.BEAST_EXPECT(brokerDebt == expectedDebt); - env.test.BEAST_EXPECT( - env.balance(pseudoAccount, broker.asset).number() == - brokerSle->at(sfCoverAvailable)); - env.test.BEAST_EXPECT(brokerSle->at(sfOwnerCount) == ownerCount); - - Account const vaultPseudo{"vaultPseudoAccount", vaultSle->at(sfAccount)}; - env.test.BEAST_EXPECT( - vaultSle->at(sfAssetsAvailable) == - env.balance(vaultPseudo, broker.asset).number()); - if (ownerCount == 0) - { - // The Vault must be perfectly balanced if there - // are no loans outstanding - auto const total = vaultSle->at(sfAssetsTotal); - auto const available = vaultSle->at(sfAssetsAvailable); - env.test.BEAST_EXPECT(total == available); - env.test.BEAST_EXPECT(vaultSle->at(sfLossUnrealized) == 0); - } - } - } - } - - void - checkPayment( - std::int32_t loanScale, - jtx::Account const& account, - jtx::PrettyAmount const& balanceBefore, - STAmount const& expectedPayment, - jtx::PrettyAmount const& adjustment) const - { - auto const borrowerScale = std::max(loanScale, balanceBefore.number().exponent()); - - STAmount const balanceChangeAmount{ - broker.asset, - roundToAsset(broker.asset, expectedPayment + adjustment, borrowerScale)}; - { - auto const difference = roundToScale( - env.balance(account, broker.asset) - (balanceBefore - balanceChangeAmount), - borrowerScale); - env.test.expect( - roundToScale(difference, loanScale) >= beast::kZero, - "Balance before: " + to_string(balanceBefore.value()) + - ", expected change: " + to_string(balanceChangeAmount) + - ", difference (balance after - expected): " + to_string(difference), - __FILE__, - __LINE__); - } - } - - /** - * Checks both the loan and broker expect states against the ledger - */ - void - operator()( - std::uint32_t previousPaymentDate, - std::uint32_t nextPaymentDate, - std::uint32_t paymentRemaining, - Number const& loanScale, - Number const& totalValue, - Number const& principalOutstanding, - Number const& managementFeeOutstanding, - Number const& periodicPayment, - std::uint32_t flags) const - { - using namespace jtx; - if (auto loan = env.le(loanKeylet); env.test.BEAST_EXPECT(loan)) - { - env.test.BEAST_EXPECT(loan->at(sfPreviousPaymentDueDate) == previousPaymentDate); - env.test.BEAST_EXPECT(loan->at(sfPaymentRemaining) == paymentRemaining); - env.test.BEAST_EXPECT(loan->at(sfNextPaymentDueDate) == nextPaymentDate); - env.test.BEAST_EXPECT(loan->at(sfLoanScale) == loanScale); - env.test.BEAST_EXPECT(loan->at(sfTotalValueOutstanding) == totalValue); - env.test.BEAST_EXPECT(loan->at(sfPrincipalOutstanding) == principalOutstanding); - env.test.BEAST_EXPECT( - loan->at(sfManagementFeeOutstanding) == managementFeeOutstanding); - env.test.BEAST_EXPECT(loan->at(sfPeriodicPayment) == periodicPayment); - env.test.BEAST_EXPECT(loan->at(sfFlags) == flags); - - auto const ls = constructLoanState(loan); - - auto const interestRate = TenthBips32{loan->at(sfInterestRate)}; - auto const paymentInterval = loan->at(sfPaymentInterval); - checkBroker( - principalOutstanding, - ls.interestDue, - interestRate, - paymentInterval, - paymentRemaining, - 1); - - if (auto brokerSle = env.le(keylet::loanBroker(broker.brokerID)); - env.test.BEAST_EXPECT(brokerSle)) - { - if (auto vaultSle = env.le(keylet::vault(brokerSle->at(sfVaultID))); - env.test.BEAST_EXPECT(vaultSle)) - { - if (((flags & lsfLoanImpaired) != 0u) && ((flags & lsfLoanDefault) == 0u)) - { - env.test.BEAST_EXPECT( - vaultSle->at(sfLossUnrealized) == - (env.current()->rules().enabled(featureLendingProtocolV1_1) && - getVaultVersion(vaultSle) == VaultVersion::CashBasis - ? principalOutstanding - : totalValue - managementFeeOutstanding)); - } - else - { - env.test.BEAST_EXPECT(vaultSle->at(sfLossUnrealized) == 0); - } - } - } - } - } - - /** - * Checks both the loan and broker expect states against the ledger - */ - void - operator()(LoanState const& state) const - { - operator()( - state.previousPaymentDate, - state.nextPaymentDate, - state.paymentRemaining, - state.loanScale, - state.totalValue, - state.principalOutstanding, - state.managementFeeOutstanding, - state.periodicPayment, - state.flags); - }; - }; - - BrokerInfo - createVaultAndBroker( + template + void + testCaseWrapper( jtx::Env& env, - jtx::PrettyAsset const& asset, - jtx::Account const& lender, - BrokerParameters const& params = BrokerParameters::defaults()) + jtx::MPTTester& mptt, + std::array const& assets, + BrokerInfo const& broker, + Number const& loanAmount, + int interestExponent) { using namespace jtx; + using namespace Lending; - Vault const vault{env}; - - auto const deposit = asset(params.vaultDeposit); - auto const debtMaximumValue = asset(params.debtMax).value(); - auto const coverDepositValue = asset(params.coverDeposit).value(); - - auto const coverRateMinValue = params.coverRateMin; - - auto [tx, vaultKeylet] = vault.create({.owner = lender, .asset = asset}); - if (params.vaultScale) - tx[sfScale] = *params.vaultScale; - env(tx); - env.close(); - BEAST_EXPECT(env.le(vaultKeylet)); - - env(vault.deposit({.depositor = lender, .id = vaultKeylet.key, .amount = deposit})); - env.close(); - if (auto const vault = env.le(keylet::vault(vaultKeylet.key)); BEAST_EXPECT(vault)) - { - BEAST_EXPECT(vault->at(sfAssetsAvailable) == deposit.value()); - } - - auto const keylet = keylet::loanBroker(lender.id(), env.seq(lender)); - - using namespace loanBroker; - env(set(lender, vaultKeylet.key, params.flags), - kData(params.data), - kManagementFeeRate(params.managementFeeRate), - kDebtMaximum(debtMaximumValue), - kCoverRateMinimum(coverRateMinValue), - kCoverRateLiquidation(TenthBips32(params.coverRateLiquidation))); - - if (coverDepositValue != beast::kZero) - env(coverDeposit(lender, keylet.key, coverDepositValue)); - - env.close(); - - return {asset, keylet, vaultKeylet, params}; - } - - /** - * Get the state without checking anything - */ - LoanState - getCurrentState(jtx::Env const& env, BrokerInfo const& broker, Keylet const& loanKeylet) - { - using d = NetClock::duration; - using tp = NetClock::time_point; - - // Lookup the current loan state - if (auto loan = env.le(loanKeylet); BEAST_EXPECT(loan)) - { - return LoanState{ - .previousPaymentDate = loan->at(sfPreviousPaymentDueDate), - .startDate = tp{d{loan->at(sfStartDate)}}, - .nextPaymentDate = loan->at(sfNextPaymentDueDate), - .paymentRemaining = loan->at(sfPaymentRemaining), - .loanScale = loan->at(sfLoanScale), - .totalValue = loan->at(sfTotalValueOutstanding), - .principalOutstanding = loan->at(sfPrincipalOutstanding), - .managementFeeOutstanding = loan->at(sfManagementFeeOutstanding), - .periodicPayment = loan->at(sfPeriodicPayment), - .flags = loan->at(sfFlags), - .paymentInterval = loan->at(sfPaymentInterval), - .interestRate = TenthBips32{loan->at(sfInterestRate)}, - }; - } - return LoanState{}; - } + auto const& asset = broker.asset.raw(); + auto const currencyLabel = getCurrencyLabel(asset); + auto const caseLabel = [&]() { + std::stringstream ss; + ss << "Lifecycle: " << loanAmount << " " << currencyLabel + << " Scale interest to: " << interestExponent << " "; + return ss.str(); + }(); + testcase << caseLabel; - /** - * Get the state and check the values against the parameters used in - * `lifecycle` - */ - LoanState - getCurrentState( - jtx::Env const& env, - BrokerInfo const& broker, - Keylet const& loanKeylet, - VerifyLoanStatus const& verifyLoanStatus) - { + using namespace loan; using namespace std::chrono_literals; using d = NetClock::duration; using tp = NetClock::time_point; - auto const state = getCurrentState(env, broker, loanKeylet); - BEAST_EXPECT(state.previousPaymentDate == 0); - BEAST_EXPECT(tp{d{state.nextPaymentDate}} == state.startDate + 600s); - BEAST_EXPECT(state.paymentRemaining == 12); - BEAST_EXPECT(state.principalOutstanding == broker.asset(1000).value()); - BEAST_EXPECT( - state.loanScale >= - (broker.asset.integral() - ? 0 - : std::max(broker.vaultScale(env), state.principalOutstanding.exponent()))); - BEAST_EXPECT(state.paymentInterval == 600); - { - NumberRoundModeGuard const mg(Number::RoundingMode::Upward); - BEAST_EXPECT( - state.totalValue == - roundToAsset( - broker.asset, state.periodicPayment * state.paymentRemaining, state.loanScale)); - } - BEAST_EXPECT( - state.managementFeeOutstanding == - computeManagementFee( - broker.asset, - state.totalValue - state.principalOutstanding, - broker.params.managementFeeRate, - state.loanScale)); - - verifyLoanStatus(state); - - return state; - } - - bool - canImpairLoan(jtx::Env const& env, BrokerInfo const& broker, LoanState const& state) - { - if (auto const brokerSle = env.le(keylet::loanBroker(broker.brokerID)); - BEAST_EXPECT(brokerSle)) - { - if (auto const vaultSle = env.le(keylet::vault(brokerSle->at(sfVaultID))); - BEAST_EXPECT(vaultSle)) - { - // log << vaultSle->getJson() << std::endl; - auto const assetsUnavailable = - vaultSle->at(sfAssetsTotal) - vaultSle->at(sfAssetsAvailable); - auto const unrealizedLoss = vaultSle->at(sfLossUnrealized) + - (env.current()->rules().enabled(featureLendingProtocolV1_1) && - getVaultVersion(vaultSle) == VaultVersion::CashBasis - ? state.principalOutstanding - : state.totalValue - state.managementFeeOutstanding); - - if (!BEAST_EXPECT(unrealizedLoss <= assetsUnavailable)) - { - return false; - } - } - } - return true; - } - - enum class AssetType { XRP = 0, IOU = 1, MPT = 2 }; - - // Specify the accounts as params to allow other accounts to be used - jtx::PrettyAsset - createAsset( - jtx::Env& env, - AssetType assetType, - BrokerParameters const& brokerParams, - jtx::Account const& issuer, - jtx::Account const& lender, - jtx::Account const& borrower) - { - using namespace jtx; + Account const issuer{"issuer"}; + // For simplicity, lender will be the sole actor for the vault & + // brokers. + Account const lender{"lender"}; + // Borrower only wants to borrow + Account const borrower{"borrower"}; + // Evan will attempt to be naughty + Account const evan{"evan"}; + // Do not fund alice + Account const alice{"alice"}; - switch (assetType) - { - case AssetType::XRP: - // TODO: remove the factor, and set up loans in drops - return PrettyAsset{xrpIssue(), 1'000'000}; + Number const principalRequest = broker.asset(loanAmount).value(); + Number const maxCoveredLoanValue = broker.params.maxCoveredLoanValue(0); + BEAST_EXPECT(maxCoveredLoanValue == 1000 * 100 / 10); + Number const maxCoveredLoanRequest = broker.asset(maxCoveredLoanValue).value(); + Number const totalVaultRequest = broker.asset(broker.params.vaultDeposit).value(); + Number const debtMaximumRequest = broker.asset(broker.params.debtMax).value(); - case AssetType::IOU: { - PrettyAsset const asset{issuer[iouCurrency_]}; + auto const loanSetFee = Fee(env.current()->fees().base * 2); - auto const limit = - asset(100 * (brokerParams.vaultDeposit + brokerParams.coverDeposit)); - if (lender != issuer) - env(trust(lender, limit)); - if (borrower != issuer) - env(trust(borrower, limit)); + auto const pseudoAcct = brokerPseudoAccount(env, broker, lender); - return asset; - } + auto const baseFee = env.current()->fees().base; - case AssetType::MPT: { - // Enough to cover initial fees - if (!env.le(keylet::account(issuer))) - env.fund(env.current()->fees().accountReserve(10, 1) * 10, issuer); - if (!env.le(keylet::account(lender))) - env.fund(env.current()->fees().accountReserve(10, 1) * 10, noripple(lender)); - if (!env.le(keylet::account(borrower))) - env.fund(env.current()->fees().accountReserve(10, 1) * 10, noripple(borrower)); - - MPTTester mptt{env, issuer, kMptInitNoFund}; - mptt.create({.flags = tfMPTCanClawback | tfMPTCanTransfer | tfMPTCanLock}); - // Scale the MPT asset so interest is interesting - PrettyAsset const asset{mptt.issuanceID(), 10'000}; - // Need to do the authorization here because mptt isn't - // accessible outside - if (lender != issuer) - mptt.authorize({.account = lender}); - if (borrower != issuer) - mptt.authorize({.account = borrower}); + auto badKeylet = keylet::vault(lender.id(), env.seq(lender)); + // Try some failure cases + // flags are checked first + env(set(evan, broker.brokerID, principalRequest, tfLoanSetMask), + Sig(sfCounterpartySignature, lender), + loanSetFee, + Ter(temINVALID_FLAG)); - env.close(); + // field length validation + // sfData: good length, bad account + env(set(evan, broker.brokerID, principalRequest), + Sig(sfCounterpartySignature, borrower), + kData(std::string(kMaxDataPayloadLength, 'X')), + loanSetFee, + Ter(tefBAD_AUTH)); + // sfData: too long + env(set(evan, broker.brokerID, principalRequest), + Sig(sfCounterpartySignature, lender), + kData(std::string(kMaxDataPayloadLength + 1, 'Y')), + loanSetFee, + Ter(temINVALID)); - return asset; - } + // field range validation + // sfOverpaymentFee: good value, bad account + env(set(evan, broker.brokerID, principalRequest), + Sig(sfCounterpartySignature, borrower), + kOverpaymentFee(kMaxOverpaymentFee), + loanSetFee, + Ter(tefBAD_AUTH)); + // sfOverpaymentFee: too big + env(set(evan, broker.brokerID, principalRequest), + Sig(sfCounterpartySignature, lender), + kOverpaymentFee(kMaxOverpaymentFee + 1), + loanSetFee, + Ter(temINVALID)); - default: - throw std::runtime_error("Unknown asset type"); - } - } + // sfInterestRate: good value, bad account + env(set(evan, broker.brokerID, principalRequest), + Sig(sfCounterpartySignature, borrower), + kInterestRate(kMaxInterestRate), + loanSetFee, + Ter(tefBAD_AUTH)); + env(set(evan, broker.brokerID, principalRequest), + Sig(sfCounterpartySignature, borrower), + kInterestRate(TenthBips32(0)), + loanSetFee, + Ter(tefBAD_AUTH)); + // sfInterestRate: too big + env(set(evan, broker.brokerID, principalRequest), + Sig(sfCounterpartySignature, lender), + kInterestRate(kMaxInterestRate + 1), + loanSetFee, + Ter(temINVALID)); + // sfInterestRate: too small + env(set(evan, broker.brokerID, principalRequest), + Sig(sfCounterpartySignature, lender), + kInterestRate(TenthBips32(-1)), + loanSetFee, + Ter(temINVALID)); - void - describeLoan( - jtx::Env& env, - BrokerParameters const& brokerParams, - LoanParameters const& loanParams, - AssetType assetType, - jtx::Account const& issuer, - jtx::Account const& lender, - jtx::Account const& borrower) - { - using namespace jtx; + // sfLateInterestRate: good value, bad account + env(set(evan, broker.brokerID, principalRequest), + Sig(sfCounterpartySignature, borrower), + kLateInterestRate(kMaxLateInterestRate), + loanSetFee, + Ter(tefBAD_AUTH)); + env(set(evan, broker.brokerID, principalRequest), + Sig(sfCounterpartySignature, borrower), + kLateInterestRate(TenthBips32(0)), + loanSetFee, + Ter(tefBAD_AUTH)); + // sfLateInterestRate: too big + env(set(evan, broker.brokerID, principalRequest), + Sig(sfCounterpartySignature, lender), + kLateInterestRate(kMaxLateInterestRate + 1), + loanSetFee, + Ter(temINVALID)); + // sfLateInterestRate: too small + env(set(evan, broker.brokerID, principalRequest), + Sig(sfCounterpartySignature, lender), + kLateInterestRate(TenthBips32(-1)), + loanSetFee, + Ter(temINVALID)); - auto const asset = createAsset(env, assetType, brokerParams, issuer, lender, borrower); - auto const principal = asset(loanParams.principalRequest).number(); - auto const interest = loanParams.interest.value_or(TenthBips32{}); - auto const interval = loanParams.payInterval.value_or(LoanSet::kDefaultPaymentInterval); - auto const total = loanParams.payTotal.value_or(LoanSet::kDefaultPaymentTotal); - auto const feeRate = brokerParams.managementFeeRate; - auto const props = computeLoanProperties( - env.current()->rules(), - asset, - principal, - interest, - interval, - total, - feeRate, - asset(brokerParams.vaultDeposit).number().exponent()); - log << "Loan properties:\n" - << "\tPrincipal: " << principal << std::endl - << "\tInterest rate: " << interest << std::endl - << "\tPayment interval: " << interval << std::endl - << "\tManagement Fee Rate: " << feeRate << std::endl - << "\tTotal Payments: " << total << std::endl - << "\tPeriodic Payment: " << props.periodicPayment << std::endl - << "\tTotal Value: " << props.loanState.valueOutstanding << std::endl - << "\tManagement Fee: " << props.loanState.managementFeeDue << std::endl - << "\tLoan Scale: " << props.loanScale << std::endl - << "\tFirst payment principal: " << props.firstPaymentPrincipal << std::endl; - - // checkGuards returns a TER, so success is 0 - BEAST_EXPECT(!checkLoanGuards( - asset, - asset(loanParams.principalRequest).number(), - loanParams.interest.value_or(TenthBips32{}) != beast::kZero, - loanParams.payTotal.value_or(LoanSet::kDefaultPaymentTotal), - props, - env.journal)); - } + // sfCloseInterestRate: good value, bad account + env(set(evan, broker.brokerID, principalRequest), + Sig(sfCounterpartySignature, borrower), + kCloseInterestRate(kMaxCloseInterestRate), + loanSetFee, + Ter(tefBAD_AUTH)); + env(set(evan, broker.brokerID, principalRequest), + Sig(sfCounterpartySignature, borrower), + kCloseInterestRate(TenthBips32(0)), + loanSetFee, + Ter(tefBAD_AUTH)); + // sfCloseInterestRate: too big + env(set(evan, broker.brokerID, principalRequest), + Sig(sfCounterpartySignature, lender), + kCloseInterestRate(kMaxCloseInterestRate + 1), + loanSetFee, + Ter(temINVALID)); + env(set(evan, broker.brokerID, principalRequest), + Sig(sfCounterpartySignature, lender), + kCloseInterestRate(TenthBips32(-1)), + loanSetFee, + Ter(temINVALID)); - std::optional> - createLoan( - jtx::Env& env, - AssetType assetType, - BrokerParameters const& brokerParams, - LoanParameters const& loanParams, - jtx::Account const& issuer, - jtx::Account const& lender, - jtx::Account const& borrower) - { - using namespace jtx; + // sfOverpaymentInterestRate: good value, bad account + env(set(evan, broker.brokerID, principalRequest), + Sig(sfCounterpartySignature, borrower), + kOverpaymentInterestRate(kMaxOverpaymentInterestRate), + loanSetFee, + Ter(tefBAD_AUTH)); + env(set(evan, broker.brokerID, principalRequest), + Sig(sfCounterpartySignature, borrower), + kOverpaymentInterestRate(TenthBips32(0)), + loanSetFee, + Ter(tefBAD_AUTH)); + // sfOverpaymentInterestRate: too big + env(set(evan, broker.brokerID, principalRequest), + Sig(sfCounterpartySignature, lender), + kOverpaymentInterestRate(kMaxOverpaymentInterestRate + 1), + loanSetFee, + Ter(temINVALID)); + env(set(evan, broker.brokerID, principalRequest), + Sig(sfCounterpartySignature, lender), + kOverpaymentInterestRate(TenthBips32(-1)), + loanSetFee, + Ter(temINVALID)); - // Enough to cover initial fees - env.fund(env.current()->fees().accountReserve(10, 1) * 10, issuer); - if (lender != issuer) - env.fund(env.current()->fees().accountReserve(10, 1) * 10, noripple(lender)); - if (borrower != issuer && borrower != lender) - env.fund(env.current()->fees().accountReserve(10, 1) * 10, noripple(borrower)); + // sfPaymentTotal: good value, bad account + env(set(evan, broker.brokerID, principalRequest), + Sig(sfCounterpartySignature, borrower), + kPaymentTotal(LoanSet::kMinPaymentTotal), + loanSetFee, + Ter(tefBAD_AUTH)); + // sfPaymentTotal: too small (there is no max) + env(set(evan, broker.brokerID, principalRequest), + Sig(sfCounterpartySignature, lender), + kPaymentTotal(LoanSet::kMinPaymentTotal - 1), + loanSetFee, + Ter(temINVALID)); - describeLoan(env, brokerParams, loanParams, assetType, issuer, lender, borrower); + // sfPaymentInterval: good value, bad account + env(set(evan, broker.brokerID, principalRequest), + Sig(sfCounterpartySignature, borrower), + kPaymentInterval(LoanSet::kMinPaymentInterval), + loanSetFee, + Ter(tefBAD_AUTH)); + // sfPaymentInterval: too small (there is no max) + env(set(evan, broker.brokerID, principalRequest), + Sig(sfCounterpartySignature, lender), + kPaymentInterval(LoanSet::kMinPaymentInterval - 1), + loanSetFee, + Ter(temINVALID)); - // Make the asset - auto const asset = createAsset(env, assetType, brokerParams, issuer, lender, borrower); + // sfGracePeriod: good value, bad account + env(set(evan, broker.brokerID, principalRequest), + Sig(sfCounterpartySignature, borrower), + kPaymentInterval(LoanSet::kMinPaymentInterval * 2), + kGracePeriod(LoanSet::kMinPaymentInterval * 2), + loanSetFee, + Ter(tefBAD_AUTH)); + // sfGracePeriod: larger than paymentInterval + env(set(evan, broker.brokerID, principalRequest), + Sig(sfCounterpartySignature, lender), + kPaymentInterval(LoanSet::kMinPaymentInterval * 2), + kGracePeriod(LoanSet::kMinPaymentInterval * 3), + loanSetFee, + Ter(temINVALID)); - env.close(); - if (asset.native() || lender != issuer) - { - env( - pay((asset.native() ? env.master : issuer), - lender, - asset(brokerParams.vaultDeposit + brokerParams.coverDeposit))); - } - // Fund the borrower later once we know the total loan - // size + // insufficient fee - single sign + env(set(borrower, broker.brokerID, principalRequest), + Sig(sfCounterpartySignature, lender), + Ter(telINSUF_FEE_P)); + // insufficient fee - multisign + env(signers(lender, 2, {{evan, 1}, {borrower, 1}})); + env(signers(borrower, 2, {{evan, 1}, {lender, 1}})); + env(set(borrower, broker.brokerID, principalRequest), + kCounterparty(lender), + Msig(evan, lender), + Msig(sfCounterpartySignature, evan, borrower), + Fee(env.current()->fees().base * 5 - 1), + Ter(telINSUF_FEE_P)); + // Bad multisign signatures for borrower (Account) + env(set(borrower, broker.brokerID, principalRequest), + kCounterparty(lender), + Msig(alice, issuer), + Msig(sfCounterpartySignature, evan, borrower), + Fee(env.current()->fees().base * 5), + Ter(tefBAD_SIGNATURE)); + // Bad multisign signatures for issuer (Counterparty) + env(set(borrower, broker.brokerID, principalRequest), + kCounterparty(lender), + Msig(evan, lender), + Msig(sfCounterpartySignature, alice, issuer), + Fee(env.current()->fees().base * 5 - 1), + Ter(tefBAD_SIGNATURE)); + env(signers(lender, kNone)); + env(signers(borrower, kNone)); + // multisign sufficient fee, but no signers set up + env(set(borrower, broker.brokerID, principalRequest), + kCounterparty(lender), + Msig(evan, lender), + Msig(sfCounterpartySignature, evan, borrower), + Fee(env.current()->fees().base * 5), + Ter(tefNOT_MULTI_SIGNING)); + // not the broker owner, no counterparty, not signed by broker + // owner + env(set(borrower, broker.brokerID, principalRequest), + Sig(sfCounterpartySignature, evan), + loanSetFee, + Ter(tefBAD_AUTH)); + // not the broker owner, counterparty is borrower + env(set(evan, broker.brokerID, principalRequest), + kCounterparty(borrower), + Sig(sfCounterpartySignature, borrower), + loanSetFee, + Ter(tecNO_PERMISSION)); + // not a LoanBroker object, no counterparty + env(set(lender, badKeylet.key, principalRequest), + Sig(sfCounterpartySignature, evan), + loanSetFee, + Ter(temBAD_SIGNER)); + // not a LoanBroker object, counterparty is valid + env(set(lender, badKeylet.key, principalRequest), + kCounterparty(borrower), + Sig(sfCounterpartySignature, borrower), + loanSetFee, + Ter(tecNO_ENTRY)); + // borrower doesn't exist + env(set(lender, broker.brokerID, principalRequest), + kCounterparty(alice), + Sig(sfCounterpartySignature, alice), + loanSetFee, + Ter(terNO_ACCOUNT)); - BrokerInfo const broker = createVaultAndBroker(env, asset, lender, brokerParams); + // Request more funds than the vault has available + env(set(evan, broker.brokerID, totalVaultRequest + 1), + Sig(sfCounterpartySignature, lender), + loanSetFee, + Ter(tecINSUFFICIENT_FUNDS)); - auto const pseudoAcctOpt = [&]() -> std::optional { - auto const brokerSle = env.le(keylet::loanBroker(broker.brokerID)); - if (!BEAST_EXPECT(brokerSle)) - return std::nullopt; - auto const brokerPseudo = brokerSle->at(sfAccount); - return Account("Broker pseudo-account", brokerPseudo); - }(); - if (!pseudoAcctOpt) - return std::nullopt; - Account const& pseudoAcct = *pseudoAcctOpt; + // Request more funds than the broker's first-loss capital can + // cover. + env(set(evan, broker.brokerID, maxCoveredLoanRequest + 1), + Sig(sfCounterpartySignature, lender), + loanSetFee, + Ter(tecINSUFFICIENT_FUNDS)); - auto const loanKeyletOpt = [&]() -> std::optional { + // Frozen trust line / locked MPT issuance + // XRP can not be frozen, but run through the loop anyway to test + // the tecLIMIT_EXCEEDED case + { auto const brokerSle = env.le(keylet::loanBroker(broker.brokerID)); if (!BEAST_EXPECT(brokerSle)) - return std::nullopt; - - // Broker has no loans - BEAST_EXPECT(brokerSle->at(sfOwnerCount) == 0); - - // The loan keylet is based on the LoanSequence of the - // _LOAN_BROKER_ object. - auto const loanSequence = brokerSle->at(sfLoanSequence); - return keylet::loan(broker.brokerID, loanSequence); - }(); - if (!loanKeyletOpt) - return std::nullopt; - Keylet const& loanKeylet = *loanKeyletOpt; - - env(loanParams(env, broker)); + return; - env.close(); + auto const vaultPseudo = [&]() { + auto const vaultSle = env.le(keylet::vault(brokerSle->at(sfVaultID))); + if (!BEAST_EXPECT(vaultSle)) + { + // This will be wrong, but the test has failed anyway. + return Account{lender}; + } + auto vaultPseudo = Account("Vault pseudo-account", vaultSle->at(sfAccount)); + return vaultPseudo; + }(); - return std::make_tuple(broker, loanKeylet, pseudoAcct); - } + auto const [freeze, deepfreeze, unfreeze, expectedResult] = + [&]() -> std::tuple< + std::function, + std::function, + std::function, + TER> { + // Freeze / lock the asset + std::function const empty; + if (broker.asset.native()) + { + // XRP can't be frozen + return std::make_tuple(empty, empty, empty, tesSUCCESS); + } + if (broker.asset.holds()) + { + auto freeze = [&](Account const& holder) { + env(trust(issuer, holder[iouCurrency_](0), tfSetFreeze)); + }; + auto deepfreeze = [&](Account const& holder) { + env(trust(issuer, holder[iouCurrency_](0), tfSetFreeze | tfSetDeepFreeze)); + }; + auto unfreeze = [&](Account const& holder) { + env(trust( + issuer, holder[iouCurrency_](0), tfClearFreeze | tfClearDeepFreeze)); + }; + return std::make_tuple(freeze, deepfreeze, unfreeze, tecFROZEN); + } - static void - topUpBorrower( - jtx::Env& env, - BrokerInfo const& broker, - jtx::Account const& issuer, - jtx::Account const& borrower, - LoanState const& state, - std::optional const& servFee) - { - using namespace jtx; + auto freeze = [&](Account const& holder) { + mptt.set({.account = issuer, .holder = holder, .flags = tfMPTLock}); + }; + auto unfreeze = [&](Account const& holder) { + mptt.set({.account = issuer, .holder = holder, .flags = tfMPTUnlock}); + }; + return std::make_tuple(freeze, empty, unfreeze, tecLOCKED); + }(); - STAmount const serviceFee = broker.asset(servFee.value_or(0)); + // Try freezing the accounts that can't be frozen + if (freeze) + { + for (auto const& account : {vaultPseudo, evan}) + { + // Freeze the account + freeze(account); - // Ensure the borrower has enough funds to make the payments - // (including tx fees, if necessary) - auto const borrowerBalance = env.balance(borrower, broker.asset); + // Try to create a loan with a frozen line + env(set(evan, broker.brokerID, debtMaximumRequest), + Sig(sfCounterpartySignature, lender), + loanSetFee, + Ter(expectedResult)); - auto const baseFee = env.current()->fees().base; + // Unfreeze the account + BEAST_EXPECT(unfreeze); + unfreeze(account); - // Add extra for transaction fees and reserves, if appropriate, or a - // tiny amount for the extra paid in each transaction - auto const totalNeeded = state.totalValue + (serviceFee * state.paymentRemaining) + - (broker.asset.native() ? Number( - baseFee * state.paymentRemaining + - accountReserve(*env.current(), borrower.id(), env.journal)) - : broker.asset(15).number()); + // Ensure the line is unfrozen with a request that is fine + // except too it requests more principal than the broker can + // carry + env(set(evan, broker.brokerID, debtMaximumRequest + 1), + Sig(sfCounterpartySignature, lender), + loanSetFee, + Ter(tecLIMIT_EXCEEDED)); + } + } - auto const shortage = totalNeeded - borrowerBalance.number(); - - if (shortage > beast::kZero && (broker.asset.native() || issuer != borrower)) - { - env( - pay((broker.asset.native() ? env.master : issuer), - borrower, - STAmount{broker.asset, shortage})); - } - } - - void - makeLoanPayments( - jtx::Env& env, - BrokerInfo const& broker, - LoanParameters const& loanParams, - Keylet const& loanKeylet, - VerifyLoanStatus const& verifyLoanStatus, - jtx::Account const& issuer, - jtx::Account const& lender, - jtx::Account const& borrower, - PaymentParameters const& paymentParams = PaymentParameters::defaults()) - { - // Make all the individual payments - using namespace jtx; - using namespace jtx::loan; - using namespace std::chrono_literals; - using d = NetClock::duration; - - bool const showStepBalances = paymentParams.showStepBalances; - - auto const currencyLabel = getCurrencyLabel(broker.asset); - - auto const baseFee = env.current()->fees().base; - - env.close(); - auto state = getCurrentState(env, broker, loanKeylet); - - verifyLoanStatus(state); + // Deep freeze the borrower, which prevents them from receiving + // funds + if (deepfreeze) + { + // Make sure evan has a trust line that so the issuer can + // freeze it. (Don't need to do this for the borrower, + // because LoanSet will create a line to the borrower + // automatically.) + env(trust(evan, issuer[iouCurrency_](100'000))); - STAmount const serviceFee = broker.asset(loanParams.serviceFee.value_or(0)); + for (auto const& account : {// these accounts can't be frozen, which deep freeze + // implies + vaultPseudo, + evan, + // these accounts can't be deep frozen + lender}) + { + // Freeze evan + deepfreeze(account); - topUpBorrower(env, broker, issuer, borrower, state, loanParams.serviceFee); + // Try to create a loan with a deep frozen line + env(set(evan, broker.brokerID, debtMaximumRequest), + Sig(sfCounterpartySignature, lender), + loanSetFee, + Ter(expectedResult)); - // Periodic payment amount will consist of - // 1. principal outstanding (1000) - // 2. interest interest rate (at 12%) - // 3. payment interval (600s) - // 4. loan service fee (2) - // Calculate these values without the helper functions - // to verify they're working correctly The numbers in - // the below BEAST_EXPECTs may not hold across assets. - auto const periodicRate = loanPeriodicRate(state.interestRate, state.paymentInterval); - STAmount const roundedPeriodicPayment{ - broker.asset, - roundPeriodicPayment(broker.asset, state.periodicPayment, state.loanScale)}; + // Unfreeze evan + BEAST_EXPECT(unfreeze); + unfreeze(account); - if (!showStepBalances) - { - log << currencyLabel << " Payment components: " - << "Payments remaining, " - << "rawInterest, rawPrincipal, " - "rawMFee, " - << "trackedValueDelta, trackedPrincipalDelta, " - "trackedInterestDelta, trackedMgmtFeeDelta, special" - << std::endl; + // Ensure the line is unfrozen with a request that is fine + // except too it requests more principal than the broker can + // carry + env(set(evan, broker.brokerID, debtMaximumRequest + 1), + Sig(sfCounterpartySignature, lender), + loanSetFee, + Ter(tecLIMIT_EXCEEDED)); + } + } } - // Include the service fee - STAmount const totalDue = roundToScale( - roundedPeriodicPayment + serviceFee, state.loanScale, Number::RoundingMode::Upward); + // Finally! Create a loan - auto currentRoundedState = constructLoanState( - state.totalValue, state.principalOutstanding, state.managementFeeOutstanding); - { - auto const raw = computeTheoreticalLoanState( - env.current()->rules(), - state.periodicPayment, - periodicRate, - state.paymentRemaining, - broker.params.managementFeeRate); - - if (showStepBalances) + auto coverAvailable = [&env, this](uint256 const& brokerID, Number const& expected) { + if (auto const brokerSle = env.le(keylet::loanBroker(brokerID)); + BEAST_EXPECT(brokerSle)) { - log << currencyLabel << " Starting loan balances: " - << "\n\tTotal value: " << currentRoundedState.valueOutstanding - << "\n\tPrincipal: " << currentRoundedState.principalOutstanding - << "\n\tInterest: " << currentRoundedState.interestDue - << "\n\tMgmt fee: " << currentRoundedState.managementFeeDue - << "\n\tPayments remaining " << state.paymentRemaining << std::endl; + auto const available = brokerSle->at(sfCoverAvailable); + BEAST_EXPECT(available == expected); + return available; } - else + return Number{}; + }; + auto getDefaultInfo = [&env, this](LoanState const& state, BrokerInfo const& broker) { + if (auto const brokerSle = env.le(keylet::loanBroker(broker.brokerID)); + BEAST_EXPECT(brokerSle)) { - log << currencyLabel << " Loan starting state: " << state.paymentRemaining << ", " - << raw.interestDue << ", " << raw.principalOutstanding << ", " - << raw.managementFeeDue << ", " << currentRoundedState.valueOutstanding << ", " - << currentRoundedState.principalOutstanding << ", " - << currentRoundedState.interestDue << ", " - << currentRoundedState.managementFeeDue << std::endl; + BEAST_EXPECT( + state.loanScale >= + (broker.asset.integral() + ? 0 + : std::max( + broker.vaultScale(env), state.principalOutstanding.exponent()))); + NumberRoundModeGuard const mg(Number::RoundingMode::Upward); + auto const defaultAmount = roundToAsset( + broker.asset, + std::min( + tenthBipsOfValue( + tenthBipsOfValue( + brokerSle->at(sfDebtTotal), broker.params.coverRateMin), + broker.params.coverRateLiquidation), + state.totalValue - state.managementFeeOutstanding), + state.loanScale); + return std::make_pair(defaultAmount, brokerSle->at(sfOwner)); } - } - - // Try to pay a little extra to show that it's _not_ - // taken - auto const extraAmount = paymentParams.overpaymentExtra - ? broker.asset(*paymentParams.overpaymentExtra).value() - : std::min(broker.asset(10).value(), STAmount{broker.asset, totalDue / 20}); - - STAmount const transactionAmount = - STAmount{broker.asset, totalDue * paymentParams.overpaymentFactor} + extraAmount; - - auto const borrowerInitialBalance = env.balance(borrower, broker.asset).number(); - auto const initialState = state; - xrpl::detail::PaymentComponents totalPaid{ - .trackedValueDelta = 0, .trackedPrincipalDelta = 0, .trackedManagementFeeDelta = 0}; - Number totalInterestPaid = 0; - Number totalFeesPaid = 0; - std::size_t totalPaymentsMade = 0; - - xrpl::LoanState currentTrueState = computeTheoreticalLoanState( - env.current()->rules(), - state.periodicPayment, - periodicRate, - state.paymentRemaining, - broker.params.managementFeeRate); - - auto validateBorrowerBalance = [&]() { - if (borrower == issuer || !paymentParams.validateBalances) - return; - auto const totalSpent = - (totalPaid.trackedValueDelta + totalFeesPaid + - (broker.asset.native() ? Number(baseFee) * totalPaymentsMade : kNumZero)); - BEAST_EXPECT( - env.balance(borrower, broker.asset).number() == - borrowerInitialBalance - totalSpent); + return std::make_pair(Number{}, AccountID{}); }; - - auto const defaultRound = broker.asset.integral() ? 3 : 0; - auto truncate = [defaultRound](Number const& n, std::optional places = std::nullopt) { - auto const p = places.value_or(defaultRound); - if (p == 0) - return n; - auto const factor = Number{1, p}; - return (n * factor).truncate() / factor; + auto replenishCover = [&env, &coverAvailable]( + BrokerInfo const& broker, + AccountID const& brokerAcct, + Number const& startingCoverAvailable, + Number const& amountToBeCovered) { + coverAvailable(broker.brokerID, startingCoverAvailable - amountToBeCovered); + env(loanBroker::coverDeposit( + brokerAcct, broker.brokerID, STAmount{broker.asset, amountToBeCovered})); + coverAvailable(broker.brokerID, startingCoverAvailable); + env.close(); }; - while (state.paymentRemaining > 0) - { - validateBorrowerBalance(); - // Compute the expected principal amount - auto const paymentComponents = xrpl::detail::computePaymentComponents( - env.current()->rules(), - broker.asset.raw(), - state.loanScale, - state.totalValue, - state.principalOutstanding, - state.managementFeeOutstanding, - state.periodicPayment, - periodicRate, - state.paymentRemaining, - broker.params.managementFeeRate); - BEAST_EXPECT( - paymentComponents.trackedValueDelta <= roundedPeriodicPayment || - (paymentComponents.specialCase == xrpl::detail::PaymentSpecialCase::Final && - paymentComponents.trackedValueDelta >= roundedPeriodicPayment)); - BEAST_EXPECT( - paymentComponents.trackedValueDelta == - paymentComponents.trackedPrincipalDelta + paymentComponents.trackedInterestPart() + - paymentComponents.trackedManagementFeeDelta); - - xrpl::LoanState const nextTrueState = computeTheoreticalLoanState( - env.current()->rules(), - state.periodicPayment, - periodicRate, - state.paymentRemaining - 1, - broker.params.managementFeeRate); - xrpl::detail::LoanStateDeltas const deltas = currentTrueState - nextTrueState; - BEAST_EXPECT( - deltas.total() == deltas.principal + deltas.interest + deltas.managementFee); - BEAST_EXPECT( - paymentComponents.specialCase == xrpl::detail::PaymentSpecialCase::Final || - deltas.total() == state.periodicPayment || - (state.loanScale - (deltas.total() - state.periodicPayment).exponent()) > 14); + auto defaultImmediately = [&](std::uint32_t baseFlag, bool impair = true) { + return [&, impair, baseFlag]( + Keylet const& loanKeylet, VerifyLoanStatus const& verifyLoanStatus) { + // toEndOfLife + // + // Default the loan - if (!showStepBalances) - { - log << currencyLabel << " Payment components: " << state.paymentRemaining << ", " - - << deltas.interest << ", " << deltas.principal << ", " << deltas.managementFee - << ", " << paymentComponents.trackedValueDelta << ", " - << paymentComponents.trackedPrincipalDelta << ", " - << paymentComponents.trackedInterestPart() << ", " - << paymentComponents.trackedManagementFeeDelta << ", " << [&]() -> char const* { - if (paymentComponents.specialCase == ::xrpl::detail::PaymentSpecialCase::Final) - return "final"; - if (paymentComponents.specialCase == ::xrpl::detail::PaymentSpecialCase::Extra) - return "extra"; - return "none"; - }() << std::endl; - } + // Initialize values with the current state + auto state = getCurrentState(env, broker, loanKeylet, verifyLoanStatus); + BEAST_EXPECT(state.flags == baseFlag); - auto const totalDueAmount = - STAmount{broker.asset, paymentComponents.trackedValueDelta + serviceFee}; + auto const& broker = verifyLoanStatus.broker; + auto const startingCoverAvailable = coverAvailable( + broker.brokerID, broker.asset(broker.params.coverDeposit).number()); - if (paymentParams.validateBalances) - { - // Due to the rounding algorithms to keep the interest and - // principal in sync with "true" values, the computed amount - // may be a little less than the rounded fixed payment - // amount. For integral types, the difference should be < 3 - // (1 unit for each of the interest and management fee). For - // IOUs, the difference should be dust. - Number const diff = totalDue - totalDueAmount; - BEAST_EXPECT( - paymentComponents.specialCase == xrpl::detail::PaymentSpecialCase::Final || - diff == beast::kZero || - (diff > beast::kZero && - ((broker.asset.integral() && (static_cast(diff) < 3)) || - (state.loanScale - diff.exponent() > 13)))); + if (impair) + { + // Check the vault + bool const canImpair = canImpairLoan(env, broker, state); + // Impair the loan, if possible + env(manage(lender, loanKeylet.key, tfLoanImpair), + canImpair ? Ter(tesSUCCESS) : Ter(tecLIMIT_EXCEEDED)); - BEAST_EXPECT( - paymentComponents.trackedPrincipalDelta >= beast::kZero && - paymentComponents.trackedPrincipalDelta <= state.principalOutstanding); - BEAST_EXPECT( - paymentComponents.specialCase != xrpl::detail::PaymentSpecialCase::Final || - paymentComponents.trackedPrincipalDelta == state.principalOutstanding); - } + if (canImpair) + { + state.flags |= tfLoanImpair; + state.nextPaymentDate = env.now().time_since_epoch().count(); - auto const borrowerBalanceBeforePayment = env.balance(borrower, broker.asset); + // Once the loan is impaired, it can't be impaired again + env(manage(lender, loanKeylet.key, tfLoanImpair), Ter(tecNO_PERMISSION)); + } + verifyLoanStatus(state); + } - // Make the payment - env(pay(borrower, loanKeylet.key, transactionAmount, paymentParams.flags)); + auto const nextDueDate = tp{d{state.nextPaymentDate}}; - env.close(d{state.paymentInterval / 2}); + // Can't default the loan yet. The grace period hasn't + // expired + env(manage(lender, loanKeylet.key, tfLoanDefault), Ter(tecTOO_SOON)); - if (paymentParams.validateBalances) - { - // Need to account for fees if the loan is in XRP - PrettyAmount adjustment = broker.asset(0); - if (broker.asset.native()) - { - adjustment = env.current()->fees().base; - } + // Let some time pass so that the loan can be + // defaulted + env.close(nextDueDate + 60s); - // Check the result - verifyLoanStatus.checkPayment( - state.loanScale, - borrower, - borrowerBalanceBeforePayment, - totalDueAmount, - adjustment); - } + auto const [amountToBeCovered, brokerAcct] = getDefaultInfo(state, broker); - if (showStepBalances) - { - auto const loanSle = env.le(loanKeylet); - if (!BEAST_EXPECT(loanSle)) - { - // No reason for this not to exist - return; - } - auto const current = constructLoanState(loanSle); - auto const errors = nextTrueState - current; - log << currencyLabel << " Loan balances: " - << "\n\tAmount taken: " << paymentComponents.trackedValueDelta - << "\n\tTotal value: " << current.valueOutstanding - << " (true: " << truncate(nextTrueState.valueOutstanding) - << ", error: " << truncate(errors.total()) - << ")\n\tPrincipal: " << current.principalOutstanding - << " (true: " << truncate(nextTrueState.principalOutstanding) - << ", error: " << truncate(errors.principal) - << ")\n\tInterest: " << current.interestDue - << " (true: " << truncate(nextTrueState.interestDue) - << ", error: " << truncate(errors.interest) - << ")\n\tMgmt fee: " << current.managementFeeDue - << " (true: " << truncate(nextTrueState.managementFeeDue) - << ", error: " << truncate(errors.managementFee) << ")\n\tPayments remaining " - << loanSle->at(sfPaymentRemaining) << std::endl; - - currentRoundedState = current; - } + // Default the loan + env(manage(lender, loanKeylet.key, tfLoanDefault)); + env.close(); - --state.paymentRemaining; - state.previousPaymentDate = state.nextPaymentDate; - if (paymentComponents.specialCase == xrpl::detail::PaymentSpecialCase::Final) - { + // The LoanBroker just lost some of it's first-loss capital. + // Replenish it. + replenishCover(broker, brokerAcct, startingCoverAvailable, amountToBeCovered); + + state.flags |= tfLoanDefault; state.paymentRemaining = 0; + state.totalValue = 0; + state.principalOutstanding = 0; + state.managementFeeOutstanding = 0; state.nextPaymentDate = 0; - } - else - { - state.nextPaymentDate += state.paymentInterval; - } - state.principalOutstanding -= paymentComponents.trackedPrincipalDelta; - state.managementFeeOutstanding -= paymentComponents.trackedManagementFeeDelta; - state.totalValue -= paymentComponents.trackedValueDelta; - - if (paymentParams.validateBalances) verifyLoanStatus(state); - totalPaid.trackedValueDelta += paymentComponents.trackedValueDelta; - totalPaid.trackedPrincipalDelta += paymentComponents.trackedPrincipalDelta; - totalPaid.trackedManagementFeeDelta += paymentComponents.trackedManagementFeeDelta; - totalInterestPaid += paymentComponents.trackedInterestPart(); - totalFeesPaid += serviceFee; - ++totalPaymentsMade; - - currentTrueState = nextTrueState; - } - validateBorrowerBalance(); + // Once a loan is defaulted, it can't be managed + env(manage(lender, loanKeylet.key, tfLoanUnimpair), Ter(tecNO_PERMISSION)); + env(manage(lender, loanKeylet.key, tfLoanImpair), Ter(tecNO_PERMISSION)); + // Can't make a payment on it either + env(pay(borrower, loanKeylet.key, broker.asset(300)), Ter(tecKILLED)); + }; + }; - // Loan is paid off - BEAST_EXPECT(state.paymentRemaining == 0); - BEAST_EXPECT(state.principalOutstanding == 0); + auto singlePayment = [&](Keylet const& loanKeylet, + VerifyLoanStatus const& verifyLoanStatus, + LoanState& state, + STAmount const& payoffAmount, + std::uint32_t numPayments, + std::uint32_t baseFlag, + std::uint32_t txFlags) { + // toEndOfLife + // + verifyLoanStatus(state); - auto const initialInterestDue = initialState.totalValue - - (initialState.principalOutstanding + initialState.managementFeeOutstanding); - if (paymentParams.validateBalances) - { - // Make sure all the payments add up - BEAST_EXPECT(totalPaid.trackedValueDelta == initialState.totalValue); - BEAST_EXPECT(totalPaid.trackedPrincipalDelta == initialState.principalOutstanding); - BEAST_EXPECT( - totalPaid.trackedManagementFeeDelta == initialState.managementFeeOutstanding); - // This is almost a tautology given the previous checks, but - // check it anyway for completeness. - BEAST_EXPECT(totalInterestPaid == initialInterestDue); - BEAST_EXPECT(totalPaymentsMade == initialState.paymentRemaining); - } + // Send some bogus pay transactions + env(pay(borrower, keylet::loan(uint256(0)).key, broker.asset(10), txFlags), + Ter(temINVALID)); + // broker.asset(80) is less than a single payment, but all these + // checks fail before that matters + env(pay(borrower, loanKeylet.key, broker.asset(-80), txFlags), Ter(temBAD_AMOUNT)); + env(pay(borrower, broker.brokerID, broker.asset(80), txFlags), Ter(tecNO_ENTRY)); + env(pay(evan, loanKeylet.key, broker.asset(80), txFlags), Ter(tecNO_PERMISSION)); - if (showStepBalances) - { - auto const loanSle = env.le(loanKeylet); - if (!BEAST_EXPECT(loanSle)) + // TODO: Write a general "isFlag" function? See STObject::isFlag. + // Maybe add a static overloaded member? + if (!(state.flags & lsfLoanOverpayment)) { - // No reason for this not to exist - return; - } - log << currencyLabel << " Total amounts paid: " - << "\n\tTotal value: " << totalPaid.trackedValueDelta - << " (initial: " << truncate(initialState.totalValue) - << ", error: " << truncate(initialState.totalValue - totalPaid.trackedValueDelta) - << ")\n\tPrincipal: " << totalPaid.trackedPrincipalDelta - << " (initial: " << truncate(initialState.principalOutstanding) << ", error: " - << truncate(initialState.principalOutstanding - totalPaid.trackedPrincipalDelta) - << ")\n\tInterest: " << totalInterestPaid - << " (initial: " << truncate(initialInterestDue) - << ", error: " << truncate(initialInterestDue - totalInterestPaid) - << ")\n\tMgmt fee: " << totalPaid.trackedManagementFeeDelta - << " (initial: " << truncate(initialState.managementFeeOutstanding) << ", error: " - << truncate( - initialState.managementFeeOutstanding - totalPaid.trackedManagementFeeDelta) - << ")\n\tTotal payments made: " << totalPaymentsMade << std::endl; - } - } - - void - runLoan( - AssetType assetType, - BrokerParameters const& brokerParams, - LoanParameters const& loanParams, - FeatureBitset features) - { - using namespace jtx; - - Account const issuer("issuer"); - Account const lender("lender"); - Account const borrower("borrower"); - - Env env(*this, features); - - auto loanResult = - createLoan(env, assetType, brokerParams, loanParams, issuer, lender, borrower); - if (BEAST_EXPECT(loanResult); !loanResult.has_value()) - return; - - auto broker = std::get(*loanResult); - auto loanKeylet = std::get(*loanResult); - auto pseudoAcct = std::get(*loanResult); - - VerifyLoanStatus const verifyLoanStatus(env, broker, pseudoAcct, loanKeylet); + // If the loan does not allow overpayments, send a payment that + // tries to make an overpayment. Do not include `txFlags`, so we + // don't end up duplicating the next test transaction. + // + // fixCleanup3_1_3 gates tfLoanOverpayment as a valid flag: + // with fix on → preflight passes, apply returns tecNO_PERMISSION; + // with fix off → preflight rejects the flag, returns temINVALID_FLAG. + bool const hasFix313 = env.current()->rules().enabled(fixCleanup3_1_3); + STAmount const overpayAmount{broker.asset, state.periodicPayment * Number{15, -1}}; + XRPAmount const overpayFee{ + baseFee * (Number{15, -1} / kLoanPaymentsPerFeeIncrement + 1)}; + env(pay(borrower, loanKeylet.key, overpayAmount, tfLoanOverpayment), + Fee(overpayFee), + Ter(hasFix313 ? TER{tecNO_PERMISSION} : TER{temINVALID_FLAG})); - makeLoanPayments( - env, - broker, - loanParams, - loanKeylet, - verifyLoanStatus, - issuer, - lender, - borrower, - PaymentParameters{.showStepBalances = true}); - } - - /** - * Runs through the complete lifecycle of a loan - * - * 1. Create a loan. - * 2. Test a bunch of transaction failure conditions. - * 3. Use the `toEndOfLife` callback to take the loan to 0. How that is done - * depends on the callback. e.g. Default, Early payoff, make all the - * normal payments, etc. - * 4. Delete the loan. The loan will alternate between being deleted by the - * lender and the borrower. - */ - void - lifecycle( - std::string const& caseLabel, - char const* label, - jtx::Env& env, - Number const& loanAmount, - int interestExponent, - jtx::Account const& lender, - jtx::Account const& borrower, - jtx::Account const& evan, - BrokerInfo const& broker, - jtx::Account const& pseudoAcct, - std::uint32_t flags, - // The end of life callback is expected to take the loan to 0 payments - // remaining, one way or another - std::function - toEndOfLife) - { - auto const [keylet, loanSequence] = [&]() { - auto const brokerSle = env.le(keylet::loanBroker(broker.brokerID)); - if (!BEAST_EXPECT(brokerSle)) - { - // will be invalid - return std::make_pair(keylet::loan(broker.brokerID), std::uint32_t(0)); + if (hasFix313) + { + env.disableFeature(fixCleanup3_1_3); + env(pay(borrower, loanKeylet.key, overpayAmount, tfLoanOverpayment), + Fee(overpayFee), + Ter(temINVALID_FLAG)); + env.enableFeature(fixCleanup3_1_3); + } } + // Try to send a payment marked as multiple mutually exclusive + // payment types. Do not include `txFlags`, so we don't duplicate + // the prior test transaction. + env(pay(borrower, + loanKeylet.key, + broker.asset(state.periodicPayment * 2), + tfLoanLatePayment | tfLoanFullPayment), + Ter(temINVALID_FLAG)); + env(pay(borrower, + loanKeylet.key, + broker.asset(state.periodicPayment * 2), + tfLoanLatePayment | tfLoanOverpayment), + Ter(temINVALID_FLAG)); + env(pay(borrower, + loanKeylet.key, + broker.asset(state.periodicPayment * 2), + tfLoanOverpayment | tfLoanFullPayment), + Ter(temINVALID_FLAG)); + env(pay(borrower, + loanKeylet.key, + broker.asset(state.periodicPayment * 2), + tfLoanLatePayment | tfLoanOverpayment | tfLoanFullPayment), + Ter(temINVALID_FLAG)); - // Broker has no loans - BEAST_EXPECT(brokerSle->at(sfOwnerCount) == 0); - - // The loan keylet is based on the LoanSequence of the _LOAN_BROKER_ - // object. - auto const loanSequence = brokerSle->at(sfLoanSequence); - return std::make_pair(keylet::loan(broker.brokerID, loanSequence), loanSequence); - }(); - - VerifyLoanStatus const verifyLoanStatus(env, broker, pseudoAcct, keylet); - - // No loans yet - verifyLoanStatus.checkBroker(0, 0, TenthBips32{0}, 1, 0, 0); - - if (!BEAST_EXPECT(loanSequence != 0)) - return; - - testcase << caseLabel << " " << label; - - using namespace jtx; - using namespace loan; - using namespace std::chrono_literals; - - auto applyExponent = [interestExponent, this](TenthBips32 value) mutable { - BEAST_EXPECT(value > TenthBips32(0)); - while (interestExponent > 0) - { - auto const oldValue = value; - value *= 10; - --interestExponent; - BEAST_EXPECT(value / 10 == oldValue); - } - while (interestExponent < 0) { - auto const oldValue = value; - value /= 10; - ++interestExponent; - BEAST_EXPECT(value * 10 == oldValue); + auto const otherAsset = + broker.asset.raw() == assets[0].raw() ? assets[1] : assets[0]; + env(pay(borrower, loanKeylet.key, otherAsset(100), txFlags), Ter(tecWRONG_ASSET)); } - return value; - }; - auto const borrowerOwnerCount = env.ownerCount(borrower); + // Amount doesn't cover a single payment + env(pay(borrower, loanKeylet.key, STAmount{broker.asset, 1}, txFlags), + Ter(tecINSUFFICIENT_PAYMENT)); - auto const loanSetFee = env.current()->fees().base * 2; - LoanParameters const loanParams{ - .account = borrower, - .counter = lender, - .counterpartyExplicit = false, - .principalRequest = loanAmount, - .setFee = loanSetFee, - .originationFee = 1, - .serviceFee = 2, - .lateFee = 3, - .closeFee = 4, - .overFee = applyExponent(percentageToTenthBips(5) / 10), - .interest = applyExponent(percentageToTenthBips(12)), - // 2.4% - .lateInterest = applyExponent(percentageToTenthBips(24) / 10), - .closeInterest = applyExponent(percentageToTenthBips(36) / 10), - .overpaymentInterest = applyExponent(percentageToTenthBips(48) / 10), - .payTotal = 12, - .payInterval = 600, - .gracePd = 60, - .flags = flags, - }; - Number const principalRequestAmount = broker.asset(loanParams.principalRequest).value(); - auto const originationFeeAmount = broker.asset(*loanParams.originationFee).value(); - auto const serviceFeeAmount = broker.asset(*loanParams.serviceFee).value(); - auto const lateFeeAmount = broker.asset(*loanParams.lateFee).value(); - auto const closeFeeAmount = broker.asset(*loanParams.closeFee).value(); + // Get the balance after these failed transactions take + // fees + auto const borrowerBalanceBeforePayment = env.balance(borrower, broker.asset); - auto const borrowerStartbalance = env.balance(borrower, broker.asset); + BEAST_EXPECT(payoffAmount > state.principalOutstanding); + // Try to pay a little extra to show that it's _not_ + // taken + auto const transactionAmount = payoffAmount + broker.asset(10); - auto createJtx = loanParams(env, broker); - // Successfully create a Loan - env(createJtx); + // Send a transaction that tries to pay more than the borrowers's + // balance + XRPAmount const badFee{ + baseFee * + (borrowerBalanceBeforePayment.number() * 2 / state.periodicPayment / + kLoanPaymentsPerFeeIncrement + + 1)}; + env(pay(borrower, + loanKeylet.key, + STAmount{broker.asset, borrowerBalanceBeforePayment.number() * 2}, + txFlags), + Fee(badFee), + Ter(tecINSUFFICIENT_FUNDS)); - env.close(); + XRPAmount const goodFee{baseFee * (numPayments / kLoanPaymentsPerFeeIncrement + 1)}; + env(pay(borrower, loanKeylet.key, transactionAmount, txFlags), Fee(goodFee)); - auto const startDate = env.current()->header().parentCloseTime.time_since_epoch().count(); + env.close(); - if (auto const brokerSle = env.le(keylet::loanBroker(broker.brokerID)); - BEAST_EXPECT(brokerSle)) - { - BEAST_EXPECT(brokerSle->at(sfOwnerCount) == 1); - } + // log << env.meta()->getJson() << std::endl; - { // Need to account for fees if the loan is in XRP PrettyAmount adjustment = broker.asset(0); if (broker.asset.native()) { - adjustment = 2 * env.current()->fees().base; + adjustment = badFee + goodFee; } - BEAST_EXPECT( - env.balance(borrower, broker.asset).value() == - borrowerStartbalance.value() + principalRequestAmount - originationFeeAmount - - adjustment.value()); - } + state.paymentRemaining = 0; + state.principalOutstanding = 0; + state.totalValue = 0; + state.managementFeeOutstanding = 0; + state.previousPaymentDate = + state.nextPaymentDate + (state.paymentInterval * (numPayments - 1)); + state.nextPaymentDate = 0; + verifyLoanStatus(state); - auto const loanFlags = - createJtx.stx->isFlag(tfLoanOverpayment) ? lsfLoanOverpayment : LedgerSpecificFlags(0); + verifyLoanStatus.checkPayment( + state.loanScale, borrower, borrowerBalanceBeforePayment, payoffAmount, adjustment); - if (auto loan = env.le(keylet); BEAST_EXPECT(loan)) - { - // log << "loan after create: " << to_string(loan->getJson()) - // << std::endl; - BEAST_EXPECT( - loan->isFlag(lsfLoanOverpayment) == createJtx.stx->isFlag(tfLoanOverpayment)); - BEAST_EXPECT(loan->at(sfLoanSequence) == loanSequence); - BEAST_EXPECT(loan->at(sfBorrower) == borrower.id()); - BEAST_EXPECT(loan->at(sfLoanBrokerID) == broker.brokerID); - BEAST_EXPECT(loan->at(sfLoanOriginationFee) == originationFeeAmount); - BEAST_EXPECT(loan->at(sfLoanServiceFee) == serviceFeeAmount); - BEAST_EXPECT(loan->at(sfLatePaymentFee) == lateFeeAmount); - BEAST_EXPECT(loan->at(sfClosePaymentFee) == closeFeeAmount); - BEAST_EXPECT(loan->at(sfOverpaymentFee) == *loanParams.overFee); - BEAST_EXPECT(loan->at(sfInterestRate) == *loanParams.interest); - BEAST_EXPECT(loan->at(sfLateInterestRate) == *loanParams.lateInterest); - BEAST_EXPECT(loan->at(sfCloseInterestRate) == *loanParams.closeInterest); - BEAST_EXPECT(loan->at(sfOverpaymentInterestRate) == *loanParams.overpaymentInterest); - BEAST_EXPECT(loan->at(sfStartDate) == startDate); - BEAST_EXPECT(loan->at(sfPaymentInterval) == *loanParams.payInterval); - BEAST_EXPECT(loan->at(sfGracePeriod) == *loanParams.gracePd); - BEAST_EXPECT(loan->at(sfPreviousPaymentDueDate) == 0); - BEAST_EXPECT(loan->at(sfNextPaymentDueDate) == startDate + *loanParams.payInterval); - BEAST_EXPECT(loan->at(sfPaymentRemaining) == *loanParams.payTotal); - BEAST_EXPECT( - loan->at(sfLoanScale) >= - (broker.asset.integral() - ? 0 - : std::max(broker.vaultScale(env), principalRequestAmount.exponent()))); - BEAST_EXPECT(loan->at(sfPrincipalOutstanding) == principalRequestAmount); - } + // Can't impair or default a paid off loan + env(manage(lender, loanKeylet.key, tfLoanImpair), Ter(tecNO_PERMISSION)); + env(manage(lender, loanKeylet.key, tfLoanDefault), Ter(tecNO_PERMISSION)); + }; - auto state = getCurrentState(env, broker, keylet, verifyLoanStatus); + auto fullPayment = [&](std::uint32_t baseFlag) { + return [&, baseFlag]( + Keylet const& loanKeylet, VerifyLoanStatus const& verifyLoanStatus) { + // toEndOfLife + // + auto state = getCurrentState(env, broker, loanKeylet, verifyLoanStatus); + env.close(state.startDate + 20s); + auto const loanAge = (env.now() - state.startDate).count(); + BEAST_EXPECT(loanAge == 30); - auto const loanProperties = computeLoanProperties( - env.current()->rules(), - broker.asset.raw(), - state.principalOutstanding, - state.interestRate, - state.paymentInterval, - state.paymentRemaining, - broker.params.managementFeeRate, - state.loanScale); + // Full payoff amount will consist of + // 1. principal outstanding (1000) + // 2. accrued interest (at 12%) + // 3. prepayment penalty (closeInterest at 3.6%) + // 4. close payment fee (4) + // Calculate these values without the helper functions + // to verify they're working correctly The numbers in + // the below BEAST_EXPECTs may not hold across assets. + Number const interval = state.paymentInterval; + auto const periodicRate = interval * Number(12, -2) / kSecondsInYear; + BEAST_EXPECT( + periodicRate == Number(2283105022831050228ULL, -24, Number::Normalized{})); + STAmount const principalOutstanding{broker.asset, state.principalOutstanding}; + STAmount const accruedInterest{ + broker.asset, state.principalOutstanding * periodicRate * loanAge / interval}; + BEAST_EXPECT(accruedInterest == broker.asset(Number(1141552511415525, -19))); + STAmount const prepaymentPenalty{ + broker.asset, state.principalOutstanding * Number(36, -3)}; + BEAST_EXPECT(prepaymentPenalty == broker.asset(36)); + STAmount const closePaymentFee = broker.asset(4); + auto const payoffAmount = roundToScale( + principalOutstanding + accruedInterest + prepaymentPenalty + closePaymentFee, + state.loanScale); + BEAST_EXPECT( + payoffAmount == + roundToAsset( + broker.asset, + broker.asset(Number(1040000114155251, -12)).number(), + state.loanScale)); - verifyLoanStatus( - 0, - startDate + *loanParams.payInterval, - *loanParams.payTotal, - state.loanScale, - loanProperties.loanState.valueOutstanding, - principalRequestAmount, - loanProperties.loanState.managementFeeDue, - loanProperties.periodicPayment, - loanFlags | 0); - - // Manage the loan - // no-op - env(manage(lender, keylet.key, 0)); - { - // no flags - auto jt = manage(lender, keylet.key, 0); - jt.removeMember(sfFlags.getName()); - env(jt); - } - // Only the lender can manage - env(manage(evan, keylet.key, 0), Ter(tecNO_PERMISSION)); - // unknown flags - env(manage(lender, keylet.key, tfLoanManageMask), Ter(temINVALID_FLAG)); - // combinations of flags are not allowed - env(manage(lender, keylet.key, tfLoanUnimpair | tfLoanImpair), Ter(temINVALID_FLAG)); - env(manage(lender, keylet.key, tfLoanImpair | tfLoanDefault), Ter(temINVALID_FLAG)); - env(manage(lender, keylet.key, tfLoanUnimpair | tfLoanDefault), Ter(temINVALID_FLAG)); - env(manage(lender, keylet.key, tfLoanUnimpair | tfLoanImpair | tfLoanDefault), - Ter(temINVALID_FLAG)); - // invalid loan ID - env(manage(lender, broker.brokerID, tfLoanImpair), Ter(tecNO_ENTRY)); - // Loan is unimpaired, can't unimpair it again - env(manage(lender, keylet.key, tfLoanUnimpair), Ter(tecNO_PERMISSION)); - // Loan is unimpaired, it can go into default, but only after it's past - // due - env(manage(lender, keylet.key, tfLoanDefault), Ter(tecTOO_SOON)); - - // Check the vault - bool const canImpair = canImpairLoan(env, broker, state); - // Impair the loan, if possible - env(manage(lender, keylet.key, tfLoanImpair), - canImpair ? Ter(tesSUCCESS) : Ter(tecLIMIT_EXCEEDED)); - // Unimpair the loan - env(manage(lender, keylet.key, tfLoanUnimpair), - canImpair ? Ter(tesSUCCESS) : Ter(tecNO_PERMISSION)); - - auto const nextDueDate = startDate + *loanParams.payInterval; + // The terms of this loan actually make the early payoff + // more expensive than just making payments + BEAST_EXPECT( + payoffAmount > + state.paymentRemaining * (state.periodicPayment + broker.asset(2).value())); - env.close(); + singlePayment( + loanKeylet, + verifyLoanStatus, + state, + payoffAmount, + 1, + baseFlag, + tfLoanFullPayment); + }; + }; - verifyLoanStatus( - 0, - nextDueDate, - *loanParams.payTotal, - loanProperties.loanScale, - loanProperties.loanState.valueOutstanding, - principalRequestAmount, - loanProperties.loanState.managementFeeDue, - loanProperties.periodicPayment, - loanFlags | 0); - - // Can't delete the loan yet. It has payments remaining. - env(del(lender, keylet.key), Ter(tecHAS_OBLIGATIONS)); - - if (BEAST_EXPECT(toEndOfLife)) - toEndOfLife(keylet, verifyLoanStatus); - env.close(); + auto combineAllPayments = [&](std::uint32_t baseFlag) { + return + [&, baseFlag](Keylet const& loanKeylet, VerifyLoanStatus const& verifyLoanStatus) { + // toEndOfLife + // - // Verify the loan is at EOL - if (auto loan = env.le(keylet); BEAST_EXPECT(loan)) - { - BEAST_EXPECT(loan->at(sfPaymentRemaining) == 0); - BEAST_EXPECT(loan->at(sfPrincipalOutstanding) == 0); - } - auto const borrowerStartingBalance = env.balance(borrower, broker.asset); + auto state = getCurrentState(env, broker, loanKeylet, verifyLoanStatus); + env.close(); - // Try to delete the loan broker with an active loan - env(loanBroker::del(lender, broker.brokerID), Ter(tecHAS_OBLIGATIONS)); - // Ensure the above tx doesn't get ordered after the LoanDelete and - // delete our broker! - env.close(); + BEAST_EXPECT( + STAmount(broker.asset, state.periodicPayment) == + broker.asset(Number(8333457002039338267, -17))); - // Test failure cases - env(del(lender, keylet.key, tfLoanOverpayment), Ter(temINVALID_FLAG)); - env(del(evan, keylet.key), Ter(tecNO_PERMISSION)); - env(del(lender, broker.brokerID), Ter(tecNO_ENTRY)); - - // Delete the loan - // Either the borrower or the lender can delete the loan. Alternate - // between who does it across tests. - static unsigned kDeleteCounter = 0; - auto const deleter = ((++kDeleteCounter % 2) != 0u) ? lender : borrower; - env(del(deleter, keylet.key)); - env.close(); + // Make all the payments in one transaction + // service fee is 2 + auto const startingPayments = state.paymentRemaining; + STAmount const payoffAmount = [&]() { + NumberRoundModeGuard const mg(Number::RoundingMode::Upward); + auto const rawPayoff = + startingPayments * (state.periodicPayment + broker.asset(2).value()); + STAmount payoffAmount{broker.asset, rawPayoff}; + BEAST_EXPECTS( + payoffAmount == broker.asset(Number(1024014840244721, -12)), + to_string(payoffAmount)); + BEAST_EXPECT(payoffAmount > state.principalOutstanding); - PrettyAmount adjustment = broker.asset(0); - if (deleter == borrower) - { - // Need to account for fees if the loan is in XRP - if (broker.asset.native()) - { - adjustment = env.current()->fees().base; - } - } + payoffAmount = roundToScale(payoffAmount, state.loanScale); - // No loans left - verifyLoanStatus.checkBroker(0, 0, *loanParams.interest, 1, 0, 0); + return payoffAmount; + }(); - BEAST_EXPECT( - env.balance(borrower, broker.asset).value() == - borrowerStartingBalance.value() - adjustment); - BEAST_EXPECT(env.ownerCount(borrower) == borrowerOwnerCount); + auto const totalPayoffValue = + state.totalValue + startingPayments * broker.asset(2).value(); + STAmount const totalPayoffAmount{broker.asset, totalPayoffValue}; - if (auto const brokerSle = env.le(keylet::loanBroker(broker.brokerID)); - BEAST_EXPECT(brokerSle)) - { - BEAST_EXPECT(brokerSle->at(sfOwnerCount) == 0); - } - } + BEAST_EXPECTS( + totalPayoffAmount == payoffAmount, + "Payoff amount: " + to_string(payoffAmount) + + ". Total Value: " + to_string(totalPayoffAmount)); - static std::string - getCurrencyLabel(Asset const& asset) - { - if (asset.native()) - return "XRP"; - if (asset.holds()) - return "IOU"; - if (asset.holds()) - return "MPT"; - return "Unknown"; - } + singlePayment( + loanKeylet, + verifyLoanStatus, + state, + payoffAmount, + state.paymentRemaining, + baseFlag, + 0); + }; + }; - /** - * Wrapper to run a series of lifecycle tests for a given asset and loan - * amount - * - * Will be used in the future to vary the loan parameters. For now, it is - * only called once. - * - * Tests a bunch of LoanSet failure conditions before lifecycle. - */ - template - void - testCaseWrapper( - jtx::Env& env, - jtx::MPTTester& mptt, - std::array const& assets, - BrokerInfo const& broker, - Number const& loanAmount, - int interestExponent) - { - using namespace jtx; - using namespace Lending; - - auto const& asset = broker.asset.raw(); - auto const currencyLabel = getCurrencyLabel(asset); - auto const caseLabel = [&]() { - std::stringstream ss; - ss << "Lifecycle: " << loanAmount << " " << currencyLabel - << " Scale interest to: " << interestExponent << " "; - return ss.str(); - }(); - testcase << caseLabel; - - using namespace loan; - using namespace std::chrono_literals; - using d = NetClock::duration; - using tp = NetClock::time_point; - - Account const issuer{"issuer"}; - // For simplicity, lender will be the sole actor for the vault & - // brokers. - Account const lender{"lender"}; - // Borrower only wants to borrow - Account const borrower{"borrower"}; - // Evan will attempt to be naughty - Account const evan{"evan"}; - // Do not fund alice - Account const alice{"alice"}; - - Number const principalRequest = broker.asset(loanAmount).value(); - Number const maxCoveredLoanValue = broker.params.maxCoveredLoanValue(0); - BEAST_EXPECT(maxCoveredLoanValue == 1000 * 100 / 10); - Number const maxCoveredLoanRequest = broker.asset(maxCoveredLoanValue).value(); - Number const totalVaultRequest = broker.asset(broker.params.vaultDeposit).value(); - Number const debtMaximumRequest = broker.asset(broker.params.debtMax).value(); - - auto const loanSetFee = Fee(env.current()->fees().base * 2); - - auto const pseudoAcct = [&]() { - auto const brokerSle = env.le(keylet::loanBroker(broker.brokerID)); - if (!BEAST_EXPECT(brokerSle)) - return Account{lender}; - auto const brokerPseudo = brokerSle->at(sfAccount); - return Account("Broker pseudo-account", brokerPseudo); - }(); - - auto const baseFee = env.current()->fees().base; - - auto badKeylet = keylet::vault(lender.id(), env.seq(lender)); - // Try some failure cases - // flags are checked first - env(set(evan, broker.brokerID, principalRequest, tfLoanSetMask), - Sig(sfCounterpartySignature, lender), - loanSetFee, - Ter(temINVALID_FLAG)); - - // field length validation - // sfData: good length, bad account - env(set(evan, broker.brokerID, principalRequest), - Sig(sfCounterpartySignature, borrower), - kData(std::string(kMaxDataPayloadLength, 'X')), - loanSetFee, - Ter(tefBAD_AUTH)); - // sfData: too long - env(set(evan, broker.brokerID, principalRequest), - Sig(sfCounterpartySignature, lender), - kData(std::string(kMaxDataPayloadLength + 1, 'Y')), - loanSetFee, - Ter(temINVALID)); - - // field range validation - // sfOverpaymentFee: good value, bad account - env(set(evan, broker.brokerID, principalRequest), - Sig(sfCounterpartySignature, borrower), - kOverpaymentFee(kMaxOverpaymentFee), - loanSetFee, - Ter(tefBAD_AUTH)); - // sfOverpaymentFee: too big - env(set(evan, broker.brokerID, principalRequest), - Sig(sfCounterpartySignature, lender), - kOverpaymentFee(kMaxOverpaymentFee + 1), - loanSetFee, - Ter(temINVALID)); - - // sfInterestRate: good value, bad account - env(set(evan, broker.brokerID, principalRequest), - Sig(sfCounterpartySignature, borrower), - kInterestRate(kMaxInterestRate), - loanSetFee, - Ter(tefBAD_AUTH)); - env(set(evan, broker.brokerID, principalRequest), - Sig(sfCounterpartySignature, borrower), - kInterestRate(TenthBips32(0)), - loanSetFee, - Ter(tefBAD_AUTH)); - // sfInterestRate: too big - env(set(evan, broker.brokerID, principalRequest), - Sig(sfCounterpartySignature, lender), - kInterestRate(kMaxInterestRate + 1), - loanSetFee, - Ter(temINVALID)); - // sfInterestRate: too small - env(set(evan, broker.brokerID, principalRequest), - Sig(sfCounterpartySignature, lender), - kInterestRate(TenthBips32(-1)), - loanSetFee, - Ter(temINVALID)); - - // sfLateInterestRate: good value, bad account - env(set(evan, broker.brokerID, principalRequest), - Sig(sfCounterpartySignature, borrower), - kLateInterestRate(kMaxLateInterestRate), - loanSetFee, - Ter(tefBAD_AUTH)); - env(set(evan, broker.brokerID, principalRequest), - Sig(sfCounterpartySignature, borrower), - kLateInterestRate(TenthBips32(0)), - loanSetFee, - Ter(tefBAD_AUTH)); - // sfLateInterestRate: too big - env(set(evan, broker.brokerID, principalRequest), - Sig(sfCounterpartySignature, lender), - kLateInterestRate(kMaxLateInterestRate + 1), - loanSetFee, - Ter(temINVALID)); - // sfLateInterestRate: too small - env(set(evan, broker.brokerID, principalRequest), - Sig(sfCounterpartySignature, lender), - kLateInterestRate(TenthBips32(-1)), - loanSetFee, - Ter(temINVALID)); - - // sfCloseInterestRate: good value, bad account - env(set(evan, broker.brokerID, principalRequest), - Sig(sfCounterpartySignature, borrower), - kCloseInterestRate(kMaxCloseInterestRate), - loanSetFee, - Ter(tefBAD_AUTH)); - env(set(evan, broker.brokerID, principalRequest), - Sig(sfCounterpartySignature, borrower), - kCloseInterestRate(TenthBips32(0)), - loanSetFee, - Ter(tefBAD_AUTH)); - // sfCloseInterestRate: too big - env(set(evan, broker.brokerID, principalRequest), - Sig(sfCounterpartySignature, lender), - kCloseInterestRate(kMaxCloseInterestRate + 1), - loanSetFee, - Ter(temINVALID)); - env(set(evan, broker.brokerID, principalRequest), - Sig(sfCounterpartySignature, lender), - kCloseInterestRate(TenthBips32(-1)), - loanSetFee, - Ter(temINVALID)); - - // sfOverpaymentInterestRate: good value, bad account - env(set(evan, broker.brokerID, principalRequest), - Sig(sfCounterpartySignature, borrower), - kOverpaymentInterestRate(kMaxOverpaymentInterestRate), - loanSetFee, - Ter(tefBAD_AUTH)); - env(set(evan, broker.brokerID, principalRequest), - Sig(sfCounterpartySignature, borrower), - kOverpaymentInterestRate(TenthBips32(0)), - loanSetFee, - Ter(tefBAD_AUTH)); - // sfOverpaymentInterestRate: too big - env(set(evan, broker.brokerID, principalRequest), - Sig(sfCounterpartySignature, lender), - kOverpaymentInterestRate(kMaxOverpaymentInterestRate + 1), - loanSetFee, - Ter(temINVALID)); - env(set(evan, broker.brokerID, principalRequest), - Sig(sfCounterpartySignature, lender), - kOverpaymentInterestRate(TenthBips32(-1)), - loanSetFee, - Ter(temINVALID)); - - // sfPaymentTotal: good value, bad account - env(set(evan, broker.brokerID, principalRequest), - Sig(sfCounterpartySignature, borrower), - kPaymentTotal(LoanSet::kMinPaymentTotal), - loanSetFee, - Ter(tefBAD_AUTH)); - // sfPaymentTotal: too small (there is no max) - env(set(evan, broker.brokerID, principalRequest), - Sig(sfCounterpartySignature, lender), - kPaymentTotal(LoanSet::kMinPaymentTotal - 1), - loanSetFee, - Ter(temINVALID)); - - // sfPaymentInterval: good value, bad account - env(set(evan, broker.brokerID, principalRequest), - Sig(sfCounterpartySignature, borrower), - kPaymentInterval(LoanSet::kMinPaymentInterval), - loanSetFee, - Ter(tefBAD_AUTH)); - // sfPaymentInterval: too small (there is no max) - env(set(evan, broker.brokerID, principalRequest), - Sig(sfCounterpartySignature, lender), - kPaymentInterval(LoanSet::kMinPaymentInterval - 1), - loanSetFee, - Ter(temINVALID)); - - // sfGracePeriod: good value, bad account - env(set(evan, broker.brokerID, principalRequest), - Sig(sfCounterpartySignature, borrower), - kPaymentInterval(LoanSet::kMinPaymentInterval * 2), - kGracePeriod(LoanSet::kMinPaymentInterval * 2), - loanSetFee, - Ter(tefBAD_AUTH)); - // sfGracePeriod: larger than paymentInterval - env(set(evan, broker.brokerID, principalRequest), - Sig(sfCounterpartySignature, lender), - kPaymentInterval(LoanSet::kMinPaymentInterval * 2), - kGracePeriod(LoanSet::kMinPaymentInterval * 3), - loanSetFee, - Ter(temINVALID)); - - // insufficient fee - single sign - env(set(borrower, broker.brokerID, principalRequest), - Sig(sfCounterpartySignature, lender), - Ter(telINSUF_FEE_P)); - // insufficient fee - multisign - env(signers(lender, 2, {{evan, 1}, {borrower, 1}})); - env(signers(borrower, 2, {{evan, 1}, {lender, 1}})); - env(set(borrower, broker.brokerID, principalRequest), - kCounterparty(lender), - Msig(evan, lender), - Msig(sfCounterpartySignature, evan, borrower), - Fee(env.current()->fees().base * 5 - 1), - Ter(telINSUF_FEE_P)); - // Bad multisign signatures for borrower (Account) - env(set(borrower, broker.brokerID, principalRequest), - kCounterparty(lender), - Msig(alice, issuer), - Msig(sfCounterpartySignature, evan, borrower), - Fee(env.current()->fees().base * 5), - Ter(tefBAD_SIGNATURE)); - // Bad multisign signatures for issuer (Counterparty) - env(set(borrower, broker.brokerID, principalRequest), - kCounterparty(lender), - Msig(evan, lender), - Msig(sfCounterpartySignature, alice, issuer), - Fee(env.current()->fees().base * 5 - 1), - Ter(tefBAD_SIGNATURE)); - env(signers(lender, kNone)); - env(signers(borrower, kNone)); - // multisign sufficient fee, but no signers set up - env(set(borrower, broker.brokerID, principalRequest), - kCounterparty(lender), - Msig(evan, lender), - Msig(sfCounterpartySignature, evan, borrower), - Fee(env.current()->fees().base * 5), - Ter(tefNOT_MULTI_SIGNING)); - // not the broker owner, no counterparty, not signed by broker - // owner - env(set(borrower, broker.brokerID, principalRequest), - Sig(sfCounterpartySignature, evan), - loanSetFee, - Ter(tefBAD_AUTH)); - // not the broker owner, counterparty is borrower - env(set(evan, broker.brokerID, principalRequest), - kCounterparty(borrower), - Sig(sfCounterpartySignature, borrower), - loanSetFee, - Ter(tecNO_PERMISSION)); - // not a LoanBroker object, no counterparty - env(set(lender, badKeylet.key, principalRequest), - Sig(sfCounterpartySignature, evan), - loanSetFee, - Ter(temBAD_SIGNER)); - // not a LoanBroker object, counterparty is valid - env(set(lender, badKeylet.key, principalRequest), - kCounterparty(borrower), - Sig(sfCounterpartySignature, borrower), - loanSetFee, - Ter(tecNO_ENTRY)); - // borrower doesn't exist - env(set(lender, broker.brokerID, principalRequest), - kCounterparty(alice), - Sig(sfCounterpartySignature, alice), - loanSetFee, - Ter(terNO_ACCOUNT)); - - // Request more funds than the vault has available - env(set(evan, broker.brokerID, totalVaultRequest + 1), - Sig(sfCounterpartySignature, lender), - loanSetFee, - Ter(tecINSUFFICIENT_FUNDS)); - - // Request more funds than the broker's first-loss capital can - // cover. - env(set(evan, broker.brokerID, maxCoveredLoanRequest + 1), - Sig(sfCounterpartySignature, lender), - loanSetFee, - Ter(tecINSUFFICIENT_FUNDS)); - - // Frozen trust line / locked MPT issuance - // XRP can not be frozen, but run through the loop anyway to test - // the tecLIMIT_EXCEEDED case - { - auto const brokerSle = env.le(keylet::loanBroker(broker.brokerID)); - if (!BEAST_EXPECT(brokerSle)) - return; - - auto const vaultPseudo = [&]() { - auto const vaultSle = env.le(keylet::vault(brokerSle->at(sfVaultID))); - if (!BEAST_EXPECT(vaultSle)) - { - // This will be wrong, but the test has failed anyway. - return Account{lender}; - } - auto vaultPseudo = Account("Vault pseudo-account", vaultSle->at(sfAccount)); - return vaultPseudo; - }(); - - auto const [freeze, deepfreeze, unfreeze, expectedResult] = - [&]() -> std::tuple< - std::function, - std::function, - std::function, - TER> { - // Freeze / lock the asset - std::function const empty; - if (broker.asset.native()) - { - // XRP can't be frozen - return std::make_tuple(empty, empty, empty, tesSUCCESS); - } - if (broker.asset.holds()) - { - auto freeze = [&](Account const& holder) { - env(trust(issuer, holder[iouCurrency_](0), tfSetFreeze)); - }; - auto deepfreeze = [&](Account const& holder) { - env(trust(issuer, holder[iouCurrency_](0), tfSetFreeze | tfSetDeepFreeze)); - }; - auto unfreeze = [&](Account const& holder) { - env(trust( - issuer, holder[iouCurrency_](0), tfClearFreeze | tfClearDeepFreeze)); - }; - return std::make_tuple(freeze, deepfreeze, unfreeze, tecFROZEN); - } - - auto freeze = [&](Account const& holder) { - mptt.set({.account = issuer, .holder = holder, .flags = tfMPTLock}); - }; - auto unfreeze = [&](Account const& holder) { - mptt.set({.account = issuer, .holder = holder, .flags = tfMPTUnlock}); - }; - return std::make_tuple(freeze, empty, unfreeze, tecLOCKED); - }(); - - // Try freezing the accounts that can't be frozen - if (freeze) - { - for (auto const& account : {vaultPseudo, evan}) - { - // Freeze the account - freeze(account); - - // Try to create a loan with a frozen line - env(set(evan, broker.brokerID, debtMaximumRequest), - Sig(sfCounterpartySignature, lender), - loanSetFee, - Ter(expectedResult)); - - // Unfreeze the account - BEAST_EXPECT(unfreeze); - unfreeze(account); - - // Ensure the line is unfrozen with a request that is fine - // except too it requests more principal than the broker can - // carry - env(set(evan, broker.brokerID, debtMaximumRequest + 1), - Sig(sfCounterpartySignature, lender), - loanSetFee, - Ter(tecLIMIT_EXCEEDED)); - } - } - - // Deep freeze the borrower, which prevents them from receiving - // funds - if (deepfreeze) - { - // Make sure evan has a trust line that so the issuer can - // freeze it. (Don't need to do this for the borrower, - // because LoanSet will create a line to the borrower - // automatically.) - env(trust(evan, issuer[iouCurrency_](100'000))); - - for (auto const& account : {// these accounts can't be frozen, which deep freeze - // implies - vaultPseudo, - evan, - // these accounts can't be deep frozen - lender}) - { - // Freeze evan - deepfreeze(account); - - // Try to create a loan with a deep frozen line - env(set(evan, broker.brokerID, debtMaximumRequest), - Sig(sfCounterpartySignature, lender), - loanSetFee, - Ter(expectedResult)); - - // Unfreeze evan - BEAST_EXPECT(unfreeze); - unfreeze(account); - - // Ensure the line is unfrozen with a request that is fine - // except too it requests more principal than the broker can - // carry - env(set(evan, broker.brokerID, debtMaximumRequest + 1), - Sig(sfCounterpartySignature, lender), - loanSetFee, - Ter(tecLIMIT_EXCEEDED)); - } - } - } - - // Finally! Create a loan - - auto coverAvailable = [&env, this](uint256 const& brokerID, Number const& expected) { - if (auto const brokerSle = env.le(keylet::loanBroker(brokerID)); - BEAST_EXPECT(brokerSle)) - { - auto const available = brokerSle->at(sfCoverAvailable); - BEAST_EXPECT(available == expected); - return available; - } - return Number{}; - }; - auto getDefaultInfo = [&env, this](LoanState const& state, BrokerInfo const& broker) { - if (auto const brokerSle = env.le(keylet::loanBroker(broker.brokerID)); - BEAST_EXPECT(brokerSle)) - { - BEAST_EXPECT( - state.loanScale >= - (broker.asset.integral() - ? 0 - : std::max( - broker.vaultScale(env), state.principalOutstanding.exponent()))); - NumberRoundModeGuard const mg(Number::RoundingMode::Upward); - auto const defaultAmount = roundToAsset( - broker.asset, - std::min( - tenthBipsOfValue( - tenthBipsOfValue( - brokerSle->at(sfDebtTotal), broker.params.coverRateMin), - broker.params.coverRateLiquidation), - state.totalValue - state.managementFeeOutstanding), - state.loanScale); - return std::make_pair(defaultAmount, brokerSle->at(sfOwner)); - } - return std::make_pair(Number{}, AccountID{}); - }; - auto replenishCover = [&env, &coverAvailable]( - BrokerInfo const& broker, - AccountID const& brokerAcct, - Number const& startingCoverAvailable, - Number const& amountToBeCovered) { - coverAvailable(broker.brokerID, startingCoverAvailable - amountToBeCovered); - env(loanBroker::coverDeposit( - brokerAcct, broker.brokerID, STAmount{broker.asset, amountToBeCovered})); - coverAvailable(broker.brokerID, startingCoverAvailable); - env.close(); - }; - - auto defaultImmediately = [&](std::uint32_t baseFlag, bool impair = true) { - return [&, impair, baseFlag]( - Keylet const& loanKeylet, VerifyLoanStatus const& verifyLoanStatus) { - // toEndOfLife - // - // Default the loan - - // Initialize values with the current state - auto state = getCurrentState(env, broker, loanKeylet, verifyLoanStatus); - BEAST_EXPECT(state.flags == baseFlag); - - auto const& broker = verifyLoanStatus.broker; - auto const startingCoverAvailable = coverAvailable( - broker.brokerID, broker.asset(broker.params.coverDeposit).number()); - - if (impair) - { - // Check the vault - bool const canImpair = canImpairLoan(env, broker, state); - // Impair the loan, if possible - env(manage(lender, loanKeylet.key, tfLoanImpair), - canImpair ? Ter(tesSUCCESS) : Ter(tecLIMIT_EXCEEDED)); - - if (canImpair) - { - state.flags |= tfLoanImpair; - state.nextPaymentDate = env.now().time_since_epoch().count(); - - // Once the loan is impaired, it can't be impaired again - env(manage(lender, loanKeylet.key, tfLoanImpair), Ter(tecNO_PERMISSION)); - } - verifyLoanStatus(state); - } - - auto const nextDueDate = tp{d{state.nextPaymentDate}}; - - // Can't default the loan yet. The grace period hasn't - // expired - env(manage(lender, loanKeylet.key, tfLoanDefault), Ter(tecTOO_SOON)); - - // Let some time pass so that the loan can be - // defaulted - env.close(nextDueDate + 60s); - - auto const [amountToBeCovered, brokerAcct] = getDefaultInfo(state, broker); - - // Default the loan - env(manage(lender, loanKeylet.key, tfLoanDefault)); - env.close(); - - // The LoanBroker just lost some of it's first-loss capital. - // Replenish it. - replenishCover(broker, brokerAcct, startingCoverAvailable, amountToBeCovered); - - state.flags |= tfLoanDefault; - state.paymentRemaining = 0; - state.totalValue = 0; - state.principalOutstanding = 0; - state.managementFeeOutstanding = 0; - state.nextPaymentDate = 0; - verifyLoanStatus(state); - - // Once a loan is defaulted, it can't be managed - env(manage(lender, loanKeylet.key, tfLoanUnimpair), Ter(tecNO_PERMISSION)); - env(manage(lender, loanKeylet.key, tfLoanImpair), Ter(tecNO_PERMISSION)); - // Can't make a payment on it either - env(pay(borrower, loanKeylet.key, broker.asset(300)), Ter(tecKILLED)); - }; - }; - - auto singlePayment = [&](Keylet const& loanKeylet, - VerifyLoanStatus const& verifyLoanStatus, - LoanState& state, - STAmount const& payoffAmount, - std::uint32_t numPayments, - std::uint32_t baseFlag, - std::uint32_t txFlags) { - // toEndOfLife - // - verifyLoanStatus(state); - - // Send some bogus pay transactions - env(pay(borrower, keylet::loan(uint256(0)).key, broker.asset(10), txFlags), - Ter(temINVALID)); - // broker.asset(80) is less than a single payment, but all these - // checks fail before that matters - env(pay(borrower, loanKeylet.key, broker.asset(-80), txFlags), Ter(temBAD_AMOUNT)); - env(pay(borrower, broker.brokerID, broker.asset(80), txFlags), Ter(tecNO_ENTRY)); - env(pay(evan, loanKeylet.key, broker.asset(80), txFlags), Ter(tecNO_PERMISSION)); - - // TODO: Write a general "isFlag" function? See STObject::isFlag. - // Maybe add a static overloaded member? - if (!(state.flags & lsfLoanOverpayment)) - { - // If the loan does not allow overpayments, send a payment that - // tries to make an overpayment. Do not include `txFlags`, so we - // don't end up duplicating the next test transaction. - // - // fixCleanup3_1_3 gates tfLoanOverpayment as a valid flag: - // with fix on → preflight passes, apply returns tecNO_PERMISSION; - // with fix off → preflight rejects the flag, returns temINVALID_FLAG. - bool const hasFix313 = env.current()->rules().enabled(fixCleanup3_1_3); - STAmount const overpayAmount{broker.asset, state.periodicPayment * Number{15, -1}}; - XRPAmount const overpayFee{ - baseFee * (Number{15, -1} / kLoanPaymentsPerFeeIncrement + 1)}; - env(pay(borrower, loanKeylet.key, overpayAmount, tfLoanOverpayment), - Fee(overpayFee), - Ter(hasFix313 ? TER{tecNO_PERMISSION} : TER{temINVALID_FLAG})); - - if (hasFix313) - { - env.disableFeature(fixCleanup3_1_3); - env(pay(borrower, loanKeylet.key, overpayAmount, tfLoanOverpayment), - Fee(overpayFee), - Ter(temINVALID_FLAG)); - env.enableFeature(fixCleanup3_1_3); - } - } - // Try to send a payment marked as multiple mutually exclusive - // payment types. Do not include `txFlags`, so we don't duplicate - // the prior test transaction. - env(pay(borrower, - loanKeylet.key, - broker.asset(state.periodicPayment * 2), - tfLoanLatePayment | tfLoanFullPayment), - Ter(temINVALID_FLAG)); - env(pay(borrower, - loanKeylet.key, - broker.asset(state.periodicPayment * 2), - tfLoanLatePayment | tfLoanOverpayment), - Ter(temINVALID_FLAG)); - env(pay(borrower, - loanKeylet.key, - broker.asset(state.periodicPayment * 2), - tfLoanOverpayment | tfLoanFullPayment), - Ter(temINVALID_FLAG)); - env(pay(borrower, - loanKeylet.key, - broker.asset(state.periodicPayment * 2), - tfLoanLatePayment | tfLoanOverpayment | tfLoanFullPayment), - Ter(temINVALID_FLAG)); - - { - auto const otherAsset = - broker.asset.raw() == assets[0].raw() ? assets[1] : assets[0]; - env(pay(borrower, loanKeylet.key, otherAsset(100), txFlags), Ter(tecWRONG_ASSET)); - } - - // Amount doesn't cover a single payment - env(pay(borrower, loanKeylet.key, STAmount{broker.asset, 1}, txFlags), - Ter(tecINSUFFICIENT_PAYMENT)); - - // Get the balance after these failed transactions take - // fees - auto const borrowerBalanceBeforePayment = env.balance(borrower, broker.asset); - - BEAST_EXPECT(payoffAmount > state.principalOutstanding); - // Try to pay a little extra to show that it's _not_ - // taken - auto const transactionAmount = payoffAmount + broker.asset(10); - - // Send a transaction that tries to pay more than the borrowers's - // balance - XRPAmount const badFee{ - baseFee * - (borrowerBalanceBeforePayment.number() * 2 / state.periodicPayment / - kLoanPaymentsPerFeeIncrement + - 1)}; - env(pay(borrower, - loanKeylet.key, - STAmount{broker.asset, borrowerBalanceBeforePayment.number() * 2}, - txFlags), - Fee(badFee), - Ter(tecINSUFFICIENT_FUNDS)); - - XRPAmount const goodFee{baseFee * (numPayments / kLoanPaymentsPerFeeIncrement + 1)}; - env(pay(borrower, loanKeylet.key, transactionAmount, txFlags), Fee(goodFee)); - - env.close(); - - // log << env.meta()->getJson() << std::endl; - - // Need to account for fees if the loan is in XRP - PrettyAmount adjustment = broker.asset(0); - if (broker.asset.native()) - { - adjustment = badFee + goodFee; - } - - state.paymentRemaining = 0; - state.principalOutstanding = 0; - state.totalValue = 0; - state.managementFeeOutstanding = 0; - state.previousPaymentDate = - state.nextPaymentDate + (state.paymentInterval * (numPayments - 1)); - state.nextPaymentDate = 0; - verifyLoanStatus(state); - - verifyLoanStatus.checkPayment( - state.loanScale, borrower, borrowerBalanceBeforePayment, payoffAmount, adjustment); - - // Can't impair or default a paid off loan - env(manage(lender, loanKeylet.key, tfLoanImpair), Ter(tecNO_PERMISSION)); - env(manage(lender, loanKeylet.key, tfLoanDefault), Ter(tecNO_PERMISSION)); - }; - - auto fullPayment = [&](std::uint32_t baseFlag) { - return [&, baseFlag]( - Keylet const& loanKeylet, VerifyLoanStatus const& verifyLoanStatus) { - // toEndOfLife - // - auto state = getCurrentState(env, broker, loanKeylet, verifyLoanStatus); - env.close(state.startDate + 20s); - auto const loanAge = (env.now() - state.startDate).count(); - BEAST_EXPECT(loanAge == 30); - - // Full payoff amount will consist of - // 1. principal outstanding (1000) - // 2. accrued interest (at 12%) - // 3. prepayment penalty (closeInterest at 3.6%) - // 4. close payment fee (4) - // Calculate these values without the helper functions - // to verify they're working correctly The numbers in - // the below BEAST_EXPECTs may not hold across assets. - Number const interval = state.paymentInterval; - auto const periodicRate = interval * Number(12, -2) / kSecondsInYear; - BEAST_EXPECT( - periodicRate == Number(2283105022831050228ULL, -24, Number::Normalized{})); - STAmount const principalOutstanding{broker.asset, state.principalOutstanding}; - STAmount const accruedInterest{ - broker.asset, state.principalOutstanding * periodicRate * loanAge / interval}; - BEAST_EXPECT(accruedInterest == broker.asset(Number(1141552511415525, -19))); - STAmount const prepaymentPenalty{ - broker.asset, state.principalOutstanding * Number(36, -3)}; - BEAST_EXPECT(prepaymentPenalty == broker.asset(36)); - STAmount const closePaymentFee = broker.asset(4); - auto const payoffAmount = roundToScale( - principalOutstanding + accruedInterest + prepaymentPenalty + closePaymentFee, - state.loanScale); - BEAST_EXPECT( - payoffAmount == - roundToAsset( - broker.asset, - broker.asset(Number(1040000114155251, -12)).number(), - state.loanScale)); - - // The terms of this loan actually make the early payoff - // more expensive than just making payments - BEAST_EXPECT( - payoffAmount > - state.paymentRemaining * (state.periodicPayment + broker.asset(2).value())); - - singlePayment( - loanKeylet, - verifyLoanStatus, - state, - payoffAmount, - 1, - baseFlag, - tfLoanFullPayment); - }; - }; - - auto combineAllPayments = [&](std::uint32_t baseFlag) { - return - [&, baseFlag](Keylet const& loanKeylet, VerifyLoanStatus const& verifyLoanStatus) { - // toEndOfLife - // - - auto state = getCurrentState(env, broker, loanKeylet, verifyLoanStatus); - env.close(); - - BEAST_EXPECT( - STAmount(broker.asset, state.periodicPayment) == - broker.asset(Number(8333457002039338267, -17))); - - // Make all the payments in one transaction - // service fee is 2 - auto const startingPayments = state.paymentRemaining; - STAmount const payoffAmount = [&]() { - NumberRoundModeGuard const mg(Number::RoundingMode::Upward); - auto const rawPayoff = - startingPayments * (state.periodicPayment + broker.asset(2).value()); - STAmount payoffAmount{broker.asset, rawPayoff}; - BEAST_EXPECTS( - payoffAmount == broker.asset(Number(1024014840244721, -12)), - to_string(payoffAmount)); - BEAST_EXPECT(payoffAmount > state.principalOutstanding); - - payoffAmount = roundToScale(payoffAmount, state.loanScale); - - return payoffAmount; - }(); - - auto const totalPayoffValue = - state.totalValue + startingPayments * broker.asset(2).value(); - STAmount const totalPayoffAmount{broker.asset, totalPayoffValue}; - - BEAST_EXPECTS( - totalPayoffAmount == payoffAmount, - "Payoff amount: " + to_string(payoffAmount) + - ". Total Value: " + to_string(totalPayoffAmount)); - - singlePayment( - loanKeylet, - verifyLoanStatus, - state, - payoffAmount, - state.paymentRemaining, - baseFlag, - 0); - }; - }; - - // There are a lot of fields that can be set on a loan, but most - // of them only affect the "math" when a payment is made. The - // only one that really affects behavior is the - // `tfLoanOverpayment` flag. - lifecycle( - caseLabel, - "Loan overpayment allowed - Impair and Default", - env, - loanAmount, - interestExponent, - lender, - borrower, - evan, - broker, - pseudoAcct, - tfLoanOverpayment, - defaultImmediately(lsfLoanOverpayment)); - - lifecycle( - caseLabel, - "Loan overpayment prohibited - Impair and Default", - env, - loanAmount, - interestExponent, - lender, - borrower, - evan, - broker, - pseudoAcct, - 0, - defaultImmediately(0)); - - lifecycle( - caseLabel, - "Loan overpayment allowed - Default without Impair", - env, - loanAmount, - interestExponent, - lender, - borrower, - evan, - broker, - pseudoAcct, - tfLoanOverpayment, - defaultImmediately(lsfLoanOverpayment, false)); - - lifecycle( - caseLabel, - "Loan overpayment prohibited - Default without Impair", - env, - loanAmount, - interestExponent, - lender, - borrower, - evan, - broker, - pseudoAcct, - 0, - defaultImmediately(0, false)); - - lifecycle( - caseLabel, - "Loan overpayment prohibited - Pay off immediately", - env, - loanAmount, - interestExponent, - lender, - borrower, - evan, - broker, - pseudoAcct, - 0, - fullPayment(0)); - - lifecycle( - caseLabel, - "Loan overpayment allowed - Pay off immediately", - env, - loanAmount, - interestExponent, - lender, - borrower, - evan, - broker, - pseudoAcct, - tfLoanOverpayment, - fullPayment(lsfLoanOverpayment)); - - lifecycle( - caseLabel, - "Loan overpayment prohibited - Combine all payments", - env, - loanAmount, - interestExponent, - lender, - borrower, - evan, - broker, - pseudoAcct, - 0, - combineAllPayments(0)); - - lifecycle( - caseLabel, - "Loan overpayment allowed - Combine all payments", - env, - loanAmount, - interestExponent, - lender, - borrower, - evan, - broker, - pseudoAcct, - tfLoanOverpayment, - combineAllPayments(lsfLoanOverpayment)); - - lifecycle( - caseLabel, - "Loan overpayment prohibited - Make payments", - env, - loanAmount, - interestExponent, - lender, - borrower, - evan, - broker, - pseudoAcct, - 0, - [&](Keylet const& loanKeylet, VerifyLoanStatus const& verifyLoanStatus) { - // toEndOfLife - // - // Draw and make multiple payments - auto state = getCurrentState(env, broker, loanKeylet, verifyLoanStatus); - BEAST_EXPECT(state.flags == 0); - env.close(); - - verifyLoanStatus(state); - - env.close(state.startDate + 20s); - auto const loanAge = (env.now() - state.startDate).count(); - BEAST_EXPECT(loanAge == 30); - - // Periodic payment amount will consist of - // 1. principal outstanding (1000) - // 2. interest interest rate (at 12%) - // 3. payment interval (600s) - // 4. loan service fee (2) - // Calculate these values without the helper functions - // to verify they're working correctly The numbers in - // the below BEAST_EXPECTs may not hold across assets. - Number const interval = state.paymentInterval; - auto const periodicRate = interval * Number(12, -2) / kSecondsInYear; - BEAST_EXPECT( - periodicRate == Number(2283105022831050228, -24, Number::Normalized{})); - STAmount const roundedPeriodicPayment{ - broker.asset, - roundPeriodicPayment(broker.asset, state.periodicPayment, state.loanScale)}; - - testcase << currencyLabel << " Payment components: " - << "Payments remaining, rawInterest, rawPrincipal, " - "rawMFee, trackedValueDelta, trackedPrincipalDelta, " - "trackedInterestDelta, trackedMgmtFeeDelta, special"; - - auto const serviceFee = broker.asset(2); - - BEAST_EXPECT( - roundedPeriodicPayment == - roundToScale( - broker.asset( - Number(8333457002039338267, -17), Number::RoundingMode::Upward), - state.loanScale, - Number::RoundingMode::Upward)); - // 83334570.01162141 - // Include the service fee - STAmount const totalDue = roundToScale( - roundedPeriodicPayment + serviceFee, - state.loanScale, - Number::RoundingMode::Upward); - // Only check the first payment since the rounding - // may drift as payments are made - BEAST_EXPECT( - totalDue == - roundToScale( - broker.asset( - Number(8533457002039338267, -17), Number::RoundingMode::Upward), - state.loanScale, - Number::RoundingMode::Upward)); - - { - auto const raw = computeTheoreticalLoanState( - env.current()->rules(), - state.periodicPayment, - periodicRate, - state.paymentRemaining, - broker.params.managementFeeRate); - auto const rounded = constructLoanState( - state.totalValue, - state.principalOutstanding, - state.managementFeeOutstanding); - testcase << currencyLabel << " Loan starting state: " << state.paymentRemaining - << ", " << raw.interestDue << ", " << raw.principalOutstanding << ", " - << raw.managementFeeDue << ", " << rounded.valueOutstanding << ", " - << rounded.principalOutstanding << ", " << rounded.interestDue << ", " - << rounded.managementFeeDue; - } - - // Try to pay a little extra to show that it's _not_ - // taken - STAmount const transactionAmount = - STAmount{broker.asset, totalDue} + broker.asset(10); - // Only check the first payment since the rounding - // may drift as payments are made - BEAST_EXPECT( - transactionAmount == - roundToScale( - broker.asset(Number(9533457002039400, -14), Number::RoundingMode::Upward), - state.loanScale, - Number::RoundingMode::Upward)); - - auto const initialState = state; - xrpl::detail::PaymentComponents totalPaid{ - .trackedValueDelta = 0, - .trackedPrincipalDelta = 0, - .trackedManagementFeeDelta = 0}; - Number totalInterestPaid = 0; - std::size_t totalPaymentsMade = 0; - - xrpl::LoanState currentTrueState = computeTheoreticalLoanState( - env.current()->rules(), - state.periodicPayment, - periodicRate, - state.paymentRemaining, - broker.params.managementFeeRate); - - while (state.paymentRemaining > 0) - { - // Compute the expected principal amount - auto const paymentComponents = xrpl::detail::computePaymentComponents( - env.current()->rules(), - broker.asset.raw(), - state.loanScale, - state.totalValue, - state.principalOutstanding, - state.managementFeeOutstanding, - state.periodicPayment, - periodicRate, - state.paymentRemaining, - broker.params.managementFeeRate); - - BEAST_EXPECTS( - paymentComponents.specialCase == xrpl::detail::PaymentSpecialCase::Final || - paymentComponents.trackedValueDelta <= roundedPeriodicPayment, - "Delta: " + to_string(paymentComponents.trackedValueDelta) + - ", periodic payment: " + to_string(roundedPeriodicPayment)); - - xrpl::LoanState const nextTrueState = computeTheoreticalLoanState( - env.current()->rules(), - state.periodicPayment, - periodicRate, - state.paymentRemaining - 1, - broker.params.managementFeeRate); - xrpl::detail::LoanStateDeltas const deltas = currentTrueState - nextTrueState; - - testcase << currencyLabel << " Payment components: " << state.paymentRemaining - << ", " << deltas.interest << ", " << deltas.principal << ", " - << deltas.managementFee << ", " << paymentComponents.trackedValueDelta - << ", " << paymentComponents.trackedPrincipalDelta << ", " - << paymentComponents.trackedInterestPart() << ", " - << paymentComponents.trackedManagementFeeDelta << ", " - << [&]() -> char const* { - if (paymentComponents.specialCase == - ::xrpl::detail::PaymentSpecialCase::Final) - return "final"; - if (paymentComponents.specialCase == - ::xrpl::detail::PaymentSpecialCase::Extra) - return "extra"; - return "none"; - }(); - - auto const totalDueAmount = STAmount{ - broker.asset, paymentComponents.trackedValueDelta + serviceFee.number()}; - - // Due to the rounding algorithms to keep the interest and - // principal in sync with "true" values, the computed amount - // may be a little less than the rounded fixed payment - // amount. For integral types, the difference should be < 3 - // (1 unit for each of the interest and management fee). For - // IOUs, the difference should be after the 8th digit. - Number const diff = totalDue - totalDueAmount; - BEAST_EXPECT( - paymentComponents.specialCase == xrpl::detail::PaymentSpecialCase::Final || - diff == beast::kZero || - (diff > beast::kZero && - ((broker.asset.integral() && (static_cast(diff) < 3)) || - (state.loanScale - diff.exponent() > 13)))); - - BEAST_EXPECT( - paymentComponents.trackedValueDelta == - paymentComponents.trackedPrincipalDelta + - paymentComponents.trackedInterestPart() + - paymentComponents.trackedManagementFeeDelta); - BEAST_EXPECT( - paymentComponents.specialCase == xrpl::detail::PaymentSpecialCase::Final || - paymentComponents.trackedValueDelta <= roundedPeriodicPayment); - - BEAST_EXPECT( - state.paymentRemaining < 12 || - roundToAsset( - broker.asset, - deltas.principal, - state.loanScale, - Number::RoundingMode::Upward) == - roundToScale( - broker.asset( - Number(8333228691531218890, -17), Number::RoundingMode::Upward), - state.loanScale, - Number::RoundingMode::Upward)); - BEAST_EXPECT( - paymentComponents.trackedPrincipalDelta >= beast::kZero && - paymentComponents.trackedPrincipalDelta <= state.principalOutstanding); - BEAST_EXPECT( - paymentComponents.specialCase != xrpl::detail::PaymentSpecialCase::Final || - paymentComponents.trackedPrincipalDelta == state.principalOutstanding); - BEAST_EXPECT( - paymentComponents.specialCase == xrpl::detail::PaymentSpecialCase::Final || - (state.periodicPayment.exponent() - - (deltas.principal + deltas.interest + deltas.managementFee - - state.periodicPayment) - .exponent()) > 14); - - auto const borrowerBalanceBeforePayment = env.balance(borrower, broker.asset); - - if (canImpairLoan(env, broker, state)) - { - // Making a payment will unimpair the loan - env(manage(lender, loanKeylet.key, tfLoanImpair)); - } - - env.close(); - - // Make the payment - env(pay(borrower, loanKeylet.key, transactionAmount)); - - env.close(); - - // Need to account for fees if the loan is in XRP - PrettyAmount adjustment = broker.asset(0); - if (broker.asset.native()) - { - adjustment = env.current()->fees().base; - } - - // Check the result - verifyLoanStatus.checkPayment( - state.loanScale, - borrower, - borrowerBalanceBeforePayment, - totalDueAmount, - adjustment); - - --state.paymentRemaining; - state.previousPaymentDate = state.nextPaymentDate; - if (paymentComponents.specialCase == xrpl::detail::PaymentSpecialCase::Final) - { - state.paymentRemaining = 0; - state.nextPaymentDate = 0; - } - else - { - state.nextPaymentDate += state.paymentInterval; - } - state.principalOutstanding -= paymentComponents.trackedPrincipalDelta; - state.managementFeeOutstanding -= paymentComponents.trackedManagementFeeDelta; - state.totalValue -= paymentComponents.trackedValueDelta; - - verifyLoanStatus(state); - - totalPaid.trackedValueDelta += paymentComponents.trackedValueDelta; - totalPaid.trackedPrincipalDelta += paymentComponents.trackedPrincipalDelta; - totalPaid.trackedManagementFeeDelta += - paymentComponents.trackedManagementFeeDelta; - totalInterestPaid += paymentComponents.trackedInterestPart(); - ++totalPaymentsMade; - - currentTrueState = nextTrueState; - } - - // Loan is paid off - BEAST_EXPECT(state.paymentRemaining == 0); - BEAST_EXPECT(state.principalOutstanding == 0); - - // Make sure all the payments add up - BEAST_EXPECT(totalPaid.trackedValueDelta == initialState.totalValue); - BEAST_EXPECT(totalPaid.trackedPrincipalDelta == initialState.principalOutstanding); - BEAST_EXPECT( - totalPaid.trackedManagementFeeDelta == initialState.managementFeeOutstanding); - // This is almost a tautology given the previous checks, but - // check it anyway for completeness. - BEAST_EXPECT( - totalInterestPaid == - initialState.totalValue - - (initialState.principalOutstanding + - initialState.managementFeeOutstanding)); - BEAST_EXPECT(totalPaymentsMade == initialState.paymentRemaining); - - // Can't impair or default a paid off loan - env(manage(lender, loanKeylet.key, tfLoanImpair), Ter(tecNO_PERMISSION)); - env(manage(lender, loanKeylet.key, tfLoanDefault), Ter(tecNO_PERMISSION)); - }); - -#if LOAN_TODO - // TODO - - /* - LoanPay fails with tecINVARIANT_FAILED error when loan_broker(also - borrower) tries to do the payment. Here's the scenario: Create a XRP - loan with loan broker as borrower, loan origination fee and loan service - fee. Loan broker makes the first payment with periodic payment and loan - service fee. - */ - - auto time = [&](std::string label, std::function timed) { - if (!BEAST_EXPECT(timed)) - return; - - using clock_type = std::chrono::steady_clock; - using duration_type = std::chrono::milliseconds; - - auto const start = clock_type::now(); - timed(); - auto const duration = - std::chrono::duration_cast(clock_type::now() - start); - - log << label << " took " << duration.count() << "ms" << std::endl; - - return duration; - }; - - lifecycle( - caseLabel, - "timing", - env, - loanAmount, - interestExponent, - lender, - borrower, - evan, - broker, - pseudoAcct, - tfLoanOverpayment, - [&](Keylet const& loanKeylet, VerifyLoanStatus const& verifyLoanStatus) { - // Estimate optimal values for kLoanPaymentsPerFeeIncrement and - // kLoanMaximumPaymentsPerTransaction. - using namespace loan; - - auto const state = getCurrentState(env, broker, verifyLoanStatus.keylet); - auto const serviceFee = broker.asset(2).value(); - - STAmount const totalDue{ - broker.asset, - roundPeriodicPayment( - broker.asset, state.periodicPayment + serviceFee, state.loanScale)}; - - // Make a single payment - time("single payment", [&]() { env(pay(borrower, loanKeylet.key, totalDue)); }); - env.close(); - - // Make all but the final payment - auto const numPayments = (state.paymentRemaining - 2); - STAmount const bigPayment{broker.asset, totalDue * numPayments}; - XRPAmount const bigFee{baseFee * (numPayments / kLoanPaymentsPerFeeIncrement + 1)}; - time("ten payments", [&]() { - env(pay(borrower, loanKeylet.key, bigPayment), Fee(bigFee)); - }); - env.close(); - - time("final payment", [&]() { - // Make the final payment - env(pay(borrower, loanKeylet.key, totalDue + STAmount{broker.asset, 1})); - }); - env.close(); - }); - - lifecycle( - caseLabel, - "Loan overpayment allowed - Explicit overpayment", - env, - loanAmount, - interestExponent, - lender, - borrower, - evan, - broker, - pseudoAcct, - tfLoanOverpayment, - [&](Keylet const& loanKeylet, VerifyLoanStatus const& verifyLoanStatus) { throw 0; }); - - lifecycle( - caseLabel, - "Loan overpayment prohibited - Late payment", - env, - loanAmount, - interestExponent, - lender, - borrower, - evan, - broker, - pseudoAcct, - tfLoanOverpayment, - [&](Keylet const& loanKeylet, VerifyLoanStatus const& verifyLoanStatus) { throw 0; }); - - lifecycle( - caseLabel, - "Loan overpayment allowed - Late payment", - env, - loanAmount, - interestExponent, - lender, - borrower, - evan, - broker, - pseudoAcct, - tfLoanOverpayment, - [&](Keylet const& loanKeylet, VerifyLoanStatus const& verifyLoanStatus) { throw 0; }); - - lifecycle( - caseLabel, - "Loan overpayment allowed - Late payment and overpayment", - env, - loanAmount, - interestExponent, - lender, - borrower, - evan, - broker, - pseudoAcct, - tfLoanOverpayment, - [&](Keylet const& loanKeylet, VerifyLoanStatus const& verifyLoanStatus) { throw 0; }); - -#endif - } - - void - testLoanSet(FeatureBitset features) - { - using namespace jtx; - - Account const issuer{"issuer"}; - Account const lender{"lender"}; - Account const borrower{"borrower"}; - - struct CaseArgs - { - bool requireAuth = false; - bool authorizeBorrower = false; - int initialXRP = 1'000'000; - }; - - auto const testCase = [&, this]( - std::function mptTest, - std::function iouTest, - CaseArgs args = {}) { - Env env(*this, features); - env.fund(XRP(args.initialXRP), issuer, lender, borrower); - env.close(); - if (args.requireAuth) - { - env(fset(issuer, asfRequireAuth)); - env.close(); - } - - // We need two different asset types, MPT and IOU. Prepare MPT - // first - MPTTester mptt{env, issuer, kMptInitNoFund}; - - auto const kNone = LedgerSpecificFlags(0); - mptt.create( - {.flags = tfMPTCanTransfer | tfMPTCanLock | - (args.requireAuth ? tfMPTRequireAuth : kNone)}); - env.close(); - PrettyAsset const mptAsset = mptt.issuanceID(); - mptt.authorize({.account = lender}); - mptt.authorize({.account = borrower}); - env.close(); - if (args.requireAuth) - { - mptt.authorize({.account = issuer, .holder = lender}); - if (args.authorizeBorrower) - mptt.authorize({.account = issuer, .holder = borrower}); - env.close(); - } - - env(pay(issuer, lender, mptAsset(10'000'000))); - env.close(); - - // Prepare IOU - PrettyAsset const iouAsset = issuer[iouCurrency_]; - env(trust(lender, iouAsset(10'000'000))); - env(trust(borrower, iouAsset(10'000'000))); - env.close(); - if (args.requireAuth) - { - env(trust(issuer, iouAsset(0), lender, tfSetfAuth)); - env(pay(issuer, lender, iouAsset(10'000'000))); - if (args.authorizeBorrower) - { - env(trust(issuer, iouAsset(0), borrower, tfSetfAuth)); - env(pay(issuer, borrower, iouAsset(10'000))); - } - } - else - { - env(pay(issuer, lender, iouAsset(10'000'000))); - env(pay(issuer, borrower, iouAsset(10'000))); - } - env.close(); - - // Create vaults and loan brokers - std::array const assets{mptAsset, iouAsset}; - std::vector brokers; - brokers.reserve(assets.size()); - for (auto const& asset : assets) - { - brokers.emplace_back(createVaultAndBroker(env, asset, lender)); - } - - if (mptTest) - mptTest(env, brokers[0], mptt); - if (iouTest) - iouTest(env, brokers[1]); - }; - - testCase( - [&, this](Env& env, BrokerInfo const& broker, auto&) { - using namespace loan; - Number const principalRequest = broker.asset(1'000).value(); - - testcase("MPT issuer is borrower, issuer submits"); - env(set(issuer, broker.brokerID, principalRequest), - kCounterparty(lender), - Sig(sfCounterpartySignature, lender), - Fee(env.current()->fees().base * 5)); - - testcase("MPT issuer is borrower, lender submits"); - env(set(lender, broker.brokerID, principalRequest), - kCounterparty(issuer), - Sig(sfCounterpartySignature, issuer), - Fee(env.current()->fees().base * 5)); - }, - [&, this](Env& env, BrokerInfo const& broker) { - using namespace loan; - Number const principalRequest = broker.asset(1'000).value(); - - testcase("IOU issuer is borrower, issuer submits"); - env(set(issuer, broker.brokerID, principalRequest), - kCounterparty(lender), - Sig(sfCounterpartySignature, lender), - Fee(env.current()->fees().base * 5)); - - testcase("IOU issuer is borrower, lender submits"); - env(set(lender, broker.brokerID, principalRequest), - kCounterparty(issuer), - Sig(sfCounterpartySignature, issuer), - Fee(env.current()->fees().base * 5)); - }, - CaseArgs{.requireAuth = true}); - - testCase( - [&, this](Env& env, BrokerInfo const& broker, auto&) { - using namespace loan; - Number const principalRequest = broker.asset(1'000).value(); - - testcase("MPT unauthorized borrower, borrower submits"); - env(set(borrower, broker.brokerID, principalRequest), - kCounterparty(lender), - Sig(sfCounterpartySignature, lender), - Fee(env.current()->fees().base * 5), - Ter{tecNO_AUTH}); - - testcase("MPT unauthorized borrower, lender submits"); - env(set(lender, broker.brokerID, principalRequest), - kCounterparty(borrower), - Sig(sfCounterpartySignature, borrower), - Fee(env.current()->fees().base * 5), - Ter{tecNO_AUTH}); - }, - [&, this](Env& env, BrokerInfo const& broker) { - using namespace loan; - Number const principalRequest = broker.asset(1'000).value(); - - testcase("IOU unauthorized borrower, borrower submits"); - env(set(borrower, broker.brokerID, principalRequest), - kCounterparty(lender), - Sig(sfCounterpartySignature, lender), - Fee(env.current()->fees().base * 5), - Ter{tecNO_AUTH}); - - testcase("IOU unauthorized borrower, lender submits"); - env(set(lender, broker.brokerID, principalRequest), - kCounterparty(borrower), - Sig(sfCounterpartySignature, borrower), - Fee(env.current()->fees().base * 5), - Ter{tecNO_AUTH}); - }, - CaseArgs{.requireAuth = true}); - - auto const [acctReserve, incReserve] = [this]() -> std::pair { - Env const env{*this, testableAmendments()}; - return { - env.current()->fees().accountReserve(0, 1).drops() / kDropsPerXrp.drops(), - env.current()->fees().increment.drops() / kDropsPerXrp.drops()}; - }(); - - testCase( - [&, this](Env& env, BrokerInfo const& broker, MPTTester& mptt) { - using namespace loan; - Number const principalRequest = broker.asset(1'000).value(); - - testcase( - "MPT authorized borrower, borrower submits, borrower has " - "no reserve"); - mptt.authorize({.account = borrower, .flags = tfMPTUnauthorize}); - env.close(); - - auto const mptoken = keylet::mptoken(mptt.issuanceID(), borrower); - auto const sleMPT1 = env.le(mptoken); - BEAST_EXPECT(sleMPT1 == nullptr); - - // Burn some XRP - env(noop(borrower), Fee(XRP((acctReserve * 2) + (incReserve * 2)))); - env.close(); - - // Cannot create loan, not enough reserve to create MPToken - env(set(borrower, broker.brokerID, principalRequest), - kCounterparty(lender), - Sig(sfCounterpartySignature, lender), - Fee(env.current()->fees().base * 5), - Ter{tecINSUFFICIENT_RESERVE}); - env.close(); - - // Can create loan now, will implicitly create MPToken - env(pay(issuer, borrower, XRP(incReserve))); - env.close(); - env(set(borrower, broker.brokerID, principalRequest), - kCounterparty(lender), - Sig(sfCounterpartySignature, lender), - Fee(env.current()->fees().base * 5)); - env.close(); - - auto const sleMPT2 = env.le(mptoken); - BEAST_EXPECT(sleMPT2 != nullptr); - }, - {}, - CaseArgs{.initialXRP = (acctReserve * 2) + (incReserve * 8) + 1}); - - testCase( - {}, - [&, this](Env& env, BrokerInfo const& broker) { - using namespace loan; - Number const principalRequest = broker.asset(1'000).value(); - - testcase( - "IOU authorized borrower, borrower submits, borrower has " - "no reserve"); - // Remove trust line from borrower to issuer - env.trust(broker.asset(0), borrower); - env.close(); - - env(pay(borrower, issuer, broker.asset(10'000))); - env.close(); - auto const trustline = keylet::trustLine(borrower, broker.asset.raw().get()); - auto const sleLine1 = env.le(trustline); - BEAST_EXPECT(sleLine1 == nullptr); - - // Burn some XRP - env(noop(borrower), Fee(XRP((acctReserve * 2) + (incReserve * 2)))); - env.close(); - - // Cannot create loan, not enough reserve to create trust line - env(set(borrower, broker.brokerID, principalRequest), - kCounterparty(lender), - Sig(sfCounterpartySignature, lender), - Fee(env.current()->fees().base * 5), - Ter{tecNO_LINE_INSUF_RESERVE}); - env.close(); - - // Can create loan now, will implicitly create trust line - env(pay(issuer, borrower, XRP(incReserve))); - env.close(); - env(set(borrower, broker.brokerID, principalRequest), - kCounterparty(lender), - Sig(sfCounterpartySignature, lender), - Fee(env.current()->fees().base * 5)); - env.close(); - - auto const sleLine2 = env.le(trustline); - BEAST_EXPECT(sleLine2 != nullptr); - }, - CaseArgs{.initialXRP = (acctReserve * 2) + (incReserve * 8) + 1}); - - testCase( - [&, this](Env& env, BrokerInfo const& broker, MPTTester& mptt) { - using namespace loan; - Number const principalRequest = broker.asset(1'000).value(); - - testcase( - "MPT authorized borrower, borrower submits, lender has " - "no reserve"); - auto const mptoken = keylet::mptoken(mptt.issuanceID(), lender); - auto const sleMPT1 = env.le(mptoken); - BEAST_EXPECT(sleMPT1 != nullptr); - - env(pay(lender, issuer, broker.asset(sleMPT1->at(sfMPTAmount)))); - env.close(); - - mptt.authorize({.account = lender, .flags = tfMPTUnauthorize}); - env.close(); - - auto const sleMPT2 = env.le(mptoken); - BEAST_EXPECT(sleMPT2 == nullptr); - - // Burn some XRP - env(noop(lender), Fee(XRP(incReserve))); - env.close(); - - // Cannot create loan, not enough reserve to create MPToken - env(set(borrower, broker.brokerID, principalRequest), - kLoanOriginationFee(broker.asset(1).value()), - kCounterparty(lender), - Sig(sfCounterpartySignature, lender), - Fee(env.current()->fees().base * 5), - Ter{tecINSUFFICIENT_RESERVE}); - env.close(); - - // Can create loan now, will implicitly create MPToken - env(pay(issuer, lender, XRP(incReserve))); - env.close(); - env(set(borrower, broker.brokerID, principalRequest), - kLoanOriginationFee(broker.asset(1).value()), - kCounterparty(lender), - Sig(sfCounterpartySignature, lender), - Fee(env.current()->fees().base * 5)); - env.close(); - - auto const sleMPT3 = env.le(mptoken); - BEAST_EXPECT(sleMPT3 != nullptr); - }, - {}, - CaseArgs{.initialXRP = (acctReserve * 2) + (incReserve * 8) + 1}); - - testCase( - {}, - [&, this](Env& env, BrokerInfo const& broker) { - using namespace loan; - Number const principalRequest = broker.asset(1'000).value(); - - testcase( - "IOU authorized borrower, borrower submits, lender has no " - "reserve"); - // Remove trust line from lender to issuer - env.trust(broker.asset(0), lender); - env.close(); - - auto const trustline = keylet::trustLine(lender, broker.asset.raw().get()); - auto const sleLine1 = env.le(trustline); - BEAST_EXPECT(sleLine1 != nullptr); - - env(pay(lender, issuer, broker.asset(abs(sleLine1->at(sfBalance).value())))); - env.close(); - auto const sleLine2 = env.le(trustline); - BEAST_EXPECT(sleLine2 == nullptr); - - // Burn some XRP - env(noop(lender), Fee(XRP(incReserve))); - env.close(); - - // Cannot create loan, not enough reserve to create trust line - env(set(borrower, broker.brokerID, principalRequest), - kLoanOriginationFee(broker.asset(1).value()), - kCounterparty(lender), - Sig(sfCounterpartySignature, lender), - Fee(env.current()->fees().base * 5), - Ter{tecNO_LINE_INSUF_RESERVE}); - env.close(); - - // Can create loan now, will implicitly create trust line - env(pay(issuer, lender, XRP(incReserve))); - env.close(); - env(set(borrower, broker.brokerID, principalRequest), - kLoanOriginationFee(broker.asset(1).value()), - kCounterparty(lender), - Sig(sfCounterpartySignature, lender), - Fee(env.current()->fees().base * 5)); - env.close(); - - auto const sleLine3 = env.le(trustline); - BEAST_EXPECT(sleLine3 != nullptr); - }, - CaseArgs{.initialXRP = (acctReserve * 2) + (incReserve * 8) + 1}); - - testCase( - [&, this](Env& env, BrokerInfo const& broker, MPTTester& mptt) { - using namespace loan; - Number const principalRequest = broker.asset(1'000).value(); - - testcase("MPT authorized borrower, unauthorized lender"); - auto const mptoken = keylet::mptoken(mptt.issuanceID(), lender); - auto const sleMPT1 = env.le(mptoken); - BEAST_EXPECT(sleMPT1 != nullptr); - - env(pay(lender, issuer, broker.asset(sleMPT1->at(sfMPTAmount)))); - env.close(); - - mptt.authorize({.account = lender, .flags = tfMPTUnauthorize}); - env.close(); - - auto const sleMPT2 = env.le(mptoken); - BEAST_EXPECT(sleMPT2 == nullptr); - - // Cannot create loan, lender not authorized to receive fee - env(set(borrower, broker.brokerID, principalRequest), - kLoanOriginationFee(broker.asset(1).value()), - kCounterparty(lender), - Sig(sfCounterpartySignature, lender), - Fee(env.current()->fees().base * 5), - Ter{tecNO_AUTH}); - env.close(); - - // Cannot create loan, even without an origination fee - env(set(borrower, broker.brokerID, principalRequest), - kCounterparty(lender), - Sig(sfCounterpartySignature, lender), - Fee(env.current()->fees().base * 5), - Ter{tecNO_AUTH}); - env.close(); - - // No MPToken for lender - no authorization and no payment - auto const sleMPT3 = env.le(mptoken); - BEAST_EXPECT(sleMPT3 == nullptr); - }, - {}, - CaseArgs{.requireAuth = true, .authorizeBorrower = true}); - - testCase( - [&, this](Env& env, BrokerInfo const& broker, auto&) { - using namespace loan; - Number const principalRequest = broker.asset(1'000).value(); - - testcase("MPT authorized borrower, borrower submits"); - env(set(borrower, broker.brokerID, principalRequest), - kCounterparty(lender), - Sig(sfCounterpartySignature, lender), - Fee(env.current()->fees().base * 5)); - }, - [&, this](Env& env, BrokerInfo const& broker) { - using namespace loan; - Number const principalRequest = broker.asset(1'000).value(); - - testcase("IOU authorized borrower, borrower submits"); - env(set(borrower, broker.brokerID, principalRequest), - kCounterparty(lender), - Sig(sfCounterpartySignature, lender), - Fee(env.current()->fees().base * 5)); - }, - CaseArgs{.requireAuth = true, .authorizeBorrower = true}); - - testCase( - [&, this](Env& env, BrokerInfo const& broker, auto&) { - using namespace loan; - Number const principalRequest = broker.asset(1'000).value(); - - testcase("MPT authorized borrower, lender submits"); - env(set(lender, broker.brokerID, principalRequest), - kCounterparty(borrower), - Sig(sfCounterpartySignature, borrower), - Fee(env.current()->fees().base * 5)); - }, - [&, this](Env& env, BrokerInfo const& broker) { - using namespace loan; - Number const principalRequest = broker.asset(1'000).value(); - - testcase("IOU authorized borrower, lender submits"); - env(set(lender, broker.brokerID, principalRequest), - kCounterparty(borrower), - Sig(sfCounterpartySignature, borrower), - Fee(env.current()->fees().base * 5)); - }, - CaseArgs{.requireAuth = true, .authorizeBorrower = true}); - - jtx::Account const alice{"alice"}; - jtx::Account const bella{"bella"}; - auto const msigSetup = [&](Env& env, Account const& account) { - json::Value const tx1 = signers(account, 2, {{alice, 1}, {bella, 1}}); - env(tx1); - env.close(); - }; - - testCase( - [&, this](Env& env, BrokerInfo const& broker, auto&) { - using namespace loan; - msigSetup(env, lender); - Number const principalRequest = broker.asset(1'000).value(); - - testcase( - "MPT authorized borrower, borrower submits, lender " - "multisign"); - env(set(borrower, broker.brokerID, principalRequest), - kCounterparty(lender), - Msig(sfCounterpartySignature, alice, bella), - Fee(env.current()->fees().base * 5)); - }, - [&, this](Env& env, BrokerInfo const& broker) { - using namespace loan; - msigSetup(env, lender); - Number const principalRequest = broker.asset(1'000).value(); - - testcase( - "IOU authorized borrower, borrower submits, lender " - "multisign"); - env(set(borrower, broker.brokerID, principalRequest), - kCounterparty(lender), - Msig(sfCounterpartySignature, alice, bella), - Fee(env.current()->fees().base * 5)); - }, - CaseArgs{.requireAuth = true, .authorizeBorrower = true}); - - testCase( - [&, this](Env& env, BrokerInfo const& broker, auto&) { - using namespace loan; - msigSetup(env, borrower); - Number const principalRequest = broker.asset(1'000).value(); - - testcase( - "MPT authorized borrower, lender submits, borrower " - "multisign"); - env(set(lender, broker.brokerID, principalRequest), - kCounterparty(borrower), - Msig(sfCounterpartySignature, alice, bella), - Fee(env.current()->fees().base * 5)); - }, - [&, this](Env& env, BrokerInfo const& broker) { - using namespace loan; - msigSetup(env, borrower); - Number const principalRequest = broker.asset(1'000).value(); - - testcase( - "IOU authorized borrower, lender submits, borrower " - "multisign"); - env(set(lender, broker.brokerID, principalRequest), - kCounterparty(borrower), - Msig(sfCounterpartySignature, alice, bella), - Fee(env.current()->fees().base * 5)); - }, - CaseArgs{.requireAuth = true, .authorizeBorrower = true}); - - testCase( - [&, this](Env& env, BrokerInfo const& broker, auto&) { - using namespace loan; - Number const principalRequest = broker.asset(1'000).value(); - Vault const vault{env}; - auto tx = vault.set({.owner = lender, .id = broker.vaultID}); - tx[sfAssetsMaximum] = BrokerParameters::defaults().vaultDeposit; - env(tx); - env.close(); - - testcase("Vault at maximum value"); - env(set(issuer, broker.brokerID, principalRequest), - kCounterparty(lender), - kInterestRate(TenthBips32(10'000)), - Sig(sfCounterpartySignature, lender), - Fee(env.current()->fees().base * 5), - Ter(tecLIMIT_EXCEEDED)); - }, - nullptr); - - testCase( - [&, this](Env& env, BrokerInfo const& broker, auto&) { - using namespace loan; - Number const principalRequest = broker.asset(1'000).value(); - Vault const vault{env}; - auto tx = vault.set({.owner = lender, .id = broker.vaultID}); - tx[sfAssetsMaximum] = - BrokerParameters::defaults().vaultDeposit + broker.asset(1).number(); - env(tx); - env.close(); - - testcase("Vault maximum value exceeded"); - env(set(issuer, broker.brokerID, principalRequest), - kCounterparty(lender), - kInterestRate(TenthBips32(100'000)), - Sig(sfCounterpartySignature, lender), - Fee(env.current()->fees().base * 5), - kPaymentTotal(2), - kPaymentInterval(3600 * 24), - Ter(tecLIMIT_EXCEEDED)); - }, - nullptr); - } - - void - testLifecycle(FeatureBitset features) - { - testcase("Lifecycle"); - using namespace jtx; - - // Create 3 loan brokers: one for XRP, one for an IOU, and one for - // an MPT. That'll require three corresponding SAVs. - Env env(*this, features); - - Account const issuer{"issuer"}; - // For simplicity, lender will be the sole actor for the vault & - // brokers. - Account const lender{"lender"}; - // Borrower only wants to borrow - Account const borrower{"borrower"}; - // Evan will attempt to be naughty - Account const evan{"evan"}; - // Do not fund alice - Account const alice{"alice"}; - - // Fund the accounts and trust lines with the same amount so that - // tests can use the same values regardless of the asset. - env.fund(XRP(100'000'000), issuer, noripple(lender, borrower, evan)); - env.close(); - - // Create assets - PrettyAsset const xrpAsset{xrpIssue(), 1'000'000}; - PrettyAsset const iouAsset = issuer[iouCurrency_]; - env(trust(lender, iouAsset(10'000'000))); - env(trust(borrower, iouAsset(10'000'000))); - env(trust(evan, iouAsset(10'000'000))); - env(pay(issuer, evan, iouAsset(1'000'000))); - env(pay(issuer, lender, iouAsset(10'000'000))); - // Fund the borrower with enough to cover interest and fees - env(pay(issuer, borrower, iouAsset(10'000))); - env.close(); - - MPTTester mptt{env, issuer, kMptInitNoFund}; - mptt.create({.flags = tfMPTCanClawback | tfMPTCanTransfer | tfMPTCanLock}); - // Scale the MPT asset a little bit so we can get some interest - PrettyAsset const mptAsset{mptt.issuanceID(), 100}; - mptt.authorize({.account = lender}); - mptt.authorize({.account = borrower}); - mptt.authorize({.account = evan}); - env(pay(issuer, lender, mptAsset(10'000'000))); - env(pay(issuer, evan, mptAsset(1'000'000))); - // Fund the borrower with enough to cover interest and fees - env(pay(issuer, borrower, mptAsset(10'000))); - env.close(); - - std::array const assets{iouAsset, xrpAsset, mptAsset}; - - // Create vaults and loan brokers - std::vector brokers; - brokers.reserve(assets.size()); - for (auto const& asset : assets) - { - brokers.emplace_back(createVaultAndBroker( - env, asset, lender, BrokerParameters{.data = "spam spam spam spam"})); - } - - // Create and update Loans - for (auto const& broker : brokers) - { - for (int amountExponent = 3; amountExponent >= 3; --amountExponent) - { - Number const loanAmount{1, amountExponent}; - for (int interestExponent = 0; interestExponent >= 0; --interestExponent) - { - testCaseWrapper(env, mptt, assets, broker, loanAmount, interestExponent); - } - } - - if (auto brokerSle = env.le(keylet::loanBroker(broker.brokerID)); - BEAST_EXPECT(brokerSle)) - { - BEAST_EXPECT(brokerSle->at(sfOwnerCount) == 0); - BEAST_EXPECT(brokerSle->at(sfDebtTotal) == 0); - - auto const coverAvailable = brokerSle->at(sfCoverAvailable); - env(loanBroker::coverWithdraw( - lender, broker.brokerID, STAmount(broker.asset, coverAvailable))); - env.close(); - - brokerSle = env.le(keylet::loanBroker(broker.brokerID)); - BEAST_EXPECT(brokerSle && brokerSle->at(sfCoverAvailable) == 0); - } - // Verify we can delete the loan broker - env(loanBroker::del(lender, broker.brokerID)); - env.close(); - } - } - - void - testSelfLoan(FeatureBitset features) - { - testcase << "Self Loan"; - - using namespace jtx; - using namespace std::chrono_literals; - // Create 3 loan brokers: one for XRP, one for an IOU, and one for - // an MPT. That'll require three corresponding SAVs. - Env env(*this, features); - - Account const issuer{"issuer"}; - // For simplicity, lender will be the sole actor for the vault & - // brokers. - Account const lender{"lender"}; - - // Fund the accounts and trust lines with the same amount so that - // tests can use the same values regardless of the asset. - env.fund(XRP(100'000'000), issuer, noripple(lender)); - env.close(); - - // Use an XRP asset for simplicity - PrettyAsset const xrpAsset{xrpIssue(), 1'000'000}; - - // Create vaults and loan brokers - BrokerInfo broker{createVaultAndBroker(env, xrpAsset, lender)}; - - using namespace loan; - - auto const loanSetFee = Fee(env.current()->fees().base * 2); - Number const principalRequest{1, 3}; - - // The LoanSet json can be created without a counterparty signature, - // but it will not pass preflight - auto createJson = env.json( - set(lender, broker.brokerID, broker.asset(principalRequest).value()), Fee(loanSetFee)); - env(createJson, Ter(temBAD_SIGNER)); - - // Adding an empty counterparty signature object also fails, but - // at the RPC level. - createJson = env.json(createJson, Json(sfCounterpartySignature, json::ValueType::Object)); - env(createJson, Ter(telENV_RPC_FAILED)); - - if (auto const jt = env.jt(createJson); BEAST_EXPECT(jt.stx)) - { - Serializer s; - jt.stx->add(s); - auto const jr = env.rpc("submit", strHex(s.slice())); - - BEAST_EXPECT(jr.isMember(jss::result)); - auto const jResult = jr[jss::result]; - BEAST_EXPECT(jResult[jss::error] == "invalidTransaction"); - BEAST_EXPECT( - jResult[jss::error_exception] == - "fails local checks: Transaction has bad signature."); - } - - // Copy the transaction signature into the counterparty signature. - json::Value counterpartyJson{json::ValueType::Object}; - counterpartyJson[sfTxnSignature] = createJson[sfTxnSignature]; - counterpartyJson[sfSigningPubKey] = createJson[sfSigningPubKey]; - if (!BEAST_EXPECT(!createJson.isMember(jss::Signers))) - counterpartyJson[sfSigners] = createJson[sfSigners]; - - // The duplicated signature works - createJson = env.json(createJson, Json(sfCounterpartySignature, counterpartyJson)); - env(createJson); - - env.close(); - - auto const startDate = env.current()->header().parentCloseTime; - - // Loan is successfully created - { - auto const res = env.rpc("account_objects", lender.human()); - auto const objects = res[jss::result][jss::account_objects]; - - std::map types; - BEAST_EXPECT(objects.size() == 4); - for (auto const& object : objects) - { - ++types[object[sfLedgerEntryType].asString()]; - } - BEAST_EXPECT(types.size() == 4); - for (std::string const type : {"MPToken", "Vault", "LoanBroker", "Loan"}) - { - BEAST_EXPECT(types[type] == 1); - } - } - auto const loanID = [&]() { - json::Value params(json::ValueType::Object); - params[jss::account] = lender.human(); - params[jss::type] = "Loan"; - auto const res = env.rpc("json", "account_objects", to_string(params)); - auto const objects = res[jss::result][jss::account_objects]; - - BEAST_EXPECT(objects.size() == 1); - - auto const loan = objects[0u]; - BEAST_EXPECT(loan[sfBorrower] == lender.human()); - // soeDEFAULT fields are not returned if they're in the default - // state - BEAST_EXPECT(!loan.isMember(sfCloseInterestRate)); - BEAST_EXPECT(!loan.isMember(sfClosePaymentFee)); - BEAST_EXPECT(loan[sfFlags] == 0); - BEAST_EXPECT(loan[sfGracePeriod] == 60); - BEAST_EXPECT(!loan.isMember(sfInterestRate)); - BEAST_EXPECT(!loan.isMember(sfLateInterestRate)); - BEAST_EXPECT(!loan.isMember(sfLatePaymentFee)); - BEAST_EXPECT(loan[sfLoanBrokerID] == to_string(broker.brokerID)); - BEAST_EXPECT(!loan.isMember(sfLoanOriginationFee)); - BEAST_EXPECT(loan[sfLoanSequence] == 1); - BEAST_EXPECT(!loan.isMember(sfLoanServiceFee)); - BEAST_EXPECT(loan[sfNextPaymentDueDate] == loan[sfStartDate].asUInt() + 60); - BEAST_EXPECT(!loan.isMember(sfOverpaymentFee)); - BEAST_EXPECT(!loan.isMember(sfOverpaymentInterestRate)); - BEAST_EXPECT(loan[sfPaymentInterval] == 60); - BEAST_EXPECT(loan[sfPeriodicPayment] == "1000000000"); - BEAST_EXPECT(loan[sfPaymentRemaining] == 1); - BEAST_EXPECT(!loan.isMember(sfPreviousPaymentDueDate)); - BEAST_EXPECT(loan[sfPrincipalOutstanding] == "1000000000"); - BEAST_EXPECT(loan[sfTotalValueOutstanding] == "1000000000"); - BEAST_EXPECT(!loan.isMember(sfLoanScale)); - BEAST_EXPECT(loan[sfStartDate].asUInt() == startDate.time_since_epoch().count()); - - return loan["index"].asString(); - }(); - auto const loanKeylet{keylet::loan(uint256{std::string_view(loanID)})}; - - env.close(startDate); - - // Make a payment - env(pay(lender, loanKeylet.key, broker.asset(1000))); - } - - void - testBatchBypassCounterparty(FeatureBitset features) - { - // From FIND-001 - testcase << "Batch Bypass Counterparty"; - - bool const lendingBatchEnabled = !std::ranges::any_of( - Batch::kDisabledTxTypes, - [](auto const& disabled) { return disabled == ttLOAN_BROKER_SET; }); - - using namespace jtx; - using namespace std::chrono_literals; - Env env(*this, features); - - Account const lender{"lender"}; - Account const borrower{"borrower"}; - - BrokerParameters const brokerParams; - env.fund(XRP(brokerParams.vaultDeposit * 100), lender, borrower); - env.close(); - - PrettyAsset const xrpAsset{xrpIssue(), 1'000'000}; - - BrokerInfo const broker{createVaultAndBroker(env, xrpAsset, lender, brokerParams)}; - - using namespace loan; - - auto const loanSetFee = Fee(env.current()->fees().base * 2); - Number const principalRequest{1, 3}; - - auto forgedLoanSet = set(borrower, broker.brokerID, principalRequest, 0); - - json::Value randomData{json::ValueType::Object}; - randomData[jss::SigningPubKey] = json::StaticString{"2600"}; - json::Value sigObject{json::ValueType::Object}; - sigObject[jss::SigningPubKey] = strHex(lender.pk().slice()); - Serializer ss; - ss.add32(HashPrefix::TxSign); - parse(randomData).addWithoutSigningFields(ss); - auto const sig = xrpl::sign(borrower.pk(), borrower.sk(), ss.slice()); - sigObject[jss::TxnSignature] = strHex(Slice{sig.data(), sig.size()}); - - forgedLoanSet[json::StaticString{"CounterpartySignature"}] = sigObject; - - // ? Fails because the lender hasn't signed the tx - env(env.json(forgedLoanSet, Fee(loanSetFee)), Ter(telENV_RPC_FAILED)); - - auto const seq = env.seq(borrower); - auto const batchFee = batch::calcBatchFee(env, 1, 2); - // ! Should fail because the lender hasn't signed the tx - env(batch::outer(borrower, seq, batchFee, tfAllOrNothing), - batch::Inner(forgedLoanSet, seq + 1), - batch::Inner(pay(borrower, lender, XRP(1)), seq + 2), - Ter(lendingBatchEnabled ? temBAD_SIGNATURE : temINVALID_INNER_BATCH)); - env.close(); - - // ? Check that the loan was NOT created - { - json::Value params(json::ValueType::Object); - params[jss::account] = borrower.human(); - params[jss::type] = "Loan"; - auto const res = env.rpc("json", "account_objects", to_string(params)); - auto const objects = res[jss::result][jss::account_objects]; - BEAST_EXPECT(objects.size() == 0); - } - } - - void - testWrongMaxDebtBehavior(FeatureBitset features) - { - // From FIND-003 - testcase << "Wrong Max Debt Behavior"; - - using namespace jtx; - using namespace std::chrono_literals; - Env env(*this, features); - - Account const issuer{"issuer"}; - Account const lender{"lender"}; - - BrokerParameters const brokerParams{.debtMax = 0}; - env.fund(XRP(brokerParams.vaultDeposit * 100), issuer, noripple(lender)); - env.close(); - - PrettyAsset const xrpAsset{xrpIssue(), 1'000'000}; - - BrokerInfo const broker{createVaultAndBroker(env, xrpAsset, lender, brokerParams)}; - - if (auto const brokerSle = env.le(keylet::loanBroker(broker.brokerID)); - BEAST_EXPECT(brokerSle)) - { - BEAST_EXPECT(brokerSle->at(sfDebtMaximum) == 0); - } - - using namespace loan; - - auto const loanSetFee = Fee(env.current()->fees().base * 2); - Number const principalRequest{1, 3}; - - auto createJson = env.json(set(lender, broker.brokerID, principalRequest), Fee(loanSetFee)); - - json::Value counterpartyJson{json::ValueType::Object}; - counterpartyJson[sfTxnSignature] = createJson[sfTxnSignature]; - counterpartyJson[sfSigningPubKey] = createJson[sfSigningPubKey]; - if (!BEAST_EXPECT(!createJson.isMember(jss::Signers))) - counterpartyJson[sfSigners] = createJson[sfSigners]; - - createJson = env.json(createJson, Json(sfCounterpartySignature, counterpartyJson)); - env(createJson); - - env.close(); - } - - void - testLoanPayComputePeriodicPaymentValidRateInvariant(FeatureBitset features) - { - // From FIND-012 - testcase << "LoanPay xrpl::detail::computePeriodicPayment : " - "valid rate"; - - using namespace jtx; - using namespace std::chrono_literals; - Env env(*this, features); - - Account const issuer{"issuer"}; - Account const lender{"lender"}; - Account const borrower{"borrower"}; - - BrokerParameters const brokerParams; - env.fund(XRP(brokerParams.vaultDeposit * 100), issuer, lender, borrower); - env.close(); - - PrettyAsset const xrpAsset{xrpIssue(), 1'000'000}; - BrokerInfo const broker{createVaultAndBroker(env, xrpAsset, lender, brokerParams)}; - - using namespace loan; - - auto const loanSetFee = Fee(env.current()->fees().base * 2); - Number const principalRequest{640562, -5}; - - Number const serviceFee{2462611968}; - std::uint32_t const numPayments{4294967295 / 800}; - - auto createJson = env.json( - set(borrower, broker.brokerID, principalRequest), - Fee(loanSetFee), - kLoanServiceFee(serviceFee), - kPaymentTotal(numPayments), - Json(sfCounterpartySignature, json::ValueType::Object)); - - createJson["CloseInterestRate"] = 55374; - createJson["ClosePaymentFee"] = "3825205248"; - createJson["LatePaymentFee"] = "237"; - createJson["LoanOriginationFee"] = "0"; - createJson["OverpaymentFee"] = 35167; - createJson["OverpaymentInterestRate"] = 1360; - createJson["PaymentInterval"] = 727; - - auto const brokerStateBefore = env.le(keylet::loanBroker(broker.brokerID)); - auto const loanSequence = brokerStateBefore->at(sfLoanSequence); - auto const keylet = keylet::loan(broker.brokerID, loanSequence); - - createJson = env.json(createJson, Sig(sfCounterpartySignature, lender)); - // Fails in preclaim because principal requested can't be - // represented as XRP - env(createJson, Ter(tecPRECISION_LOSS)); - env.close(); - - BEAST_EXPECT(!env.le(keylet)); - - Number const actualPrincipal{6}; - - createJson[sfPrincipalRequested] = actualPrincipal; - createJson.removeMember(sfSequence.jsonName); - createJson = env.json(createJson, Sig(sfCounterpartySignature, lender)); - // Fails in doApply because the payment is too small to be - // represented as XRP. - env(createJson, Ter(tecPRECISION_LOSS)); - env.close(); - } - - void - testRPC(FeatureBitset features) - { - // This will expand as more test cases are added. Some functionality - // is tested in other test functions. - testcase("RPC"); - - using namespace jtx; - - Env env(*this, features); - - auto lowerFee = [&]() { - // Run the local fee back down. - while (env.app().getFeeTrack().lowerLocalFee()) - ; - }; - - auto const baseFee = env.current()->fees().base; - - Account const alice{"alice"}; - std::string const borrowerPass = "borrower"; - Account const borrower{borrowerPass, KeyType::Ed25519}; - auto const lenderPass = "lender"; - Account const lender{lenderPass, KeyType::Ed25519}; - - env.fund(XRP(1'000'000), alice, lender, borrower); - env.close(); - env(noop(lender)); - env(noop(lender)); - env(noop(lender)); - env(noop(lender)); - env(noop(lender)); - env.close(); - - { - testcase("RPC AccountSet"); - json::Value txJson{json::ValueType::Object}; - txJson[sfTransactionType] = "AccountSet"; - txJson[sfAccount] = borrower.human(); - - auto const signParams = [&]() { - json::Value signParams{json::ValueType::Object}; - signParams[jss::passphrase] = borrowerPass; - signParams[jss::key_type] = "ed25519"; - signParams[jss::tx_json] = txJson; - return signParams; - }(); - auto const jSign = env.rpc("json", "sign", to_string(signParams)); - BEAST_EXPECT(jSign.isMember(jss::result) && jSign[jss::result].isMember(jss::tx_json)); - auto txSignResult = jSign[jss::result][jss::tx_json]; - auto txSignBlob = jSign[jss::result][jss::tx_blob].asString(); - txSignResult.removeMember(jss::hash); - - auto const jtx = env.jt(txJson, Sig(borrower)); - BEAST_EXPECT(txSignResult == jtx.jv); - - lowerFee(); - auto const jSubmit = env.rpc("submit", txSignBlob); - BEAST_EXPECT( - jSubmit.isMember(jss::result) && - jSubmit[jss::result].isMember(jss::engine_result) && - jSubmit[jss::result][jss::engine_result].asString() == "tesSUCCESS"); - - lowerFee(); - env(jtx.jv, Sig(kNone), Seq(kNone), Fee(kNone), Ter(tefPAST_SEQ)); - } - - { - testcase("RPC LoanSet - illegal signature_target"); - - json::Value txJson{json::ValueType::Object}; - txJson[sfTransactionType] = "AccountSet"; - txJson[sfAccount] = borrower.human(); - - auto const borrowerSignParams = [&]() { - json::Value params{json::ValueType::Object}; - params[jss::passphrase] = borrowerPass; - params[jss::key_type] = "ed25519"; - params[jss::signature_target] = "Destination"; - params[jss::tx_json] = txJson; - return params; - }(); - auto const jSignBorrower = env.rpc("json", "sign", to_string(borrowerSignParams)); - BEAST_EXPECT( - jSignBorrower.isMember(jss::result) && - jSignBorrower[jss::result].isMember(jss::error) && - jSignBorrower[jss::result][jss::error] == "invalidParams" && - jSignBorrower[jss::result].isMember(jss::error_message) && - jSignBorrower[jss::result][jss::error_message] == "Destination"); - } - { - testcase("RPC LoanSet - sign and submit borrower initiated"); - // 1. Borrower creates the transaction - json::Value txJson{json::ValueType::Object}; - txJson[sfTransactionType] = "LoanSet"; - txJson[sfAccount] = borrower.human(); - txJson[sfCounterparty] = lender.human(); - txJson[sfLoanBrokerID] = - "FF924CD18A236C2B49CF8E80A351CEAC6A10171DC9F110025646894FEC" - "F83F" - "5C"; - txJson[sfPrincipalRequested] = "100000000"; - txJson[sfPaymentTotal] = 10000; - txJson[sfPaymentInterval] = 3600; - txJson[sfGracePeriod] = 300; - txJson[sfFlags] = 65536; // tfLoanOverpayment - txJson[sfFee] = to_string(24 * baseFee / 10); - - // 2. Borrower signs the transaction - auto const borrowerSignParams = [&]() { - json::Value params{json::ValueType::Object}; - params[jss::passphrase] = borrowerPass; - params[jss::key_type] = "ed25519"; - params[jss::tx_json] = txJson; - return params; - }(); - auto const jSignBorrower = env.rpc("json", "sign", to_string(borrowerSignParams)); - BEAST_EXPECTS( - jSignBorrower.isMember(jss::result) && - jSignBorrower[jss::result].isMember(jss::tx_json), - to_string(jSignBorrower)); - auto const txBorrowerSignResult = jSignBorrower[jss::result][jss::tx_json]; - auto const txBorrowerSignBlob = jSignBorrower[jss::result][jss::tx_blob].asString(); - - // 2a. Borrower attempts to submit the transaction. It doesn't - // work - { - lowerFee(); - auto const jSubmitBlob = env.rpc("submit", txBorrowerSignBlob); - BEAST_EXPECT(jSubmitBlob.isMember(jss::result)); - auto const jSubmitBlobResult = jSubmitBlob[jss::result]; - BEAST_EXPECT(jSubmitBlobResult.isMember(jss::tx_json)); - // Transaction fails because the CounterpartySignature is - // missing - BEAST_EXPECT( - jSubmitBlobResult.isMember(jss::engine_result) && - jSubmitBlobResult[jss::engine_result].asString() == "temBAD_SIGNER"); - } - - // 3. Borrower sends the signed transaction to the lender - // 4. Lender signs the transaction - auto const lenderSignParams = [&]() { - json::Value params{json::ValueType::Object}; - params[jss::passphrase] = lenderPass; - params[jss::key_type] = "ed25519"; - params[jss::signature_target] = "CounterpartySignature"; - params[jss::tx_json] = txBorrowerSignResult; - return params; - }(); - auto const jSignLender = env.rpc("json", "sign", to_string(lenderSignParams)); - BEAST_EXPECT( - jSignLender.isMember(jss::result) && - jSignLender[jss::result].isMember(jss::tx_json)); - auto const txLenderSignResult = jSignLender[jss::result][jss::tx_json]; - auto const txLenderSignBlob = jSignLender[jss::result][jss::tx_blob].asString(); - - // 5. Lender submits the signed transaction blob - lowerFee(); - auto const jSubmitBlob = env.rpc("submit", txLenderSignBlob); - BEAST_EXPECT(jSubmitBlob.isMember(jss::result)); - auto const jSubmitBlobResult = jSubmitBlob[jss::result]; - BEAST_EXPECT(jSubmitBlobResult.isMember(jss::tx_json)); - auto const jSubmitBlobTx = jSubmitBlobResult[jss::tx_json]; - // To get far enough to return tecNO_ENTRY means that the - // signatures all validated. Of course the transaction won't - // succeed because no Vault or Broker were created. - BEAST_EXPECTS( - jSubmitBlobResult.isMember(jss::engine_result) && - jSubmitBlobResult[jss::engine_result].asString() == "tecNO_ENTRY", - to_string(jSubmitBlobResult)); - - BEAST_EXPECT( - !jSubmitBlob.isMember(jss::error) && !jSubmitBlobResult.isMember(jss::error)); - - // 4-alt. Lender submits the transaction json originally - // received from the Borrower. It gets signed, but is now a - // duplicate, so fails. Borrower could done this instead of - // steps 4 and 5. - lowerFee(); - auto const jSubmitJson = env.rpc("json", "submit", to_string(lenderSignParams)); - BEAST_EXPECT(jSubmitJson.isMember(jss::result)); - auto const jSubmitJsonResult = jSubmitJson[jss::result]; - BEAST_EXPECT(jSubmitJsonResult.isMember(jss::tx_json)); - auto const jSubmitJsonTx = jSubmitJsonResult[jss::tx_json]; - // Since the previous tx claimed a fee, this duplicate is not - // going anywhere - BEAST_EXPECTS( - jSubmitJsonResult.isMember(jss::engine_result) && - jSubmitJsonResult[jss::engine_result].asString() == "tefPAST_SEQ", - to_string(jSubmitJsonResult)); - - BEAST_EXPECT( - !jSubmitJson.isMember(jss::error) && !jSubmitJsonResult.isMember(jss::error)); - - BEAST_EXPECT(jSubmitBlobTx == jSubmitJsonTx); - } - - { - testcase("RPC LoanSet - sign and submit lender initiated"); - // 1. Lender creates the transaction - json::Value txJson{json::ValueType::Object}; - txJson[sfTransactionType] = "LoanSet"; - txJson[sfAccount] = lender.human(); - txJson[sfCounterparty] = borrower.human(); - txJson[sfLoanBrokerID] = - "FF924CD18A236C2B49CF8E80A351CEAC6A10171DC9F110025646894FEC" - "F83F" - "5C"; - txJson[sfPrincipalRequested] = "100000000"; - txJson[sfPaymentTotal] = 10000; - txJson[sfPaymentInterval] = 3600; - txJson[sfGracePeriod] = 300; - txJson[sfFlags] = 65536; // tfLoanOverpayment - txJson[sfFee] = to_string(24 * baseFee / 10); - - // 2. Lender signs the transaction - auto const lenderSignParams = [&]() { - json::Value params{json::ValueType::Object}; - params[jss::passphrase] = lenderPass; - params[jss::key_type] = "ed25519"; - params[jss::tx_json] = txJson; - return params; - }(); - auto const jSignLender = env.rpc("json", "sign", to_string(lenderSignParams)); - BEAST_EXPECT( - jSignLender.isMember(jss::result) && - jSignLender[jss::result].isMember(jss::tx_json)); - auto const txLenderSignResult = jSignLender[jss::result][jss::tx_json]; - auto const txLenderSignBlob = jSignLender[jss::result][jss::tx_blob].asString(); - - // 2a. Lender attempts to submit the transaction. It doesn't - // work - { - lowerFee(); - auto const jSubmitBlob = env.rpc("submit", txLenderSignBlob); - BEAST_EXPECT(jSubmitBlob.isMember(jss::result)); - auto const jSubmitBlobResult = jSubmitBlob[jss::result]; - BEAST_EXPECT(jSubmitBlobResult.isMember(jss::tx_json)); - // Transaction fails because the CounterpartySignature is - // missing - BEAST_EXPECT( - jSubmitBlobResult.isMember(jss::engine_result) && - jSubmitBlobResult[jss::engine_result].asString() == "temBAD_SIGNER"); - } - - // 3. Lender sends the signed transaction to the Borrower - // 4. Borrower signs the transaction - auto const borrowerSignParams = [&]() { - json::Value params{json::ValueType::Object}; - params[jss::passphrase] = borrowerPass; - params[jss::key_type] = "ed25519"; - params[jss::signature_target] = "CounterpartySignature"; - params[jss::tx_json] = txLenderSignResult; - return params; - }(); - auto const jSignBorrower = env.rpc("json", "sign", to_string(borrowerSignParams)); - BEAST_EXPECT( - jSignBorrower.isMember(jss::result) && - jSignBorrower[jss::result].isMember(jss::tx_json)); - auto const txBorrowerSignResult = jSignBorrower[jss::result][jss::tx_json]; - auto const txBorrowerSignBlob = jSignBorrower[jss::result][jss::tx_blob].asString(); - - // 5. Borrower submits the signed transaction blob - lowerFee(); - auto const jSubmitBlob = env.rpc("submit", txBorrowerSignBlob); - BEAST_EXPECT(jSubmitBlob.isMember(jss::result)); - auto const jSubmitBlobResult = jSubmitBlob[jss::result]; - BEAST_EXPECT(jSubmitBlobResult.isMember(jss::tx_json)); - auto const jSubmitBlobTx = jSubmitBlobResult[jss::tx_json]; - // To get far enough to return tecNO_ENTRY means that the - // signatures all validated. Of course the transaction won't - // succeed because no Vault or Broker were created. - BEAST_EXPECTS( - jSubmitBlobResult.isMember(jss::engine_result) && - jSubmitBlobResult[jss::engine_result].asString() == "tecNO_ENTRY", - to_string(jSubmitBlobResult)); - - BEAST_EXPECT( - !jSubmitBlob.isMember(jss::error) && !jSubmitBlobResult.isMember(jss::error)); - - // 4-alt. Borrower submits the transaction json originally - // received from the Lender. It gets signed, but is now a - // duplicate, so fails. Lender could done this instead of steps - // 4 and 5. - lowerFee(); - auto const jSubmitJson = env.rpc("json", "submit", to_string(borrowerSignParams)); - BEAST_EXPECT(jSubmitJson.isMember(jss::result)); - auto const jSubmitJsonResult = jSubmitJson[jss::result]; - BEAST_EXPECT(jSubmitJsonResult.isMember(jss::tx_json)); - auto const jSubmitJsonTx = jSubmitJsonResult[jss::tx_json]; - // Since the previous tx claimed a fee, this duplicate is not - // going anywhere - BEAST_EXPECTS( - jSubmitJsonResult.isMember(jss::engine_result) && - jSubmitJsonResult[jss::engine_result].asString() == "tefPAST_SEQ", - to_string(jSubmitJsonResult)); - - BEAST_EXPECT( - !jSubmitJson.isMember(jss::error) && !jSubmitJsonResult.isMember(jss::error)); - - BEAST_EXPECT(jSubmitBlobTx == jSubmitJsonTx); - } - } - - void - testServiceFeeOnBrokerDeepFreeze() - { - testcase << "Service Fee On Broker Deep Freeze"; - using namespace jtx; - using namespace loan; - Account const issuer("issuer"); - Account const borrower("borrower"); - Account const broker("broker"); - auto const iou = issuer["IOU"]; - - for (bool const deepFreeze : {true, false}) - { - Env env(*this); - - auto getCoverBalance = [&](BrokerInfo const& brokerInfo, auto const& accountField) { - if (auto const le = env.le(keylet::loanBroker(brokerInfo.brokerID)); - BEAST_EXPECT(le)) - { - auto const account = le->at(accountField); - if (auto const sleLine = env.le(keylet::trustLine(account, iou)); - BEAST_EXPECT(sleLine)) - { - STAmount balance = sleLine->at(sfBalance); - if (account > issuer.id()) - balance.negate(); - return balance; - } - } - return STAmount{iou}; - }; - - env.fund(XRP(20'000), issuer, broker, borrower); - env.close(); - - env(trust(broker, iou(20'000'000))); - env(pay(issuer, broker, iou(10'000'000))); - env.close(); - - auto const brokerInfo = createVaultAndBroker(env, iou, broker); - - BEAST_EXPECT(getCoverBalance(brokerInfo, sfAccount) == iou(1'000)); - - auto const keylet = keylet::loan(brokerInfo.brokerID, 1); - - env(set(borrower, brokerInfo.brokerID, 10'000), - Sig(sfCounterpartySignature, broker), - kLoanServiceFee(iou(100).value()), - kPaymentInterval(100), - Fee(XRP(100))); - env.close(); - - env(trust(borrower, iou(20'000'000))); - // The borrower increases their limit and acquires some IOU so - // they can pay interest - env(pay(issuer, borrower, iou(500))); - env.close(); - - if (auto const le = env.le(keylet::loan(keylet.key)); BEAST_EXPECT(le)) - { - if (deepFreeze) - { - env(trust(issuer, broker["IOU"](0), tfSetFreeze | tfSetDeepFreeze)); - env.close(); - } - - env(pay(borrower, keylet.key, iou(10'100)), Fee(XRP(100))); - env.close(); - - if (deepFreeze) - { - // The fee goes to the broker pseudo-account - BEAST_EXPECT(getCoverBalance(brokerInfo, sfAccount) == iou(1'100)); - BEAST_EXPECT(getCoverBalance(brokerInfo, sfOwner) == iou(8'999'000)); - } - else - { - // The fee goes to the broker account - BEAST_EXPECT(getCoverBalance(brokerInfo, sfOwner) == iou(8'999'100)); - BEAST_EXPECT(getCoverBalance(brokerInfo, sfAccount) == iou(1'000)); - } - } - }; - } - - void - testIssuerLoan() - { - testcase << "Issuer Loan"; - - using namespace jtx; - using namespace loan; - Account const issuer("issuer"); - Account const borrower = issuer; - Account const lender("lender"); - Env env(*this); - - env.fund(XRP(1'000), issuer, lender); - - static constexpr std::int64_t kIssuerBalance = 10'000'000; - MPTTester const asset( - {.env = env, .issuer = issuer, .holders = {lender}, .pay = kIssuerBalance}); - - BrokerParameters const brokerParams{ - .debtMax = 200, - }; - auto const broker = createVaultAndBroker(env, asset, lender, brokerParams); - auto const loanSetFee = Fee(env.current()->fees().base * 2); - // Create Loan - env(set(borrower, broker.brokerID, 200), Sig(sfCounterpartySignature, lender), loanSetFee); - env.close(); - // Issuer should not create MPToken - BEAST_EXPECT(!env.le(keylet::mptoken(asset.issuanceID(), issuer))); - // Issuer "borrowed" 200, OutstandingAmount decreased by 200 - BEAST_EXPECT(env.balance(issuer, asset) == asset(-kIssuerBalance + 200)); - // Pay Loan - auto const loanKeylet = keylet::loan(broker.brokerID, 1); - env(pay(borrower, loanKeylet.key, asset(200))); - env.close(); - // Issuer "re-payed" 200, OutstandingAmount increased by 200 - BEAST_EXPECT(env.balance(issuer, asset) == asset(-kIssuerBalance)); - } - - void - testInvalidLoanDelete() - { - testcase("Invalid LoanDelete"); - using namespace jtx; - using namespace loan; - - // preflight: temINVALID, LoanID == zero - { - Account const alice{"alice"}; - Env env(*this); - env.fund(XRP(1'000), alice); - env.close(); - env(del(alice, beast::kZero), Ter(temINVALID)); - } - } - - void - testInvalidLoanManage() - { - testcase("Invalid LoanManage"); - using namespace jtx; - using namespace loan; - - // preflight: temINVALID, LoanID == zero - { - Account const alice{"alice"}; - Env env(*this); - env.fund(XRP(1'000), alice); - env.close(); - env(manage(alice, beast::kZero, tfLoanDefault), Ter(temINVALID)); - } - } - - void - testInvalidLoanPay() - { - testcase("Invalid LoanPay"); - using namespace jtx; - using namespace loan; - Account const lender{"lender"}; - Account const issuer{"issuer"}; - Account const borrower{"borrower"}; - auto const iou = issuer["IOU"]; - - // preclaim - Env env(*this); - env.fund(XRP(1'000), lender, issuer, borrower); - env(trust(lender, iou(10'000'000))); - env(pay(issuer, lender, iou(5'000'000))); - BrokerInfo brokerInfo{createVaultAndBroker(env, issuer["IOU"], lender)}; - - auto const loanSetFee = Fee(env.current()->fees().base * 2); - STAmount const debtMaximumRequest = brokerInfo.asset(1'000).value(); - - env(set(borrower, brokerInfo.brokerID, debtMaximumRequest), - Sig(sfCounterpartySignature, lender), - loanSetFee); - - env.close(); - - std::uint32_t const loanSequence = 1; - auto const loanKeylet = keylet::loan(brokerInfo.brokerID, loanSequence); - - env(fset(issuer, asfGlobalFreeze)); - env.close(); - - // preclaim: tecFROZEN - env(pay(borrower, loanKeylet.key, debtMaximumRequest), Ter(tecFROZEN)); - env.close(); - - env(fclear(issuer, asfGlobalFreeze)); - env.close(); - - auto const pseudoBroker = [&]() -> std::optional { - if (auto brokerSle = env.le(keylet::loanBroker(brokerInfo.brokerID)); - BEAST_EXPECT(brokerSle)) - { - return Account{"pseudo", brokerSle->at(sfAccount)}; - } - - return std::nullopt; - }(); - if (!pseudoBroker) - return; - - // Lender and pseudoaccount must both be frozen - env(trust(issuer, lender["IOU"](1'000), lender, tfSetFreeze | tfSetDeepFreeze)); - env(trust( - issuer, (*pseudoBroker)["IOU"](1'000), *pseudoBroker, tfSetFreeze | tfSetDeepFreeze)); - env.close(); - - // preclaim: tecFROZEN due to deep frozen - env(pay(borrower, loanKeylet.key, debtMaximumRequest), Ter(tecFROZEN)); - env.close(); - - // Only one needs to be unfrozen - env(trust(issuer, lender["IOU"](1'000), tfClearFreeze | tfClearDeepFreeze)); - env.close(); - - // The payment is late by this point - env(pay(borrower, loanKeylet.key, debtMaximumRequest), Ter(tecEXPIRED)); - env.close(); - env(pay(borrower, loanKeylet.key, debtMaximumRequest, tfLoanLatePayment)); - env.close(); - - // preclaim: tecKILLED - // note that tecKILLED in loanMakePayment() - // doesn't happen because of the preclaim check. - env(pay(borrower, loanKeylet.key, debtMaximumRequest), Ter(tecKILLED)); - } - - void - testInvalidLoanSet() - { - testcase("Invalid LoanSet"); - using namespace jtx; - using namespace loan; - Account const lender{"lender"}; - Account const issuer{"issuer"}; - Account const borrower{"borrower"}; - Account const sponsor{"sponsor"}; - auto const iou = issuer["IOU"]; - - auto testWrapper = [&](auto&& test) { - Env env(*this); - env.fund(XRP(1'000), lender, issuer, borrower, sponsor); - env(trust(lender, iou(10'000'000))); - env(pay(issuer, lender, iou(5'000'000))); - BrokerInfo const brokerInfo{createVaultAndBroker(env, issuer["IOU"], lender)}; - - auto const loanSetFee = Fee(env.current()->fees().base * 2); - Number const debtMaximumRequest = brokerInfo.asset(1'000).value(); - test(env, brokerInfo, loanSetFee, debtMaximumRequest); - }; - - // preflight: - testWrapper([&](Env& env, - BrokerInfo const& brokerInfo, - jtx::Fee const& loanSetFee, - Number const& debtMaximumRequest) { - for (auto const sponsorFlags : {spfSponsorReserve, spfSponsorReserve | spfSponsorFee}) - { - env(set(borrower, brokerInfo.brokerID, debtMaximumRequest), - sponsor::As(sponsor, sponsorFlags), - Sig(sfCounterpartySignature, lender), - loanSetFee, - Ter(temINVALID_FLAG)); - } - - // first temBAD_SIGNER: TODO - // invalid grace period - { - // zero grace period - env(set(borrower, brokerInfo.brokerID, debtMaximumRequest), - Sig(sfCounterpartySignature, lender), - kGracePeriod(0), - loanSetFee, - Ter(temINVALID)); - - // grace period less than default minimum - env(set(borrower, brokerInfo.brokerID, debtMaximumRequest), - Sig(sfCounterpartySignature, lender), - kGracePeriod(LoanSet::kDefaultGracePeriod - 1), - loanSetFee, - Ter(temINVALID)); - - // grace period greater than payment interval - env(set(borrower, brokerInfo.brokerID, debtMaximumRequest), - Sig(sfCounterpartySignature, lender), - kPaymentInterval(120), - kGracePeriod(121), - loanSetFee, - Ter(temINVALID)); - } - // empty/zero broker ID - { - auto jv = set(borrower, uint256{}, debtMaximumRequest); - - auto testZeroBrokerID = [&](std::string const& id, std::uint32_t flags = 0) { - // empty broker ID - jv[sfLoanBrokerID] = id; - env(jv, - Sig(sfCounterpartySignature, lender), - loanSetFee, - Txflags(flags), - Ter(temINVALID)); - }; - // empty broker ID - testZeroBrokerID(std::string("")); - // zero broker ID - // needs a flag to distinguish the parsed STTx from the prior - // test - testZeroBrokerID(to_string(uint256{}), tfFullyCanonicalSig); - } - - // preflightCheckSigningKey() failure: - // can it happen? the signature is checked before transactor - // executes - - JTx const tx = env.jt( - set(borrower, brokerInfo.brokerID, debtMaximumRequest), - Sig(sfCounterpartySignature, lender), - loanSetFee); - STTx local = *(tx.stx); - auto counterpartySig = local.getFieldObject(sfCounterpartySignature); - auto badPubKey = counterpartySig.getFieldVL(sfSigningPubKey); - badPubKey[20] ^= 0xAA; - counterpartySig.setFieldVL(sfSigningPubKey, badPubKey); - local.setFieldObject(sfCounterpartySignature, counterpartySig); - json::Value jvResult; - jvResult[jss::tx_blob] = strHex(local.getSerializer().slice()); - auto res = env.rpc("json", "submit", to_string(jvResult))["result"]; - BEAST_EXPECT( - res[jss::error] == "invalidTransaction" && - res[jss::error_exception] == - "fails local checks: Counterparty: Invalid signature."); - }); - - // preclaim: - testWrapper([&](Env& env, - BrokerInfo const& brokerInfo, - jtx::Fee const& loanSetFee, - Number const& debtMaximumRequest) { - // canAddHoldingFailure (IOU only, if MPT doesn't have - // MPTCanTransfer set, then can't create Vault/LoanBroker, - // and LoanSet will fail with different error - env(fclear(issuer, asfDefaultRipple)); - env.close(); - env(set(borrower, brokerInfo.brokerID, debtMaximumRequest), - Sig(sfCounterpartySignature, lender), - loanSetFee, - Ter(terNO_RIPPLE)); - }); - - // doApply: - testWrapper([&](Env& env, - BrokerInfo const& brokerInfo, - jtx::Fee const& loanSetFee, - Number const& debtMaximumRequest) { - auto const amt = - env.balance(borrower) - accountReserve(*env.current(), borrower.id(), env.journal); - env(pay(borrower, issuer, amt)); - - // tecINSUFFICIENT_RESERVE - env(set(borrower, brokerInfo.brokerID, debtMaximumRequest), - Sig(sfCounterpartySignature, lender), - loanSetFee, - Ter(tecINSUFFICIENT_RESERVE)); - - // addEmptyHolding failure - env(pay(issuer, borrower, amt)); - env(fset(issuer, asfGlobalFreeze)); - env.close(); - - env(set(borrower, brokerInfo.brokerID, debtMaximumRequest), - Sig(sfCounterpartySignature, lender), - loanSetFee, - Ter(tecFROZEN)); - }); - } - - void - testAccountSendMptMinAmountInvariant(FeatureBitset features) - { - // (From FIND-006) - testcase << "LoanSet trigger xrpl::accountSendMPT : minimum amount " - "and MPT"; - - using namespace jtx; - using namespace std::chrono_literals; - Env env(*this, features); - - Account const issuer{"issuer"}; - Account const lender{"lender"}; - Account const borrower{"borrower"}; - - env.fund(XRP(1'000'000), issuer, lender, borrower); - env.close(); - - MPTTester mptt{env, issuer, kMptInitNoFund}; - mptt.create({.flags = tfMPTCanClawback | tfMPTCanTransfer | tfMPTCanLock}); - PrettyAsset const mptAsset = mptt.issuanceID(); - mptt.authorize({.account = lender}); - mptt.authorize({.account = borrower}); - env(pay(issuer, lender, mptAsset(2'000'000))); - env(pay(issuer, borrower, mptAsset(1'000))); - env.close(); - - BrokerInfo const broker{createVaultAndBroker(env, mptAsset, lender)}; - - using namespace loan; - - auto const loanSetFee = Fee(env.current()->fees().base * 2); - Number const principalRequest{1, 3}; - - auto createJson = env.json( - set(borrower, broker.brokerID, principalRequest), - Fee(loanSetFee), - Json(sfCounterpartySignature, json::ValueType::Object)); - - createJson["CloseInterestRate"] = 76671; - createJson["ClosePaymentFee"] = "2061925410"; - createJson["GracePeriod"] = 434; - createJson["InterestRate"] = 50302; - createJson["LateInterestRate"] = 30322; - createJson["LatePaymentFee"] = "294427911"; - createJson["LoanOriginationFee"] = "3250635102"; - createJson["LoanServiceFee"] = "9557386"; - createJson["OverpaymentFee"] = 51249; - createJson["OverpaymentInterestRate"] = 14304; - createJson["PaymentInterval"] = 434; - createJson["PaymentTotal"] = "2891743748"; - createJson["PrincipalRequested"] = "8516.98"; - - auto const brokerStateBefore = env.le(keylet::loanBroker(broker.brokerID)); - - createJson = env.json(createJson, Sig(sfCounterpartySignature, lender)); - env(createJson, Ter(temINVALID)); - env.close(); - } - - void - testLoanPayDebtDecreaseInvariant(FeatureBitset features) - { - // From FIND-007 - testcase << "LoanPay xrpl::LoanPay::doApply : debtDecrease " - "rounding good"; - - using namespace jtx; - using namespace std::chrono_literals; - using namespace Lending; - Env env(*this, features); - - Account const issuer{"issuer"}; - Account const lender{"lender"}; - Account const borrower{"borrower"}; - - env.fund(XRP(1'000'000), issuer, lender, borrower); - env.close(); - - PrettyAsset const iouAsset = issuer[iouCurrency_]; - auto trustLenderTx = env.json(trust(lender, iouAsset(1'000'000'000))); - env(trustLenderTx); - auto trustBorrowerTx = env.json(trust(borrower, iouAsset(1'000'000'000))); - env(trustBorrowerTx); - auto payLenderTx = pay(issuer, lender, iouAsset(100'000'000)); - env(payLenderTx); - auto payIssuerTx = pay(issuer, borrower, iouAsset(1'000'000)); - env(payIssuerTx); - env.close(); - - BrokerInfo broker{createVaultAndBroker(env, iouAsset, lender)}; - - using namespace loan; - - auto const baseFee = env.current()->fees().base; - auto const loanSetFee = Fee(baseFee * 2); - Number const principalRequest{1, 3}; - - auto createJson = env.json( - set(borrower, broker.brokerID, principalRequest), - Fee(loanSetFee), - Json(sfCounterpartySignature, json::ValueType::Object)); - - createJson["ClosePaymentFee"] = "0"; - createJson["GracePeriod"] = 60; - createJson["InterestRate"] = 24346; - createJson["LateInterestRate"] = 65535; - createJson["LatePaymentFee"] = "0"; - createJson["LoanOriginationFee"] = "218"; - createJson["LoanServiceFee"] = "0"; - createJson["PaymentInterval"] = 60; - createJson["PaymentTotal"] = 5678; - createJson["PrincipalRequested"] = "9924.81"; - - auto const brokerStateBefore = env.le(keylet::loanBroker(broker.brokerID)); - auto const loanSequence = brokerStateBefore->at(sfLoanSequence); - auto const keylet = keylet::loan(broker.brokerID, loanSequence); - - createJson = env.json(createJson, Sig(sfCounterpartySignature, lender)); - env(createJson, Ter(tesSUCCESS)); - env.close(); - - auto const pseudoAcct = [&]() { - auto const brokerSle = env.le(keylet::loanBroker(broker.brokerID)); - if (!BEAST_EXPECT(brokerSle)) - return Account{lender}; - auto const brokerPseudo = brokerSle->at(sfAccount); - return Account("Broker pseudo-account", brokerPseudo); - }(); - - VerifyLoanStatus const verifyLoanStatus(env, broker, pseudoAcct, keylet); - auto const originalState = getCurrentState(env, broker, keylet); - verifyLoanStatus(originalState); - - Number const payment{3'269'349'176'470'588, -12}; - XRPAmount const payFee{ - baseFee * - ((payment / originalState.periodicPayment) / kLoanPaymentsPerFeeIncrement + 1)}; - auto loanPayTx = - env.json(pay(borrower, keylet.key, STAmount{broker.asset, payment}), Fee(payFee)); - BEAST_EXPECT(to_string(payment) == "3269.349176470588"); - env(loanPayTx, Ter(tesSUCCESS)); - env.close(); - - auto const newState = getCurrentState(env, broker, keylet); - BEAST_EXPECT( - isRounded(broker.asset, newState.managementFeeOutstanding, originalState.loanScale)); - BEAST_EXPECT(newState.managementFeeOutstanding < originalState.managementFeeOutstanding); - BEAST_EXPECT(isRounded(broker.asset, newState.totalValue, originalState.loanScale)); - BEAST_EXPECT( - isRounded(broker.asset, newState.principalOutstanding, originalState.loanScale)); - } - - void - testLoanPayComputePeriodicPaymentValidTotalInterestInvariant(FeatureBitset features) - { - // From FIND-010 - testcase << "xrpl::loanComputePaymentParts : valid total interest"; - - using namespace jtx; - using namespace std::chrono_literals; - Env env(*this, features); - - Account const issuer{"issuer"}; - Account const lender{"lender"}; - Account const borrower{"borrower"}; - - env.fund(XRP(1'000'000), issuer, lender, borrower); - env.close(); - - PrettyAsset const iouAsset = issuer[iouCurrency_]; - auto trustLenderTx = env.json(trust(lender, iouAsset(1'000'000'000))); - env(trustLenderTx); - auto trustBorrowerTx = env.json(trust(borrower, iouAsset(1'000'000'000))); - env(trustBorrowerTx); - auto payLenderTx = pay(issuer, lender, iouAsset(100'000'000)); - env(payLenderTx); - auto payIssuerTx = pay(issuer, borrower, iouAsset(1'000'000)); - env(payIssuerTx); - env.close(); - - BrokerInfo const broker{createVaultAndBroker(env, iouAsset, lender)}; - - using namespace loan; - - auto const loanSetFee = Fee(env.current()->fees().base * 2); - Number const principalRequest{1, 3}; - - auto createJson = env.json( - set(borrower, broker.brokerID, principalRequest), - Fee(loanSetFee), - Json(sfCounterpartySignature, json::ValueType::Object)); - - createJson["CloseInterestRate"] = 47299; - createJson["ClosePaymentFee"] = "3985819770"; - createJson["InterestRate"] = 92; - createJson["LatePaymentFee"] = "3866894865"; - createJson["LoanOriginationFee"] = "0"; - createJson["LoanServiceFee"] = "2348810240"; - createJson["OverpaymentFee"] = 58545; - createJson["PaymentInterval"] = 60; - createJson["PaymentTotal"] = 1; - createJson["PrincipalRequested"] = "0.000763058"; - - auto const brokerStateBefore = env.le(keylet::loanBroker(broker.brokerID)); - auto const loanSequence = brokerStateBefore->at(sfLoanSequence); - auto const keylet = keylet::loan(broker.brokerID, loanSequence); - - createJson = env.json(createJson, Sig(sfCounterpartySignature, lender)); - env(createJson); - env.close(); - - auto loanPayTx = env.json(pay(borrower, keylet.key, STAmount{broker.asset, Number{}})); - loanPayTx["Amount"]["value"] = "0.000281284125490196"; - env(loanPayTx, Ter(tecINSUFFICIENT_PAYMENT)); - env.close(); - } - - void - testDosLoanPay(FeatureBitset features) - { - bool const feeCapped = features[fixCleanup3_1_3]; - - // From FIND-005 - testcase << "DoS LoanPay: fee calculation " << (feeCapped ? "capped" : "uncapped"); - - using namespace jtx; - using namespace std::chrono_literals; - using namespace Lending; - Env env(*this, features); - - Account const issuer{"issuer"}; - Account const lender{"lender"}; - Account const borrower{"borrower"}; - - env.fund(XRP(1'000'000), issuer, lender, borrower); - env.close(); - - BEAST_EXPECT(feeCapped == env.current()->rules().enabled(fixCleanup3_1_3)); - - PrettyAsset const iouAsset = issuer[iouCurrency_]; - env(trust(lender, iouAsset(100'000'000))); - env(trust(borrower, iouAsset(100'000'000))); - env(pay(issuer, lender, iouAsset(10'000'000))); - env(pay(issuer, borrower, iouAsset(1'000))); - env.close(); - - BrokerInfo const broker{createVaultAndBroker(env, iouAsset, lender)}; - - using namespace loan; - - auto const loanSetFee = Fee(env.current()->fees().base * 2); - Number const principalRequest{3959'37, -2}; - auto const baseFee = env.current()->fees().base; - - auto const createJson = env.json( - set(borrower, broker.brokerID, principalRequest), - Fee(loanSetFee), - Json(sfCounterpartySignature, json::ValueType::Object), - kClosePaymentFee(0), - kGracePeriod(60), - kInterestRate(TenthBips32(20930)), - kLateInterestRate(TenthBips32(77049)), - kLatePaymentFee(0), - kLoanServiceFee(0), - kOverpaymentFee(TenthBips32(7)), - kOverpaymentInterestRate(TenthBips32(66653)), - kPaymentInterval(60), - kPaymentTotal(3239184)); - - // There are enough payments due on this loan that it only needs to be - // created once, and can be paid on multiple times. Just don't create a - // gazillion test cases. - auto const brokerStateBefore = env.le(keylet::loanBroker(broker.brokerID)); - auto const loanSequence = brokerStateBefore->at(sfLoanSequence); - auto const keylet = keylet::loan(broker.brokerID, loanSequence); - - env(createJson, Sig(sfCounterpartySignature, lender)); - env.close(); - - auto const roundedPayment = [&]() { - auto const stateBefore = getCurrentState(env, broker, keylet); - BEAST_EXPECT(stateBefore.paymentRemaining == 3239184); - BEAST_EXPECT(stateBefore.paymentRemaining > kLoanMaximumPaymentsPerTransaction); - - return roundToAsset( - iouAsset, - stateBefore.periodicPayment, - stateBefore.loanScale, - Number::RoundingMode::Upward); - }(); - - auto test = [&](int const payFactor, - int const feeFactor, - TER const expectedTer = tesSUCCESS) { - auto const stateBefore = getCurrentState(env, broker, keylet); - BEAST_EXPECT(stateBefore.paymentRemaining <= 3239184); - BEAST_EXPECT(stateBefore.paymentRemaining > kLoanMaximumPaymentsPerTransaction); - - Number const amount = roundedPayment * payFactor; - auto loanPayTx = env.json(pay(borrower, keylet.key, STAmount{broker.asset, amount})); - XRPAmount const payFee{baseFee * feeFactor}; - env(loanPayTx, Ter(expectedTer), Fee(payFee)); - env.close(); - auto const expectedChange = isTesSuccess(expectedTer) - ? std::min(kLoanMaximumPaymentsPerTransaction, payFactor) - : 0; - - auto const stateAfter = getCurrentState(env, broker, keylet); - BEAST_EXPECT( - stateAfter.paymentRemaining == stateBefore.paymentRemaining - expectedChange); - }; - - static constexpr std::int64_t kMaxFeeIncrements = - kLoanMaximumPaymentsPerTransaction / kLoanPaymentsPerFeeIncrement; - - TER const failWithoutFix = feeCapped ? (TER)tesSUCCESS : (TER)telINSUF_FEE_P; - - // * Amount well above threshold -> capped fee - // The original test case - way over the limit - more fee is always ok - test(1819878, 363976); - // The capped fee is only sufficient if the amendment is enabled. - test(1819878, kMaxFeeIncrements, failWithoutFix); - - // * Amount exactly at threshold -> capped fee - test(kLoanMaximumPaymentsPerTransaction, kMaxFeeIncrements); - // More fee is always ok - test(kLoanMaximumPaymentsPerTransaction, kMaxFeeIncrements + 10); - - // * Amount below threshold -> normal calculation - test(1, 1); - test(kLoanPaymentsPerFeeIncrement * 2, 2); - test(0, 0, temBAD_AMOUNT); - test(0, 1, temBAD_AMOUNT); - // Fee difference rounds evenly - test( - kLoanMaximumPaymentsPerTransaction - 10, - ((kLoanMaximumPaymentsPerTransaction - 10) / kLoanPaymentsPerFeeIncrement) - 1, - telINSUF_FEE_P); - test( - kLoanMaximumPaymentsPerTransaction - 10, - ((kLoanMaximumPaymentsPerTransaction - 10) / kLoanPaymentsPerFeeIncrement)); - // More fee is always ok - test( - kLoanMaximumPaymentsPerTransaction - 10, - ((kLoanMaximumPaymentsPerTransaction - 10) / kLoanPaymentsPerFeeIncrement) + 3); - // Fee rounds up - for (int under = 1; under < kLoanPaymentsPerFeeIncrement; ++under) - { - test(kLoanMaximumPaymentsPerTransaction - under, kMaxFeeIncrements - 1, telINSUF_FEE_P); - test(kLoanMaximumPaymentsPerTransaction - under, kMaxFeeIncrements); - } - // Only when you get one less fee increment can you pay less - test( - kLoanMaximumPaymentsPerTransaction - kLoanPaymentsPerFeeIncrement, - kMaxFeeIncrements - 1); - // And again, more fee is always ok. - test(kLoanMaximumPaymentsPerTransaction - kLoanPaymentsPerFeeIncrement, kMaxFeeIncrements); - } - - void - testLoanPayComputePeriodicPaymentValidTotalPrincipalPaidInvariant(FeatureBitset features) - { - // From FIND-009 - testcase << "xrpl::loanComputePaymentParts : totalPrincipalPaid " - "rounded"; - - using namespace jtx; - using namespace std::chrono_literals; - using namespace Lending; - Env env(*this, features); - - Account const issuer{"issuer"}; - Account const lender{"lender"}; - Account const borrower{"borrower"}; - - env.fund(XRP(1'000'000), issuer, lender, borrower); - env.close(); - - PrettyAsset const iouAsset = issuer[iouCurrency_]; - auto trustLenderTx = env.json(trust(lender, iouAsset(1'000'000'000))); - env(trustLenderTx); - auto trustBorrowerTx = env.json(trust(borrower, iouAsset(1'000'000'000))); - env(trustBorrowerTx); - auto payLenderTx = pay(issuer, lender, iouAsset(100'000'000)); - env(payLenderTx); - auto payIssuerTx = pay(issuer, borrower, iouAsset(1'000'000)); - env(payIssuerTx); - env.close(); - - BrokerInfo const broker{createVaultAndBroker(env, iouAsset, lender)}; - - using namespace loan; - - auto const loanSetFee = Fee(env.current()->fees().base * 2); - Number const principalRequest{1, 3}; - - auto createJson = env.json( - set(borrower, broker.brokerID, principalRequest), - Fee(loanSetFee), - Json(sfCounterpartySignature, json::ValueType::Object)); - - createJson["ClosePaymentFee"] = "0"; - createJson["InterestRate"] = 24346; - createJson["LateInterestRate"] = 65535; - createJson["LatePaymentFee"] = "0"; - createJson["LoanOriginationFee"] = "218"; - createJson["LoanServiceFee"] = "0"; - createJson["PaymentInterval"] = 60; - createJson["PaymentTotal"] = 5678; - createJson["PrincipalRequested"] = "9924.81"; - - auto const brokerStateBefore = env.le(keylet::loanBroker(broker.brokerID)); - auto const loanSequence = brokerStateBefore->at(sfLoanSequence); - auto const keylet = keylet::loan(broker.brokerID, loanSequence); - - createJson = env.json(createJson, Sig(sfCounterpartySignature, lender)); - env(createJson, Ter(tesSUCCESS)); - env.close(); - - auto const baseFee = env.current()->fees().base; - - auto const stateBefore = getCurrentState(env, broker, keylet); - - { - auto loanPayTx = env.json(pay(borrower, keylet.key, STAmount{broker.asset, Number{}})); - Number const amount{3074'745'058'823'529, -12}; - BEAST_EXPECT(to_string(amount) == "3074.745058823529"); - XRPAmount const payFee{ - baseFee * - (amount / stateBefore.periodicPayment / kLoanPaymentsPerFeeIncrement + 1)}; - loanPayTx["Amount"]["value"] = to_string(amount); - env(loanPayTx, Fee(payFee), Ter(tesSUCCESS)); - env.close(); - } - - { - auto loanPayTx = env.json(pay(borrower, keylet.key, STAmount{broker.asset, Number{}})); - Number const amount{6732'118'170'944'051, -12}; - BEAST_EXPECT(to_string(amount) == "6732.118170944051"); - XRPAmount const payFee{ - baseFee * - (amount / stateBefore.periodicPayment / kLoanPaymentsPerFeeIncrement + 1)}; - loanPayTx["Amount"]["value"] = to_string(amount); - env(loanPayTx, Fee(payFee), Ter(tesSUCCESS)); - env.close(); - } - - auto const stateAfter = getCurrentState(env, broker, keylet); - // Total interest outstanding is non-negative - BEAST_EXPECT(stateAfter.totalValue >= stateAfter.principalOutstanding); - // Principal paid is non-negative - BEAST_EXPECT(stateBefore.principalOutstanding >= stateAfter.principalOutstanding); - // Total value change is non-negative - BEAST_EXPECT(stateBefore.totalValue >= stateAfter.totalValue); - // Value delta is larger or same as principal delta (meaning - // non-negative interest paid) - BEAST_EXPECT( - (stateBefore.totalValue - stateAfter.totalValue) >= - (stateBefore.principalOutstanding - stateAfter.principalOutstanding)); - } - - void - testLoanPayComputePeriodicPaymentValidTotalInterestPaidInvariant(FeatureBitset features) - { - // From FIND-008 - testcase << "xrpl::loanComputePaymentParts : loanValueChange rounded"; - - using namespace jtx; - using namespace std::chrono_literals; - using namespace Lending; - Env env(*this, features); - - Account const issuer{"issuer"}; - Account const lender{"lender"}; - Account const borrower{"borrower"}; - - env.fund(XRP(1'000'000), issuer, lender, borrower); - env.close(); - - PrettyAsset const iouAsset = issuer[iouCurrency_]; - auto trustLenderTx = env.json(trust(lender, iouAsset(1'000'000'000))); - env(trustLenderTx); - auto trustBorrowerTx = env.json(trust(borrower, iouAsset(1'000'000'000))); - env(trustBorrowerTx); - auto payLenderTx = pay(issuer, lender, iouAsset(100'000'000)); - env(payLenderTx); - auto payIssuerTx = pay(issuer, borrower, iouAsset(10'000'000)); - env(payIssuerTx); - env.close(); - - BrokerInfo const broker{createVaultAndBroker(env, iouAsset, lender)}; - { - auto const coverDepositValue = broker.asset(broker.params.coverDeposit * 10).value(); - env(loanBroker::coverDeposit(lender, broker.brokerID, coverDepositValue)); - env.close(); - } - - using namespace loan; - - auto const loanSetFee = Fee(env.current()->fees().base * 2); - Number const principalRequest{1, 3}; - - auto createJson = env.json( - set(borrower, broker.brokerID, principalRequest), - Fee(loanSetFee), - Json(sfCounterpartySignature, json::ValueType::Object)); - - createJson["ClosePaymentFee"] = "0"; - createJson["InterestRate"] = 12833; - createJson["LateInterestRate"] = 77048; - createJson["LatePaymentFee"] = "0"; - createJson["LoanOriginationFee"] = "218"; - createJson["LoanServiceFee"] = "0"; - createJson["PaymentInterval"] = 752; - createJson["PaymentTotal"] = 5678; - createJson["PrincipalRequested"] = "9924.81"; - - auto const brokerStateBefore = env.le(keylet::loanBroker(broker.brokerID)); - auto const loanSequence = brokerStateBefore->at(sfLoanSequence); - auto const keylet = keylet::loan(broker.brokerID, loanSequence); - - createJson = env.json(createJson, Sig(sfCounterpartySignature, lender)); - env(createJson, Ter(tesSUCCESS)); - env.close(); - - auto const baseFee = env.current()->fees().base; - - auto const stateBefore = getCurrentState(env, broker, keylet); - BEAST_EXPECT(stateBefore.paymentRemaining == 5678); - BEAST_EXPECT(stateBefore.paymentRemaining > kLoanMaximumPaymentsPerTransaction); - - auto loanPayTx = env.json(pay(borrower, keylet.key, STAmount{broker.asset, Number{}})); - Number const amount{9924'81, -2}; - BEAST_EXPECT(to_string(amount) == "9924.81"); - XRPAmount const payFee{ - baseFee * (amount / stateBefore.periodicPayment / kLoanPaymentsPerFeeIncrement + 1)}; - loanPayTx["Amount"]["value"] = to_string(amount); - env(loanPayTx, Fee(payFee), Ter(tesSUCCESS)); - env.close(); - - auto const stateAfter = getCurrentState(env, broker, keylet); - BEAST_EXPECT( - stateAfter.paymentRemaining == - stateBefore.paymentRemaining - kLoanMaximumPaymentsPerTransaction); - } - - void - testLoanNextPaymentDueDateOverflow(FeatureBitset features) - { - // For FIND-013 - testcase << "Prevent nextPaymentDueDate overflow"; - - using namespace jtx; - using namespace std::chrono_literals; - using namespace Lending; - Env env{*this, features}; - - Account const issuer{"issuer"}; - Account const lender{"lender"}; - Account const borrower{"borrower"}; - - env.fund(XRP(1'000'000), issuer, lender, borrower); - env.close(); - - PrettyAsset const iouAsset = issuer[iouCurrency_]; - auto trustLenderTx = env.json(trust(lender, iouAsset(1'000'000'000))); - env(trustLenderTx); - auto trustBorrowerTx = env.json(trust(borrower, iouAsset(1'000'000'000))); - env(trustBorrowerTx); - auto payLenderTx = pay(issuer, lender, iouAsset(100'000'000)); - env(payLenderTx); - auto payIssuerTx = pay(issuer, borrower, iouAsset(10'000'000)); - env(payIssuerTx); - env.close(); - - BrokerParameters const brokerParams{.debtMax = Number{0}, .coverRateMin = TenthBips32{1}}; - BrokerInfo broker{createVaultAndBroker(env, iouAsset, lender, brokerParams)}; - - using namespace loan; - - auto const loanSetFee = Fee(env.current()->fees().base * 2); - - using timeType = decltype(sfNextPaymentDueDate)::type::value_type; - static_assert(std::is_same_v); - constexpr timeType kMaxTime = std::numeric_limits::max(); - static_assert(kMaxTime == 4'294'967'295); - - auto const baseJson = [&]() { - auto createJson = env.json( - set(borrower, broker.brokerID, Number{55524'81, -2}), - Fee(loanSetFee), - kClosePaymentFee(0), - kGracePeriod(LoanSet::kDefaultGracePeriod), - kInterestRate(TenthBips32(12833)), - kLateInterestRate(TenthBips32(77048)), - kLatePaymentFee(0), - kLoanOriginationFee(218), - Json(sfCounterpartySignature, json::ValueType::Object)); - - createJson.removeMember(sfSequence.getJsonName()); - - return createJson; - }(); - - auto const baseFee = env.current()->fees().base; - - auto parentCloseTime = [&]() { - return env.current()->parentCloseTime().time_since_epoch().count(); - }; - auto maxLoanTime = [&]() { - auto const startDate = parentCloseTime(); - - BEAST_EXPECT(startDate >= 50); - - return kMaxTime - startDate; - }; - - { - // straight-up overflow: interval - auto const interval = maxLoanTime() + 1; - auto const total = 1; - auto createJson = env.json(baseJson, kPaymentInterval(interval), kPaymentTotal(total)); - - env(createJson, Sig(sfCounterpartySignature, lender), Ter(tecKILLED)); - env.close(); - } - { - // straight-up overflow: total - // min interval is 60 - auto const interval = 60; - auto const total = maxLoanTime() + 1; - auto createJson = env.json(baseJson, kPaymentInterval(interval), kPaymentTotal(total)); - - env(createJson, Sig(sfCounterpartySignature, lender), Ter(tecKILLED)); - env.close(); - } - { - // straight-up overflow: grace period - // min interval is 60 - auto const interval = maxLoanTime() + 1; - auto const total = 1; - auto const grace = interval; - auto createJson = env.json( - baseJson, kPaymentInterval(interval), kPaymentTotal(total), kGracePeriod(grace)); - - // The grace period can't be larger than the interval. - env(createJson, Sig(sfCounterpartySignature, lender), Ter(tecKILLED)); - env.close(); - } - { - // Overflow with multiplication of a few large intervals - auto const interval = 1'000'000'000; - auto const total = 10; - auto createJson = env.json(baseJson, kPaymentInterval(interval), kPaymentTotal(total)); - - env(createJson, Sig(sfCounterpartySignature, lender), Ter(tecKILLED)); - env.close(); - } - { - // Overflow with multiplication of many small payments - // min interval is 60 - auto const interval = 60; - auto const total = 1'000'000'000; - auto createJson = env.json(baseJson, kPaymentInterval(interval), kPaymentTotal(total)); - - env(createJson, Sig(sfCounterpartySignature, lender), Ter(tecKILLED)); - env.close(); - } - { - // Overflow with an absurdly large grace period - // min interval is 60 - auto const total = 60; - auto const interval = (maxLoanTime() - total) / total; - auto const grace = interval; - auto createJson = env.json( - baseJson, kPaymentInterval(interval), kPaymentTotal(total), kGracePeriod(grace)); - - env(createJson, Sig(sfCounterpartySignature, lender), Ter(tecKILLED)); - env.close(); - } - { - // Start date when the ledger is closed will be larger - auto const brokerStateBefore = env.le(keylet::loanBroker(broker.brokerID)); - auto const loanSequence = brokerStateBefore->at(sfLoanSequence); - auto const keylet = keylet::loan(broker.brokerID, loanSequence); - - auto const grace = 100; - auto const interval = maxLoanTime() - grace; - auto const total = 1; - auto createJson = env.json( - baseJson, kPaymentInterval(interval), kPaymentTotal(total), kGracePeriod(grace)); - - env(createJson, Sig(sfCounterpartySignature, lender), Ter(tesSUCCESS)); - env.close(); - - // The transaction is killed in the closed ledger - auto const meta = env.meta(); - if (BEAST_EXPECT(meta)) - { - BEAST_EXPECT(meta->at(sfTransactionResult) == tecKILLED); - } - - // If the transaction had succeeded, the loan would exist - auto const loanSle = env.le(keylet); - // but it doesn't - BEAST_EXPECT(!loanSle); - } - { - // Start date when the ledger is closed will be larger - auto const brokerStateBefore = env.le(keylet::loanBroker(broker.brokerID)); - auto const loanSequence = brokerStateBefore->at(sfLoanSequence); - auto const keylet = keylet::loan(broker.brokerID, loanSequence); - - auto const closeStartDate = ((parentCloseTime() / 10) + 1) * 10; - auto const grace = 5'000; - auto const interval = kMaxTime - closeStartDate - grace; - auto const total = 1; - auto createJson = env.json( - baseJson, kPaymentInterval(interval), kPaymentTotal(total), kGracePeriod(grace)); - - env(createJson, Sig(sfCounterpartySignature, lender), Ter(tesSUCCESS)); - env.close(); - - // The transaction succeeds in the closed ledger - auto const meta = env.meta(); - if (BEAST_EXPECT(meta)) - { - BEAST_EXPECT(meta->at(sfTransactionResult) == tesSUCCESS); - } - - // This loan exists - auto const afterState = getCurrentState(env, broker, keylet); - BEAST_EXPECT(afterState.nextPaymentDate == kMaxTime - grace); - BEAST_EXPECT(afterState.previousPaymentDate == 0); - BEAST_EXPECT(afterState.paymentRemaining == 1); - } - - { - // Ensure the borrower has funds to pay back the loan - env(pay(issuer, borrower, iouAsset(Number{1'055'524'81, -2}))); - - // Start date when the ledger is closed will be larger - auto const closeStartDate = ((parentCloseTime() / 10) + 1) * 10; - auto const grace = 5'000; - auto const maxLoanTime = kMaxTime - closeStartDate - grace; - auto const total = [&]() { - if (maxLoanTime % 5 == 0) - return 5; - if (maxLoanTime % 3 == 0) - return 3; - if (maxLoanTime % 2 == 0) - return 2; - return 0; - }(); - if (!BEAST_EXPECT(total != 0)) - return; - - auto const brokerState = env.le(keylet::loanBroker(broker.brokerID)); - // Intentionally shadow the outer values - auto const loanSequence = brokerState->at(sfLoanSequence); - auto const keylet = keylet::loan(broker.brokerID, loanSequence); - - auto const interval = maxLoanTime / total; - auto createJson = env.json( - baseJson, kPaymentInterval(interval), kPaymentTotal(total), kGracePeriod(grace)); - - env(createJson, Sig(sfCounterpartySignature, lender), Ter(tesSUCCESS)); - env.close(); - - // This loan exists - auto const beforeState = getCurrentState(env, broker, keylet); - BEAST_EXPECT(beforeState.nextPaymentDate == closeStartDate + interval); - BEAST_EXPECT(beforeState.previousPaymentDate == 0); - BEAST_EXPECT(beforeState.paymentRemaining == total); - BEAST_EXPECT(beforeState.periodicPayment > 0); - - // pay all but the last payment - { - NumberRoundModeGuard const mg{Number::RoundingMode::Upward}; - Number const payment = beforeState.periodicPayment * (total - 1); - XRPAmount const payFee{baseFee * ((total - 1) / kLoanPaymentsPerFeeIncrement + 1)}; - STAmount const paymentAmount = - roundToScale(STAmount{broker.asset, payment}, beforeState.loanScale); - auto loanPayTx = env.json(pay(borrower, keylet.key, paymentAmount), Fee(payFee)); - env(loanPayTx, Ter(tesSUCCESS)); - env.close(); - } - - // The loan is on the last payment - auto const afterState = getCurrentState(env, broker, keylet); - BEAST_EXPECT(afterState.paymentRemaining == 1); - BEAST_EXPECT(afterState.nextPaymentDate == kMaxTime - grace); - BEAST_EXPECT(afterState.previousPaymentDate == kMaxTime - grace - interval); - } - } - - void - testRequireAuth() - { - testcase("Require Auth - Implicit Pseudo-account authorization"); - using namespace jtx; - using namespace loan; - Account const lender{"lender"}; - Account const issuer{"issuer"}; - Account const borrower{"borrower"}; - Env env(*this); - - env.fund(XRP(100'000), issuer, lender, borrower); - env.close(); - - auto asset = MPTTester({ - .env = env, - .issuer = issuer, - .holders = {lender, borrower}, - .flags = kMptDexFlags | tfMPTRequireAuth | tfMPTCanClawback | tfMPTCanLock, - .authHolder = true, - }); - - env(pay(issuer, lender, asset(5'000'000))); - BrokerInfo brokerInfo{createVaultAndBroker(env, asset, lender)}; - - auto const loanSetFee = Fee(env.current()->fees().base * 2); - STAmount const debtMaximumRequest = brokerInfo.asset(1'000).value(); - - auto forUnauthAuth = [&](auto&& doTx) { - for (auto const flag : {tfMPTUnauthorize, 0u}) - { - asset.authorize({.account = issuer, .holder = borrower, .flags = flag}); - env.close(); - doTx(flag == 0); - env.close(); - } - }; - - // Can't create a loan if the borrower is not authorized - forUnauthAuth([&](bool authorized) { - auto const err = !authorized ? Ter(tecNO_AUTH) : Ter(tesSUCCESS); - env(set(borrower, brokerInfo.brokerID, debtMaximumRequest), - Sig(sfCounterpartySignature, lender), - loanSetFee, - err); - }); - - static constexpr std::uint32_t kLoanSequence = 1; - auto const loanKeylet = keylet::loan(brokerInfo.brokerID, kLoanSequence); - - // Can't loan pay if the borrower is not authorized - forUnauthAuth([&](bool authorized) { - auto const err = !authorized ? Ter(tecNO_AUTH) : Ter(tesSUCCESS); - env(pay(borrower, loanKeylet.key, debtMaximumRequest), err); - }); - } - - void - testLendingCanTradeDisabledNoImpact() - { - testcase("Lending: CanTrade disabled has no impact"); - using namespace jtx; - using namespace loan; - using namespace loanBroker; - - Env env(*this, all_); - - Account const issuer{"issuer"}; - Account const lender{"lender"}; - Account const borrower{"borrower"}; - - env.fund(XRP(1'000'000), issuer, lender, borrower); - env.close(); - - MPTTester mpt( - {.env = env, - .issuer = issuer, - .holders = {lender, borrower}, - .flags = tfMPTCanTransfer | tfMPTCanLock, - .mutableFlags = tmfMPTCanEnableCanTrade}); - PrettyAsset const asset = mpt.issuanceID(); - env(pay(issuer, lender, asset(10'000'000))); - env(pay(issuer, borrower, asset(100'000))); - env.close(); - - auto const broker = createVaultAndBroker(env, asset, lender); - - // CanTrade is not set - env(offer(lender, XRP(1), asset(10)), Ter{tecNO_PERMISSION}); - env.close(); - - auto const loanSetFee = Fee(env.current()->fees().base * 2); - - // New cover deposits still work. - env(coverDeposit(lender, broker.brokerID, asset(100))); - env.close(); - - // New loan issuance still works. - env(loan::set(borrower, broker.brokerID, 1'000), - Sig(sfCounterpartySignature, lender), - loanSetFee); - env.close(); - auto const loanKeylet = keylet::loan(broker.brokerID, 1); - BEAST_EXPECT(env.le(loanKeylet)); - - // Repayment still works. - env(pay(borrower, loanKeylet.key, asset(1'000))); - env.close(); - - // Cover withdrawal still works. - env(coverWithdraw(lender, broker.brokerID, asset(100))); - env.close(); - - // Enable CanTrade and verify the DEX path is restored. - mpt.set({.mutableFlags = tmfMPTSetCanTrade}); - env.close(); - - env(offer(lender, XRP(1), asset(10))); - env.close(); - } - -#if LOAN_TODO - void - testLoanPayLateFullPaymentBypassesPenalties(FeatureBitset features) - { - testcase("LoanPay full payment skips late penalties"); - using namespace jtx; - using namespace loan; - using namespace std::chrono_literals; - - Env env(*this, features); - - Account const issuer{"issuer"}; - Account const lender{"lender"}; - Account const borrower{"borrower"}; - - env.fund(XRP(1'000'000), issuer, lender, borrower); - env.close(); - - PrettyAsset const asset = issuer[iouCurrency]; - env(trust(lender, asset(100'000'000))); - env(trust(borrower, asset(100'000'000))); - env(pay(issuer, lender, asset(50'000'000))); - env(pay(issuer, borrower, asset(5'000'000))); - env.close(); - - BrokerInfo broker{createVaultAndBroker(env, asset, lender)}; - - auto const loanSetFee = Fee(env.current()->fees().base * 2); - - auto const brokerPreLoan = env.le(keylet::loanBroker(broker.brokerID)); - if (BEAST_EXPECT(brokerPreLoan); !brokerPreLoan.has_value()) - return; - - auto const loanSequence = brokerPreLoan->at(sfLoanSequence); - auto const loanKeylet = keylet::loan(broker.brokerID, loanSequence); - - Number const principal = asset(1'000).value(); - Number const serviceFee = asset(2).value(); - Number const lateFee = asset(5).value(); - Number const closeFee = asset(4).value(); - - env(set(borrower, broker.brokerID, principal), - Sig(sfCounterpartySignature, lender), - kLoanServiceFee(serviceFee), - kLatePaymentFee(lateFee), - kClosePaymentFee(closeFee), - kInterestRate(percentageToTenthBips(12)), - kLateInterestRate(percentageToTenthBips(24) / 10), - kCloseInterestRate(percentageToTenthBips(5)), - kPaymentTotal(12), - kPaymentInterval(600), - kGracePeriod(0), - Fee(loanSetFee)); - env.close(); - - auto state1 = getCurrentState(env, broker, loanKeylet); - if (!BEAST_EXPECT(state1.paymentRemaining > 1)) - return; - - using d = NetClock::duration; - using tp = NetClock::time_point; - auto const overdueClose = tp{d{state1.nextPaymentDate + state1.paymentInterval}}; - env.close(overdueClose); - - auto const brokerSle = env.le(keylet::loanBroker(broker.brokerID)); - auto const loanSle = env.le(loanKeylet); - if (!BEAST_EXPECT(brokerSle && loanSle)) - return; - - auto state = getCurrentState(env, broker, loanKeylet); - - TenthBips16 const managementFeeRate{brokerSle->at(sfManagementFeeRate)}; - TenthBips32 const interestRateValue{loanSle->at(sfInterestRate)}; - TenthBips32 const lateInterestRateValue{loanSle->at(sfLateInterestRate)}; - TenthBips32 const closeInterestRateValue{loanSle->at(sfCloseInterestRate)}; - - Number const closePaymentFeeRounded = - roundToAsset(broker.asset, loanSle->at(sfClosePaymentFee), state.loanScale); - Number const latePaymentFeeRounded = - roundToAsset(broker.asset, loanSle->at(sfLatePaymentFee), state.loanScale); - - auto const roundedLoanState = constructLoanState( - state.totalValue, state.principalOutstanding, state.managementFeeOutstanding); - Number const totalInterestOutstanding = roundedLoanState.interestDue; - - auto const periodicRate = loanPeriodicRate(interestRateValue, state.paymentInterval); - auto const rawLoanState = computeTheoreticalLoanState( - env.current()->rules(), - state.periodicPayment, - periodicRate, - state.paymentRemaining, - managementFeeRate); - - auto const parentCloseTime = env.current()->parentCloseTime(); - auto const startDateSeconds = - static_cast(state.startDate.time_since_epoch().count()); - - Number const fullPaymentInterest = computeFullPaymentInterest( - rawLoanState.principalOutstanding, - periodicRate, - parentCloseTime, - state.paymentInterval, - state.previousPaymentDate, - startDateSeconds, - closeInterestRateValue); - - Number const roundedFullInterestAmount = - roundToAsset(broker.asset, fullPaymentInterest, state.loanScale); - Number const roundedFullManagementFee = computeManagementFee( - broker.asset, roundedFullInterestAmount, managementFeeRate, state.loanScale); - Number const roundedFullInterest = roundedFullInterestAmount - roundedFullManagementFee; - - Number const trackedValueDelta = - state.principalOutstanding + totalInterestOutstanding + state.managementFeeOutstanding; - Number const untrackedManagementFee = - closePaymentFeeRounded + roundedFullManagementFee - state.managementFeeOutstanding; - Number const untrackedInterest = roundedFullInterest - totalInterestOutstanding; - - Number const baseFullDue = trackedValueDelta + untrackedInterest + untrackedManagementFee; - BEAST_EXPECT(baseFullDue == roundToAsset(broker.asset, baseFullDue, state.loanScale)); - - auto const overdueSeconds = - parentCloseTime.time_since_epoch().count() - state.nextPaymentDate; - if (!BEAST_EXPECT(overdueSeconds > 0)) - return; - - Number const overdueRate = loanPeriodicRate(lateInterestRateValue, overdueSeconds); - Number const lateInterestRaw = state.principalOutstanding * overdueRate; - Number const lateInterestRounded = - roundToAsset(broker.asset, lateInterestRaw, state.loanScale); - Number const lateManagementFeeRounded = computeManagementFee( - broker.asset, lateInterestRounded, managementFeeRate, state.loanScale); - Number const penaltyDue = - lateInterestRounded + lateManagementFeeRounded + latePaymentFeeRounded; - BEAST_EXPECT(penaltyDue > Number{}); - - auto const balanceBefore = env.balance(borrower, broker.asset).number(); - - STAmount const paymentAmount{broker.asset.raw(), baseFullDue}; - env(pay(borrower, loanKeylet.key, paymentAmount, tfLoanFullPayment)); - env.close(); - - if (auto const meta = env.meta(); BEAST_EXPECT(meta)) - BEAST_EXPECT(meta->at(sfTransactionResult) == tesSUCCESS); - - auto const balanceAfter = env.balance(borrower, broker.asset).number(); - Number const actualPaid = balanceBefore - balanceAfter; - BEAST_EXPECT(actualPaid == baseFullDue); - - Number const expectedWithPenalty = baseFullDue + penaltyDue; - BEAST_EXPECT(expectedWithPenalty > actualPaid); - BEAST_EXPECT(expectedWithPenalty - actualPaid == penaltyDue); - } - - void - testLoanCoverMinimumRoundingExploit(FeatureBitset features) - { - auto testLoanCoverMinimumRoundingExploit = [&, this](Number const& principalRequest) { - testcase << "LoanBrokerCoverClawback drains cover via rounding" - << " principalRequested=" << to_string(principalRequest); - - using namespace jtx; - using namespace loan; - using namespace loanBroker; - - Env env(*this, features); - - Account const issuer{"issuer"}; - Account const lender{"lender"}; - Account const borrower{"borrower"}; - - env.fund(XRP(1'000'000'000), issuer, lender, borrower); - env.close(); - - env(fset(issuer, asfAllowTrustLineClawback)); - env.close(); - - PrettyAsset const asset = issuer[iouCurrency]; - env(trust(lender, asset(2'000'0000))); - env(trust(borrower, asset(2'000'0000))); - env.close(); - - env(pay(issuer, lender, asset(2'000'0000))); - env.close(); - - BrokerParameters brokerParams{.debtMax = 0, .coverRateMin = TenthBips32{10'000}}; - BrokerInfo broker{createVaultAndBroker(env, asset, lender, brokerParams)}; - - auto const loanSetFee = Fee(env.current()->fees().base * 2); - auto createTx = env.jt( - set(borrower, broker.brokerID, principalRequest), - Sig(sfCounterpartySignature, lender), - loanSetFee, - kPaymentInterval(600), - kPaymentTotal(1), - kGracePeriod(60)); - env(createTx); - env.close(); - - auto const brokerBefore = env.le(keylet::loanBroker(broker.brokerID)); - BEAST_EXPECT(brokerBefore); - if (!brokerBefore) - return; - - Number const debtOutstanding = brokerBefore->at(sfDebtTotal); - Number const coverAvailableBefore = brokerBefore->at(sfCoverAvailable); - - BEAST_EXPECT(debtOutstanding > Number{}); - BEAST_EXPECT(coverAvailableBefore > Number{}); - - log << "debt=" << to_string(debtOutstanding) - << " cover_available=" << to_string(coverAvailableBefore); - - env(coverClawback(issuer, 0), loanBrokerID(broker.brokerID)); - env.close(); - - auto const brokerAfter = env.le(keylet::loanBroker(broker.brokerID)); - BEAST_EXPECT(brokerAfter); - if (!brokerAfter) - return; - - Number const debtAfter = brokerAfter->at(sfDebtTotal); - // the debt has not changed - BEAST_EXPECT(debtAfter == debtOutstanding); - - Number const coverAvailableAfter = brokerAfter->at(sfCoverAvailable); - - // since the cover rate min != 0, the cover available should not - // be zero - BEAST_EXPECT(coverAvailableAfter != Number{}); - }; - - // Call the lambda with different principal values - testLoanCoverMinimumRoundingExploit(Number{1, -30}); // 1e-30 units - testLoanCoverMinimumRoundingExploit(Number{1, -20}); // 1e-20 units - testLoanCoverMinimumRoundingExploit(Number{1, -10}); // 1e-10 units - testLoanCoverMinimumRoundingExploit(Number{1, 1}); // 1e-10 units - } -#endif - - void - testPoCUnsignedUnderflowOnFullPayAfterEarlyPeriodic(FeatureBitset features) - { - // --- PoC Summary ---------------------------------------------------- - // Scenario: Borrower makes one periodic payment early (before next due) - // so doPayment sets sfPreviousPaymentDueDate to the (future) - // sfNextPaymentDueDate and advances sfNextPaymentDueDate by one - // interval. Borrower then immediately performs a full-payment - // (tfLoanFullPayment). Why it matters: Full-payment interest accrual - // uses - // delta = now - max(prevPaymentDate, startDate) - // with an unsigned clock representation (uint32). If prevPaymentDate is - // in the future, the subtraction underflows to a very large positive - // number. This inflates roundedFullInterest and total full-close due, - // and LoanPay applies the inflated valueChange to the vault - // (sfAssetsTotal), increasing NAV. - // -------------------------------------------------------------------- - testcase("PoC: Unsigned-underflow full-pay accrual after early periodic"); - - using namespace jtx; - using namespace loan; - using namespace std::chrono_literals; - - Env env{*this, features}; - - Account const lender{"poc_lender4"}; - Account const borrower{"poc_borrower4"}; - env.fund(XRP(3'000'000), lender, borrower); - env.close(); - - PrettyAsset const asset{xrpIssue(), 1'000'000}; - BrokerParameters const brokerParams{}; - auto const broker = createVaultAndBroker(env, asset, lender, brokerParams); - - // Create a 3-payment loan so full-payment path is enabled after 1 - // periodic payment. - auto const loanSetFee = Fee(env.current()->fees().base * 2); - Number const principalRequest = asset(1000).value(); - auto const originationFee = asset(0).value(); - auto const serviceFee = asset(1).value(); - auto const serviceFeePA = asset(1); - auto const lateFee = asset(0).value(); - auto const closeFee = asset(0).value(); - auto const interest = percentageToTenthBips(12); - auto const lateInterest = percentageToTenthBips(12) / 10; - auto const closeInterest = percentageToTenthBips(12) / 10; - auto const overpaymentInterest = percentageToTenthBips(12) / 10; - auto const total = 3u; - auto const interval = 600u; - auto const grace = 60u; - - auto createJtx = env.jt( - set(borrower, broker.brokerID, principalRequest, 0), - Sig(sfCounterpartySignature, lender), - kLoanOriginationFee(originationFee), - kLoanServiceFee(serviceFee), - kLatePaymentFee(lateFee), - kClosePaymentFee(closeFee), - kOverpaymentFee(percentageToTenthBips(5) / 10), - kInterestRate(interest), - kLateInterestRate(lateInterest), - kCloseInterestRate(closeInterest), - kOverpaymentInterestRate(overpaymentInterest), - kPaymentTotal(total), - kPaymentInterval(interval), - kGracePeriod(grace), - Fee(loanSetFee)); - - auto const brokerSle = env.le(keylet::loanBroker(broker.brokerID)); - BEAST_EXPECT(brokerSle); - auto const loanSequence = brokerSle ? brokerSle->at(sfLoanSequence) : 0; - auto const loanKeylet = keylet::loan(broker.brokerID, loanSequence); - - env(createJtx); - env.close(); - - // Compute a regular periodic due and pay it early (before next due). - auto state = getCurrentState(env, broker, loanKeylet); - Number const periodicRate = loanPeriodicRate(state.interestRate, state.paymentInterval); - auto const components = xrpl::detail::computePaymentComponents( - env.current()->rules(), - asset.raw(), - state.loanScale, - state.totalValue, - state.principalOutstanding, - state.managementFeeOutstanding, - state.periodicPayment, - periodicRate, - state.paymentRemaining, - brokerParams.managementFeeRate); - STAmount const regularDue{asset, components.trackedValueDelta + serviceFeePA.number()}; - // now < nextDue immediately after creation, so this is an early pay. - env(pay(borrower, loanKeylet.key, regularDue)); - env.close(); - - // Immediately attempt a full payoff. Compute the exact full-payment - // due to ensure the tx applies. - auto after = getCurrentState(env, broker, loanKeylet); - auto const loanSle = env.le(loanKeylet); - BEAST_EXPECT(loanSle); - auto const brokerSle2 = env.le(keylet::loanBroker(broker.brokerID)); - BEAST_EXPECT(brokerSle2); - - auto const closePaymentFee = loanSle ? loanSle->at(sfClosePaymentFee) : Number{}; - auto const closeInterestRate = - loanSle ? TenthBips32{loanSle->at(sfCloseInterestRate)} : TenthBips32{}; - auto const managementFeeRate = - brokerSle2 ? TenthBips16{brokerSle2->at(sfManagementFeeRate)} : TenthBips16{}; - - Number const periodicRate2 = loanPeriodicRate(after.interestRate, after.paymentInterval); - // Accrued + prepayment-penalty interest based on current periodic - // schedule - auto const fullPaymentInterest = computeFullPaymentInterest( - xrpl::detail::loanPrincipalFromPeriodicPayment( - env.current()->rules(), - after.periodicPayment, - periodicRate2, - after.paymentRemaining), - periodicRate2, - env.current()->parentCloseTime(), - after.paymentInterval, - after.previousPaymentDate, - static_cast(after.startDate.time_since_epoch().count()), - closeInterestRate); - - // Round to asset scale and split interest/fee parts - auto const roundedInterest = - roundToAsset(asset.raw(), fullPaymentInterest, after.loanScale); - Number const roundedFullMgmtFee = - computeManagementFee(asset.raw(), roundedInterest, managementFeeRate, after.loanScale); - Number const roundedFullInterest = roundedInterest - roundedFullMgmtFee; - - // Show both signed and unsigned deltas to highlight the underflow. - auto const nowSecs = - static_cast(env.current()->parentCloseTime().time_since_epoch().count()); - auto const startSecs = - static_cast(after.startDate.time_since_epoch().count()); - auto const lastPaymentDate = std::max(after.previousPaymentDate, startSecs); - auto const signedDelta = - static_cast(nowSecs) - static_cast(lastPaymentDate); - auto const unsignedDelta = static_cast(nowSecs - lastPaymentDate); - log << "PoC window: prev=" << after.previousPaymentDate << " start=" << startSecs - << " now=" << nowSecs << " signedDelta=" << signedDelta - << " unsignedDelta=" << unsignedDelta << std::endl; - - // Reference (clamped) computation: emulate a non-negative accrual - // window by clamping prevPaymentDate to 'now' for the full-pay path. - auto const prevClamped = std::min(after.previousPaymentDate, nowSecs); - auto const fullPaymentInterestClamped = computeFullPaymentInterest( - xrpl::detail::loanPrincipalFromPeriodicPayment( - env.current()->rules(), - after.periodicPayment, - periodicRate2, - after.paymentRemaining), - periodicRate2, - env.current()->parentCloseTime(), - after.paymentInterval, - prevClamped, - startSecs, - closeInterestRate); - auto const roundedInterestClamped = - roundToAsset(asset.raw(), fullPaymentInterestClamped, after.loanScale); - Number const roundedFullMgmtFeeClamped = computeManagementFee( - asset.raw(), roundedInterestClamped, managementFeeRate, after.loanScale); - Number const roundedFullInterestClamped = - roundedInterestClamped - roundedFullMgmtFeeClamped; - STAmount const fullDueClamped{ - asset, - after.principalOutstanding + roundedFullInterestClamped + roundedFullMgmtFeeClamped + - closePaymentFee}; - - // Collect vault NAV before closing payment - auto const vaultId2 = brokerSle2 ? brokerSle2->at(sfVaultID) : uint256{}; - auto const vaultKey2 = keylet::vault(vaultId2); - auto const vaultBefore = env.le(vaultKey2); - BEAST_EXPECT(vaultBefore); - Number const assetsTotalBefore = vaultBefore ? vaultBefore->at(sfAssetsTotal) : Number{}; - - STAmount const fullDue{ - asset, - after.principalOutstanding + roundedFullInterest + roundedFullMgmtFee + - closePaymentFee}; - - log << "PoC payoff: principalOutstanding=" << after.principalOutstanding - << " roundedFullInterest=" << roundedFullInterest - << " roundedFullMgmtFee=" << roundedFullMgmtFee << " closeFee=" << closePaymentFee - << " fullDue=" << to_string(fullDue.getJson()) << std::endl; - log << "PoC reference (clamped): roundedFullInterestClamped=" << roundedFullInterestClamped - << " roundedFullMgmtFeeClamped=" << roundedFullMgmtFeeClamped - << " fullDueClamped=" << to_string(fullDueClamped.getJson()) << std::endl; - - env(pay(borrower, loanKeylet.key, fullDue), Txflags(tfLoanFullPayment)); - env.close(); + // There are a lot of fields that can be set on a loan, but most + // of them only affect the "math" when a payment is made. The + // only one that really affects behavior is the + // `tfLoanOverpayment` flag. + lifecycle( + caseLabel, + "Loan overpayment allowed - Impair and Default", + env, + loanAmount, + interestExponent, + lender, + borrower, + evan, + broker, + pseudoAcct, + tfLoanOverpayment, + defaultImmediately(lsfLoanOverpayment)); - // Sanity: underflow present (unsigned delta very large relative to - // interval) - BEAST_EXPECT(unsignedDelta > after.paymentInterval); + lifecycle( + caseLabel, + "Loan overpayment prohibited - Impair and Default", + env, + loanAmount, + interestExponent, + lender, + borrower, + evan, + broker, + pseudoAcct, + 0, + defaultImmediately(0)); - // Compare vault NAV before/after the full close - auto const vaultAfter = env.le(vaultKey2); - BEAST_EXPECT(vaultAfter); - if (vaultAfter) - { - auto const assetsTotalAfter = vaultAfter->at(sfAssetsTotal); - log << "PoC NAV: assetsTotalBefore=" << assetsTotalBefore - << " assetsTotalAfter=" << assetsTotalAfter - << " delta=" << (assetsTotalAfter - assetsTotalBefore) << std::endl; - - // Value-based proof: underflowed window yields a payoff larger than - // the clamped (non-underflow) reference. - BEAST_EXPECT(fullDue == fullDueClamped); - if (fullDue > fullDueClamped) - log << "PoC delta: overcharge (fullDue > clamped)" << std::endl; - } + lifecycle( + caseLabel, + "Loan overpayment allowed - Default without Impair", + env, + loanAmount, + interestExponent, + lender, + borrower, + evan, + broker, + pseudoAcct, + tfLoanOverpayment, + defaultImmediately(lsfLoanOverpayment, false)); - // Loan should be paid off - auto const finalLoan = env.le(loanKeylet); - BEAST_EXPECT(finalLoan); - if (finalLoan) - { - BEAST_EXPECT(finalLoan->at(sfPaymentRemaining) == 0); - BEAST_EXPECT(finalLoan->at(sfPrincipalOutstanding) == 0); - } - } + lifecycle( + caseLabel, + "Loan overpayment prohibited - Default without Impair", + env, + loanAmount, + interestExponent, + lender, + borrower, + evan, + broker, + pseudoAcct, + 0, + defaultImmediately(0, false)); - void - testDustManipulation(FeatureBitset features) - { - testcase("Dust manipulation"); + lifecycle( + caseLabel, + "Loan overpayment prohibited - Pay off immediately", + env, + loanAmount, + interestExponent, + lender, + borrower, + evan, + broker, + pseudoAcct, + 0, + fullPayment(0)); - using namespace jtx; - using namespace std::chrono_literals; - Env env{*this, features}; + lifecycle( + caseLabel, + "Loan overpayment allowed - Pay off immediately", + env, + loanAmount, + interestExponent, + lender, + borrower, + evan, + broker, + pseudoAcct, + tfLoanOverpayment, + fullPayment(lsfLoanOverpayment)); - // Setup: Create accounts - Account const issuer{"issuer"}; - Account const lender{"lender"}; - Account const borrower{"borrower"}; - Account const victim{"victim"}; + lifecycle( + caseLabel, + "Loan overpayment prohibited - Combine all payments", + env, + loanAmount, + interestExponent, + lender, + borrower, + evan, + broker, + pseudoAcct, + 0, + combineAllPayments(0)); - env.fund(XRP(1'000'000'00), issuer, lender, borrower, victim); - env.close(); + lifecycle( + caseLabel, + "Loan overpayment allowed - Combine all payments", + env, + loanAmount, + interestExponent, + lender, + borrower, + evan, + broker, + pseudoAcct, + tfLoanOverpayment, + combineAllPayments(lsfLoanOverpayment)); - // Step 1: Create vault with IOU asset - auto asset = issuer["USD"]; - env(trust(lender, asset(100000))); - env(trust(borrower, asset(100000))); - env(trust(victim, asset(100000))); - env(pay(issuer, lender, asset(50000))); - env(pay(issuer, borrower, asset(50000))); - env(pay(issuer, victim, asset(50000))); - env.close(); + lifecycle( + caseLabel, + "Loan overpayment prohibited - Make payments", + env, + loanAmount, + interestExponent, + lender, + borrower, + evan, + broker, + pseudoAcct, + 0, + [&](Keylet const& loanKeylet, VerifyLoanStatus const& verifyLoanStatus) { + // toEndOfLife + // + // Draw and make multiple payments + auto state = getCurrentState(env, broker, loanKeylet, verifyLoanStatus); + BEAST_EXPECT(state.flags == 0); + env.close(); - BrokerParameters const brokerParams{ - .vaultDeposit = 10000, - .debtMax = Number{0}, - .coverRateMin = TenthBips32{1000}, - .coverRateLiquidation = TenthBips32{2500}}; + verifyLoanStatus(state); - auto broker = createVaultAndBroker(env, asset, lender, brokerParams); + env.close(state.startDate + 20s); + auto const loanAge = (env.now() - state.startDate).count(); + BEAST_EXPECT(loanAge == 30); - auto const loanKeyletOpt = [&]() -> std::optional { - auto const brokerSle = env.le(keylet::loanBroker(broker.brokerID)); - if (!BEAST_EXPECT(brokerSle)) - return std::nullopt; + // Periodic payment amount will consist of + // 1. principal outstanding (1000) + // 2. interest interest rate (at 12%) + // 3. payment interval (600s) + // 4. loan service fee (2) + // Calculate these values without the helper functions + // to verify they're working correctly The numbers in + // the below BEAST_EXPECTs may not hold across assets. + Number const interval = state.paymentInterval; + auto const periodicRate = interval * Number(12, -2) / kSecondsInYear; + BEAST_EXPECT( + periodicRate == Number(2283105022831050228, -24, Number::Normalized{})); + STAmount const roundedPeriodicPayment{ + broker.asset, + roundPeriodicPayment(broker.asset, state.periodicPayment, state.loanScale)}; - // Broker has no loans - BEAST_EXPECT(brokerSle->at(sfOwnerCount) == 0); + testcase << currencyLabel << " Payment components: " + << "Payments remaining, rawInterest, rawPrincipal, " + "rawMFee, trackedValueDelta, trackedPrincipalDelta, " + "trackedInterestDelta, trackedMgmtFeeDelta, special"; - // The loan keylet is based on the LoanSequence of the - // _LOAN_BROKER_ object. - auto const loanSequence = brokerSle->at(sfLoanSequence); - return keylet::loan(broker.brokerID, loanSequence); - }(); - if (!loanKeyletOpt) - return; + auto const serviceFee = broker.asset(2); - auto const& vaultKeylet = broker.vaultKeylet(); + BEAST_EXPECT( + roundedPeriodicPayment == + roundToScale( + broker.asset( + Number(8333457002039338267, -17), Number::RoundingMode::Upward), + state.loanScale, + Number::RoundingMode::Upward)); + // 83334570.01162141 + // Include the service fee + STAmount const totalDue = roundToScale( + roundedPeriodicPayment + serviceFee, + state.loanScale, + Number::RoundingMode::Upward); + // Only check the first payment since the rounding + // may drift as payments are made + BEAST_EXPECT( + totalDue == + roundToScale( + broker.asset( + Number(8533457002039338267, -17), Number::RoundingMode::Upward), + state.loanScale, + Number::RoundingMode::Upward)); - { - auto const vaultSle = env.le(vaultKeylet); - Number const assetsTotal = vaultSle->at(sfAssetsTotal); - Number const assetsAvail = vaultSle->at(sfAssetsAvailable); - - log << "Before loan creation:" << std::endl; - log << " AssetsTotal: " << assetsTotal << std::endl; - log << " AssetsAvailable: " << assetsAvail << std::endl; - log << " Difference: " << (assetsTotal - assetsAvail) << std::endl; - - // before the loan the assets total and available should be equal - BEAST_EXPECT(assetsAvail == assetsTotal); - BEAST_EXPECT(assetsAvail == broker.asset(brokerParams.vaultDeposit).number()); - } + { + auto const raw = computeTheoreticalLoanState( + env.current()->rules(), + state.periodicPayment, + periodicRate, + state.paymentRemaining, + broker.params.managementFeeRate); + auto const rounded = constructLoanState( + state.totalValue, + state.principalOutstanding, + state.managementFeeOutstanding); + testcase << currencyLabel << " Loan starting state: " << state.paymentRemaining + << ", " << raw.interestDue << ", " << raw.principalOutstanding << ", " + << raw.managementFeeDue << ", " << rounded.valueOutstanding << ", " + << rounded.principalOutstanding << ", " << rounded.interestDue << ", " + << rounded.managementFeeDue; + } - Keylet const& loanKeylet = *loanKeyletOpt; + // Try to pay a little extra to show that it's _not_ + // taken + STAmount const transactionAmount = + STAmount{broker.asset, totalDue} + broker.asset(10); + // Only check the first payment since the rounding + // may drift as payments are made + BEAST_EXPECT( + transactionAmount == + roundToScale( + broker.asset(Number(9533457002039400, -14), Number::RoundingMode::Upward), + state.loanScale, + Number::RoundingMode::Upward)); - LoanParameters const loanParams{ - .account = lender, - .counter = borrower, - .principalRequest = Number{100}, - .interest = TenthBips32{1922}, - .payTotal = 5816, - .payInterval = 86400 * 6, - .gracePd = 86400 * 5, - }; + auto const initialState = state; + xrpl::detail::PaymentComponents totalPaid{ + .trackedValueDelta = 0, + .trackedPrincipalDelta = 0, + .trackedManagementFeeDelta = 0}; + Number totalInterestPaid = 0; + std::size_t totalPaymentsMade = 0; - env(loanParams(env, broker)); - env.close(); + xrpl::LoanState currentTrueState = computeTheoreticalLoanState( + env.current()->rules(), + state.periodicPayment, + periodicRate, + state.paymentRemaining, + broker.params.managementFeeRate); - // Wait for loan to be late enough to default - env.close(std::chrono::seconds(86400 * 40)); // 40 days + while (state.paymentRemaining > 0) + { + // Compute the expected principal amount + auto const paymentComponents = xrpl::detail::computePaymentComponents( + env.current()->rules(), + broker.asset.raw(), + state.loanScale, + state.totalValue, + state.principalOutstanding, + state.managementFeeOutstanding, + state.periodicPayment, + periodicRate, + state.paymentRemaining, + broker.params.managementFeeRate); - { - auto const vaultSle = env.le(vaultKeylet); - Number const assetsTotal = vaultSle->at(sfAssetsTotal); - Number const assetsAvail = vaultSle->at(sfAssetsAvailable); + BEAST_EXPECTS( + paymentComponents.specialCase == xrpl::detail::PaymentSpecialCase::Final || + paymentComponents.trackedValueDelta <= roundedPeriodicPayment, + "Delta: " + to_string(paymentComponents.trackedValueDelta) + + ", periodic payment: " + to_string(roundedPeriodicPayment)); - log << "After loan creation:" << std::endl; - log << " AssetsTotal: " << assetsTotal << std::endl; - log << " AssetsAvailable: " << assetsAvail << std::endl; - log << " Difference: " << (assetsTotal - assetsAvail) << std::endl; + xrpl::LoanState const nextTrueState = computeTheoreticalLoanState( + env.current()->rules(), + state.periodicPayment, + periodicRate, + state.paymentRemaining - 1, + broker.params.managementFeeRate); + xrpl::detail::LoanStateDeltas const deltas = currentTrueState - nextTrueState; - auto const loanSle = env.le(loanKeylet); - if (!BEAST_EXPECT(loanSle)) - return; - auto const state = constructLoanState(loanSle); - - log << "Loan state:" << std::endl; - log << " ValueOutstanding: " << state.valueOutstanding << std::endl; - log << " PrincipalOutstanding: " << state.principalOutstanding << std::endl; - log << " InterestOutstanding: " << state.interestOutstanding() << std::endl; - log << " InterestDue: " << state.interestDue << std::endl; - log << " FeeDue: " << state.managementFeeDue << std::endl; - - // after loan creation the assets total and available should - // reflect the value of the loan - BEAST_EXPECT(assetsAvail < assetsTotal); - BEAST_EXPECT( - assetsAvail == - broker.asset(brokerParams.vaultDeposit - loanParams.principalRequest).number()); - BEAST_EXPECT( - assetsTotal == - broker.asset(brokerParams.vaultDeposit + state.interestDue).number()); - } + testcase << currencyLabel << " Payment components: " << state.paymentRemaining + << ", " << deltas.interest << ", " << deltas.principal << ", " + << deltas.managementFee << ", " << paymentComponents.trackedValueDelta + << ", " << paymentComponents.trackedPrincipalDelta << ", " + << paymentComponents.trackedInterestPart() << ", " + << paymentComponents.trackedManagementFeeDelta << ", " + << [&]() -> char const* { + if (paymentComponents.specialCase == + ::xrpl::detail::PaymentSpecialCase::Final) + return "final"; + if (paymentComponents.specialCase == + ::xrpl::detail::PaymentSpecialCase::Extra) + return "extra"; + return "none"; + }(); - // Step 7: Trigger default (dust adjustment will occur) - env(jtx::loan::manage(lender, loanKeylet.key, tfLoanDefault)); - env.close(); + auto const totalDueAmount = STAmount{ + broker.asset, paymentComponents.trackedValueDelta + serviceFee.number()}; - // Step 8: Verify phantom assets created - { - auto const vaultSle2 = env.le(vaultKeylet); - Number const assetsTotal2 = vaultSle2->at(sfAssetsTotal); - Number const assetsAvail2 = vaultSle2->at(sfAssetsAvailable); + // Due to the rounding algorithms to keep the interest and + // principal in sync with "true" values, the computed amount + // may be a little less than the rounded fixed payment + // amount. For integral types, the difference should be < 3 + // (1 unit for each of the interest and management fee). For + // IOUs, the difference should be after the 8th digit. + Number const diff = totalDue - totalDueAmount; + BEAST_EXPECT( + paymentComponents.specialCase == xrpl::detail::PaymentSpecialCase::Final || + diff == beast::kZero || + (diff > beast::kZero && + ((broker.asset.integral() && (static_cast(diff) < 3)) || + (state.loanScale - diff.exponent() > 13)))); - log << "After default:" << std::endl; - log << " AssetsTotal: " << assetsTotal2 << std::endl; - log << " AssetsAvailable: " << assetsAvail2 << std::endl; - log << " Difference: " << (assetsTotal2 - assetsAvail2) << std::endl; + BEAST_EXPECT( + paymentComponents.trackedValueDelta == + paymentComponents.trackedPrincipalDelta + + paymentComponents.trackedInterestPart() + + paymentComponents.trackedManagementFeeDelta); + BEAST_EXPECT( + paymentComponents.specialCase == xrpl::detail::PaymentSpecialCase::Final || + paymentComponents.trackedValueDelta <= roundedPeriodicPayment); - // after a default the assets total and available should be equal - BEAST_EXPECT(assetsAvail2 == assetsTotal2); - } - } + BEAST_EXPECT( + state.paymentRemaining < 12 || + roundToAsset( + broker.asset, + deltas.principal, + state.loanScale, + Number::RoundingMode::Upward) == + roundToScale( + broker.asset( + Number(8333228691531218890, -17), Number::RoundingMode::Upward), + state.loanScale, + Number::RoundingMode::Upward)); + BEAST_EXPECT( + paymentComponents.trackedPrincipalDelta >= beast::kZero && + paymentComponents.trackedPrincipalDelta <= state.principalOutstanding); + BEAST_EXPECT( + paymentComponents.specialCase != xrpl::detail::PaymentSpecialCase::Final || + paymentComponents.trackedPrincipalDelta == state.principalOutstanding); + BEAST_EXPECT( + paymentComponents.specialCase == xrpl::detail::PaymentSpecialCase::Final || + (state.periodicPayment.exponent() - + (deltas.principal + deltas.interest + deltas.managementFee - + state.periodicPayment) + .exponent()) > 14); - void - testRIPD3831(FeatureBitset features) - { - using namespace jtx; + auto const borrowerBalanceBeforePayment = env.balance(borrower, broker.asset); - testcase("RIPD-3831"); + if (canImpairLoan(env, broker, state)) + { + // Making a payment will unimpair the loan + env(manage(lender, loanKeylet.key, tfLoanImpair)); + } - Account const issuer("issuer"); - Account const lender("lender"); - Account const borrower("borrower"); + env.close(); - BrokerParameters const brokerParams{ - .vaultDeposit = 100000, - .debtMax = 0, - .coverRateMin = TenthBips32{0}, - // .managementFeeRate = TenthBips16{5919}, - .coverRateLiquidation = TenthBips32{0}}; - LoanParameters const loanParams{ - .account = lender, - .counter = borrower, - .principalRequest = Number{200'000, -6}, - .lateFee = Number{200, -6}, - .interest = TenthBips32{50'000}, - .payTotal = 10, - .payInterval = 150}; + // Make the payment + env(pay(borrower, loanKeylet.key, transactionAmount)); - auto const assetType = AssetType::XRP; + env.close(); - Env env{*this, features}; + // Need to account for fees if the loan is in XRP + PrettyAmount adjustment = broker.asset(0); + if (broker.asset.native()) + { + adjustment = env.current()->fees().base; + } - auto loanResult = - createLoan(env, assetType, brokerParams, loanParams, issuer, lender, borrower); + // Check the result + verifyLoanStatus.checkPayment( + state.loanScale, + borrower, + borrowerBalanceBeforePayment, + totalDueAmount, + adjustment); - if (BEAST_EXPECT(loanResult); !loanResult.has_value()) - return; + --state.paymentRemaining; + state.previousPaymentDate = state.nextPaymentDate; + if (paymentComponents.specialCase == xrpl::detail::PaymentSpecialCase::Final) + { + state.paymentRemaining = 0; + state.nextPaymentDate = 0; + } + else + { + state.nextPaymentDate += state.paymentInterval; + } + state.principalOutstanding -= paymentComponents.trackedPrincipalDelta; + state.managementFeeOutstanding -= paymentComponents.trackedManagementFeeDelta; + state.totalValue -= paymentComponents.trackedValueDelta; - auto broker = std::get(*loanResult); - auto loanKeylet = std::get(*loanResult); + verifyLoanStatus(state); - using tp = NetClock::time_point; - using d = NetClock::duration; + totalPaid.trackedValueDelta += paymentComponents.trackedValueDelta; + totalPaid.trackedPrincipalDelta += paymentComponents.trackedPrincipalDelta; + totalPaid.trackedManagementFeeDelta += + paymentComponents.trackedManagementFeeDelta; + totalInterestPaid += paymentComponents.trackedInterestPart(); + ++totalPaymentsMade; - auto state = getCurrentState(env, broker, loanKeylet); - if (auto loan = env.le(loanKeylet); BEAST_EXPECT(loan)) - { - env.close(tp{d{loan->at(sfNextPaymentDueDate) + loan->at(sfGracePeriod) + 1}}); - } + currentTrueState = nextTrueState; + } - topUpBorrower(env, broker, issuer, borrower, state, loanParams.serviceFee); + // Loan is paid off + BEAST_EXPECT(state.paymentRemaining == 0); + BEAST_EXPECT(state.principalOutstanding == 0); - using namespace jtx::loan; + // Make sure all the payments add up + BEAST_EXPECT(totalPaid.trackedValueDelta == initialState.totalValue); + BEAST_EXPECT(totalPaid.trackedPrincipalDelta == initialState.principalOutstanding); + BEAST_EXPECT( + totalPaid.trackedManagementFeeDelta == initialState.managementFeeOutstanding); + // This is almost a tautology given the previous checks, but + // check it anyway for completeness. + BEAST_EXPECT( + totalInterestPaid == + initialState.totalValue - + (initialState.principalOutstanding + + initialState.managementFeeOutstanding)); + BEAST_EXPECT(totalPaymentsMade == initialState.paymentRemaining); - auto jv = pay(borrower, loanKeylet.key, drops(XRPAmount(state.totalValue))); + // Can't impair or default a paid off loan + env(manage(lender, loanKeylet.key, tfLoanImpair), Ter(tecNO_PERMISSION)); + env(manage(lender, loanKeylet.key, tfLoanDefault), Ter(tecNO_PERMISSION)); + }); - { - auto const submitParam = to_string(jv); - auto const jr = env.rpc("submit", borrower.name(), submitParam); +#if LOAN_TODO + // TODO - BEAST_EXPECT(jr.isMember(jss::result)); - auto const jResult = jr[jss::result]; - } + /* + LoanPay fails with tecINVARIANT_FAILED error when loan_broker(also + borrower) tries to do the payment. Here's the scenario: Create a XRP + loan with loan broker as borrower, loan origination fee and loan service + fee. Loan broker makes the first payment with periodic payment and loan + service fee. + */ - env.close(); + auto time = [&](std::string label, std::function timed) { + if (!BEAST_EXPECT(timed)) + return; - // Make sure the system keeps responding - env(noop(borrower)); - env.close(); - env(noop(issuer)); - env.close(); - env(noop(lender)); - env.close(); - } + using clock_type = std::chrono::steady_clock; + using duration_type = std::chrono::milliseconds; - void - testRIPD3459(FeatureBitset features) - { - testcase("RIPD-3459 - LoanBroker incorrect debt total"); + auto const start = clock_type::now(); + timed(); + auto const duration = + std::chrono::duration_cast(clock_type::now() - start); - using namespace jtx; + log << label << " took " << duration.count() << "ms" << std::endl; - Account const issuer("issuer"); - Account const lender("lender"); - Account const borrower("borrower"); + return duration; + }; - BrokerParameters const brokerParams{ - .vaultDeposit = 200'000, - .debtMax = 0, - .coverRateMin = TenthBips32{0}, - .managementFeeRate = TenthBips16{500}, - .coverRateLiquidation = TenthBips32{0}}; - LoanParameters const loanParams{ - .account = lender, - .counter = borrower, - .principalRequest = Number{100'000, -4}, - .interest = TenthBips32{100'000}, - .payTotal = 10}; + lifecycle( + caseLabel, + "timing", + env, + loanAmount, + interestExponent, + lender, + borrower, + evan, + broker, + pseudoAcct, + tfLoanOverpayment, + [&](Keylet const& loanKeylet, VerifyLoanStatus const& verifyLoanStatus) { + // Estimate optimal values for kLoanPaymentsPerFeeIncrement and + // kLoanMaximumPaymentsPerTransaction. + using namespace loan; - auto const assetType = AssetType::MPT; + auto const state = getCurrentState(env, broker, verifyLoanStatus.keylet); + auto const serviceFee = broker.asset(2).value(); - Env env{*this, features}; + STAmount const totalDue{ + broker.asset, + roundPeriodicPayment( + broker.asset, state.periodicPayment + serviceFee, state.loanScale)}; - auto loanResult = - createLoan(env, assetType, brokerParams, loanParams, issuer, lender, borrower); + // Make a single payment + time("single payment", [&]() { env(pay(borrower, loanKeylet.key, totalDue)); }); + env.close(); - if (BEAST_EXPECT(loanResult); !loanResult.has_value()) - return; + // Make all but the final payment + auto const numPayments = (state.paymentRemaining - 2); + STAmount const bigPayment{broker.asset, totalDue * numPayments}; + XRPAmount const bigFee{baseFee * (numPayments / kLoanPaymentsPerFeeIncrement + 1)}; + time("ten payments", [&]() { + env(pay(borrower, loanKeylet.key, bigPayment), Fee(bigFee)); + }); + env.close(); - auto broker = std::get(*loanResult); - auto loanKeylet = std::get(*loanResult); - auto pseudoAcct = std::get(*loanResult); + time("final payment", [&]() { + // Make the final payment + env(pay(borrower, loanKeylet.key, totalDue + STAmount{broker.asset, 1})); + }); + env.close(); + }); - VerifyLoanStatus const verifyLoanStatus(env, broker, pseudoAcct, loanKeylet); + lifecycle( + caseLabel, + "Loan overpayment allowed - Explicit overpayment", + env, + loanAmount, + interestExponent, + lender, + borrower, + evan, + broker, + pseudoAcct, + tfLoanOverpayment, + [&](Keylet const& loanKeylet, VerifyLoanStatus const& verifyLoanStatus) { throw 0; }); - if (auto const brokerSle = env.le(broker.brokerKeylet()); BEAST_EXPECT(brokerSle)) - { - if (auto const loanSle = env.le(loanKeylet); BEAST_EXPECT(loanSle)) - { - BEAST_EXPECT(brokerSle->at(sfDebtTotal) == loanSle->at(sfTotalValueOutstanding)); - } - } + lifecycle( + caseLabel, + "Loan overpayment prohibited - Late payment", + env, + loanAmount, + interestExponent, + lender, + borrower, + evan, + broker, + pseudoAcct, + tfLoanOverpayment, + [&](Keylet const& loanKeylet, VerifyLoanStatus const& verifyLoanStatus) { throw 0; }); - makeLoanPayments( + lifecycle( + caseLabel, + "Loan overpayment allowed - Late payment", env, + loanAmount, + interestExponent, + lender, + borrower, + evan, broker, - loanParams, - loanKeylet, - verifyLoanStatus, - issuer, + pseudoAcct, + tfLoanOverpayment, + [&](Keylet const& loanKeylet, VerifyLoanStatus const& verifyLoanStatus) { throw 0; }); + + lifecycle( + caseLabel, + "Loan overpayment allowed - Late payment and overpayment", + env, + loanAmount, + interestExponent, lender, borrower, - PaymentParameters{.showStepBalances = true}); + evan, + broker, + pseudoAcct, + tfLoanOverpayment, + [&](Keylet const& loanKeylet, VerifyLoanStatus const& verifyLoanStatus) { throw 0; }); - if (auto const brokerSle = env.le(broker.brokerKeylet()); BEAST_EXPECT(brokerSle)) - { - if (auto const loanSle = env.le(loanKeylet); BEAST_EXPECT(loanSle)) - { - BEAST_EXPECT(brokerSle->at(sfDebtTotal) == loanSle->at(sfTotalValueOutstanding)); - BEAST_EXPECT(brokerSle->at(sfDebtTotal) == beast::kZero); - } - } +#endif } void - testRIPD3901() + testLoanSet(FeatureBitset features) { - testcase("Crash with tfLoanOverpayment"); using namespace jtx; - using namespace loan; - Account const lender{"lender"}; + Account const issuer{"issuer"}; + Account const lender{"lender"}; Account const borrower{"borrower"}; - Account const depositor{"depositor"}; - auto const txFee = Fee(XRP(100)); - - Env env(*this); - Vault const vault(env); - - env.fund(XRP(10'000), lender, issuer, borrower, depositor); - env.close(); - - auto [tx, vaultKeyLet] = vault.create({.owner = lender, .asset = xrpIssue()}); - env(tx, txFee); - env.close(); - - env(vault.deposit({.depositor = depositor, .id = vaultKeyLet.key, .amount = XRP(1'000)}), - txFee); - env.close(); - - auto const brokerKeyLet = keylet::loanBroker(lender.id(), env.seq(lender)); - - env(loanBroker::set(lender, vaultKeyLet.key), txFee); - env.close(); - - // BrokerInfo brokerInfo{xrpIssue(), keylet, vaultKeyLet, {}}; - - STAmount const debtMaximumRequest = XRPAmount(200'000); - - env(set(borrower, brokerKeyLet.key, debtMaximumRequest), - Sig(sfCounterpartySignature, lender), - kInterestRate(TenthBips32(50'000)), - kPaymentTotal(2), - kPaymentInterval(150), - Txflags(tfLoanOverpayment), - txFee); - env.close(); - - std::uint32_t const loanSequence = 1; - auto const loanKeylet = keylet::loan(brokerKeyLet.key, loanSequence); - if (auto loan = env.le(loanKeylet); env.test.BEAST_EXPECT(loan)) + struct CaseArgs { - env(loan::pay(borrower, loanKeylet.key, XRPAmount(150'001)), - Txflags(tfLoanOverpayment), - txFee); + bool requireAuth = false; + bool authorizeBorrower = false; + int initialXRP = 1'000'000; + }; + + auto const testCase = [&, this]( + std::function mptTest, + std::function iouTest, + CaseArgs args = {}) { + Env env(*this, features); + env.fund(XRP(args.initialXRP), issuer, lender, borrower); env.close(); - } - } + if (args.requireAuth) + { + env(fset(issuer, asfRequireAuth)); + env.close(); + } - void - testRoundingAllowsUndercoverage(FeatureBitset features) - { - testcase("Minimum cover rounding allows undercoverage (XRP)"); + // We need two different asset types, MPT and IOU. Prepare MPT + // first + MPTTester mptt{env, issuer, kMptInitNoFund}; - using namespace jtx; - using namespace loanBroker; + auto const kNone = LedgerSpecificFlags(0); + mptt.create( + {.flags = tfMPTCanTransfer | tfMPTCanLock | + (args.requireAuth ? tfMPTRequireAuth : kNone)}); + env.close(); + PrettyAsset const mptAsset = mptt.issuanceID(); + mptt.authorize({.account = lender}); + mptt.authorize({.account = borrower}); + env.close(); + if (args.requireAuth) + { + mptt.authorize({.account = issuer, .holder = lender}); + if (args.authorizeBorrower) + mptt.authorize({.account = issuer, .holder = borrower}); + env.close(); + } - Env env{*this, features}; + env(pay(issuer, lender, mptAsset(10'000'000))); + env.close(); - Account const lender{"lender"}; - Account const borrower{"borrower"}; + // Prepare IOU + PrettyAsset const iouAsset = issuer[iouCurrency_]; + env(trust(lender, iouAsset(10'000'000))); + env(trust(borrower, iouAsset(10'000'000))); + env.close(); + if (args.requireAuth) + { + env(trust(issuer, iouAsset(0), lender, tfSetfAuth)); + env(pay(issuer, lender, iouAsset(10'000'000))); + if (args.authorizeBorrower) + { + env(trust(issuer, iouAsset(0), borrower, tfSetfAuth)); + env(pay(issuer, borrower, iouAsset(10'000))); + } + } + else + { + env(pay(issuer, lender, iouAsset(10'000'000))); + env(pay(issuer, borrower, iouAsset(10'000))); + } + env.close(); - env.fund(XRP(200'000), lender, borrower); - env.close(); + // Create vaults and loan brokers + std::array const assets{mptAsset, iouAsset}; + std::vector brokers; + brokers.reserve(assets.size()); + for (auto const& asset : assets) + { + brokers.emplace_back(createVaultAndBroker(env, asset, lender)); + } - // Vault with XRP asset - Vault const vault{env}; - auto [vaultCreate, vaultKeylet] = vault.create({.owner = lender, .asset = xrpIssue()}); - env(vaultCreate); - env.close(); - BEAST_EXPECT(env.le(vaultKeylet)); + if (mptTest) + mptTest(env, brokers[0], mptt); + if (iouTest) + iouTest(env, brokers[1]); + }; - // Seed the vault with XRP so it can fund the loan principal - PrettyAsset const xrpAsset{xrpIssue(), 1}; + testCase( + [&, this](Env& env, BrokerInfo const& broker, auto&) { + using namespace loan; + Number const principalRequest = broker.asset(1'000).value(); - BrokerParameters const brokerParams{ - .vaultDeposit = 1'000, - .debtMax = Number{0}, - .coverRateMin = TenthBips32{10'000}, - .coverDeposit = 82, - }; + testcase("MPT issuer is borrower, issuer submits"); + env(set(issuer, broker.brokerID, principalRequest), + kCounterparty(lender), + Sig(sfCounterpartySignature, lender), + Fee(env.current()->fees().base * 5)); - auto const brokerInfo = createVaultAndBroker(env, xrpAsset, lender, brokerParams); - // Create a loan with principal 804 XRP and 0% interest (so - // DebtTotal increases by exactly 804) - env(loan::set(borrower, brokerInfo.brokerID, xrpAsset(804).value()), - loan::kInterestRate(TenthBips32(0)), - Sig(sfCounterpartySignature, lender), - Fee(env.current()->fees().base * 2)); - BEAST_EXPECT(env.ter() == tesSUCCESS); - env.close(); + testcase("MPT issuer is borrower, lender submits"); + env(set(lender, broker.brokerID, principalRequest), + kCounterparty(issuer), + Sig(sfCounterpartySignature, issuer), + Fee(env.current()->fees().base * 5)); + }, + [&, this](Env& env, BrokerInfo const& broker) { + using namespace loan; + Number const principalRequest = broker.asset(1'000).value(); - // Verify DebtTotal is exactly 804 - if (auto const brokerSle = env.le(keylet::loanBroker(brokerInfo.brokerID)); - BEAST_EXPECT(brokerSle)) - { - log << *brokerSle << std::endl; - BEAST_EXPECT(brokerSle->at(sfDebtTotal) == Number(804)); - } + testcase("IOU issuer is borrower, issuer submits"); + env(set(issuer, broker.brokerID, principalRequest), + kCounterparty(lender), + Sig(sfCounterpartySignature, lender), + Fee(env.current()->fees().base * 5)); - // Attempt to withdraw 2 XRP to self, leaving 80 XRP CoverAvailable. - // The minimum is 80.4 XRP, which rounds up to 81 XRP, so this fails. - env(coverWithdraw(lender, brokerInfo.brokerID, xrpAsset(2).value()), - Ter(tecINSUFFICIENT_FUNDS)); - BEAST_EXPECT(env.ter() == tecINSUFFICIENT_FUNDS); - env.close(); + testcase("IOU issuer is borrower, lender submits"); + env(set(lender, broker.brokerID, principalRequest), + kCounterparty(issuer), + Sig(sfCounterpartySignature, issuer), + Fee(env.current()->fees().base * 5)); + }, + CaseArgs{.requireAuth = true}); - // Attempt to withdraw 1 XRP to self, leaving 81 XRP CoverAvailable. - // because that leaves sufficient cover, this succeeds - env(coverWithdraw(lender, brokerInfo.brokerID, xrpAsset(1).value())); - BEAST_EXPECT(env.ter() == tesSUCCESS); - env.close(); + testCase( + [&, this](Env& env, BrokerInfo const& broker, auto&) { + using namespace loan; + Number const principalRequest = broker.asset(1'000).value(); - // Validate CoverAvailable == 80 XRP and DebtTotal remains 804 - if (auto const brokerSle = env.le(keylet::loanBroker(brokerInfo.brokerID)); - BEAST_EXPECT(brokerSle)) - { - log << *brokerSle << std::endl; - BEAST_EXPECT(brokerSle->at(sfCoverAvailable) == xrpAsset(81).value()); - BEAST_EXPECT(brokerSle->at(sfDebtTotal) == Number(804)); - - // Also demonstrate that the true minimum (804 * 10%) exceeds 80 - auto const theoreticalMin = tenthBipsOfValue(Number(804), TenthBips32(10'000)); - log << "Theoretical min cover: " << theoreticalMin << std::endl; - BEAST_EXPECT(Number(804, -1) == theoreticalMin); - } - } + testcase("MPT unauthorized borrower, borrower submits"); + env(set(borrower, broker.brokerID, principalRequest), + kCounterparty(lender), + Sig(sfCounterpartySignature, lender), + Fee(env.current()->fees().base * 5), + Ter{tecNO_AUTH}); - void - testRIPD3902(FeatureBitset features) - { - testcase("RIPD-3902 - 1 IOU loan payments"); + testcase("MPT unauthorized borrower, lender submits"); + env(set(lender, broker.brokerID, principalRequest), + kCounterparty(borrower), + Sig(sfCounterpartySignature, borrower), + Fee(env.current()->fees().base * 5), + Ter{tecNO_AUTH}); + }, + [&, this](Env& env, BrokerInfo const& broker) { + using namespace loan; + Number const principalRequest = broker.asset(1'000).value(); - using namespace jtx; + testcase("IOU unauthorized borrower, borrower submits"); + env(set(borrower, broker.brokerID, principalRequest), + kCounterparty(lender), + Sig(sfCounterpartySignature, lender), + Fee(env.current()->fees().base * 5), + Ter{tecNO_AUTH}); - Account const issuer("issuer"); - Account const lender("lender"); - Account const borrower("borrower"); + testcase("IOU unauthorized borrower, lender submits"); + env(set(lender, broker.brokerID, principalRequest), + kCounterparty(borrower), + Sig(sfCounterpartySignature, borrower), + Fee(env.current()->fees().base * 5), + Ter{tecNO_AUTH}); + }, + CaseArgs{.requireAuth = true}); - BrokerParameters const brokerParams{ - .vaultDeposit = 10, - .debtMax = 0, - .coverRateMin = TenthBips32{0}, - .managementFeeRate = TenthBips16{0}, - .coverRateLiquidation = TenthBips32{0}}; - LoanParameters const loanParams{ - .account = lender, - .counter = borrower, - .principalRequest = Number{1, 0}, - .interest = TenthBips32{100'000}, - .payTotal = 5, - .payInterval = 150, - .gracePd = 60}; + auto const [acctReserve, incReserve] = [this]() -> std::pair { + Env const env{*this, testableAmendments()}; + return { + env.current()->fees().accountReserve(0, 1).drops() / kDropsPerXrp.drops(), + env.current()->fees().increment.drops() / kDropsPerXrp.drops()}; + }(); - auto const assetType = AssetType::IOU; + testCase( + [&, this](Env& env, BrokerInfo const& broker, MPTTester& mptt) { + using namespace loan; + Number const principalRequest = broker.asset(1'000).value(); - Env env{*this, features}; + testcase( + "MPT authorized borrower, borrower submits, borrower has " + "no reserve"); + mptt.authorize({.account = borrower, .flags = tfMPTUnauthorize}); + env.close(); - auto loanResult = - createLoan(env, assetType, brokerParams, loanParams, issuer, lender, borrower); + auto const mptoken = keylet::mptoken(mptt.issuanceID(), borrower); + auto const sleMPT1 = env.le(mptoken); + BEAST_EXPECT(sleMPT1 == nullptr); - if (BEAST_EXPECT(loanResult); !loanResult.has_value()) - return; + // Burn some XRP + env(noop(borrower), Fee(XRP((acctReserve * 2) + (incReserve * 2)))); + env.close(); - auto broker = std::get(*loanResult); - auto loanKeylet = std::get(*loanResult); - auto pseudoAcct = std::get(*loanResult); + // Cannot create loan, not enough reserve to create MPToken + env(set(borrower, broker.brokerID, principalRequest), + kCounterparty(lender), + Sig(sfCounterpartySignature, lender), + Fee(env.current()->fees().base * 5), + Ter{tecINSUFFICIENT_RESERVE}); + env.close(); - VerifyLoanStatus const verifyLoanStatus(env, broker, pseudoAcct, loanKeylet); + // Can create loan now, will implicitly create MPToken + env(pay(issuer, borrower, XRP(incReserve))); + env.close(); + env(set(borrower, broker.brokerID, principalRequest), + kCounterparty(lender), + Sig(sfCounterpartySignature, lender), + Fee(env.current()->fees().base * 5)); + env.close(); - makeLoanPayments( - env, - broker, - loanParams, - loanKeylet, - verifyLoanStatus, - issuer, - lender, - borrower, - PaymentParameters{.showStepBalances = true}); - } + auto const sleMPT2 = env.le(mptoken); + BEAST_EXPECT(sleMPT2 != nullptr); + }, + {}, + CaseArgs{.initialXRP = (acctReserve * 2) + (incReserve * 8) + 1}); - void - testBorrowerIsBroker() - { - testcase("Test Borrower is Broker"); - using namespace jtx; - using namespace loan; - Account const broker{"broker"}; - Account const issuer{"issuer"}; - Account const borrower{"borrower"}; - Account const depositor{"depositor"}; + testCase( + {}, + [&, this](Env& env, BrokerInfo const& broker) { + using namespace loan; + Number const principalRequest = broker.asset(1'000).value(); - auto testLoanAsset = [&](auto&& getMaxDebt, auto const& borrower) { - Env env(*this); - Vault const vault(env); + testcase( + "IOU authorized borrower, borrower submits, borrower has " + "no reserve"); + // Remove trust line from borrower to issuer + env.trust(broker.asset(0), borrower); + env.close(); - if (borrower == broker) - { - env.fund(XRP(10'000), broker, issuer, depositor); - } - else - { - env.fund(XRP(10'000), broker, borrower, issuer, depositor); - } - env.close(); + env(pay(borrower, issuer, broker.asset(10'000))); + env.close(); + auto const trustline = keylet::trustLine(borrower, broker.asset.raw().get()); + auto const sleLine1 = env.le(trustline); + BEAST_EXPECT(sleLine1 == nullptr); - auto const xrpFee = XRP(100); - auto const txFee = Fee(xrpFee); + // Burn some XRP + env(noop(borrower), Fee(XRP((acctReserve * 2) + (incReserve * 2)))); + env.close(); - STAmount const debtMaximumRequest = getMaxDebt(env); + // Cannot create loan, not enough reserve to create trust line + env(set(borrower, broker.brokerID, principalRequest), + kCounterparty(lender), + Sig(sfCounterpartySignature, lender), + Fee(env.current()->fees().base * 5), + Ter{tecNO_LINE_INSUF_RESERVE}); + env.close(); - auto const& asset = debtMaximumRequest.asset(); - auto const initialVault = asset(debtMaximumRequest * 100); + // Can create loan now, will implicitly create trust line + env(pay(issuer, borrower, XRP(incReserve))); + env.close(); + env(set(borrower, broker.brokerID, principalRequest), + kCounterparty(lender), + Sig(sfCounterpartySignature, lender), + Fee(env.current()->fees().base * 5)); + env.close(); - auto [tx, vaultKeylet] = vault.create({.owner = broker, .asset = asset}); - env(tx, txFee); - env.close(); + auto const sleLine2 = env.le(trustline); + BEAST_EXPECT(sleLine2 != nullptr); + }, + CaseArgs{.initialXRP = (acctReserve * 2) + (incReserve * 8) + 1}); - env(vault.deposit( - {.depositor = depositor, .id = vaultKeylet.key, .amount = initialVault}), - txFee); - env.close(); + testCase( + [&, this](Env& env, BrokerInfo const& broker, MPTTester& mptt) { + using namespace loan; + Number const principalRequest = broker.asset(1'000).value(); - auto const brokerKeylet = keylet::loanBroker(broker.id(), env.seq(broker)); + testcase( + "MPT authorized borrower, borrower submits, lender has " + "no reserve"); + auto const mptoken = keylet::mptoken(mptt.issuanceID(), lender); + auto const sleMPT1 = env.le(mptoken); + BEAST_EXPECT(sleMPT1 != nullptr); - env(loanBroker::set(broker, vaultKeylet.key), txFee); - env.close(); + env(pay(lender, issuer, broker.asset(sleMPT1->at(sfMPTAmount)))); + env.close(); - auto const serviceFee = 101; + mptt.authorize({.account = lender, .flags = tfMPTUnauthorize}); + env.close(); - env(set(broker, brokerKeylet.key, debtMaximumRequest), - kCounterparty(borrower), - Sig(sfCounterpartySignature, borrower), - kLoanServiceFee(serviceFee), - kPaymentTotal(10), - txFee); - env.close(); + auto const sleMPT2 = env.le(mptoken); + BEAST_EXPECT(sleMPT2 == nullptr); - std::uint32_t const loanSequence = 1; - auto const loanKeylet = keylet::loan(brokerKeylet.key, loanSequence); + // Burn some XRP + env(noop(lender), Fee(XRP(incReserve))); + env.close(); - auto const brokerBalanceBefore = env.balance(broker, asset); + // Cannot create loan, not enough reserve to create MPToken + env(set(borrower, broker.brokerID, principalRequest), + kLoanOriginationFee(broker.asset(1).value()), + kCounterparty(lender), + Sig(sfCounterpartySignature, lender), + Fee(env.current()->fees().base * 5), + Ter{tecINSUFFICIENT_RESERVE}); + env.close(); - if (auto const loanSle = env.le(loanKeylet); env.test.BEAST_EXPECT(loanSle)) - { - auto const payment = loanSle->at(sfPeriodicPayment); - auto const totalPayment = payment + serviceFee; - env(loan::pay(borrower, loanKeylet.key, asset(totalPayment)), txFee); + // Can create loan now, will implicitly create MPToken + env(pay(issuer, lender, XRP(incReserve))); + env.close(); + env(set(borrower, broker.brokerID, principalRequest), + kLoanOriginationFee(broker.asset(1).value()), + kCounterparty(lender), + Sig(sfCounterpartySignature, lender), + Fee(env.current()->fees().base * 5)); env.close(); - if (auto const vaultSle = env.le(vaultKeylet); BEAST_EXPECT(vaultSle)) - { - auto const expected = [&]() { - // The service fee is transferred to the broker if - // a borrower is not the broker - if (borrower != broker) - return brokerBalanceBefore.number() + serviceFee; - // Since a borrower is the broker, the payment is - // transferred to the Vault from the broker but not - // the service fee. - // If the asset is XRP then the broker pays the txFee. - if (asset.native()) - return brokerBalanceBefore.number() - payment - xrpFee.number(); - return brokerBalanceBefore.number() - payment; - }(); - BEAST_EXPECT(env.balance(broker, asset).value() == asset(expected).value()); - } - } - }; - // Test when a borrower is the broker and is not to verify correct - // service fee transfer in both cases. - for (auto const& borrowerAcct : {broker, borrower}) - { - testLoanAsset( - [&](Env&) -> STAmount { return STAmount{XRPAmount{200'000}}; }, borrowerAcct); - testLoanAsset( - [&](Env& env) -> STAmount { - auto const iou = issuer["USD"]; - env(trust(broker, iou(1'000'000'000))); - env(trust(depositor, iou(1'000'000'000))); - env(pay(issuer, broker, iou(100'000'000))); - env(pay(issuer, depositor, iou(100'000'000))); - env.close(); - return iou(200'000); - }, - borrowerAcct); - testLoanAsset( - [&](Env& env) -> STAmount { - MPTTester const mpt( - {.env = env, - .issuer = issuer, - .holders = {broker, depositor}, - .pay = 100'000'000}); - return mpt(200'000); - }, - borrowerAcct); - } - } - void - testIssuerIsBorrower(FeatureBitset features) - { - testcase("RIPD-4096 - Issuer as borrower"); + auto const sleMPT3 = env.le(mptoken); + BEAST_EXPECT(sleMPT3 != nullptr); + }, + {}, + CaseArgs{.initialXRP = (acctReserve * 2) + (incReserve * 8) + 1}); - using namespace jtx; + testCase( + {}, + [&, this](Env& env, BrokerInfo const& broker) { + using namespace loan; + Number const principalRequest = broker.asset(1'000).value(); - Account const issuer("issuer"); - Account const lender("lender"); + testcase( + "IOU authorized borrower, borrower submits, lender has no " + "reserve"); + // Remove trust line from lender to issuer + env.trust(broker.asset(0), lender); + env.close(); - BrokerParameters const brokerParams{ - .vaultDeposit = 100'000, - .debtMax = 0, - .coverRateMin = TenthBips32{0}, - .managementFeeRate = TenthBips16{0}, - .coverRateLiquidation = TenthBips32{0}}; - LoanParameters const loanParams{ - .account = lender, .counter = issuer, .principalRequest = Number{10000}}; + auto const trustline = keylet::trustLine(lender, broker.asset.raw().get()); + auto const sleLine1 = env.le(trustline); + BEAST_EXPECT(sleLine1 != nullptr); - auto const assetType = AssetType::IOU; + env(pay(lender, issuer, broker.asset(abs(sleLine1->at(sfBalance).value())))); + env.close(); + auto const sleLine2 = env.le(trustline); + BEAST_EXPECT(sleLine2 == nullptr); - Env env{*this, features}; + // Burn some XRP + env(noop(lender), Fee(XRP(incReserve))); + env.close(); - auto loanResult = - createLoan(env, assetType, brokerParams, loanParams, issuer, lender, issuer); + // Cannot create loan, not enough reserve to create trust line + env(set(borrower, broker.brokerID, principalRequest), + kLoanOriginationFee(broker.asset(1).value()), + kCounterparty(lender), + Sig(sfCounterpartySignature, lender), + Fee(env.current()->fees().base * 5), + Ter{tecNO_LINE_INSUF_RESERVE}); + env.close(); - if (BEAST_EXPECT(loanResult); !loanResult.has_value()) - return; + // Can create loan now, will implicitly create trust line + env(pay(issuer, lender, XRP(incReserve))); + env.close(); + env(set(borrower, broker.brokerID, principalRequest), + kLoanOriginationFee(broker.asset(1).value()), + kCounterparty(lender), + Sig(sfCounterpartySignature, lender), + Fee(env.current()->fees().base * 5)); + env.close(); - auto broker = std::get(*loanResult); - auto loanKeylet = std::get(*loanResult); - auto pseudoAcct = std::get(*loanResult); + auto const sleLine3 = env.le(trustline); + BEAST_EXPECT(sleLine3 != nullptr); + }, + CaseArgs{.initialXRP = (acctReserve * 2) + (incReserve * 8) + 1}); - VerifyLoanStatus const verifyLoanStatus(env, broker, pseudoAcct, loanKeylet); + testCase( + [&, this](Env& env, BrokerInfo const& broker, MPTTester& mptt) { + using namespace loan; + Number const principalRequest = broker.asset(1'000).value(); - makeLoanPayments( - env, - broker, - loanParams, - loanKeylet, - verifyLoanStatus, - issuer, - lender, - issuer, - PaymentParameters{.showStepBalances = true}); - } + testcase("MPT authorized borrower, unauthorized lender"); + auto const mptoken = keylet::mptoken(mptt.issuanceID(), lender); + auto const sleMPT1 = env.le(mptoken); + BEAST_EXPECT(sleMPT1 != nullptr); - void - testLimitExceeded() - { - testcase("RIPD-4125 - overpayment"); + env(pay(lender, issuer, broker.asset(sleMPT1->at(sfMPTAmount)))); + env.close(); - using namespace jtx; + mptt.authorize({.account = lender, .flags = tfMPTUnauthorize}); + env.close(); - Account const issuer("issuer"); - Account const lender("lender"); - Account const borrower("borrower"); + auto const sleMPT2 = env.le(mptoken); + BEAST_EXPECT(sleMPT2 == nullptr); - BrokerParameters const brokerParams{ - .vaultDeposit = 100'000, - .debtMax = 0, - .coverRateMin = TenthBips32{0}, - .managementFeeRate = TenthBips16{0}, - .coverRateLiquidation = TenthBips32{0}}; - LoanParameters const loanParams{ - .account = lender, - .counter = borrower, - .principalRequest = Number{200000, -6}, - .interest = TenthBips32{50000}, - .payTotal = 3, - .payInterval = 200, - .gracePd = 60, - .flags = tfLoanOverpayment, - }; + // Cannot create loan, lender not authorized to receive fee + env(set(borrower, broker.brokerID, principalRequest), + kLoanOriginationFee(broker.asset(1).value()), + kCounterparty(lender), + Sig(sfCounterpartySignature, lender), + Fee(env.current()->fees().base * 5), + Ter{tecNO_AUTH}); + env.close(); - auto const assetType = AssetType::XRP; + // Cannot create loan, even without an origination fee + env(set(borrower, broker.brokerID, principalRequest), + kCounterparty(lender), + Sig(sfCounterpartySignature, lender), + Fee(env.current()->fees().base * 5), + Ter{tecNO_AUTH}); + env.close(); - Env env(*this, makeConfig(), all_, nullptr, beast::Severity::Warning); + // No MPToken for lender - no authorization and no payment + auto const sleMPT3 = env.le(mptoken); + BEAST_EXPECT(sleMPT3 == nullptr); + }, + {}, + CaseArgs{.requireAuth = true, .authorizeBorrower = true}); - auto loanResult = - createLoan(env, assetType, brokerParams, loanParams, issuer, lender, borrower); + testCase( + [&, this](Env& env, BrokerInfo const& broker, auto&) { + using namespace loan; + Number const principalRequest = broker.asset(1'000).value(); - if (BEAST_EXPECT(loanResult); !loanResult.has_value()) - return; + testcase("MPT authorized borrower, borrower submits"); + env(set(borrower, broker.brokerID, principalRequest), + kCounterparty(lender), + Sig(sfCounterpartySignature, lender), + Fee(env.current()->fees().base * 5)); + }, + [&, this](Env& env, BrokerInfo const& broker) { + using namespace loan; + Number const principalRequest = broker.asset(1'000).value(); - auto broker = std::get(*loanResult); - auto loanKeylet = std::get(*loanResult); - auto pseudoAcct = std::get(*loanResult); + testcase("IOU authorized borrower, borrower submits"); + env(set(borrower, broker.brokerID, principalRequest), + kCounterparty(lender), + Sig(sfCounterpartySignature, lender), + Fee(env.current()->fees().base * 5)); + }, + CaseArgs{.requireAuth = true, .authorizeBorrower = true}); - VerifyLoanStatus const verifyLoanStatus(env, broker, pseudoAcct, loanKeylet); + testCase( + [&, this](Env& env, BrokerInfo const& broker, auto&) { + using namespace loan; + Number const principalRequest = broker.asset(1'000).value(); - auto const state = getCurrentState(env, broker, loanKeylet); + testcase("MPT authorized borrower, lender submits"); + env(set(lender, broker.brokerID, principalRequest), + kCounterparty(borrower), + Sig(sfCounterpartySignature, borrower), + Fee(env.current()->fees().base * 5)); + }, + [&, this](Env& env, BrokerInfo const& broker) { + using namespace loan; + Number const principalRequest = broker.asset(1'000).value(); - env(loan::pay( - borrower, - loanKeylet.key, - STAmount{broker.asset, state.periodicPayment * 3 / 2 + 1}, - tfLoanOverpayment)); - env.close(); + testcase("IOU authorized borrower, lender submits"); + env(set(lender, broker.brokerID, principalRequest), + kCounterparty(borrower), + Sig(sfCounterpartySignature, borrower), + Fee(env.current()->fees().base * 5)); + }, + CaseArgs{.requireAuth = true, .authorizeBorrower = true}); - PaymentParameters const paymentParams{ - .showStepBalances = false, - .validateBalances = true, + jtx::Account const alice{"alice"}; + jtx::Account const bella{"bella"}; + auto const msigSetup = [&](Env& env, Account const& account) { + json::Value const tx1 = signers(account, 2, {{alice, 1}, {bella, 1}}); + env(tx1); + env.close(); }; - makeLoanPayments( - env, - broker, - loanParams, - loanKeylet, - verifyLoanStatus, - issuer, - lender, - borrower, - paymentParams); - } - - void - testOverpaymentManagementFee(FeatureBitset features) - { - testcase("testOverpaymentManagementFee"); - - using namespace jtx; - using namespace loan; - - Env env{*this, features}; + testCase( + [&, this](Env& env, BrokerInfo const& broker, auto&) { + using namespace loan; + msigSetup(env, lender); + Number const principalRequest = broker.asset(1'000).value(); - Account const lender{"lender"}, borrower{"borrower"}; + testcase( + "MPT authorized borrower, borrower submits, lender " + "multisign"); + env(set(borrower, broker.brokerID, principalRequest), + kCounterparty(lender), + Msig(sfCounterpartySignature, alice, bella), + Fee(env.current()->fees().base * 5)); + }, + [&, this](Env& env, BrokerInfo const& broker) { + using namespace loan; + msigSetup(env, lender); + Number const principalRequest = broker.asset(1'000).value(); - env.fund(XRP(10'000'000), lender, borrower); - env.close(); + testcase( + "IOU authorized borrower, borrower submits, lender " + "multisign"); + env(set(borrower, broker.brokerID, principalRequest), + kCounterparty(lender), + Msig(sfCounterpartySignature, alice, bella), + Fee(env.current()->fees().base * 5)); + }, + CaseArgs{.requireAuth = true, .authorizeBorrower = true}); - PrettyAsset const asset{xrpIssue(), 1000}; + testCase( + [&, this](Env& env, BrokerInfo const& broker, auto&) { + using namespace loan; + msigSetup(env, borrower); + Number const principalRequest = broker.asset(1'000).value(); - auto const result = createVaultAndBroker( - env, - asset, - lender, - { - .vaultDeposit = asset(100'000).value(), - .managementFeeRate = TenthBips16(10'000), - }); + testcase( + "MPT authorized borrower, lender submits, borrower " + "multisign"); + env(set(lender, broker.brokerID, principalRequest), + kCounterparty(borrower), + Msig(sfCounterpartySignature, alice, bella), + Fee(env.current()->fees().base * 5)); + }, + [&, this](Env& env, BrokerInfo const& broker) { + using namespace loan; + msigSetup(env, borrower); + Number const principalRequest = broker.asset(1'000).value(); - auto const loanSetFee = Fee(env.current()->fees().base * 2); + testcase( + "IOU authorized borrower, lender submits, borrower " + "multisign"); + env(set(lender, broker.brokerID, principalRequest), + kCounterparty(borrower), + Msig(sfCounterpartySignature, alice, bella), + Fee(env.current()->fees().base * 5)); + }, + CaseArgs{.requireAuth = true, .authorizeBorrower = true}); - auto const loanKeylet = keylet::loan( - result.brokerKeylet().key, (env.le(result.brokerKeylet()))->at(sfLoanSequence)); - env(loan::set( - borrower, result.brokerKeylet().key, asset(10'000).value(), tfLoanOverpayment), - Sig(sfCounterpartySignature, lender), - loan::kPaymentInterval(86400 * 30), - loan::kPaymentTotal(3), - loan::kOverpaymentInterestRate(TenthBips32(percentageToTenthBips(20))), - loanSetFee); + testCase( + [&, this](Env& env, BrokerInfo const& broker, auto&) { + using namespace loan; + Number const principalRequest = broker.asset(1'000).value(); + Vault const vault{env}; + auto tx = vault.set({.owner = lender, .id = broker.vaultID}); + tx[sfAssetsMaximum] = BrokerParameters::defaults().vaultDeposit; + env(tx); + env.close(); - // From calculator - auto const expectedOverpaymentManagementFee = Number{33333, 0}; - auto const loanBrokerBalanceBefore = env.balance(lender); + testcase("Vault at maximum value"); + env(set(issuer, broker.brokerID, principalRequest), + kCounterparty(lender), + kInterestRate(TenthBips32(10'000)), + Sig(sfCounterpartySignature, lender), + Fee(env.current()->fees().base * 5), + Ter(tecLIMIT_EXCEEDED)); + }, + nullptr); - auto const loanPayFee = Fee(env.current()->fees().base * 2); - env(pay(borrower, loanKeylet.key, asset(5'000).value(), tfLoanOverpayment), loanPayFee); - env.close(); + testCase( + [&, this](Env& env, BrokerInfo const& broker, auto&) { + using namespace loan; + Number const principalRequest = broker.asset(1'000).value(); + Vault const vault{env}; + auto tx = vault.set({.owner = lender, .id = broker.vaultID}); + tx[sfAssetsMaximum] = + BrokerParameters::defaults().vaultDeposit + broker.asset(1).number(); + env(tx); + env.close(); - BEAST_EXPECTS( - env.balance(lender) - loanBrokerBalanceBefore == expectedOverpaymentManagementFee, - "overpayment management fee missmatch; expected:" + - to_string(expectedOverpaymentManagementFee) + - " got: " + to_string(env.balance(lender) - loanBrokerBalanceBefore)); + testcase("Vault maximum value exceeded"); + env(set(issuer, broker.brokerID, principalRequest), + kCounterparty(lender), + kInterestRate(TenthBips32(100'000)), + Sig(sfCounterpartySignature, lender), + Fee(env.current()->fees().base * 5), + kPaymentTotal(2), + kPaymentInterval(3600 * 24), + Ter(tecLIMIT_EXCEEDED)); + }, + nullptr); } void - testLoanPayBrokerOwnerMissingTrustline(FeatureBitset features) + testLifecycle(FeatureBitset features) { - testcase << "LoanPay Broker Owner Missing Trustline (PoC)"; + testcase("Lifecycle"); using namespace jtx; - using namespace loan; - Account const issuer("issuer"); - Account const borrower("borrower"); - Account const broker("broker"); - auto const iou = issuer["IOU"]; + + // Create 3 loan brokers: one for XRP, one for an IOU, and one for + // an MPT. That'll require three corresponding SAVs. Env env(*this, features); - env.fund(XRP(20'000), issuer, broker, borrower); - env.close(); - // Set up trustlines and fund accounts - env(trust(broker, iou(20'000'000))); - env(trust(borrower, iou(20'000'000))); - env(pay(issuer, broker, iou(10'000'000))); - env(pay(issuer, borrower, iou(1'000))); - env.close(); - // Create vault and broker - auto const brokerInfo = createVaultAndBroker(env, iou, broker); - // Create a loan first (this creates debt) - auto const keylet = keylet::loan(brokerInfo.brokerID, 1); - env(set(borrower, brokerInfo.brokerID, 10'000), - Sig(sfCounterpartySignature, broker), - kLoanServiceFee(iou(100).value()), - kPaymentInterval(100), - Fee(XRP(100))); - env.close(); - // Ensure broker has sufficient cover so brokerPayee == brokerOwner - // We need coverAvailable >= (debtTotal * coverRateMinimum) - // Deposit enough cover to ensure the fee goes to broker owner - // The default coverRateMinimum is 10%, so for a 10,000 loan we need - // at least 1,000 cover. Default cover is 1,000, so we add more to be - // safe. - auto const additionalCover = iou(50'000).value(); - env(loanBroker::coverDeposit(broker, brokerInfo.brokerID, STAmount{iou, additionalCover})); - env.close(); - // Verify broker owner has a trustline - auto const brokerTrustline = keylet::trustLine(broker, iou); - BEAST_EXPECT(env.le(brokerTrustline) != nullptr); - // Broker owner deletes their trustline - // First, pay any positive balance to issuer to zero it out - auto const brokerBalance = env.balance(broker, iou); - env(pay(broker, issuer, brokerBalance)); - env.close(); - // Remove the trustline by setting limit to 0 - env(trust(broker, iou(0))); - env.close(); - // Verify trustline is deleted - BEAST_EXPECT(env.le(brokerTrustline) == nullptr); - // Now borrower tries to make a payment - // We should get a tesSUCCESS instead of a tecNO_LINE. - env(pay(borrower, keylet.key, iou(10'100)), Fee(XRP(100)), Ter(tesSUCCESS)); - env.close(); - // Verify trustline is still deleted - BEAST_EXPECT(env.le(brokerTrustline) == nullptr); - // Verify the service fee went to the broker pseudo-account - if (auto const brokerSle = env.le(keylet::loanBroker(brokerInfo.brokerID)); - BEAST_EXPECT(brokerSle)) - { - Account const pseudo("pseudo-account", brokerSle->at(sfAccount)); - auto const balance = env.balance(pseudo, iou); - // 1,000 default + 50,000 extra + 100 service fee from LoanPay - BEAST_EXPECTS(balance == iou(51'100), to_string(json::Value(balance))); - } - } - void - testLoanPayBrokerOwnerUnauthorizedMPT(FeatureBitset features) - { - testcase << "LoanPay Broker Owner MPT unauthorized"; - using namespace jtx; - using namespace loan; + Account const issuer{"issuer"}; + // For simplicity, lender will be the sole actor for the vault & + // brokers. + Account const lender{"lender"}; + // Borrower only wants to borrow + Account const borrower{"borrower"}; + // Evan will attempt to be naughty + Account const evan{"evan"}; + // Do not fund alice + Account const alice{"alice"}; - Account const issuer("issuer"); - Account const borrower("borrower"); - Account const broker("broker"); + // Fund the accounts and trust lines with the same amount so that + // tests can use the same values regardless of the asset. + env.fund(XRP(100'000'000), issuer, noripple(lender, borrower, evan)); + env.close(); - Env env{*this, features}; - env.fund(XRP(20'000), issuer, broker, borrower); + // Create assets + PrettyAsset const xrpAsset{xrpIssue(), 1'000'000}; + PrettyAsset const iouAsset = issuer[iouCurrency_]; + env(trust(lender, iouAsset(10'000'000))); + env(trust(borrower, iouAsset(10'000'000))); + env(trust(evan, iouAsset(10'000'000))); + env(pay(issuer, evan, iouAsset(1'000'000))); + env(pay(issuer, lender, iouAsset(10'000'000))); + // Fund the borrower with enough to cover interest and fees + env(pay(issuer, borrower, iouAsset(10'000))); env.close(); MPTTester mptt{env, issuer, kMptInitNoFund}; mptt.create({.flags = tfMPTCanClawback | tfMPTCanTransfer | tfMPTCanLock}); - - PrettyAsset const mpt{mptt.issuanceID()}; - - // Authorize broker and borrower - mptt.authorize({.account = broker}); + // Scale the MPT asset a little bit so we can get some interest + PrettyAsset const mptAsset{mptt.issuanceID(), 100}; + mptt.authorize({.account = lender}); mptt.authorize({.account = borrower}); - + mptt.authorize({.account = evan}); + env(pay(issuer, lender, mptAsset(10'000'000))); + env(pay(issuer, evan, mptAsset(1'000'000))); + // Fund the borrower with enough to cover interest and fees + env(pay(issuer, borrower, mptAsset(10'000))); env.close(); - // Fund accounts - env(pay(issuer, broker, mpt(10'000'000))); - env(pay(issuer, borrower, mpt(1'000))); - env.close(); + std::array const assets{iouAsset, xrpAsset, mptAsset}; - // Create vault and broker - auto const brokerInfo = createVaultAndBroker(env, mpt, broker); - // Create a loan first (this creates debt) - auto const keylet = keylet::loan(brokerInfo.brokerID, 1); - env(set(borrower, brokerInfo.brokerID, 10'000), - Sig(sfCounterpartySignature, broker), - kLoanServiceFee(mpt(100).value()), - kPaymentInterval(100), - Fee(XRP(100))); - env.close(); - // Ensure broker has sufficient cover so brokerPayee == brokerOwner - // We need coverAvailable >= (debtTotal * coverRateMinimum) - // Deposit enough cover to ensure the fee goes to broker owner - // The default coverRateMinimum is 10%, so for a 10,000 loan we need - // at least 1,000 cover. Default cover is 1,000, so we add more to be - // safe. - auto const additionalCover = mpt(50'000).value(); - env(loanBroker::coverDeposit(broker, brokerInfo.brokerID, STAmount{mpt, additionalCover})); - env.close(); - // Verify broker owner is authorized - auto const brokerMpt = keylet::mptoken(mptt.issuanceID(), broker); - BEAST_EXPECT(env.le(brokerMpt) != nullptr); - // Broker owner unauthorizes. - // First, pay any positive balance to issuer to zero it out - auto const brokerBalance = env.balance(broker, mpt); - env(pay(broker, issuer, brokerBalance)); - env.close(); - // Then, unauthorize the MPT. - mptt.authorize({.account = broker, .flags = tfMPTUnauthorize}); - env.close(); - // Verify the MPT is unauthorized. - BEAST_EXPECT(env.le(brokerMpt) == nullptr); - // Now borrower tries to make a payment - // We should get a tesSUCCESS instead of a tecNO_AUTH. - auto const borrowerBalance = env.balance(borrower, mpt); - env(pay(borrower, keylet.key, mpt(10'100)), Fee(XRP(100)), Ter(tesSUCCESS)); - env.close(); - // Verify the MPT is still unauthorized. - BEAST_EXPECT(env.le(brokerMpt) == nullptr); - // Verify the service fee went to the broker pseudo-account - if (auto const brokerSle = env.le(keylet::loanBroker(brokerInfo.brokerID)); - BEAST_EXPECT(brokerSle)) + // Create vaults and loan brokers + std::vector brokers; + brokers.reserve(assets.size()); + for (auto const& asset : assets) { - Account const pseudo("pseudo-account", brokerSle->at(sfAccount)); - auto const balance = env.balance(pseudo, mpt); - // 1,000 default + 50,000 extra + 100 service fee from LoanPay - BEAST_EXPECTS(balance == mpt(51'100), to_string(json::Value(balance))); + brokers.emplace_back(createVaultAndBroker( + env, asset, lender, BrokerParameters{.data = "spam spam spam spam"})); + } + + // Create and update Loans + for (auto const& broker : brokers) + { + for (int amountExponent = 3; amountExponent >= 3; --amountExponent) + { + Number const loanAmount{1, amountExponent}; + for (int interestExponent = 0; interestExponent >= 0; --interestExponent) + { + testCaseWrapper(env, mptt, assets, broker, loanAmount, interestExponent); + } + } + + if (auto brokerSle = env.le(keylet::loanBroker(broker.brokerID)); + BEAST_EXPECT(brokerSle)) + { + BEAST_EXPECT(brokerSle->at(sfOwnerCount) == 0); + BEAST_EXPECT(brokerSle->at(sfDebtTotal) == 0); + + auto const coverAvailable = brokerSle->at(sfCoverAvailable); + env(loanBroker::coverWithdraw( + lender, broker.brokerID, STAmount(broker.asset, coverAvailable))); + env.close(); + + brokerSle = env.le(keylet::loanBroker(broker.brokerID)); + BEAST_EXPECT(brokerSle && brokerSle->at(sfCoverAvailable) == 0); + } + // Verify we can delete the loan broker + env(loanBroker::del(lender, broker.brokerID)); + env.close(); } } void - testLoanPayBrokerOwnerNoPermissionedDomainMPT(FeatureBitset features) + testSelfLoan(FeatureBitset features) { - testcase << "LoanPay Broker Owner without permissioned domain of the MPT"; - using namespace jtx; - using namespace loan; - - Account const issuer("issuer"); - Account const borrower("borrower"); - Account const broker("broker"); + testcase << "Self Loan"; - Env env{*this, features}; - env.fund(XRP(20'000), issuer, broker, borrower); - env.close(); + using namespace jtx; + using namespace std::chrono_literals; + // Create 3 loan brokers: one for XRP, one for an IOU, and one for + // an MPT. That'll require three corresponding SAVs. + Env env(*this, features); - auto credType = "credential1"; + Account const issuer{"issuer"}; + // For simplicity, lender will be the sole actor for the vault & + // brokers. + Account const lender{"lender"}; - pdomain::Credentials const credentials1 = {{.issuer = issuer, .credType = credType}}; - env(pdomain::setTx(issuer, credentials1)); + // Fund the accounts and trust lines with the same amount so that + // tests can use the same values regardless of the asset. + env.fund(XRP(100'000'000), issuer, noripple(lender)); env.close(); - auto domainID = pdomain::getNewDomain(env.meta()); + // Use an XRP asset for simplicity + PrettyAsset const xrpAsset{xrpIssue(), 1'000'000}; - env(credentials::create(broker, issuer, credType)); - env(credentials::accept(broker, issuer, credType)); - env.close(); + // Create vaults and loan brokers + BrokerInfo broker{createVaultAndBroker(env, xrpAsset, lender)}; - env(credentials::create(borrower, issuer, credType)); - env(credentials::accept(borrower, issuer, credType)); - env.close(); + using namespace loan; - MPTTester mptt{env, issuer, kMptInitNoFund}; - mptt.create({ - .flags = tfMPTCanClawback | tfMPTRequireAuth | tfMPTCanTransfer | tfMPTCanLock, - .domainID = domainID, - }); + auto const loanSetFee = Fee(env.current()->fees().base * 2); + Number const principalRequest{1, 3}; - PrettyAsset const mpt{mptt.issuanceID()}; + // The LoanSet json can be created without a counterparty signature, + // but it will not pass preflight + auto createJson = env.json( + set(lender, broker.brokerID, broker.asset(principalRequest).value()), Fee(loanSetFee)); + env(createJson, Ter(temBAD_SIGNER)); - // Authorize broker and borrower - mptt.authorize({.account = broker}); - mptt.authorize({.account = borrower}); + // Adding an empty counterparty signature object also fails, but + // at the RPC level. + createJson = env.json(createJson, Json(sfCounterpartySignature, json::ValueType::Object)); + env(createJson, Ter(telENV_RPC_FAILED)); - env.close(); + if (auto const jt = env.jt(createJson); BEAST_EXPECT(jt.stx)) + { + Serializer s; + jt.stx->add(s); + auto const jr = env.rpc("submit", strHex(s.slice())); - // Fund accounts - env(pay(issuer, broker, mpt(10'000'000))); - env(pay(issuer, borrower, mpt(1'000))); - env.close(); + BEAST_EXPECT(jr.isMember(jss::result)); + auto const jResult = jr[jss::result]; + BEAST_EXPECT(jResult[jss::error] == "invalidTransaction"); + BEAST_EXPECT( + jResult[jss::error_exception] == + "fails local checks: Transaction has bad signature."); + } - // Create vault and broker - auto const brokerInfo = createVaultAndBroker(env, mpt, broker); - // Create a loan first (this creates debt) - auto const keylet = keylet::loan(brokerInfo.brokerID, 1); - env(set(borrower, brokerInfo.brokerID, 10'000), - Sig(sfCounterpartySignature, broker), - kLoanServiceFee(mpt(100).value()), - kPaymentInterval(100), - Fee(XRP(100))); - env.close(); - // Ensure broker has sufficient cover so brokerPayee == brokerOwner - // We need coverAvailable >= (debtTotal * coverRateMinimum) - // Deposit enough cover to ensure the fee goes to broker owner - // The default coverRateMinimum is 10%, so for a 10,000 loan we need - // at least 1,000 cover. Default cover is 1,000, so we add more to be - // safe. - auto const additionalCover = mpt(50'000).value(); - env(loanBroker::coverDeposit(broker, brokerInfo.brokerID, STAmount{mpt, additionalCover})); - env.close(); - // Verify broker owner is authorized - auto const brokerMpt = keylet::mptoken(mptt.issuanceID(), broker); - BEAST_EXPECT(env.le(brokerMpt) != nullptr); - // Remove the credentials for the Broker owner. - // First, pay any positive balance to issuer to zero it out - auto const brokerBalance = env.balance(broker, mpt); - env(pay(broker, issuer, brokerBalance)); - env.close(); + // Copy the transaction signature into the counterparty signature. + json::Value counterpartyJson{json::ValueType::Object}; + counterpartyJson[sfTxnSignature] = createJson[sfTxnSignature]; + counterpartyJson[sfSigningPubKey] = createJson[sfSigningPubKey]; + if (!BEAST_EXPECT(!createJson.isMember(jss::Signers))) + counterpartyJson[sfSigners] = createJson[sfSigners]; + + // The duplicated signature works + createJson = env.json(createJson, Json(sfCounterpartySignature, counterpartyJson)); + env(createJson); - env(credentials::deleteCred(broker, broker, issuer, credType)); env.close(); - // Make sure the broker is not authorized to hold the MPT after we - // deleted the credentials - env(pay(issuer, broker, mpt(1'000)), Ter(tecNO_AUTH)); + auto const startDate = env.current()->header().parentCloseTime; - // Now borrower tries to make a payment - // We should get a tesSUCCESS instead of a tecNO_AUTH. - auto const borrowerBalance = env.balance(borrower, mpt); - env(pay(borrower, keylet.key, mpt(10'100)), Fee(XRP(100)), Ter(tesSUCCESS)); - env.close(); - // Verify broker is still not authorized - env(pay(issuer, broker, mpt(1'000)), Ter(tecNO_AUTH)); - // Verify the service fee went to the broker pseudo-account - if (auto const brokerSle = env.le(keylet::loanBroker(brokerInfo.brokerID)); - BEAST_EXPECT(brokerSle)) + // Loan is successfully created { - Account const pseudo("pseudo-account", brokerSle->at(sfAccount)); - auto const balance = env.balance(pseudo, mpt); - // 1,000 default + 50,000 extra + 100 service fee from LoanPay - BEAST_EXPECTS(balance == mpt(51'100), to_string(json::Value(balance))); + auto const res = env.rpc("account_objects", lender.human()); + auto const objects = res[jss::result][jss::account_objects]; + + std::map types; + BEAST_EXPECT(objects.size() == 4); + for (auto const& object : objects) + { + ++types[object[sfLedgerEntryType].asString()]; + } + BEAST_EXPECT(types.size() == 4); + for (std::string const type : {"MPToken", "Vault", "LoanBroker", "Loan"}) + { + BEAST_EXPECT(types[type] == 1); + } } + auto const loanID = [&]() { + json::Value params(json::ValueType::Object); + params[jss::account] = lender.human(); + params[jss::type] = "Loan"; + auto const res = env.rpc("json", "account_objects", to_string(params)); + auto const objects = res[jss::result][jss::account_objects]; + + BEAST_EXPECT(objects.size() == 1); + + auto const loan = objects[0u]; + BEAST_EXPECT(loan[sfBorrower] == lender.human()); + // soeDEFAULT fields are not returned if they're in the default + // state + BEAST_EXPECT(!loan.isMember(sfCloseInterestRate)); + BEAST_EXPECT(!loan.isMember(sfClosePaymentFee)); + BEAST_EXPECT(loan[sfFlags] == 0); + BEAST_EXPECT(loan[sfGracePeriod] == 60); + BEAST_EXPECT(!loan.isMember(sfInterestRate)); + BEAST_EXPECT(!loan.isMember(sfLateInterestRate)); + BEAST_EXPECT(!loan.isMember(sfLatePaymentFee)); + BEAST_EXPECT(loan[sfLoanBrokerID] == to_string(broker.brokerID)); + BEAST_EXPECT(!loan.isMember(sfLoanOriginationFee)); + BEAST_EXPECT(loan[sfLoanSequence] == 1); + BEAST_EXPECT(!loan.isMember(sfLoanServiceFee)); + BEAST_EXPECT(loan[sfNextPaymentDueDate] == loan[sfStartDate].asUInt() + 60); + BEAST_EXPECT(!loan.isMember(sfOverpaymentFee)); + BEAST_EXPECT(!loan.isMember(sfOverpaymentInterestRate)); + BEAST_EXPECT(loan[sfPaymentInterval] == 60); + BEAST_EXPECT(loan[sfPeriodicPayment] == "1000000000"); + BEAST_EXPECT(loan[sfPaymentRemaining] == 1); + BEAST_EXPECT(!loan.isMember(sfPreviousPaymentDueDate)); + BEAST_EXPECT(loan[sfPrincipalOutstanding] == "1000000000"); + BEAST_EXPECT(loan[sfTotalValueOutstanding] == "1000000000"); + BEAST_EXPECT(!loan.isMember(sfLoanScale)); + BEAST_EXPECT(loan[sfStartDate].asUInt() == startDate.time_since_epoch().count()); + + return loan["index"].asString(); + }(); + auto const loanKeylet{keylet::loan(uint256{std::string_view(loanID)})}; + + env.close(startDate); + + // Make a payment + env(pay(lender, loanKeylet.key, broker.asset(1000))); } void - testLoanSetBrokerOwnerNoPermissionedDomainMPT(FeatureBitset features) + testBatchBypassCounterparty(FeatureBitset features) { - testcase << "LoanSet Broker Owner without permissioned domain of the MPT"; - using namespace jtx; - using namespace loan; + // From FIND-001 + testcase << "Batch Bypass Counterparty"; - Account const issuer("issuer"); - Account const borrower("borrower"); - Account const broker("broker"); + bool const lendingBatchEnabled = !std::ranges::any_of( + Batch::kDisabledTxTypes, + [](auto const& disabled) { return disabled == ttLOAN_BROKER_SET; }); - Env env{*this, features}; - env.fund(XRP(20'000), issuer, broker, borrower); - env.close(); + using namespace jtx; + using namespace std::chrono_literals; + Env env(*this, features); - auto credType = "credential1"; + Account const lender{"lender"}; + Account const borrower{"borrower"}; - pdomain::Credentials const credentials1{{.issuer = issuer, .credType = credType}}; - env(pdomain::setTx(issuer, credentials1)); + BrokerParameters const brokerParams; + env.fund(XRP(brokerParams.vaultDeposit * 100), lender, borrower); env.close(); - auto domainID = pdomain::getNewDomain(env.meta()); + PrettyAsset const xrpAsset{xrpIssue(), 1'000'000}; - // Add credentials for the broker and borrower - env(credentials::create(broker, issuer, credType)); - env(credentials::accept(broker, issuer, credType)); - env.close(); + BrokerInfo const broker{createVaultAndBroker(env, xrpAsset, lender, brokerParams)}; - env(credentials::create(borrower, issuer, credType)); - env(credentials::accept(borrower, issuer, credType)); - env.close(); + using namespace loan; - MPTTester mptt{env, issuer, kMptInitNoFund}; - mptt.create({ - .flags = tfMPTCanClawback | tfMPTRequireAuth | tfMPTCanTransfer | tfMPTCanLock, - .domainID = domainID, - }); + auto const loanSetFee = Fee(env.current()->fees().base * 2); + Number const principalRequest{1, 3}; - PrettyAsset const mpt{mptt.issuanceID()}; + auto forgedLoanSet = set(borrower, broker.brokerID, principalRequest, 0); - // Authorize broker and borrower - mptt.authorize({.account = broker}); - mptt.authorize({.account = borrower}); - env.close(); + json::Value randomData{json::ValueType::Object}; + randomData[jss::SigningPubKey] = json::StaticString{"2600"}; + json::Value sigObject{json::ValueType::Object}; + sigObject[jss::SigningPubKey] = strHex(lender.pk().slice()); + Serializer ss; + ss.add32(HashPrefix::TxSign); + parse(randomData).addWithoutSigningFields(ss); + auto const sig = xrpl::sign(borrower.pk(), borrower.sk(), ss.slice()); + sigObject[jss::TxnSignature] = strHex(Slice{sig.data(), sig.size()}); - // Fund accounts - env(pay(issuer, broker, mpt(10'000'000))); - env(pay(issuer, borrower, mpt(1'000))); - env.close(); + forgedLoanSet[json::StaticString{"CounterpartySignature"}] = sigObject; - // Create vault and broker - auto const brokerInfo = createVaultAndBroker(env, mpt, broker); + // ? Fails because the lender hasn't signed the tx + env(env.json(forgedLoanSet, Fee(loanSetFee)), Ter(telENV_RPC_FAILED)); - // Remove the credentials for the Broker owner. - // Clear the balance first. - auto const brokerBalance = env.balance(broker, mpt); - env(pay(broker, issuer, brokerBalance)); - env.close(); - // Delete the credentials - env(credentials::deleteCred(broker, broker, issuer, credType)); + auto const seq = env.seq(borrower); + auto const batchFee = batch::calcBatchFee(env, 1, 2); + // ! Should fail because the lender hasn't signed the tx + env(batch::outer(borrower, seq, batchFee, tfAllOrNothing), + batch::Inner(forgedLoanSet, seq + 1), + batch::Inner(pay(borrower, lender, XRP(1)), seq + 2), + Ter(lendingBatchEnabled ? temBAD_SIGNATURE : temINVALID_INNER_BATCH)); env.close(); - // Create a loan, this should fail for tecNO_AUTH - env(set(borrower, brokerInfo.brokerID, 10'000), - Sig(sfCounterpartySignature, broker), - kLoanServiceFee(mpt(100).value()), - kPaymentInterval(100), - Fee(XRP(100)), - Ter(tecNO_AUTH)); - env.close(); + // ? Check that the loan was NOT created + { + json::Value params(json::ValueType::Object); + params[jss::account] = borrower.human(); + params[jss::type] = "Loan"; + auto const res = env.rpc("json", "account_objects", to_string(params)); + auto const objects = res[jss::result][jss::account_objects]; + BEAST_EXPECT(objects.size() == 0); + } } void - testSequentialFLCDepletion(FeatureBitset features) + testWrongMaxDebtBehavior(FeatureBitset features) { - testcase << "First-Loss Capital Depletion on Sequential Defaults"; + // From FIND-003 + testcase << "Wrong Max Debt Behavior"; using namespace jtx; - using namespace loan; - using namespace loanBroker; - - Env env{*this, features}; + using namespace std::chrono_literals; + Env env(*this, features); Account const issuer{"issuer"}; Account const lender{"lender"}; - Account const borrowerA{"borrowerA"}; - Account const borrowerB{"borrowerB"}; - env.fund(XRP(1'000'000), issuer, lender, borrowerA, borrowerB); + BrokerParameters const brokerParams{.debtMax = 0}; + env.fund(XRP(brokerParams.vaultDeposit * 100), issuer, noripple(lender)); env.close(); - PrettyAsset const asset = xrpIssue(); - auto const vaultDepositAmount = - asset(200'000); // Enough for 2 x 50k loans plus interest/fees + PrettyAsset const xrpAsset{xrpIssue(), 1'000'000}; - auto const brokerInfo = createVaultAndBroker( - env, - asset, - lender, - { - .vaultDeposit = vaultDepositAmount.value(), - .debtMax = 0, - .coverRateMin = TenthBips32(20000), // 20% - .coverDeposit = 21'000, - .managementFeeRate = TenthBips16(100), // 0.1% - .coverRateLiquidation = TenthBips32(100000), - }); - auto const brokerKeylet = brokerInfo.brokerKeylet(); - - // Create two identical loans: each 50,000 XRP principal (scaled down to - // avoid funding issues) Total DebtTotal will be ~100,000 XRP (principal - // + interest) Formula will calculate cover as: 100% × (20% × 100,000) = - // 20,000 XRP So we need FLC = 20,000 XRP to be fully consumed by first - // default - auto const principalAmount = Number(50'000); - auto const loanPaymentInterval = 2592000; // 30 days - auto const loanGracePeriod = 604800; // 7 days - - // Create Loan A - auto loanATx = env.jt( - set(borrowerA, brokerKeylet.key, principalAmount), - Sig(sfCounterpartySignature, lender), - kInterestRate(TenthBips32(500)), // 5% - kPaymentTotal(12), - loan::kPaymentInterval(loanPaymentInterval), - loan::kGracePeriod(loanGracePeriod), - Fee(XRP(10))); // Sufficient fee for multi-sig transaction - env(loanATx); - env.close(); + BrokerInfo const broker{createVaultAndBroker(env, xrpAsset, lender, brokerParams)}; - auto const loanAKeylet = keylet::loan(brokerKeylet.key, 1); + if (auto const brokerSle = env.le(keylet::loanBroker(broker.brokerID)); + BEAST_EXPECT(brokerSle)) + { + BEAST_EXPECT(brokerSle->at(sfDebtMaximum) == 0); + } - // Create Loan B - auto loanBTx = env.jt( - set(borrowerB, brokerKeylet.key, principalAmount), - Sig(sfCounterpartySignature, lender), - kInterestRate(TenthBips32(500)), // 5% - kPaymentTotal(12), - loan::kPaymentInterval(loanPaymentInterval), - loan::kGracePeriod(loanGracePeriod), - Fee(XRP(10))); // Sufficient fee for multi-sig transaction - env(loanBTx); - env.close(); + using namespace loan; - auto const loanBKeylet = keylet::loan(brokerKeylet.key, 2); + auto const loanSetFee = Fee(env.current()->fees().base * 2); + Number const principalRequest{1, 3}; - auto loanASle = env.le(loanAKeylet); - if (!BEAST_EXPECT(loanASle)) - return; + auto createJson = env.json(set(lender, broker.brokerID, principalRequest), Fee(loanSetFee)); + + json::Value counterpartyJson{json::ValueType::Object}; + counterpartyJson[sfTxnSignature] = createJson[sfTxnSignature]; + counterpartyJson[sfSigningPubKey] = createJson[sfSigningPubKey]; + if (!BEAST_EXPECT(!createJson.isMember(jss::Signers))) + counterpartyJson[sfSigners] = createJson[sfSigners]; - // Advance time past grace period for both loans to be defaultable - auto const loanANextDue = loanASle->at(sfNextPaymentDueDate); - auto const loanAGrace = loanASle->at(sfGracePeriod); - env.close(std::chrono::seconds{loanANextDue + loanAGrace + 60}); + createJson = env.json(createJson, Json(sfCounterpartySignature, counterpartyJson)); + env(createJson); - env(manage(lender, loanAKeylet.key, tfLoanDefault), Ter(tesSUCCESS)); env.close(); + } - // Verify Loan A is defaulted - loanASle = env.le(loanAKeylet); - if (!BEAST_EXPECT(loanASle)) - return; - BEAST_EXPECT(loanASle->isFlag(lsfLoanDefault)); - BEAST_EXPECT(loanASle->at(sfPaymentRemaining) == 0); + void + testRPC(FeatureBitset features) + { + // This will expand as more test cases are added. Some functionality + // is tested in other test functions. + testcase("RPC"); - // Check broker state after first default (from committed ledger) - auto brokerSle = env.le(brokerKeylet); - if (!BEAST_EXPECT(brokerSle)) - return; - auto const afterFirstDebtTotal = brokerSle->at(sfDebtTotal); - auto const afterFirstCoverAvailable = brokerSle->at(sfCoverAvailable); + using namespace jtx; - // DebtTotal should have decreased by Loan A's debt - BEAST_EXPECT(afterFirstDebtTotal == 50'134); + Env env(*this, features); - // CoverAvailable should have decreased significantly - BEAST_EXPECT(afterFirstCoverAvailable == 946); + auto lowerFee = [&]() { + // Run the local fee back down. + while (env.app().getFeeTrack().lowerLocalFee()) + ; + }; - env(manage(lender, loanBKeylet.key, tfLoanDefault), Ter(tesSUCCESS)); + auto const baseFee = env.current()->fees().base; - brokerSle = env.le(brokerKeylet); - if (!BEAST_EXPECT(brokerSle)) - return; - auto const afterSecondDebtTotal = brokerSle->at(sfDebtTotal); - auto const afterSecondCoverAvailable = brokerSle->at(sfCoverAvailable); + Account const alice{"alice"}; + std::string const borrowerPass = "borrower"; + Account const borrower{borrowerPass, KeyType::Ed25519}; + auto const lenderPass = "lender"; + Account const lender{lenderPass, KeyType::Ed25519}; - BEAST_EXPECT(afterSecondDebtTotal == 0); + env.fund(XRP(1'000'000), alice, lender, borrower); + env.close(); + env(noop(lender)); + env(noop(lender)); + env(noop(lender)); + env(noop(lender)); + env(noop(lender)); + env.close(); - BEAST_EXPECT(afterSecondCoverAvailable == 0); - } + { + testcase("RPC AccountSet"); + json::Value txJson{json::ValueType::Object}; + txJson[sfTransactionType] = "AccountSet"; + txJson[sfAccount] = borrower.human(); - void - testYieldTheftRounding(std::uint32_t flags) - { - testcase("Rounding manipulation does not permit yield theft"); - using namespace jtx; - using namespace loan; + auto const signParams = [&]() { + json::Value signParams{json::ValueType::Object}; + signParams[jss::passphrase] = borrowerPass; + signParams[jss::key_type] = "ed25519"; + signParams[jss::tx_json] = txJson; + return signParams; + }(); + auto const jSign = env.rpc("json", "sign", to_string(signParams)); + BEAST_EXPECT(jSign.isMember(jss::result) && jSign[jss::result].isMember(jss::tx_json)); + auto txSignResult = jSign[jss::result][jss::tx_json]; + auto txSignBlob = jSign[jss::result][jss::tx_blob].asString(); + txSignResult.removeMember(jss::hash); - // 1. Setup Environment - Env env(*this, all_); - Account const issuer{"issuer"}; - Account const lender{"lender"}; - Account const borrower{"borrower"}; + auto const jtx = env.jt(txJson, Sig(borrower)); + BEAST_EXPECT(txSignResult == jtx.jv); - env.fund(XRP(1000), issuer, lender, borrower); - env.close(); + lowerFee(); + auto const jSubmit = env.rpc("submit", txSignBlob); + BEAST_EXPECT( + jSubmit.isMember(jss::result) && + jSubmit[jss::result].isMember(jss::engine_result) && + jSubmit[jss::result][jss::engine_result].asString() == "tesSUCCESS"); - // 2. Asset Selection - PrettyAsset const iou = issuer["USD"]; - env(trust(lender, iou(100'000'000))); - env(trust(borrower, iou(100'000'000))); - env(pay(issuer, lender, iou(100'000'000))); - env(pay(issuer, borrower, iou(100'000'000))); - env.close(); + lowerFee(); + env(jtx.jv, Sig(kNone), Seq(kNone), Fee(kNone), Ter(tefPAST_SEQ)); + } - // 3. Create Vault and Broker with High Debt Limit (100M) - auto const brokerInfo = createVaultAndBroker( - env, - iou, - lender, + { + testcase("RPC LoanSet - illegal signature_target"); + + json::Value txJson{json::ValueType::Object}; + txJson[sfTransactionType] = "AccountSet"; + txJson[sfAccount] = borrower.human(); + + auto const borrowerSignParams = [&]() { + json::Value params{json::ValueType::Object}; + params[jss::passphrase] = borrowerPass; + params[jss::key_type] = "ed25519"; + params[jss::signature_target] = "Destination"; + params[jss::tx_json] = txJson; + return params; + }(); + auto const jSignBorrower = env.rpc("json", "sign", to_string(borrowerSignParams)); + BEAST_EXPECT( + jSignBorrower.isMember(jss::result) && + jSignBorrower[jss::result].isMember(jss::error) && + jSignBorrower[jss::result][jss::error] == "invalidParams" && + jSignBorrower[jss::result].isMember(jss::error_message) && + jSignBorrower[jss::result][jss::error_message] == "Destination"); + } + { + testcase("RPC LoanSet - sign and submit borrower initiated"); + // 1. Borrower creates the transaction + json::Value txJson{json::ValueType::Object}; + txJson[sfTransactionType] = "LoanSet"; + txJson[sfAccount] = borrower.human(); + txJson[sfCounterparty] = lender.human(); + txJson[sfLoanBrokerID] = + "FF924CD18A236C2B49CF8E80A351CEAC6A10171DC9F110025646894FEC" + "F83F" + "5C"; + txJson[sfPrincipalRequested] = "100000000"; + txJson[sfPaymentTotal] = 10000; + txJson[sfPaymentInterval] = 3600; + txJson[sfGracePeriod] = 300; + txJson[sfFlags] = 65536; // tfLoanOverpayment + txJson[sfFee] = to_string(24 * baseFee / 10); + + // 2. Borrower signs the transaction + auto const borrowerSignParams = [&]() { + json::Value params{json::ValueType::Object}; + params[jss::passphrase] = borrowerPass; + params[jss::key_type] = "ed25519"; + params[jss::tx_json] = txJson; + return params; + }(); + auto const jSignBorrower = env.rpc("json", "sign", to_string(borrowerSignParams)); + BEAST_EXPECTS( + jSignBorrower.isMember(jss::result) && + jSignBorrower[jss::result].isMember(jss::tx_json), + to_string(jSignBorrower)); + auto const txBorrowerSignResult = jSignBorrower[jss::result][jss::tx_json]; + auto const txBorrowerSignBlob = jSignBorrower[jss::result][jss::tx_blob].asString(); + + // 2a. Borrower attempts to submit the transaction. It doesn't + // work { - .vaultDeposit = 5'000'000, - .debtMax = Number{100'000'000}, - .coverDeposit = 500'000, - }); - auto const [currentSeq, vaultKeylet] = [&]() { - auto const brokerSle = env.le(keylet::loanBroker(brokerInfo.brokerID)); - if (!BEAST_EXPECT(brokerSle)) - return std::make_tuple(0u, keylet::unchecked(beast::kZero)); - auto const currentSeq = brokerSle->at(sfLoanSequence); - auto const vaultKeylet = keylet::vault(brokerSle->at(sfVaultID)); - return std::make_tuple(currentSeq, vaultKeylet); - }(); + lowerFee(); + auto const jSubmitBlob = env.rpc("submit", txBorrowerSignBlob); + BEAST_EXPECT(jSubmitBlob.isMember(jss::result)); + auto const jSubmitBlobResult = jSubmitBlob[jss::result]; + BEAST_EXPECT(jSubmitBlobResult.isMember(jss::tx_json)); + // Transaction fails because the CounterpartySignature is + // missing + BEAST_EXPECT( + jSubmitBlobResult.isMember(jss::engine_result) && + jSubmitBlobResult[jss::engine_result].asString() == "temBAD_SIGNER"); + } + + // 3. Borrower sends the signed transaction to the lender + // 4. Lender signs the transaction + auto const lenderSignParams = [&]() { + json::Value params{json::ValueType::Object}; + params[jss::passphrase] = lenderPass; + params[jss::key_type] = "ed25519"; + params[jss::signature_target] = "CounterpartySignature"; + params[jss::tx_json] = txBorrowerSignResult; + return params; + }(); + auto const jSignLender = env.rpc("json", "sign", to_string(lenderSignParams)); + BEAST_EXPECT( + jSignLender.isMember(jss::result) && + jSignLender[jss::result].isMember(jss::tx_json)); + auto const txLenderSignResult = jSignLender[jss::result][jss::tx_json]; + auto const txLenderSignBlob = jSignLender[jss::result][jss::tx_blob].asString(); + + // 5. Lender submits the signed transaction blob + lowerFee(); + auto const jSubmitBlob = env.rpc("submit", txLenderSignBlob); + BEAST_EXPECT(jSubmitBlob.isMember(jss::result)); + auto const jSubmitBlobResult = jSubmitBlob[jss::result]; + BEAST_EXPECT(jSubmitBlobResult.isMember(jss::tx_json)); + auto const jSubmitBlobTx = jSubmitBlobResult[jss::tx_json]; + // To get far enough to return tecNO_ENTRY means that the + // signatures all validated. Of course the transaction won't + // succeed because no Vault or Broker were created. + BEAST_EXPECTS( + jSubmitBlobResult.isMember(jss::engine_result) && + jSubmitBlobResult[jss::engine_result].asString() == "tecNO_ENTRY", + to_string(jSubmitBlobResult)); + + BEAST_EXPECT( + !jSubmitBlob.isMember(jss::error) && !jSubmitBlobResult.isMember(jss::error)); + + // 4-alt. Lender submits the transaction json originally + // received from the Borrower. It gets signed, but is now a + // duplicate, so fails. Borrower could done this instead of + // steps 4 and 5. + lowerFee(); + auto const jSubmitJson = env.rpc("json", "submit", to_string(lenderSignParams)); + BEAST_EXPECT(jSubmitJson.isMember(jss::result)); + auto const jSubmitJsonResult = jSubmitJson[jss::result]; + BEAST_EXPECT(jSubmitJsonResult.isMember(jss::tx_json)); + auto const jSubmitJsonTx = jSubmitJsonResult[jss::tx_json]; + // Since the previous tx claimed a fee, this duplicate is not + // going anywhere + BEAST_EXPECTS( + jSubmitJsonResult.isMember(jss::engine_result) && + jSubmitJsonResult[jss::engine_result].asString() == "tefPAST_SEQ", + to_string(jSubmitJsonResult)); - // 4. Loan Parameters (Attack Vector) - Number const principal = 1'000'000; - TenthBips32 const interestRate = TenthBips32{1}; // 0.001% - std::uint32_t const paymentInterval = 86400; - std::uint32_t const paymentTotal = 3650; + BEAST_EXPECT( + !jSubmitJson.isMember(jss::error) && !jSubmitJsonResult.isMember(jss::error)); - auto const loanSetFee = Fee(env.current()->fees().base * 2); - env(set(borrower, brokerInfo.brokerID, iou(principal).value(), flags), - Sig(sfCounterpartySignature, lender), - loan::kInterestRate(interestRate), - loan::kPaymentInterval(paymentInterval), - loan::kPaymentTotal(paymentTotal), - Fee(loanSetFee)); - env.close(); + BEAST_EXPECT(jSubmitBlobTx == jSubmitJsonTx); + } - // --- RETRIEVE OBJECTS & SETUP ATTACK --- + { + testcase("RPC LoanSet - sign and submit lender initiated"); + // 1. Lender creates the transaction + json::Value txJson{json::ValueType::Object}; + txJson[sfTransactionType] = "LoanSet"; + txJson[sfAccount] = lender.human(); + txJson[sfCounterparty] = borrower.human(); + txJson[sfLoanBrokerID] = + "FF924CD18A236C2B49CF8E80A351CEAC6A10171DC9F110025646894FEC" + "F83F" + "5C"; + txJson[sfPrincipalRequested] = "100000000"; + txJson[sfPaymentTotal] = 10000; + txJson[sfPaymentInterval] = 3600; + txJson[sfGracePeriod] = 300; + txJson[sfFlags] = 65536; // tfLoanOverpayment + txJson[sfFee] = to_string(24 * baseFee / 10); - auto borrowerBalance = [&]() { return env.balance(borrower, iou); }; - auto const borrowerScale = static_cast(borrowerBalance()).exponent(); + // 2. Lender signs the transaction + auto const lenderSignParams = [&]() { + json::Value params{json::ValueType::Object}; + params[jss::passphrase] = lenderPass; + params[jss::key_type] = "ed25519"; + params[jss::tx_json] = txJson; + return params; + }(); + auto const jSignLender = env.rpc("json", "sign", to_string(lenderSignParams)); + BEAST_EXPECT( + jSignLender.isMember(jss::result) && + jSignLender[jss::result].isMember(jss::tx_json)); + auto const txLenderSignResult = jSignLender[jss::result][jss::tx_json]; + auto const txLenderSignBlob = jSignLender[jss::result][jss::tx_blob].asString(); - auto const loanKeylet = keylet::loan(brokerInfo.brokerID, currentSeq); - auto const maybePeriodicPayment = [&]() -> std::optional { - auto const loanSle = env.le(loanKeylet); - if (!BEAST_EXPECT(loanSle)) - return std::nullopt; - // Construct Payment - return STAmount{iou, loanSle->at(sfPeriodicPayment)}; - }(); - if (!maybePeriodicPayment) - return; - auto const periodicPayment = *maybePeriodicPayment; - auto const roundedPayment = - roundToScale(periodicPayment, borrowerScale, Number::RoundingMode::Upward); - - // ATTACK: Add dust buffer (1e-9) to force 'excess' logic execution - STAmount const paymentBuffer{iou, Number(1, -9)}; - STAmount const attackPayment = periodicPayment + paymentBuffer; - - auto const maybeInitialVaultAssets = [&]() -> std::optional { - auto const vault = env.le(vaultKeylet); - if (!BEAST_EXPECT(vault)) - return std::nullopt; - return vault->at(sfAssetsTotal); - }(); - if (!maybeInitialVaultAssets) - return; - auto const initialVaultAssets = *maybeInitialVaultAssets; + // 2a. Lender attempts to submit the transaction. It doesn't + // work + { + lowerFee(); + auto const jSubmitBlob = env.rpc("submit", txLenderSignBlob); + BEAST_EXPECT(jSubmitBlob.isMember(jss::result)); + auto const jSubmitBlobResult = jSubmitBlob[jss::result]; + BEAST_EXPECT(jSubmitBlobResult.isMember(jss::tx_json)); + // Transaction fails because the CounterpartySignature is + // missing + BEAST_EXPECT( + jSubmitBlobResult.isMember(jss::engine_result) && + jSubmitBlobResult[jss::engine_result].asString() == "temBAD_SIGNER"); + } - // 5. Execution Loop - int yieldTheftCount = 0; - auto previousAssetsTotal = initialVaultAssets; + // 3. Lender sends the signed transaction to the Borrower + // 4. Borrower signs the transaction + auto const borrowerSignParams = [&]() { + json::Value params{json::ValueType::Object}; + params[jss::passphrase] = borrowerPass; + params[jss::key_type] = "ed25519"; + params[jss::signature_target] = "CounterpartySignature"; + params[jss::tx_json] = txLenderSignResult; + return params; + }(); + auto const jSignBorrower = env.rpc("json", "sign", to_string(borrowerSignParams)); + BEAST_EXPECT( + jSignBorrower.isMember(jss::result) && + jSignBorrower[jss::result].isMember(jss::tx_json)); + auto const txBorrowerSignResult = jSignBorrower[jss::result][jss::tx_json]; + auto const txBorrowerSignBlob = jSignBorrower[jss::result][jss::tx_blob].asString(); - for (int i = 0; i < 100; ++i) - { - auto const balanceBefore = borrowerBalance(); - env(pay(borrower, loanKeylet.key, attackPayment, flags)); - env.close(); - auto const borrowerDelta = balanceBefore - borrowerBalance(); - BEAST_EXPECT(borrowerDelta.signum() == roundedPayment.signum()); + // 5. Borrower submits the signed transaction blob + lowerFee(); + auto const jSubmitBlob = env.rpc("submit", txBorrowerSignBlob); + BEAST_EXPECT(jSubmitBlob.isMember(jss::result)); + auto const jSubmitBlobResult = jSubmitBlob[jss::result]; + BEAST_EXPECT(jSubmitBlobResult.isMember(jss::tx_json)); + auto const jSubmitBlobTx = jSubmitBlobResult[jss::tx_json]; + // To get far enough to return tecNO_ENTRY means that the + // signatures all validated. Of course the transaction won't + // succeed because no Vault or Broker were created. + BEAST_EXPECTS( + jSubmitBlobResult.isMember(jss::engine_result) && + jSubmitBlobResult[jss::engine_result].asString() == "tecNO_ENTRY", + to_string(jSubmitBlobResult)); - auto const loanSle = env.le(loanKeylet); - if (!BEAST_EXPECT(loanSle)) - break; - auto const updatedPayment = STAmount{iou, loanSle->at(sfPeriodicPayment)}; - BEAST_EXPECT( - (roundToScale(updatedPayment, borrowerScale, Number::RoundingMode::Upward) == - roundedPayment)); BEAST_EXPECT( - (updatedPayment == periodicPayment) || - (flags == tfLoanOverpayment && i >= 2 && updatedPayment < periodicPayment)); - - auto const currentVaultSle = env.le(vaultKeylet); - if (!BEAST_EXPECT(currentVaultSle)) - break; + !jSubmitBlob.isMember(jss::error) && !jSubmitBlobResult.isMember(jss::error)); - auto const currentAssetsTotal = currentVaultSle->at(sfAssetsTotal); - auto const delta = currentAssetsTotal - previousAssetsTotal; + // 4-alt. Borrower submits the transaction json originally + // received from the Lender. It gets signed, but is now a + // duplicate, so fails. Lender could done this instead of steps + // 4 and 5. + lowerFee(); + auto const jSubmitJson = env.rpc("json", "submit", to_string(borrowerSignParams)); + BEAST_EXPECT(jSubmitJson.isMember(jss::result)); + auto const jSubmitJsonResult = jSubmitJson[jss::result]; + BEAST_EXPECT(jSubmitJsonResult.isMember(jss::tx_json)); + auto const jSubmitJsonTx = jSubmitJsonResult[jss::tx_json]; + // Since the previous tx claimed a fee, this duplicate is not + // going anywhere + BEAST_EXPECTS( + jSubmitJsonResult.isMember(jss::engine_result) && + jSubmitJsonResult[jss::engine_result].asString() == "tefPAST_SEQ", + to_string(jSubmitJsonResult)); BEAST_EXPECT( - (delta == beast::kZero && borrowerDelta <= roundedPayment) || - (delta > beast::kZero && borrowerDelta > roundedPayment)); - - // If tx succeeded but Assets Total didn't change, interest was - // stolen. - if (delta == beast::kZero && borrowerDelta > roundedPayment) - { - yieldTheftCount++; - } + !jSubmitJson.isMember(jss::error) && !jSubmitJsonResult.isMember(jss::error)); - previousAssetsTotal = currentAssetsTotal; + BEAST_EXPECT(jSubmitBlobTx == jSubmitJsonTx); } - - BEAST_EXPECTS(yieldTheftCount == 0, std::to_string(yieldTheftCount)); } - // Tests that vault withdrawals work correctly when the vault has unrealized - // loss from an impaired loan, ensuring the invariant check properly - // accounts for the loss. void - testWithdrawReflectsUnrealizedLoss(FeatureBitset features) + testServiceFeeOnBrokerDeepFreeze() { + testcase << "Service Fee On Broker Deep Freeze"; using namespace jtx; using namespace loan; - using namespace std::chrono_literals; - - testcase("Vault withdraw reflects sfLossUnrealized"); - - // Test constants - static constexpr std::int64_t kInitialFunding = 1'000'000; - static constexpr std::int64_t kLenderInitialIou = 5'000'000; - static constexpr std::int64_t kDepositorInitialIou = 1'000'000; - static constexpr std::int64_t kBorrowerInitialIou = 100'000; - static constexpr std::int64_t kDepositAmount = 5'000; - static constexpr std::int64_t kPrincipalAmount = 99; - static constexpr std::uint64_t kExpectedSharesPerDepositor = 5'000'000'000; - static constexpr std::uint32_t kLocalPaymentInterval = 600; - static constexpr std::uint32_t kLocalPaymentTotal = 2; - - Env env{*this, features}; - - // Setup accounts - Account const issuer{"issuer"}; - Account const lender{"lender"}; - Account const depositorA{"lpA"}; - Account const depositorB{"lpB"}; - Account const borrower{"borrowerA"}; + Account const issuer("issuer"); + Account const borrower("borrower"); + Account const broker("broker"); + auto const iou = issuer["IOU"]; - env.fund(XRP(kInitialFunding), issuer, lender, depositorA, depositorB, borrower); - env.close(); + for (bool const deepFreeze : {true, false}) + { + Env env(*this); - // Setup trust lines - PrettyAsset const iouAsset = issuer[iouCurrency_]; - env(trust(lender, iouAsset(10'000'000))); - env(trust(depositorA, iouAsset(10'000'000))); - env(trust(depositorB, iouAsset(10'000'000))); - env(trust(borrower, iouAsset(10'000'000))); - env.close(); + auto getCoverBalance = [&](BrokerInfo const& brokerInfo, auto const& accountField) { + if (auto const le = env.le(keylet::loanBroker(brokerInfo.brokerID)); + BEAST_EXPECT(le)) + { + auto const account = le->at(accountField); + if (auto const sleLine = env.le(keylet::trustLine(account, iou)); + BEAST_EXPECT(sleLine)) + { + STAmount balance = sleLine->at(sfBalance); + if (account > issuer.id()) + balance.negate(); + return balance; + } + } + return STAmount{iou}; + }; - // Fund accounts with IOUs - env(pay(issuer, lender, iouAsset(kLenderInitialIou))); - env(pay(issuer, depositorA, iouAsset(kDepositorInitialIou))); - env(pay(issuer, depositorB, iouAsset(kDepositorInitialIou))); - env(pay(issuer, borrower, iouAsset(kBorrowerInitialIou))); - env.close(); + env.fund(XRP(20'000), issuer, broker, borrower); + env.close(); - // Create vault and broker, then add deposits from two depositors - auto const broker = createVaultAndBroker(env, iouAsset, lender); - Vault v{env}; - - env(v.deposit({ - .depositor = depositorA, - .id = broker.vaultKeylet().key, - .amount = iouAsset(kDepositAmount), - }), - Ter(tesSUCCESS)); - env(v.deposit({ - .depositor = depositorB, - .id = broker.vaultKeylet().key, - .amount = iouAsset(kDepositAmount), - }), - Ter(tesSUCCESS)); - env.close(); + env(trust(broker, iou(20'000'000))); + env(pay(issuer, broker, iou(10'000'000))); + env.close(); - // Create a loan - auto const sleBroker = env.le(keylet::loanBroker(broker.brokerID)); - if (!BEAST_EXPECT(sleBroker)) - return; + auto const brokerInfo = createVaultAndBroker(env, iou, broker); - auto const loanKeylet = keylet::loan(broker.brokerID, sleBroker->at(sfLoanSequence)); + BEAST_EXPECT(getCoverBalance(brokerInfo, sfAccount) == iou(1'000)); - env(set(borrower, broker.brokerID, kPrincipalAmount), - Sig(sfCounterpartySignature, lender), - kPaymentTotal(kLocalPaymentTotal), - kPaymentInterval(kLocalPaymentInterval), - Fee(env.current()->fees().base * 2), - Ter(tesSUCCESS)); - env.close(); + auto const keylet = keylet::loan(brokerInfo.brokerID, 1); - // Impair the loan to create unrealized loss - env(manage(lender, loanKeylet.key, tfLoanImpair), Ter(tesSUCCESS)); - env.close(); + env(set(borrower, brokerInfo.brokerID, 10'000), + Sig(sfCounterpartySignature, broker), + kLoanServiceFee(iou(100).value()), + kPaymentInterval(100), + Fee(XRP(100))); + env.close(); - // Verify unrealized loss is recorded in the vault - auto const vaultAfterImpair = env.le(broker.vaultKeylet()); - if (!BEAST_EXPECT(vaultAfterImpair)) - return; + env(trust(borrower, iou(20'000'000))); + // The borrower increases their limit and acquires some IOU so + // they can pay interest + env(pay(issuer, borrower, iou(500))); + env.close(); - BEAST_EXPECT( - vaultAfterImpair->at(sfLossUnrealized) == broker.asset(kPrincipalAmount).value()); + if (auto const le = env.le(keylet::loan(keylet.key)); BEAST_EXPECT(le)) + { + if (deepFreeze) + { + env(trust(issuer, broker["IOU"](0), tfSetFreeze | tfSetDeepFreeze)); + env.close(); + } - // Helper to get share balance for a depositor - auto const shareAsset = vaultAfterImpair->at(sfShareMPTID); - auto const getShareBalance = [&](Account const& depositor) -> std::uint64_t { - auto const token = env.le(keylet::mptoken(shareAsset, depositor.id())); - return token ? token->getFieldU64(sfMPTAmount) : 0; - }; + env(pay(borrower, keylet.key, iou(10'100)), Fee(XRP(100))); + env.close(); - // Verify both depositors have equal shares - auto const sharesLpA = getShareBalance(depositorA); - auto const sharesLpB = getShareBalance(depositorB); - BEAST_EXPECT(sharesLpA == kExpectedSharesPerDepositor); - BEAST_EXPECT(sharesLpB == kExpectedSharesPerDepositor); - BEAST_EXPECT(sharesLpA == sharesLpB); - - // Helper to attempt withdrawal - auto const attemptWithdrawShares = [&](Account const& depositor, - std::uint64_t shareAmount, - TER expected) { - STAmount const shareAmt{MPTIssue{shareAsset}, Number(shareAmount)}; - env(v.withdraw( - {.depositor = depositor, .id = broker.vaultKeylet().key, .amount = shareAmt}), - Ter(expected)); - env.close(); + if (deepFreeze) + { + // The fee goes to the broker pseudo-account + BEAST_EXPECT(getCoverBalance(brokerInfo, sfAccount) == iou(1'100)); + BEAST_EXPECT(getCoverBalance(brokerInfo, sfOwner) == iou(8'999'000)); + } + else + { + // The fee goes to the broker account + BEAST_EXPECT(getCoverBalance(brokerInfo, sfOwner) == iou(8'999'100)); + BEAST_EXPECT(getCoverBalance(brokerInfo, sfAccount) == iou(1'000)); + } + } }; - - // Regression test: Both depositors should successfully withdraw despite - // unrealized loss. Previously failed with invariant violation: - // "withdrawal must change vault and destination balance by equal - // amount". This was caused by sharesToAssetsWithdraw rounding down, - // creating a mismatch where vaultDeltaAssets * -1 != destinationDelta - // when unrealized loss exists. - attemptWithdrawShares(depositorA, sharesLpA, tesSUCCESS); - attemptWithdrawShares(depositorB, sharesLpB, tesSUCCESS); } - // A residual overpayment can reduce the stored principal by one scale-unit - // *less* than computeOverpaymentComponents predicts, firing the - // "principal change agrees" XRPL_ASSERT_PARTS in doOverpayment: - // - // trackedPrincipalDelta == principalOutstanding - newPrincipalOutstanding - // - // tryOverpayment re-amortizes the loan at the reduced principal, then - // re-derives the theoretical principal from the new periodic payment via - // (P * paymentFactor) / paymentFactor. That round-trip is not exact in - // Number's 19-digit arithmetic; a positive residual pushes the recomputed - // principal a hair above the exact grid point `oldPrincipal - delta`, and - // the Upward rounding in tryOverpayment then bumps it a full scale-unit - // higher. The principal therefore drops by `delta - 1 unit`, not `delta`. - // - // Concrete case (isolated, at the tryOverpayment level): - // A 100 USD loan at the minimum non-zero rate, 3 payments, loanScale -10. - // After one regular payment (principalOutstanding 66.6666666674) a residual overpayment of - // 0.049999998 yields trackedPrincipalDelta 0.048999998 but only reduces the principal by - // 0.0489999979 (newPrincipal 66.6176666695) — short by 1e-10. - // - // With fixCleanup3_2_0, tryOverpayment pins the new principal to the exact, - // on-grid reduction (oldPrincipal - trackedPrincipalDelta) instead of the - // lossy (P*factor)/factor round-trip, so the assertion holds and the - // overpayment applies cleanly. The three "principal change agrees" / - // "interest paid agrees" / "principal payment matches" assertions are - // gated behind the same amendment, so without it they are disabled (the - // server does not abort) and the loan keeps the pre-amendment computation. - // - // The test runs the same scenario under both amendment settings and checks - // the stored principal against a ground-truth value derived independently of - // the loan-state computation under test. void - testBugOverpaymentPrincipalChange() + testIssuerLoan() { - testcase("bug: doOverpayment asserts 'principal change agrees'"); + testcase << "Issuer Loan"; using namespace jtx; using namespace loan; - using namespace xrpl::detail; - - struct Params - { - TenthBips32 interestRate; - TenthBips16 managementFeeRate; - std::uint32_t paymentTotal; - std::uint32_t paymentInterval; - std::int64_t principal; - Number overpayment; - TenthBips32 overpaymentInterestRate; - TenthBips32 overpaymentFeeRate; - std::optional vaultScale; - }; - - struct Result - { - Number principalOutstanding; // stored principal after the LoanPay - Number expectedNewPrincipal; // ground truth, independent of the fix - Number managementFeeChange; // managementFeeOutstanding after - before - Number unit; // one scale-unit at the loan scale - }; - - auto runScenario = [this](FeatureBitset features, Params const& p) -> Result { - Env env(*this, features); - - Account const issuer{"issuer"}; - Account const lender{"vaultOwner"}; - Account const borrower{"borrower"}; - - env.fund(XRP(1'000'000), issuer, lender, borrower); - env(fset(issuer, asfDefaultRipple)); - env.close(); - - PrettyAsset const iouAsset = issuer["USD"]; - Asset const asset = iouAsset.raw(); - STAmount const iouLimit{asset, Number{9'999'999'999'999'999LL}}; - env(trust(lender, iouLimit)); - env(trust(borrower, iouLimit)); - env(pay(issuer, lender, iouAsset(1'000'000))); - env(pay(issuer, borrower, iouAsset(1'000'000))); - env.close(); - - auto const broker = createVaultAndBroker( - env, - iouAsset, - lender, - {.vaultDeposit = 900'000, - .debtMax = 0, - .managementFeeRate = p.managementFeeRate, - .vaultScale = p.vaultScale}); - - auto const brokerSle = env.le(broker.brokerKeylet()); - BEAST_EXPECT(brokerSle); - auto const loanSequence = brokerSle ? brokerSle->at(sfLoanSequence) : 0; - auto const loanKeylet = keylet::loan(broker.brokerID, loanSequence); - - env(set(borrower, broker.brokerID, Number{p.principal}, tfLoanOverpayment), - Sig(sfCounterpartySignature, lender), - kInterestRate(p.interestRate), - kPaymentTotal(p.paymentTotal), - kPaymentInterval(p.paymentInterval), - kGracePeriod(p.paymentInterval), - kOverpaymentFee(p.overpaymentFeeRate), - kOverpaymentInterestRate(p.overpaymentInterestRate), - Fee(env.current()->fees().base * 2), - Ter(tesSUCCESS)); - env.close(); + Account const issuer("issuer"); + Account const borrower = issuer; + Account const lender("lender"); + Env env(*this); - // The single LoanPay below makes one regular payment (the overpayment - // is smaller than one period) and leaves the residual as an - // overpayment. - auto const s = getCurrentState(env, broker, loanKeylet); - auto const periodicRate = loanPeriodicRate(s.interestRate, s.paymentInterval); - auto const onePeriod = computePaymentComponents( - env.current()->rules(), - asset, - s.loanScale, - s.totalValue, - s.principalOutstanding, - s.managementFeeOutstanding, - s.periodicPayment, - periodicRate, - s.paymentRemaining, - p.managementFeeRate); - - // Ground truth: the stored principal must drop by exactly the regular - // payment's principal portion plus the overpayment's principal - // portion. computeOverpaymentComponents depends only on the - // overpayment amount and rates (not on the loan-state computation - // under test), so it is an independent oracle. Both components are - // computed under the same rules as the env so the payment factor - // matches. - auto const overpaymentComponents = computeOverpaymentComponents( - env.current()->rules(), - asset, - s.loanScale, - p.overpayment, - p.overpaymentInterestRate, - p.overpaymentFeeRate, - p.managementFeeRate); - Number const expectedNewPrincipal = s.principalOutstanding - - onePeriod.trackedPrincipalDelta - overpaymentComponents.trackedPrincipalDelta; - - Number const managementFeeBefore = s.managementFeeOutstanding; - - STAmount const payAmount{asset, onePeriod.trackedValueDelta + p.overpayment}; - env(pay(borrower, loanKeylet.key, payAmount), - Txflags(tfLoanOverpayment), - Ter(tesSUCCESS)); - env.close(); + env.fund(XRP(1'000), issuer, lender); - auto const loanSle = env.le(loanKeylet); - BEAST_EXPECT(loanSle); + static constexpr std::int64_t kIssuerBalance = 10'000'000; + MPTTester const asset( + {.env = env, .issuer = issuer, .holders = {lender}, .pay = kIssuerBalance}); - return Result{ - .principalOutstanding = loanSle ? Number{loanSle->at(sfPrincipalOutstanding)} : 0, - .expectedNewPrincipal = expectedNewPrincipal, - .managementFeeChange = - (loanSle ? Number{loanSle->at(sfManagementFeeOutstanding)} : Number{0}) - - managementFeeBefore, - .unit = Number{1, s.loanScale}}; + BrokerParameters const brokerParams{ + .debtMax = 200, }; - - // Scenario 1: the original near-zero-rate principal reproduction - // (loanScale -10, no management fee). 0.049999998 is smaller than one - // period, so it stays a residual overpayment. - Params const principalCase{ - .interestRate = TenthBips32{1}, - .managementFeeRate = TenthBips16{0}, - .paymentTotal = 3, - .paymentInterval = 60, - .principal = 100, - .overpayment = Number{49999998, -9}, - .overpaymentInterestRate = TenthBips32{1000}, - .overpaymentFeeRate = TenthBips32{1000}, - .vaultScale = 1}; - - // With fixCleanup3_2_0 the stored principal lands exactly on the - // ground-truth grid point: it is reduced by exactly the overpayment's - // principal portion. This is the key correctness check: if the principal - // pin were removed (even with the assertions still gated off), the lossy - // (P * factor) / factor round-trip would leave the principal one - // scale-unit high and this would fail. - Result const fixed = runScenario(all_, principalCase); - BEAST_EXPECTS( - fixed.principalOutstanding == fixed.expectedNewPrincipal, - "fixed principal " + to_string(fixed.principalOutstanding) + " != expected " + - to_string(fixed.expectedNewPrincipal)); - - // Without the amendment the loan amortizes with the catastrophically - // cancelling near-zero payment factor, so its schedule (and ground truth) - // differ from the fixed case; the gated assertions keep the server from - // aborting and the overpayment still lands exactly on that schedule. - Result const legacy = runScenario(all_ - fixCleanup3_2_0, principalCase); - BEAST_EXPECTS( - legacy.principalOutstanding == legacy.expectedNewPrincipal, - "legacy principal " + to_string(legacy.principalOutstanding) + " != expected " + - to_string(legacy.expectedNewPrincipal)); - - // Scenario 2: a normal-rate loan with a 10% management fee. At a normal - // rate the payment factor is identical across the amendment, so toggling - // fixCleanup3_2_0 isolates the fix. This overpayment (found by search) - // lands on a state where both the principal and the management fee differ - // by one scale-unit between the fixed and legacy paths. - Params const feeCase{ - .interestRate = TenthBips32{10000}, - .managementFeeRate = TenthBips16{10000}, - .paymentTotal = 6, - .paymentInterval = 30u * 24 * 60 * 60, - .principal = 1000, - .overpayment = Number{214367363, -10}, - .overpaymentInterestRate = TenthBips32{0}, - .overpaymentFeeRate = TenthBips32{0}, - .vaultScale = std::nullopt}; - - Result const feeFixed = runScenario(all_, feeCase); - Result const feeLegacy = runScenario(all_ - fixCleanup3_2_0, feeCase); - - // With the fix the principal is the exact reduction; without it the lossy - // (P * factor) / factor round-trip leaves it one scale-unit high. - BEAST_EXPECTS( - feeFixed.principalOutstanding == feeFixed.expectedNewPrincipal, - "fee-case fixed principal " + to_string(feeFixed.principalOutstanding) + - " != expected " + to_string(feeFixed.expectedNewPrincipal)); - BEAST_EXPECTS( - feeLegacy.principalOutstanding == feeLegacy.expectedNewPrincipal + feeLegacy.unit, - "fee-case legacy principal " + to_string(feeLegacy.principalOutstanding) + - " != expected " + to_string(feeLegacy.expectedNewPrincipal + feeLegacy.unit)); - - // Management fee: the overpayment re-amortizes a fee-bearing loan, so the management fee - // outstanding drops. - // - // Unlike the principal that is already at the correct precision, the re-amortized - // management fee is tenthBipsOfValue of the new schedule's gross interest, which depends - // on the recomputed periodic payment. So the expected change below is a pinned constant - // captured from a passing run a magic value only because there is nothing simpler to - // compare against. - // - // At the integration level, toggling the amendment also changes the regular payment's - // rounding so a fixed-vs-legacy comparison cannot isolate the overpayment management-fee - // fix. - BEAST_EXPECT(feeFixed.managementFeeChange == feeLegacy.managementFeeChange); - BEAST_EXPECTS( - (feeFixed.managementFeeChange == Number{-8219709543, -10}), - "fee-case mgmt fee change " + to_string(feeFixed.managementFeeChange)); + auto const broker = createVaultAndBroker(env, asset, lender, brokerParams); + auto const loanSetFee = Fee(env.current()->fees().base * 2); + // Create Loan + env(set(borrower, broker.brokerID, 200), Sig(sfCounterpartySignature, lender), loanSetFee); + env.close(); + // Issuer should not create MPToken + BEAST_EXPECT(!env.le(keylet::mptoken(asset.issuanceID(), issuer))); + // Issuer "borrowed" 200, OutstandingAmount decreased by 200 + BEAST_EXPECT(env.balance(issuer, asset) == asset(-kIssuerBalance + 200)); + // Pay Loan + auto const loanKeylet = keylet::loan(broker.brokerID, 1); + env(pay(borrower, loanKeylet.key, asset(200))); + env.close(); + // Issuer "re-payed" 200, OutstandingAmount increased by 200 + BEAST_EXPECT(env.balance(issuer, asset) == asset(-kIssuerBalance)); } - // A LoanSet with InterestRate = 1 (0.001% annualized, the minimum non-zero - // rate). At such a near-zero rate the closed-form payment factor - // (1 + r)^n - 1 cancels catastrophically. - // - // Without fixCleanup3_2_0 the resulting amortization is degenerate and the - // LoanSet is rejected with tecPRECISION_LOSS (no loan created). With the - // amendment, computePowerMinusOneHybrid uses a numerically-stable series - // expansion, so the loan is created and the scheduled payments - // (2 * periodicPayment) cover the principal — no economic underpayment - // (yield theft). - // - // The test runs the same LoanSet under both amendment settings and pins the - // exact outcome for each. void - testLoanSetNearZeroInterestRateSucceeds() + testInvalidLoanDelete() { - testcase("LoanSet near-zero interest rate covers principal"); - + testcase("Invalid LoanDelete"); using namespace jtx; using namespace loan; - Number const principalRequested{1000}; - - struct Result + // preflight: temINVALID, LoanID == zero { - TER ter = tesSUCCESS; - bool created = false; - std::int32_t loanScale = 0; - Number principal; - Number totalValue; - Number managementFee; - Number periodicPayment; - }; - - auto runScenario = [&](FeatureBitset features, TER expectedTer) -> Result { - Env env(*this, features); - - Account const issuer{"issuer"}; - Account const lender{"vaultOwner"}; - Account const borrower{"borrower"}; - - env.fund(XRP(1'000'000), issuer, lender, borrower); - 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(lender, iouLimit)); - env(trust(borrower, iouLimit)); - env(pay(issuer, lender, iouAsset(1'000'000))); - env(pay(issuer, borrower, iouAsset(1'000'000))); + Account const alice{"alice"}; + Env env(*this); + env.fund(XRP(1'000), alice); env.close(); + env(del(alice, beast::kZero), Ter(temINVALID)); + } + } - auto const broker = createVaultAndBroker( - env, - iouAsset, - lender, - {.vaultDeposit = 100'000, .debtMax = 0, .managementFeeRate = TenthBips16{0}}); - - auto const brokerSle = env.le(broker.brokerKeylet()); - BEAST_EXPECT(brokerSle); - auto const loanSequence = brokerSle ? brokerSle->at(sfLoanSequence) : 0; - auto const loanKeylet = keylet::loan(broker.brokerID, loanSequence); + void + testInvalidLoanManage() + { + testcase("Invalid LoanManage"); + using namespace jtx; + using namespace loan; - env(set(borrower, broker.brokerID, principalRequested), - Sig(sfCounterpartySignature, lender), - kInterestRate(TenthBips32{1}), - kPaymentTotal(2), - kPaymentInterval(400), - Fee(env.current()->fees().base * 2), - Ter(expectedTer)); + // preflight: temINVALID, LoanID == zero + { + Account const alice{"alice"}; + Env env(*this); + env.fund(XRP(1'000), alice); env.close(); - - Result r; - r.ter = env.ter(); - if (auto const loanSle = env.le(loanKeylet)) - { - r.created = true; - r.loanScale = loanSle->at(sfLoanScale); - r.principal = loanSle->at(sfPrincipalOutstanding); - r.totalValue = loanSle->at(sfTotalValueOutstanding); - r.managementFee = loanSle->at(sfManagementFeeOutstanding); - r.periodicPayment = loanSle->at(sfPeriodicPayment); - } - return r; - }; - - Result const fixed = runScenario(all_, tesSUCCESS); - Result const legacy = runScenario(all_ - fixCleanup3_2_0, tecPRECISION_LOSS); - - // Without the amendment, the catastrophically-cancelling closed-form - // payment factor produces a degenerate amortization that fails - // checkLoanGuards: the LoanSet is rejected with tecPRECISION_LOSS and no - // loan is created. - BEAST_EXPECT(legacy.ter == tecPRECISION_LOSS); - BEAST_EXPECT(!legacy.created); - - // With the amendment the stable series expansion produces a valid loan - // at loanScale -10. - BEAST_EXPECT(fixed.ter == tesSUCCESS); - BEAST_EXPECT(fixed.created); - BEAST_EXPECT(fixed.loanScale == -10); - BEAST_EXPECT(fixed.principal == principalRequested); - BEAST_EXPECT((fixed.totalValue == Number{10000000001903, -10})); - BEAST_EXPECT(fixed.managementFee == beast::kZero); - - // Periodic payment from the numerically-stable series expansion, and the - // scheduled total (2 * periodicPayment) which exceeds the 1000 principal - // — no economic underpayment / yield theft. - BEAST_EXPECT((fixed.periodicPayment == Number{5000000000951293762, -16})); - BEAST_EXPECT((fixed.periodicPayment * 2 == Number{1000000000190258752, -15})); - BEAST_EXPECT(fixed.periodicPayment * 2 > principalRequested); + env(manage(alice, beast::kZero, tfLoanDefault), Ter(temINVALID)); + } } - // 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() + testInvalidLoanPay() { - testcase("bug: computeOverpaymentComponents isRounded assertion"); - + testcase("Invalid LoanPay"); using namespace jtx; using namespace loan; - Env env(*this, all_); - + Account const lender{"lender"}; Account const issuer{"issuer"}; - Account const lender{"vaultOwner"}; Account const borrower{"borrower"}; + auto const iou = issuer["IOU"]; + + // preclaim + Env env(*this); + env.fund(XRP(1'000), lender, issuer, borrower); + env(trust(lender, iou(10'000'000))); + env(pay(issuer, lender, iou(5'000'000))); + BrokerInfo brokerInfo{createVaultAndBroker(env, issuer["IOU"], lender)}; + + auto const loanSetFee = Fee(env.current()->fees().base * 2); + STAmount const debtMaximumRequest = brokerInfo.asset(1'000).value(); + + env(set(borrower, brokerInfo.brokerID, debtMaximumRequest), + Sig(sfCounterpartySignature, lender), + loanSetFee); - env.fund(XRP(1'000'000), issuer, lender, borrower); - 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(lender, iouLimit)); - env(trust(borrower, iouLimit)); - env(pay(issuer, lender, iouAsset(1'000'000))); - env(pay(issuer, borrower, iouAsset(1'000'000))); + std::uint32_t const loanSequence = 1; + auto const loanKeylet = keylet::loan(brokerInfo.brokerID, loanSequence); + + env(fset(issuer, asfGlobalFreeze)); env.close(); - auto const broker = createVaultAndBroker( - env, - iouAsset, - lender, - {.vaultDeposit = 100'000, - .debtMax = 5000, - .managementFeeRate = TenthBips16{1000}, - .vaultScale = 1}); + // preclaim: tecFROZEN + env(pay(borrower, loanKeylet.key, debtMaximumRequest), Ter(tecFROZEN)); + env.close(); + + env(fclear(issuer, asfGlobalFreeze)); + env.close(); + + auto const pseudoBroker = [&]() -> std::optional { + if (auto brokerSle = env.le(keylet::loanBroker(brokerInfo.brokerID)); + BEAST_EXPECT(brokerSle)) + { + return Account{"pseudo", brokerSle->at(sfAccount)}; + } - auto const sleBroker = env.le(broker.brokerKeylet()); - if (!BEAST_EXPECT(sleBroker)) + return std::nullopt; + }(); + if (!pseudoBroker) return; - auto const loanSequence = sleBroker->at(sfLoanSequence); - auto const loanKeylet = keylet::loan(broker.brokerID, loanSequence); - using namespace loan; - env(set(borrower, broker.brokerID, Number{1000}, tfLoanOverpayment), - Sig(sfCounterpartySignature, lender), - kInterestRate(TenthBips32{10000}), - kPaymentTotal(12), - kPaymentInterval(60), - kGracePeriod(60), - kOverpaymentFee(TenthBips32{1000}), - kOverpaymentInterestRate(TenthBips32{1000}), - Fee(env.current()->fees().base * 2), - Ter(tesSUCCESS)); + // Lender and pseudoaccount must both be frozen + env(trust(issuer, lender["IOU"](1'000), lender, tfSetFreeze | tfSetDeepFreeze)); + env(trust( + issuer, (*pseudoBroker)["IOU"](1'000), *pseudoBroker, tfSetFreeze | tfSetDeepFreeze)); + env.close(); + + // preclaim: tecFROZEN due to deep frozen + env(pay(borrower, loanKeylet.key, debtMaximumRequest), Ter(tecFROZEN)); + env.close(); + + // Only one needs to be unfrozen + env(trust(issuer, lender["IOU"](1'000), tfClearFreeze | tfClearDeepFreeze)); 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)); + // The payment is late by this point + env(pay(borrower, loanKeylet.key, debtMaximumRequest), Ter(tecEXPIRED)); + env.close(); + env(pay(borrower, loanKeylet.key, debtMaximumRequest, tfLoanLatePayment)); env.close(); + + // preclaim: tecKILLED + // note that tecKILLED in loanMakePayment() + // doesn't happen because of the preclaim check. + env(pay(borrower, loanKeylet.key, debtMaximumRequest), Ter(tecKILLED)); } - // Regression for the dual-rounding fix at coarse (integer-MPT) scale. - // - // Loan: P=1, r=50% (50000 tenth-bips), n=3, yearly interval. The - // amortization schedule produces a fractional principal - // (~0.47) which under round-to-nearest collapses to 0 in a single - // step, causing `doPayment`'s strict `>` assertion on principal to - // fire mid-loan. With fixCleanup3_2_0 enabled, principal is rounded - // upward (sticks at 1 across the first two periods) and only clears - // in the final payment. - // - // The test pays one period at a time across three LoanPay - // transactions and verifies the loan completes (paymentRemaining=0) - // with totals matching the loan's economics (1 principal + 2 interest). void - testIntegerScalePrincipalSticks(FeatureBitset features) + testInvalidLoanSet() { - // Without fixCleanup3_2_0, this behavior will abort the server, so - // don't run without it. - if (!features[fixCleanup3_2_0]) - return; - - testcase("edge: integer MPT principal stuck mid-loan completes via final"); - + testcase("Invalid LoanSet"); using namespace jtx; - Env env(*this, features); - - Account const issuer{"issuer"}; + using namespace loan; Account const lender{"lender"}; + Account const issuer{"issuer"}; Account const borrower{"borrower"}; + Account const sponsor{"sponsor"}; + auto const iou = issuer["IOU"]; - env.fund(XRP(100'000), issuer, lender, borrower); - env.close(); + auto testWrapper = [&](auto&& test) { + Env env(*this); + env.fund(XRP(1'000), lender, issuer, borrower, sponsor); + env(trust(lender, iou(10'000'000))); + env(pay(issuer, lender, iou(5'000'000))); + BrokerInfo const brokerInfo{createVaultAndBroker(env, issuer["IOU"], lender)}; - MPTTester mptt{env, issuer, kMptInitNoFund}; - mptt.create({.maxAmt = 100'000, .flags = tfMPTCanTransfer}); - PrettyAsset const asset{mptt.issuanceID()}; + auto const loanSetFee = Fee(env.current()->fees().base * 2); + Number const debtMaximumRequest = brokerInfo.asset(1'000).value(); + test(env, brokerInfo, loanSetFee, debtMaximumRequest); + }; - mptt.authorize({.account = lender}); - mptt.authorize({.account = borrower}); + // preflight: + testWrapper([&](Env& env, + BrokerInfo const& brokerInfo, + jtx::Fee const& loanSetFee, + Number const& debtMaximumRequest) { + for (auto const sponsorFlags : {spfSponsorReserve, spfSponsorReserve | spfSponsorFee}) + { + env(set(borrower, brokerInfo.brokerID, debtMaximumRequest), + sponsor::As(sponsor, sponsorFlags), + Sig(sfCounterpartySignature, lender), + loanSetFee, + Ter(temINVALID_FLAG)); + } - env(pay(issuer, lender, asset(10'000))); - env(pay(issuer, borrower, asset(10'000))); - env.close(); + // first temBAD_SIGNER: TODO + // invalid grace period + { + // zero grace period + env(set(borrower, brokerInfo.brokerID, debtMaximumRequest), + Sig(sfCounterpartySignature, lender), + kGracePeriod(0), + loanSetFee, + Ter(temINVALID)); - Vault const vault{env}; - auto [vaultTx, vaultKeylet] = vault.create({.owner = lender, .asset = asset}); - env(vaultTx); - env.close(); + // grace period less than default minimum + env(set(borrower, brokerInfo.brokerID, debtMaximumRequest), + Sig(sfCounterpartySignature, lender), + kGracePeriod(LoanSet::kDefaultGracePeriod - 1), + loanSetFee, + Ter(temINVALID)); - env(vault.deposit({.depositor = lender, .id = vaultKeylet.key, .amount = asset(5'000)})); - env.close(); + // grace period greater than payment interval + env(set(borrower, brokerInfo.brokerID, debtMaximumRequest), + Sig(sfCounterpartySignature, lender), + kPaymentInterval(120), + kGracePeriod(121), + loanSetFee, + Ter(temINVALID)); + } + // empty/zero broker ID + { + auto jv = set(borrower, uint256{}, debtMaximumRequest); - auto const brokerKeylet = keylet::loanBroker(lender.id(), env.seq(lender)); - env(loanBroker::set(lender, vaultKeylet.key), - loanBroker::kDebtMaximum(Number{100}), - Fee(env.current()->fees().base * 2)); - env.close(); + auto testZeroBrokerID = [&](std::string const& id, std::uint32_t flags = 0) { + // empty broker ID + jv[sfLoanBrokerID] = id; + env(jv, + Sig(sfCounterpartySignature, lender), + loanSetFee, + Txflags(flags), + Ter(temINVALID)); + }; + // empty broker ID + testZeroBrokerID(std::string("")); + // zero broker ID + // needs a flag to distinguish the parsed STTx from the prior + // test + testZeroBrokerID(to_string(uint256{}), tfFullyCanonicalSig); + } - auto const brokerStateBefore = env.le(brokerKeylet); - if (!BEAST_EXPECT(brokerStateBefore)) - return; - auto const loanSequence = brokerStateBefore->at(sfLoanSequence); - auto const loanKeylet = keylet::loan(brokerKeylet.key, loanSequence); + // preflightCheckSigningKey() failure: + // can it happen? the signature is checked before transactor + // executes - env(loan::set(borrower, brokerKeylet.key, Number{1}), - Sig(sfCounterpartySignature, lender), - loan::kInterestRate(TenthBips32{50'000}), - loan::kPaymentTotal(3), - loan::kPaymentInterval(31'536'000), - Fee(env.current()->fees().base * 2)); - env.close(); + JTx const tx = env.jt( + set(borrower, brokerInfo.brokerID, debtMaximumRequest), + Sig(sfCounterpartySignature, lender), + loanSetFee); + STTx local = *(tx.stx); + auto counterpartySig = local.getFieldObject(sfCounterpartySignature); + auto badPubKey = counterpartySig.getFieldVL(sfSigningPubKey); + badPubKey[20] ^= 0xAA; + counterpartySig.setFieldVL(sfSigningPubKey, badPubKey); + local.setFieldObject(sfCounterpartySignature, counterpartySig); + json::Value jvResult; + jvResult[jss::tx_blob] = strHex(local.getSerializer().slice()); + auto res = env.rpc("json", "submit", to_string(jvResult))["result"]; + BEAST_EXPECT( + res[jss::error] == "invalidTransaction" && + res[jss::error_exception] == + "fails local checks: Counterparty: Invalid signature."); + }); + + // preclaim: + testWrapper([&](Env& env, + BrokerInfo const& brokerInfo, + jtx::Fee const& loanSetFee, + Number const& debtMaximumRequest) { + // canAddHoldingFailure (IOU only, if MPT doesn't have + // MPTCanTransfer set, then can't create Vault/LoanBroker, + // and LoanSet will fail with different error + env(fclear(issuer, asfDefaultRipple)); + env.close(); + env(set(borrower, brokerInfo.brokerID, debtMaximumRequest), + Sig(sfCounterpartySignature, lender), + loanSetFee, + Ter(terNO_RIPPLE)); + }); - auto const borrowerStart = env.balance(borrower, asset).value(); + // doApply: + testWrapper([&](Env& env, + BrokerInfo const& brokerInfo, + jtx::Fee const& loanSetFee, + Number const& debtMaximumRequest) { + auto const amt = + env.balance(borrower) - accountReserve(*env.current(), borrower.id(), env.journal); + env(pay(borrower, issuer, amt)); - // Three separate periodic payments of 1 each. Expected per-period - // evolution at integer MPT scale (TVO = PO + interestDue + - // managementFeeDue): - // start: PO=1, TVO=3, paymentRemaining=3 - // after pay #1: PO=1, TVO=2, paymentRemaining=2 (principal sticks) - // after pay #2: PO=1, TVO=1, paymentRemaining=1 (principal sticks) - // after pay #3: PO=0, TVO=0, paymentRemaining=0 (final clears) - std::array const expectedPO{Number{1}, Number{1}, Number{0}}; - std::array const expectedTVO{Number{2}, Number{1}, Number{0}}; - std::array const expectedRemaining{2, 1, 0}; + // tecINSUFFICIENT_RESERVE + env(set(borrower, brokerInfo.brokerID, debtMaximumRequest), + Sig(sfCounterpartySignature, lender), + loanSetFee, + Ter(tecINSUFFICIENT_RESERVE)); - for (int i = 0; i < 3; ++i) - { - env(loan::pay(borrower, loanKeylet.key, asset(1)), Ter(tesSUCCESS)); + // addEmptyHolding failure + env(pay(issuer, borrower, amt)); + env(fset(issuer, asfGlobalFreeze)); env.close(); - auto const sle = env.le(loanKeylet); - if (!BEAST_EXPECT(sle)) - return; - BEAST_EXPECT(sle->at(sfPrincipalOutstanding) == expectedPO[i]); - BEAST_EXPECT(sle->at(sfTotalValueOutstanding) == expectedTVO[i]); - BEAST_EXPECT(sle->at(sfPaymentRemaining) == expectedRemaining[i]); - } - - // Borrower paid 3 total regardless of fee split (1 principal + 2 - // interest+fee, matching loan economics). - auto const borrowerEnd = env.balance(borrower, asset).value(); - BEAST_EXPECT(borrowerStart - borrowerEnd == asset(3).value()); + env(set(borrower, brokerInfo.brokerID, debtMaximumRequest), + Sig(sfCounterpartySignature, lender), + loanSetFee, + Ter(tecFROZEN)); + }); } - // A near-zero interest rate on a 100 USD loan - // produces total interest of ~6 units at loanScale -9. Numerical error - // in the amortization formula pushes the theoretical principal above - // the theoretical value, producing a negative theoretical interest. - // The payment delta then exceeds the actual outstanding interest, - // violating XRPL_ASSERT_PARTS in computePaymentComponents. void - testBugInterestDueDeltaCrash() + testRequireAuth() { - testcase("bug: LoanPay asserts 'interest due delta' on near-zero rate"); - + testcase("Require Auth - Implicit Pseudo-account authorization"); using namespace jtx; - using namespace std::chrono_literals; - Env env(*this, all_); - - Account const issuer{"issuer"}; + using namespace loan; Account const lender{"lender"}; + Account const issuer{"issuer"}; Account const borrower{"borrower"}; + Env env(*this); - env.fund(XRP(1'000'000), issuer, lender, borrower); - env.close(); - env(fset(issuer, asfDefaultRipple)); - env.close(); - - PrettyAsset const iouAsset = issuer["USD"]; - env(trust(lender, iouAsset(1'000'000'000))); - env(trust(borrower, iouAsset(1'000'000'000))); - env(pay(issuer, lender, iouAsset(5'000'000))); - env(pay(issuer, borrower, iouAsset(5'000'000))); + env.fund(XRP(100'000), issuer, lender, borrower); env.close(); - BrokerParameters const brokerParams{ - .vaultDeposit = 1'000'000, - .debtMax = 1'000'000, - .coverRateMin = TenthBips32{0}, - .coverDeposit = 0, - .managementFeeRate = TenthBips16{0}, - .coverRateLiquidation = TenthBips32{0}}; - - BrokerInfo const broker{createVaultAndBroker(env, iouAsset, lender, brokerParams)}; + auto asset = MPTTester({ + .env = env, + .issuer = issuer, + .holders = {lender, borrower}, + .flags = kMptDexFlags | tfMPTRequireAuth | tfMPTCanClawback | tfMPTCanLock, + .authHolder = true, + }); - using namespace loan; + env(pay(issuer, lender, asset(5'000'000))); + BrokerInfo brokerInfo{createVaultAndBroker(env, asset, lender)}; auto const loanSetFee = Fee(env.current()->fees().base * 2); - Number const principalRequest{100}; - - auto createJson = env.json( - set(borrower, broker.brokerID, principalRequest), - Fee(loanSetFee), - Json(sfCounterpartySignature, json::ValueType::Object)); + STAmount const debtMaximumRequest = brokerInfo.asset(1'000).value(); - createJson["InterestRate"] = 1; // minimum non-zero rate - createJson["PaymentTotal"] = 3; - createJson["PaymentInterval"] = 600; + auto forUnauthAuth = [&](auto&& doTx) { + for (auto const flag : {tfMPTUnauthorize, 0u}) + { + asset.authorize({.account = issuer, .holder = borrower, .flags = flag}); + env.close(); + doTx(flag == 0); + env.close(); + } + }; - auto const brokerStateBefore = env.le(keylet::loanBroker(broker.brokerID)); - auto const loanSequence = brokerStateBefore->at(sfLoanSequence); - auto const keylet = keylet::loan(broker.brokerID, loanSequence); + // Can't create a loan if the borrower is not authorized + forUnauthAuth([&](bool authorized) { + auto const err = !authorized ? Ter(tecNO_AUTH) : Ter(tesSUCCESS); + env(set(borrower, brokerInfo.brokerID, debtMaximumRequest), + Sig(sfCounterpartySignature, lender), + loanSetFee, + err); + }); - createJson = env.json(createJson, Sig(sfCounterpartySignature, lender)); - env(createJson, Ter(tesSUCCESS)); - env.close(); + static constexpr std::uint32_t kLoanSequence = 1; + auto const loanKeylet = keylet::loan(brokerInfo.brokerID, kLoanSequence); - // For principal=100, n=3 the amortization schedule produces a - // periodic payment ≈ 33.33 USD. We pay 35 USD, which is more than - // one period's worth — enough for the LoanPay path to enter - // computePaymentComponents and reach the assertion that fires - // when the bug is present. With the fix, the tx applies cleanly. - env(pay(borrower, keylet.key, iouAsset(35)), Ter(tesSUCCESS)); - env.close(); + // Can't loan pay if the borrower is not authorized + forUnauthAuth([&](bool authorized) { + auto const err = !authorized ? Ter(tecNO_AUTH) : Ter(tesSUCCESS); + env(pay(borrower, loanKeylet.key, debtMaximumRequest), err); + }); } - // Integration test: full lifecycle of a $1B loan in the bug regime. - // Verifies that the vault collects the economically-correct interest - // income and that conservation holds at the trust-line level. - // - // Pre-fix (closed-form `power(1+r, n) - 1`): vault collected only - // ~$0.058 per $1B due to cancellation of `(1+r)^n - 1` at r*n ~ 5.7e-10. - // Post-fix (hybrid binomial path): vault collects ~$0.38 per $1B, - // matching the value computed independently with arbitrary-precision - // Decimal arithmetic. void - testFullLifecycleVaultPnLNearZeroRate() + testLendingCanTradeDisabledNoImpact() { - testcase("integration: full loan lifecycle, vault interest at near-zero rate"); - + testcase("Lending: CanTrade disabled has no impact"); using namespace jtx; - using namespace jtx::loan; - using namespace std::chrono_literals; + using namespace loan; + using namespace loanBroker; + Env env(*this, all_); Account const issuer{"issuer"}; @@ -8071,1463 +3053,1071 @@ class Loan_test : public beast::unit_test::Suite env.fund(XRP(1'000'000), issuer, lender, borrower); env.close(); - env(fset(issuer, asfDefaultRipple)); - env.close(); - PrettyAsset const iouAsset = issuer["USD"]; - STAmount const trustLimit{iouAsset.raw(), Number{1, 17}}; - env(trust(lender, trustLimit)); - env(trust(borrower, trustLimit)); + MPTTester mpt( + {.env = env, + .issuer = issuer, + .holders = {lender, borrower}, + .flags = tfMPTCanTransfer | tfMPTCanLock, + .mutableFlags = tmfMPTCanEnableCanTrade}); + PrettyAsset const asset = mpt.issuanceID(); + env(pay(issuer, lender, asset(10'000'000))); + env(pay(issuer, borrower, asset(100'000))); env.close(); - env(pay(issuer, lender, iouAsset(5'000'000'000LL))); - env(pay(issuer, borrower, iouAsset(5'000'000'000LL))); + + auto const broker = createVaultAndBroker(env, asset, lender); + + // CanTrade is not set + env(offer(lender, XRP(1), asset(10)), Ter{tecNO_PERMISSION}); env.close(); - auto usdBalance = [&](Account const& a) { - return env.balance(a, iouAsset.raw().get()).value(); - }; - STAmount const borrowerStartBal = usdBalance(borrower); + auto const loanSetFee = Fee(env.current()->fees().base * 2); - BrokerParameters const brokerParams{ - .vaultDeposit = Number{2, 9}, - .debtMax = Number{0}, - .coverRateMin = TenthBips32{0}, - .coverDeposit = 0, - .managementFeeRate = TenthBips16{0}, - .coverRateLiquidation = TenthBips32{0}}; - BrokerInfo const broker{createVaultAndBroker(env, iouAsset, lender, brokerParams)}; + // New cover deposits still work. + env(coverDeposit(lender, broker.brokerID, asset(100))); + env.close(); - auto const vaultBefore = env.le(broker.vaultKeylet()); - BEAST_EXPECT(vaultBefore); - Number const vaultAvailableBefore = vaultBefore->at(sfAssetsAvailable); + // New loan issuance still works. + env(loan::set(borrower, broker.brokerID, 1'000), + Sig(sfCounterpartySignature, lender), + loanSetFee); + env.close(); + auto const loanKeylet = keylet::loan(broker.brokerID, 1); + BEAST_EXPECT(env.le(loanKeylet)); - // Loan: $1B principal, 3 payments, 600s interval, rate=1 TenthBips32. - auto const loanSetFee = Fee(env.current()->fees().base * 2); - Number const principalRequest{1, 9}; - auto createJson = env.json( - set(borrower, broker.brokerID, principalRequest), - Fee(loanSetFee), - Json(sfCounterpartySignature, json::ValueType::Object)); - createJson["InterestRate"] = 1; - createJson["PaymentTotal"] = 3; - createJson["PaymentInterval"] = 600; - - auto const brokerStateBefore = env.le(keylet::loanBroker(broker.brokerID)); - auto const loanSequence = brokerStateBefore->at(sfLoanSequence); - auto const loanKeylet = keylet::loan(broker.brokerID, loanSequence); - createJson = env.json(createJson, Sig(sfCounterpartySignature, lender)); - env(createJson, Ter(tesSUCCESS)); + // Repayment still works. + env(pay(borrower, loanKeylet.key, asset(1'000))); env.close(); - auto const loanSle = env.le(loanKeylet); - BEAST_EXPECT(loanSle); - Number const expectedTotalInterest = - loanSle->at(sfTotalValueOutstanding) - loanSle->at(sfPrincipalOutstanding); + // Cover withdrawal still works. + env(coverWithdraw(lender, broker.brokerID, asset(100))); + env.close(); - env(pay(borrower, loanKeylet.key, iouAsset(1'500'000'000LL)), Ter(tesSUCCESS)); + // Enable CanTrade and verify the DEX path is restored. + mpt.set({.mutableFlags = tmfMPTSetCanTrade}); env.close(); - auto const vaultAfter = env.le(broker.vaultKeylet()); - Number const vaultAvailableAfter = vaultAfter->at(sfAssetsAvailable); - Number const vaultGain = vaultAvailableAfter - vaultAvailableBefore; - - STAmount const borrowerEndBal = usdBalance(borrower); - STAmount const borrowerNetOut = borrowerStartBal - borrowerEndBal; - - // Self-consistency: vault gained exactly the expected interest - // computed at LoanSet, and the borrower's outflow matches. - BEAST_EXPECT(vaultGain == expectedTotalInterest); - BEAST_EXPECT(Number(borrowerNetOut) == expectedTotalInterest); - - // Mathematical correctness: the total interest for this loan - // configuration is 0.38051750382930729983, calculated - // independently using 50-digit Decimal arithmetic (no - // cancellation possible at that precision). At Number's 19-digit - // mantissa this rounds to 0.38051750382930729 — the literal - // below. The vault's actual gain must agree to within - // sub-microcent precision. - Number const decimalReference{38051750382930729LL, -17}; - Number const tolerance{1, -6}; // 1e-6 USD = sub-microcent - Number const error = abs(vaultGain - decimalReference); - BEAST_EXPECTS( - error < tolerance, - "vault gain " + to_string(vaultGain) + " differs from Decimal reference " + - to_string(decimalReference) + " by " + to_string(error) + " — exceeds tolerance " + - to_string(tolerance)); + env(offer(lender, XRP(1), asset(10))); + env.close(); } - // Verify that LoanPay, LoanBrokerCoverWithdraw, and LoanSet all use the - // same vault-scale minimum cover when fixCleanup3_2_0 is enabled. - // Before the amendment, each transactor computed its minimum cover at a - // different precision (loanScale, debtScale, or the raw unrounded - // tenthBipsOfValue), which could lead to inconsistent decisions for the - // same broker state. After the amendment all three use - // minimumBrokerCover at vaultScale. void - testMinimumBrokerCoverConsistency(FeatureBitset features) + testBorrowerIsBroker() { + testcase("Test Borrower is Broker"); using namespace jtx; using namespace loan; - using namespace loanBroker; + Account const broker{"broker"}; + Account const issuer{"issuer"}; + Account const borrower{"borrower"}; + Account const depositor{"depositor"}; - bool const withAmendment = features[fixCleanup3_2_0]; + auto testLoanAsset = [&](auto&& getMaxDebt, auto const& borrower) { + Env env(*this); + Vault const vault(env); - struct Ctx - { - jtx::Account issuer; - jtx::Account lender; - jtx::Account borrower; - jtx::PrettyAsset iou; - BrokerInfo broker; - BrokerParameters brokerParams; - }; + if (borrower == broker) + { + env.fund(XRP(10'000), broker, issuer, depositor); + } + else + { + env.fund(XRP(10'000), broker, borrower, issuer, depositor); + } + env.close(); - // Shared setup, parametrized by vaultDeposit (the only varying setup - // field across the three scenarios). Each call runs in its own Env - // so multiple invocations within one scenario cannot interfere. - // The caller is responsible for invoking testcase(...) before the - // first runTest call of each scenario. - auto runTest = [&](Number vaultDeposit, auto&& body) { - Env env(*this, features); + auto const xrpFee = XRP(100); + auto const txFee = Fee(xrpFee); - Account const issuer{"issuer"}; - Account const lender{"lender"}; - Account const borrower{"borrower"}; + STAmount const debtMaximumRequest = getMaxDebt(env); - env.fund(XRP(1'000'000'000), issuer, lender, borrower); + auto const& asset = debtMaximumRequest.asset(); + auto const initialVault = asset(debtMaximumRequest * 100); + + auto [tx, vaultKeylet] = vault.create({.owner = broker, .asset = asset}); + env(tx, txFee); env.close(); - // Enable clawback on the issuer *before* any trust lines exist - // (asfAllowTrustLineClawback requires an empty owner directory). - env(fset(issuer, asfAllowTrustLineClawback)); + env(vault.deposit( + {.depositor = depositor, .id = vaultKeylet.key, .amount = initialVault}), + txFee); env.close(); - PrettyAsset const iou = issuer[iouCurrency_]; - env(trust(lender, iou(1'000'000'000))); - env(trust(borrower, iou(1'000'000'000))); + auto const brokerKeylet = keylet::loanBroker(broker.id(), env.seq(broker)); + + env(loanBroker::set(broker, vaultKeylet.key), txFee); env.close(); - env(pay(issuer, lender, iou(100'000'000))); - env(pay(issuer, borrower, iou(100'000'000))); + + auto const serviceFee = 101; + + env(set(broker, brokerKeylet.key, debtMaximumRequest), + kCounterparty(borrower), + Sig(sfCounterpartySignature, borrower), + kLoanServiceFee(serviceFee), + kPaymentTotal(10), + txFee); env.close(); - // 13.37% — non-round rate produces a messier minimum. - BrokerParameters const brokerParams{ - .vaultDeposit = vaultDeposit, - .debtMax = 0, - .coverRateMin = TenthBips32{13'370}, - .coverDeposit = 5'000, - .managementFeeRate = TenthBips16{500}}; + std::uint32_t const loanSequence = 1; + auto const loanKeylet = keylet::loan(brokerKeylet.key, loanSequence); + + auto const brokerBalanceBefore = env.balance(broker, asset); + + if (auto const loanSle = env.le(loanKeylet); env.test.BEAST_EXPECT(loanSle)) + { + auto const payment = loanSle->at(sfPeriodicPayment); + auto const totalPayment = payment + serviceFee; + env(loan::pay(borrower, loanKeylet.key, asset(totalPayment)), txFee); + env.close(); + if (auto const vaultSle = env.le(vaultKeylet); BEAST_EXPECT(vaultSle)) + { + auto const expected = [&]() { + // The service fee is transferred to the broker if + // a borrower is not the broker + if (borrower != broker) + return brokerBalanceBefore.number() + serviceFee; + // Since a borrower is the broker, the payment is + // transferred to the Vault from the broker but not + // the service fee. + // If the asset is XRP then the broker pays the txFee. + if (asset.native()) + return brokerBalanceBefore.number() - payment - xrpFee.number(); + return brokerBalanceBefore.number() - payment; + }(); + BEAST_EXPECT(env.balance(broker, asset).value() == asset(expected).value()); + } + } + }; + // Test when a borrower is the broker and is not to verify correct + // service fee transfer in both cases. + for (auto const& borrowerAcct : {broker, borrower}) + { + testLoanAsset( + [&](Env&) -> STAmount { return STAmount{XRPAmount{200'000}}; }, borrowerAcct); + testLoanAsset( + [&](Env& env) -> STAmount { + auto const iou = issuer["USD"]; + env(trust(broker, iou(1'000'000'000))); + env(trust(depositor, iou(1'000'000'000))); + env(pay(issuer, broker, iou(100'000'000))); + env(pay(issuer, depositor, iou(100'000'000))); + env.close(); + return iou(200'000); + }, + borrowerAcct); + testLoanAsset( + [&](Env& env) -> STAmount { + MPTTester const mpt( + {.env = env, + .issuer = issuer, + .holders = {broker, depositor}, + .pay = 100'000'000}); + return mpt(200'000); + }, + borrowerAcct); + } + } + + void + testIssuerIsBorrower(FeatureBitset features) + { + testcase("RIPD-4096 - Issuer as borrower"); - BrokerInfo const broker = createVaultAndBroker(env, iou, lender, brokerParams); + using namespace jtx; - body( - env, - Ctx{.issuer = issuer, - .lender = lender, - .borrower = borrower, - .iou = iou, - .broker = broker, - .brokerParams = brokerParams}); - }; + Account const issuer("issuer"); + Account const lender("lender"); - // Scenario 1 — LoanPay - // - // Verify that LoanPay's minimum cover check uses vault scale (not - // loan scale). Before the amendment, different loans could produce - // different fee routing decisions for the same broker-level state. - // Small vault deposit => vaultScale = -12. - testcase("LoanPay minimum cover scale consistency"); - { - struct LoanKeylets - { - Keylet tiny; - Keylet big; - }; + BrokerParameters const brokerParams{ + .vaultDeposit = 100'000, + .debtMax = 0, + .coverRateMin = TenthBips32{0}, + .managementFeeRate = TenthBips16{0}, + .coverRateLiquidation = TenthBips32{0}}; + LoanParameters const loanParams{ + .account = lender, .counter = issuer, .principalRequest = Number{10000}}; - // Create the tiny + big loans and reduce cover via clawback so - // that subsequent LoanPay calls hit the minimum-cover boundary. - // Used by the two pay-and-check sub-tests below so each can run - // in its own Env. - auto setupLoansAndClawback = [&](Env& env, Ctx const& c) -> std::optional { - Asset const asset{c.iou}; + auto const assetType = AssetType::IOU; - // Create the TINY loan first (while vaultScale is still - // small). principal 0.01, 0% interest, 1 payment => - // loanScale = vaultScale. - auto const brokerSle1 = env.le(keylet::loanBroker(c.broker.brokerID)); - if (!BEAST_EXPECT(brokerSle1)) - return std::nullopt; - auto const tinyLoanSeq = brokerSle1->at(sfLoanSequence); - auto const tinyLoanKeylet = keylet::loan(c.broker.brokerID, tinyLoanSeq); + Env env{*this, features}; - env(set(c.borrower, c.broker.brokerID, Number{1, -2}), - Sig(sfCounterpartySignature, c.lender), - kInterestRate(TenthBips32{0}), - kPaymentTotal(1), - kPaymentInterval(86400 * 365), - Fee(XRP(10))); - env.close(); + auto loanResult = + createLoan(env, assetType, brokerParams, loanParams, issuer, lender, issuer); - // Create the BIG loan second. 100% annual interest over 20 - // payments pushes totalValueOutstanding high enough that - // loanScale > vaultScale. - auto const brokerSle2 = env.le(keylet::loanBroker(c.broker.brokerID)); - if (!BEAST_EXPECT(brokerSle2)) - return std::nullopt; - auto const bigLoanSeq = brokerSle2->at(sfLoanSequence); - auto const bigLoanKeylet = keylet::loan(c.broker.brokerID, bigLoanSeq); + if (BEAST_EXPECT(loanResult); !loanResult.has_value()) + return; - env(set(c.borrower, c.broker.brokerID, Number{500}), - Sig(sfCounterpartySignature, c.lender), - kInterestRate(TenthBips32{100'000}), - kPaymentTotal(20), - kPaymentInterval(86400 * 365), - Fee(XRP(10))); - env.close(); + auto broker = std::get(*loanResult); + auto loanKeylet = std::get(*loanResult); + auto pseudoAcct = std::get(*loanResult); - // The tiny loan's scale is frozen at the vault's pre-big-loan - // scale, so it is strictly smaller than the big loan's. - // After the big loan is created the vault absorbs its value, - // pushing vaultScale up to match bigLoanScale. - auto const tinyLoanSle = env.le(tinyLoanKeylet); - auto const bigLoanSle = env.le(bigLoanKeylet); - auto const vaultSle = env.le(keylet::vault(c.broker.vaultID)); - if (!BEAST_EXPECT(tinyLoanSle) || !BEAST_EXPECT(bigLoanSle) || - !BEAST_EXPECT(vaultSle)) - return std::nullopt; - if (!BEAST_EXPECT(tinyLoanSle->at(sfLoanScale) == -12) || - !BEAST_EXPECT(bigLoanSle->at(sfLoanScale) == -11) || - !BEAST_EXPECT(getAssetsTotalScale(vaultSle) == -11)) - return std::nullopt; + VerifyLoanStatus const verifyLoanStatus(env, broker, pseudoAcct, loanKeylet); - // Use issuer clawback to reduce cover to the minimum the - // clawback transactor allows. Compute the amount as - // initialCover - expectedCoverAfter so we exercise the exact - // clawback rather than relying on the transactor to clip - // down. - // - // Before the amendment the clawback minimum is the - // *unrounded* tenthBipsOfValue — strictly less than the - // rounded-at-vaultScale minimum LoanPay uses for the big - // loan. After the amendment both clawback and LoanPay use - // the same rounded minimum (via minimumBrokerCover), so - // cover lands exactly at that threshold. - Number const expectedCoverAfter = withAmendment ? Number{1330651855688460000, -15} - : Number{1330651855688458000, -15}; - Number const clawbackAmount = - Number{c.brokerParams.coverDeposit} - expectedCoverAfter; + makeLoanPayments( + env, + broker, + loanParams, + loanKeylet, + verifyLoanStatus, + issuer, + lender, + issuer, + PaymentParameters{.showStepBalances = true}); + } - env(coverClawback(c.issuer), - kLoanBrokerId(c.broker.brokerID), - kAmount(STAmount{asset, clawbackAmount})); - env.close(); + void + testLimitExceeded() + { + testcase("RIPD-4125 - overpayment"); - auto const brokerSle = env.le(keylet::loanBroker(c.broker.brokerID)); - if (!BEAST_EXPECT(brokerSle) || - !BEAST_EXPECT(brokerSle->at(sfCoverAvailable) == expectedCoverAfter)) - return std::nullopt; + using namespace jtx; - return LoanKeylets{.tiny = tinyLoanKeylet, .big = bigLoanKeylet}; - }; + Account const issuer("issuer"); + Account const lender("lender"); + Account const borrower("borrower"); - // Pay one loan and report whether the fee went to the broker's - // pseudo account (the fallback when cover < minimum) rather - // than to the owner. - auto feeGoesToPseudo = [&](Env& env, Ctx const& c, Keylet const& loanKeylet) -> bool { - Asset const asset{c.iou}; - auto const brokerSle = env.le(keylet::loanBroker(c.broker.brokerID)); - if (!BEAST_EXPECT(brokerSle)) - return false; - auto const pseudoAcct = Account("pseudo", brokerSle->at(sfAccount)); - auto const pseudoBefore = env.balance(pseudoAcct, c.iou); + BrokerParameters const brokerParams{ + .vaultDeposit = 100'000, + .debtMax = 0, + .coverRateMin = TenthBips32{0}, + .managementFeeRate = TenthBips16{0}, + .coverRateLiquidation = TenthBips32{0}}; + LoanParameters const loanParams{ + .account = lender, + .counter = borrower, + .principalRequest = Number{200000, -6}, + .interest = TenthBips32{50000}, + .payTotal = 3, + .payInterval = 200, + .gracePd = 60, + .flags = tfLoanOverpayment, + }; - auto const payLoan = env.le(loanKeylet); - if (!BEAST_EXPECT(payLoan)) - return false; - auto const periodicPayment = payLoan->at(sfPeriodicPayment); - auto const serviceFee = payLoan->at(sfLoanServiceFee); - std::int32_t const loanScale = payLoan->at(sfLoanScale); + auto const assetType = AssetType::XRP; - auto const payment = roundPeriodicPayment(asset, periodicPayment, loanScale); - auto const payAmt = STAmount{asset, payment + serviceFee}; + Env env(*this, makeConfig(), all_, nullptr, beast::Severity::Warning); - env(loan::pay(c.borrower, loanKeylet.key, payAmt), Fee(XRP(10))); - env.close(); + auto loanResult = + createLoan(env, assetType, brokerParams, loanParams, issuer, lender, borrower); - auto const pseudoAfter = env.balance(pseudoAcct, c.iou); - return pseudoAfter.number() > pseudoBefore.number(); - }; + if (BEAST_EXPECT(loanResult); !loanResult.has_value()) + return; - // Pay the BIG loan in its own Env so its outcome cannot affect - // the TINY-loan check. With the fix, LoanPay and clawback use - // the same vaultScale minimum (cover == minAtVaultScale => - // fee to owner). Without the fix, LoanPay uses bigLoanScale=-11, - // rounds up to a larger minimum than what clawback used => - // cover < min => fee to pseudo. - runTest(/*vaultDeposit=*/1'000, [&](Env& env, Ctx const& c) { - auto const loans = setupLoansAndClawback(env, c); - if (!loans) - return; - BEAST_EXPECT(feeGoesToPseudo(env, c, loans->big) == !withAmendment); - }); + auto broker = std::get(*loanResult); + auto loanKeylet = std::get(*loanResult); + auto pseudoAcct = std::get(*loanResult); - // Pay the TINY loan in its own Env. Fee goes to the owner - // either way: - // - With the fix: LoanPay uses vaultScale=-11 (same as - // clawback) => owner. - // - Without the fix: LoanPay uses tinyLoanScale=-12, rounds - // up at -12 (a no-op) => min == cover => owner. - runTest(/*vaultDeposit=*/1'000, [&](Env& env, Ctx const& c) { - auto const loans = setupLoansAndClawback(env, c); - if (!loans) - return; - BEAST_EXPECT(!feeGoesToPseudo(env, c, loans->tiny)); - }); - } + VerifyLoanStatus const verifyLoanStatus(env, broker, pseudoAcct, loanKeylet); - // Scenario 2 — LoanBrokerCoverWithdraw - // - // Verify that CoverWithdraw's minimum cover check uses vault scale - // (not scale(debtTotal, asset)). Before the amendment, CoverWithdraw - // used: - // roundToAsset(asset, tenthBipsOfValue(debt, rate), scale(debt, asset)) - // which could disagree with LoanPay's minimum (which used loanScale). - // - // Use a large vault deposit so that vaultScale (from AssetsTotal) is - // strictly larger than debtScale (from DebtTotal). With - // vaultDeposit = 100,000: after the big loan - // AssetsTotal ≈ 109,500 → vaultScale = -10 - // DebtTotal ≈ 10,000 → debtScale = -11 - // The one-order-of-magnitude gap makes roundToAsset at -10 truncate - // more aggressively than at -11, exposing the bug. - testcase("CoverWithdraw minimum cover scale consistency"); - runTest( - /*vaultDeposit=*/100'000, [&](Env& env, Ctx const& c) { - Asset const asset{c.iou}; + auto const state = getCurrentState(env, broker, loanKeylet); + + env(loan::pay( + borrower, + loanKeylet.key, + STAmount{broker.asset, state.periodicPayment * 3 / 2 + 1}, + tfLoanOverpayment)); + env.close(); + + PaymentParameters const paymentParams{ + .showStepBalances = false, + .validateBalances = true, + }; - // Create only the big loan to push DebtTotal up to ~10,000 - // while AssetsTotal stays around 109,500 (dominated by the - // large vault deposit). - env(set(c.borrower, c.broker.brokerID, Number{500}), - Sig(sfCounterpartySignature, c.lender), - kInterestRate(TenthBips32{100'000}), - kPaymentTotal(20), - kPaymentInterval(86400 * 365), - Fee(XRP(10))); - env.close(); + makeLoanPayments( + env, + broker, + loanParams, + loanKeylet, + verifyLoanStatus, + issuer, + lender, + borrower, + paymentParams); + } - // Read broker state and compute both old and new minimums. - auto const brokerSle = env.le(keylet::loanBroker(c.broker.brokerID)); - auto const vaultSle = env.le(keylet::vault(c.broker.vaultID)); - if (!BEAST_EXPECT(brokerSle) || !BEAST_EXPECT(vaultSle)) - return; + void + testOverpaymentManagementFee(FeatureBitset features) + { + testcase("testOverpaymentManagementFee"); - auto const coverAvail = brokerSle->at(sfCoverAvailable); - auto const debtTotal = brokerSle->at(sfDebtTotal); - auto const vaultScale = getAssetsTotalScale(vaultSle); - auto const debtScale = scale(debtTotal, asset); + using namespace jtx; + using namespace loan; - // Sanity: debt scale differs from vault scale for this setup. - BEAST_EXPECT(debtScale < vaultScale); + Env env{*this, features}; - auto const oldMin = [&]() { - NumberRoundModeGuard const mg(Number::RoundingMode::Upward); - return roundToAsset( - asset, - tenthBipsOfValue(debtTotal, TenthBips32{c.brokerParams.coverRateMin}), - debtScale); - }(); - auto const newMin = minimumBrokerCover( - debtTotal, TenthBips32{c.brokerParams.coverRateMin}, vaultSle); + Account const lender{"lender"}, borrower{"borrower"}; - // The new (vaultScale) minimum must be strictly larger than - // the old (debtScale) minimum — that is the gap the amendment - // closes. - Number const expectedNewMin{1330650518688500000, -15}; - Number const expectedOldMin{1330650518688472000, -15}; - BEAST_EXPECT(newMin == expectedNewMin); - BEAST_EXPECT(oldMin == expectedOldMin); + env.fund(XRP(10'000'000), lender, borrower); + env.close(); - // Try to withdraw so that remaining cover lands between the - // two minimums: oldMin < target < newMin. - auto const target = oldMin + (newMin - oldMin) / 2; - auto const withdrawAmount = STAmount{asset, coverAvail - target}; + PrettyAsset const asset{xrpIssue(), 1000}; - if (withAmendment) - { - // CoverWithdraw now uses vaultScale: target < newMin - // => FAILS. - env(coverWithdraw(c.lender, c.broker.brokerID, withdrawAmount), - Ter(tecINSUFFICIENT_FUNDS)); - } - else - { - // Old CoverWithdraw uses debtScale: target > oldMin - // => SUCCEEDS. - env(coverWithdraw(c.lender, c.broker.brokerID, withdrawAmount)); - } - env.close(); + auto const result = createVaultAndBroker( + env, + asset, + lender, + { + .vaultDeposit = asset(100'000).value(), + .managementFeeRate = TenthBips16(10'000), }); - // Scenario 3 — LoanSet - // - // Verify that LoanSet's minimum cover check uses vault scale (not the - // raw unrounded tenthBipsOfValue). Before the amendment, LoanSet - // used tenthBipsOfValue(newDebtTotal, coverRateMinimum) (no - // roundToAsset), while clawback/withdraw used different formulas. - // After the amendment all use minimumBrokerCover at vaultScale, and - // rounding at a coarser scale can absorb a tiny debt increase — - // allowing a loan that would otherwise be rejected. - testcase("LoanSet minimum cover scale consistency"); - runTest( - /*vaultDeposit=*/1'000, [&](Env& env, Ctx const& c) { - // Create the tiny loan (scale -12) AND the big loan (scale - // -11). Both loans are needed so that DebtTotal has a full - // 16-digit mantissa — a "messy" value where roundToAsset at - // vaultScale actually truncates digits and produces a - // different result from the raw tenthBipsOfValue. With only - // the big loan, DebtTotal has ~4 significant digits and - // rounding at scale -11 is a no-op, masking the amendment's - // effect. - env(set(c.borrower, c.broker.brokerID, Number{1, -2}), - Sig(sfCounterpartySignature, c.lender), - kInterestRate(TenthBips32{0}), - kPaymentTotal(1), - kPaymentInterval(86400 * 365), - Fee(XRP(10))); - env.close(); + auto const loanSetFee = Fee(env.current()->fees().base * 2); - env(set(c.borrower, c.broker.brokerID, Number{500}), - Sig(sfCounterpartySignature, c.lender), - kInterestRate(TenthBips32{100'000}), - kPaymentTotal(20), - kPaymentInterval(86400 * 365), - Fee(XRP(10))); - env.close(); + auto const loanKeylet = keylet::loan( + result.brokerKeylet().key, (env.le(result.brokerKeylet()))->at(sfLoanSequence)); + env(loan::set( + borrower, result.brokerKeylet().key, asset(10'000).value(), tfLoanOverpayment), + Sig(sfCounterpartySignature, lender), + loan::kPaymentInterval(86400 * 30), + loan::kPaymentTotal(3), + loan::kOverpaymentInterestRate(TenthBips32(percentageToTenthBips(20))), + loanSetFee); - // Clawback to reduce cover to the clawback transactor's - // minimum. Pass the exact amount rather than relying on the - // transactor to clip down; the setup matches Scenario 1 so - // the same residual-cover values apply. - Number const expectedCoverAfter = withAmendment ? Number{1330651855688460000, -15} - : Number{1330651855688458000, -15}; - Number const clawbackAmount = - Number{c.brokerParams.coverDeposit} - expectedCoverAfter; - env(coverClawback(c.issuer), - kLoanBrokerId(c.broker.brokerID), - kAmount(c.iou(clawbackAmount))); - env.close(); + // From calculator + auto const expectedOverpaymentManagementFee = Number{33333, 0}; + auto const loanBrokerBalanceBefore = env.balance(lender); - // Verify scales. - auto const vaultSle = env.le(keylet::vault(c.broker.vaultID)); - if (!BEAST_EXPECT(vaultSle)) - return; - auto const vaultScale = getAssetsTotalScale(vaultSle); - BEAST_EXPECT(vaultScale == -11); + auto const loanPayFee = Fee(env.current()->fees().base * 2); + env(pay(borrower, loanKeylet.key, asset(5'000).value(), tfLoanOverpayment), loanPayFee); + env.close(); - // Now try to create a tiny additional loan. Principal is - // 1e-11 (the smallest value that survives the precision - // check at loanScale = vaultScale = -11), with 0% interest - // and 1 payment. - // - // The tiny debt increase adds ~1.337e-12 to the unrounded - // minimum. - // - Without the amendment: the old LoanSet formula rounds - // up during tenthBipsOfValue (16-digit Number - // normalisation), pushing the minimum past the cover left - // by clawback => tecINSUFFICIENT_FUNDS. - // - With the amendment: minimumBrokerCover rounds at - // vaultScale=-11, which absorbs the tiny increase — the - // rounded minimum stays the same => tesSUCCESS. - auto const tinyPrincipal = Number{1, -11}; + BEAST_EXPECTS( + env.balance(lender) - loanBrokerBalanceBefore == expectedOverpaymentManagementFee, + "overpayment management fee missmatch; expected:" + + to_string(expectedOverpaymentManagementFee) + + " got: " + to_string(env.balance(lender) - loanBrokerBalanceBefore)); + } - if (withAmendment) - { - env(set(c.borrower, c.broker.brokerID, tinyPrincipal), - Sig(sfCounterpartySignature, c.lender), - kInterestRate(TenthBips32{0}), - kPaymentTotal(1), - kPaymentInterval(86400 * 365), - Fee(XRP(10))); - } - else - { - env(set(c.borrower, c.broker.brokerID, tinyPrincipal), - Sig(sfCounterpartySignature, c.lender), - kInterestRate(TenthBips32{0}), - kPaymentTotal(1), - kPaymentInterval(86400 * 365), - Fee(XRP(10)), - Ter(tecINSUFFICIENT_FUNDS)); - } - env.close(); - }); + void + testLoanPayBrokerOwnerMissingTrustline(FeatureBitset features) + { + testcase << "LoanPay Broker Owner Missing Trustline (PoC)"; + using namespace jtx; + using namespace loan; + Account const issuer("issuer"); + Account const borrower("borrower"); + Account const broker("broker"); + auto const iou = issuer["IOU"]; + Env env(*this, features); + env.fund(XRP(20'000), issuer, broker, borrower); + env.close(); + // Set up trustlines and fund accounts + env(trust(broker, iou(20'000'000))); + env(trust(borrower, iou(20'000'000))); + env(pay(issuer, broker, iou(10'000'000))); + env(pay(issuer, borrower, iou(1'000))); + env.close(); + // Create vault and broker + auto const brokerInfo = createVaultAndBroker(env, iou, broker); + // Create a loan first (this creates debt) + auto const keylet = keylet::loan(brokerInfo.brokerID, 1); + env(set(borrower, brokerInfo.brokerID, 10'000), + Sig(sfCounterpartySignature, broker), + kLoanServiceFee(iou(100).value()), + kPaymentInterval(100), + Fee(XRP(100))); + env.close(); + // Ensure broker has sufficient cover so brokerPayee == brokerOwner + // We need coverAvailable >= (debtTotal * coverRateMinimum) + // Deposit enough cover to ensure the fee goes to broker owner + // The default coverRateMinimum is 10%, so for a 10,000 loan we need + // at least 1,000 cover. Default cover is 1,000, so we add more to be + // safe. + auto const additionalCover = iou(50'000).value(); + env(loanBroker::coverDeposit(broker, brokerInfo.brokerID, STAmount{iou, additionalCover})); + env.close(); + // Verify broker owner has a trustline + auto const brokerTrustline = keylet::trustLine(broker, iou); + BEAST_EXPECT(env.le(brokerTrustline) != nullptr); + // Broker owner deletes their trustline + // First, pay any positive balance to issuer to zero it out + auto const brokerBalance = env.balance(broker, iou); + env(pay(broker, issuer, brokerBalance)); + env.close(); + // Remove the trustline by setting limit to 0 + env(trust(broker, iou(0))); + env.close(); + // Verify trustline is deleted + BEAST_EXPECT(env.le(brokerTrustline) == nullptr); + // Now borrower tries to make a payment + // We should get a tesSUCCESS instead of a tecNO_LINE. + env(pay(borrower, keylet.key, iou(10'100)), Fee(XRP(100)), Ter(tesSUCCESS)); + env.close(); + // Verify trustline is still deleted + BEAST_EXPECT(env.le(brokerTrustline) == nullptr); + // Verify the service fee went to the broker pseudo-account + if (auto const brokerSle = env.le(keylet::loanBroker(brokerInfo.brokerID)); + BEAST_EXPECT(brokerSle)) + { + Account const pseudo("pseudo-account", brokerSle->at(sfAccount)); + auto const balance = env.balance(pseudo, iou); + // 1,000 default + 50,000 extra + 100 service fee from LoanPay + BEAST_EXPECTS(balance == iou(51'100), to_string(json::Value(balance))); + } } - // LendingProtocolV1_1 ("cash-basis" accounting) dedicated coverage. - // - // Existing tests never enable featureLendingProtocolV1_1 (see `all_` - // above), so these are the only tests in this file that exercise the - // amendment. They are called once, directly, from - // runAmendmentIndependent() -- not looped through - // runAmendmentSensitive()/amendmentCombinations(), since doing so would - // require re-deriving whole-life-specific expected values for ~15 - // unrelated regression tests. - - // 1. LoanSet origination: Vault.AssetsTotal/LoanBroker.DebtTotal deltas, - // and the AssetsMaximum/DebtMaximum guards (which always check against - // principal + interestDue, regardless of the amendment). void - testCashBasisLoanSetOrigination() + testLoanPayBrokerOwnerUnauthorizedMPT(FeatureBitset features) { - testcase("cash-basis: LoanSet origination"); - + testcase << "LoanPay Broker Owner MPT unauthorized"; using namespace jtx; using namespace loan; - PrettyAsset const xrpAsset{xrpIssue(), 1'000'000}; - BrokerParameters const brokerParams{ - .vaultDeposit = 100'000, - .debtMax = 0, - .coverRateMin = TenthBips32{0}, - .coverDeposit = 0, - .managementFeeRate = TenthBips16{0}, - .coverRateLiquidation = TenthBips32{0}}; - - Number const principalRequest{10'000}; - TenthBips32 const interestRate{percentageToTenthBips(10)}; - std::uint32_t const paymentTotal = 2; - std::uint32_t const paymentInterval = 86400; - - // Creates a broker/vault, submits a single LoanSet with a nonzero - // interest rate, and returns the observed Vault.AssetsTotal / - // LoanBroker.DebtTotal deltas plus the loan's own computed - // interestDue and principalOutstanding. - auto runOrigination = [&](FeatureBitset features) { - Env env(*this, features); - - Account const lender{"lender"}; - Account const borrower{"borrower"}; - env.fund(XRP(1'000'000), lender, borrower); - env.close(); - - BrokerInfo const broker{createVaultAndBroker(env, xrpAsset, lender, brokerParams)}; - - auto const vaultBefore = env.le(broker.vaultKeylet()); - auto const brokerBefore = env.le(broker.brokerKeylet()); - BEAST_EXPECT(vaultBefore && brokerBefore); - Number const assetsTotalBefore = vaultBefore->at(sfAssetsTotal); - Number const debtTotalBefore = brokerBefore->at(sfDebtTotal); - - auto const loanSequence = brokerBefore->at(sfLoanSequence); - auto const loanKeylet = keylet::loan(broker.brokerID, loanSequence); - - env(set(borrower, broker.brokerID, xrpAsset(principalRequest).value()), - kCounterparty(lender), - kInterestRate(interestRate), - kPaymentTotal(paymentTotal), - kPaymentInterval(paymentInterval), - Sig(sfCounterpartySignature, lender), - Fee(env.current()->fees().base * 2), - Ter(tesSUCCESS)); - env.close(); - - auto const loanSle = env.le(loanKeylet); - BEAST_EXPECT(loanSle); - Number const principalOutstanding = loanSle->at(sfPrincipalOutstanding); - Number const totalValueOutstanding = loanSle->at(sfTotalValueOutstanding); - Number const interestDue = totalValueOutstanding - principalOutstanding; - BEAST_EXPECT(interestDue > beast::kZero); - BEAST_EXPECT(principalOutstanding == xrpAsset(principalRequest).value()); - - auto const vaultAfter = env.le(broker.vaultKeylet()); - auto const brokerAfter = env.le(broker.brokerKeylet()); - BEAST_EXPECT(vaultAfter && brokerAfter); - Number const assetsTotalDelta = - Number(vaultAfter->at(sfAssetsTotal)) - assetsTotalBefore; - Number const debtTotalDelta = Number(brokerAfter->at(sfDebtTotal)) - debtTotalBefore; - - return std::make_tuple( - assetsTotalDelta, debtTotalDelta, interestDue, principalOutstanding); - }; - - Number interestDueCash{}; - Number principalOutstandingCash{}; - { - auto const [assetsTotalDelta, debtTotalDelta, interestDue, principalOutstanding] = - runOrigination(all_ | featureLendingProtocolV1_1); - interestDueCash = interestDue; - principalOutstandingCash = principalOutstanding; - - BEAST_EXPECTS( - assetsTotalDelta == beast::kZero, - "cash-basis origination must not change AssetsTotal; delta=" + - to_string(assetsTotalDelta)); - BEAST_EXPECTS( - debtTotalDelta == principalOutstanding, - "cash-basis origination must add principal-only to DebtTotal; delta=" + - to_string(debtTotalDelta) + " principal=" + to_string(principalOutstanding)); - } - - { - auto const [assetsTotalDelta, debtTotalDelta, interestDue, principalOutstanding] = - runOrigination(all_); - - BEAST_EXPECTS( - assetsTotalDelta == interestDue, - "whole-life origination must add interestDue to AssetsTotal; delta=" + - to_string(assetsTotalDelta) + " interestDue=" + to_string(interestDue)); - BEAST_EXPECTS( - debtTotalDelta == principalOutstanding + interestDue, - "whole-life origination must add principal+interest to DebtTotal; delta=" + - to_string(debtTotalDelta)); - } - - // AssetsMaximum guard checks interestDue headroom only under - // whole-life accounting; DebtMaximum guard also varies by model. - auto runVaultGuard = [&](FeatureBitset features, Number const& slack, TER expected) { - Env env(*this, features); - - Account const lender{"lender"}; - Account const borrower{"borrower"}; - env.fund(XRP(1'000'000), lender, borrower); - env.close(); - - BrokerInfo const broker{createVaultAndBroker(env, xrpAsset, lender, brokerParams)}; - - auto const vaultSle = env.le(broker.vaultKeylet()); - BEAST_EXPECT(vaultSle); - Number const assetsTotalBefore = vaultSle->at(sfAssetsTotal); - - Vault const vault{env}; - auto tx = vault.set({.owner = lender, .id = broker.vaultID}); - tx[sfAssetsMaximum] = assetsTotalBefore + slack; - env(tx); - env.close(); - - env(set(borrower, broker.brokerID, xrpAsset(principalRequest).value()), - kCounterparty(lender), - kInterestRate(interestRate), - kPaymentTotal(paymentTotal), - kPaymentInterval(paymentInterval), - Sig(sfCounterpartySignature, lender), - Fee(env.current()->fees().base * 2), - Ter(expected)); - env.close(); - }; - - auto runBrokerGuard = [&](FeatureBitset features, Number const& debtMaximum, TER expected) { - Env env(*this, features); + Account const issuer("issuer"); + Account const borrower("borrower"); + Account const broker("broker"); - Account const lender{"lender"}; - Account const borrower{"borrower"}; - env.fund(XRP(1'000'000), lender, borrower); - env.close(); + Env env{*this, features}; + env.fund(XRP(20'000), issuer, broker, borrower); + env.close(); - BrokerInfo const broker{createVaultAndBroker(env, xrpAsset, lender, brokerParams)}; + MPTTester mptt{env, issuer, kMptInitNoFund}; + mptt.create({.flags = tfMPTCanClawback | tfMPTCanTransfer | tfMPTCanLock}); - env(loanBroker::set(lender, broker.vaultID), - loanBroker::kLoanBrokerId(broker.brokerID), - loanBroker::kDebtMaximum(debtMaximum), - Fee(env.current()->fees().base * 2)); - env.close(); + PrettyAsset const mpt{mptt.issuanceID()}; - env(set(borrower, broker.brokerID, xrpAsset(principalRequest).value()), - kCounterparty(lender), - kInterestRate(interestRate), - kPaymentTotal(paymentTotal), - kPaymentInterval(paymentInterval), - Sig(sfCounterpartySignature, lender), - Fee(env.current()->fees().base * 2), - Ter(expected)); - env.close(); - }; + // Authorize broker and borrower + mptt.authorize({.account = broker}); + mptt.authorize({.account = borrower}); - Number const oneDrop = xrpAsset(1).value(); - { - testcase("whole-life: LoanSet AssetsMaximum guard checks interestDue headroom"); - // Guard rejects when there's not quite enough headroom for the - // interest. - runVaultGuard(all_, interestDueCash - oneDrop, tecLIMIT_EXCEEDED); - // Guard accepts at the exact boundary. - runVaultGuard(all_, interestDueCash, tesSUCCESS); - } + env.close(); - { - testcase("cash-basis: LoanSet AssetsMaximum guard ignores interestDue headroom"); - // Even far less headroom than interestDue still succeeds, since - // cash-basis origination never adds interest to AssetsTotal. - runVaultGuard(all_ | featureLendingProtocolV1_1, oneDrop, tesSUCCESS); - } + // Fund accounts + env(pay(issuer, broker, mpt(10'000'000))); + env(pay(issuer, borrower, mpt(1'000))); + env.close(); - // DebtMaximum guard: cash-basis projects principal-only DebtTotal; - // whole-life projects principal + interestDue. - for (auto const cashBasis : {true, false}) + // Create vault and broker + auto const brokerInfo = createVaultAndBroker(env, mpt, broker); + // Create a loan first (this creates debt) + auto const keylet = keylet::loan(brokerInfo.brokerID, 1); + env(set(borrower, brokerInfo.brokerID, 10'000), + Sig(sfCounterpartySignature, broker), + kLoanServiceFee(mpt(100).value()), + kPaymentInterval(100), + Fee(XRP(100))); + env.close(); + // Ensure broker has sufficient cover so brokerPayee == brokerOwner + // We need coverAvailable >= (debtTotal * coverRateMinimum) + // Deposit enough cover to ensure the fee goes to broker owner + // The default coverRateMinimum is 10%, so for a 10,000 loan we need + // at least 1,000 cover. Default cover is 1,000, so we add more to be + // safe. + auto const additionalCover = mpt(50'000).value(); + env(loanBroker::coverDeposit(broker, brokerInfo.brokerID, STAmount{mpt, additionalCover})); + env.close(); + // Verify broker owner is authorized + auto const brokerMpt = keylet::mptoken(mptt.issuanceID(), broker); + BEAST_EXPECT(env.le(brokerMpt) != nullptr); + // Broker owner unauthorizes. + // First, pay any positive balance to issuer to zero it out + auto const brokerBalance = env.balance(broker, mpt); + env(pay(broker, issuer, brokerBalance)); + env.close(); + // Then, unauthorize the MPT. + mptt.authorize({.account = broker, .flags = tfMPTUnauthorize}); + env.close(); + // Verify the MPT is unauthorized. + BEAST_EXPECT(env.le(brokerMpt) == nullptr); + // Now borrower tries to make a payment + // We should get a tesSUCCESS instead of a tecNO_AUTH. + auto const borrowerBalance = env.balance(borrower, mpt); + env(pay(borrower, keylet.key, mpt(10'100)), Fee(XRP(100)), Ter(tesSUCCESS)); + env.close(); + // Verify the MPT is still unauthorized. + BEAST_EXPECT(env.le(brokerMpt) == nullptr); + // Verify the service fee went to the broker pseudo-account + if (auto const brokerSle = env.le(keylet::loanBroker(brokerInfo.brokerID)); + BEAST_EXPECT(brokerSle)) { - testcase( - std::string("LoanSet DebtMaximum guard (") + - (cashBasis ? "cash-basis)" : "whole-life)")); - auto const features = cashBasis ? all_ | featureLendingProtocolV1_1 : all_; - Number const newDebtTotal = - principalOutstandingCash + (cashBasis ? Number{} : interestDueCash); - runBrokerGuard(features, newDebtTotal - oneDrop, tecLIMIT_EXCEEDED); - runBrokerGuard(features, newDebtTotal, tesSUCCESS); + Account const pseudo("pseudo-account", brokerSle->at(sfAccount)); + auto const balance = env.balance(pseudo, mpt); + // 1,000 default + 50,000 extra + 100 service fee from LoanPay + BEAST_EXPECTS(balance == mpt(51'100), to_string(json::Value(balance))); } } - // 2. LoanPay: regular, late, overpayment, and full-payment types. - // Assert Vault.AssetsTotal/LoanBroker.DebtTotal deltas match - // interestPaid/principalPaid under cash-basis, and cross-check the - // amendment-disabled run's deltas against the documented whole-life - // formula (AssetsTotal += valueChange; DebtTotal mirrors the loan's own - // TotalValueOutstanding delta exactly, since whole-life debt recognition - // tracks total loan value). void - testCashBasisLoanPay() + testLoanPayBrokerOwnerNoPermissionedDomainMPT(FeatureBitset features) { + testcase << "LoanPay Broker Owner without permissioned domain of the MPT"; using namespace jtx; using namespace loan; - using namespace std::chrono_literals; - using tp = NetClock::time_point; - - PrettyAsset const xrpAsset{xrpIssue(), 1'000'000}; - BrokerParameters const brokerParams{ - .vaultDeposit = 1'000'000, - .debtMax = 0, - .coverRateMin = TenthBips32{0}, - .coverDeposit = 0, - .managementFeeRate = TenthBips16{0}, - .coverRateLiquidation = TenthBips32{0}}; - - Number const principalRequest{12'000}; - TenthBips32 const interestRate{percentageToTenthBips(12)}; - std::uint32_t const paymentTotal = 4; - std::uint32_t const paymentInterval = 600; - std::uint32_t const gracePeriod = 300; - - struct PaymentDeltas - { - Number principalPaid; - Number assetsTotalDelta; - Number debtTotalDelta; - Number totalValueDelta; - }; - - // Sets up a fresh broker + loan, advances time, submits a single - // payment of the given type/amount, and returns the observed deltas. - auto runPayment = [&](FeatureBitset features, - std::uint32_t loanSetFlags, - std::uint32_t payFlags, - std::function const& advanceTime, - std::function const& paymentAmount) { - Env env(*this, features); - - Account const lender{"lender"}; - Account const borrower{"borrower"}; - env.fund(XRP(10'000'000), lender, borrower); - env.close(); - - BrokerInfo const broker{createVaultAndBroker(env, xrpAsset, lender, brokerParams)}; - - LoanParameters const loanParams{ - .account = borrower, - .counter = lender, - .principalRequest = principalRequest, - .interest = interestRate, - .payTotal = paymentTotal, - .payInterval = paymentInterval, - .gracePd = gracePeriod, - .flags = loanSetFlags, - }; - - auto const brokerBeforeLoan = env.le(broker.brokerKeylet()); - BEAST_EXPECT(brokerBeforeLoan); - auto const loanSequence = brokerBeforeLoan->at(sfLoanSequence); - auto const loanKeylet = keylet::loan(broker.brokerID, loanSequence); - - env(loanParams(env, broker)); - env.close(); - - LoanState const state = getCurrentState(env, broker, loanKeylet); - - advanceTime(env, state.startDate); - - auto const vaultBefore = env.le(broker.vaultKeylet()); - auto const brokerBefore = env.le(broker.brokerKeylet()); - auto const loanBefore = env.le(loanKeylet); - BEAST_EXPECT(vaultBefore && brokerBefore && loanBefore); - Number const principalBefore = loanBefore->at(sfPrincipalOutstanding); - Number const totalValueBefore = loanBefore->at(sfTotalValueOutstanding); - Number const assetsTotalBefore = vaultBefore->at(sfAssetsTotal); - Number const debtTotalBefore = brokerBefore->at(sfDebtTotal); - - STAmount const amount = paymentAmount(state); - env(pay(borrower, loanKeylet.key, amount, payFlags), Ter(tesSUCCESS)); - env.close(); + Account const issuer("issuer"); + Account const borrower("borrower"); + Account const broker("broker"); - auto const vaultAfter = env.le(broker.vaultKeylet()); - auto const brokerAfter = env.le(broker.brokerKeylet()); - auto const loanAfter = env.le(loanKeylet); - BEAST_EXPECT(vaultAfter && brokerAfter && loanAfter); - - Number const principalAfter = loanAfter->at(sfPrincipalOutstanding); - Number const totalValueAfter = loanAfter->at(sfTotalValueOutstanding); - Number const assetsTotalAfter = vaultAfter->at(sfAssetsTotal); - Number const debtTotalAfter = brokerAfter->at(sfDebtTotal); - - return PaymentDeltas{ - .principalPaid = principalBefore - principalAfter, - .assetsTotalDelta = assetsTotalAfter - assetsTotalBefore, - .debtTotalDelta = debtTotalAfter - debtTotalBefore, - .totalValueDelta = totalValueAfter - totalValueBefore}; - }; + Env env{*this, features}; + env.fund(XRP(20'000), issuer, broker, borrower); + env.close(); - // Compares the disabled (whole-life) and enabled (cash-basis) runs - // of the same payment scenario, and asserts the documented - // relationships between them. - auto checkScenario = [&](std::string const& label, - PaymentDeltas const& off, - PaymentDeltas const& on) { - testcase("cash-basis: LoanPay " + label); + auto credType = "credential1"; - // The loan's own PrincipalOutstanding field is untouched by - // the amendment. - BEAST_EXPECTS( - off.principalPaid == on.principalPaid, - "principalPaid must be amendment-independent; off=" + to_string(off.principalPaid) + - " on=" + to_string(on.principalPaid)); - - // Whole-life structural invariant: DebtTotal (which - // recognizes a loan's full remaining value as debt) must - // change exactly as the loan's own TotalValueOutstanding - // does. - BEAST_EXPECTS( - off.debtTotalDelta == off.totalValueDelta, - "whole-life DebtTotal delta must mirror TotalValueOutstanding delta; " - "debtTotalDelta=" + - to_string(off.debtTotalDelta) + - " totalValueDelta=" + to_string(off.totalValueDelta)); - - // Derive interestPaid from the whole-life run's independent - // ledger deltas: - // assetsTotalDelta_off == valueChange - // debtTotalDelta_off == valueChange - (principalPaid + interestPaid) - // => interestPaid == assetsTotalDelta_off - debtTotalDelta_off - principalPaid - Number const interestPaid = - off.assetsTotalDelta - off.debtTotalDelta - off.principalPaid; - BEAST_EXPECTS( - interestPaid >= beast::kZero, - "derived interestPaid must be non-negative: " + to_string(interestPaid)); + pdomain::Credentials const credentials1 = {{.issuer = issuer, .credType = credType}}; + env(pdomain::setTx(issuer, credentials1)); + env.close(); - BEAST_EXPECTS( - on.assetsTotalDelta == interestPaid, - "cash-basis AssetsTotal delta must equal interestPaid; delta=" + - to_string(on.assetsTotalDelta) + " interestPaid=" + to_string(interestPaid)); - BEAST_EXPECTS( - on.debtTotalDelta == -on.principalPaid, - "cash-basis DebtTotal delta must equal -principalPaid; delta=" + - to_string(on.debtTotalDelta) + " principalPaid=" + to_string(on.principalPaid)); - }; + auto domainID = pdomain::getNewDomain(env.meta()); - // ---- Regular, on-time payment ---- - { - auto const noAdvance = [](Env& env, tp const&) { env.close(); }; - auto const regularAmount = [&](LoanState const& state) { - return STAmount{ - xrpAsset, - roundPeriodicPayment(xrpAsset, state.periodicPayment, state.loanScale) * - Number{3, -1} * 5}; // 1.5x, so only a single period is paid - }; + env(credentials::create(broker, issuer, credType)); + env(credentials::accept(broker, issuer, credType)); + env.close(); - auto const off = runPayment(all_, 0, 0, noAdvance, regularAmount); - auto const on = - runPayment(all_ | featureLendingProtocolV1_1, 0, 0, noAdvance, regularAmount); + env(credentials::create(borrower, issuer, credType)); + env(credentials::accept(borrower, issuer, credType)); + env.close(); - // Regular, on-time payments never change the loan's value beyond - // normal amortization (production asserts valueChange == 0), so - // AssetsTotal must be unaffected in the whole-life run. - BEAST_EXPECTS( - off.assetsTotalDelta == beast::kZero, - "regular on-time payment must not change AssetsTotal under whole-life; delta=" + - to_string(off.assetsTotalDelta)); + MPTTester mptt{env, issuer, kMptInitNoFund}; + mptt.create({ + .flags = tfMPTCanClawback | tfMPTRequireAuth | tfMPTCanTransfer | tfMPTCanLock, + .domainID = domainID, + }); - checkScenario("regular payment", off, on); - } + PrettyAsset const mpt{mptt.issuanceID()}; - // ---- Late payment ---- - { - auto const advancePastDue = [&](Env& env, tp const& startDate) { - env.close(startDate + std::chrono::seconds(paymentInterval + 1)); - }; - auto const lateAmount = [&](LoanState const& state) { - return STAmount{ - xrpAsset, - roundPeriodicPayment(xrpAsset, state.periodicPayment, state.loanScale) * - Number{3}}; // generous; excess is not withdrawn - }; + // Authorize broker and borrower + mptt.authorize({.account = broker}); + mptt.authorize({.account = borrower}); - auto const off = runPayment(all_, 0, tfLoanLatePayment, advancePastDue, lateAmount); - auto const on = runPayment( - all_ | featureLendingProtocolV1_1, - 0, - tfLoanLatePayment, - advancePastDue, - lateAmount); + env.close(); - checkScenario("late payment", off, on); - } + // Fund accounts + env(pay(issuer, broker, mpt(10'000'000))); + env(pay(issuer, borrower, mpt(1'000))); + env.close(); - // ---- Overpayment ---- - { - auto const noAdvance = [](Env& env, tp const&) { env.close(); }; - auto const overpayAmount = [&](LoanState const& state) { - // One regular period, plus a generous extra principal - // paydown. - return STAmount{ - xrpAsset, - roundPeriodicPayment(xrpAsset, state.periodicPayment, state.loanScale) + - xrpAsset(2'000).value()}; - }; + // Create vault and broker + auto const brokerInfo = createVaultAndBroker(env, mpt, broker); + // Create a loan first (this creates debt) + auto const keylet = keylet::loan(brokerInfo.brokerID, 1); + env(set(borrower, brokerInfo.brokerID, 10'000), + Sig(sfCounterpartySignature, broker), + kLoanServiceFee(mpt(100).value()), + kPaymentInterval(100), + Fee(XRP(100))); + env.close(); + // Ensure broker has sufficient cover so brokerPayee == brokerOwner + // We need coverAvailable >= (debtTotal * coverRateMinimum) + // Deposit enough cover to ensure the fee goes to broker owner + // The default coverRateMinimum is 10%, so for a 10,000 loan we need + // at least 1,000 cover. Default cover is 1,000, so we add more to be + // safe. + auto const additionalCover = mpt(50'000).value(); + env(loanBroker::coverDeposit(broker, brokerInfo.brokerID, STAmount{mpt, additionalCover})); + env.close(); + // Verify broker owner is authorized + auto const brokerMpt = keylet::mptoken(mptt.issuanceID(), broker); + BEAST_EXPECT(env.le(brokerMpt) != nullptr); + // Remove the credentials for the Broker owner. + // First, pay any positive balance to issuer to zero it out + auto const brokerBalance = env.balance(broker, mpt); + env(pay(broker, issuer, brokerBalance)); + env.close(); - auto const off = - runPayment(all_, tfLoanOverpayment, tfLoanOverpayment, noAdvance, overpayAmount); - auto const on = runPayment( - all_ | featureLendingProtocolV1_1, - tfLoanOverpayment, - tfLoanOverpayment, - noAdvance, - overpayAmount); + env(credentials::deleteCred(broker, broker, issuer, credType)); + env.close(); - checkScenario("overpayment", off, on); - } + // Make sure the broker is not authorized to hold the MPT after we + // deleted the credentials + env(pay(issuer, broker, mpt(1'000)), Ter(tecNO_AUTH)); - // ---- Full payment ---- + // Now borrower tries to make a payment + // We should get a tesSUCCESS instead of a tecNO_AUTH. + auto const borrowerBalance = env.balance(borrower, mpt); + env(pay(borrower, keylet.key, mpt(10'100)), Fee(XRP(100)), Ter(tesSUCCESS)); + env.close(); + // Verify broker is still not authorized + env(pay(issuer, broker, mpt(1'000)), Ter(tecNO_AUTH)); + // Verify the service fee went to the broker pseudo-account + if (auto const brokerSle = env.le(keylet::loanBroker(brokerInfo.brokerID)); + BEAST_EXPECT(brokerSle)) { - auto const noAdvance = [](Env& env, tp const&) { env.close(); }; - auto const fullAmount = [&](LoanState const&) { - // Generously large: full payment only ever consumes exactly - // what's due (principal + accrued interest; close fee/ - // prepayment penalty are 0 here), excess is not withdrawn. - return STAmount{xrpAsset, xrpAsset(principalRequest).value() * Number{2}}; - }; - - auto const off = runPayment(all_, 0, tfLoanFullPayment, noAdvance, fullAmount); - auto const on = runPayment( - all_ | featureLendingProtocolV1_1, 0, tfLoanFullPayment, noAdvance, fullAmount); - - checkScenario("full payment", off, on); + Account const pseudo("pseudo-account", brokerSle->at(sfAccount)); + auto const balance = env.balance(pseudo, mpt); + // 1,000 default + 50,000 extra + 100 service fee from LoanPay + BEAST_EXPECTS(balance == mpt(51'100), to_string(json::Value(balance))); } } - // 3. LoanManage: impair, unimpair, and default. void - testCashBasisLoanManage() + testLoanSetBrokerOwnerNoPermissionedDomainMPT(FeatureBitset features) { + testcase << "LoanSet Broker Owner without permissioned domain of the MPT"; using namespace jtx; using namespace loan; - using namespace std::chrono_literals; - - PrettyAsset const xrpAsset{xrpIssue(), 1'000'000}; - BrokerParameters const brokerParams{ - .vaultDeposit = 1'000'000, - .debtMax = 0, - .coverRateMin = TenthBips32{percentageToTenthBips(10)}, - .coverDeposit = 5'000, - .managementFeeRate = TenthBips16{0}, - .coverRateLiquidation = TenthBips32{percentageToTenthBips(25)}}; - Number const principalRequest{10'000}; - TenthBips32 const interestRate{percentageToTenthBips(12)}; - std::uint32_t const paymentTotal = 4; - std::uint32_t const paymentInterval = 600; - std::uint32_t const gracePeriod = 60; + Account const issuer("issuer"); + Account const borrower("borrower"); + Account const broker("broker"); - auto setupLoan = [&](Env& env) { - Account const lender{"lender"}; - Account const borrower{"borrower"}; - env.fund(XRP(10'000'000), lender, borrower); - env.close(); + Env env{*this, features}; + env.fund(XRP(20'000), issuer, broker, borrower); + env.close(); - BrokerInfo const broker{createVaultAndBroker(env, xrpAsset, lender, brokerParams)}; + auto credType = "credential1"; - LoanParameters const loanParams{ - .account = borrower, - .counter = lender, - .principalRequest = principalRequest, - .interest = interestRate, - .payTotal = paymentTotal, - .payInterval = paymentInterval, - .gracePd = gracePeriod, - }; + pdomain::Credentials const credentials1{{.issuer = issuer, .credType = credType}}; + env(pdomain::setTx(issuer, credentials1)); + env.close(); - auto const brokerBeforeLoan = env.le(broker.brokerKeylet()); - BEAST_EXPECT(brokerBeforeLoan); - auto const loanSequence = brokerBeforeLoan->at(sfLoanSequence); - auto const loanKeylet = keylet::loan(broker.brokerID, loanSequence); + auto domainID = pdomain::getNewDomain(env.meta()); - env(loanParams(env, broker)); - env.close(); + // Add credentials for the broker and borrower + env(credentials::create(broker, issuer, credType)); + env(credentials::accept(broker, issuer, credType)); + env.close(); - return std::make_tuple(broker, loanKeylet, lender, borrower); - }; + env(credentials::create(borrower, issuer, credType)); + env(credentials::accept(borrower, issuer, credType)); + env.close(); - // ---- impair / unimpair ---- - auto runImpairUnimpair = [&](FeatureBitset features) { - Env env(*this, features); - auto const [broker, loanKeylet, lender, borrower] = setupLoan(env); + MPTTester mptt{env, issuer, kMptInitNoFund}; + mptt.create({ + .flags = tfMPTCanClawback | tfMPTRequireAuth | tfMPTCanTransfer | tfMPTCanLock, + .domainID = domainID, + }); - auto const loanBefore = env.le(loanKeylet); - BEAST_EXPECT(loanBefore); - Number const principalOutstanding = loanBefore->at(sfPrincipalOutstanding); - Number const totalValueOutstanding = loanBefore->at(sfTotalValueOutstanding); - Number const managementFeeOutstanding = loanBefore->at(sfManagementFeeOutstanding); + PrettyAsset const mpt{mptt.issuanceID()}; - Number const expectedExposure = - env.current()->rules().enabled(featureLendingProtocolV1_1) - ? principalOutstanding - : totalValueOutstanding - managementFeeOutstanding; + // Authorize broker and borrower + mptt.authorize({.account = broker}); + mptt.authorize({.account = borrower}); + env.close(); - auto const vaultBeforeImpair = env.le(broker.vaultKeylet()); - BEAST_EXPECT(vaultBeforeImpair); - Number const lossBefore = vaultBeforeImpair->at(sfLossUnrealized); + // Fund accounts + env(pay(issuer, broker, mpt(10'000'000))); + env(pay(issuer, borrower, mpt(1'000))); + env.close(); - env(manage(lender, loanKeylet.key, tfLoanImpair), Ter(tesSUCCESS)); - env.close(); + // Create vault and broker + auto const brokerInfo = createVaultAndBroker(env, mpt, broker); - auto const vaultAfterImpair = env.le(broker.vaultKeylet()); - BEAST_EXPECT(vaultAfterImpair); - Number const impairDelta = Number(vaultAfterImpair->at(sfLossUnrealized)) - lossBefore; + // Remove the credentials for the Broker owner. + // Clear the balance first. + auto const brokerBalance = env.balance(broker, mpt); + env(pay(broker, issuer, brokerBalance)); + env.close(); + // Delete the credentials + env(credentials::deleteCred(broker, broker, issuer, credType)); + env.close(); - env(manage(lender, loanKeylet.key, tfLoanUnimpair), Ter(tesSUCCESS)); - env.close(); + // Create a loan, this should fail for tecNO_AUTH + env(set(borrower, brokerInfo.brokerID, 10'000), + Sig(sfCounterpartySignature, broker), + kLoanServiceFee(mpt(100).value()), + kPaymentInterval(100), + Fee(XRP(100)), + Ter(tecNO_AUTH)); + env.close(); + } - auto const vaultAfterUnimpair = env.le(broker.vaultKeylet()); - BEAST_EXPECT(vaultAfterUnimpair); - Number const netDelta = Number(vaultAfterUnimpair->at(sfLossUnrealized)) - lossBefore; + // Verify that LoanPay, LoanBrokerCoverWithdraw, and LoanSet all use the + // same vault-scale minimum cover when fixCleanup3_2_0 is enabled. + // Before the amendment, each transactor computed its minimum cover at a + // different precision (loanScale, debtScale, or the raw unrounded + // tenthBipsOfValue), which could lead to inconsistent decisions for the + // same broker state. After the amendment all three use + // minimumBrokerCover at vaultScale. + void + testMinimumBrokerCoverConsistency(FeatureBitset features) + { + using namespace jtx; + using namespace loan; + using namespace loanBroker; - return std::make_tuple(expectedExposure, impairDelta, netDelta); - }; + bool const withAmendment = features[fixCleanup3_2_0]; - for (auto const features : {all_ | featureLendingProtocolV1_1, all_}) + struct Ctx { - testcase( - std::string("cash-basis: LoanManage impair/unimpair (") + - (features[featureLendingProtocolV1_1] ? "enabled)" : "disabled)")); - auto const [expectedExposure, impairDelta, netDelta] = runImpairUnimpair(features); - - BEAST_EXPECTS( - impairDelta == expectedExposure, - "impair must add loanVaultExposure to LossUnrealized; delta=" + - to_string(impairDelta) + " expected=" + to_string(expectedExposure)); - BEAST_EXPECTS( - netDelta == beast::kZero, - "unimpair must be an exact reversal of impair; net=" + to_string(netDelta)); - } + jtx::Account issuer; + jtx::Account lender; + jtx::Account borrower; + jtx::PrettyAsset iou; + BrokerInfo broker; + BrokerParameters brokerParams; + }; - // ---- impair, then default ---- - auto runDefault = [&](FeatureBitset features) { + // Shared setup, parametrized by vaultDeposit (the only varying setup + // field across the three scenarios). Each call runs in its own Env + // so multiple invocations within one scenario cannot interfere. + // The caller is responsible for invoking testcase(...) before the + // first runTest call of each scenario. + auto runTest = [&](Number vaultDeposit, auto&& body) { Env env(*this, features); - auto const [broker, loanKeylet, lender, borrower] = setupLoan(env); - auto const loanBeforeImpair = env.le(loanKeylet); - BEAST_EXPECT(loanBeforeImpair); - Number const principalOutstanding = loanBeforeImpair->at(sfPrincipalOutstanding); - Number const totalValueOutstanding = loanBeforeImpair->at(sfTotalValueOutstanding); - Number const managementFeeOutstanding = - loanBeforeImpair->at(sfManagementFeeOutstanding); + Account const issuer{"issuer"}; + Account const lender{"lender"}; + Account const borrower{"borrower"}; - Number const expectedExposure = - env.current()->rules().enabled(featureLendingProtocolV1_1) - ? principalOutstanding - : totalValueOutstanding - managementFeeOutstanding; + env.fund(XRP(1'000'000'000), issuer, lender, borrower); + env.close(); - env(manage(lender, loanKeylet.key, tfLoanImpair), Ter(tesSUCCESS)); + // Enable clawback on the issuer *before* any trust lines exist + // (asfAllowTrustLineClawback requires an empty owner directory). + env(fset(issuer, asfAllowTrustLineClawback)); env.close(); - LoanState const state = getCurrentState(env, broker, loanKeylet); - env.close( - state.startDate + std::chrono::seconds(paymentInterval) + - std::chrono::seconds(gracePeriod) + 60s); + PrettyAsset const iou = issuer[iouCurrency_]; + env(trust(lender, iou(1'000'000'000))); + env(trust(borrower, iou(1'000'000'000))); + env.close(); + env(pay(issuer, lender, iou(100'000'000))); + env(pay(issuer, borrower, iou(100'000'000))); + env.close(); - auto const vaultBefore = env.le(broker.vaultKeylet()); - auto const brokerBefore = env.le(broker.brokerKeylet()); - BEAST_EXPECT(vaultBefore && brokerBefore); - Number const assetsTotalBefore = vaultBefore->at(sfAssetsTotal); - Number const debtTotalBefore = brokerBefore->at(sfDebtTotal); - Number const lossBefore = vaultBefore->at(sfLossUnrealized); - Number const coverAvailableBefore = brokerBefore->at(sfCoverAvailable); + // 13.37% — non-round rate produces a messier minimum. + BrokerParameters const brokerParams{ + .vaultDeposit = vaultDeposit, + .debtMax = 0, + .coverRateMin = TenthBips32{13'370}, + .coverDeposit = 5'000, + .managementFeeRate = TenthBips16{500}}; - env(manage(lender, loanKeylet.key, tfLoanDefault), Ter(tesSUCCESS)); - env.close(); + BrokerInfo const broker = createVaultAndBroker(env, iou, lender, brokerParams); - auto const vaultAfter = env.le(broker.vaultKeylet()); - auto const brokerAfter = env.le(broker.brokerKeylet()); - BEAST_EXPECT(vaultAfter && brokerAfter); - Number const assetsTotalDelta = - Number(vaultAfter->at(sfAssetsTotal)) - assetsTotalBefore; - Number const debtTotalDelta = Number(brokerAfter->at(sfDebtTotal)) - debtTotalBefore; - Number const lossDelta = Number(vaultAfter->at(sfLossUnrealized)) - lossBefore; - Number const coverAvailableDelta = - Number(brokerAfter->at(sfCoverAvailable)) - coverAvailableBefore; - - Number const defaultCovered = -coverAvailableDelta; - Number const vaultDefaultAmount = expectedExposure - defaultCovered; - - return std::make_tuple( - expectedExposure, assetsTotalDelta, debtTotalDelta, lossDelta, vaultDefaultAmount); + body( + env, + Ctx{.issuer = issuer, + .lender = lender, + .borrower = borrower, + .iou = iou, + .broker = broker, + .brokerParams = brokerParams}); }; - for (auto const features : {all_ | featureLendingProtocolV1_1, all_}) + // Scenario 1 — LoanPay + // + // Verify that LoanPay's minimum cover check uses vault scale (not + // loan scale). Before the amendment, different loans could produce + // different fee routing decisions for the same broker-level state. + // Small vault deposit => vaultScale = -12. + testcase("LoanPay minimum cover scale consistency"); { - testcase( - std::string("cash-basis: LoanManage default (") + - (features[featureLendingProtocolV1_1] ? "enabled)" : "disabled)")); - auto const - [expectedExposure, - assetsTotalDelta, - debtTotalDelta, - lossDelta, - vaultDefaultAmount] = runDefault(features); - - BEAST_EXPECTS( - debtTotalDelta == -expectedExposure, - "default must reduce DebtTotal by the unified default amount; delta=" + - to_string(debtTotalDelta) + " expected=" + to_string(expectedExposure)); - BEAST_EXPECTS( - lossDelta == -expectedExposure, - "default must reverse the earlier impair's LossUnrealized exactly; delta=" + - to_string(lossDelta) + " expected=" + to_string(expectedExposure)); - BEAST_EXPECTS( - assetsTotalDelta == -vaultDefaultAmount, - "default must reduce AssetsTotal by (defaultAmount - defaultCovered); delta=" + - to_string(assetsTotalDelta) + " expected=" + to_string(-vaultDefaultAmount)); - } - } - - // 3b. LEVersion regression: a Vault created before featureLendingProtocolV1_1 - // activates (LEVersion absent) must keep whole-life (accrual) accounting - // forever, even after the amendment is later enabled -- the switch is - // per-Vault (LEVersion == VaultVersion::CashBasis), not a single global amendment - // flag. - void - testLegacyVaultKeepsAccrualAfterAmendmentEnabled() - { - testcase("LEVersion: legacy vault keeps accrual after amendment enabled"); - - using namespace jtx; - using namespace loan; - using namespace std::chrono_literals; - - PrettyAsset const xrpAsset{xrpIssue(), 1'000'000}; - BrokerParameters const brokerParams{ - .vaultDeposit = 1'000'000, - .debtMax = 0, - .coverRateMin = TenthBips32{percentageToTenthBips(10)}, - .coverDeposit = 5'000, - .managementFeeRate = TenthBips16{0}, - .coverRateLiquidation = TenthBips32{percentageToTenthBips(25)}}; - - Number const principalRequest{10'000}; - TenthBips32 const interestRate{percentageToTenthBips(12)}; - std::uint32_t const paymentTotal = 4; - std::uint32_t const paymentInterval = 600; - std::uint32_t const gracePeriod = 60; - - // Amendment disabled at Vault creation time: LEVersion stays absent. - Env env(*this, all_); - - Account const lender{"lender"}; - Account const borrower{"borrower"}; - env.fund(XRP(10'000'000), lender, borrower); - env.close(); - - BrokerInfo const broker{createVaultAndBroker(env, xrpAsset, lender, brokerParams)}; + struct LoanKeylets + { + Keylet tiny; + Keylet big; + }; - { - auto const vaultSle = env.le(broker.vaultKeylet()); - BEAST_EXPECT(vaultSle); - BEAST_EXPECT(!vaultSle->isFieldPresent(sfLEVersion)); - } + // Create the tiny + big loans and reduce cover via clawback so + // that subsequent LoanPay calls hit the minimum-cover boundary. + // Used by the two pay-and-check sub-tests below so each can run + // in its own Env. + auto setupLoansAndClawback = [&](Env& env, Ctx const& c) -> std::optional { + Asset const asset{c.iou}; - // Now enable the amendment -- production dispatch must still treat - // this specific Vault as accrual-basis, since its LEVersion is - // (and remains) absent. - env.enableFeature(featureLendingProtocolV1_1); - env.close(); + // Create the TINY loan first (while vaultScale is still + // small). principal 0.01, 0% interest, 1 payment => + // loanScale = vaultScale. + auto const brokerSle1 = env.le(keylet::loanBroker(c.broker.brokerID)); + if (!BEAST_EXPECT(brokerSle1)) + return std::nullopt; + auto const tinyLoanSeq = brokerSle1->at(sfLoanSequence); + auto const tinyLoanKeylet = keylet::loan(c.broker.brokerID, tinyLoanSeq); - LoanParameters const loanParams{ - .account = borrower, - .counter = lender, - .principalRequest = principalRequest, - .interest = interestRate, - .payTotal = paymentTotal, - .payInterval = paymentInterval, - .gracePd = gracePeriod, - }; + env(set(c.borrower, c.broker.brokerID, Number{1, -2}), + Sig(sfCounterpartySignature, c.lender), + kInterestRate(TenthBips32{0}), + kPaymentTotal(1), + kPaymentInterval(86400 * 365), + Fee(XRP(10))); + env.close(); - auto const brokerBeforeLoan = env.le(broker.brokerKeylet()); - BEAST_EXPECT(brokerBeforeLoan); - auto const loanSequence = brokerBeforeLoan->at(sfLoanSequence); - auto const loanKeylet = keylet::loan(broker.brokerID, loanSequence); + // Create the BIG loan second. 100% annual interest over 20 + // payments pushes totalValueOutstanding high enough that + // loanScale > vaultScale. + auto const brokerSle2 = env.le(keylet::loanBroker(c.broker.brokerID)); + if (!BEAST_EXPECT(brokerSle2)) + return std::nullopt; + auto const bigLoanSeq = brokerSle2->at(sfLoanSequence); + auto const bigLoanKeylet = keylet::loan(c.broker.brokerID, bigLoanSeq); - // ---- LoanSet origination: whole-life formulas expected ---- - auto const vaultBeforeSet = env.le(broker.vaultKeylet()); - auto const brokerBeforeSet = env.le(broker.brokerKeylet()); - BEAST_EXPECT(vaultBeforeSet && brokerBeforeSet); - Number const assetsTotalBeforeSet = vaultBeforeSet->at(sfAssetsTotal); - Number const debtTotalBeforeSet = brokerBeforeSet->at(sfDebtTotal); + env(set(c.borrower, c.broker.brokerID, Number{500}), + Sig(sfCounterpartySignature, c.lender), + kInterestRate(TenthBips32{100'000}), + kPaymentTotal(20), + kPaymentInterval(86400 * 365), + Fee(XRP(10))); + env.close(); - env(loanParams(env, broker)); - env.close(); + // The tiny loan's scale is frozen at the vault's pre-big-loan + // scale, so it is strictly smaller than the big loan's. + // After the big loan is created the vault absorbs its value, + // pushing vaultScale up to match bigLoanScale. + auto const tinyLoanSle = env.le(tinyLoanKeylet); + auto const bigLoanSle = env.le(bigLoanKeylet); + auto const vaultSle = env.le(keylet::vault(c.broker.vaultID)); + if (!BEAST_EXPECT(tinyLoanSle) || !BEAST_EXPECT(bigLoanSle) || + !BEAST_EXPECT(vaultSle)) + return std::nullopt; + if (!BEAST_EXPECT(tinyLoanSle->at(sfLoanScale) == -12) || + !BEAST_EXPECT(bigLoanSle->at(sfLoanScale) == -11) || + !BEAST_EXPECT(getAssetsTotalScale(vaultSle) == -11)) + return std::nullopt; - auto const loanAfterSet = env.le(loanKeylet); - BEAST_EXPECT(loanAfterSet); - Number const principalOutstanding = loanAfterSet->at(sfPrincipalOutstanding); - Number const totalValueOutstanding = loanAfterSet->at(sfTotalValueOutstanding); - Number const interestDue = totalValueOutstanding - principalOutstanding; - BEAST_EXPECT(interestDue > beast::kZero); - - auto const vaultAfterSet = env.le(broker.vaultKeylet()); - auto const brokerAfterSet = env.le(broker.brokerKeylet()); - BEAST_EXPECT(vaultAfterSet && brokerAfterSet); - Number const assetsTotalDeltaSet = - Number(vaultAfterSet->at(sfAssetsTotal)) - assetsTotalBeforeSet; - Number const debtTotalDeltaSet = - Number(brokerAfterSet->at(sfDebtTotal)) - debtTotalBeforeSet; + // Use issuer clawback to reduce cover to the minimum the + // clawback transactor allows. Compute the amount as + // initialCover - expectedCoverAfter so we exercise the exact + // clawback rather than relying on the transactor to clip + // down. + // + // Before the amendment the clawback minimum is the + // *unrounded* tenthBipsOfValue — strictly less than the + // rounded-at-vaultScale minimum LoanPay uses for the big + // loan. After the amendment both clawback and LoanPay use + // the same rounded minimum (via minimumBrokerCover), so + // cover lands exactly at that threshold. + Number const expectedCoverAfter = withAmendment ? Number{1330651855688460000, -15} + : Number{1330651855688458000, -15}; + Number const clawbackAmount = + Number{c.brokerParams.coverDeposit} - expectedCoverAfter; - BEAST_EXPECTS( - assetsTotalDeltaSet == interestDue, - "legacy vault origination must still add interestDue to AssetsTotal; delta=" + - to_string(assetsTotalDeltaSet) + " interestDue=" + to_string(interestDue)); - BEAST_EXPECTS( - debtTotalDeltaSet == principalOutstanding + interestDue, - "legacy vault origination must still add principal+interest to DebtTotal; delta=" + - to_string(debtTotalDeltaSet)); + env(coverClawback(c.issuer), + kLoanBrokerId(c.broker.brokerID), + kAmount(STAmount{asset, clawbackAmount})); + env.close(); - LoanState const state = getCurrentState(env, broker, loanKeylet); - env.close(); + auto const brokerSle = env.le(keylet::loanBroker(c.broker.brokerID)); + if (!BEAST_EXPECT(brokerSle) || + !BEAST_EXPECT(brokerSle->at(sfCoverAvailable) == expectedCoverAfter)) + return std::nullopt; - // ---- LoanPay: whole-life formulas expected ---- - auto const vaultBeforePay = env.le(broker.vaultKeylet()); - auto const brokerBeforePay = env.le(broker.brokerKeylet()); - auto const loanBeforePay = env.le(loanKeylet); - BEAST_EXPECT(vaultBeforePay && brokerBeforePay && loanBeforePay); - Number const totalValueBeforePay = loanBeforePay->at(sfTotalValueOutstanding); - Number const assetsTotalBeforePay = vaultBeforePay->at(sfAssetsTotal); - Number const debtTotalBeforePay = brokerBeforePay->at(sfDebtTotal); - - STAmount const paymentAmount{ - xrpAsset, roundPeriodicPayment(xrpAsset, state.periodicPayment, state.loanScale)}; - env(pay(borrower, loanKeylet.key, paymentAmount), Ter(tesSUCCESS)); - env.close(); + return LoanKeylets{.tiny = tinyLoanKeylet, .big = bigLoanKeylet}; + }; - auto const vaultAfterPay = env.le(broker.vaultKeylet()); - auto const brokerAfterPay = env.le(broker.brokerKeylet()); - auto const loanAfterPay = env.le(loanKeylet); - BEAST_EXPECT(vaultAfterPay && brokerAfterPay && loanAfterPay); - Number const totalValueAfterPay = loanAfterPay->at(sfTotalValueOutstanding); - Number const assetsTotalDeltaPay = - Number(vaultAfterPay->at(sfAssetsTotal)) - assetsTotalBeforePay; - Number const debtTotalDeltaPay = - Number(brokerAfterPay->at(sfDebtTotal)) - debtTotalBeforePay; - Number const totalValueDeltaPay = totalValueAfterPay - totalValueBeforePay; - - // A regular, on-time payment has valueChange == 0, so whole-life - // AssetsTotal is untouched and DebtTotal mirrors TotalValueOutstanding. - BEAST_EXPECTS( - assetsTotalDeltaPay == beast::kZero, - "legacy vault regular payment must not change AssetsTotal; delta=" + - to_string(assetsTotalDeltaPay)); - BEAST_EXPECTS( - debtTotalDeltaPay == totalValueDeltaPay, - "legacy vault DebtTotal delta must mirror TotalValueOutstanding delta; " - "debtTotalDelta=" + - to_string(debtTotalDeltaPay) + " totalValueDelta=" + to_string(totalValueDeltaPay)); - - // ---- LoanManage: impair, then default -- whole-life exposure expected ---- - auto const loanBeforeImpair = env.le(loanKeylet); - BEAST_EXPECT(loanBeforeImpair); - Number const totalValueBeforeImpair = loanBeforeImpair->at(sfTotalValueOutstanding); - Number const managementFeeBeforeImpair = loanBeforeImpair->at(sfManagementFeeOutstanding); - Number const expectedExposure = totalValueBeforeImpair - managementFeeBeforeImpair; - - env(manage(lender, loanKeylet.key, tfLoanImpair), Ter(tesSUCCESS)); - env.close(); + // Pay one loan and report whether the fee went to the broker's + // pseudo account (the fallback when cover < minimum) rather + // than to the owner. + auto feeGoesToPseudo = [&](Env& env, Ctx const& c, Keylet const& loanKeylet) -> bool { + Asset const asset{c.iou}; + auto const brokerSle = env.le(keylet::loanBroker(c.broker.brokerID)); + if (!BEAST_EXPECT(brokerSle)) + return false; + auto const pseudoAcct = Account("pseudo", brokerSle->at(sfAccount)); + auto const pseudoBefore = env.balance(pseudoAcct, c.iou); - LoanState const stateAtImpair = getCurrentState(env, broker, loanKeylet); - env.close( - stateAtImpair.startDate + std::chrono::seconds(paymentInterval) + - std::chrono::seconds(gracePeriod) + 60s); + auto const payLoan = env.le(loanKeylet); + if (!BEAST_EXPECT(payLoan)) + return false; + auto const periodicPayment = payLoan->at(sfPeriodicPayment); + auto const serviceFee = payLoan->at(sfLoanServiceFee); + std::int32_t const loanScale = payLoan->at(sfLoanScale); - auto const vaultBeforeDefault = env.le(broker.vaultKeylet()); - auto const brokerBeforeDefault = env.le(broker.brokerKeylet()); - BEAST_EXPECT(vaultBeforeDefault && brokerBeforeDefault); - Number const debtTotalBeforeDefault = brokerBeforeDefault->at(sfDebtTotal); - Number const lossBeforeDefault = vaultBeforeDefault->at(sfLossUnrealized); + auto const payment = roundPeriodicPayment(asset, periodicPayment, loanScale); + auto const payAmt = STAmount{asset, payment + serviceFee}; - env(manage(lender, loanKeylet.key, tfLoanDefault), Ter(tesSUCCESS)); - env.close(); + env(loan::pay(c.borrower, loanKeylet.key, payAmt), Fee(XRP(10))); + env.close(); - auto const vaultAfterDefault = env.le(broker.vaultKeylet()); - auto const brokerAfterDefault = env.le(broker.brokerKeylet()); - BEAST_EXPECT(vaultAfterDefault && brokerAfterDefault); - Number const debtTotalDeltaDefault = - Number(brokerAfterDefault->at(sfDebtTotal)) - debtTotalBeforeDefault; - Number const lossDeltaDefault = - Number(vaultAfterDefault->at(sfLossUnrealized)) - lossBeforeDefault; + auto const pseudoAfter = env.balance(pseudoAcct, c.iou); + return pseudoAfter.number() > pseudoBefore.number(); + }; - BEAST_EXPECTS( - debtTotalDeltaDefault == -expectedExposure, - "legacy vault default must reduce DebtTotal by whole-life exposure; delta=" + - to_string(debtTotalDeltaDefault) + " expected=" + to_string(expectedExposure)); - BEAST_EXPECTS( - lossDeltaDefault == -expectedExposure, - "legacy vault default must reverse the earlier impair's LossUnrealized exactly; " - "delta=" + - to_string(lossDeltaDefault) + " expected=" + to_string(expectedExposure)); + // Pay the BIG loan in its own Env so its outcome cannot affect + // the TINY-loan check. With the fix, LoanPay and clawback use + // the same vaultScale minimum (cover == minAtVaultScale => + // fee to owner). Without the fix, LoanPay uses bigLoanScale=-11, + // rounds up to a larger minimum than what clawback used => + // cover < min => fee to pseudo. + runTest(/*vaultDeposit=*/1'000, [&](Env& env, Ctx const& c) { + auto const loans = setupLoansAndClawback(env, c); + if (!loans) + return; + BEAST_EXPECT(feeGoesToPseudo(env, c, loans->big) == !withAmendment); + }); - // Confirm the Vault's LEVersion truly never got set, throughout. - { - auto const vaultSle = env.le(broker.vaultKeylet()); - BEAST_EXPECT(vaultSle); - BEAST_EXPECT(!vaultSle->isFieldPresent(sfLEVersion)); - BEAST_EXPECT(getVaultVersion(vaultSle) == VaultVersion::Legacy); + // Pay the TINY loan in its own Env. Fee goes to the owner + // either way: + // - With the fix: LoanPay uses vaultScale=-11 (same as + // clawback) => owner. + // - Without the fix: LoanPay uses tinyLoanScale=-12, rounds + // up at -12 (a no-op) => min == cover => owner. + runTest(/*vaultDeposit=*/1'000, [&](Env& env, Ctx const& c) { + auto const loans = setupLoansAndClawback(env, c); + if (!loans) + return; + BEAST_EXPECT(!feeGoesToPseudo(env, c, loans->tiny)); + }); } - } - - // 4. End-to-end trajectory: LoanSet -> 2 LoanPays -> LoanManage(default), - // entirely under the amendment, with independently hand-computed - // expected AssetsTotal/DebtTotal/LossUnrealized/CoverAvailable values at - // each step. 0% interest keeps the arithmetic exact and tractable; the - // divergence from whole-life accounting is already covered directly by - // testCashBasisLoanSetOrigination/LoanPay/LoanManage above, so this test - // focuses purely on an independent, from-scratch trajectory check. - void - testCashBasisEndToEndTrajectory() - { - testcase("cash-basis: end-to-end trajectory"); - using namespace jtx; - using namespace loan; - using namespace std::chrono_literals; + // Scenario 2 — LoanBrokerCoverWithdraw + // + // Verify that CoverWithdraw's minimum cover check uses vault scale + // (not scale(debtTotal, asset)). Before the amendment, CoverWithdraw + // used: + // roundToAsset(asset, tenthBipsOfValue(debt, rate), scale(debt, asset)) + // which could disagree with LoanPay's minimum (which used loanScale). + // + // Use a large vault deposit so that vaultScale (from AssetsTotal) is + // strictly larger than debtScale (from DebtTotal). With + // vaultDeposit = 100,000: after the big loan + // AssetsTotal ≈ 109,500 → vaultScale = -10 + // DebtTotal ≈ 10,000 → debtScale = -11 + // The one-order-of-magnitude gap makes roundToAsset at -10 truncate + // more aggressively than at -11, exposing the bug. + testcase("CoverWithdraw minimum cover scale consistency"); + runTest( + /*vaultDeposit=*/100'000, [&](Env& env, Ctx const& c) { + Asset const asset{c.iou}; - PrettyAsset const xrpAsset{xrpIssue(), 1'000'000}; - BrokerParameters const brokerParams{ - .vaultDeposit = 100'000, .managementFeeRate = TenthBips16{0}}; + // Create only the big loan to push DebtTotal up to ~10,000 + // while AssetsTotal stays around 109,500 (dominated by the + // large vault deposit). + env(set(c.borrower, c.broker.brokerID, Number{500}), + Sig(sfCounterpartySignature, c.lender), + kInterestRate(TenthBips32{100'000}), + kPaymentTotal(20), + kPaymentInterval(86400 * 365), + Fee(XRP(10))); + env.close(); - Env env(*this, all_ | featureLendingProtocolV1_1); + // Read broker state and compute both old and new minimums. + auto const brokerSle = env.le(keylet::loanBroker(c.broker.brokerID)); + auto const vaultSle = env.le(keylet::vault(c.broker.vaultID)); + if (!BEAST_EXPECT(brokerSle) || !BEAST_EXPECT(vaultSle)) + return; - Account const lender{"lender"}; - Account const borrower{"borrower"}; - env.fund(XRP(10'000'000), lender, borrower); - env.close(); + auto const coverAvail = brokerSle->at(sfCoverAvailable); + auto const debtTotal = brokerSle->at(sfDebtTotal); + auto const vaultScale = getAssetsTotalScale(vaultSle); + auto const debtScale = scale(debtTotal, asset); - BrokerInfo const broker{createVaultAndBroker(env, xrpAsset, lender, brokerParams)}; + // Sanity: debt scale differs from vault scale for this setup. + BEAST_EXPECT(debtScale < vaultScale); - // Hand computation (all values in XRP, drops == 1e-6 XRP): - // Vault: AssetsTotal starts at 100'000 (the deposit). - // Broker: DebtTotal starts at 0, CoverAvailable starts at 1'000 - // (BrokerParameters::defaults().coverDeposit). - auto const vaultKeylet = broker.vaultKeylet(); - auto const brokerKeylet = broker.brokerKeylet(); - - // All the "human XRP unit" constants below (e.g. `100'000`) are - // converted to raw native (drops) values via xrpAsset(...), since - // that's how the ledger fields are actually denominated. - auto const checkVaultBroker = [&](Number const& assetsTotalUnits, - Number const& debtTotalUnits, - Number const& lossUnrealizedUnits, - Number const& coverAvailableUnits, - char const* step) { - Number const assetsTotal = xrpAsset(assetsTotalUnits).value(); - Number const debtTotal = xrpAsset(debtTotalUnits).value(); - Number const lossUnrealized = xrpAsset(lossUnrealizedUnits).value(); - Number const coverAvailable = xrpAsset(coverAvailableUnits).value(); - - auto const vaultSle = env.le(vaultKeylet); - auto const brokerSle = env.le(brokerKeylet); - BEAST_EXPECT(vaultSle && brokerSle); - BEAST_EXPECTS( - vaultSle->at(sfAssetsTotal) == assetsTotal, - std::string(step) + ": AssetsTotal expected " + to_string(assetsTotal) + " got " + - to_string(Number(vaultSle->at(sfAssetsTotal)))); - BEAST_EXPECTS( - brokerSle->at(sfDebtTotal) == debtTotal, - std::string(step) + ": DebtTotal expected " + to_string(debtTotal) + " got " + - to_string(Number(brokerSle->at(sfDebtTotal)))); - BEAST_EXPECTS( - vaultSle->at(sfLossUnrealized) == lossUnrealized, - std::string(step) + ": LossUnrealized expected " + to_string(lossUnrealized) + - " got " + to_string(Number(vaultSle->at(sfLossUnrealized)))); - BEAST_EXPECTS( - brokerSle->at(sfCoverAvailable) == coverAvailable, - std::string(step) + ": CoverAvailable expected " + to_string(coverAvailable) + - " got " + to_string(Number(brokerSle->at(sfCoverAvailable)))); - }; + auto const oldMin = [&]() { + NumberRoundModeGuard const mg(Number::RoundingMode::Upward); + return roundToAsset( + asset, + tenthBipsOfValue(debtTotal, TenthBips32{c.brokerParams.coverRateMin}), + debtScale); + }(); + auto const newMin = minimumBrokerCover( + debtTotal, TenthBips32{c.brokerParams.coverRateMin}, vaultSle); - checkVaultBroker(100'000, 0, 0, 1'000, "before LoanSet"); + // The new (vaultScale) minimum must be strictly larger than + // the old (debtScale) minimum — that is the gap the amendment + // closes. + Number const expectedNewMin{1330650518688500000, -15}; + Number const expectedOldMin{1330650518688472000, -15}; + BEAST_EXPECT(newMin == expectedNewMin); + BEAST_EXPECT(oldMin == expectedOldMin); - // Loan: principal=1200, 0% interest, 12 payments of 100 each, no fees. - Number const principalRequest{1'200}; - std::uint32_t const paymentTotal = 12; - std::uint32_t const paymentInterval = 600; - std::uint32_t const gracePeriod = 60; + // Try to withdraw so that remaining cover lands between the + // two minimums: oldMin < target < newMin. + auto const target = oldMin + (newMin - oldMin) / 2; + auto const withdrawAmount = STAmount{asset, coverAvail - target}; - auto const brokerBeforeLoan = env.le(brokerKeylet); - BEAST_EXPECT(brokerBeforeLoan); - auto const loanSequence = brokerBeforeLoan->at(sfLoanSequence); - auto const loanKeylet = keylet::loan(broker.brokerID, loanSequence); + if (withAmendment) + { + // CoverWithdraw now uses vaultScale: target < newMin + // => FAILS. + env(coverWithdraw(c.lender, c.broker.brokerID, withdrawAmount), + Ter(tecINSUFFICIENT_FUNDS)); + } + else + { + // Old CoverWithdraw uses debtScale: target > oldMin + // => SUCCEEDS. + env(coverWithdraw(c.lender, c.broker.brokerID, withdrawAmount)); + } + env.close(); + }); - LoanParameters const loanParams{ - .account = borrower, - .counter = lender, - .principalRequest = principalRequest, - .interest = TenthBips32{0}, - .payTotal = paymentTotal, - .payInterval = paymentInterval, - .gracePd = gracePeriod, - }; - env(loanParams(env, broker)); - env.close(); + // Scenario 3 — LoanSet + // + // Verify that LoanSet's minimum cover check uses vault scale (not the + // raw unrounded tenthBipsOfValue). Before the amendment, LoanSet + // used tenthBipsOfValue(newDebtTotal, coverRateMinimum) (no + // roundToAsset), while clawback/withdraw used different formulas. + // After the amendment all use minimumBrokerCover at vaultScale, and + // rounding at a coarser scale can absorb a tiny debt increase — + // allowing a loan that would otherwise be rejected. + testcase("LoanSet minimum cover scale consistency"); + runTest( + /*vaultDeposit=*/1'000, [&](Env& env, Ctx const& c) { + // Create the tiny loan (scale -12) AND the big loan (scale + // -11). Both loans are needed so that DebtTotal has a full + // 16-digit mantissa — a "messy" value where roundToAsset at + // vaultScale actually truncates digits and produces a + // different result from the raw tenthBipsOfValue. With only + // the big loan, DebtTotal has ~4 significant digits and + // rounding at scale -11 is a no-op, masking the amendment's + // effect. + env(set(c.borrower, c.broker.brokerID, Number{1, -2}), + Sig(sfCounterpartySignature, c.lender), + kInterestRate(TenthBips32{0}), + kPaymentTotal(1), + kPaymentInterval(86400 * 365), + Fee(XRP(10))); + env.close(); - // Origination (cash-basis): AssetsTotal += 0, DebtTotal += principal. - checkVaultBroker(100'000, 1'200, 0, 1'000, "after LoanSet"); + env(set(c.borrower, c.broker.brokerID, Number{500}), + Sig(sfCounterpartySignature, c.lender), + kInterestRate(TenthBips32{100'000}), + kPaymentTotal(20), + kPaymentInterval(86400 * 365), + Fee(XRP(10))); + env.close(); - LoanState const state = getCurrentState(env, broker, loanKeylet); - BEAST_EXPECT(state.periodicPayment == xrpAsset(100).value()); + // Clawback to reduce cover to the clawback transactor's + // minimum. Pass the exact amount rather than relying on the + // transactor to clip down; the setup matches Scenario 1 so + // the same residual-cover values apply. + Number const expectedCoverAfter = withAmendment ? Number{1330651855688460000, -15} + : Number{1330651855688458000, -15}; + Number const clawbackAmount = + Number{c.brokerParams.coverDeposit} - expectedCoverAfter; + env(coverClawback(c.issuer), + kLoanBrokerId(c.broker.brokerID), + kAmount(c.iou(clawbackAmount))); + env.close(); - // Payment 1: principalPaid=100, interestPaid=0. - // AssetsTotal += 0; DebtTotal -= 100. - env(pay(borrower, loanKeylet.key, xrpAsset(100).value()), Ter(tesSUCCESS)); - env.close(); - checkVaultBroker(100'000, 1'100, 0, 1'000, "after payment 1"); + // Verify scales. + auto const vaultSle = env.le(keylet::vault(c.broker.vaultID)); + if (!BEAST_EXPECT(vaultSle)) + return; + auto const vaultScale = getAssetsTotalScale(vaultSle); + BEAST_EXPECT(vaultScale == -11); - // Payment 2: same as above. - env(pay(borrower, loanKeylet.key, xrpAsset(100).value()), Ter(tesSUCCESS)); - env.close(); - checkVaultBroker(100'000, 1'000, 0, 1'000, "after payment 2"); - - // Default (no impair): principalOutstanding remaining is 1'000. - // totalDefaultAmount (cash-basis) = PrincipalOutstanding = 1'000. - // minimumCover = DebtTotal(1'000) * coverRateMin(10%) = 100. - // covered = min(minimumCover * coverRateLiquidation(25%), totalDefaultAmount) - // = min(25, 1'000) = 25. - // defaultCovered = min(covered, CoverAvailable(1'000)) = 25. - // vaultDefaultAmount = 1'000 - 25 = 975. - // DebtTotal -= 1'000 -> 0. CoverAvailable -= 25 -> 975. - // AssetsTotal -= 975 -> 99'025. LossUnrealized unaffected (never impaired). - auto const loanBeforeDefault = env.le(loanKeylet); - BEAST_EXPECT(loanBeforeDefault); - BEAST_EXPECT( - Number(loanBeforeDefault->at(sfPrincipalOutstanding)) == xrpAsset(1'000).value()); - - env.close(state.startDate + std::chrono::seconds((3 * paymentInterval) + gracePeriod) + 1s); - - env(manage(lender, loanKeylet.key, tfLoanDefault), Ter(tesSUCCESS)); - env.close(); + // Now try to create a tiny additional loan. Principal is + // 1e-11 (the smallest value that survives the precision + // check at loanScale = vaultScale = -11), with 0% interest + // and 1 payment. + // + // The tiny debt increase adds ~1.337e-12 to the unrounded + // minimum. + // - Without the amendment: the old LoanSet formula rounds + // up during tenthBipsOfValue (16-digit Number + // normalisation), pushing the minimum past the cover left + // by clawback => tecINSUFFICIENT_FUNDS. + // - With the amendment: minimumBrokerCover rounds at + // vaultScale=-11, which absorbs the tiny increase — the + // rounded minimum stays the same => tesSUCCESS. + auto const tinyPrincipal = Number{1, -11}; - checkVaultBroker(99'025, 0, 0, 975, "after LoanManage(default)"); + if (withAmendment) + { + env(set(c.borrower, c.broker.brokerID, tinyPrincipal), + Sig(sfCounterpartySignature, c.lender), + kInterestRate(TenthBips32{0}), + kPaymentTotal(1), + kPaymentInterval(86400 * 365), + Fee(XRP(10))); + } + else + { + env(set(c.borrower, c.broker.brokerID, tinyPrincipal), + Sig(sfCounterpartySignature, c.lender), + kInterestRate(TenthBips32{0}), + kPaymentTotal(1), + kPaymentInterval(86400 * 365), + Fee(XRP(10)), + Ter(tecINSUFFICIENT_FUNDS)); + } + env.close(); + }); } void @@ -9541,72 +4131,29 @@ class Loan_test : public beast::unit_test::Suite testIssuerLoan(); testServiceFeeOnBrokerDeepFreeze(); testRequireAuth(); - testRIPD3901(); testBorrowerIsBroker(); testLimitExceeded(); testLendingCanTradeDisabledNoImpact(); - testBugOverpaymentPrincipalChange(); - testBugOverpayUnroundedAmount(); - - for (auto const flags : {0u, tfLoanOverpayment}) - testYieldTheftRounding(flags); - testBugInterestDueDeltaCrash(); - testFullLifecycleVaultPnLNearZeroRate(); - testLoanSetNearZeroInterestRateSucceeds(); - - testCashBasisLoanSetOrigination(); - testCashBasisLoanPay(); - testCashBasisLoanManage(); - testLegacyVaultKeepsAccrualAfterAmendmentEnabled(); - testCashBasisEndToEndTrajectory(); } // Tests run under each entry in amendmentCombinations(). void runAmendmentSensitive(FeatureBitset features) { -#if LOAN_TODO - testLoanPayLateFullPaymentBypassesPenalties(features); - testLoanCoverMinimumRoundingExploit(features); -#endif // Lifecycle testLifecycle(features); testLoanSet(features); - testDosLoanPay(features); testSelfLoan(features); - - // Payment paths - testWithdrawReflectsUnrealizedLoss(features); - testPoCUnsignedUnderflowOnFullPayAfterEarlyPeriodic(features); testBatchBypassCounterparty(features); - testLoanNextPaymentDueDateOverflow(features); - testSequentialFLCDepletion(features); - - // Invariants - testLoanPayComputePeriodicPaymentValidRateInvariant(features); - testAccountSendMptMinAmountInvariant(features); - testLoanPayDebtDecreaseInvariant(features); testWrongMaxDebtBehavior(features); - testLoanPayComputePeriodicPaymentValidTotalInterestInvariant(features); - testLoanPayComputePeriodicPaymentValidTotalPrincipalPaidInvariant(features); - testLoanPayComputePeriodicPaymentValidTotalInterestPaidInvariant(features); // RPC testRPC(features); - // Edge / rounding - testDustManipulation(features); - testRoundingAllowsUndercoverage(features); testOverpaymentManagementFee(features); testIssuerIsBorrower(features); - testIntegerScalePrincipalSticks(features); testMinimumBrokerCoverConsistency(features); - // RIPD regressions - testRIPD3831(features); - testRIPD3459(features); - testRIPD3902(features); - // Broker-owner permissions testLoanPayBrokerOwnerMissingTrustline(features); testLoanPayBrokerOwnerUnauthorizedMPT(features); @@ -9625,7 +4172,7 @@ class Loan_test : public beast::unit_test::Suite } }; -class LoanBatch_test : public Loan_test +class LoanBatch_test : public LoanTestBase { protected: beast::xor_shift_engine engine_; From b9d58a64ceec16f96edb7273ee3217e14f19261c Mon Sep 17 00:00:00 2001 From: Vito <5780819+Tapanito@users.noreply.github.com> Date: Fri, 24 Jul 2026 18:42:11 +0200 Subject: [PATCH 09/11] test: Regroup Loan test suites by theme Supersedes the previous 4-file split (Loan/LoanInvariant/ LoanRegression/LoanCashBasis) with a more granular, 10-suite layout: - Loan: core LoanSet lifecycle - LoanLifecycle: full lifecycle, self-loan, issuer/broker-as-borrower - LoanValidation: disabled-amendment and invalid-transaction checks - LoanPay: payment-mechanics regressions (fees, near-zero rate, overflow) - LoanInvariants: fuzzer-derived invariant regressions - LoanRounding: precision/scale/dust rounding regressions - LoanCoverFreezeAuth: first-loss-cover, freeze, and MPT auth permissions - LoanSecurity: PoC and RIPD-numbered regressions - LoanCashBasis: unchanged, LendingProtocolV1_1 cash-basis coverage - LoanMisc: RPC signing flow, plus the LoanBatch/LoanArbitrary manual fuzz suites relocated here Shared fixtures/helpers remain in LoanTestBase.h; testCaseWrapper moves there too since it's used by both LoanSet (Loan) and testLifecycle (LoanLifecycle), not just the former. Pure reorganization: every function body is byte-identical to the prior split, and independent/sensitive call-site wiring matches exactly (verified via diff). The combined suite total is 1071 cases (vs. 1070 previously) with an identical 116951 assertions and 0 failures. The extra case is a benign case-boundary bookkeeping artifact: testLoanSet's (unchanged) setup code performs real transaction-application work before its own first testcase() call, and now that it's the sole/first function in its own suite, that pre-existing activity surfaces as a new blank case instead of attaching to a predecessor's case as it did when other functions ran before it in the old grouping. No assertions were gained, lost, or duplicated. --- src/test/app/LoanCoverFreezeAuth_test.cpp | 697 +++ src/test/app/LoanInvariant_test.cpp | 1903 --------- src/test/app/LoanInvariants_test.cpp | 847 ++++ src/test/app/LoanLifecycle_test.cpp | 645 +++ src/test/app/LoanMisc_test.cpp | 528 +++ src/test/app/LoanPay_test.cpp | 726 ++++ ...ression_test.cpp => LoanRounding_test.cpp} | 949 ++--- src/test/app/LoanSecurity_test.cpp | 514 +++ src/test/app/LoanTestBase.h | 1340 ++++++ src/test/app/LoanValidation_test.cpp | 522 +++ src/test/app/Loan_test.cpp | 3732 +---------------- 11 files changed, 6287 insertions(+), 6116 deletions(-) create mode 100644 src/test/app/LoanCoverFreezeAuth_test.cpp delete mode 100644 src/test/app/LoanInvariant_test.cpp create mode 100644 src/test/app/LoanInvariants_test.cpp create mode 100644 src/test/app/LoanLifecycle_test.cpp create mode 100644 src/test/app/LoanMisc_test.cpp create mode 100644 src/test/app/LoanPay_test.cpp rename src/test/app/{LoanRegression_test.cpp => LoanRounding_test.cpp} (60%) create mode 100644 src/test/app/LoanSecurity_test.cpp create mode 100644 src/test/app/LoanValidation_test.cpp diff --git a/src/test/app/LoanCoverFreezeAuth_test.cpp b/src/test/app/LoanCoverFreezeAuth_test.cpp new file mode 100644 index 00000000000..092740187ea --- /dev/null +++ b/src/test/app/LoanCoverFreezeAuth_test.cpp @@ -0,0 +1,697 @@ +#include + +namespace xrpl::test { + +class LoanCoverFreezeAuth_test : public LoanTestBase +{ +private: + void + testSequentialFLCDepletion(FeatureBitset features) + { + testcase << "First-Loss Capital Depletion on Sequential Defaults"; + + using namespace jtx; + using namespace loan; + using namespace loanBroker; + + Env env{*this, features}; + + Account const issuer{"issuer"}; + Account const lender{"lender"}; + Account const borrowerA{"borrowerA"}; + Account const borrowerB{"borrowerB"}; + + env.fund(XRP(1'000'000), issuer, lender, borrowerA, borrowerB); + env.close(); + + PrettyAsset const asset = xrpIssue(); + auto const vaultDepositAmount = + asset(200'000); // Enough for 2 x 50k loans plus interest/fees + + auto const brokerInfo = createVaultAndBroker( + env, + asset, + lender, + { + .vaultDeposit = vaultDepositAmount.value(), + .debtMax = 0, + .coverRateMin = TenthBips32(20000), // 20% + .coverDeposit = 21'000, + .managementFeeRate = TenthBips16(100), // 0.1% + .coverRateLiquidation = TenthBips32(100000), + }); + auto const brokerKeylet = brokerInfo.brokerKeylet(); + + // Create two identical loans: each 50,000 XRP principal (scaled down to + // avoid funding issues) Total DebtTotal will be ~100,000 XRP (principal + // + interest) Formula will calculate cover as: 100% × (20% × 100,000) = + // 20,000 XRP So we need FLC = 20,000 XRP to be fully consumed by first + // default + auto const principalAmount = Number(50'000); + auto const loanPaymentInterval = 2592000; // 30 days + auto const loanGracePeriod = 604800; // 7 days + + // Create Loan A + auto loanATx = env.jt( + set(borrowerA, brokerKeylet.key, principalAmount), + Sig(sfCounterpartySignature, lender), + kInterestRate(TenthBips32(500)), // 5% + kPaymentTotal(12), + loan::kPaymentInterval(loanPaymentInterval), + loan::kGracePeriod(loanGracePeriod), + Fee(XRP(10))); // Sufficient fee for multi-sig transaction + env(loanATx); + env.close(); + + auto const loanAKeylet = keylet::loan(brokerKeylet.key, 1); + + // Create Loan B + auto loanBTx = env.jt( + set(borrowerB, brokerKeylet.key, principalAmount), + Sig(sfCounterpartySignature, lender), + kInterestRate(TenthBips32(500)), // 5% + kPaymentTotal(12), + loan::kPaymentInterval(loanPaymentInterval), + loan::kGracePeriod(loanGracePeriod), + Fee(XRP(10))); // Sufficient fee for multi-sig transaction + env(loanBTx); + env.close(); + + auto const loanBKeylet = keylet::loan(brokerKeylet.key, 2); + + auto loanASle = env.le(loanAKeylet); + if (!BEAST_EXPECT(loanASle)) + return; + + // Advance time past grace period for both loans to be defaultable + auto const loanANextDue = loanASle->at(sfNextPaymentDueDate); + auto const loanAGrace = loanASle->at(sfGracePeriod); + env.close(std::chrono::seconds{loanANextDue + loanAGrace + 60}); + + env(manage(lender, loanAKeylet.key, tfLoanDefault), Ter(tesSUCCESS)); + env.close(); + + // Verify Loan A is defaulted + loanASle = env.le(loanAKeylet); + if (!BEAST_EXPECT(loanASle)) + return; + BEAST_EXPECT(loanASle->isFlag(lsfLoanDefault)); + BEAST_EXPECT(loanASle->at(sfPaymentRemaining) == 0); + + // Check broker state after first default (from committed ledger) + auto brokerSle = env.le(brokerKeylet); + if (!BEAST_EXPECT(brokerSle)) + return; + auto const afterFirstDebtTotal = brokerSle->at(sfDebtTotal); + auto const afterFirstCoverAvailable = brokerSle->at(sfCoverAvailable); + + // DebtTotal should have decreased by Loan A's debt + BEAST_EXPECT(afterFirstDebtTotal == 50'134); + + // CoverAvailable should have decreased significantly + BEAST_EXPECT(afterFirstCoverAvailable == 946); + + env(manage(lender, loanBKeylet.key, tfLoanDefault), Ter(tesSUCCESS)); + + brokerSle = env.le(brokerKeylet); + if (!BEAST_EXPECT(brokerSle)) + return; + auto const afterSecondDebtTotal = brokerSle->at(sfDebtTotal); + auto const afterSecondCoverAvailable = brokerSle->at(sfCoverAvailable); + + BEAST_EXPECT(afterSecondDebtTotal == 0); + + BEAST_EXPECT(afterSecondCoverAvailable == 0); + } + + // Tests that vault withdrawals work correctly when the vault has unrealized + // loss from an impaired loan, ensuring the invariant check properly + // accounts for the loss. + void + testWithdrawReflectsUnrealizedLoss(FeatureBitset features) + { + using namespace jtx; + using namespace loan; + using namespace std::chrono_literals; + + testcase("Vault withdraw reflects sfLossUnrealized"); + + // Test constants + static constexpr std::int64_t kInitialFunding = 1'000'000; + static constexpr std::int64_t kLenderInitialIou = 5'000'000; + static constexpr std::int64_t kDepositorInitialIou = 1'000'000; + static constexpr std::int64_t kBorrowerInitialIou = 100'000; + static constexpr std::int64_t kDepositAmount = 5'000; + static constexpr std::int64_t kPrincipalAmount = 99; + static constexpr std::uint64_t kExpectedSharesPerDepositor = 5'000'000'000; + static constexpr std::uint32_t kLocalPaymentInterval = 600; + static constexpr std::uint32_t kLocalPaymentTotal = 2; + + Env env{*this, features}; + + // Setup accounts + Account const issuer{"issuer"}; + Account const lender{"lender"}; + Account const depositorA{"lpA"}; + Account const depositorB{"lpB"}; + Account const borrower{"borrowerA"}; + + env.fund(XRP(kInitialFunding), issuer, lender, depositorA, depositorB, borrower); + env.close(); + + // Setup trust lines + PrettyAsset const iouAsset = issuer[iouCurrency_]; + env(trust(lender, iouAsset(10'000'000))); + env(trust(depositorA, iouAsset(10'000'000))); + env(trust(depositorB, iouAsset(10'000'000))); + env(trust(borrower, iouAsset(10'000'000))); + env.close(); + + // Fund accounts with IOUs + env(pay(issuer, lender, iouAsset(kLenderInitialIou))); + env(pay(issuer, depositorA, iouAsset(kDepositorInitialIou))); + env(pay(issuer, depositorB, iouAsset(kDepositorInitialIou))); + env(pay(issuer, borrower, iouAsset(kBorrowerInitialIou))); + env.close(); + + // Create vault and broker, then add deposits from two depositors + auto const broker = createVaultAndBroker(env, iouAsset, lender); + Vault v{env}; + + env(v.deposit({ + .depositor = depositorA, + .id = broker.vaultKeylet().key, + .amount = iouAsset(kDepositAmount), + }), + Ter(tesSUCCESS)); + env(v.deposit({ + .depositor = depositorB, + .id = broker.vaultKeylet().key, + .amount = iouAsset(kDepositAmount), + }), + Ter(tesSUCCESS)); + env.close(); + + // Create a loan + auto const sleBroker = env.le(keylet::loanBroker(broker.brokerID)); + if (!BEAST_EXPECT(sleBroker)) + return; + + auto const loanKeylet = keylet::loan(broker.brokerID, sleBroker->at(sfLoanSequence)); + + env(set(borrower, broker.brokerID, kPrincipalAmount), + Sig(sfCounterpartySignature, lender), + kPaymentTotal(kLocalPaymentTotal), + kPaymentInterval(kLocalPaymentInterval), + Fee(env.current()->fees().base * 2), + Ter(tesSUCCESS)); + env.close(); + + // Impair the loan to create unrealized loss + env(manage(lender, loanKeylet.key, tfLoanImpair), Ter(tesSUCCESS)); + env.close(); + + // Verify unrealized loss is recorded in the vault + auto const vaultAfterImpair = env.le(broker.vaultKeylet()); + if (!BEAST_EXPECT(vaultAfterImpair)) + return; + + BEAST_EXPECT( + vaultAfterImpair->at(sfLossUnrealized) == broker.asset(kPrincipalAmount).value()); + + // Helper to get share balance for a depositor + auto const shareAsset = vaultAfterImpair->at(sfShareMPTID); + auto const getShareBalance = [&](Account const& depositor) -> std::uint64_t { + auto const token = env.le(keylet::mptoken(shareAsset, depositor.id())); + return token ? token->getFieldU64(sfMPTAmount) : 0; + }; + + // Verify both depositors have equal shares + auto const sharesLpA = getShareBalance(depositorA); + auto const sharesLpB = getShareBalance(depositorB); + BEAST_EXPECT(sharesLpA == kExpectedSharesPerDepositor); + BEAST_EXPECT(sharesLpB == kExpectedSharesPerDepositor); + BEAST_EXPECT(sharesLpA == sharesLpB); + + // Helper to attempt withdrawal + auto const attemptWithdrawShares = [&](Account const& depositor, + std::uint64_t shareAmount, + TER expected) { + STAmount const shareAmt{MPTIssue{shareAsset}, Number(shareAmount)}; + env(v.withdraw( + {.depositor = depositor, .id = broker.vaultKeylet().key, .amount = shareAmt}), + Ter(expected)); + env.close(); + }; + + // Regression test: Both depositors should successfully withdraw despite + // unrealized loss. Previously failed with invariant violation: + // "withdrawal must change vault and destination balance by equal + // amount". This was caused by sharesToAssetsWithdraw rounding down, + // creating a mismatch where vaultDeltaAssets * -1 != destinationDelta + // when unrealized loss exists. + attemptWithdrawShares(depositorA, sharesLpA, tesSUCCESS); + attemptWithdrawShares(depositorB, sharesLpB, tesSUCCESS); + } + + void + testServiceFeeOnBrokerDeepFreeze() + { + testcase << "Service Fee On Broker Deep Freeze"; + using namespace jtx; + using namespace loan; + Account const issuer("issuer"); + Account const borrower("borrower"); + Account const broker("broker"); + auto const iou = issuer["IOU"]; + + for (bool const deepFreeze : {true, false}) + { + Env env(*this); + + auto getCoverBalance = [&](BrokerInfo const& brokerInfo, auto const& accountField) { + if (auto const le = env.le(keylet::loanBroker(brokerInfo.brokerID)); + BEAST_EXPECT(le)) + { + auto const account = le->at(accountField); + if (auto const sleLine = env.le(keylet::trustLine(account, iou)); + BEAST_EXPECT(sleLine)) + { + STAmount balance = sleLine->at(sfBalance); + if (account > issuer.id()) + balance.negate(); + return balance; + } + } + return STAmount{iou}; + }; + + env.fund(XRP(20'000), issuer, broker, borrower); + env.close(); + + env(trust(broker, iou(20'000'000))); + env(pay(issuer, broker, iou(10'000'000))); + env.close(); + + auto const brokerInfo = createVaultAndBroker(env, iou, broker); + + BEAST_EXPECT(getCoverBalance(brokerInfo, sfAccount) == iou(1'000)); + + auto const keylet = keylet::loan(brokerInfo.brokerID, 1); + + env(set(borrower, brokerInfo.brokerID, 10'000), + Sig(sfCounterpartySignature, broker), + kLoanServiceFee(iou(100).value()), + kPaymentInterval(100), + Fee(XRP(100))); + env.close(); + + env(trust(borrower, iou(20'000'000))); + // The borrower increases their limit and acquires some IOU so + // they can pay interest + env(pay(issuer, borrower, iou(500))); + env.close(); + + if (auto const le = env.le(keylet::loan(keylet.key)); BEAST_EXPECT(le)) + { + if (deepFreeze) + { + env(trust(issuer, broker["IOU"](0), tfSetFreeze | tfSetDeepFreeze)); + env.close(); + } + + env(pay(borrower, keylet.key, iou(10'100)), Fee(XRP(100))); + env.close(); + + if (deepFreeze) + { + // The fee goes to the broker pseudo-account + BEAST_EXPECT(getCoverBalance(brokerInfo, sfAccount) == iou(1'100)); + BEAST_EXPECT(getCoverBalance(brokerInfo, sfOwner) == iou(8'999'000)); + } + else + { + // The fee goes to the broker account + BEAST_EXPECT(getCoverBalance(brokerInfo, sfOwner) == iou(8'999'100)); + BEAST_EXPECT(getCoverBalance(brokerInfo, sfAccount) == iou(1'000)); + } + } + }; + } + + void + testLoanPayBrokerOwnerMissingTrustline(FeatureBitset features) + { + testcase << "LoanPay Broker Owner Missing Trustline (PoC)"; + using namespace jtx; + using namespace loan; + Account const issuer("issuer"); + Account const borrower("borrower"); + Account const broker("broker"); + auto const iou = issuer["IOU"]; + Env env(*this, features); + env.fund(XRP(20'000), issuer, broker, borrower); + env.close(); + // Set up trustlines and fund accounts + env(trust(broker, iou(20'000'000))); + env(trust(borrower, iou(20'000'000))); + env(pay(issuer, broker, iou(10'000'000))); + env(pay(issuer, borrower, iou(1'000))); + env.close(); + // Create vault and broker + auto const brokerInfo = createVaultAndBroker(env, iou, broker); + // Create a loan first (this creates debt) + auto const keylet = keylet::loan(brokerInfo.brokerID, 1); + env(set(borrower, brokerInfo.brokerID, 10'000), + Sig(sfCounterpartySignature, broker), + kLoanServiceFee(iou(100).value()), + kPaymentInterval(100), + Fee(XRP(100))); + env.close(); + // Ensure broker has sufficient cover so brokerPayee == brokerOwner + // We need coverAvailable >= (debtTotal * coverRateMinimum) + // Deposit enough cover to ensure the fee goes to broker owner + // The default coverRateMinimum is 10%, so for a 10,000 loan we need + // at least 1,000 cover. Default cover is 1,000, so we add more to be + // safe. + auto const additionalCover = iou(50'000).value(); + env(loanBroker::coverDeposit(broker, brokerInfo.brokerID, STAmount{iou, additionalCover})); + env.close(); + // Verify broker owner has a trustline + auto const brokerTrustline = keylet::trustLine(broker, iou); + BEAST_EXPECT(env.le(brokerTrustline) != nullptr); + // Broker owner deletes their trustline + // First, pay any positive balance to issuer to zero it out + auto const brokerBalance = env.balance(broker, iou); + env(pay(broker, issuer, brokerBalance)); + env.close(); + // Remove the trustline by setting limit to 0 + env(trust(broker, iou(0))); + env.close(); + // Verify trustline is deleted + BEAST_EXPECT(env.le(brokerTrustline) == nullptr); + // Now borrower tries to make a payment + // We should get a tesSUCCESS instead of a tecNO_LINE. + env(pay(borrower, keylet.key, iou(10'100)), Fee(XRP(100)), Ter(tesSUCCESS)); + env.close(); + // Verify trustline is still deleted + BEAST_EXPECT(env.le(brokerTrustline) == nullptr); + // Verify the service fee went to the broker pseudo-account + if (auto const brokerSle = env.le(keylet::loanBroker(brokerInfo.brokerID)); + BEAST_EXPECT(brokerSle)) + { + Account const pseudo("pseudo-account", brokerSle->at(sfAccount)); + auto const balance = env.balance(pseudo, iou); + // 1,000 default + 50,000 extra + 100 service fee from LoanPay + BEAST_EXPECTS(balance == iou(51'100), to_string(json::Value(balance))); + } + } + + void + testLoanPayBrokerOwnerUnauthorizedMPT(FeatureBitset features) + { + testcase << "LoanPay Broker Owner MPT unauthorized"; + using namespace jtx; + using namespace loan; + + Account const issuer("issuer"); + Account const borrower("borrower"); + Account const broker("broker"); + + Env env{*this, features}; + env.fund(XRP(20'000), issuer, broker, borrower); + env.close(); + + MPTTester mptt{env, issuer, kMptInitNoFund}; + mptt.create({.flags = tfMPTCanClawback | tfMPTCanTransfer | tfMPTCanLock}); + + PrettyAsset const mpt{mptt.issuanceID()}; + + // Authorize broker and borrower + mptt.authorize({.account = broker}); + mptt.authorize({.account = borrower}); + + env.close(); + + // Fund accounts + env(pay(issuer, broker, mpt(10'000'000))); + env(pay(issuer, borrower, mpt(1'000))); + env.close(); + + // Create vault and broker + auto const brokerInfo = createVaultAndBroker(env, mpt, broker); + // Create a loan first (this creates debt) + auto const keylet = keylet::loan(brokerInfo.brokerID, 1); + env(set(borrower, brokerInfo.brokerID, 10'000), + Sig(sfCounterpartySignature, broker), + kLoanServiceFee(mpt(100).value()), + kPaymentInterval(100), + Fee(XRP(100))); + env.close(); + // Ensure broker has sufficient cover so brokerPayee == brokerOwner + // We need coverAvailable >= (debtTotal * coverRateMinimum) + // Deposit enough cover to ensure the fee goes to broker owner + // The default coverRateMinimum is 10%, so for a 10,000 loan we need + // at least 1,000 cover. Default cover is 1,000, so we add more to be + // safe. + auto const additionalCover = mpt(50'000).value(); + env(loanBroker::coverDeposit(broker, brokerInfo.brokerID, STAmount{mpt, additionalCover})); + env.close(); + // Verify broker owner is authorized + auto const brokerMpt = keylet::mptoken(mptt.issuanceID(), broker); + BEAST_EXPECT(env.le(brokerMpt) != nullptr); + // Broker owner unauthorizes. + // First, pay any positive balance to issuer to zero it out + auto const brokerBalance = env.balance(broker, mpt); + env(pay(broker, issuer, brokerBalance)); + env.close(); + // Then, unauthorize the MPT. + mptt.authorize({.account = broker, .flags = tfMPTUnauthorize}); + env.close(); + // Verify the MPT is unauthorized. + BEAST_EXPECT(env.le(brokerMpt) == nullptr); + // Now borrower tries to make a payment + // We should get a tesSUCCESS instead of a tecNO_AUTH. + auto const borrowerBalance = env.balance(borrower, mpt); + env(pay(borrower, keylet.key, mpt(10'100)), Fee(XRP(100)), Ter(tesSUCCESS)); + env.close(); + // Verify the MPT is still unauthorized. + BEAST_EXPECT(env.le(brokerMpt) == nullptr); + // Verify the service fee went to the broker pseudo-account + if (auto const brokerSle = env.le(keylet::loanBroker(brokerInfo.brokerID)); + BEAST_EXPECT(brokerSle)) + { + Account const pseudo("pseudo-account", brokerSle->at(sfAccount)); + auto const balance = env.balance(pseudo, mpt); + // 1,000 default + 50,000 extra + 100 service fee from LoanPay + BEAST_EXPECTS(balance == mpt(51'100), to_string(json::Value(balance))); + } + } + + void + testLoanPayBrokerOwnerNoPermissionedDomainMPT(FeatureBitset features) + { + testcase << "LoanPay Broker Owner without permissioned domain of the MPT"; + using namespace jtx; + using namespace loan; + + Account const issuer("issuer"); + Account const borrower("borrower"); + Account const broker("broker"); + + Env env{*this, features}; + env.fund(XRP(20'000), issuer, broker, borrower); + env.close(); + + auto credType = "credential1"; + + pdomain::Credentials const credentials1 = {{.issuer = issuer, .credType = credType}}; + env(pdomain::setTx(issuer, credentials1)); + env.close(); + + auto domainID = pdomain::getNewDomain(env.meta()); + + env(credentials::create(broker, issuer, credType)); + env(credentials::accept(broker, issuer, credType)); + env.close(); + + env(credentials::create(borrower, issuer, credType)); + env(credentials::accept(borrower, issuer, credType)); + env.close(); + + MPTTester mptt{env, issuer, kMptInitNoFund}; + mptt.create({ + .flags = tfMPTCanClawback | tfMPTRequireAuth | tfMPTCanTransfer | tfMPTCanLock, + .domainID = domainID, + }); + + PrettyAsset const mpt{mptt.issuanceID()}; + + // Authorize broker and borrower + mptt.authorize({.account = broker}); + mptt.authorize({.account = borrower}); + + env.close(); + + // Fund accounts + env(pay(issuer, broker, mpt(10'000'000))); + env(pay(issuer, borrower, mpt(1'000))); + env.close(); + + // Create vault and broker + auto const brokerInfo = createVaultAndBroker(env, mpt, broker); + // Create a loan first (this creates debt) + auto const keylet = keylet::loan(brokerInfo.brokerID, 1); + env(set(borrower, brokerInfo.brokerID, 10'000), + Sig(sfCounterpartySignature, broker), + kLoanServiceFee(mpt(100).value()), + kPaymentInterval(100), + Fee(XRP(100))); + env.close(); + // Ensure broker has sufficient cover so brokerPayee == brokerOwner + // We need coverAvailable >= (debtTotal * coverRateMinimum) + // Deposit enough cover to ensure the fee goes to broker owner + // The default coverRateMinimum is 10%, so for a 10,000 loan we need + // at least 1,000 cover. Default cover is 1,000, so we add more to be + // safe. + auto const additionalCover = mpt(50'000).value(); + env(loanBroker::coverDeposit(broker, brokerInfo.brokerID, STAmount{mpt, additionalCover})); + env.close(); + // Verify broker owner is authorized + auto const brokerMpt = keylet::mptoken(mptt.issuanceID(), broker); + BEAST_EXPECT(env.le(brokerMpt) != nullptr); + // Remove the credentials for the Broker owner. + // First, pay any positive balance to issuer to zero it out + auto const brokerBalance = env.balance(broker, mpt); + env(pay(broker, issuer, brokerBalance)); + env.close(); + + env(credentials::deleteCred(broker, broker, issuer, credType)); + env.close(); + + // Make sure the broker is not authorized to hold the MPT after we + // deleted the credentials + env(pay(issuer, broker, mpt(1'000)), Ter(tecNO_AUTH)); + + // Now borrower tries to make a payment + // We should get a tesSUCCESS instead of a tecNO_AUTH. + auto const borrowerBalance = env.balance(borrower, mpt); + env(pay(borrower, keylet.key, mpt(10'100)), Fee(XRP(100)), Ter(tesSUCCESS)); + env.close(); + // Verify broker is still not authorized + env(pay(issuer, broker, mpt(1'000)), Ter(tecNO_AUTH)); + // Verify the service fee went to the broker pseudo-account + if (auto const brokerSle = env.le(keylet::loanBroker(brokerInfo.brokerID)); + BEAST_EXPECT(brokerSle)) + { + Account const pseudo("pseudo-account", brokerSle->at(sfAccount)); + auto const balance = env.balance(pseudo, mpt); + // 1,000 default + 50,000 extra + 100 service fee from LoanPay + BEAST_EXPECTS(balance == mpt(51'100), to_string(json::Value(balance))); + } + } + + void + testLoanSetBrokerOwnerNoPermissionedDomainMPT(FeatureBitset features) + { + testcase << "LoanSet Broker Owner without permissioned domain of the MPT"; + using namespace jtx; + using namespace loan; + + Account const issuer("issuer"); + Account const borrower("borrower"); + Account const broker("broker"); + + Env env{*this, features}; + env.fund(XRP(20'000), issuer, broker, borrower); + env.close(); + + auto credType = "credential1"; + + pdomain::Credentials const credentials1{{.issuer = issuer, .credType = credType}}; + env(pdomain::setTx(issuer, credentials1)); + env.close(); + + auto domainID = pdomain::getNewDomain(env.meta()); + + // Add credentials for the broker and borrower + env(credentials::create(broker, issuer, credType)); + env(credentials::accept(broker, issuer, credType)); + env.close(); + + env(credentials::create(borrower, issuer, credType)); + env(credentials::accept(borrower, issuer, credType)); + env.close(); + + MPTTester mptt{env, issuer, kMptInitNoFund}; + mptt.create({ + .flags = tfMPTCanClawback | tfMPTRequireAuth | tfMPTCanTransfer | tfMPTCanLock, + .domainID = domainID, + }); + + PrettyAsset const mpt{mptt.issuanceID()}; + + // Authorize broker and borrower + mptt.authorize({.account = broker}); + mptt.authorize({.account = borrower}); + env.close(); + + // Fund accounts + env(pay(issuer, broker, mpt(10'000'000))); + env(pay(issuer, borrower, mpt(1'000))); + env.close(); + + // Create vault and broker + auto const brokerInfo = createVaultAndBroker(env, mpt, broker); + + // Remove the credentials for the Broker owner. + // Clear the balance first. + auto const brokerBalance = env.balance(broker, mpt); + env(pay(broker, issuer, brokerBalance)); + env.close(); + // Delete the credentials + env(credentials::deleteCred(broker, broker, issuer, credType)); + env.close(); + + // Create a loan, this should fail for tecNO_AUTH + env(set(borrower, brokerInfo.brokerID, 10'000), + Sig(sfCounterpartySignature, broker), + kLoanServiceFee(mpt(100).value()), + kPaymentInterval(100), + Fee(XRP(100)), + Ter(tecNO_AUTH)); + env.close(); + } + + void + runAmendmentIndependent() + { + testServiceFeeOnBrokerDeepFreeze(); + } + + // Tests run under each entry in amendmentCombinations(). + void + runAmendmentSensitive(FeatureBitset features) + { + testSequentialFLCDepletion(features); + testWithdrawReflectsUnrealizedLoss(features); + testLoanPayBrokerOwnerMissingTrustline(features); + testLoanPayBrokerOwnerUnauthorizedMPT(features); + testLoanPayBrokerOwnerNoPermissionedDomainMPT(features); + testLoanSetBrokerOwnerNoPermissionedDomainMPT(features); + } + +public: + void + run() override + { + runAmendmentIndependent(); + for (auto const& features : jtx::amendmentCombinations( + {fixCleanup3_1_3, fixCleanup3_2_0, featureMPTokensV2}, all_)) + runAmendmentSensitive(features); + } +}; + +BEAST_DEFINE_TESTSUITE(LoanCoverFreezeAuth, tx, xrpl); + +} // namespace xrpl::test diff --git a/src/test/app/LoanInvariant_test.cpp b/src/test/app/LoanInvariant_test.cpp deleted file mode 100644 index fac71a55fa7..00000000000 --- a/src/test/app/LoanInvariant_test.cpp +++ /dev/null @@ -1,1903 +0,0 @@ -#include - -namespace xrpl::test { - -class LoanInvariant_test : public LoanTestBase -{ -private: - // Each of these regression tests reproduces a single fuzzer-found (FIND-*) - // scenario against xrpl::detail::computePeriodicPayment / - // loanComputePaymentParts. They're merged into one function, one block - // per finding, because each is a narrow, self-contained repro that - // shares little beyond the surrounding scaffold. - void - testLoanPayComputePeriodicPaymentInvariants(FeatureBitset features) - { - using namespace jtx; - using namespace std::chrono_literals; - using namespace Lending; - - // From FIND-012 - { - testcase << "LoanPay xrpl::detail::computePeriodicPayment : " - "valid rate"; - - Env env(*this, features); - - Account const issuer{"issuer"}; - Account const lender{"lender"}; - Account const borrower{"borrower"}; - - BrokerParameters const brokerParams; - env.fund(XRP(brokerParams.vaultDeposit * 100), issuer, lender, borrower); - env.close(); - - PrettyAsset const xrpAsset{xrpIssue(), 1'000'000}; - BrokerInfo const broker{createVaultAndBroker(env, xrpAsset, lender, brokerParams)}; - - using namespace loan; - - auto const loanSetFee = Fee(env.current()->fees().base * 2); - Number const principalRequest{640562, -5}; - - Number const serviceFee{2462611968}; - std::uint32_t const numPayments{4294967295 / 800}; - - auto createJson = env.json( - set(borrower, broker.brokerID, principalRequest), - Fee(loanSetFee), - kLoanServiceFee(serviceFee), - kPaymentTotal(numPayments), - Json(sfCounterpartySignature, json::ValueType::Object)); - - createJson["CloseInterestRate"] = 55374; - createJson["ClosePaymentFee"] = "3825205248"; - createJson["LatePaymentFee"] = "237"; - createJson["LoanOriginationFee"] = "0"; - createJson["OverpaymentFee"] = 35167; - createJson["OverpaymentInterestRate"] = 1360; - createJson["PaymentInterval"] = 727; - - auto const keylet = nextLoanKeylet(env, broker); - - createJson = env.json(createJson, Sig(sfCounterpartySignature, lender)); - // Fails in preclaim because principal requested can't be - // represented as XRP - env(createJson, Ter(tecPRECISION_LOSS)); - env.close(); - - BEAST_EXPECT(!env.le(keylet)); - - Number const actualPrincipal{6}; - - createJson[sfPrincipalRequested] = actualPrincipal; - createJson.removeMember(sfSequence.jsonName); - createJson = env.json(createJson, Sig(sfCounterpartySignature, lender)); - // Fails in doApply because the payment is too small to be - // represented as XRP. - env(createJson, Ter(tecPRECISION_LOSS)); - env.close(); - } - - // From FIND-010 - { - testcase << "xrpl::loanComputePaymentParts : valid total interest"; - - Env env(*this, features); - - Account const issuer{"issuer"}; - Account const lender{"lender"}; - Account const borrower{"borrower"}; - - PrettyAsset const iouAsset = createFundedIouAsset(env, issuer, lender, borrower); - - BrokerInfo const broker{createVaultAndBroker(env, iouAsset, lender)}; - - using namespace loan; - - auto const loanSetFee = Fee(env.current()->fees().base * 2); - Number const principalRequest{1, 3}; - - auto createJson = env.json( - set(borrower, broker.brokerID, principalRequest), - Fee(loanSetFee), - Json(sfCounterpartySignature, json::ValueType::Object)); - - createJson["CloseInterestRate"] = 47299; - createJson["ClosePaymentFee"] = "3985819770"; - createJson["InterestRate"] = 92; - createJson["LatePaymentFee"] = "3866894865"; - createJson["LoanOriginationFee"] = "0"; - createJson["LoanServiceFee"] = "2348810240"; - createJson["OverpaymentFee"] = 58545; - createJson["PaymentInterval"] = 60; - createJson["PaymentTotal"] = 1; - createJson["PrincipalRequested"] = "0.000763058"; - - auto const keylet = nextLoanKeylet(env, broker); - - createJson = env.json(createJson, Sig(sfCounterpartySignature, lender)); - env(createJson); - env.close(); - - auto loanPayTx = env.json(pay(borrower, keylet.key, STAmount{broker.asset, Number{}})); - loanPayTx["Amount"]["value"] = "0.000281284125490196"; - env(loanPayTx, Ter(tecINSUFFICIENT_PAYMENT)); - env.close(); - } - - // From FIND-009 - { - testcase << "xrpl::loanComputePaymentParts : totalPrincipalPaid " - "rounded"; - - Env env(*this, features); - - Account const issuer{"issuer"}; - Account const lender{"lender"}; - Account const borrower{"borrower"}; - - PrettyAsset const iouAsset = createFundedIouAsset(env, issuer, lender, borrower); - - BrokerInfo const broker{createVaultAndBroker(env, iouAsset, lender)}; - - using namespace loan; - - auto const loanSetFee = Fee(env.current()->fees().base * 2); - Number const principalRequest{1, 3}; - - auto createJson = env.json( - set(borrower, broker.brokerID, principalRequest), - Fee(loanSetFee), - Json(sfCounterpartySignature, json::ValueType::Object)); - - createJson["ClosePaymentFee"] = "0"; - createJson["InterestRate"] = 24346; - createJson["LateInterestRate"] = 65535; - createJson["LatePaymentFee"] = "0"; - createJson["LoanOriginationFee"] = "218"; - createJson["LoanServiceFee"] = "0"; - createJson["PaymentInterval"] = 60; - createJson["PaymentTotal"] = 5678; - createJson["PrincipalRequested"] = "9924.81"; - - auto const keylet = nextLoanKeylet(env, broker); - - createJson = env.json(createJson, Sig(sfCounterpartySignature, lender)); - env(createJson, Ter(tesSUCCESS)); - env.close(); - - auto const baseFee = env.current()->fees().base; - - auto const stateBefore = getCurrentState(env, broker, keylet); - - { - auto loanPayTx = - env.json(pay(borrower, keylet.key, STAmount{broker.asset, Number{}})); - Number const amount{3074'745'058'823'529, -12}; - BEAST_EXPECT(to_string(amount) == "3074.745058823529"); - XRPAmount const payFee{ - baseFee * - (amount / stateBefore.periodicPayment / kLoanPaymentsPerFeeIncrement + 1)}; - loanPayTx["Amount"]["value"] = to_string(amount); - env(loanPayTx, Fee(payFee), Ter(tesSUCCESS)); - env.close(); - } - - { - auto loanPayTx = - env.json(pay(borrower, keylet.key, STAmount{broker.asset, Number{}})); - Number const amount{6732'118'170'944'051, -12}; - BEAST_EXPECT(to_string(amount) == "6732.118170944051"); - XRPAmount const payFee{ - baseFee * - (amount / stateBefore.periodicPayment / kLoanPaymentsPerFeeIncrement + 1)}; - loanPayTx["Amount"]["value"] = to_string(amount); - env(loanPayTx, Fee(payFee), Ter(tesSUCCESS)); - env.close(); - } - - auto const stateAfter = getCurrentState(env, broker, keylet); - // Total interest outstanding is non-negative - BEAST_EXPECT(stateAfter.totalValue >= stateAfter.principalOutstanding); - // Principal paid is non-negative - BEAST_EXPECT(stateBefore.principalOutstanding >= stateAfter.principalOutstanding); - // Total value change is non-negative - BEAST_EXPECT(stateBefore.totalValue >= stateAfter.totalValue); - // Value delta is larger or same as principal delta (meaning - // non-negative interest paid) - BEAST_EXPECT( - (stateBefore.totalValue - stateAfter.totalValue) >= - (stateBefore.principalOutstanding - stateAfter.principalOutstanding)); - } - - // From FIND-008 - { - testcase << "xrpl::loanComputePaymentParts : loanValueChange rounded"; - - Env env(*this, features); - - Account const issuer{"issuer"}; - Account const lender{"lender"}; - Account const borrower{"borrower"}; - - PrettyAsset const iouAsset = - createFundedIouAsset(env, issuer, lender, borrower, 100'000'000, 10'000'000); - - BrokerInfo const broker{createVaultAndBroker(env, iouAsset, lender)}; - { - auto const coverDepositValue = - broker.asset(broker.params.coverDeposit * 10).value(); - env(loanBroker::coverDeposit(lender, broker.brokerID, coverDepositValue)); - env.close(); - } - - using namespace loan; - - auto const loanSetFee = Fee(env.current()->fees().base * 2); - Number const principalRequest{1, 3}; - - auto createJson = env.json( - set(borrower, broker.brokerID, principalRequest), - Fee(loanSetFee), - Json(sfCounterpartySignature, json::ValueType::Object)); - - createJson["ClosePaymentFee"] = "0"; - createJson["InterestRate"] = 12833; - createJson["LateInterestRate"] = 77048; - createJson["LatePaymentFee"] = "0"; - createJson["LoanOriginationFee"] = "218"; - createJson["LoanServiceFee"] = "0"; - createJson["PaymentInterval"] = 752; - createJson["PaymentTotal"] = 5678; - createJson["PrincipalRequested"] = "9924.81"; - - auto const keylet = nextLoanKeylet(env, broker); - - createJson = env.json(createJson, Sig(sfCounterpartySignature, lender)); - env(createJson, Ter(tesSUCCESS)); - env.close(); - - auto const baseFee = env.current()->fees().base; - - auto const stateBefore = getCurrentState(env, broker, keylet); - BEAST_EXPECT(stateBefore.paymentRemaining == 5678); - BEAST_EXPECT(stateBefore.paymentRemaining > kLoanMaximumPaymentsPerTransaction); - - auto loanPayTx = env.json(pay(borrower, keylet.key, STAmount{broker.asset, Number{}})); - Number const amount{9924'81, -2}; - BEAST_EXPECT(to_string(amount) == "9924.81"); - XRPAmount const payFee{ - baseFee * - (amount / stateBefore.periodicPayment / kLoanPaymentsPerFeeIncrement + 1)}; - loanPayTx["Amount"]["value"] = to_string(amount); - env(loanPayTx, Fee(payFee), Ter(tesSUCCESS)); - env.close(); - - auto const stateAfter = getCurrentState(env, broker, keylet); - BEAST_EXPECT( - stateAfter.paymentRemaining == - stateBefore.paymentRemaining - kLoanMaximumPaymentsPerTransaction); - } - } - - void - testAccountSendMptMinAmountInvariant(FeatureBitset features) - { - // (From FIND-006) - testcase << "LoanSet trigger xrpl::accountSendMPT : minimum amount " - "and MPT"; - - using namespace jtx; - using namespace std::chrono_literals; - Env env(*this, features); - - Account const issuer{"issuer"}; - Account const lender{"lender"}; - Account const borrower{"borrower"}; - - env.fund(XRP(1'000'000), issuer, lender, borrower); - env.close(); - - MPTTester mptt{env, issuer, kMptInitNoFund}; - mptt.create({.flags = tfMPTCanClawback | tfMPTCanTransfer | tfMPTCanLock}); - PrettyAsset const mptAsset = mptt.issuanceID(); - mptt.authorize({.account = lender}); - mptt.authorize({.account = borrower}); - env(pay(issuer, lender, mptAsset(2'000'000))); - env(pay(issuer, borrower, mptAsset(1'000))); - env.close(); - - BrokerInfo const broker{createVaultAndBroker(env, mptAsset, lender)}; - - using namespace loan; - - auto const loanSetFee = Fee(env.current()->fees().base * 2); - Number const principalRequest{1, 3}; - - auto createJson = env.json( - set(borrower, broker.brokerID, principalRequest), - Fee(loanSetFee), - Json(sfCounterpartySignature, json::ValueType::Object)); - - createJson["CloseInterestRate"] = 76671; - createJson["ClosePaymentFee"] = "2061925410"; - createJson["GracePeriod"] = 434; - createJson["InterestRate"] = 50302; - createJson["LateInterestRate"] = 30322; - createJson["LatePaymentFee"] = "294427911"; - createJson["LoanOriginationFee"] = "3250635102"; - createJson["LoanServiceFee"] = "9557386"; - createJson["OverpaymentFee"] = 51249; - createJson["OverpaymentInterestRate"] = 14304; - createJson["PaymentInterval"] = 434; - createJson["PaymentTotal"] = "2891743748"; - createJson["PrincipalRequested"] = "8516.98"; - - auto const brokerStateBefore = env.le(keylet::loanBroker(broker.brokerID)); - - createJson = env.json(createJson, Sig(sfCounterpartySignature, lender)); - env(createJson, Ter(temINVALID)); - env.close(); - } - - void - testLoanPayDebtDecreaseInvariant(FeatureBitset features) - { - // From FIND-007 - testcase << "LoanPay xrpl::LoanPay::doApply : debtDecrease " - "rounding good"; - - using namespace jtx; - using namespace std::chrono_literals; - using namespace Lending; - Env env(*this, features); - - Account const issuer{"issuer"}; - Account const lender{"lender"}; - Account const borrower{"borrower"}; - - PrettyAsset const iouAsset = createFundedIouAsset(env, issuer, lender, borrower); - - BrokerInfo broker{createVaultAndBroker(env, iouAsset, lender)}; - - using namespace loan; - - auto const baseFee = env.current()->fees().base; - auto const loanSetFee = Fee(baseFee * 2); - Number const principalRequest{1, 3}; - - auto createJson = env.json( - set(borrower, broker.brokerID, principalRequest), - Fee(loanSetFee), - Json(sfCounterpartySignature, json::ValueType::Object)); - - createJson["ClosePaymentFee"] = "0"; - createJson["GracePeriod"] = 60; - createJson["InterestRate"] = 24346; - createJson["LateInterestRate"] = 65535; - createJson["LatePaymentFee"] = "0"; - createJson["LoanOriginationFee"] = "218"; - createJson["LoanServiceFee"] = "0"; - createJson["PaymentInterval"] = 60; - createJson["PaymentTotal"] = 5678; - createJson["PrincipalRequested"] = "9924.81"; - - auto const keylet = nextLoanKeylet(env, broker); - - createJson = env.json(createJson, Sig(sfCounterpartySignature, lender)); - env(createJson, Ter(tesSUCCESS)); - env.close(); - - auto const pseudoAcct = brokerPseudoAccount(env, broker, lender); - - VerifyLoanStatus const verifyLoanStatus(env, broker, pseudoAcct, keylet); - auto const originalState = getCurrentState(env, broker, keylet); - verifyLoanStatus(originalState); - - Number const payment{3'269'349'176'470'588, -12}; - XRPAmount const payFee{ - baseFee * - ((payment / originalState.periodicPayment) / kLoanPaymentsPerFeeIncrement + 1)}; - auto loanPayTx = - env.json(pay(borrower, keylet.key, STAmount{broker.asset, payment}), Fee(payFee)); - BEAST_EXPECT(to_string(payment) == "3269.349176470588"); - env(loanPayTx, Ter(tesSUCCESS)); - env.close(); - - auto const newState = getCurrentState(env, broker, keylet); - BEAST_EXPECT( - isRounded(broker.asset, newState.managementFeeOutstanding, originalState.loanScale)); - BEAST_EXPECT(newState.managementFeeOutstanding < originalState.managementFeeOutstanding); - BEAST_EXPECT(isRounded(broker.asset, newState.totalValue, originalState.loanScale)); - BEAST_EXPECT( - isRounded(broker.asset, newState.principalOutstanding, originalState.loanScale)); - } - - void - testDosLoanPay(FeatureBitset features) - { - bool const feeCapped = features[fixCleanup3_1_3]; - - // From FIND-005 - testcase << "DoS LoanPay: fee calculation " << (feeCapped ? "capped" : "uncapped"); - - using namespace jtx; - using namespace std::chrono_literals; - using namespace Lending; - Env env(*this, features); - - Account const issuer{"issuer"}; - Account const lender{"lender"}; - Account const borrower{"borrower"}; - - env.fund(XRP(1'000'000), issuer, lender, borrower); - env.close(); - - BEAST_EXPECT(feeCapped == env.current()->rules().enabled(fixCleanup3_1_3)); - - PrettyAsset const iouAsset = issuer[iouCurrency_]; - env(trust(lender, iouAsset(100'000'000))); - env(trust(borrower, iouAsset(100'000'000))); - env(pay(issuer, lender, iouAsset(10'000'000))); - env(pay(issuer, borrower, iouAsset(1'000))); - env.close(); - - BrokerInfo const broker{createVaultAndBroker(env, iouAsset, lender)}; - - using namespace loan; - - auto const loanSetFee = Fee(env.current()->fees().base * 2); - Number const principalRequest{3959'37, -2}; - auto const baseFee = env.current()->fees().base; - - auto const createJson = env.json( - set(borrower, broker.brokerID, principalRequest), - Fee(loanSetFee), - Json(sfCounterpartySignature, json::ValueType::Object), - kClosePaymentFee(0), - kGracePeriod(60), - kInterestRate(TenthBips32(20930)), - kLateInterestRate(TenthBips32(77049)), - kLatePaymentFee(0), - kLoanServiceFee(0), - kOverpaymentFee(TenthBips32(7)), - kOverpaymentInterestRate(TenthBips32(66653)), - kPaymentInterval(60), - kPaymentTotal(3239184)); - - // There are enough payments due on this loan that it only needs to be - // created once, and can be paid on multiple times. Just don't create a - // gazillion test cases. - auto const keylet = nextLoanKeylet(env, broker); - - env(createJson, Sig(sfCounterpartySignature, lender)); - env.close(); - - auto const roundedPayment = [&]() { - auto const stateBefore = getCurrentState(env, broker, keylet); - BEAST_EXPECT(stateBefore.paymentRemaining == 3239184); - BEAST_EXPECT(stateBefore.paymentRemaining > kLoanMaximumPaymentsPerTransaction); - - return roundToAsset( - iouAsset, - stateBefore.periodicPayment, - stateBefore.loanScale, - Number::RoundingMode::Upward); - }(); - - auto test = [&](int const payFactor, - int const feeFactor, - TER const expectedTer = tesSUCCESS) { - auto const stateBefore = getCurrentState(env, broker, keylet); - BEAST_EXPECT(stateBefore.paymentRemaining <= 3239184); - BEAST_EXPECT(stateBefore.paymentRemaining > kLoanMaximumPaymentsPerTransaction); - - Number const amount = roundedPayment * payFactor; - auto loanPayTx = env.json(pay(borrower, keylet.key, STAmount{broker.asset, amount})); - XRPAmount const payFee{baseFee * feeFactor}; - env(loanPayTx, Ter(expectedTer), Fee(payFee)); - env.close(); - auto const expectedChange = isTesSuccess(expectedTer) - ? std::min(kLoanMaximumPaymentsPerTransaction, payFactor) - : 0; - - auto const stateAfter = getCurrentState(env, broker, keylet); - BEAST_EXPECT( - stateAfter.paymentRemaining == stateBefore.paymentRemaining - expectedChange); - }; - - static constexpr std::int64_t kMaxFeeIncrements = - kLoanMaximumPaymentsPerTransaction / kLoanPaymentsPerFeeIncrement; - - TER const failWithoutFix = feeCapped ? (TER)tesSUCCESS : (TER)telINSUF_FEE_P; - - // * Amount well above threshold -> capped fee - // The original test case - way over the limit - more fee is always ok - test(1819878, 363976); - // The capped fee is only sufficient if the amendment is enabled. - test(1819878, kMaxFeeIncrements, failWithoutFix); - - // * Amount exactly at threshold -> capped fee - test(kLoanMaximumPaymentsPerTransaction, kMaxFeeIncrements); - // More fee is always ok - test(kLoanMaximumPaymentsPerTransaction, kMaxFeeIncrements + 10); - - // * Amount below threshold -> normal calculation - test(1, 1); - test(kLoanPaymentsPerFeeIncrement * 2, 2); - test(0, 0, temBAD_AMOUNT); - test(0, 1, temBAD_AMOUNT); - // Fee difference rounds evenly - test( - kLoanMaximumPaymentsPerTransaction - 10, - ((kLoanMaximumPaymentsPerTransaction - 10) / kLoanPaymentsPerFeeIncrement) - 1, - telINSUF_FEE_P); - test( - kLoanMaximumPaymentsPerTransaction - 10, - ((kLoanMaximumPaymentsPerTransaction - 10) / kLoanPaymentsPerFeeIncrement)); - // More fee is always ok - test( - kLoanMaximumPaymentsPerTransaction - 10, - ((kLoanMaximumPaymentsPerTransaction - 10) / kLoanPaymentsPerFeeIncrement) + 3); - // Fee rounds up - for (int under = 1; under < kLoanPaymentsPerFeeIncrement; ++under) - { - test(kLoanMaximumPaymentsPerTransaction - under, kMaxFeeIncrements - 1, telINSUF_FEE_P); - test(kLoanMaximumPaymentsPerTransaction - under, kMaxFeeIncrements); - } - // Only when you get one less fee increment can you pay less - test( - kLoanMaximumPaymentsPerTransaction - kLoanPaymentsPerFeeIncrement, - kMaxFeeIncrements - 1); - // And again, more fee is always ok. - test(kLoanMaximumPaymentsPerTransaction - kLoanPaymentsPerFeeIncrement, kMaxFeeIncrements); - } - - void - testLoanNextPaymentDueDateOverflow(FeatureBitset features) - { - // For FIND-013 - testcase << "Prevent nextPaymentDueDate overflow"; - - using namespace jtx; - using namespace std::chrono_literals; - using namespace Lending; - Env env{*this, features}; - - Account const issuer{"issuer"}; - Account const lender{"lender"}; - Account const borrower{"borrower"}; - - PrettyAsset const iouAsset = - createFundedIouAsset(env, issuer, lender, borrower, 100'000'000, 10'000'000); - - BrokerParameters const brokerParams{.debtMax = Number{0}, .coverRateMin = TenthBips32{1}}; - BrokerInfo broker{createVaultAndBroker(env, iouAsset, lender, brokerParams)}; - - using namespace loan; - - auto const loanSetFee = Fee(env.current()->fees().base * 2); - - using timeType = decltype(sfNextPaymentDueDate)::type::value_type; - static_assert(std::is_same_v); - constexpr timeType kMaxTime = std::numeric_limits::max(); - static_assert(kMaxTime == 4'294'967'295); - - auto const baseJson = [&]() { - auto createJson = env.json( - set(borrower, broker.brokerID, Number{55524'81, -2}), - Fee(loanSetFee), - kClosePaymentFee(0), - kGracePeriod(LoanSet::kDefaultGracePeriod), - kInterestRate(TenthBips32(12833)), - kLateInterestRate(TenthBips32(77048)), - kLatePaymentFee(0), - kLoanOriginationFee(218), - Json(sfCounterpartySignature, json::ValueType::Object)); - - createJson.removeMember(sfSequence.getJsonName()); - - return createJson; - }(); - - auto const baseFee = env.current()->fees().base; - - auto parentCloseTime = [&]() { - return env.current()->parentCloseTime().time_since_epoch().count(); - }; - auto maxLoanTime = [&]() { - auto const startDate = parentCloseTime(); - - BEAST_EXPECT(startDate >= 50); - - return kMaxTime - startDate; - }; - - { - // straight-up overflow: interval - auto const interval = maxLoanTime() + 1; - auto const total = 1; - auto createJson = env.json(baseJson, kPaymentInterval(interval), kPaymentTotal(total)); - - env(createJson, Sig(sfCounterpartySignature, lender), Ter(tecKILLED)); - env.close(); - } - { - // straight-up overflow: total - // min interval is 60 - auto const interval = 60; - auto const total = maxLoanTime() + 1; - auto createJson = env.json(baseJson, kPaymentInterval(interval), kPaymentTotal(total)); - - env(createJson, Sig(sfCounterpartySignature, lender), Ter(tecKILLED)); - env.close(); - } - { - // straight-up overflow: grace period - // min interval is 60 - auto const interval = maxLoanTime() + 1; - auto const total = 1; - auto const grace = interval; - auto createJson = env.json( - baseJson, kPaymentInterval(interval), kPaymentTotal(total), kGracePeriod(grace)); - - // The grace period can't be larger than the interval. - env(createJson, Sig(sfCounterpartySignature, lender), Ter(tecKILLED)); - env.close(); - } - { - // Overflow with multiplication of a few large intervals - auto const interval = 1'000'000'000; - auto const total = 10; - auto createJson = env.json(baseJson, kPaymentInterval(interval), kPaymentTotal(total)); - - env(createJson, Sig(sfCounterpartySignature, lender), Ter(tecKILLED)); - env.close(); - } - { - // Overflow with multiplication of many small payments - // min interval is 60 - auto const interval = 60; - auto const total = 1'000'000'000; - auto createJson = env.json(baseJson, kPaymentInterval(interval), kPaymentTotal(total)); - - env(createJson, Sig(sfCounterpartySignature, lender), Ter(tecKILLED)); - env.close(); - } - { - // Overflow with an absurdly large grace period - // min interval is 60 - auto const total = 60; - auto const interval = (maxLoanTime() - total) / total; - auto const grace = interval; - auto createJson = env.json( - baseJson, kPaymentInterval(interval), kPaymentTotal(total), kGracePeriod(grace)); - - env(createJson, Sig(sfCounterpartySignature, lender), Ter(tecKILLED)); - env.close(); - } - { - // Start date when the ledger is closed will be larger - auto const keylet = nextLoanKeylet(env, broker); - - auto const grace = 100; - auto const interval = maxLoanTime() - grace; - auto const total = 1; - auto createJson = env.json( - baseJson, kPaymentInterval(interval), kPaymentTotal(total), kGracePeriod(grace)); - - env(createJson, Sig(sfCounterpartySignature, lender), Ter(tesSUCCESS)); - env.close(); - - // The transaction is killed in the closed ledger - auto const meta = env.meta(); - if (BEAST_EXPECT(meta)) - { - BEAST_EXPECT(meta->at(sfTransactionResult) == tecKILLED); - } - - // If the transaction had succeeded, the loan would exist - auto const loanSle = env.le(keylet); - // but it doesn't - BEAST_EXPECT(!loanSle); - } - { - // Start date when the ledger is closed will be larger - auto const keylet = nextLoanKeylet(env, broker); - - auto const closeStartDate = ((parentCloseTime() / 10) + 1) * 10; - auto const grace = 5'000; - auto const interval = kMaxTime - closeStartDate - grace; - auto const total = 1; - auto createJson = env.json( - baseJson, kPaymentInterval(interval), kPaymentTotal(total), kGracePeriod(grace)); - - env(createJson, Sig(sfCounterpartySignature, lender), Ter(tesSUCCESS)); - env.close(); - - // The transaction succeeds in the closed ledger - auto const meta = env.meta(); - if (BEAST_EXPECT(meta)) - { - BEAST_EXPECT(meta->at(sfTransactionResult) == tesSUCCESS); - } - - // This loan exists - auto const afterState = getCurrentState(env, broker, keylet); - BEAST_EXPECT(afterState.nextPaymentDate == kMaxTime - grace); - BEAST_EXPECT(afterState.previousPaymentDate == 0); - BEAST_EXPECT(afterState.paymentRemaining == 1); - } - - { - // Ensure the borrower has funds to pay back the loan - env(pay(issuer, borrower, iouAsset(Number{1'055'524'81, -2}))); - - // Start date when the ledger is closed will be larger - auto const closeStartDate = ((parentCloseTime() / 10) + 1) * 10; - auto const grace = 5'000; - auto const maxLoanTime = kMaxTime - closeStartDate - grace; - auto const total = [&]() { - if (maxLoanTime % 5 == 0) - return 5; - if (maxLoanTime % 3 == 0) - return 3; - if (maxLoanTime % 2 == 0) - return 2; - return 0; - }(); - if (!BEAST_EXPECT(total != 0)) - return; - - auto const brokerState = env.le(keylet::loanBroker(broker.brokerID)); - // Intentionally shadow the outer values - auto const loanSequence = brokerState->at(sfLoanSequence); - auto const keylet = keylet::loan(broker.brokerID, loanSequence); - - auto const interval = maxLoanTime / total; - auto createJson = env.json( - baseJson, kPaymentInterval(interval), kPaymentTotal(total), kGracePeriod(grace)); - - env(createJson, Sig(sfCounterpartySignature, lender), Ter(tesSUCCESS)); - env.close(); - - // This loan exists - auto const beforeState = getCurrentState(env, broker, keylet); - BEAST_EXPECT(beforeState.nextPaymentDate == closeStartDate + interval); - BEAST_EXPECT(beforeState.previousPaymentDate == 0); - BEAST_EXPECT(beforeState.paymentRemaining == total); - BEAST_EXPECT(beforeState.periodicPayment > 0); - - // pay all but the last payment - { - NumberRoundModeGuard const mg{Number::RoundingMode::Upward}; - Number const payment = beforeState.periodicPayment * (total - 1); - XRPAmount const payFee{baseFee * ((total - 1) / kLoanPaymentsPerFeeIncrement + 1)}; - STAmount const paymentAmount = - roundToScale(STAmount{broker.asset, payment}, beforeState.loanScale); - auto loanPayTx = env.json(pay(borrower, keylet.key, paymentAmount), Fee(payFee)); - env(loanPayTx, Ter(tesSUCCESS)); - env.close(); - } - - // The loan is on the last payment - auto const afterState = getCurrentState(env, broker, keylet); - BEAST_EXPECT(afterState.paymentRemaining == 1); - BEAST_EXPECT(afterState.nextPaymentDate == kMaxTime - grace); - BEAST_EXPECT(afterState.previousPaymentDate == kMaxTime - grace - interval); - } - } - -#if LOAN_TODO - void - testLoanPayLateFullPaymentBypassesPenalties(FeatureBitset features) - { - testcase("LoanPay full payment skips late penalties"); - using namespace jtx; - using namespace loan; - using namespace std::chrono_literals; - - Env env(*this, features); - - Account const issuer{"issuer"}; - Account const lender{"lender"}; - Account const borrower{"borrower"}; - - env.fund(XRP(1'000'000), issuer, lender, borrower); - env.close(); - - PrettyAsset const asset = issuer[iouCurrency]; - env(trust(lender, asset(100'000'000))); - env(trust(borrower, asset(100'000'000))); - env(pay(issuer, lender, asset(50'000'000))); - env(pay(issuer, borrower, asset(5'000'000))); - env.close(); - - BrokerInfo broker{createVaultAndBroker(env, asset, lender)}; - - auto const loanSetFee = Fee(env.current()->fees().base * 2); - - auto const brokerPreLoan = env.le(keylet::loanBroker(broker.brokerID)); - if (BEAST_EXPECT(brokerPreLoan); !brokerPreLoan.has_value()) - return; - - auto const loanSequence = brokerPreLoan->at(sfLoanSequence); - auto const loanKeylet = keylet::loan(broker.brokerID, loanSequence); - - Number const principal = asset(1'000).value(); - Number const serviceFee = asset(2).value(); - Number const lateFee = asset(5).value(); - Number const closeFee = asset(4).value(); - - env(set(borrower, broker.brokerID, principal), - Sig(sfCounterpartySignature, lender), - kLoanServiceFee(serviceFee), - kLatePaymentFee(lateFee), - kClosePaymentFee(closeFee), - kInterestRate(percentageToTenthBips(12)), - kLateInterestRate(percentageToTenthBips(24) / 10), - kCloseInterestRate(percentageToTenthBips(5)), - kPaymentTotal(12), - kPaymentInterval(600), - kGracePeriod(0), - Fee(loanSetFee)); - env.close(); - - auto state1 = getCurrentState(env, broker, loanKeylet); - if (!BEAST_EXPECT(state1.paymentRemaining > 1)) - return; - - using d = NetClock::duration; - using tp = NetClock::time_point; - auto const overdueClose = tp{d{state1.nextPaymentDate + state1.paymentInterval}}; - env.close(overdueClose); - - auto const brokerSle = env.le(keylet::loanBroker(broker.brokerID)); - auto const loanSle = env.le(loanKeylet); - if (!BEAST_EXPECT(brokerSle && loanSle)) - return; - - auto state = getCurrentState(env, broker, loanKeylet); - - TenthBips16 const managementFeeRate{brokerSle->at(sfManagementFeeRate)}; - TenthBips32 const interestRateValue{loanSle->at(sfInterestRate)}; - TenthBips32 const lateInterestRateValue{loanSle->at(sfLateInterestRate)}; - TenthBips32 const closeInterestRateValue{loanSle->at(sfCloseInterestRate)}; - - Number const closePaymentFeeRounded = - roundToAsset(broker.asset, loanSle->at(sfClosePaymentFee), state.loanScale); - Number const latePaymentFeeRounded = - roundToAsset(broker.asset, loanSle->at(sfLatePaymentFee), state.loanScale); - - auto const roundedLoanState = constructLoanState( - state.totalValue, state.principalOutstanding, state.managementFeeOutstanding); - Number const totalInterestOutstanding = roundedLoanState.interestDue; - - auto const periodicRate = loanPeriodicRate(interestRateValue, state.paymentInterval); - auto const rawLoanState = computeTheoreticalLoanState( - env.current()->rules(), - state.periodicPayment, - periodicRate, - state.paymentRemaining, - managementFeeRate); - - auto const parentCloseTime = env.current()->parentCloseTime(); - auto const startDateSeconds = - static_cast(state.startDate.time_since_epoch().count()); - - Number const fullPaymentInterest = computeFullPaymentInterest( - rawLoanState.principalOutstanding, - periodicRate, - parentCloseTime, - state.paymentInterval, - state.previousPaymentDate, - startDateSeconds, - closeInterestRateValue); - - Number const roundedFullInterestAmount = - roundToAsset(broker.asset, fullPaymentInterest, state.loanScale); - Number const roundedFullManagementFee = computeManagementFee( - broker.asset, roundedFullInterestAmount, managementFeeRate, state.loanScale); - Number const roundedFullInterest = roundedFullInterestAmount - roundedFullManagementFee; - - Number const trackedValueDelta = - state.principalOutstanding + totalInterestOutstanding + state.managementFeeOutstanding; - Number const untrackedManagementFee = - closePaymentFeeRounded + roundedFullManagementFee - state.managementFeeOutstanding; - Number const untrackedInterest = roundedFullInterest - totalInterestOutstanding; - - Number const baseFullDue = trackedValueDelta + untrackedInterest + untrackedManagementFee; - BEAST_EXPECT(baseFullDue == roundToAsset(broker.asset, baseFullDue, state.loanScale)); - - auto const overdueSeconds = - parentCloseTime.time_since_epoch().count() - state.nextPaymentDate; - if (!BEAST_EXPECT(overdueSeconds > 0)) - return; - - Number const overdueRate = loanPeriodicRate(lateInterestRateValue, overdueSeconds); - Number const lateInterestRaw = state.principalOutstanding * overdueRate; - Number const lateInterestRounded = - roundToAsset(broker.asset, lateInterestRaw, state.loanScale); - Number const lateManagementFeeRounded = computeManagementFee( - broker.asset, lateInterestRounded, managementFeeRate, state.loanScale); - Number const penaltyDue = - lateInterestRounded + lateManagementFeeRounded + latePaymentFeeRounded; - BEAST_EXPECT(penaltyDue > Number{}); - - auto const balanceBefore = env.balance(borrower, broker.asset).number(); - - STAmount const paymentAmount{broker.asset.raw(), baseFullDue}; - env(pay(borrower, loanKeylet.key, paymentAmount, tfLoanFullPayment)); - env.close(); - - if (auto const meta = env.meta(); BEAST_EXPECT(meta)) - BEAST_EXPECT(meta->at(sfTransactionResult) == tesSUCCESS); - - auto const balanceAfter = env.balance(borrower, broker.asset).number(); - Number const actualPaid = balanceBefore - balanceAfter; - BEAST_EXPECT(actualPaid == baseFullDue); - - Number const expectedWithPenalty = baseFullDue + penaltyDue; - BEAST_EXPECT(expectedWithPenalty > actualPaid); - BEAST_EXPECT(expectedWithPenalty - actualPaid == penaltyDue); - } - - void - testLoanCoverMinimumRoundingExploit(FeatureBitset features) - { - auto testLoanCoverMinimumRoundingExploit = [&, this](Number const& principalRequest) { - testcase << "LoanBrokerCoverClawback drains cover via rounding" - << " principalRequested=" << to_string(principalRequest); - - using namespace jtx; - using namespace loan; - using namespace loanBroker; - - Env env(*this, features); - - Account const issuer{"issuer"}; - Account const lender{"lender"}; - Account const borrower{"borrower"}; - - env.fund(XRP(1'000'000'000), issuer, lender, borrower); - env.close(); - - env(fset(issuer, asfAllowTrustLineClawback)); - env.close(); - - PrettyAsset const asset = issuer[iouCurrency]; - env(trust(lender, asset(2'000'0000))); - env(trust(borrower, asset(2'000'0000))); - env.close(); - - env(pay(issuer, lender, asset(2'000'0000))); - env.close(); - - BrokerParameters brokerParams{.debtMax = 0, .coverRateMin = TenthBips32{10'000}}; - BrokerInfo broker{createVaultAndBroker(env, asset, lender, brokerParams)}; - - auto const loanSetFee = Fee(env.current()->fees().base * 2); - auto createTx = env.jt( - set(borrower, broker.brokerID, principalRequest), - Sig(sfCounterpartySignature, lender), - loanSetFee, - kPaymentInterval(600), - kPaymentTotal(1), - kGracePeriod(60)); - env(createTx); - env.close(); - - auto const brokerBefore = env.le(keylet::loanBroker(broker.brokerID)); - BEAST_EXPECT(brokerBefore); - if (!brokerBefore) - return; - - Number const debtOutstanding = brokerBefore->at(sfDebtTotal); - Number const coverAvailableBefore = brokerBefore->at(sfCoverAvailable); - - BEAST_EXPECT(debtOutstanding > Number{}); - BEAST_EXPECT(coverAvailableBefore > Number{}); - - log << "debt=" << to_string(debtOutstanding) - << " cover_available=" << to_string(coverAvailableBefore); - - env(coverClawback(issuer, 0), loanBrokerID(broker.brokerID)); - env.close(); - - auto const brokerAfter = env.le(keylet::loanBroker(broker.brokerID)); - BEAST_EXPECT(brokerAfter); - if (!brokerAfter) - return; - - Number const debtAfter = brokerAfter->at(sfDebtTotal); - // the debt has not changed - BEAST_EXPECT(debtAfter == debtOutstanding); - - Number const coverAvailableAfter = brokerAfter->at(sfCoverAvailable); - - // since the cover rate min != 0, the cover available should not - // be zero - BEAST_EXPECT(coverAvailableAfter != Number{}); - }; - - // Call the lambda with different principal values - testLoanCoverMinimumRoundingExploit(Number{1, -30}); // 1e-30 units - testLoanCoverMinimumRoundingExploit(Number{1, -20}); // 1e-20 units - testLoanCoverMinimumRoundingExploit(Number{1, -10}); // 1e-10 units - testLoanCoverMinimumRoundingExploit(Number{1, 1}); // 1e-10 units - } -#endif - - void - testPoCUnsignedUnderflowOnFullPayAfterEarlyPeriodic(FeatureBitset features) - { - // --- PoC Summary ---------------------------------------------------- - // Scenario: Borrower makes one periodic payment early (before next due) - // so doPayment sets sfPreviousPaymentDueDate to the (future) - // sfNextPaymentDueDate and advances sfNextPaymentDueDate by one - // interval. Borrower then immediately performs a full-payment - // (tfLoanFullPayment). Why it matters: Full-payment interest accrual - // uses - // delta = now - max(prevPaymentDate, startDate) - // with an unsigned clock representation (uint32). If prevPaymentDate is - // in the future, the subtraction underflows to a very large positive - // number. This inflates roundedFullInterest and total full-close due, - // and LoanPay applies the inflated valueChange to the vault - // (sfAssetsTotal), increasing NAV. - // -------------------------------------------------------------------- - testcase("PoC: Unsigned-underflow full-pay accrual after early periodic"); - - using namespace jtx; - using namespace loan; - using namespace std::chrono_literals; - - Env env{*this, features}; - - Account const lender{"poc_lender4"}; - Account const borrower{"poc_borrower4"}; - env.fund(XRP(3'000'000), lender, borrower); - env.close(); - - PrettyAsset const asset{xrpIssue(), 1'000'000}; - BrokerParameters const brokerParams{}; - auto const broker = createVaultAndBroker(env, asset, lender, brokerParams); - - // Create a 3-payment loan so full-payment path is enabled after 1 - // periodic payment. - auto const loanSetFee = Fee(env.current()->fees().base * 2); - Number const principalRequest = asset(1000).value(); - auto const originationFee = asset(0).value(); - auto const serviceFee = asset(1).value(); - auto const serviceFeePA = asset(1); - auto const lateFee = asset(0).value(); - auto const closeFee = asset(0).value(); - auto const interest = percentageToTenthBips(12); - auto const lateInterest = percentageToTenthBips(12) / 10; - auto const closeInterest = percentageToTenthBips(12) / 10; - auto const overpaymentInterest = percentageToTenthBips(12) / 10; - auto const total = 3u; - auto const interval = 600u; - auto const grace = 60u; - - auto createJtx = env.jt( - set(borrower, broker.brokerID, principalRequest, 0), - Sig(sfCounterpartySignature, lender), - kLoanOriginationFee(originationFee), - kLoanServiceFee(serviceFee), - kLatePaymentFee(lateFee), - kClosePaymentFee(closeFee), - kOverpaymentFee(percentageToTenthBips(5) / 10), - kInterestRate(interest), - kLateInterestRate(lateInterest), - kCloseInterestRate(closeInterest), - kOverpaymentInterestRate(overpaymentInterest), - kPaymentTotal(total), - kPaymentInterval(interval), - kGracePeriod(grace), - Fee(loanSetFee)); - - auto const brokerSle = env.le(keylet::loanBroker(broker.brokerID)); - BEAST_EXPECT(brokerSle); - auto const loanSequence = brokerSle ? brokerSle->at(sfLoanSequence) : 0; - auto const loanKeylet = keylet::loan(broker.brokerID, loanSequence); - - env(createJtx); - env.close(); - - // Compute a regular periodic due and pay it early (before next due). - auto state = getCurrentState(env, broker, loanKeylet); - Number const periodicRate = loanPeriodicRate(state.interestRate, state.paymentInterval); - auto const components = xrpl::detail::computePaymentComponents( - env.current()->rules(), - asset.raw(), - state.loanScale, - state.totalValue, - state.principalOutstanding, - state.managementFeeOutstanding, - state.periodicPayment, - periodicRate, - state.paymentRemaining, - brokerParams.managementFeeRate); - STAmount const regularDue{asset, components.trackedValueDelta + serviceFeePA.number()}; - // now < nextDue immediately after creation, so this is an early pay. - env(pay(borrower, loanKeylet.key, regularDue)); - env.close(); - - // Immediately attempt a full payoff. Compute the exact full-payment - // due to ensure the tx applies. - auto after = getCurrentState(env, broker, loanKeylet); - auto const loanSle = env.le(loanKeylet); - BEAST_EXPECT(loanSle); - auto const brokerSle2 = env.le(keylet::loanBroker(broker.brokerID)); - BEAST_EXPECT(brokerSle2); - - auto const closePaymentFee = loanSle ? loanSle->at(sfClosePaymentFee) : Number{}; - auto const closeInterestRate = - loanSle ? TenthBips32{loanSle->at(sfCloseInterestRate)} : TenthBips32{}; - auto const managementFeeRate = - brokerSle2 ? TenthBips16{brokerSle2->at(sfManagementFeeRate)} : TenthBips16{}; - - Number const periodicRate2 = loanPeriodicRate(after.interestRate, after.paymentInterval); - // Accrued + prepayment-penalty interest based on current periodic - // schedule - auto const fullPaymentInterest = computeFullPaymentInterest( - xrpl::detail::loanPrincipalFromPeriodicPayment( - env.current()->rules(), - after.periodicPayment, - periodicRate2, - after.paymentRemaining), - periodicRate2, - env.current()->parentCloseTime(), - after.paymentInterval, - after.previousPaymentDate, - static_cast(after.startDate.time_since_epoch().count()), - closeInterestRate); - - // Round to asset scale and split interest/fee parts - auto const roundedInterest = - roundToAsset(asset.raw(), fullPaymentInterest, after.loanScale); - Number const roundedFullMgmtFee = - computeManagementFee(asset.raw(), roundedInterest, managementFeeRate, after.loanScale); - Number const roundedFullInterest = roundedInterest - roundedFullMgmtFee; - - // Show both signed and unsigned deltas to highlight the underflow. - auto const nowSecs = - static_cast(env.current()->parentCloseTime().time_since_epoch().count()); - auto const startSecs = - static_cast(after.startDate.time_since_epoch().count()); - auto const lastPaymentDate = std::max(after.previousPaymentDate, startSecs); - auto const signedDelta = - static_cast(nowSecs) - static_cast(lastPaymentDate); - auto const unsignedDelta = static_cast(nowSecs - lastPaymentDate); - log << "PoC window: prev=" << after.previousPaymentDate << " start=" << startSecs - << " now=" << nowSecs << " signedDelta=" << signedDelta - << " unsignedDelta=" << unsignedDelta << std::endl; - - // Reference (clamped) computation: emulate a non-negative accrual - // window by clamping prevPaymentDate to 'now' for the full-pay path. - auto const prevClamped = std::min(after.previousPaymentDate, nowSecs); - auto const fullPaymentInterestClamped = computeFullPaymentInterest( - xrpl::detail::loanPrincipalFromPeriodicPayment( - env.current()->rules(), - after.periodicPayment, - periodicRate2, - after.paymentRemaining), - periodicRate2, - env.current()->parentCloseTime(), - after.paymentInterval, - prevClamped, - startSecs, - closeInterestRate); - auto const roundedInterestClamped = - roundToAsset(asset.raw(), fullPaymentInterestClamped, after.loanScale); - Number const roundedFullMgmtFeeClamped = computeManagementFee( - asset.raw(), roundedInterestClamped, managementFeeRate, after.loanScale); - Number const roundedFullInterestClamped = - roundedInterestClamped - roundedFullMgmtFeeClamped; - STAmount const fullDueClamped{ - asset, - after.principalOutstanding + roundedFullInterestClamped + roundedFullMgmtFeeClamped + - closePaymentFee}; - - // Collect vault NAV before closing payment - auto const vaultId2 = brokerSle2 ? brokerSle2->at(sfVaultID) : uint256{}; - auto const vaultKey2 = keylet::vault(vaultId2); - auto const vaultBefore = env.le(vaultKey2); - BEAST_EXPECT(vaultBefore); - Number const assetsTotalBefore = vaultBefore ? vaultBefore->at(sfAssetsTotal) : Number{}; - - STAmount const fullDue{ - asset, - after.principalOutstanding + roundedFullInterest + roundedFullMgmtFee + - closePaymentFee}; - - log << "PoC payoff: principalOutstanding=" << after.principalOutstanding - << " roundedFullInterest=" << roundedFullInterest - << " roundedFullMgmtFee=" << roundedFullMgmtFee << " closeFee=" << closePaymentFee - << " fullDue=" << to_string(fullDue.getJson()) << std::endl; - log << "PoC reference (clamped): roundedFullInterestClamped=" << roundedFullInterestClamped - << " roundedFullMgmtFeeClamped=" << roundedFullMgmtFeeClamped - << " fullDueClamped=" << to_string(fullDueClamped.getJson()) << std::endl; - - env(pay(borrower, loanKeylet.key, fullDue), Txflags(tfLoanFullPayment)); - env.close(); - - // Sanity: underflow present (unsigned delta very large relative to - // interval) - BEAST_EXPECT(unsignedDelta > after.paymentInterval); - - // Compare vault NAV before/after the full close - auto const vaultAfter = env.le(vaultKey2); - BEAST_EXPECT(vaultAfter); - if (vaultAfter) - { - auto const assetsTotalAfter = vaultAfter->at(sfAssetsTotal); - log << "PoC NAV: assetsTotalBefore=" << assetsTotalBefore - << " assetsTotalAfter=" << assetsTotalAfter - << " delta=" << (assetsTotalAfter - assetsTotalBefore) << std::endl; - - // Value-based proof: underflowed window yields a payoff larger than - // the clamped (non-underflow) reference. - BEAST_EXPECT(fullDue == fullDueClamped); - if (fullDue > fullDueClamped) - log << "PoC delta: overcharge (fullDue > clamped)" << std::endl; - } - - // Loan should be paid off - auto const finalLoan = env.le(loanKeylet); - BEAST_EXPECT(finalLoan); - if (finalLoan) - { - BEAST_EXPECT(finalLoan->at(sfPaymentRemaining) == 0); - BEAST_EXPECT(finalLoan->at(sfPrincipalOutstanding) == 0); - } - } - - void - testDustManipulation(FeatureBitset features) - { - testcase("Dust manipulation"); - - using namespace jtx; - using namespace std::chrono_literals; - Env env{*this, features}; - - // Setup: Create accounts - Account const issuer{"issuer"}; - Account const lender{"lender"}; - Account const borrower{"borrower"}; - Account const victim{"victim"}; - - env.fund(XRP(1'000'000'00), issuer, lender, borrower, victim); - env.close(); - - // Step 1: Create vault with IOU asset - auto asset = issuer["USD"]; - env(trust(lender, asset(100000))); - env(trust(borrower, asset(100000))); - env(trust(victim, asset(100000))); - env(pay(issuer, lender, asset(50000))); - env(pay(issuer, borrower, asset(50000))); - env(pay(issuer, victim, asset(50000))); - env.close(); - - BrokerParameters const brokerParams{ - .vaultDeposit = 10000, - .debtMax = Number{0}, - .coverRateMin = TenthBips32{1000}, - .coverRateLiquidation = TenthBips32{2500}}; - - auto broker = createVaultAndBroker(env, asset, lender, brokerParams); - - auto const loanKeyletOpt = [&]() -> std::optional { - auto const brokerSle = env.le(keylet::loanBroker(broker.brokerID)); - if (!BEAST_EXPECT(brokerSle)) - return std::nullopt; - - // Broker has no loans - BEAST_EXPECT(brokerSle->at(sfOwnerCount) == 0); - - // The loan keylet is based on the LoanSequence of the - // _LOAN_BROKER_ object. - auto const loanSequence = brokerSle->at(sfLoanSequence); - return keylet::loan(broker.brokerID, loanSequence); - }(); - if (!loanKeyletOpt) - return; - - auto const& vaultKeylet = broker.vaultKeylet(); - - { - auto const vaultSle = env.le(vaultKeylet); - Number const assetsTotal = vaultSle->at(sfAssetsTotal); - Number const assetsAvail = vaultSle->at(sfAssetsAvailable); - - log << "Before loan creation:" << std::endl; - log << " AssetsTotal: " << assetsTotal << std::endl; - log << " AssetsAvailable: " << assetsAvail << std::endl; - log << " Difference: " << (assetsTotal - assetsAvail) << std::endl; - - // before the loan the assets total and available should be equal - BEAST_EXPECT(assetsAvail == assetsTotal); - BEAST_EXPECT(assetsAvail == broker.asset(brokerParams.vaultDeposit).number()); - } - - Keylet const& loanKeylet = *loanKeyletOpt; - - LoanParameters const loanParams{ - .account = lender, - .counter = borrower, - .principalRequest = Number{100}, - .interest = TenthBips32{1922}, - .payTotal = 5816, - .payInterval = 86400 * 6, - .gracePd = 86400 * 5, - }; - - env(loanParams(env, broker)); - env.close(); - - // Wait for loan to be late enough to default - env.close(std::chrono::seconds(86400 * 40)); // 40 days - - { - auto const vaultSle = env.le(vaultKeylet); - Number const assetsTotal = vaultSle->at(sfAssetsTotal); - Number const assetsAvail = vaultSle->at(sfAssetsAvailable); - - log << "After loan creation:" << std::endl; - log << " AssetsTotal: " << assetsTotal << std::endl; - log << " AssetsAvailable: " << assetsAvail << std::endl; - log << " Difference: " << (assetsTotal - assetsAvail) << std::endl; - - auto const loanSle = env.le(loanKeylet); - if (!BEAST_EXPECT(loanSle)) - return; - auto const state = constructLoanState(loanSle); - - log << "Loan state:" << std::endl; - log << " ValueOutstanding: " << state.valueOutstanding << std::endl; - log << " PrincipalOutstanding: " << state.principalOutstanding << std::endl; - log << " InterestOutstanding: " << state.interestOutstanding() << std::endl; - log << " InterestDue: " << state.interestDue << std::endl; - log << " FeeDue: " << state.managementFeeDue << std::endl; - - // after loan creation the assets total and available should - // reflect the value of the loan - BEAST_EXPECT(assetsAvail < assetsTotal); - BEAST_EXPECT( - assetsAvail == - broker.asset(brokerParams.vaultDeposit - loanParams.principalRequest).number()); - BEAST_EXPECT( - assetsTotal == - broker.asset(brokerParams.vaultDeposit + state.interestDue).number()); - } - - // Step 7: Trigger default (dust adjustment will occur) - env(jtx::loan::manage(lender, loanKeylet.key, tfLoanDefault)); - env.close(); - - // Step 8: Verify phantom assets created - { - auto const vaultSle2 = env.le(vaultKeylet); - Number const assetsTotal2 = vaultSle2->at(sfAssetsTotal); - Number const assetsAvail2 = vaultSle2->at(sfAssetsAvailable); - - log << "After default:" << std::endl; - log << " AssetsTotal: " << assetsTotal2 << std::endl; - log << " AssetsAvailable: " << assetsAvail2 << std::endl; - log << " Difference: " << (assetsTotal2 - assetsAvail2) << std::endl; - - // after a default the assets total and available should be equal - BEAST_EXPECT(assetsAvail2 == assetsTotal2); - } - } - - void - testRoundingAllowsUndercoverage(FeatureBitset features) - { - testcase("Minimum cover rounding allows undercoverage (XRP)"); - - using namespace jtx; - using namespace loanBroker; - - Env env{*this, features}; - - Account const lender{"lender"}; - Account const borrower{"borrower"}; - - env.fund(XRP(200'000), lender, borrower); - env.close(); - - // Vault with XRP asset - Vault const vault{env}; - auto [vaultCreate, vaultKeylet] = vault.create({.owner = lender, .asset = xrpIssue()}); - env(vaultCreate); - env.close(); - BEAST_EXPECT(env.le(vaultKeylet)); - - // Seed the vault with XRP so it can fund the loan principal - PrettyAsset const xrpAsset{xrpIssue(), 1}; - - BrokerParameters const brokerParams{ - .vaultDeposit = 1'000, - .debtMax = Number{0}, - .coverRateMin = TenthBips32{10'000}, - .coverDeposit = 82, - }; - - auto const brokerInfo = createVaultAndBroker(env, xrpAsset, lender, brokerParams); - // Create a loan with principal 804 XRP and 0% interest (so - // DebtTotal increases by exactly 804) - env(loan::set(borrower, brokerInfo.brokerID, xrpAsset(804).value()), - loan::kInterestRate(TenthBips32(0)), - Sig(sfCounterpartySignature, lender), - Fee(env.current()->fees().base * 2)); - BEAST_EXPECT(env.ter() == tesSUCCESS); - env.close(); - - // Verify DebtTotal is exactly 804 - if (auto const brokerSle = env.le(keylet::loanBroker(brokerInfo.brokerID)); - BEAST_EXPECT(brokerSle)) - { - log << *brokerSle << std::endl; - BEAST_EXPECT(brokerSle->at(sfDebtTotal) == Number(804)); - } - - // Attempt to withdraw 2 XRP to self, leaving 80 XRP CoverAvailable. - // The minimum is 80.4 XRP, which rounds up to 81 XRP, so this fails. - env(coverWithdraw(lender, brokerInfo.brokerID, xrpAsset(2).value()), - Ter(tecINSUFFICIENT_FUNDS)); - BEAST_EXPECT(env.ter() == tecINSUFFICIENT_FUNDS); - env.close(); - - // Attempt to withdraw 1 XRP to self, leaving 81 XRP CoverAvailable. - // because that leaves sufficient cover, this succeeds - env(coverWithdraw(lender, brokerInfo.brokerID, xrpAsset(1).value())); - BEAST_EXPECT(env.ter() == tesSUCCESS); - env.close(); - - // Validate CoverAvailable == 80 XRP and DebtTotal remains 804 - if (auto const brokerSle = env.le(keylet::loanBroker(brokerInfo.brokerID)); - BEAST_EXPECT(brokerSle)) - { - log << *brokerSle << std::endl; - BEAST_EXPECT(brokerSle->at(sfCoverAvailable) == xrpAsset(81).value()); - BEAST_EXPECT(brokerSle->at(sfDebtTotal) == Number(804)); - - // Also demonstrate that the true minimum (804 * 10%) exceeds 80 - auto const theoreticalMin = tenthBipsOfValue(Number(804), TenthBips32(10'000)); - log << "Theoretical min cover: " << theoreticalMin << std::endl; - BEAST_EXPECT(Number(804, -1) == theoreticalMin); - } - } - - void - testSequentialFLCDepletion(FeatureBitset features) - { - testcase << "First-Loss Capital Depletion on Sequential Defaults"; - - using namespace jtx; - using namespace loan; - using namespace loanBroker; - - Env env{*this, features}; - - Account const issuer{"issuer"}; - Account const lender{"lender"}; - Account const borrowerA{"borrowerA"}; - Account const borrowerB{"borrowerB"}; - - env.fund(XRP(1'000'000), issuer, lender, borrowerA, borrowerB); - env.close(); - - PrettyAsset const asset = xrpIssue(); - auto const vaultDepositAmount = - asset(200'000); // Enough for 2 x 50k loans plus interest/fees - - auto const brokerInfo = createVaultAndBroker( - env, - asset, - lender, - { - .vaultDeposit = vaultDepositAmount.value(), - .debtMax = 0, - .coverRateMin = TenthBips32(20000), // 20% - .coverDeposit = 21'000, - .managementFeeRate = TenthBips16(100), // 0.1% - .coverRateLiquidation = TenthBips32(100000), - }); - auto const brokerKeylet = brokerInfo.brokerKeylet(); - - // Create two identical loans: each 50,000 XRP principal (scaled down to - // avoid funding issues) Total DebtTotal will be ~100,000 XRP (principal - // + interest) Formula will calculate cover as: 100% × (20% × 100,000) = - // 20,000 XRP So we need FLC = 20,000 XRP to be fully consumed by first - // default - auto const principalAmount = Number(50'000); - auto const loanPaymentInterval = 2592000; // 30 days - auto const loanGracePeriod = 604800; // 7 days - - // Create Loan A - auto loanATx = env.jt( - set(borrowerA, brokerKeylet.key, principalAmount), - Sig(sfCounterpartySignature, lender), - kInterestRate(TenthBips32(500)), // 5% - kPaymentTotal(12), - loan::kPaymentInterval(loanPaymentInterval), - loan::kGracePeriod(loanGracePeriod), - Fee(XRP(10))); // Sufficient fee for multi-sig transaction - env(loanATx); - env.close(); - - auto const loanAKeylet = keylet::loan(brokerKeylet.key, 1); - - // Create Loan B - auto loanBTx = env.jt( - set(borrowerB, brokerKeylet.key, principalAmount), - Sig(sfCounterpartySignature, lender), - kInterestRate(TenthBips32(500)), // 5% - kPaymentTotal(12), - loan::kPaymentInterval(loanPaymentInterval), - loan::kGracePeriod(loanGracePeriod), - Fee(XRP(10))); // Sufficient fee for multi-sig transaction - env(loanBTx); - env.close(); - - auto const loanBKeylet = keylet::loan(brokerKeylet.key, 2); - - auto loanASle = env.le(loanAKeylet); - if (!BEAST_EXPECT(loanASle)) - return; - - // Advance time past grace period for both loans to be defaultable - auto const loanANextDue = loanASle->at(sfNextPaymentDueDate); - auto const loanAGrace = loanASle->at(sfGracePeriod); - env.close(std::chrono::seconds{loanANextDue + loanAGrace + 60}); - - env(manage(lender, loanAKeylet.key, tfLoanDefault), Ter(tesSUCCESS)); - env.close(); - - // Verify Loan A is defaulted - loanASle = env.le(loanAKeylet); - if (!BEAST_EXPECT(loanASle)) - return; - BEAST_EXPECT(loanASle->isFlag(lsfLoanDefault)); - BEAST_EXPECT(loanASle->at(sfPaymentRemaining) == 0); - - // Check broker state after first default (from committed ledger) - auto brokerSle = env.le(brokerKeylet); - if (!BEAST_EXPECT(brokerSle)) - return; - auto const afterFirstDebtTotal = brokerSle->at(sfDebtTotal); - auto const afterFirstCoverAvailable = brokerSle->at(sfCoverAvailable); - - // DebtTotal should have decreased by Loan A's debt - BEAST_EXPECT(afterFirstDebtTotal == 50'134); - - // CoverAvailable should have decreased significantly - BEAST_EXPECT(afterFirstCoverAvailable == 946); - - env(manage(lender, loanBKeylet.key, tfLoanDefault), Ter(tesSUCCESS)); - - brokerSle = env.le(brokerKeylet); - if (!BEAST_EXPECT(brokerSle)) - return; - auto const afterSecondDebtTotal = brokerSle->at(sfDebtTotal); - auto const afterSecondCoverAvailable = brokerSle->at(sfCoverAvailable); - - BEAST_EXPECT(afterSecondDebtTotal == 0); - - BEAST_EXPECT(afterSecondCoverAvailable == 0); - } - - void - testYieldTheftRounding(std::uint32_t flags) - { - testcase("Rounding manipulation does not permit yield theft"); - using namespace jtx; - using namespace loan; - - // 1. Setup Environment - Env env(*this, all_); - Account const issuer{"issuer"}; - Account const lender{"lender"}; - Account const borrower{"borrower"}; - - env.fund(XRP(1000), issuer, lender, borrower); - env.close(); - - // 2. Asset Selection - PrettyAsset const iou = issuer["USD"]; - env(trust(lender, iou(100'000'000))); - env(trust(borrower, iou(100'000'000))); - env(pay(issuer, lender, iou(100'000'000))); - env(pay(issuer, borrower, iou(100'000'000))); - env.close(); - - // 3. Create Vault and Broker with High Debt Limit (100M) - auto const brokerInfo = createVaultAndBroker( - env, - iou, - lender, - { - .vaultDeposit = 5'000'000, - .debtMax = Number{100'000'000}, - .coverDeposit = 500'000, - }); - auto const [currentSeq, vaultKeylet] = [&]() { - auto const brokerSle = env.le(keylet::loanBroker(brokerInfo.brokerID)); - if (!BEAST_EXPECT(brokerSle)) - return std::make_tuple(0u, keylet::unchecked(beast::kZero)); - auto const currentSeq = brokerSle->at(sfLoanSequence); - auto const vaultKeylet = keylet::vault(brokerSle->at(sfVaultID)); - return std::make_tuple(currentSeq, vaultKeylet); - }(); - - // 4. Loan Parameters (Attack Vector) - Number const principal = 1'000'000; - TenthBips32 const interestRate = TenthBips32{1}; // 0.001% - std::uint32_t const paymentInterval = 86400; - std::uint32_t const paymentTotal = 3650; - - auto const loanSetFee = Fee(env.current()->fees().base * 2); - env(set(borrower, brokerInfo.brokerID, iou(principal).value(), flags), - Sig(sfCounterpartySignature, lender), - loan::kInterestRate(interestRate), - loan::kPaymentInterval(paymentInterval), - loan::kPaymentTotal(paymentTotal), - Fee(loanSetFee)); - env.close(); - - // --- RETRIEVE OBJECTS & SETUP ATTACK --- - - auto borrowerBalance = [&]() { return env.balance(borrower, iou); }; - auto const borrowerScale = static_cast(borrowerBalance()).exponent(); - - auto const loanKeylet = keylet::loan(brokerInfo.brokerID, currentSeq); - auto const maybePeriodicPayment = [&]() -> std::optional { - auto const loanSle = env.le(loanKeylet); - if (!BEAST_EXPECT(loanSle)) - return std::nullopt; - // Construct Payment - return STAmount{iou, loanSle->at(sfPeriodicPayment)}; - }(); - if (!maybePeriodicPayment) - return; - auto const periodicPayment = *maybePeriodicPayment; - auto const roundedPayment = - roundToScale(periodicPayment, borrowerScale, Number::RoundingMode::Upward); - - // ATTACK: Add dust buffer (1e-9) to force 'excess' logic execution - STAmount const paymentBuffer{iou, Number(1, -9)}; - STAmount const attackPayment = periodicPayment + paymentBuffer; - - auto const maybeInitialVaultAssets = [&]() -> std::optional { - auto const vault = env.le(vaultKeylet); - if (!BEAST_EXPECT(vault)) - return std::nullopt; - return vault->at(sfAssetsTotal); - }(); - if (!maybeInitialVaultAssets) - return; - auto const initialVaultAssets = *maybeInitialVaultAssets; - - // 5. Execution Loop - int yieldTheftCount = 0; - auto previousAssetsTotal = initialVaultAssets; - - for (int i = 0; i < 100; ++i) - { - auto const balanceBefore = borrowerBalance(); - env(pay(borrower, loanKeylet.key, attackPayment, flags)); - env.close(); - auto const borrowerDelta = balanceBefore - borrowerBalance(); - BEAST_EXPECT(borrowerDelta.signum() == roundedPayment.signum()); - - auto const loanSle = env.le(loanKeylet); - if (!BEAST_EXPECT(loanSle)) - break; - auto const updatedPayment = STAmount{iou, loanSle->at(sfPeriodicPayment)}; - BEAST_EXPECT( - (roundToScale(updatedPayment, borrowerScale, Number::RoundingMode::Upward) == - roundedPayment)); - BEAST_EXPECT( - (updatedPayment == periodicPayment) || - (flags == tfLoanOverpayment && i >= 2 && updatedPayment < periodicPayment)); - - auto const currentVaultSle = env.le(vaultKeylet); - if (!BEAST_EXPECT(currentVaultSle)) - break; - - auto const currentAssetsTotal = currentVaultSle->at(sfAssetsTotal); - auto const delta = currentAssetsTotal - previousAssetsTotal; - - BEAST_EXPECT( - (delta == beast::kZero && borrowerDelta <= roundedPayment) || - (delta > beast::kZero && borrowerDelta > roundedPayment)); - - // If tx succeeded but Assets Total didn't change, interest was - // stolen. - if (delta == beast::kZero && borrowerDelta > roundedPayment) - { - yieldTheftCount++; - } - - previousAssetsTotal = currentAssetsTotal; - } - - BEAST_EXPECTS(yieldTheftCount == 0, std::to_string(yieldTheftCount)); - } - - // Tests that vault withdrawals work correctly when the vault has unrealized - // loss from an impaired loan, ensuring the invariant check properly - // accounts for the loss. - void - testWithdrawReflectsUnrealizedLoss(FeatureBitset features) - { - using namespace jtx; - using namespace loan; - using namespace std::chrono_literals; - - testcase("Vault withdraw reflects sfLossUnrealized"); - - // Test constants - static constexpr std::int64_t kInitialFunding = 1'000'000; - static constexpr std::int64_t kLenderInitialIou = 5'000'000; - static constexpr std::int64_t kDepositorInitialIou = 1'000'000; - static constexpr std::int64_t kBorrowerInitialIou = 100'000; - static constexpr std::int64_t kDepositAmount = 5'000; - static constexpr std::int64_t kPrincipalAmount = 99; - static constexpr std::uint64_t kExpectedSharesPerDepositor = 5'000'000'000; - static constexpr std::uint32_t kLocalPaymentInterval = 600; - static constexpr std::uint32_t kLocalPaymentTotal = 2; - - Env env{*this, features}; - - // Setup accounts - Account const issuer{"issuer"}; - Account const lender{"lender"}; - Account const depositorA{"lpA"}; - Account const depositorB{"lpB"}; - Account const borrower{"borrowerA"}; - - env.fund(XRP(kInitialFunding), issuer, lender, depositorA, depositorB, borrower); - env.close(); - - // Setup trust lines - PrettyAsset const iouAsset = issuer[iouCurrency_]; - env(trust(lender, iouAsset(10'000'000))); - env(trust(depositorA, iouAsset(10'000'000))); - env(trust(depositorB, iouAsset(10'000'000))); - env(trust(borrower, iouAsset(10'000'000))); - env.close(); - - // Fund accounts with IOUs - env(pay(issuer, lender, iouAsset(kLenderInitialIou))); - env(pay(issuer, depositorA, iouAsset(kDepositorInitialIou))); - env(pay(issuer, depositorB, iouAsset(kDepositorInitialIou))); - env(pay(issuer, borrower, iouAsset(kBorrowerInitialIou))); - env.close(); - - // Create vault and broker, then add deposits from two depositors - auto const broker = createVaultAndBroker(env, iouAsset, lender); - Vault v{env}; - - env(v.deposit({ - .depositor = depositorA, - .id = broker.vaultKeylet().key, - .amount = iouAsset(kDepositAmount), - }), - Ter(tesSUCCESS)); - env(v.deposit({ - .depositor = depositorB, - .id = broker.vaultKeylet().key, - .amount = iouAsset(kDepositAmount), - }), - Ter(tesSUCCESS)); - env.close(); - - // Create a loan - auto const sleBroker = env.le(keylet::loanBroker(broker.brokerID)); - if (!BEAST_EXPECT(sleBroker)) - return; - - auto const loanKeylet = keylet::loan(broker.brokerID, sleBroker->at(sfLoanSequence)); - - env(set(borrower, broker.brokerID, kPrincipalAmount), - Sig(sfCounterpartySignature, lender), - kPaymentTotal(kLocalPaymentTotal), - kPaymentInterval(kLocalPaymentInterval), - Fee(env.current()->fees().base * 2), - Ter(tesSUCCESS)); - env.close(); - - // Impair the loan to create unrealized loss - env(manage(lender, loanKeylet.key, tfLoanImpair), Ter(tesSUCCESS)); - env.close(); - - // Verify unrealized loss is recorded in the vault - auto const vaultAfterImpair = env.le(broker.vaultKeylet()); - if (!BEAST_EXPECT(vaultAfterImpair)) - return; - - BEAST_EXPECT( - vaultAfterImpair->at(sfLossUnrealized) == broker.asset(kPrincipalAmount).value()); - - // Helper to get share balance for a depositor - auto const shareAsset = vaultAfterImpair->at(sfShareMPTID); - auto const getShareBalance = [&](Account const& depositor) -> std::uint64_t { - auto const token = env.le(keylet::mptoken(shareAsset, depositor.id())); - return token ? token->getFieldU64(sfMPTAmount) : 0; - }; - - // Verify both depositors have equal shares - auto const sharesLpA = getShareBalance(depositorA); - auto const sharesLpB = getShareBalance(depositorB); - BEAST_EXPECT(sharesLpA == kExpectedSharesPerDepositor); - BEAST_EXPECT(sharesLpB == kExpectedSharesPerDepositor); - BEAST_EXPECT(sharesLpA == sharesLpB); - - // Helper to attempt withdrawal - auto const attemptWithdrawShares = [&](Account const& depositor, - std::uint64_t shareAmount, - TER expected) { - STAmount const shareAmt{MPTIssue{shareAsset}, Number(shareAmount)}; - env(v.withdraw( - {.depositor = depositor, .id = broker.vaultKeylet().key, .amount = shareAmt}), - Ter(expected)); - env.close(); - }; - - // Regression test: Both depositors should successfully withdraw despite - // unrealized loss. Previously failed with invariant violation: - // "withdrawal must change vault and destination balance by equal - // amount". This was caused by sharesToAssetsWithdraw rounding down, - // creating a mismatch where vaultDeltaAssets * -1 != destinationDelta - // when unrealized loss exists. - attemptWithdrawShares(depositorA, sharesLpA, tesSUCCESS); - attemptWithdrawShares(depositorB, sharesLpB, tesSUCCESS); - } - - void - runAmendmentIndependent() - { - for (auto const flags : {0u, tfLoanOverpayment}) - testYieldTheftRounding(flags); - } - - // Tests run under each entry in amendmentCombinations(). - void - runAmendmentSensitive(FeatureBitset features) - { -#if LOAN_TODO - testLoanPayLateFullPaymentBypassesPenalties(features); - testLoanCoverMinimumRoundingExploit(features); -#endif - testDosLoanPay(features); - testWithdrawReflectsUnrealizedLoss(features); - testPoCUnsignedUnderflowOnFullPayAfterEarlyPeriodic(features); - testLoanNextPaymentDueDateOverflow(features); - testSequentialFLCDepletion(features); - testLoanPayComputePeriodicPaymentInvariants(features); - testAccountSendMptMinAmountInvariant(features); - testLoanPayDebtDecreaseInvariant(features); - testDustManipulation(features); - testRoundingAllowsUndercoverage(features); - } - -public: - void - run() override - { - runAmendmentIndependent(); - for (auto const& features : jtx::amendmentCombinations( - {fixCleanup3_1_3, fixCleanup3_2_0, featureMPTokensV2}, all_)) - runAmendmentSensitive(features); - } -}; - -BEAST_DEFINE_TESTSUITE(LoanInvariant, tx, xrpl); - -} // namespace xrpl::test diff --git a/src/test/app/LoanInvariants_test.cpp b/src/test/app/LoanInvariants_test.cpp new file mode 100644 index 00000000000..0b895d03422 --- /dev/null +++ b/src/test/app/LoanInvariants_test.cpp @@ -0,0 +1,847 @@ +#include + +namespace xrpl::test { + +class LoanInvariants_test : public LoanTestBase +{ +private: + // Each of these regression tests reproduces a single fuzzer-found (FIND-*) + // scenario against xrpl::detail::computePeriodicPayment / + // loanComputePaymentParts. They're merged into one function, one block + // per finding, because each is a narrow, self-contained repro that + // shares little beyond the surrounding scaffold. + void + testLoanPayComputePeriodicPaymentInvariants(FeatureBitset features) + { + using namespace jtx; + using namespace std::chrono_literals; + using namespace Lending; + + // From FIND-012 + { + testcase << "LoanPay xrpl::detail::computePeriodicPayment : " + "valid rate"; + + Env env(*this, features); + + Account const issuer{"issuer"}; + Account const lender{"lender"}; + Account const borrower{"borrower"}; + + BrokerParameters const brokerParams; + env.fund(XRP(brokerParams.vaultDeposit * 100), issuer, lender, borrower); + env.close(); + + PrettyAsset const xrpAsset{xrpIssue(), 1'000'000}; + BrokerInfo const broker{createVaultAndBroker(env, xrpAsset, lender, brokerParams)}; + + using namespace loan; + + auto const loanSetFee = Fee(env.current()->fees().base * 2); + Number const principalRequest{640562, -5}; + + Number const serviceFee{2462611968}; + std::uint32_t const numPayments{4294967295 / 800}; + + auto createJson = env.json( + set(borrower, broker.brokerID, principalRequest), + Fee(loanSetFee), + kLoanServiceFee(serviceFee), + kPaymentTotal(numPayments), + Json(sfCounterpartySignature, json::ValueType::Object)); + + createJson["CloseInterestRate"] = 55374; + createJson["ClosePaymentFee"] = "3825205248"; + createJson["LatePaymentFee"] = "237"; + createJson["LoanOriginationFee"] = "0"; + createJson["OverpaymentFee"] = 35167; + createJson["OverpaymentInterestRate"] = 1360; + createJson["PaymentInterval"] = 727; + + auto const keylet = nextLoanKeylet(env, broker); + + createJson = env.json(createJson, Sig(sfCounterpartySignature, lender)); + // Fails in preclaim because principal requested can't be + // represented as XRP + env(createJson, Ter(tecPRECISION_LOSS)); + env.close(); + + BEAST_EXPECT(!env.le(keylet)); + + Number const actualPrincipal{6}; + + createJson[sfPrincipalRequested] = actualPrincipal; + createJson.removeMember(sfSequence.jsonName); + createJson = env.json(createJson, Sig(sfCounterpartySignature, lender)); + // Fails in doApply because the payment is too small to be + // represented as XRP. + env(createJson, Ter(tecPRECISION_LOSS)); + env.close(); + } + + // From FIND-010 + { + testcase << "xrpl::loanComputePaymentParts : valid total interest"; + + Env env(*this, features); + + Account const issuer{"issuer"}; + Account const lender{"lender"}; + Account const borrower{"borrower"}; + + PrettyAsset const iouAsset = createFundedIouAsset(env, issuer, lender, borrower); + + BrokerInfo const broker{createVaultAndBroker(env, iouAsset, lender)}; + + using namespace loan; + + auto const loanSetFee = Fee(env.current()->fees().base * 2); + Number const principalRequest{1, 3}; + + auto createJson = env.json( + set(borrower, broker.brokerID, principalRequest), + Fee(loanSetFee), + Json(sfCounterpartySignature, json::ValueType::Object)); + + createJson["CloseInterestRate"] = 47299; + createJson["ClosePaymentFee"] = "3985819770"; + createJson["InterestRate"] = 92; + createJson["LatePaymentFee"] = "3866894865"; + createJson["LoanOriginationFee"] = "0"; + createJson["LoanServiceFee"] = "2348810240"; + createJson["OverpaymentFee"] = 58545; + createJson["PaymentInterval"] = 60; + createJson["PaymentTotal"] = 1; + createJson["PrincipalRequested"] = "0.000763058"; + + auto const keylet = nextLoanKeylet(env, broker); + + createJson = env.json(createJson, Sig(sfCounterpartySignature, lender)); + env(createJson); + env.close(); + + auto loanPayTx = env.json(pay(borrower, keylet.key, STAmount{broker.asset, Number{}})); + loanPayTx["Amount"]["value"] = "0.000281284125490196"; + env(loanPayTx, Ter(tecINSUFFICIENT_PAYMENT)); + env.close(); + } + + // From FIND-009 + { + testcase << "xrpl::loanComputePaymentParts : totalPrincipalPaid " + "rounded"; + + Env env(*this, features); + + Account const issuer{"issuer"}; + Account const lender{"lender"}; + Account const borrower{"borrower"}; + + PrettyAsset const iouAsset = createFundedIouAsset(env, issuer, lender, borrower); + + BrokerInfo const broker{createVaultAndBroker(env, iouAsset, lender)}; + + using namespace loan; + + auto const loanSetFee = Fee(env.current()->fees().base * 2); + Number const principalRequest{1, 3}; + + auto createJson = env.json( + set(borrower, broker.brokerID, principalRequest), + Fee(loanSetFee), + Json(sfCounterpartySignature, json::ValueType::Object)); + + createJson["ClosePaymentFee"] = "0"; + createJson["InterestRate"] = 24346; + createJson["LateInterestRate"] = 65535; + createJson["LatePaymentFee"] = "0"; + createJson["LoanOriginationFee"] = "218"; + createJson["LoanServiceFee"] = "0"; + createJson["PaymentInterval"] = 60; + createJson["PaymentTotal"] = 5678; + createJson["PrincipalRequested"] = "9924.81"; + + auto const keylet = nextLoanKeylet(env, broker); + + createJson = env.json(createJson, Sig(sfCounterpartySignature, lender)); + env(createJson, Ter(tesSUCCESS)); + env.close(); + + auto const baseFee = env.current()->fees().base; + + auto const stateBefore = getCurrentState(env, broker, keylet); + + { + auto loanPayTx = + env.json(pay(borrower, keylet.key, STAmount{broker.asset, Number{}})); + Number const amount{3074'745'058'823'529, -12}; + BEAST_EXPECT(to_string(amount) == "3074.745058823529"); + XRPAmount const payFee{ + baseFee * + (amount / stateBefore.periodicPayment / kLoanPaymentsPerFeeIncrement + 1)}; + loanPayTx["Amount"]["value"] = to_string(amount); + env(loanPayTx, Fee(payFee), Ter(tesSUCCESS)); + env.close(); + } + + { + auto loanPayTx = + env.json(pay(borrower, keylet.key, STAmount{broker.asset, Number{}})); + Number const amount{6732'118'170'944'051, -12}; + BEAST_EXPECT(to_string(amount) == "6732.118170944051"); + XRPAmount const payFee{ + baseFee * + (amount / stateBefore.periodicPayment / kLoanPaymentsPerFeeIncrement + 1)}; + loanPayTx["Amount"]["value"] = to_string(amount); + env(loanPayTx, Fee(payFee), Ter(tesSUCCESS)); + env.close(); + } + + auto const stateAfter = getCurrentState(env, broker, keylet); + // Total interest outstanding is non-negative + BEAST_EXPECT(stateAfter.totalValue >= stateAfter.principalOutstanding); + // Principal paid is non-negative + BEAST_EXPECT(stateBefore.principalOutstanding >= stateAfter.principalOutstanding); + // Total value change is non-negative + BEAST_EXPECT(stateBefore.totalValue >= stateAfter.totalValue); + // Value delta is larger or same as principal delta (meaning + // non-negative interest paid) + BEAST_EXPECT( + (stateBefore.totalValue - stateAfter.totalValue) >= + (stateBefore.principalOutstanding - stateAfter.principalOutstanding)); + } + + // From FIND-008 + { + testcase << "xrpl::loanComputePaymentParts : loanValueChange rounded"; + + Env env(*this, features); + + Account const issuer{"issuer"}; + Account const lender{"lender"}; + Account const borrower{"borrower"}; + + PrettyAsset const iouAsset = + createFundedIouAsset(env, issuer, lender, borrower, 100'000'000, 10'000'000); + + BrokerInfo const broker{createVaultAndBroker(env, iouAsset, lender)}; + { + auto const coverDepositValue = + broker.asset(broker.params.coverDeposit * 10).value(); + env(loanBroker::coverDeposit(lender, broker.brokerID, coverDepositValue)); + env.close(); + } + + using namespace loan; + + auto const loanSetFee = Fee(env.current()->fees().base * 2); + Number const principalRequest{1, 3}; + + auto createJson = env.json( + set(borrower, broker.brokerID, principalRequest), + Fee(loanSetFee), + Json(sfCounterpartySignature, json::ValueType::Object)); + + createJson["ClosePaymentFee"] = "0"; + createJson["InterestRate"] = 12833; + createJson["LateInterestRate"] = 77048; + createJson["LatePaymentFee"] = "0"; + createJson["LoanOriginationFee"] = "218"; + createJson["LoanServiceFee"] = "0"; + createJson["PaymentInterval"] = 752; + createJson["PaymentTotal"] = 5678; + createJson["PrincipalRequested"] = "9924.81"; + + auto const keylet = nextLoanKeylet(env, broker); + + createJson = env.json(createJson, Sig(sfCounterpartySignature, lender)); + env(createJson, Ter(tesSUCCESS)); + env.close(); + + auto const baseFee = env.current()->fees().base; + + auto const stateBefore = getCurrentState(env, broker, keylet); + BEAST_EXPECT(stateBefore.paymentRemaining == 5678); + BEAST_EXPECT(stateBefore.paymentRemaining > kLoanMaximumPaymentsPerTransaction); + + auto loanPayTx = env.json(pay(borrower, keylet.key, STAmount{broker.asset, Number{}})); + Number const amount{9924'81, -2}; + BEAST_EXPECT(to_string(amount) == "9924.81"); + XRPAmount const payFee{ + baseFee * + (amount / stateBefore.periodicPayment / kLoanPaymentsPerFeeIncrement + 1)}; + loanPayTx["Amount"]["value"] = to_string(amount); + env(loanPayTx, Fee(payFee), Ter(tesSUCCESS)); + env.close(); + + auto const stateAfter = getCurrentState(env, broker, keylet); + BEAST_EXPECT( + stateAfter.paymentRemaining == + stateBefore.paymentRemaining - kLoanMaximumPaymentsPerTransaction); + } + } + + void + testLoanPayDebtDecreaseInvariant(FeatureBitset features) + { + // From FIND-007 + testcase << "LoanPay xrpl::LoanPay::doApply : debtDecrease " + "rounding good"; + + using namespace jtx; + using namespace std::chrono_literals; + using namespace Lending; + Env env(*this, features); + + Account const issuer{"issuer"}; + Account const lender{"lender"}; + Account const borrower{"borrower"}; + + PrettyAsset const iouAsset = createFundedIouAsset(env, issuer, lender, borrower); + + BrokerInfo broker{createVaultAndBroker(env, iouAsset, lender)}; + + using namespace loan; + + auto const baseFee = env.current()->fees().base; + auto const loanSetFee = Fee(baseFee * 2); + Number const principalRequest{1, 3}; + + auto createJson = env.json( + set(borrower, broker.brokerID, principalRequest), + Fee(loanSetFee), + Json(sfCounterpartySignature, json::ValueType::Object)); + + createJson["ClosePaymentFee"] = "0"; + createJson["GracePeriod"] = 60; + createJson["InterestRate"] = 24346; + createJson["LateInterestRate"] = 65535; + createJson["LatePaymentFee"] = "0"; + createJson["LoanOriginationFee"] = "218"; + createJson["LoanServiceFee"] = "0"; + createJson["PaymentInterval"] = 60; + createJson["PaymentTotal"] = 5678; + createJson["PrincipalRequested"] = "9924.81"; + + auto const keylet = nextLoanKeylet(env, broker); + + createJson = env.json(createJson, Sig(sfCounterpartySignature, lender)); + env(createJson, Ter(tesSUCCESS)); + env.close(); + + auto const pseudoAcct = brokerPseudoAccount(env, broker, lender); + + VerifyLoanStatus const verifyLoanStatus(env, broker, pseudoAcct, keylet); + auto const originalState = getCurrentState(env, broker, keylet); + verifyLoanStatus(originalState); + + Number const payment{3'269'349'176'470'588, -12}; + XRPAmount const payFee{ + baseFee * + ((payment / originalState.periodicPayment) / kLoanPaymentsPerFeeIncrement + 1)}; + auto loanPayTx = + env.json(pay(borrower, keylet.key, STAmount{broker.asset, payment}), Fee(payFee)); + BEAST_EXPECT(to_string(payment) == "3269.349176470588"); + env(loanPayTx, Ter(tesSUCCESS)); + env.close(); + + auto const newState = getCurrentState(env, broker, keylet); + BEAST_EXPECT( + isRounded(broker.asset, newState.managementFeeOutstanding, originalState.loanScale)); + BEAST_EXPECT(newState.managementFeeOutstanding < originalState.managementFeeOutstanding); + BEAST_EXPECT(isRounded(broker.asset, newState.totalValue, originalState.loanScale)); + BEAST_EXPECT( + isRounded(broker.asset, newState.principalOutstanding, originalState.loanScale)); + } + + void + testAccountSendMptMinAmountInvariant(FeatureBitset features) + { + // (From FIND-006) + testcase << "LoanSet trigger xrpl::accountSendMPT : minimum amount " + "and MPT"; + + using namespace jtx; + using namespace std::chrono_literals; + Env env(*this, features); + + Account const issuer{"issuer"}; + Account const lender{"lender"}; + Account const borrower{"borrower"}; + + env.fund(XRP(1'000'000), issuer, lender, borrower); + env.close(); + + MPTTester mptt{env, issuer, kMptInitNoFund}; + mptt.create({.flags = tfMPTCanClawback | tfMPTCanTransfer | tfMPTCanLock}); + PrettyAsset const mptAsset = mptt.issuanceID(); + mptt.authorize({.account = lender}); + mptt.authorize({.account = borrower}); + env(pay(issuer, lender, mptAsset(2'000'000))); + env(pay(issuer, borrower, mptAsset(1'000))); + env.close(); + + BrokerInfo const broker{createVaultAndBroker(env, mptAsset, lender)}; + + using namespace loan; + + auto const loanSetFee = Fee(env.current()->fees().base * 2); + Number const principalRequest{1, 3}; + + auto createJson = env.json( + set(borrower, broker.brokerID, principalRequest), + Fee(loanSetFee), + Json(sfCounterpartySignature, json::ValueType::Object)); + + createJson["CloseInterestRate"] = 76671; + createJson["ClosePaymentFee"] = "2061925410"; + createJson["GracePeriod"] = 434; + createJson["InterestRate"] = 50302; + createJson["LateInterestRate"] = 30322; + createJson["LatePaymentFee"] = "294427911"; + createJson["LoanOriginationFee"] = "3250635102"; + createJson["LoanServiceFee"] = "9557386"; + createJson["OverpaymentFee"] = 51249; + createJson["OverpaymentInterestRate"] = 14304; + createJson["PaymentInterval"] = 434; + createJson["PaymentTotal"] = "2891743748"; + createJson["PrincipalRequested"] = "8516.98"; + + auto const brokerStateBefore = env.le(keylet::loanBroker(broker.brokerID)); + + createJson = env.json(createJson, Sig(sfCounterpartySignature, lender)); + env(createJson, Ter(temINVALID)); + env.close(); + } + + // Verify that LoanPay, LoanBrokerCoverWithdraw, and LoanSet all use the + // same vault-scale minimum cover when fixCleanup3_2_0 is enabled. + // Before the amendment, each transactor computed its minimum cover at a + // different precision (loanScale, debtScale, or the raw unrounded + // tenthBipsOfValue), which could lead to inconsistent decisions for the + // same broker state. After the amendment all three use + // minimumBrokerCover at vaultScale. + void + testMinimumBrokerCoverConsistency(FeatureBitset features) + { + using namespace jtx; + using namespace loan; + using namespace loanBroker; + + bool const withAmendment = features[fixCleanup3_2_0]; + + struct Ctx + { + jtx::Account issuer; + jtx::Account lender; + jtx::Account borrower; + jtx::PrettyAsset iou; + BrokerInfo broker; + BrokerParameters brokerParams; + }; + + // Shared setup, parametrized by vaultDeposit (the only varying setup + // field across the three scenarios). Each call runs in its own Env + // so multiple invocations within one scenario cannot interfere. + // The caller is responsible for invoking testcase(...) before the + // first runTest call of each scenario. + auto runTest = [&](Number vaultDeposit, auto&& body) { + Env env(*this, features); + + Account const issuer{"issuer"}; + Account const lender{"lender"}; + Account const borrower{"borrower"}; + + env.fund(XRP(1'000'000'000), issuer, lender, borrower); + env.close(); + + // Enable clawback on the issuer *before* any trust lines exist + // (asfAllowTrustLineClawback requires an empty owner directory). + env(fset(issuer, asfAllowTrustLineClawback)); + env.close(); + + PrettyAsset const iou = issuer[iouCurrency_]; + env(trust(lender, iou(1'000'000'000))); + env(trust(borrower, iou(1'000'000'000))); + env.close(); + env(pay(issuer, lender, iou(100'000'000))); + env(pay(issuer, borrower, iou(100'000'000))); + env.close(); + + // 13.37% — non-round rate produces a messier minimum. + BrokerParameters const brokerParams{ + .vaultDeposit = vaultDeposit, + .debtMax = 0, + .coverRateMin = TenthBips32{13'370}, + .coverDeposit = 5'000, + .managementFeeRate = TenthBips16{500}}; + + BrokerInfo const broker = createVaultAndBroker(env, iou, lender, brokerParams); + + body( + env, + Ctx{.issuer = issuer, + .lender = lender, + .borrower = borrower, + .iou = iou, + .broker = broker, + .brokerParams = brokerParams}); + }; + + // Scenario 1 — LoanPay + // + // Verify that LoanPay's minimum cover check uses vault scale (not + // loan scale). Before the amendment, different loans could produce + // different fee routing decisions for the same broker-level state. + // Small vault deposit => vaultScale = -12. + testcase("LoanPay minimum cover scale consistency"); + { + struct LoanKeylets + { + Keylet tiny; + Keylet big; + }; + + // Create the tiny + big loans and reduce cover via clawback so + // that subsequent LoanPay calls hit the minimum-cover boundary. + // Used by the two pay-and-check sub-tests below so each can run + // in its own Env. + auto setupLoansAndClawback = [&](Env& env, Ctx const& c) -> std::optional { + Asset const asset{c.iou}; + + // Create the TINY loan first (while vaultScale is still + // small). principal 0.01, 0% interest, 1 payment => + // loanScale = vaultScale. + auto const brokerSle1 = env.le(keylet::loanBroker(c.broker.brokerID)); + if (!BEAST_EXPECT(brokerSle1)) + return std::nullopt; + auto const tinyLoanSeq = brokerSle1->at(sfLoanSequence); + auto const tinyLoanKeylet = keylet::loan(c.broker.brokerID, tinyLoanSeq); + + env(set(c.borrower, c.broker.brokerID, Number{1, -2}), + Sig(sfCounterpartySignature, c.lender), + kInterestRate(TenthBips32{0}), + kPaymentTotal(1), + kPaymentInterval(86400 * 365), + Fee(XRP(10))); + env.close(); + + // Create the BIG loan second. 100% annual interest over 20 + // payments pushes totalValueOutstanding high enough that + // loanScale > vaultScale. + auto const brokerSle2 = env.le(keylet::loanBroker(c.broker.brokerID)); + if (!BEAST_EXPECT(brokerSle2)) + return std::nullopt; + auto const bigLoanSeq = brokerSle2->at(sfLoanSequence); + auto const bigLoanKeylet = keylet::loan(c.broker.brokerID, bigLoanSeq); + + env(set(c.borrower, c.broker.brokerID, Number{500}), + Sig(sfCounterpartySignature, c.lender), + kInterestRate(TenthBips32{100'000}), + kPaymentTotal(20), + kPaymentInterval(86400 * 365), + Fee(XRP(10))); + env.close(); + + // The tiny loan's scale is frozen at the vault's pre-big-loan + // scale, so it is strictly smaller than the big loan's. + // After the big loan is created the vault absorbs its value, + // pushing vaultScale up to match bigLoanScale. + auto const tinyLoanSle = env.le(tinyLoanKeylet); + auto const bigLoanSle = env.le(bigLoanKeylet); + auto const vaultSle = env.le(keylet::vault(c.broker.vaultID)); + if (!BEAST_EXPECT(tinyLoanSle) || !BEAST_EXPECT(bigLoanSle) || + !BEAST_EXPECT(vaultSle)) + return std::nullopt; + if (!BEAST_EXPECT(tinyLoanSle->at(sfLoanScale) == -12) || + !BEAST_EXPECT(bigLoanSle->at(sfLoanScale) == -11) || + !BEAST_EXPECT(getAssetsTotalScale(vaultSle) == -11)) + return std::nullopt; + + // Use issuer clawback to reduce cover to the minimum the + // clawback transactor allows. Compute the amount as + // initialCover - expectedCoverAfter so we exercise the exact + // clawback rather than relying on the transactor to clip + // down. + // + // Before the amendment the clawback minimum is the + // *unrounded* tenthBipsOfValue — strictly less than the + // rounded-at-vaultScale minimum LoanPay uses for the big + // loan. After the amendment both clawback and LoanPay use + // the same rounded minimum (via minimumBrokerCover), so + // cover lands exactly at that threshold. + Number const expectedCoverAfter = withAmendment ? Number{1330651855688460000, -15} + : Number{1330651855688458000, -15}; + Number const clawbackAmount = + Number{c.brokerParams.coverDeposit} - expectedCoverAfter; + + env(coverClawback(c.issuer), + kLoanBrokerId(c.broker.brokerID), + kAmount(STAmount{asset, clawbackAmount})); + env.close(); + + auto const brokerSle = env.le(keylet::loanBroker(c.broker.brokerID)); + if (!BEAST_EXPECT(brokerSle) || + !BEAST_EXPECT(brokerSle->at(sfCoverAvailable) == expectedCoverAfter)) + return std::nullopt; + + return LoanKeylets{.tiny = tinyLoanKeylet, .big = bigLoanKeylet}; + }; + + // Pay one loan and report whether the fee went to the broker's + // pseudo account (the fallback when cover < minimum) rather + // than to the owner. + auto feeGoesToPseudo = [&](Env& env, Ctx const& c, Keylet const& loanKeylet) -> bool { + Asset const asset{c.iou}; + auto const brokerSle = env.le(keylet::loanBroker(c.broker.brokerID)); + if (!BEAST_EXPECT(brokerSle)) + return false; + auto const pseudoAcct = Account("pseudo", brokerSle->at(sfAccount)); + auto const pseudoBefore = env.balance(pseudoAcct, c.iou); + + auto const payLoan = env.le(loanKeylet); + if (!BEAST_EXPECT(payLoan)) + return false; + auto const periodicPayment = payLoan->at(sfPeriodicPayment); + auto const serviceFee = payLoan->at(sfLoanServiceFee); + std::int32_t const loanScale = payLoan->at(sfLoanScale); + + auto const payment = roundPeriodicPayment(asset, periodicPayment, loanScale); + auto const payAmt = STAmount{asset, payment + serviceFee}; + + env(loan::pay(c.borrower, loanKeylet.key, payAmt), Fee(XRP(10))); + env.close(); + + auto const pseudoAfter = env.balance(pseudoAcct, c.iou); + return pseudoAfter.number() > pseudoBefore.number(); + }; + + // Pay the BIG loan in its own Env so its outcome cannot affect + // the TINY-loan check. With the fix, LoanPay and clawback use + // the same vaultScale minimum (cover == minAtVaultScale => + // fee to owner). Without the fix, LoanPay uses bigLoanScale=-11, + // rounds up to a larger minimum than what clawback used => + // cover < min => fee to pseudo. + runTest(/*vaultDeposit=*/1'000, [&](Env& env, Ctx const& c) { + auto const loans = setupLoansAndClawback(env, c); + if (!loans) + return; + BEAST_EXPECT(feeGoesToPseudo(env, c, loans->big) == !withAmendment); + }); + + // Pay the TINY loan in its own Env. Fee goes to the owner + // either way: + // - With the fix: LoanPay uses vaultScale=-11 (same as + // clawback) => owner. + // - Without the fix: LoanPay uses tinyLoanScale=-12, rounds + // up at -12 (a no-op) => min == cover => owner. + runTest(/*vaultDeposit=*/1'000, [&](Env& env, Ctx const& c) { + auto const loans = setupLoansAndClawback(env, c); + if (!loans) + return; + BEAST_EXPECT(!feeGoesToPseudo(env, c, loans->tiny)); + }); + } + + // Scenario 2 — LoanBrokerCoverWithdraw + // + // Verify that CoverWithdraw's minimum cover check uses vault scale + // (not scale(debtTotal, asset)). Before the amendment, CoverWithdraw + // used: + // roundToAsset(asset, tenthBipsOfValue(debt, rate), scale(debt, asset)) + // which could disagree with LoanPay's minimum (which used loanScale). + // + // Use a large vault deposit so that vaultScale (from AssetsTotal) is + // strictly larger than debtScale (from DebtTotal). With + // vaultDeposit = 100,000: after the big loan + // AssetsTotal ≈ 109,500 → vaultScale = -10 + // DebtTotal ≈ 10,000 → debtScale = -11 + // The one-order-of-magnitude gap makes roundToAsset at -10 truncate + // more aggressively than at -11, exposing the bug. + testcase("CoverWithdraw minimum cover scale consistency"); + runTest( + /*vaultDeposit=*/100'000, [&](Env& env, Ctx const& c) { + Asset const asset{c.iou}; + + // Create only the big loan to push DebtTotal up to ~10,000 + // while AssetsTotal stays around 109,500 (dominated by the + // large vault deposit). + env(set(c.borrower, c.broker.brokerID, Number{500}), + Sig(sfCounterpartySignature, c.lender), + kInterestRate(TenthBips32{100'000}), + kPaymentTotal(20), + kPaymentInterval(86400 * 365), + Fee(XRP(10))); + env.close(); + + // Read broker state and compute both old and new minimums. + auto const brokerSle = env.le(keylet::loanBroker(c.broker.brokerID)); + auto const vaultSle = env.le(keylet::vault(c.broker.vaultID)); + if (!BEAST_EXPECT(brokerSle) || !BEAST_EXPECT(vaultSle)) + return; + + auto const coverAvail = brokerSle->at(sfCoverAvailable); + auto const debtTotal = brokerSle->at(sfDebtTotal); + auto const vaultScale = getAssetsTotalScale(vaultSle); + auto const debtScale = scale(debtTotal, asset); + + // Sanity: debt scale differs from vault scale for this setup. + BEAST_EXPECT(debtScale < vaultScale); + + auto const oldMin = [&]() { + NumberRoundModeGuard const mg(Number::RoundingMode::Upward); + return roundToAsset( + asset, + tenthBipsOfValue(debtTotal, TenthBips32{c.brokerParams.coverRateMin}), + debtScale); + }(); + auto const newMin = minimumBrokerCover( + debtTotal, TenthBips32{c.brokerParams.coverRateMin}, vaultSle); + + // The new (vaultScale) minimum must be strictly larger than + // the old (debtScale) minimum — that is the gap the amendment + // closes. + Number const expectedNewMin{1330650518688500000, -15}; + Number const expectedOldMin{1330650518688472000, -15}; + BEAST_EXPECT(newMin == expectedNewMin); + BEAST_EXPECT(oldMin == expectedOldMin); + + // Try to withdraw so that remaining cover lands between the + // two minimums: oldMin < target < newMin. + auto const target = oldMin + (newMin - oldMin) / 2; + auto const withdrawAmount = STAmount{asset, coverAvail - target}; + + if (withAmendment) + { + // CoverWithdraw now uses vaultScale: target < newMin + // => FAILS. + env(coverWithdraw(c.lender, c.broker.brokerID, withdrawAmount), + Ter(tecINSUFFICIENT_FUNDS)); + } + else + { + // Old CoverWithdraw uses debtScale: target > oldMin + // => SUCCEEDS. + env(coverWithdraw(c.lender, c.broker.brokerID, withdrawAmount)); + } + env.close(); + }); + + // Scenario 3 — LoanSet + // + // Verify that LoanSet's minimum cover check uses vault scale (not the + // raw unrounded tenthBipsOfValue). Before the amendment, LoanSet + // used tenthBipsOfValue(newDebtTotal, coverRateMinimum) (no + // roundToAsset), while clawback/withdraw used different formulas. + // After the amendment all use minimumBrokerCover at vaultScale, and + // rounding at a coarser scale can absorb a tiny debt increase — + // allowing a loan that would otherwise be rejected. + testcase("LoanSet minimum cover scale consistency"); + runTest( + /*vaultDeposit=*/1'000, [&](Env& env, Ctx const& c) { + // Create the tiny loan (scale -12) AND the big loan (scale + // -11). Both loans are needed so that DebtTotal has a full + // 16-digit mantissa — a "messy" value where roundToAsset at + // vaultScale actually truncates digits and produces a + // different result from the raw tenthBipsOfValue. With only + // the big loan, DebtTotal has ~4 significant digits and + // rounding at scale -11 is a no-op, masking the amendment's + // effect. + env(set(c.borrower, c.broker.brokerID, Number{1, -2}), + Sig(sfCounterpartySignature, c.lender), + kInterestRate(TenthBips32{0}), + kPaymentTotal(1), + kPaymentInterval(86400 * 365), + Fee(XRP(10))); + env.close(); + + env(set(c.borrower, c.broker.brokerID, Number{500}), + Sig(sfCounterpartySignature, c.lender), + kInterestRate(TenthBips32{100'000}), + kPaymentTotal(20), + kPaymentInterval(86400 * 365), + Fee(XRP(10))); + env.close(); + + // Clawback to reduce cover to the clawback transactor's + // minimum. Pass the exact amount rather than relying on the + // transactor to clip down; the setup matches Scenario 1 so + // the same residual-cover values apply. + Number const expectedCoverAfter = withAmendment ? Number{1330651855688460000, -15} + : Number{1330651855688458000, -15}; + Number const clawbackAmount = + Number{c.brokerParams.coverDeposit} - expectedCoverAfter; + env(coverClawback(c.issuer), + kLoanBrokerId(c.broker.brokerID), + kAmount(c.iou(clawbackAmount))); + env.close(); + + // Verify scales. + auto const vaultSle = env.le(keylet::vault(c.broker.vaultID)); + if (!BEAST_EXPECT(vaultSle)) + return; + auto const vaultScale = getAssetsTotalScale(vaultSle); + BEAST_EXPECT(vaultScale == -11); + + // Now try to create a tiny additional loan. Principal is + // 1e-11 (the smallest value that survives the precision + // check at loanScale = vaultScale = -11), with 0% interest + // and 1 payment. + // + // The tiny debt increase adds ~1.337e-12 to the unrounded + // minimum. + // - Without the amendment: the old LoanSet formula rounds + // up during tenthBipsOfValue (16-digit Number + // normalisation), pushing the minimum past the cover left + // by clawback => tecINSUFFICIENT_FUNDS. + // - With the amendment: minimumBrokerCover rounds at + // vaultScale=-11, which absorbs the tiny increase — the + // rounded minimum stays the same => tesSUCCESS. + auto const tinyPrincipal = Number{1, -11}; + + if (withAmendment) + { + env(set(c.borrower, c.broker.brokerID, tinyPrincipal), + Sig(sfCounterpartySignature, c.lender), + kInterestRate(TenthBips32{0}), + kPaymentTotal(1), + kPaymentInterval(86400 * 365), + Fee(XRP(10))); + } + else + { + env(set(c.borrower, c.broker.brokerID, tinyPrincipal), + Sig(sfCounterpartySignature, c.lender), + kInterestRate(TenthBips32{0}), + kPaymentTotal(1), + kPaymentInterval(86400 * 365), + Fee(XRP(10)), + Ter(tecINSUFFICIENT_FUNDS)); + } + env.close(); + }); + } + + // Tests run under each entry in amendmentCombinations(). + void + runAmendmentSensitive(FeatureBitset features) + { + testLoanPayComputePeriodicPaymentInvariants(features); + testLoanPayDebtDecreaseInvariant(features); + testAccountSendMptMinAmountInvariant(features); + testMinimumBrokerCoverConsistency(features); + } + +public: + void + run() override + { + for (auto const& features : jtx::amendmentCombinations( + {fixCleanup3_1_3, fixCleanup3_2_0, featureMPTokensV2}, all_)) + runAmendmentSensitive(features); + } +}; + +BEAST_DEFINE_TESTSUITE(LoanInvariants, tx, xrpl); + +} // namespace xrpl::test diff --git a/src/test/app/LoanLifecycle_test.cpp b/src/test/app/LoanLifecycle_test.cpp new file mode 100644 index 00000000000..7bc4c8ebf57 --- /dev/null +++ b/src/test/app/LoanLifecycle_test.cpp @@ -0,0 +1,645 @@ +#include + +namespace xrpl::test { + +class LoanLifecycle_test : public LoanTestBase +{ +private: + void + testLifecycle(FeatureBitset features) + { + testcase("Lifecycle"); + using namespace jtx; + + // Create 3 loan brokers: one for XRP, one for an IOU, and one for + // an MPT. That'll require three corresponding SAVs. + Env env(*this, features); + + Account const issuer{"issuer"}; + // For simplicity, lender will be the sole actor for the vault & + // brokers. + Account const lender{"lender"}; + // Borrower only wants to borrow + Account const borrower{"borrower"}; + // Evan will attempt to be naughty + Account const evan{"evan"}; + // Do not fund alice + Account const alice{"alice"}; + + // Fund the accounts and trust lines with the same amount so that + // tests can use the same values regardless of the asset. + env.fund(XRP(100'000'000), issuer, noripple(lender, borrower, evan)); + env.close(); + + // Create assets + PrettyAsset const xrpAsset{xrpIssue(), 1'000'000}; + PrettyAsset const iouAsset = issuer[iouCurrency_]; + env(trust(lender, iouAsset(10'000'000))); + env(trust(borrower, iouAsset(10'000'000))); + env(trust(evan, iouAsset(10'000'000))); + env(pay(issuer, evan, iouAsset(1'000'000))); + env(pay(issuer, lender, iouAsset(10'000'000))); + // Fund the borrower with enough to cover interest and fees + env(pay(issuer, borrower, iouAsset(10'000))); + env.close(); + + MPTTester mptt{env, issuer, kMptInitNoFund}; + mptt.create({.flags = tfMPTCanClawback | tfMPTCanTransfer | tfMPTCanLock}); + // Scale the MPT asset a little bit so we can get some interest + PrettyAsset const mptAsset{mptt.issuanceID(), 100}; + mptt.authorize({.account = lender}); + mptt.authorize({.account = borrower}); + mptt.authorize({.account = evan}); + env(pay(issuer, lender, mptAsset(10'000'000))); + env(pay(issuer, evan, mptAsset(1'000'000))); + // Fund the borrower with enough to cover interest and fees + env(pay(issuer, borrower, mptAsset(10'000))); + env.close(); + + std::array const assets{iouAsset, xrpAsset, mptAsset}; + + // Create vaults and loan brokers + std::vector brokers; + brokers.reserve(assets.size()); + for (auto const& asset : assets) + { + brokers.emplace_back(createVaultAndBroker( + env, asset, lender, BrokerParameters{.data = "spam spam spam spam"})); + } + + // Create and update Loans + for (auto const& broker : brokers) + { + for (int amountExponent = 3; amountExponent >= 3; --amountExponent) + { + Number const loanAmount{1, amountExponent}; + for (int interestExponent = 0; interestExponent >= 0; --interestExponent) + { + testCaseWrapper(env, mptt, assets, broker, loanAmount, interestExponent); + } + } + + if (auto brokerSle = env.le(keylet::loanBroker(broker.brokerID)); + BEAST_EXPECT(brokerSle)) + { + BEAST_EXPECT(brokerSle->at(sfOwnerCount) == 0); + BEAST_EXPECT(brokerSle->at(sfDebtTotal) == 0); + + auto const coverAvailable = brokerSle->at(sfCoverAvailable); + env(loanBroker::coverWithdraw( + lender, broker.brokerID, STAmount(broker.asset, coverAvailable))); + env.close(); + + brokerSle = env.le(keylet::loanBroker(broker.brokerID)); + BEAST_EXPECT(brokerSle && brokerSle->at(sfCoverAvailable) == 0); + } + // Verify we can delete the loan broker + env(loanBroker::del(lender, broker.brokerID)); + env.close(); + } + } + + void + testSelfLoan(FeatureBitset features) + { + testcase << "Self Loan"; + + using namespace jtx; + using namespace std::chrono_literals; + // Create 3 loan brokers: one for XRP, one for an IOU, and one for + // an MPT. That'll require three corresponding SAVs. + Env env(*this, features); + + Account const issuer{"issuer"}; + // For simplicity, lender will be the sole actor for the vault & + // brokers. + Account const lender{"lender"}; + + // Fund the accounts and trust lines with the same amount so that + // tests can use the same values regardless of the asset. + env.fund(XRP(100'000'000), issuer, noripple(lender)); + env.close(); + + // Use an XRP asset for simplicity + PrettyAsset const xrpAsset{xrpIssue(), 1'000'000}; + + // Create vaults and loan brokers + BrokerInfo broker{createVaultAndBroker(env, xrpAsset, lender)}; + + using namespace loan; + + auto const loanSetFee = Fee(env.current()->fees().base * 2); + Number const principalRequest{1, 3}; + + // The LoanSet json can be created without a counterparty signature, + // but it will not pass preflight + auto createJson = env.json( + set(lender, broker.brokerID, broker.asset(principalRequest).value()), Fee(loanSetFee)); + env(createJson, Ter(temBAD_SIGNER)); + + // Adding an empty counterparty signature object also fails, but + // at the RPC level. + createJson = env.json(createJson, Json(sfCounterpartySignature, json::ValueType::Object)); + env(createJson, Ter(telENV_RPC_FAILED)); + + if (auto const jt = env.jt(createJson); BEAST_EXPECT(jt.stx)) + { + Serializer s; + jt.stx->add(s); + auto const jr = env.rpc("submit", strHex(s.slice())); + + BEAST_EXPECT(jr.isMember(jss::result)); + auto const jResult = jr[jss::result]; + BEAST_EXPECT(jResult[jss::error] == "invalidTransaction"); + BEAST_EXPECT( + jResult[jss::error_exception] == + "fails local checks: Transaction has bad signature."); + } + + // Copy the transaction signature into the counterparty signature. + json::Value counterpartyJson{json::ValueType::Object}; + counterpartyJson[sfTxnSignature] = createJson[sfTxnSignature]; + counterpartyJson[sfSigningPubKey] = createJson[sfSigningPubKey]; + if (!BEAST_EXPECT(!createJson.isMember(jss::Signers))) + counterpartyJson[sfSigners] = createJson[sfSigners]; + + // The duplicated signature works + createJson = env.json(createJson, Json(sfCounterpartySignature, counterpartyJson)); + env(createJson); + + env.close(); + + auto const startDate = env.current()->header().parentCloseTime; + + // Loan is successfully created + { + auto const res = env.rpc("account_objects", lender.human()); + auto const objects = res[jss::result][jss::account_objects]; + + std::map types; + BEAST_EXPECT(objects.size() == 4); + for (auto const& object : objects) + { + ++types[object[sfLedgerEntryType].asString()]; + } + BEAST_EXPECT(types.size() == 4); + for (std::string const type : {"MPToken", "Vault", "LoanBroker", "Loan"}) + { + BEAST_EXPECT(types[type] == 1); + } + } + auto const loanID = [&]() { + json::Value params(json::ValueType::Object); + params[jss::account] = lender.human(); + params[jss::type] = "Loan"; + auto const res = env.rpc("json", "account_objects", to_string(params)); + auto const objects = res[jss::result][jss::account_objects]; + + BEAST_EXPECT(objects.size() == 1); + + auto const loan = objects[0u]; + BEAST_EXPECT(loan[sfBorrower] == lender.human()); + // soeDEFAULT fields are not returned if they're in the default + // state + BEAST_EXPECT(!loan.isMember(sfCloseInterestRate)); + BEAST_EXPECT(!loan.isMember(sfClosePaymentFee)); + BEAST_EXPECT(loan[sfFlags] == 0); + BEAST_EXPECT(loan[sfGracePeriod] == 60); + BEAST_EXPECT(!loan.isMember(sfInterestRate)); + BEAST_EXPECT(!loan.isMember(sfLateInterestRate)); + BEAST_EXPECT(!loan.isMember(sfLatePaymentFee)); + BEAST_EXPECT(loan[sfLoanBrokerID] == to_string(broker.brokerID)); + BEAST_EXPECT(!loan.isMember(sfLoanOriginationFee)); + BEAST_EXPECT(loan[sfLoanSequence] == 1); + BEAST_EXPECT(!loan.isMember(sfLoanServiceFee)); + BEAST_EXPECT(loan[sfNextPaymentDueDate] == loan[sfStartDate].asUInt() + 60); + BEAST_EXPECT(!loan.isMember(sfOverpaymentFee)); + BEAST_EXPECT(!loan.isMember(sfOverpaymentInterestRate)); + BEAST_EXPECT(loan[sfPaymentInterval] == 60); + BEAST_EXPECT(loan[sfPeriodicPayment] == "1000000000"); + BEAST_EXPECT(loan[sfPaymentRemaining] == 1); + BEAST_EXPECT(!loan.isMember(sfPreviousPaymentDueDate)); + BEAST_EXPECT(loan[sfPrincipalOutstanding] == "1000000000"); + BEAST_EXPECT(loan[sfTotalValueOutstanding] == "1000000000"); + BEAST_EXPECT(!loan.isMember(sfLoanScale)); + BEAST_EXPECT(loan[sfStartDate].asUInt() == startDate.time_since_epoch().count()); + + return loan["index"].asString(); + }(); + auto const loanKeylet{keylet::loan(uint256{std::string_view(loanID)})}; + + env.close(startDate); + + // Make a payment + env(pay(lender, loanKeylet.key, broker.asset(1000))); + } + + void + testIssuerLoan() + { + testcase << "Issuer Loan"; + + using namespace jtx; + using namespace loan; + Account const issuer("issuer"); + Account const borrower = issuer; + Account const lender("lender"); + Env env(*this); + + env.fund(XRP(1'000), issuer, lender); + + static constexpr std::int64_t kIssuerBalance = 10'000'000; + MPTTester const asset( + {.env = env, .issuer = issuer, .holders = {lender}, .pay = kIssuerBalance}); + + BrokerParameters const brokerParams{ + .debtMax = 200, + }; + auto const broker = createVaultAndBroker(env, asset, lender, brokerParams); + auto const loanSetFee = Fee(env.current()->fees().base * 2); + // Create Loan + env(set(borrower, broker.brokerID, 200), Sig(sfCounterpartySignature, lender), loanSetFee); + env.close(); + // Issuer should not create MPToken + BEAST_EXPECT(!env.le(keylet::mptoken(asset.issuanceID(), issuer))); + // Issuer "borrowed" 200, OutstandingAmount decreased by 200 + BEAST_EXPECT(env.balance(issuer, asset) == asset(-kIssuerBalance + 200)); + // Pay Loan + auto const loanKeylet = keylet::loan(broker.brokerID, 1); + env(pay(borrower, loanKeylet.key, asset(200))); + env.close(); + // Issuer "re-payed" 200, OutstandingAmount increased by 200 + BEAST_EXPECT(env.balance(issuer, asset) == asset(-kIssuerBalance)); + } + + void + testBorrowerIsBroker() + { + testcase("Test Borrower is Broker"); + using namespace jtx; + using namespace loan; + Account const broker{"broker"}; + Account const issuer{"issuer"}; + Account const borrower{"borrower"}; + Account const depositor{"depositor"}; + + auto testLoanAsset = [&](auto&& getMaxDebt, auto const& borrower) { + Env env(*this); + Vault const vault(env); + + if (borrower == broker) + { + env.fund(XRP(10'000), broker, issuer, depositor); + } + else + { + env.fund(XRP(10'000), broker, borrower, issuer, depositor); + } + env.close(); + + auto const xrpFee = XRP(100); + auto const txFee = Fee(xrpFee); + + STAmount const debtMaximumRequest = getMaxDebt(env); + + auto const& asset = debtMaximumRequest.asset(); + auto const initialVault = asset(debtMaximumRequest * 100); + + auto [tx, vaultKeylet] = vault.create({.owner = broker, .asset = asset}); + env(tx, txFee); + env.close(); + + env(vault.deposit( + {.depositor = depositor, .id = vaultKeylet.key, .amount = initialVault}), + txFee); + env.close(); + + auto const brokerKeylet = keylet::loanBroker(broker.id(), env.seq(broker)); + + env(loanBroker::set(broker, vaultKeylet.key), txFee); + env.close(); + + auto const serviceFee = 101; + + env(set(broker, brokerKeylet.key, debtMaximumRequest), + kCounterparty(borrower), + Sig(sfCounterpartySignature, borrower), + kLoanServiceFee(serviceFee), + kPaymentTotal(10), + txFee); + env.close(); + + std::uint32_t const loanSequence = 1; + auto const loanKeylet = keylet::loan(brokerKeylet.key, loanSequence); + + auto const brokerBalanceBefore = env.balance(broker, asset); + + if (auto const loanSle = env.le(loanKeylet); env.test.BEAST_EXPECT(loanSle)) + { + auto const payment = loanSle->at(sfPeriodicPayment); + auto const totalPayment = payment + serviceFee; + env(loan::pay(borrower, loanKeylet.key, asset(totalPayment)), txFee); + env.close(); + if (auto const vaultSle = env.le(vaultKeylet); BEAST_EXPECT(vaultSle)) + { + auto const expected = [&]() { + // The service fee is transferred to the broker if + // a borrower is not the broker + if (borrower != broker) + return brokerBalanceBefore.number() + serviceFee; + // Since a borrower is the broker, the payment is + // transferred to the Vault from the broker but not + // the service fee. + // If the asset is XRP then the broker pays the txFee. + if (asset.native()) + return brokerBalanceBefore.number() - payment - xrpFee.number(); + return brokerBalanceBefore.number() - payment; + }(); + BEAST_EXPECT(env.balance(broker, asset).value() == asset(expected).value()); + } + } + }; + // Test when a borrower is the broker and is not to verify correct + // service fee transfer in both cases. + for (auto const& borrowerAcct : {broker, borrower}) + { + testLoanAsset( + [&](Env&) -> STAmount { return STAmount{XRPAmount{200'000}}; }, borrowerAcct); + testLoanAsset( + [&](Env& env) -> STAmount { + auto const iou = issuer["USD"]; + env(trust(broker, iou(1'000'000'000))); + env(trust(depositor, iou(1'000'000'000))); + env(pay(issuer, broker, iou(100'000'000))); + env(pay(issuer, depositor, iou(100'000'000))); + env.close(); + return iou(200'000); + }, + borrowerAcct); + testLoanAsset( + [&](Env& env) -> STAmount { + MPTTester const mpt( + {.env = env, + .issuer = issuer, + .holders = {broker, depositor}, + .pay = 100'000'000}); + return mpt(200'000); + }, + borrowerAcct); + } + } + + void + testIssuerIsBorrower(FeatureBitset features) + { + testcase("RIPD-4096 - Issuer as borrower"); + + using namespace jtx; + + Account const issuer("issuer"); + Account const lender("lender"); + + BrokerParameters const brokerParams{ + .vaultDeposit = 100'000, + .debtMax = 0, + .coverRateMin = TenthBips32{0}, + .managementFeeRate = TenthBips16{0}, + .coverRateLiquidation = TenthBips32{0}}; + LoanParameters const loanParams{ + .account = lender, .counter = issuer, .principalRequest = Number{10000}}; + + auto const assetType = AssetType::IOU; + + Env env{*this, features}; + + auto loanResult = + createLoan(env, assetType, brokerParams, loanParams, issuer, lender, issuer); + + if (BEAST_EXPECT(loanResult); !loanResult.has_value()) + return; + + auto broker = std::get(*loanResult); + auto loanKeylet = std::get(*loanResult); + auto pseudoAcct = std::get(*loanResult); + + VerifyLoanStatus const verifyLoanStatus(env, broker, pseudoAcct, loanKeylet); + + makeLoanPayments( + env, + broker, + loanParams, + loanKeylet, + verifyLoanStatus, + issuer, + lender, + issuer, + PaymentParameters{.showStepBalances = true}); + } + + void + testBatchBypassCounterparty(FeatureBitset features) + { + // From FIND-001 + testcase << "Batch Bypass Counterparty"; + + bool const lendingBatchEnabled = !std::ranges::any_of( + Batch::kDisabledTxTypes, + [](auto const& disabled) { return disabled == ttLOAN_BROKER_SET; }); + + using namespace jtx; + using namespace std::chrono_literals; + Env env(*this, features); + + Account const lender{"lender"}; + Account const borrower{"borrower"}; + + BrokerParameters const brokerParams; + env.fund(XRP(brokerParams.vaultDeposit * 100), lender, borrower); + env.close(); + + PrettyAsset const xrpAsset{xrpIssue(), 1'000'000}; + + BrokerInfo const broker{createVaultAndBroker(env, xrpAsset, lender, brokerParams)}; + + using namespace loan; + + auto const loanSetFee = Fee(env.current()->fees().base * 2); + Number const principalRequest{1, 3}; + + auto forgedLoanSet = set(borrower, broker.brokerID, principalRequest, 0); + + json::Value randomData{json::ValueType::Object}; + randomData[jss::SigningPubKey] = json::StaticString{"2600"}; + json::Value sigObject{json::ValueType::Object}; + sigObject[jss::SigningPubKey] = strHex(lender.pk().slice()); + Serializer ss; + ss.add32(HashPrefix::TxSign); + parse(randomData).addWithoutSigningFields(ss); + auto const sig = xrpl::sign(borrower.pk(), borrower.sk(), ss.slice()); + sigObject[jss::TxnSignature] = strHex(Slice{sig.data(), sig.size()}); + + forgedLoanSet[json::StaticString{"CounterpartySignature"}] = sigObject; + + // ? Fails because the lender hasn't signed the tx + env(env.json(forgedLoanSet, Fee(loanSetFee)), Ter(telENV_RPC_FAILED)); + + auto const seq = env.seq(borrower); + auto const batchFee = batch::calcBatchFee(env, 1, 2); + // ! Should fail because the lender hasn't signed the tx + env(batch::outer(borrower, seq, batchFee, tfAllOrNothing), + batch::Inner(forgedLoanSet, seq + 1), + batch::Inner(pay(borrower, lender, XRP(1)), seq + 2), + Ter(lendingBatchEnabled ? temBAD_SIGNATURE : temINVALID_INNER_BATCH)); + env.close(); + + // ? Check that the loan was NOT created + { + json::Value params(json::ValueType::Object); + params[jss::account] = borrower.human(); + params[jss::type] = "Loan"; + auto const res = env.rpc("json", "account_objects", to_string(params)); + auto const objects = res[jss::result][jss::account_objects]; + BEAST_EXPECT(objects.size() == 0); + } + } + + // Integration test: full lifecycle of a $1B loan in the bug regime. + // Verifies that the vault collects the economically-correct interest + // income and that conservation holds at the trust-line level. + // + // Pre-fix (closed-form `power(1+r, n) - 1`): vault collected only + // ~$0.058 per $1B due to cancellation of `(1+r)^n - 1` at r*n ~ 5.7e-10. + // Post-fix (hybrid binomial path): vault collects ~$0.38 per $1B, + // matching the value computed independently with arbitrary-precision + // Decimal arithmetic. + void + testFullLifecycleVaultPnLNearZeroRate() + { + testcase("integration: full loan lifecycle, vault interest at near-zero rate"); + + using namespace jtx; + using namespace jtx::loan; + using namespace std::chrono_literals; + Env env(*this, all_); + + Account const issuer{"issuer"}; + Account const lender{"lender"}; + Account const borrower{"borrower"}; + + env.fund(XRP(1'000'000), issuer, lender, borrower); + env.close(); + env(fset(issuer, asfDefaultRipple)); + env.close(); + + PrettyAsset const iouAsset = issuer["USD"]; + STAmount const trustLimit{iouAsset.raw(), Number{1, 17}}; + env(trust(lender, trustLimit)); + env(trust(borrower, trustLimit)); + env.close(); + env(pay(issuer, lender, iouAsset(5'000'000'000LL))); + env(pay(issuer, borrower, iouAsset(5'000'000'000LL))); + env.close(); + + auto usdBalance = [&](Account const& a) { + return env.balance(a, iouAsset.raw().get()).value(); + }; + STAmount const borrowerStartBal = usdBalance(borrower); + + BrokerParameters const brokerParams{ + .vaultDeposit = Number{2, 9}, + .debtMax = Number{0}, + .coverRateMin = TenthBips32{0}, + .coverDeposit = 0, + .managementFeeRate = TenthBips16{0}, + .coverRateLiquidation = TenthBips32{0}}; + BrokerInfo const broker{createVaultAndBroker(env, iouAsset, lender, brokerParams)}; + + auto const vaultBefore = env.le(broker.vaultKeylet()); + BEAST_EXPECT(vaultBefore); + Number const vaultAvailableBefore = vaultBefore->at(sfAssetsAvailable); + + // Loan: $1B principal, 3 payments, 600s interval, rate=1 TenthBips32. + auto const loanSetFee = Fee(env.current()->fees().base * 2); + Number const principalRequest{1, 9}; + auto createJson = env.json( + set(borrower, broker.brokerID, principalRequest), + Fee(loanSetFee), + Json(sfCounterpartySignature, json::ValueType::Object)); + createJson["InterestRate"] = 1; + createJson["PaymentTotal"] = 3; + createJson["PaymentInterval"] = 600; + + auto const loanKeylet = nextLoanKeylet(env, broker); + createJson = env.json(createJson, Sig(sfCounterpartySignature, lender)); + env(createJson, Ter(tesSUCCESS)); + env.close(); + + auto const loanSle = env.le(loanKeylet); + BEAST_EXPECT(loanSle); + Number const expectedTotalInterest = + loanSle->at(sfTotalValueOutstanding) - loanSle->at(sfPrincipalOutstanding); + + env(pay(borrower, loanKeylet.key, iouAsset(1'500'000'000LL)), Ter(tesSUCCESS)); + env.close(); + + auto const vaultAfter = env.le(broker.vaultKeylet()); + Number const vaultAvailableAfter = vaultAfter->at(sfAssetsAvailable); + Number const vaultGain = vaultAvailableAfter - vaultAvailableBefore; + + STAmount const borrowerEndBal = usdBalance(borrower); + STAmount const borrowerNetOut = borrowerStartBal - borrowerEndBal; + + // Self-consistency: vault gained exactly the expected interest + // computed at LoanSet, and the borrower's outflow matches. + BEAST_EXPECT(vaultGain == expectedTotalInterest); + BEAST_EXPECT(Number(borrowerNetOut) == expectedTotalInterest); + + // Mathematical correctness: the total interest for this loan + // configuration is 0.38051750382930729983, calculated + // independently using 50-digit Decimal arithmetic (no + // cancellation possible at that precision). At Number's 19-digit + // mantissa this rounds to 0.38051750382930729 — the literal + // below. The vault's actual gain must agree to within + // sub-microcent precision. + Number const decimalReference{38051750382930729LL, -17}; + Number const tolerance{1, -6}; // 1e-6 USD = sub-microcent + Number const error = abs(vaultGain - decimalReference); + BEAST_EXPECTS( + error < tolerance, + "vault gain " + to_string(vaultGain) + " differs from Decimal reference " + + to_string(decimalReference) + " by " + to_string(error) + " — exceeds tolerance " + + to_string(tolerance)); + } + + void + runAmendmentIndependent() + { + testIssuerLoan(); + testBorrowerIsBroker(); + testFullLifecycleVaultPnLNearZeroRate(); + } + + // Tests run under each entry in amendmentCombinations(). + void + runAmendmentSensitive(FeatureBitset features) + { + testLifecycle(features); + testSelfLoan(features); + testIssuerIsBorrower(features); + testBatchBypassCounterparty(features); + } + +public: + void + run() override + { + runAmendmentIndependent(); + for (auto const& features : jtx::amendmentCombinations( + {fixCleanup3_1_3, fixCleanup3_2_0, featureMPTokensV2}, all_)) + runAmendmentSensitive(features); + } +}; + +BEAST_DEFINE_TESTSUITE(LoanLifecycle, tx, xrpl); + +} // namespace xrpl::test diff --git a/src/test/app/LoanMisc_test.cpp b/src/test/app/LoanMisc_test.cpp new file mode 100644 index 00000000000..7c0831b87ca --- /dev/null +++ b/src/test/app/LoanMisc_test.cpp @@ -0,0 +1,528 @@ +#include + +namespace xrpl::test { + +class LoanMisc_test : public LoanTestBase +{ +private: + void + testRPC(FeatureBitset features) + { + // This will expand as more test cases are added. Some functionality + // is tested in other test functions. + testcase("RPC"); + + using namespace jtx; + + Env env(*this, features); + + auto lowerFee = [&]() { + // Run the local fee back down. + while (env.app().getFeeTrack().lowerLocalFee()) + ; + }; + + auto const baseFee = env.current()->fees().base; + + Account const alice{"alice"}; + std::string const borrowerPass = "borrower"; + Account const borrower{borrowerPass, KeyType::Ed25519}; + auto const lenderPass = "lender"; + Account const lender{lenderPass, KeyType::Ed25519}; + + env.fund(XRP(1'000'000), alice, lender, borrower); + env.close(); + env(noop(lender)); + env(noop(lender)); + env(noop(lender)); + env(noop(lender)); + env(noop(lender)); + env.close(); + + { + testcase("RPC AccountSet"); + json::Value txJson{json::ValueType::Object}; + txJson[sfTransactionType] = "AccountSet"; + txJson[sfAccount] = borrower.human(); + + auto const signParams = [&]() { + json::Value signParams{json::ValueType::Object}; + signParams[jss::passphrase] = borrowerPass; + signParams[jss::key_type] = "ed25519"; + signParams[jss::tx_json] = txJson; + return signParams; + }(); + auto const jSign = env.rpc("json", "sign", to_string(signParams)); + BEAST_EXPECT(jSign.isMember(jss::result) && jSign[jss::result].isMember(jss::tx_json)); + auto txSignResult = jSign[jss::result][jss::tx_json]; + auto txSignBlob = jSign[jss::result][jss::tx_blob].asString(); + txSignResult.removeMember(jss::hash); + + auto const jtx = env.jt(txJson, Sig(borrower)); + BEAST_EXPECT(txSignResult == jtx.jv); + + lowerFee(); + auto const jSubmit = env.rpc("submit", txSignBlob); + BEAST_EXPECT( + jSubmit.isMember(jss::result) && + jSubmit[jss::result].isMember(jss::engine_result) && + jSubmit[jss::result][jss::engine_result].asString() == "tesSUCCESS"); + + lowerFee(); + env(jtx.jv, Sig(kNone), Seq(kNone), Fee(kNone), Ter(tefPAST_SEQ)); + } + + { + testcase("RPC LoanSet - illegal signature_target"); + + json::Value txJson{json::ValueType::Object}; + txJson[sfTransactionType] = "AccountSet"; + txJson[sfAccount] = borrower.human(); + + auto const borrowerSignParams = [&]() { + json::Value params{json::ValueType::Object}; + params[jss::passphrase] = borrowerPass; + params[jss::key_type] = "ed25519"; + params[jss::signature_target] = "Destination"; + params[jss::tx_json] = txJson; + return params; + }(); + auto const jSignBorrower = env.rpc("json", "sign", to_string(borrowerSignParams)); + BEAST_EXPECT( + jSignBorrower.isMember(jss::result) && + jSignBorrower[jss::result].isMember(jss::error) && + jSignBorrower[jss::result][jss::error] == "invalidParams" && + jSignBorrower[jss::result].isMember(jss::error_message) && + jSignBorrower[jss::result][jss::error_message] == "Destination"); + } + { + testcase("RPC LoanSet - sign and submit borrower initiated"); + // 1. Borrower creates the transaction + json::Value txJson{json::ValueType::Object}; + txJson[sfTransactionType] = "LoanSet"; + txJson[sfAccount] = borrower.human(); + txJson[sfCounterparty] = lender.human(); + txJson[sfLoanBrokerID] = + "FF924CD18A236C2B49CF8E80A351CEAC6A10171DC9F110025646894FEC" + "F83F" + "5C"; + txJson[sfPrincipalRequested] = "100000000"; + txJson[sfPaymentTotal] = 10000; + txJson[sfPaymentInterval] = 3600; + txJson[sfGracePeriod] = 300; + txJson[sfFlags] = 65536; // tfLoanOverpayment + txJson[sfFee] = to_string(24 * baseFee / 10); + + // 2. Borrower signs the transaction + auto const borrowerSignParams = [&]() { + json::Value params{json::ValueType::Object}; + params[jss::passphrase] = borrowerPass; + params[jss::key_type] = "ed25519"; + params[jss::tx_json] = txJson; + return params; + }(); + auto const jSignBorrower = env.rpc("json", "sign", to_string(borrowerSignParams)); + BEAST_EXPECTS( + jSignBorrower.isMember(jss::result) && + jSignBorrower[jss::result].isMember(jss::tx_json), + to_string(jSignBorrower)); + auto const txBorrowerSignResult = jSignBorrower[jss::result][jss::tx_json]; + auto const txBorrowerSignBlob = jSignBorrower[jss::result][jss::tx_blob].asString(); + + // 2a. Borrower attempts to submit the transaction. It doesn't + // work + { + lowerFee(); + auto const jSubmitBlob = env.rpc("submit", txBorrowerSignBlob); + BEAST_EXPECT(jSubmitBlob.isMember(jss::result)); + auto const jSubmitBlobResult = jSubmitBlob[jss::result]; + BEAST_EXPECT(jSubmitBlobResult.isMember(jss::tx_json)); + // Transaction fails because the CounterpartySignature is + // missing + BEAST_EXPECT( + jSubmitBlobResult.isMember(jss::engine_result) && + jSubmitBlobResult[jss::engine_result].asString() == "temBAD_SIGNER"); + } + + // 3. Borrower sends the signed transaction to the lender + // 4. Lender signs the transaction + auto const lenderSignParams = [&]() { + json::Value params{json::ValueType::Object}; + params[jss::passphrase] = lenderPass; + params[jss::key_type] = "ed25519"; + params[jss::signature_target] = "CounterpartySignature"; + params[jss::tx_json] = txBorrowerSignResult; + return params; + }(); + auto const jSignLender = env.rpc("json", "sign", to_string(lenderSignParams)); + BEAST_EXPECT( + jSignLender.isMember(jss::result) && + jSignLender[jss::result].isMember(jss::tx_json)); + auto const txLenderSignResult = jSignLender[jss::result][jss::tx_json]; + auto const txLenderSignBlob = jSignLender[jss::result][jss::tx_blob].asString(); + + // 5. Lender submits the signed transaction blob + lowerFee(); + auto const jSubmitBlob = env.rpc("submit", txLenderSignBlob); + BEAST_EXPECT(jSubmitBlob.isMember(jss::result)); + auto const jSubmitBlobResult = jSubmitBlob[jss::result]; + BEAST_EXPECT(jSubmitBlobResult.isMember(jss::tx_json)); + auto const jSubmitBlobTx = jSubmitBlobResult[jss::tx_json]; + // To get far enough to return tecNO_ENTRY means that the + // signatures all validated. Of course the transaction won't + // succeed because no Vault or Broker were created. + BEAST_EXPECTS( + jSubmitBlobResult.isMember(jss::engine_result) && + jSubmitBlobResult[jss::engine_result].asString() == "tecNO_ENTRY", + to_string(jSubmitBlobResult)); + + BEAST_EXPECT( + !jSubmitBlob.isMember(jss::error) && !jSubmitBlobResult.isMember(jss::error)); + + // 4-alt. Lender submits the transaction json originally + // received from the Borrower. It gets signed, but is now a + // duplicate, so fails. Borrower could done this instead of + // steps 4 and 5. + lowerFee(); + auto const jSubmitJson = env.rpc("json", "submit", to_string(lenderSignParams)); + BEAST_EXPECT(jSubmitJson.isMember(jss::result)); + auto const jSubmitJsonResult = jSubmitJson[jss::result]; + BEAST_EXPECT(jSubmitJsonResult.isMember(jss::tx_json)); + auto const jSubmitJsonTx = jSubmitJsonResult[jss::tx_json]; + // Since the previous tx claimed a fee, this duplicate is not + // going anywhere + BEAST_EXPECTS( + jSubmitJsonResult.isMember(jss::engine_result) && + jSubmitJsonResult[jss::engine_result].asString() == "tefPAST_SEQ", + to_string(jSubmitJsonResult)); + + BEAST_EXPECT( + !jSubmitJson.isMember(jss::error) && !jSubmitJsonResult.isMember(jss::error)); + + BEAST_EXPECT(jSubmitBlobTx == jSubmitJsonTx); + } + + { + testcase("RPC LoanSet - sign and submit lender initiated"); + // 1. Lender creates the transaction + json::Value txJson{json::ValueType::Object}; + txJson[sfTransactionType] = "LoanSet"; + txJson[sfAccount] = lender.human(); + txJson[sfCounterparty] = borrower.human(); + txJson[sfLoanBrokerID] = + "FF924CD18A236C2B49CF8E80A351CEAC6A10171DC9F110025646894FEC" + "F83F" + "5C"; + txJson[sfPrincipalRequested] = "100000000"; + txJson[sfPaymentTotal] = 10000; + txJson[sfPaymentInterval] = 3600; + txJson[sfGracePeriod] = 300; + txJson[sfFlags] = 65536; // tfLoanOverpayment + txJson[sfFee] = to_string(24 * baseFee / 10); + + // 2. Lender signs the transaction + auto const lenderSignParams = [&]() { + json::Value params{json::ValueType::Object}; + params[jss::passphrase] = lenderPass; + params[jss::key_type] = "ed25519"; + params[jss::tx_json] = txJson; + return params; + }(); + auto const jSignLender = env.rpc("json", "sign", to_string(lenderSignParams)); + BEAST_EXPECT( + jSignLender.isMember(jss::result) && + jSignLender[jss::result].isMember(jss::tx_json)); + auto const txLenderSignResult = jSignLender[jss::result][jss::tx_json]; + auto const txLenderSignBlob = jSignLender[jss::result][jss::tx_blob].asString(); + + // 2a. Lender attempts to submit the transaction. It doesn't + // work + { + lowerFee(); + auto const jSubmitBlob = env.rpc("submit", txLenderSignBlob); + BEAST_EXPECT(jSubmitBlob.isMember(jss::result)); + auto const jSubmitBlobResult = jSubmitBlob[jss::result]; + BEAST_EXPECT(jSubmitBlobResult.isMember(jss::tx_json)); + // Transaction fails because the CounterpartySignature is + // missing + BEAST_EXPECT( + jSubmitBlobResult.isMember(jss::engine_result) && + jSubmitBlobResult[jss::engine_result].asString() == "temBAD_SIGNER"); + } + + // 3. Lender sends the signed transaction to the Borrower + // 4. Borrower signs the transaction + auto const borrowerSignParams = [&]() { + json::Value params{json::ValueType::Object}; + params[jss::passphrase] = borrowerPass; + params[jss::key_type] = "ed25519"; + params[jss::signature_target] = "CounterpartySignature"; + params[jss::tx_json] = txLenderSignResult; + return params; + }(); + auto const jSignBorrower = env.rpc("json", "sign", to_string(borrowerSignParams)); + BEAST_EXPECT( + jSignBorrower.isMember(jss::result) && + jSignBorrower[jss::result].isMember(jss::tx_json)); + auto const txBorrowerSignResult = jSignBorrower[jss::result][jss::tx_json]; + auto const txBorrowerSignBlob = jSignBorrower[jss::result][jss::tx_blob].asString(); + + // 5. Borrower submits the signed transaction blob + lowerFee(); + auto const jSubmitBlob = env.rpc("submit", txBorrowerSignBlob); + BEAST_EXPECT(jSubmitBlob.isMember(jss::result)); + auto const jSubmitBlobResult = jSubmitBlob[jss::result]; + BEAST_EXPECT(jSubmitBlobResult.isMember(jss::tx_json)); + auto const jSubmitBlobTx = jSubmitBlobResult[jss::tx_json]; + // To get far enough to return tecNO_ENTRY means that the + // signatures all validated. Of course the transaction won't + // succeed because no Vault or Broker were created. + BEAST_EXPECTS( + jSubmitBlobResult.isMember(jss::engine_result) && + jSubmitBlobResult[jss::engine_result].asString() == "tecNO_ENTRY", + to_string(jSubmitBlobResult)); + + BEAST_EXPECT( + !jSubmitBlob.isMember(jss::error) && !jSubmitBlobResult.isMember(jss::error)); + + // 4-alt. Borrower submits the transaction json originally + // received from the Lender. It gets signed, but is now a + // duplicate, so fails. Lender could done this instead of steps + // 4 and 5. + lowerFee(); + auto const jSubmitJson = env.rpc("json", "submit", to_string(borrowerSignParams)); + BEAST_EXPECT(jSubmitJson.isMember(jss::result)); + auto const jSubmitJsonResult = jSubmitJson[jss::result]; + BEAST_EXPECT(jSubmitJsonResult.isMember(jss::tx_json)); + auto const jSubmitJsonTx = jSubmitJsonResult[jss::tx_json]; + // Since the previous tx claimed a fee, this duplicate is not + // going anywhere + BEAST_EXPECTS( + jSubmitJsonResult.isMember(jss::engine_result) && + jSubmitJsonResult[jss::engine_result].asString() == "tefPAST_SEQ", + to_string(jSubmitJsonResult)); + + BEAST_EXPECT( + !jSubmitJson.isMember(jss::error) && !jSubmitJsonResult.isMember(jss::error)); + + BEAST_EXPECT(jSubmitBlobTx == jSubmitJsonTx); + } + } + + void + testLendingCanTradeDisabledNoImpact() + { + testcase("Lending: CanTrade disabled has no impact"); + using namespace jtx; + using namespace loan; + using namespace loanBroker; + + Env env(*this, all_); + + Account const issuer{"issuer"}; + Account const lender{"lender"}; + Account const borrower{"borrower"}; + + env.fund(XRP(1'000'000), issuer, lender, borrower); + env.close(); + + MPTTester mpt( + {.env = env, + .issuer = issuer, + .holders = {lender, borrower}, + .flags = tfMPTCanTransfer | tfMPTCanLock, + .mutableFlags = tmfMPTCanEnableCanTrade}); + PrettyAsset const asset = mpt.issuanceID(); + env(pay(issuer, lender, asset(10'000'000))); + env(pay(issuer, borrower, asset(100'000))); + env.close(); + + auto const broker = createVaultAndBroker(env, asset, lender); + + // CanTrade is not set + env(offer(lender, XRP(1), asset(10)), Ter{tecNO_PERMISSION}); + env.close(); + + auto const loanSetFee = Fee(env.current()->fees().base * 2); + + // New cover deposits still work. + env(coverDeposit(lender, broker.brokerID, asset(100))); + env.close(); + + // New loan issuance still works. + env(loan::set(borrower, broker.brokerID, 1'000), + Sig(sfCounterpartySignature, lender), + loanSetFee); + env.close(); + auto const loanKeylet = keylet::loan(broker.brokerID, 1); + BEAST_EXPECT(env.le(loanKeylet)); + + // Repayment still works. + env(pay(borrower, loanKeylet.key, asset(1'000))); + env.close(); + + // Cover withdrawal still works. + env(coverWithdraw(lender, broker.brokerID, asset(100))); + env.close(); + + // Enable CanTrade and verify the DEX path is restored. + mpt.set({.mutableFlags = tmfMPTSetCanTrade}); + env.close(); + + env(offer(lender, XRP(1), asset(10))); + env.close(); + } + + void + runAmendmentIndependent() + { + testLendingCanTradeDisabledNoImpact(); + } + + // Tests run under each entry in amendmentCombinations(). + void + runAmendmentSensitive(FeatureBitset features) + { + testRPC(features); + } + +public: + void + run() override + { + runAmendmentIndependent(); + for (auto const& features : jtx::amendmentCombinations( + {fixCleanup3_1_3, fixCleanup3_2_0, featureMPTokensV2}, all_)) + runAmendmentSensitive(features); + } +}; + +class LoanBatch_test : public LoanTestBase +{ +protected: + beast::xor_shift_engine engine_; + + std::uniform_int_distribution<> assetDist_{0, 2}; + std::uniform_int_distribution principalDist_{100'000, 1'000'000'000}; + std::uniform_int_distribution interestRateDist_{0, 10000}; + std::uniform_int_distribution<> paymentTotalDist_{12, 10000}; + std::uniform_int_distribution<> paymentIntervalDist_{60, 3600 * 24 * 30}; + std::uniform_int_distribution managementFeeRateDist_{0, 10'000}; + std::uniform_int_distribution<> serviceFeeDist_{0, 20}; + /* + # Generate parameters that are more likely to be valid + principal = Decimal(str(rand.randint(100000, + 100'000'000))).quantize(ROUND_TARGET) + + interest_rate = Decimal(rand.randint(1, 10000)) / + Decimal(100000) + + payment_total = rand.randint(12, 10000) + + payment_interval = Decimal(str(rand.randint(60, 2629746))) + + interest_fee = Decimal(rand.randint(0, 100000)) / + Decimal(100000) +*/ + + void + testRandomLoan() + { + using namespace jtx; + + Account const issuer("issuer"); + Account const lender("lender"); + Account const borrower("borrower"); + + // Determine all the random parameters at once + auto const assetType = static_cast(assetDist_(engine_)); + auto const principalRequest = principalDist_(engine_); + TenthBips16 const managementFeeRate{managementFeeRateDist_(engine_)}; + auto const serviceFee = serviceFeeDist_(engine_); + TenthBips32 interest{interestRateDist_(engine_)}; + auto const payTotal = paymentTotalDist_(engine_); + auto const payInterval = paymentIntervalDist_(engine_); + + BrokerParameters const brokerParams{ + .vaultDeposit = principalRequest * 10, + .debtMax = 0, + .coverRateMin = TenthBips32{0}, + .managementFeeRate = managementFeeRate}; + LoanParameters const loanParams{ + .account = lender, + .counter = borrower, + .principalRequest = principalRequest, + .serviceFee = serviceFee, + .interest = interest, + .payTotal = payTotal, + .payInterval = payInterval, + }; + + runLoan(assetType, brokerParams, loanParams, all_); + } + +public: + void + run() override + { + auto const numIterations = [s = arg()]() -> int { + int const defaultNum = 5; + if (s.empty()) + return defaultNum; + try + { + std::size_t pos = 0; + auto const r = stoi(s, &pos); + if (pos != s.size()) + return defaultNum; + return r; + } + catch (...) + { + return defaultNum; + } + }(); + + using namespace jtx; + + auto const updateInterval = std::min(numIterations / 5, 100); + + for (int i = 0; i < numIterations; ++i) + { + if (i % updateInterval == 0) + testcase << "Random Loan Test iteration " << (i + 1) << "/" << numIterations; + testRandomLoan(); + } + } +}; + +class LoanArbitrary_test : public LoanBatch_test +{ + void + run() override + { + using namespace jtx; + + BrokerParameters const brokerParams{ + .vaultDeposit = 10000, + .debtMax = 0, + .coverRateMin = TenthBips32{0}, + .managementFeeRate = TenthBips16{0}, + .coverRateLiquidation = TenthBips32{0}}; + LoanParameters const loanParams{ + .account = Account("lender"), + .counter = Account("borrower"), + .principalRequest = Number{200000, -6}, + .interest = TenthBips32{50000}, + .payTotal = 2, + .payInterval = 200}; + + runLoan(AssetType::XRP, brokerParams, loanParams, all_); + } +}; + +BEAST_DEFINE_TESTSUITE(LoanMisc, tx, xrpl); +BEAST_DEFINE_TESTSUITE_MANUAL(LoanBatch, tx, xrpl); +BEAST_DEFINE_TESTSUITE_MANUAL(LoanArbitrary, tx, xrpl); + +} // namespace xrpl::test diff --git a/src/test/app/LoanPay_test.cpp b/src/test/app/LoanPay_test.cpp new file mode 100644 index 00000000000..f2719669f0d --- /dev/null +++ b/src/test/app/LoanPay_test.cpp @@ -0,0 +1,726 @@ +#include + +namespace xrpl::test { + +class LoanPay_test : public LoanTestBase +{ +private: +#if LOAN_TODO + void + testLoanPayLateFullPaymentBypassesPenalties(FeatureBitset features) + { + testcase("LoanPay full payment skips late penalties"); + using namespace jtx; + using namespace loan; + using namespace std::chrono_literals; + + Env env(*this, features); + + Account const issuer{"issuer"}; + Account const lender{"lender"}; + Account const borrower{"borrower"}; + + env.fund(XRP(1'000'000), issuer, lender, borrower); + env.close(); + + PrettyAsset const asset = issuer[iouCurrency]; + env(trust(lender, asset(100'000'000))); + env(trust(borrower, asset(100'000'000))); + env(pay(issuer, lender, asset(50'000'000))); + env(pay(issuer, borrower, asset(5'000'000))); + env.close(); + + BrokerInfo broker{createVaultAndBroker(env, asset, lender)}; + + auto const loanSetFee = Fee(env.current()->fees().base * 2); + + auto const brokerPreLoan = env.le(keylet::loanBroker(broker.brokerID)); + if (BEAST_EXPECT(brokerPreLoan); !brokerPreLoan.has_value()) + return; + + auto const loanSequence = brokerPreLoan->at(sfLoanSequence); + auto const loanKeylet = keylet::loan(broker.brokerID, loanSequence); + + Number const principal = asset(1'000).value(); + Number const serviceFee = asset(2).value(); + Number const lateFee = asset(5).value(); + Number const closeFee = asset(4).value(); + + env(set(borrower, broker.brokerID, principal), + Sig(sfCounterpartySignature, lender), + kLoanServiceFee(serviceFee), + kLatePaymentFee(lateFee), + kClosePaymentFee(closeFee), + kInterestRate(percentageToTenthBips(12)), + kLateInterestRate(percentageToTenthBips(24) / 10), + kCloseInterestRate(percentageToTenthBips(5)), + kPaymentTotal(12), + kPaymentInterval(600), + kGracePeriod(0), + Fee(loanSetFee)); + env.close(); + + auto state1 = getCurrentState(env, broker, loanKeylet); + if (!BEAST_EXPECT(state1.paymentRemaining > 1)) + return; + + using d = NetClock::duration; + using tp = NetClock::time_point; + auto const overdueClose = tp{d{state1.nextPaymentDate + state1.paymentInterval}}; + env.close(overdueClose); + + auto const brokerSle = env.le(keylet::loanBroker(broker.brokerID)); + auto const loanSle = env.le(loanKeylet); + if (!BEAST_EXPECT(brokerSle && loanSle)) + return; + + auto state = getCurrentState(env, broker, loanKeylet); + + TenthBips16 const managementFeeRate{brokerSle->at(sfManagementFeeRate)}; + TenthBips32 const interestRateValue{loanSle->at(sfInterestRate)}; + TenthBips32 const lateInterestRateValue{loanSle->at(sfLateInterestRate)}; + TenthBips32 const closeInterestRateValue{loanSle->at(sfCloseInterestRate)}; + + Number const closePaymentFeeRounded = + roundToAsset(broker.asset, loanSle->at(sfClosePaymentFee), state.loanScale); + Number const latePaymentFeeRounded = + roundToAsset(broker.asset, loanSle->at(sfLatePaymentFee), state.loanScale); + + auto const roundedLoanState = constructLoanState( + state.totalValue, state.principalOutstanding, state.managementFeeOutstanding); + Number const totalInterestOutstanding = roundedLoanState.interestDue; + + auto const periodicRate = loanPeriodicRate(interestRateValue, state.paymentInterval); + auto const rawLoanState = computeTheoreticalLoanState( + env.current()->rules(), + state.periodicPayment, + periodicRate, + state.paymentRemaining, + managementFeeRate); + + auto const parentCloseTime = env.current()->parentCloseTime(); + auto const startDateSeconds = + static_cast(state.startDate.time_since_epoch().count()); + + Number const fullPaymentInterest = computeFullPaymentInterest( + rawLoanState.principalOutstanding, + periodicRate, + parentCloseTime, + state.paymentInterval, + state.previousPaymentDate, + startDateSeconds, + closeInterestRateValue); + + Number const roundedFullInterestAmount = + roundToAsset(broker.asset, fullPaymentInterest, state.loanScale); + Number const roundedFullManagementFee = computeManagementFee( + broker.asset, roundedFullInterestAmount, managementFeeRate, state.loanScale); + Number const roundedFullInterest = roundedFullInterestAmount - roundedFullManagementFee; + + Number const trackedValueDelta = + state.principalOutstanding + totalInterestOutstanding + state.managementFeeOutstanding; + Number const untrackedManagementFee = + closePaymentFeeRounded + roundedFullManagementFee - state.managementFeeOutstanding; + Number const untrackedInterest = roundedFullInterest - totalInterestOutstanding; + + Number const baseFullDue = trackedValueDelta + untrackedInterest + untrackedManagementFee; + BEAST_EXPECT(baseFullDue == roundToAsset(broker.asset, baseFullDue, state.loanScale)); + + auto const overdueSeconds = + parentCloseTime.time_since_epoch().count() - state.nextPaymentDate; + if (!BEAST_EXPECT(overdueSeconds > 0)) + return; + + Number const overdueRate = loanPeriodicRate(lateInterestRateValue, overdueSeconds); + Number const lateInterestRaw = state.principalOutstanding * overdueRate; + Number const lateInterestRounded = + roundToAsset(broker.asset, lateInterestRaw, state.loanScale); + Number const lateManagementFeeRounded = computeManagementFee( + broker.asset, lateInterestRounded, managementFeeRate, state.loanScale); + Number const penaltyDue = + lateInterestRounded + lateManagementFeeRounded + latePaymentFeeRounded; + BEAST_EXPECT(penaltyDue > Number{}); + + auto const balanceBefore = env.balance(borrower, broker.asset).number(); + + STAmount const paymentAmount{broker.asset.raw(), baseFullDue}; + env(pay(borrower, loanKeylet.key, paymentAmount, tfLoanFullPayment)); + env.close(); + + if (auto const meta = env.meta(); BEAST_EXPECT(meta)) + BEAST_EXPECT(meta->at(sfTransactionResult) == tesSUCCESS); + + auto const balanceAfter = env.balance(borrower, broker.asset).number(); + Number const actualPaid = balanceBefore - balanceAfter; + BEAST_EXPECT(actualPaid == baseFullDue); + + Number const expectedWithPenalty = baseFullDue + penaltyDue; + BEAST_EXPECT(expectedWithPenalty > actualPaid); + BEAST_EXPECT(expectedWithPenalty - actualPaid == penaltyDue); + } +#endif + + void + testOverpaymentManagementFee(FeatureBitset features) + { + testcase("testOverpaymentManagementFee"); + + using namespace jtx; + using namespace loan; + + Env env{*this, features}; + + Account const lender{"lender"}, borrower{"borrower"}; + + env.fund(XRP(10'000'000), lender, borrower); + env.close(); + + PrettyAsset const asset{xrpIssue(), 1000}; + + auto const result = createVaultAndBroker( + env, + asset, + lender, + { + .vaultDeposit = asset(100'000).value(), + .managementFeeRate = TenthBips16(10'000), + }); + + auto const loanSetFee = Fee(env.current()->fees().base * 2); + + auto const loanKeylet = keylet::loan( + result.brokerKeylet().key, (env.le(result.brokerKeylet()))->at(sfLoanSequence)); + env(loan::set( + borrower, result.brokerKeylet().key, asset(10'000).value(), tfLoanOverpayment), + Sig(sfCounterpartySignature, lender), + loan::kPaymentInterval(86400 * 30), + loan::kPaymentTotal(3), + loan::kOverpaymentInterestRate(TenthBips32(percentageToTenthBips(20))), + loanSetFee); + + // From calculator + auto const expectedOverpaymentManagementFee = Number{33333, 0}; + auto const loanBrokerBalanceBefore = env.balance(lender); + + auto const loanPayFee = Fee(env.current()->fees().base * 2); + env(pay(borrower, loanKeylet.key, asset(5'000).value(), tfLoanOverpayment), loanPayFee); + env.close(); + + BEAST_EXPECTS( + env.balance(lender) - loanBrokerBalanceBefore == expectedOverpaymentManagementFee, + "overpayment management fee missmatch; expected:" + + to_string(expectedOverpaymentManagementFee) + + " got: " + to_string(env.balance(lender) - loanBrokerBalanceBefore)); + } + + void + testDosLoanPay(FeatureBitset features) + { + bool const feeCapped = features[fixCleanup3_1_3]; + + // From FIND-005 + testcase << "DoS LoanPay: fee calculation " << (feeCapped ? "capped" : "uncapped"); + + using namespace jtx; + using namespace std::chrono_literals; + using namespace Lending; + Env env(*this, features); + + Account const issuer{"issuer"}; + Account const lender{"lender"}; + Account const borrower{"borrower"}; + + env.fund(XRP(1'000'000), issuer, lender, borrower); + env.close(); + + BEAST_EXPECT(feeCapped == env.current()->rules().enabled(fixCleanup3_1_3)); + + PrettyAsset const iouAsset = issuer[iouCurrency_]; + env(trust(lender, iouAsset(100'000'000))); + env(trust(borrower, iouAsset(100'000'000))); + env(pay(issuer, lender, iouAsset(10'000'000))); + env(pay(issuer, borrower, iouAsset(1'000))); + env.close(); + + BrokerInfo const broker{createVaultAndBroker(env, iouAsset, lender)}; + + using namespace loan; + + auto const loanSetFee = Fee(env.current()->fees().base * 2); + Number const principalRequest{3959'37, -2}; + auto const baseFee = env.current()->fees().base; + + auto const createJson = env.json( + set(borrower, broker.brokerID, principalRequest), + Fee(loanSetFee), + Json(sfCounterpartySignature, json::ValueType::Object), + kClosePaymentFee(0), + kGracePeriod(60), + kInterestRate(TenthBips32(20930)), + kLateInterestRate(TenthBips32(77049)), + kLatePaymentFee(0), + kLoanServiceFee(0), + kOverpaymentFee(TenthBips32(7)), + kOverpaymentInterestRate(TenthBips32(66653)), + kPaymentInterval(60), + kPaymentTotal(3239184)); + + // There are enough payments due on this loan that it only needs to be + // created once, and can be paid on multiple times. Just don't create a + // gazillion test cases. + auto const keylet = nextLoanKeylet(env, broker); + + env(createJson, Sig(sfCounterpartySignature, lender)); + env.close(); + + auto const roundedPayment = [&]() { + auto const stateBefore = getCurrentState(env, broker, keylet); + BEAST_EXPECT(stateBefore.paymentRemaining == 3239184); + BEAST_EXPECT(stateBefore.paymentRemaining > kLoanMaximumPaymentsPerTransaction); + + return roundToAsset( + iouAsset, + stateBefore.periodicPayment, + stateBefore.loanScale, + Number::RoundingMode::Upward); + }(); + + auto test = [&](int const payFactor, + int const feeFactor, + TER const expectedTer = tesSUCCESS) { + auto const stateBefore = getCurrentState(env, broker, keylet); + BEAST_EXPECT(stateBefore.paymentRemaining <= 3239184); + BEAST_EXPECT(stateBefore.paymentRemaining > kLoanMaximumPaymentsPerTransaction); + + Number const amount = roundedPayment * payFactor; + auto loanPayTx = env.json(pay(borrower, keylet.key, STAmount{broker.asset, amount})); + XRPAmount const payFee{baseFee * feeFactor}; + env(loanPayTx, Ter(expectedTer), Fee(payFee)); + env.close(); + auto const expectedChange = isTesSuccess(expectedTer) + ? std::min(kLoanMaximumPaymentsPerTransaction, payFactor) + : 0; + + auto const stateAfter = getCurrentState(env, broker, keylet); + BEAST_EXPECT( + stateAfter.paymentRemaining == stateBefore.paymentRemaining - expectedChange); + }; + + static constexpr std::int64_t kMaxFeeIncrements = + kLoanMaximumPaymentsPerTransaction / kLoanPaymentsPerFeeIncrement; + + TER const failWithoutFix = feeCapped ? (TER)tesSUCCESS : (TER)telINSUF_FEE_P; + + // * Amount well above threshold -> capped fee + // The original test case - way over the limit - more fee is always ok + test(1819878, 363976); + // The capped fee is only sufficient if the amendment is enabled. + test(1819878, kMaxFeeIncrements, failWithoutFix); + + // * Amount exactly at threshold -> capped fee + test(kLoanMaximumPaymentsPerTransaction, kMaxFeeIncrements); + // More fee is always ok + test(kLoanMaximumPaymentsPerTransaction, kMaxFeeIncrements + 10); + + // * Amount below threshold -> normal calculation + test(1, 1); + test(kLoanPaymentsPerFeeIncrement * 2, 2); + test(0, 0, temBAD_AMOUNT); + test(0, 1, temBAD_AMOUNT); + // Fee difference rounds evenly + test( + kLoanMaximumPaymentsPerTransaction - 10, + ((kLoanMaximumPaymentsPerTransaction - 10) / kLoanPaymentsPerFeeIncrement) - 1, + telINSUF_FEE_P); + test( + kLoanMaximumPaymentsPerTransaction - 10, + ((kLoanMaximumPaymentsPerTransaction - 10) / kLoanPaymentsPerFeeIncrement)); + // More fee is always ok + test( + kLoanMaximumPaymentsPerTransaction - 10, + ((kLoanMaximumPaymentsPerTransaction - 10) / kLoanPaymentsPerFeeIncrement) + 3); + // Fee rounds up + for (int under = 1; under < kLoanPaymentsPerFeeIncrement; ++under) + { + test(kLoanMaximumPaymentsPerTransaction - under, kMaxFeeIncrements - 1, telINSUF_FEE_P); + test(kLoanMaximumPaymentsPerTransaction - under, kMaxFeeIncrements); + } + // Only when you get one less fee increment can you pay less + test( + kLoanMaximumPaymentsPerTransaction - kLoanPaymentsPerFeeIncrement, + kMaxFeeIncrements - 1); + // And again, more fee is always ok. + test(kLoanMaximumPaymentsPerTransaction - kLoanPaymentsPerFeeIncrement, kMaxFeeIncrements); + } + + // A LoanSet with InterestRate = 1 (0.001% annualized, the minimum non-zero + // rate). At such a near-zero rate the closed-form payment factor + // (1 + r)^n - 1 cancels catastrophically. + // + // Without fixCleanup3_2_0 the resulting amortization is degenerate and the + // LoanSet is rejected with tecPRECISION_LOSS (no loan created). With the + // amendment, computePowerMinusOneHybrid uses a numerically-stable series + // expansion, so the loan is created and the scheduled payments + // (2 * periodicPayment) cover the principal — no economic underpayment + // (yield theft). + // + // The test runs the same LoanSet under both amendment settings and pins the + // exact outcome for each. + void + testLoanSetNearZeroInterestRateSucceeds() + { + testcase("LoanSet near-zero interest rate covers principal"); + + using namespace jtx; + using namespace loan; + + Number const principalRequested{1000}; + + struct Result + { + TER ter = tesSUCCESS; + bool created = false; + std::int32_t loanScale = 0; + Number principal; + Number totalValue; + Number managementFee; + Number periodicPayment; + }; + + auto runScenario = [&](FeatureBitset features, TER expectedTer) -> Result { + Env env(*this, features); + + Account const issuer{"issuer"}; + Account const lender{"vaultOwner"}; + Account const borrower{"borrower"}; + + PrettyAsset const iouAsset = createFundedRippleIouAsset(env, issuer, lender, borrower); + + auto const broker = createVaultAndBroker( + env, + iouAsset, + lender, + {.vaultDeposit = 100'000, .debtMax = 0, .managementFeeRate = TenthBips16{0}}); + + auto const brokerSle = env.le(broker.brokerKeylet()); + BEAST_EXPECT(brokerSle); + auto const loanSequence = brokerSle ? brokerSle->at(sfLoanSequence) : 0; + auto const loanKeylet = keylet::loan(broker.brokerID, loanSequence); + + env(set(borrower, broker.brokerID, principalRequested), + Sig(sfCounterpartySignature, lender), + kInterestRate(TenthBips32{1}), + kPaymentTotal(2), + kPaymentInterval(400), + Fee(env.current()->fees().base * 2), + Ter(expectedTer)); + env.close(); + + Result r; + r.ter = env.ter(); + if (auto const loanSle = env.le(loanKeylet)) + { + r.created = true; + r.loanScale = loanSle->at(sfLoanScale); + r.principal = loanSle->at(sfPrincipalOutstanding); + r.totalValue = loanSle->at(sfTotalValueOutstanding); + r.managementFee = loanSle->at(sfManagementFeeOutstanding); + r.periodicPayment = loanSle->at(sfPeriodicPayment); + } + return r; + }; + + Result const fixed = runScenario(all_, tesSUCCESS); + Result const legacy = runScenario(all_ - fixCleanup3_2_0, tecPRECISION_LOSS); + + // Without the amendment, the catastrophically-cancelling closed-form + // payment factor produces a degenerate amortization that fails + // checkLoanGuards: the LoanSet is rejected with tecPRECISION_LOSS and no + // loan is created. + BEAST_EXPECT(legacy.ter == tecPRECISION_LOSS); + BEAST_EXPECT(!legacy.created); + + // With the amendment the stable series expansion produces a valid loan + // at loanScale -10. + BEAST_EXPECT(fixed.ter == tesSUCCESS); + BEAST_EXPECT(fixed.created); + BEAST_EXPECT(fixed.loanScale == -10); + BEAST_EXPECT(fixed.principal == principalRequested); + BEAST_EXPECT((fixed.totalValue == Number{10000000001903, -10})); + BEAST_EXPECT(fixed.managementFee == beast::kZero); + + // Periodic payment from the numerically-stable series expansion, and the + // scheduled total (2 * periodicPayment) which exceeds the 1000 principal + // — no economic underpayment / yield theft. + BEAST_EXPECT((fixed.periodicPayment == Number{5000000000951293762, -16})); + BEAST_EXPECT((fixed.periodicPayment * 2 == Number{1000000000190258752, -15})); + BEAST_EXPECT(fixed.periodicPayment * 2 > principalRequested); + } + + void + testLoanNextPaymentDueDateOverflow(FeatureBitset features) + { + // For FIND-013 + testcase << "Prevent nextPaymentDueDate overflow"; + + using namespace jtx; + using namespace std::chrono_literals; + using namespace Lending; + Env env{*this, features}; + + Account const issuer{"issuer"}; + Account const lender{"lender"}; + Account const borrower{"borrower"}; + + PrettyAsset const iouAsset = + createFundedIouAsset(env, issuer, lender, borrower, 100'000'000, 10'000'000); + + BrokerParameters const brokerParams{.debtMax = Number{0}, .coverRateMin = TenthBips32{1}}; + BrokerInfo broker{createVaultAndBroker(env, iouAsset, lender, brokerParams)}; + + using namespace loan; + + auto const loanSetFee = Fee(env.current()->fees().base * 2); + + using timeType = decltype(sfNextPaymentDueDate)::type::value_type; + static_assert(std::is_same_v); + constexpr timeType kMaxTime = std::numeric_limits::max(); + static_assert(kMaxTime == 4'294'967'295); + + auto const baseJson = [&]() { + auto createJson = env.json( + set(borrower, broker.brokerID, Number{55524'81, -2}), + Fee(loanSetFee), + kClosePaymentFee(0), + kGracePeriod(LoanSet::kDefaultGracePeriod), + kInterestRate(TenthBips32(12833)), + kLateInterestRate(TenthBips32(77048)), + kLatePaymentFee(0), + kLoanOriginationFee(218), + Json(sfCounterpartySignature, json::ValueType::Object)); + + createJson.removeMember(sfSequence.getJsonName()); + + return createJson; + }(); + + auto const baseFee = env.current()->fees().base; + + auto parentCloseTime = [&]() { + return env.current()->parentCloseTime().time_since_epoch().count(); + }; + auto maxLoanTime = [&]() { + auto const startDate = parentCloseTime(); + + BEAST_EXPECT(startDate >= 50); + + return kMaxTime - startDate; + }; + + { + // straight-up overflow: interval + auto const interval = maxLoanTime() + 1; + auto const total = 1; + auto createJson = env.json(baseJson, kPaymentInterval(interval), kPaymentTotal(total)); + + env(createJson, Sig(sfCounterpartySignature, lender), Ter(tecKILLED)); + env.close(); + } + { + // straight-up overflow: total + // min interval is 60 + auto const interval = 60; + auto const total = maxLoanTime() + 1; + auto createJson = env.json(baseJson, kPaymentInterval(interval), kPaymentTotal(total)); + + env(createJson, Sig(sfCounterpartySignature, lender), Ter(tecKILLED)); + env.close(); + } + { + // straight-up overflow: grace period + // min interval is 60 + auto const interval = maxLoanTime() + 1; + auto const total = 1; + auto const grace = interval; + auto createJson = env.json( + baseJson, kPaymentInterval(interval), kPaymentTotal(total), kGracePeriod(grace)); + + // The grace period can't be larger than the interval. + env(createJson, Sig(sfCounterpartySignature, lender), Ter(tecKILLED)); + env.close(); + } + { + // Overflow with multiplication of a few large intervals + auto const interval = 1'000'000'000; + auto const total = 10; + auto createJson = env.json(baseJson, kPaymentInterval(interval), kPaymentTotal(total)); + + env(createJson, Sig(sfCounterpartySignature, lender), Ter(tecKILLED)); + env.close(); + } + { + // Overflow with multiplication of many small payments + // min interval is 60 + auto const interval = 60; + auto const total = 1'000'000'000; + auto createJson = env.json(baseJson, kPaymentInterval(interval), kPaymentTotal(total)); + + env(createJson, Sig(sfCounterpartySignature, lender), Ter(tecKILLED)); + env.close(); + } + { + // Overflow with an absurdly large grace period + // min interval is 60 + auto const total = 60; + auto const interval = (maxLoanTime() - total) / total; + auto const grace = interval; + auto createJson = env.json( + baseJson, kPaymentInterval(interval), kPaymentTotal(total), kGracePeriod(grace)); + + env(createJson, Sig(sfCounterpartySignature, lender), Ter(tecKILLED)); + env.close(); + } + { + // Start date when the ledger is closed will be larger + auto const keylet = nextLoanKeylet(env, broker); + + auto const grace = 100; + auto const interval = maxLoanTime() - grace; + auto const total = 1; + auto createJson = env.json( + baseJson, kPaymentInterval(interval), kPaymentTotal(total), kGracePeriod(grace)); + + env(createJson, Sig(sfCounterpartySignature, lender), Ter(tesSUCCESS)); + env.close(); + + // The transaction is killed in the closed ledger + auto const meta = env.meta(); + if (BEAST_EXPECT(meta)) + { + BEAST_EXPECT(meta->at(sfTransactionResult) == tecKILLED); + } + + // If the transaction had succeeded, the loan would exist + auto const loanSle = env.le(keylet); + // but it doesn't + BEAST_EXPECT(!loanSle); + } + { + // Start date when the ledger is closed will be larger + auto const keylet = nextLoanKeylet(env, broker); + + auto const closeStartDate = ((parentCloseTime() / 10) + 1) * 10; + auto const grace = 5'000; + auto const interval = kMaxTime - closeStartDate - grace; + auto const total = 1; + auto createJson = env.json( + baseJson, kPaymentInterval(interval), kPaymentTotal(total), kGracePeriod(grace)); + + env(createJson, Sig(sfCounterpartySignature, lender), Ter(tesSUCCESS)); + env.close(); + + // The transaction succeeds in the closed ledger + auto const meta = env.meta(); + if (BEAST_EXPECT(meta)) + { + BEAST_EXPECT(meta->at(sfTransactionResult) == tesSUCCESS); + } + + // This loan exists + auto const afterState = getCurrentState(env, broker, keylet); + BEAST_EXPECT(afterState.nextPaymentDate == kMaxTime - grace); + BEAST_EXPECT(afterState.previousPaymentDate == 0); + BEAST_EXPECT(afterState.paymentRemaining == 1); + } + + { + // Ensure the borrower has funds to pay back the loan + env(pay(issuer, borrower, iouAsset(Number{1'055'524'81, -2}))); + + // Start date when the ledger is closed will be larger + auto const closeStartDate = ((parentCloseTime() / 10) + 1) * 10; + auto const grace = 5'000; + auto const maxLoanTime = kMaxTime - closeStartDate - grace; + auto const total = [&]() { + if (maxLoanTime % 5 == 0) + return 5; + if (maxLoanTime % 3 == 0) + return 3; + if (maxLoanTime % 2 == 0) + return 2; + return 0; + }(); + if (!BEAST_EXPECT(total != 0)) + return; + + auto const brokerState = env.le(keylet::loanBroker(broker.brokerID)); + // Intentionally shadow the outer values + auto const loanSequence = brokerState->at(sfLoanSequence); + auto const keylet = keylet::loan(broker.brokerID, loanSequence); + + auto const interval = maxLoanTime / total; + auto createJson = env.json( + baseJson, kPaymentInterval(interval), kPaymentTotal(total), kGracePeriod(grace)); + + env(createJson, Sig(sfCounterpartySignature, lender), Ter(tesSUCCESS)); + env.close(); + + // This loan exists + auto const beforeState = getCurrentState(env, broker, keylet); + BEAST_EXPECT(beforeState.nextPaymentDate == closeStartDate + interval); + BEAST_EXPECT(beforeState.previousPaymentDate == 0); + BEAST_EXPECT(beforeState.paymentRemaining == total); + BEAST_EXPECT(beforeState.periodicPayment > 0); + + // pay all but the last payment + { + NumberRoundModeGuard const mg{Number::RoundingMode::Upward}; + Number const payment = beforeState.periodicPayment * (total - 1); + XRPAmount const payFee{baseFee * ((total - 1) / kLoanPaymentsPerFeeIncrement + 1)}; + STAmount const paymentAmount = + roundToScale(STAmount{broker.asset, payment}, beforeState.loanScale); + auto loanPayTx = env.json(pay(borrower, keylet.key, paymentAmount), Fee(payFee)); + env(loanPayTx, Ter(tesSUCCESS)); + env.close(); + } + + // The loan is on the last payment + auto const afterState = getCurrentState(env, broker, keylet); + BEAST_EXPECT(afterState.paymentRemaining == 1); + BEAST_EXPECT(afterState.nextPaymentDate == kMaxTime - grace); + BEAST_EXPECT(afterState.previousPaymentDate == kMaxTime - grace - interval); + } + } + + void + runAmendmentIndependent() + { + testLoanSetNearZeroInterestRateSucceeds(); + } + + // Tests run under each entry in amendmentCombinations(). + void + runAmendmentSensitive(FeatureBitset features) + { +#if LOAN_TODO + testLoanPayLateFullPaymentBypassesPenalties(features); +#endif + testOverpaymentManagementFee(features); + testDosLoanPay(features); + testLoanNextPaymentDueDateOverflow(features); + } + +public: + void + run() override + { + runAmendmentIndependent(); + for (auto const& features : jtx::amendmentCombinations( + {fixCleanup3_1_3, fixCleanup3_2_0, featureMPTokensV2}, all_)) + runAmendmentSensitive(features); + } +}; + +BEAST_DEFINE_TESTSUITE(LoanPay, tx, xrpl); + +} // namespace xrpl::test diff --git a/src/test/app/LoanRegression_test.cpp b/src/test/app/LoanRounding_test.cpp similarity index 60% rename from src/test/app/LoanRegression_test.cpp rename to src/test/app/LoanRounding_test.cpp index 009c58fc5b3..e3fe0dcac16 100644 --- a/src/test/app/LoanRegression_test.cpp +++ b/src/test/app/LoanRounding_test.cpp @@ -2,260 +2,556 @@ namespace xrpl::test { -class LoanRegression_test : public LoanTestBase +class LoanRounding_test : public LoanTestBase { private: void - testRIPD3831(FeatureBitset features) + testDustManipulation(FeatureBitset features) { + testcase("Dust manipulation"); + using namespace jtx; + using namespace std::chrono_literals; + Env env{*this, features}; - testcase("RIPD-3831"); + // Setup: Create accounts + Account const issuer{"issuer"}; + Account const lender{"lender"}; + Account const borrower{"borrower"}; + Account const victim{"victim"}; - Account const issuer("issuer"); - Account const lender("lender"); - Account const borrower("borrower"); + env.fund(XRP(1'000'000'00), issuer, lender, borrower, victim); + env.close(); + + // Step 1: Create vault with IOU asset + auto asset = issuer["USD"]; + env(trust(lender, asset(100000))); + env(trust(borrower, asset(100000))); + env(trust(victim, asset(100000))); + env(pay(issuer, lender, asset(50000))); + env(pay(issuer, borrower, asset(50000))); + env(pay(issuer, victim, asset(50000))); + env.close(); BrokerParameters const brokerParams{ - .vaultDeposit = 100000, - .debtMax = 0, - .coverRateMin = TenthBips32{0}, - // .managementFeeRate = TenthBips16{5919}, - .coverRateLiquidation = TenthBips32{0}}; - LoanParameters const loanParams{ - .account = lender, - .counter = borrower, - .principalRequest = Number{200'000, -6}, - .lateFee = Number{200, -6}, - .interest = TenthBips32{50'000}, - .payTotal = 10, - .payInterval = 150}; + .vaultDeposit = 10000, + .debtMax = Number{0}, + .coverRateMin = TenthBips32{1000}, + .coverRateLiquidation = TenthBips32{2500}}; - auto const assetType = AssetType::XRP; + auto broker = createVaultAndBroker(env, asset, lender, brokerParams); - Env env{*this, features}; + auto const loanKeyletOpt = [&]() -> std::optional { + auto const brokerSle = env.le(keylet::loanBroker(broker.brokerID)); + if (!BEAST_EXPECT(brokerSle)) + return std::nullopt; - auto loanResult = - createLoan(env, assetType, brokerParams, loanParams, issuer, lender, borrower); + // Broker has no loans + BEAST_EXPECT(brokerSle->at(sfOwnerCount) == 0); - if (BEAST_EXPECT(loanResult); !loanResult.has_value()) + // The loan keylet is based on the LoanSequence of the + // _LOAN_BROKER_ object. + auto const loanSequence = brokerSle->at(sfLoanSequence); + return keylet::loan(broker.brokerID, loanSequence); + }(); + if (!loanKeyletOpt) return; - auto broker = std::get(*loanResult); - auto loanKeylet = std::get(*loanResult); - - using tp = NetClock::time_point; - using d = NetClock::duration; + auto const& vaultKeylet = broker.vaultKeylet(); - auto state = getCurrentState(env, broker, loanKeylet); - if (auto loan = env.le(loanKeylet); BEAST_EXPECT(loan)) { - env.close(tp{d{loan->at(sfNextPaymentDueDate) + loan->at(sfGracePeriod) + 1}}); + auto const vaultSle = env.le(vaultKeylet); + Number const assetsTotal = vaultSle->at(sfAssetsTotal); + Number const assetsAvail = vaultSle->at(sfAssetsAvailable); + + log << "Before loan creation:" << std::endl; + log << " AssetsTotal: " << assetsTotal << std::endl; + log << " AssetsAvailable: " << assetsAvail << std::endl; + log << " Difference: " << (assetsTotal - assetsAvail) << std::endl; + + // before the loan the assets total and available should be equal + BEAST_EXPECT(assetsAvail == assetsTotal); + BEAST_EXPECT(assetsAvail == broker.asset(brokerParams.vaultDeposit).number()); } - topUpBorrower(env, broker, issuer, borrower, state, loanParams.serviceFee); + Keylet const& loanKeylet = *loanKeyletOpt; - using namespace jtx::loan; + LoanParameters const loanParams{ + .account = lender, + .counter = borrower, + .principalRequest = Number{100}, + .interest = TenthBips32{1922}, + .payTotal = 5816, + .payInterval = 86400 * 6, + .gracePd = 86400 * 5, + }; + + env(loanParams(env, broker)); + env.close(); - auto jv = pay(borrower, loanKeylet.key, drops(XRPAmount(state.totalValue))); + // Wait for loan to be late enough to default + env.close(std::chrono::seconds(86400 * 40)); // 40 days { - auto const submitParam = to_string(jv); - auto const jr = env.rpc("submit", borrower.name(), submitParam); + auto const vaultSle = env.le(vaultKeylet); + Number const assetsTotal = vaultSle->at(sfAssetsTotal); + Number const assetsAvail = vaultSle->at(sfAssetsAvailable); - BEAST_EXPECT(jr.isMember(jss::result)); - auto const jResult = jr[jss::result]; + log << "After loan creation:" << std::endl; + log << " AssetsTotal: " << assetsTotal << std::endl; + log << " AssetsAvailable: " << assetsAvail << std::endl; + log << " Difference: " << (assetsTotal - assetsAvail) << std::endl; + + auto const loanSle = env.le(loanKeylet); + if (!BEAST_EXPECT(loanSle)) + return; + auto const state = constructLoanState(loanSle); + + log << "Loan state:" << std::endl; + log << " ValueOutstanding: " << state.valueOutstanding << std::endl; + log << " PrincipalOutstanding: " << state.principalOutstanding << std::endl; + log << " InterestOutstanding: " << state.interestOutstanding() << std::endl; + log << " InterestDue: " << state.interestDue << std::endl; + log << " FeeDue: " << state.managementFeeDue << std::endl; + + // after loan creation the assets total and available should + // reflect the value of the loan + BEAST_EXPECT(assetsAvail < assetsTotal); + BEAST_EXPECT( + assetsAvail == + broker.asset(brokerParams.vaultDeposit - loanParams.principalRequest).number()); + BEAST_EXPECT( + assetsTotal == + broker.asset(brokerParams.vaultDeposit + state.interestDue).number()); } + // Step 7: Trigger default (dust adjustment will occur) + env(jtx::loan::manage(lender, loanKeylet.key, tfLoanDefault)); env.close(); - // Make sure the system keeps responding - env(noop(borrower)); - env.close(); - env(noop(issuer)); - env.close(); - env(noop(lender)); - env.close(); + // Step 8: Verify phantom assets created + { + auto const vaultSle2 = env.le(vaultKeylet); + Number const assetsTotal2 = vaultSle2->at(sfAssetsTotal); + Number const assetsAvail2 = vaultSle2->at(sfAssetsAvailable); + + log << "After default:" << std::endl; + log << " AssetsTotal: " << assetsTotal2 << std::endl; + log << " AssetsAvailable: " << assetsAvail2 << std::endl; + log << " Difference: " << (assetsTotal2 - assetsAvail2) << std::endl; + + // after a default the assets total and available should be equal + BEAST_EXPECT(assetsAvail2 == assetsTotal2); + } } void - testRIPD3459(FeatureBitset features) + testRoundingAllowsUndercoverage(FeatureBitset features) { - testcase("RIPD-3459 - LoanBroker incorrect debt total"); + testcase("Minimum cover rounding allows undercoverage (XRP)"); using namespace jtx; + using namespace loanBroker; - Account const issuer("issuer"); - Account const lender("lender"); - Account const borrower("borrower"); + Env env{*this, features}; - BrokerParameters const brokerParams{ - .vaultDeposit = 200'000, - .debtMax = 0, - .coverRateMin = TenthBips32{0}, - .managementFeeRate = TenthBips16{500}, - .coverRateLiquidation = TenthBips32{0}}; - LoanParameters const loanParams{ - .account = lender, - .counter = borrower, - .principalRequest = Number{100'000, -4}, - .interest = TenthBips32{100'000}, - .payTotal = 10}; + Account const lender{"lender"}; + Account const borrower{"borrower"}; - auto const assetType = AssetType::MPT; + env.fund(XRP(200'000), lender, borrower); + env.close(); - Env env{*this, features}; + // Vault with XRP asset + Vault const vault{env}; + auto [vaultCreate, vaultKeylet] = vault.create({.owner = lender, .asset = xrpIssue()}); + env(vaultCreate); + env.close(); + BEAST_EXPECT(env.le(vaultKeylet)); - auto loanResult = - createLoan(env, assetType, brokerParams, loanParams, issuer, lender, borrower); + // Seed the vault with XRP so it can fund the loan principal + PrettyAsset const xrpAsset{xrpIssue(), 1}; - if (BEAST_EXPECT(loanResult); !loanResult.has_value()) - return; + BrokerParameters const brokerParams{ + .vaultDeposit = 1'000, + .debtMax = Number{0}, + .coverRateMin = TenthBips32{10'000}, + .coverDeposit = 82, + }; + + auto const brokerInfo = createVaultAndBroker(env, xrpAsset, lender, brokerParams); + // Create a loan with principal 804 XRP and 0% interest (so + // DebtTotal increases by exactly 804) + env(loan::set(borrower, brokerInfo.brokerID, xrpAsset(804).value()), + loan::kInterestRate(TenthBips32(0)), + Sig(sfCounterpartySignature, lender), + Fee(env.current()->fees().base * 2)); + BEAST_EXPECT(env.ter() == tesSUCCESS); + env.close(); - auto broker = std::get(*loanResult); - auto loanKeylet = std::get(*loanResult); - auto pseudoAcct = std::get(*loanResult); + // Verify DebtTotal is exactly 804 + if (auto const brokerSle = env.le(keylet::loanBroker(brokerInfo.brokerID)); + BEAST_EXPECT(brokerSle)) + { + log << *brokerSle << std::endl; + BEAST_EXPECT(brokerSle->at(sfDebtTotal) == Number(804)); + } - VerifyLoanStatus const verifyLoanStatus(env, broker, pseudoAcct, loanKeylet); + // Attempt to withdraw 2 XRP to self, leaving 80 XRP CoverAvailable. + // The minimum is 80.4 XRP, which rounds up to 81 XRP, so this fails. + env(coverWithdraw(lender, brokerInfo.brokerID, xrpAsset(2).value()), + Ter(tecINSUFFICIENT_FUNDS)); + BEAST_EXPECT(env.ter() == tecINSUFFICIENT_FUNDS); + env.close(); - if (auto const brokerSle = env.le(broker.brokerKeylet()); BEAST_EXPECT(brokerSle)) + // Attempt to withdraw 1 XRP to self, leaving 81 XRP CoverAvailable. + // because that leaves sufficient cover, this succeeds + env(coverWithdraw(lender, brokerInfo.brokerID, xrpAsset(1).value())); + BEAST_EXPECT(env.ter() == tesSUCCESS); + env.close(); + + // Validate CoverAvailable == 80 XRP and DebtTotal remains 804 + if (auto const brokerSle = env.le(keylet::loanBroker(brokerInfo.brokerID)); + BEAST_EXPECT(brokerSle)) { - if (auto const loanSle = env.le(loanKeylet); BEAST_EXPECT(loanSle)) - { - BEAST_EXPECT(brokerSle->at(sfDebtTotal) == loanSle->at(sfTotalValueOutstanding)); - } + log << *brokerSle << std::endl; + BEAST_EXPECT(brokerSle->at(sfCoverAvailable) == xrpAsset(81).value()); + BEAST_EXPECT(brokerSle->at(sfDebtTotal) == Number(804)); + + // Also demonstrate that the true minimum (804 * 10%) exceeds 80 + auto const theoreticalMin = tenthBipsOfValue(Number(804), TenthBips32(10'000)); + log << "Theoretical min cover: " << theoreticalMin << std::endl; + BEAST_EXPECT(Number(804, -1) == theoreticalMin); } + } + + void + testYieldTheftRounding(std::uint32_t flags) + { + testcase("Rounding manipulation does not permit yield theft"); + using namespace jtx; + using namespace loan; - makeLoanPayments( + // 1. Setup Environment + Env env(*this, all_); + Account const issuer{"issuer"}; + Account const lender{"lender"}; + Account const borrower{"borrower"}; + + env.fund(XRP(1000), issuer, lender, borrower); + env.close(); + + // 2. Asset Selection + PrettyAsset const iou = issuer["USD"]; + env(trust(lender, iou(100'000'000))); + env(trust(borrower, iou(100'000'000))); + env(pay(issuer, lender, iou(100'000'000))); + env(pay(issuer, borrower, iou(100'000'000))); + env.close(); + + // 3. Create Vault and Broker with High Debt Limit (100M) + auto const brokerInfo = createVaultAndBroker( env, - broker, - loanParams, - loanKeylet, - verifyLoanStatus, - issuer, + iou, lender, - borrower, - PaymentParameters{.showStepBalances = true}); + { + .vaultDeposit = 5'000'000, + .debtMax = Number{100'000'000}, + .coverDeposit = 500'000, + }); + auto const [currentSeq, vaultKeylet] = [&]() { + auto const brokerSle = env.le(keylet::loanBroker(brokerInfo.brokerID)); + if (!BEAST_EXPECT(brokerSle)) + return std::make_tuple(0u, keylet::unchecked(beast::kZero)); + auto const currentSeq = brokerSle->at(sfLoanSequence); + auto const vaultKeylet = keylet::vault(brokerSle->at(sfVaultID)); + return std::make_tuple(currentSeq, vaultKeylet); + }(); + + // 4. Loan Parameters (Attack Vector) + Number const principal = 1'000'000; + TenthBips32 const interestRate = TenthBips32{1}; // 0.001% + std::uint32_t const paymentInterval = 86400; + std::uint32_t const paymentTotal = 3650; - if (auto const brokerSle = env.le(broker.brokerKeylet()); BEAST_EXPECT(brokerSle)) + auto const loanSetFee = Fee(env.current()->fees().base * 2); + env(set(borrower, brokerInfo.brokerID, iou(principal).value(), flags), + Sig(sfCounterpartySignature, lender), + loan::kInterestRate(interestRate), + loan::kPaymentInterval(paymentInterval), + loan::kPaymentTotal(paymentTotal), + Fee(loanSetFee)); + env.close(); + + // --- RETRIEVE OBJECTS & SETUP ATTACK --- + + auto borrowerBalance = [&]() { return env.balance(borrower, iou); }; + auto const borrowerScale = static_cast(borrowerBalance()).exponent(); + + auto const loanKeylet = keylet::loan(brokerInfo.brokerID, currentSeq); + auto const maybePeriodicPayment = [&]() -> std::optional { + auto const loanSle = env.le(loanKeylet); + if (!BEAST_EXPECT(loanSle)) + return std::nullopt; + // Construct Payment + return STAmount{iou, loanSle->at(sfPeriodicPayment)}; + }(); + if (!maybePeriodicPayment) + return; + auto const periodicPayment = *maybePeriodicPayment; + auto const roundedPayment = + roundToScale(periodicPayment, borrowerScale, Number::RoundingMode::Upward); + + // ATTACK: Add dust buffer (1e-9) to force 'excess' logic execution + STAmount const paymentBuffer{iou, Number(1, -9)}; + STAmount const attackPayment = periodicPayment + paymentBuffer; + + auto const maybeInitialVaultAssets = [&]() -> std::optional { + auto const vault = env.le(vaultKeylet); + if (!BEAST_EXPECT(vault)) + return std::nullopt; + return vault->at(sfAssetsTotal); + }(); + if (!maybeInitialVaultAssets) + return; + auto const initialVaultAssets = *maybeInitialVaultAssets; + + // 5. Execution Loop + int yieldTheftCount = 0; + auto previousAssetsTotal = initialVaultAssets; + + for (int i = 0; i < 100; ++i) { - if (auto const loanSle = env.le(loanKeylet); BEAST_EXPECT(loanSle)) + auto const balanceBefore = borrowerBalance(); + env(pay(borrower, loanKeylet.key, attackPayment, flags)); + env.close(); + auto const borrowerDelta = balanceBefore - borrowerBalance(); + BEAST_EXPECT(borrowerDelta.signum() == roundedPayment.signum()); + + auto const loanSle = env.le(loanKeylet); + if (!BEAST_EXPECT(loanSle)) + break; + auto const updatedPayment = STAmount{iou, loanSle->at(sfPeriodicPayment)}; + BEAST_EXPECT( + (roundToScale(updatedPayment, borrowerScale, Number::RoundingMode::Upward) == + roundedPayment)); + BEAST_EXPECT( + (updatedPayment == periodicPayment) || + (flags == tfLoanOverpayment && i >= 2 && updatedPayment < periodicPayment)); + + auto const currentVaultSle = env.le(vaultKeylet); + if (!BEAST_EXPECT(currentVaultSle)) + break; + + auto const currentAssetsTotal = currentVaultSle->at(sfAssetsTotal); + auto const delta = currentAssetsTotal - previousAssetsTotal; + + BEAST_EXPECT( + (delta == beast::kZero && borrowerDelta <= roundedPayment) || + (delta > beast::kZero && borrowerDelta > roundedPayment)); + + // If tx succeeded but Assets Total didn't change, interest was + // stolen. + if (delta == beast::kZero && borrowerDelta > roundedPayment) { - BEAST_EXPECT(brokerSle->at(sfDebtTotal) == loanSle->at(sfTotalValueOutstanding)); - BEAST_EXPECT(brokerSle->at(sfDebtTotal) == beast::kZero); + yieldTheftCount++; } + + previousAssetsTotal = currentAssetsTotal; } + + BEAST_EXPECTS(yieldTheftCount == 0, std::to_string(yieldTheftCount)); } + // Regression for the dual-rounding fix at coarse (integer-MPT) scale. + // + // Loan: P=1, r=50% (50000 tenth-bips), n=3, yearly interval. The + // amortization schedule produces a fractional principal + // (~0.47) which under round-to-nearest collapses to 0 in a single + // step, causing `doPayment`'s strict `>` assertion on principal to + // fire mid-loan. With fixCleanup3_2_0 enabled, principal is rounded + // upward (sticks at 1 across the first two periods) and only clears + // in the final payment. + // + // The test pays one period at a time across three LoanPay + // transactions and verifies the loan completes (paymentRemaining=0) + // with totals matching the loan's economics (1 principal + 2 interest). void - testRIPD3901() + testIntegerScalePrincipalSticks(FeatureBitset features) { - testcase("Crash with tfLoanOverpayment"); + // Without fixCleanup3_2_0, this behavior will abort the server, so + // don't run without it. + if (!features[fixCleanup3_2_0]) + return; + + testcase("edge: integer MPT principal stuck mid-loan completes via final"); + using namespace jtx; - using namespace loan; - Account const lender{"lender"}; + Env env(*this, features); + Account const issuer{"issuer"}; + Account const lender{"lender"}; Account const borrower{"borrower"}; - Account const depositor{"depositor"}; - auto const txFee = Fee(XRP(100)); - Env env(*this); - Vault const vault(env); - - env.fund(XRP(10'000), lender, issuer, borrower, depositor); + env.fund(XRP(100'000), issuer, lender, borrower); env.close(); - auto [tx, vaultKeyLet] = vault.create({.owner = lender, .asset = xrpIssue()}); - env(tx, txFee); - env.close(); + MPTTester mptt{env, issuer, kMptInitNoFund}; + mptt.create({.maxAmt = 100'000, .flags = tfMPTCanTransfer}); + PrettyAsset const asset{mptt.issuanceID()}; + + mptt.authorize({.account = lender}); + mptt.authorize({.account = borrower}); - env(vault.deposit({.depositor = depositor, .id = vaultKeyLet.key, .amount = XRP(1'000)}), - txFee); + env(pay(issuer, lender, asset(10'000))); + env(pay(issuer, borrower, asset(10'000))); env.close(); - auto const brokerKeyLet = keylet::loanBroker(lender.id(), env.seq(lender)); + Vault const vault{env}; + auto [vaultTx, vaultKeylet] = vault.create({.owner = lender, .asset = asset}); + env(vaultTx); + env.close(); - env(loanBroker::set(lender, vaultKeyLet.key), txFee); + env(vault.deposit({.depositor = lender, .id = vaultKeylet.key, .amount = asset(5'000)})); env.close(); - // BrokerInfo brokerInfo{xrpIssue(), keylet, vaultKeyLet, {}}; + auto const brokerKeylet = keylet::loanBroker(lender.id(), env.seq(lender)); + env(loanBroker::set(lender, vaultKeylet.key), + loanBroker::kDebtMaximum(Number{100}), + Fee(env.current()->fees().base * 2)); + env.close(); - STAmount const debtMaximumRequest = XRPAmount(200'000); + auto const brokerStateBefore = env.le(brokerKeylet); + if (!BEAST_EXPECT(brokerStateBefore)) + return; + auto const loanSequence = brokerStateBefore->at(sfLoanSequence); + auto const loanKeylet = keylet::loan(brokerKeylet.key, loanSequence); - env(set(borrower, brokerKeyLet.key, debtMaximumRequest), + env(loan::set(borrower, brokerKeylet.key, Number{1}), Sig(sfCounterpartySignature, lender), - kInterestRate(TenthBips32(50'000)), - kPaymentTotal(2), - kPaymentInterval(150), - Txflags(tfLoanOverpayment), - txFee); + loan::kInterestRate(TenthBips32{50'000}), + loan::kPaymentTotal(3), + loan::kPaymentInterval(31'536'000), + Fee(env.current()->fees().base * 2)); env.close(); - std::uint32_t const loanSequence = 1; - auto const loanKeylet = keylet::loan(brokerKeyLet.key, loanSequence); + auto const borrowerStart = env.balance(borrower, asset).value(); - if (auto loan = env.le(loanKeylet); env.test.BEAST_EXPECT(loan)) + // Three separate periodic payments of 1 each. Expected per-period + // evolution at integer MPT scale (TVO = PO + interestDue + + // managementFeeDue): + // start: PO=1, TVO=3, paymentRemaining=3 + // after pay #1: PO=1, TVO=2, paymentRemaining=2 (principal sticks) + // after pay #2: PO=1, TVO=1, paymentRemaining=1 (principal sticks) + // after pay #3: PO=0, TVO=0, paymentRemaining=0 (final clears) + std::array const expectedPO{Number{1}, Number{1}, Number{0}}; + std::array const expectedTVO{Number{2}, Number{1}, Number{0}}; + std::array const expectedRemaining{2, 1, 0}; + + for (int i = 0; i < 3; ++i) { - env(loan::pay(borrower, loanKeylet.key, XRPAmount(150'001)), - Txflags(tfLoanOverpayment), - txFee); + env(loan::pay(borrower, loanKeylet.key, asset(1)), Ter(tesSUCCESS)); env.close(); + + auto const sle = env.le(loanKeylet); + if (!BEAST_EXPECT(sle)) + return; + BEAST_EXPECT(sle->at(sfPrincipalOutstanding) == expectedPO[i]); + BEAST_EXPECT(sle->at(sfTotalValueOutstanding) == expectedTVO[i]); + BEAST_EXPECT(sle->at(sfPaymentRemaining) == expectedRemaining[i]); } + + // Borrower paid 3 total regardless of fee split (1 principal + 2 + // interest+fee, matching loan economics). + auto const borrowerEnd = env.balance(borrower, asset).value(); + BEAST_EXPECT(borrowerStart - borrowerEnd == asset(3).value()); } +#if LOAN_TODO void - testRIPD3902(FeatureBitset features) + testLoanCoverMinimumRoundingExploit(FeatureBitset features) { - testcase("RIPD-3902 - 1 IOU loan payments"); + auto testLoanCoverMinimumRoundingExploit = [&, this](Number const& principalRequest) { + testcase << "LoanBrokerCoverClawback drains cover via rounding" + << " principalRequested=" << to_string(principalRequest); - using namespace jtx; + using namespace jtx; + using namespace loan; + using namespace loanBroker; - Account const issuer("issuer"); - Account const lender("lender"); - Account const borrower("borrower"); + Env env(*this, features); - BrokerParameters const brokerParams{ - .vaultDeposit = 10, - .debtMax = 0, - .coverRateMin = TenthBips32{0}, - .managementFeeRate = TenthBips16{0}, - .coverRateLiquidation = TenthBips32{0}}; - LoanParameters const loanParams{ - .account = lender, - .counter = borrower, - .principalRequest = Number{1, 0}, - .interest = TenthBips32{100'000}, - .payTotal = 5, - .payInterval = 150, - .gracePd = 60}; + Account const issuer{"issuer"}; + Account const lender{"lender"}; + Account const borrower{"borrower"}; - auto const assetType = AssetType::IOU; + env.fund(XRP(1'000'000'000), issuer, lender, borrower); + env.close(); - Env env{*this, features}; + env(fset(issuer, asfAllowTrustLineClawback)); + env.close(); - auto loanResult = - createLoan(env, assetType, brokerParams, loanParams, issuer, lender, borrower); + PrettyAsset const asset = issuer[iouCurrency]; + env(trust(lender, asset(2'000'0000))); + env(trust(borrower, asset(2'000'0000))); + env.close(); - if (BEAST_EXPECT(loanResult); !loanResult.has_value()) - return; + env(pay(issuer, lender, asset(2'000'0000))); + env.close(); - auto broker = std::get(*loanResult); - auto loanKeylet = std::get(*loanResult); - auto pseudoAcct = std::get(*loanResult); + BrokerParameters brokerParams{.debtMax = 0, .coverRateMin = TenthBips32{10'000}}; + BrokerInfo broker{createVaultAndBroker(env, asset, lender, brokerParams)}; - VerifyLoanStatus const verifyLoanStatus(env, broker, pseudoAcct, loanKeylet); + auto const loanSetFee = Fee(env.current()->fees().base * 2); + auto createTx = env.jt( + set(borrower, broker.brokerID, principalRequest), + Sig(sfCounterpartySignature, lender), + loanSetFee, + kPaymentInterval(600), + kPaymentTotal(1), + kGracePeriod(60)); + env(createTx); + env.close(); - makeLoanPayments( - env, - broker, - loanParams, - loanKeylet, - verifyLoanStatus, - issuer, - lender, - borrower, - PaymentParameters{.showStepBalances = true}); + auto const brokerBefore = env.le(keylet::loanBroker(broker.brokerID)); + BEAST_EXPECT(brokerBefore); + if (!brokerBefore) + return; + + Number const debtOutstanding = brokerBefore->at(sfDebtTotal); + Number const coverAvailableBefore = brokerBefore->at(sfCoverAvailable); + + BEAST_EXPECT(debtOutstanding > Number{}); + BEAST_EXPECT(coverAvailableBefore > Number{}); + + log << "debt=" << to_string(debtOutstanding) + << " cover_available=" << to_string(coverAvailableBefore); + + env(coverClawback(issuer, 0), loanBrokerID(broker.brokerID)); + env.close(); + + auto const brokerAfter = env.le(keylet::loanBroker(broker.brokerID)); + BEAST_EXPECT(brokerAfter); + if (!brokerAfter) + return; + + Number const debtAfter = brokerAfter->at(sfDebtTotal); + // the debt has not changed + BEAST_EXPECT(debtAfter == debtOutstanding); + + Number const coverAvailableAfter = brokerAfter->at(sfCoverAvailable); + + // since the cover rate min != 0, the cover available should not + // be zero + BEAST_EXPECT(coverAvailableAfter != Number{}); + }; + + // Call the lambda with different principal values + testLoanCoverMinimumRoundingExploit(Number{1, -30}); // 1e-30 units + testLoanCoverMinimumRoundingExploit(Number{1, -20}); // 1e-20 units + testLoanCoverMinimumRoundingExploit(Number{1, -10}); // 1e-10 units + testLoanCoverMinimumRoundingExploit(Number{1, 1}); // 1e-10 units } +#endif // A residual overpayment can reduce the stored principal by one scale-unit // *less* than computeOverpaymentComponents predicts, firing the @@ -493,110 +789,6 @@ class LoanRegression_test : public LoanTestBase "fee-case mgmt fee change " + to_string(feeFixed.managementFeeChange)); } - // A LoanSet with InterestRate = 1 (0.001% annualized, the minimum non-zero - // rate). At such a near-zero rate the closed-form payment factor - // (1 + r)^n - 1 cancels catastrophically. - // - // Without fixCleanup3_2_0 the resulting amortization is degenerate and the - // LoanSet is rejected with tecPRECISION_LOSS (no loan created). With the - // amendment, computePowerMinusOneHybrid uses a numerically-stable series - // expansion, so the loan is created and the scheduled payments - // (2 * periodicPayment) cover the principal — no economic underpayment - // (yield theft). - // - // The test runs the same LoanSet under both amendment settings and pins the - // exact outcome for each. - void - testLoanSetNearZeroInterestRateSucceeds() - { - testcase("LoanSet near-zero interest rate covers principal"); - - using namespace jtx; - using namespace loan; - - Number const principalRequested{1000}; - - struct Result - { - TER ter = tesSUCCESS; - bool created = false; - std::int32_t loanScale = 0; - Number principal; - Number totalValue; - Number managementFee; - Number periodicPayment; - }; - - auto runScenario = [&](FeatureBitset features, TER expectedTer) -> Result { - Env env(*this, features); - - Account const issuer{"issuer"}; - Account const lender{"vaultOwner"}; - Account const borrower{"borrower"}; - - PrettyAsset const iouAsset = createFundedRippleIouAsset(env, issuer, lender, borrower); - - auto const broker = createVaultAndBroker( - env, - iouAsset, - lender, - {.vaultDeposit = 100'000, .debtMax = 0, .managementFeeRate = TenthBips16{0}}); - - auto const brokerSle = env.le(broker.brokerKeylet()); - BEAST_EXPECT(brokerSle); - auto const loanSequence = brokerSle ? brokerSle->at(sfLoanSequence) : 0; - auto const loanKeylet = keylet::loan(broker.brokerID, loanSequence); - - env(set(borrower, broker.brokerID, principalRequested), - Sig(sfCounterpartySignature, lender), - kInterestRate(TenthBips32{1}), - kPaymentTotal(2), - kPaymentInterval(400), - Fee(env.current()->fees().base * 2), - Ter(expectedTer)); - env.close(); - - Result r; - r.ter = env.ter(); - if (auto const loanSle = env.le(loanKeylet)) - { - r.created = true; - r.loanScale = loanSle->at(sfLoanScale); - r.principal = loanSle->at(sfPrincipalOutstanding); - r.totalValue = loanSle->at(sfTotalValueOutstanding); - r.managementFee = loanSle->at(sfManagementFeeOutstanding); - r.periodicPayment = loanSle->at(sfPeriodicPayment); - } - return r; - }; - - Result const fixed = runScenario(all_, tesSUCCESS); - Result const legacy = runScenario(all_ - fixCleanup3_2_0, tecPRECISION_LOSS); - - // Without the amendment, the catastrophically-cancelling closed-form - // payment factor produces a degenerate amortization that fails - // checkLoanGuards: the LoanSet is rejected with tecPRECISION_LOSS and no - // loan is created. - BEAST_EXPECT(legacy.ter == tecPRECISION_LOSS); - BEAST_EXPECT(!legacy.created); - - // With the amendment the stable series expansion produces a valid loan - // at loanScale -10. - BEAST_EXPECT(fixed.ter == tesSUCCESS); - BEAST_EXPECT(fixed.created); - BEAST_EXPECT(fixed.loanScale == -10); - BEAST_EXPECT(fixed.principal == principalRequested); - BEAST_EXPECT((fixed.totalValue == Number{10000000001903, -10})); - BEAST_EXPECT(fixed.managementFee == beast::kZero); - - // Periodic payment from the numerically-stable series expansion, and the - // scheduled total (2 * periodicPayment) which exceeds the 1000 principal - // — no economic underpayment / yield theft. - BEAST_EXPECT((fixed.periodicPayment == Number{5000000000951293762, -16})); - BEAST_EXPECT((fixed.periodicPayment * 2 == Number{1000000000190258752, -15})); - BEAST_EXPECT(fixed.periodicPayment * 2 > principalRequested); - } - // 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" @@ -655,110 +847,6 @@ class LoanRegression_test : public LoanTestBase env.close(); } - // Regression for the dual-rounding fix at coarse (integer-MPT) scale. - // - // Loan: P=1, r=50% (50000 tenth-bips), n=3, yearly interval. The - // amortization schedule produces a fractional principal - // (~0.47) which under round-to-nearest collapses to 0 in a single - // step, causing `doPayment`'s strict `>` assertion on principal to - // fire mid-loan. With fixCleanup3_2_0 enabled, principal is rounded - // upward (sticks at 1 across the first two periods) and only clears - // in the final payment. - // - // The test pays one period at a time across three LoanPay - // transactions and verifies the loan completes (paymentRemaining=0) - // with totals matching the loan's economics (1 principal + 2 interest). - void - testIntegerScalePrincipalSticks(FeatureBitset features) - { - // Without fixCleanup3_2_0, this behavior will abort the server, so - // don't run without it. - if (!features[fixCleanup3_2_0]) - return; - - testcase("edge: integer MPT principal stuck mid-loan completes via final"); - - using namespace jtx; - Env env(*this, features); - - Account const issuer{"issuer"}; - Account const lender{"lender"}; - Account const borrower{"borrower"}; - - env.fund(XRP(100'000), issuer, lender, borrower); - env.close(); - - MPTTester mptt{env, issuer, kMptInitNoFund}; - mptt.create({.maxAmt = 100'000, .flags = tfMPTCanTransfer}); - PrettyAsset const asset{mptt.issuanceID()}; - - mptt.authorize({.account = lender}); - mptt.authorize({.account = borrower}); - - env(pay(issuer, lender, asset(10'000))); - env(pay(issuer, borrower, asset(10'000))); - env.close(); - - Vault const vault{env}; - auto [vaultTx, vaultKeylet] = vault.create({.owner = lender, .asset = asset}); - env(vaultTx); - env.close(); - - env(vault.deposit({.depositor = lender, .id = vaultKeylet.key, .amount = asset(5'000)})); - env.close(); - - auto const brokerKeylet = keylet::loanBroker(lender.id(), env.seq(lender)); - env(loanBroker::set(lender, vaultKeylet.key), - loanBroker::kDebtMaximum(Number{100}), - Fee(env.current()->fees().base * 2)); - env.close(); - - auto const brokerStateBefore = env.le(brokerKeylet); - if (!BEAST_EXPECT(brokerStateBefore)) - return; - auto const loanSequence = brokerStateBefore->at(sfLoanSequence); - auto const loanKeylet = keylet::loan(brokerKeylet.key, loanSequence); - - env(loan::set(borrower, brokerKeylet.key, Number{1}), - Sig(sfCounterpartySignature, lender), - loan::kInterestRate(TenthBips32{50'000}), - loan::kPaymentTotal(3), - loan::kPaymentInterval(31'536'000), - Fee(env.current()->fees().base * 2)); - env.close(); - - auto const borrowerStart = env.balance(borrower, asset).value(); - - // Three separate periodic payments of 1 each. Expected per-period - // evolution at integer MPT scale (TVO = PO + interestDue + - // managementFeeDue): - // start: PO=1, TVO=3, paymentRemaining=3 - // after pay #1: PO=1, TVO=2, paymentRemaining=2 (principal sticks) - // after pay #2: PO=1, TVO=1, paymentRemaining=1 (principal sticks) - // after pay #3: PO=0, TVO=0, paymentRemaining=0 (final clears) - std::array const expectedPO{Number{1}, Number{1}, Number{0}}; - std::array const expectedTVO{Number{2}, Number{1}, Number{0}}; - std::array const expectedRemaining{2, 1, 0}; - - for (int i = 0; i < 3; ++i) - { - env(loan::pay(borrower, loanKeylet.key, asset(1)), Ter(tesSUCCESS)); - env.close(); - - auto const sle = env.le(loanKeylet); - if (!BEAST_EXPECT(sle)) - return; - BEAST_EXPECT(sle->at(sfPrincipalOutstanding) == expectedPO[i]); - BEAST_EXPECT(sle->at(sfTotalValueOutstanding) == expectedTVO[i]); - BEAST_EXPECT(sle->at(sfPaymentRemaining) == expectedRemaining[i]); - } - - // Borrower paid 3 total regardless of fee split (1 principal + 2 - // interest+fee, matching loan economics). - auto const borrowerEnd = env.balance(borrower, asset).value(); - BEAST_EXPECT(borrowerStart - borrowerEnd == asset(3).value()); - } - // A near-zero interest rate on a 100 USD loan // produces total interest of ~6 units at loanScale -9. Numerical error // in the amortization formula pushes the theoretical principal above @@ -829,133 +917,26 @@ class LoanRegression_test : public LoanTestBase env.close(); } - // Integration test: full lifecycle of a $1B loan in the bug regime. - // Verifies that the vault collects the economically-correct interest - // income and that conservation holds at the trust-line level. - // - // Pre-fix (closed-form `power(1+r, n) - 1`): vault collected only - // ~$0.058 per $1B due to cancellation of `(1+r)^n - 1` at r*n ~ 5.7e-10. - // Post-fix (hybrid binomial path): vault collects ~$0.38 per $1B, - // matching the value computed independently with arbitrary-precision - // Decimal arithmetic. - void - testFullLifecycleVaultPnLNearZeroRate() - { - testcase("integration: full loan lifecycle, vault interest at near-zero rate"); - - using namespace jtx; - using namespace jtx::loan; - using namespace std::chrono_literals; - Env env(*this, all_); - - Account const issuer{"issuer"}; - Account const lender{"lender"}; - Account const borrower{"borrower"}; - - env.fund(XRP(1'000'000), issuer, lender, borrower); - env.close(); - env(fset(issuer, asfDefaultRipple)); - env.close(); - - PrettyAsset const iouAsset = issuer["USD"]; - STAmount const trustLimit{iouAsset.raw(), Number{1, 17}}; - env(trust(lender, trustLimit)); - env(trust(borrower, trustLimit)); - env.close(); - env(pay(issuer, lender, iouAsset(5'000'000'000LL))); - env(pay(issuer, borrower, iouAsset(5'000'000'000LL))); - env.close(); - - auto usdBalance = [&](Account const& a) { - return env.balance(a, iouAsset.raw().get()).value(); - }; - STAmount const borrowerStartBal = usdBalance(borrower); - - BrokerParameters const brokerParams{ - .vaultDeposit = Number{2, 9}, - .debtMax = Number{0}, - .coverRateMin = TenthBips32{0}, - .coverDeposit = 0, - .managementFeeRate = TenthBips16{0}, - .coverRateLiquidation = TenthBips32{0}}; - BrokerInfo const broker{createVaultAndBroker(env, iouAsset, lender, brokerParams)}; - - auto const vaultBefore = env.le(broker.vaultKeylet()); - BEAST_EXPECT(vaultBefore); - Number const vaultAvailableBefore = vaultBefore->at(sfAssetsAvailable); - - // Loan: $1B principal, 3 payments, 600s interval, rate=1 TenthBips32. - auto const loanSetFee = Fee(env.current()->fees().base * 2); - Number const principalRequest{1, 9}; - auto createJson = env.json( - set(borrower, broker.brokerID, principalRequest), - Fee(loanSetFee), - Json(sfCounterpartySignature, json::ValueType::Object)); - createJson["InterestRate"] = 1; - createJson["PaymentTotal"] = 3; - createJson["PaymentInterval"] = 600; - - auto const loanKeylet = nextLoanKeylet(env, broker); - createJson = env.json(createJson, Sig(sfCounterpartySignature, lender)); - env(createJson, Ter(tesSUCCESS)); - env.close(); - - auto const loanSle = env.le(loanKeylet); - BEAST_EXPECT(loanSle); - Number const expectedTotalInterest = - loanSle->at(sfTotalValueOutstanding) - loanSle->at(sfPrincipalOutstanding); - - env(pay(borrower, loanKeylet.key, iouAsset(1'500'000'000LL)), Ter(tesSUCCESS)); - env.close(); - - auto const vaultAfter = env.le(broker.vaultKeylet()); - Number const vaultAvailableAfter = vaultAfter->at(sfAssetsAvailable); - Number const vaultGain = vaultAvailableAfter - vaultAvailableBefore; - - STAmount const borrowerEndBal = usdBalance(borrower); - STAmount const borrowerNetOut = borrowerStartBal - borrowerEndBal; - - // Self-consistency: vault gained exactly the expected interest - // computed at LoanSet, and the borrower's outflow matches. - BEAST_EXPECT(vaultGain == expectedTotalInterest); - BEAST_EXPECT(Number(borrowerNetOut) == expectedTotalInterest); - - // Mathematical correctness: the total interest for this loan - // configuration is 0.38051750382930729983, calculated - // independently using 50-digit Decimal arithmetic (no - // cancellation possible at that precision). At Number's 19-digit - // mantissa this rounds to 0.38051750382930729 — the literal - // below. The vault's actual gain must agree to within - // sub-microcent precision. - Number const decimalReference{38051750382930729LL, -17}; - Number const tolerance{1, -6}; // 1e-6 USD = sub-microcent - Number const error = abs(vaultGain - decimalReference); - BEAST_EXPECTS( - error < tolerance, - "vault gain " + to_string(vaultGain) + " differs from Decimal reference " + - to_string(decimalReference) + " by " + to_string(error) + " — exceeds tolerance " + - to_string(tolerance)); - } - void runAmendmentIndependent() { - testRIPD3901(); + for (auto const flags : {0u, tfLoanOverpayment}) + testYieldTheftRounding(flags); testBugOverpaymentPrincipalChange(); testBugOverpayUnroundedAmount(); testBugInterestDueDeltaCrash(); - testFullLifecycleVaultPnLNearZeroRate(); - testLoanSetNearZeroInterestRateSucceeds(); } // Tests run under each entry in amendmentCombinations(). void runAmendmentSensitive(FeatureBitset features) { + testDustManipulation(features); + testRoundingAllowsUndercoverage(features); testIntegerScalePrincipalSticks(features); - testRIPD3831(features); - testRIPD3459(features); - testRIPD3902(features); +#if LOAN_TODO + testLoanCoverMinimumRoundingExploit(features); +#endif } public: @@ -969,6 +950,6 @@ class LoanRegression_test : public LoanTestBase } }; -BEAST_DEFINE_TESTSUITE(LoanRegression, tx, xrpl); +BEAST_DEFINE_TESTSUITE(LoanRounding, tx, xrpl); } // namespace xrpl::test diff --git a/src/test/app/LoanSecurity_test.cpp b/src/test/app/LoanSecurity_test.cpp new file mode 100644 index 00000000000..5b6f3ee5d4e --- /dev/null +++ b/src/test/app/LoanSecurity_test.cpp @@ -0,0 +1,514 @@ +#include + +namespace xrpl::test { + +class LoanSecurity_test : public LoanTestBase +{ +private: + void + testPoCUnsignedUnderflowOnFullPayAfterEarlyPeriodic(FeatureBitset features) + { + // --- PoC Summary ---------------------------------------------------- + // Scenario: Borrower makes one periodic payment early (before next due) + // so doPayment sets sfPreviousPaymentDueDate to the (future) + // sfNextPaymentDueDate and advances sfNextPaymentDueDate by one + // interval. Borrower then immediately performs a full-payment + // (tfLoanFullPayment). Why it matters: Full-payment interest accrual + // uses + // delta = now - max(prevPaymentDate, startDate) + // with an unsigned clock representation (uint32). If prevPaymentDate is + // in the future, the subtraction underflows to a very large positive + // number. This inflates roundedFullInterest and total full-close due, + // and LoanPay applies the inflated valueChange to the vault + // (sfAssetsTotal), increasing NAV. + // -------------------------------------------------------------------- + testcase("PoC: Unsigned-underflow full-pay accrual after early periodic"); + + using namespace jtx; + using namespace loan; + using namespace std::chrono_literals; + + Env env{*this, features}; + + Account const lender{"poc_lender4"}; + Account const borrower{"poc_borrower4"}; + env.fund(XRP(3'000'000), lender, borrower); + env.close(); + + PrettyAsset const asset{xrpIssue(), 1'000'000}; + BrokerParameters const brokerParams{}; + auto const broker = createVaultAndBroker(env, asset, lender, brokerParams); + + // Create a 3-payment loan so full-payment path is enabled after 1 + // periodic payment. + auto const loanSetFee = Fee(env.current()->fees().base * 2); + Number const principalRequest = asset(1000).value(); + auto const originationFee = asset(0).value(); + auto const serviceFee = asset(1).value(); + auto const serviceFeePA = asset(1); + auto const lateFee = asset(0).value(); + auto const closeFee = asset(0).value(); + auto const interest = percentageToTenthBips(12); + auto const lateInterest = percentageToTenthBips(12) / 10; + auto const closeInterest = percentageToTenthBips(12) / 10; + auto const overpaymentInterest = percentageToTenthBips(12) / 10; + auto const total = 3u; + auto const interval = 600u; + auto const grace = 60u; + + auto createJtx = env.jt( + set(borrower, broker.brokerID, principalRequest, 0), + Sig(sfCounterpartySignature, lender), + kLoanOriginationFee(originationFee), + kLoanServiceFee(serviceFee), + kLatePaymentFee(lateFee), + kClosePaymentFee(closeFee), + kOverpaymentFee(percentageToTenthBips(5) / 10), + kInterestRate(interest), + kLateInterestRate(lateInterest), + kCloseInterestRate(closeInterest), + kOverpaymentInterestRate(overpaymentInterest), + kPaymentTotal(total), + kPaymentInterval(interval), + kGracePeriod(grace), + Fee(loanSetFee)); + + auto const brokerSle = env.le(keylet::loanBroker(broker.brokerID)); + BEAST_EXPECT(brokerSle); + auto const loanSequence = brokerSle ? brokerSle->at(sfLoanSequence) : 0; + auto const loanKeylet = keylet::loan(broker.brokerID, loanSequence); + + env(createJtx); + env.close(); + + // Compute a regular periodic due and pay it early (before next due). + auto state = getCurrentState(env, broker, loanKeylet); + Number const periodicRate = loanPeriodicRate(state.interestRate, state.paymentInterval); + auto const components = xrpl::detail::computePaymentComponents( + env.current()->rules(), + asset.raw(), + state.loanScale, + state.totalValue, + state.principalOutstanding, + state.managementFeeOutstanding, + state.periodicPayment, + periodicRate, + state.paymentRemaining, + brokerParams.managementFeeRate); + STAmount const regularDue{asset, components.trackedValueDelta + serviceFeePA.number()}; + // now < nextDue immediately after creation, so this is an early pay. + env(pay(borrower, loanKeylet.key, regularDue)); + env.close(); + + // Immediately attempt a full payoff. Compute the exact full-payment + // due to ensure the tx applies. + auto after = getCurrentState(env, broker, loanKeylet); + auto const loanSle = env.le(loanKeylet); + BEAST_EXPECT(loanSle); + auto const brokerSle2 = env.le(keylet::loanBroker(broker.brokerID)); + BEAST_EXPECT(brokerSle2); + + auto const closePaymentFee = loanSle ? loanSle->at(sfClosePaymentFee) : Number{}; + auto const closeInterestRate = + loanSle ? TenthBips32{loanSle->at(sfCloseInterestRate)} : TenthBips32{}; + auto const managementFeeRate = + brokerSle2 ? TenthBips16{brokerSle2->at(sfManagementFeeRate)} : TenthBips16{}; + + Number const periodicRate2 = loanPeriodicRate(after.interestRate, after.paymentInterval); + // Accrued + prepayment-penalty interest based on current periodic + // schedule + auto const fullPaymentInterest = computeFullPaymentInterest( + xrpl::detail::loanPrincipalFromPeriodicPayment( + env.current()->rules(), + after.periodicPayment, + periodicRate2, + after.paymentRemaining), + periodicRate2, + env.current()->parentCloseTime(), + after.paymentInterval, + after.previousPaymentDate, + static_cast(after.startDate.time_since_epoch().count()), + closeInterestRate); + + // Round to asset scale and split interest/fee parts + auto const roundedInterest = + roundToAsset(asset.raw(), fullPaymentInterest, after.loanScale); + Number const roundedFullMgmtFee = + computeManagementFee(asset.raw(), roundedInterest, managementFeeRate, after.loanScale); + Number const roundedFullInterest = roundedInterest - roundedFullMgmtFee; + + // Show both signed and unsigned deltas to highlight the underflow. + auto const nowSecs = + static_cast(env.current()->parentCloseTime().time_since_epoch().count()); + auto const startSecs = + static_cast(after.startDate.time_since_epoch().count()); + auto const lastPaymentDate = std::max(after.previousPaymentDate, startSecs); + auto const signedDelta = + static_cast(nowSecs) - static_cast(lastPaymentDate); + auto const unsignedDelta = static_cast(nowSecs - lastPaymentDate); + log << "PoC window: prev=" << after.previousPaymentDate << " start=" << startSecs + << " now=" << nowSecs << " signedDelta=" << signedDelta + << " unsignedDelta=" << unsignedDelta << std::endl; + + // Reference (clamped) computation: emulate a non-negative accrual + // window by clamping prevPaymentDate to 'now' for the full-pay path. + auto const prevClamped = std::min(after.previousPaymentDate, nowSecs); + auto const fullPaymentInterestClamped = computeFullPaymentInterest( + xrpl::detail::loanPrincipalFromPeriodicPayment( + env.current()->rules(), + after.periodicPayment, + periodicRate2, + after.paymentRemaining), + periodicRate2, + env.current()->parentCloseTime(), + after.paymentInterval, + prevClamped, + startSecs, + closeInterestRate); + auto const roundedInterestClamped = + roundToAsset(asset.raw(), fullPaymentInterestClamped, after.loanScale); + Number const roundedFullMgmtFeeClamped = computeManagementFee( + asset.raw(), roundedInterestClamped, managementFeeRate, after.loanScale); + Number const roundedFullInterestClamped = + roundedInterestClamped - roundedFullMgmtFeeClamped; + STAmount const fullDueClamped{ + asset, + after.principalOutstanding + roundedFullInterestClamped + roundedFullMgmtFeeClamped + + closePaymentFee}; + + // Collect vault NAV before closing payment + auto const vaultId2 = brokerSle2 ? brokerSle2->at(sfVaultID) : uint256{}; + auto const vaultKey2 = keylet::vault(vaultId2); + auto const vaultBefore = env.le(vaultKey2); + BEAST_EXPECT(vaultBefore); + Number const assetsTotalBefore = vaultBefore ? vaultBefore->at(sfAssetsTotal) : Number{}; + + STAmount const fullDue{ + asset, + after.principalOutstanding + roundedFullInterest + roundedFullMgmtFee + + closePaymentFee}; + + log << "PoC payoff: principalOutstanding=" << after.principalOutstanding + << " roundedFullInterest=" << roundedFullInterest + << " roundedFullMgmtFee=" << roundedFullMgmtFee << " closeFee=" << closePaymentFee + << " fullDue=" << to_string(fullDue.getJson()) << std::endl; + log << "PoC reference (clamped): roundedFullInterestClamped=" << roundedFullInterestClamped + << " roundedFullMgmtFeeClamped=" << roundedFullMgmtFeeClamped + << " fullDueClamped=" << to_string(fullDueClamped.getJson()) << std::endl; + + env(pay(borrower, loanKeylet.key, fullDue), Txflags(tfLoanFullPayment)); + env.close(); + + // Sanity: underflow present (unsigned delta very large relative to + // interval) + BEAST_EXPECT(unsignedDelta > after.paymentInterval); + + // Compare vault NAV before/after the full close + auto const vaultAfter = env.le(vaultKey2); + BEAST_EXPECT(vaultAfter); + if (vaultAfter) + { + auto const assetsTotalAfter = vaultAfter->at(sfAssetsTotal); + log << "PoC NAV: assetsTotalBefore=" << assetsTotalBefore + << " assetsTotalAfter=" << assetsTotalAfter + << " delta=" << (assetsTotalAfter - assetsTotalBefore) << std::endl; + + // Value-based proof: underflowed window yields a payoff larger than + // the clamped (non-underflow) reference. + BEAST_EXPECT(fullDue == fullDueClamped); + if (fullDue > fullDueClamped) + log << "PoC delta: overcharge (fullDue > clamped)" << std::endl; + } + + // Loan should be paid off + auto const finalLoan = env.le(loanKeylet); + BEAST_EXPECT(finalLoan); + if (finalLoan) + { + BEAST_EXPECT(finalLoan->at(sfPaymentRemaining) == 0); + BEAST_EXPECT(finalLoan->at(sfPrincipalOutstanding) == 0); + } + } + + void + testRIPD3831(FeatureBitset features) + { + using namespace jtx; + + testcase("RIPD-3831"); + + Account const issuer("issuer"); + Account const lender("lender"); + Account const borrower("borrower"); + + BrokerParameters const brokerParams{ + .vaultDeposit = 100000, + .debtMax = 0, + .coverRateMin = TenthBips32{0}, + // .managementFeeRate = TenthBips16{5919}, + .coverRateLiquidation = TenthBips32{0}}; + LoanParameters const loanParams{ + .account = lender, + .counter = borrower, + .principalRequest = Number{200'000, -6}, + .lateFee = Number{200, -6}, + .interest = TenthBips32{50'000}, + .payTotal = 10, + .payInterval = 150}; + + auto const assetType = AssetType::XRP; + + Env env{*this, features}; + + auto loanResult = + createLoan(env, assetType, brokerParams, loanParams, issuer, lender, borrower); + + if (BEAST_EXPECT(loanResult); !loanResult.has_value()) + return; + + auto broker = std::get(*loanResult); + auto loanKeylet = std::get(*loanResult); + + using tp = NetClock::time_point; + using d = NetClock::duration; + + auto state = getCurrentState(env, broker, loanKeylet); + if (auto loan = env.le(loanKeylet); BEAST_EXPECT(loan)) + { + env.close(tp{d{loan->at(sfNextPaymentDueDate) + loan->at(sfGracePeriod) + 1}}); + } + + topUpBorrower(env, broker, issuer, borrower, state, loanParams.serviceFee); + + using namespace jtx::loan; + + auto jv = pay(borrower, loanKeylet.key, drops(XRPAmount(state.totalValue))); + + { + auto const submitParam = to_string(jv); + auto const jr = env.rpc("submit", borrower.name(), submitParam); + + BEAST_EXPECT(jr.isMember(jss::result)); + auto const jResult = jr[jss::result]; + } + + env.close(); + + // Make sure the system keeps responding + env(noop(borrower)); + env.close(); + env(noop(issuer)); + env.close(); + env(noop(lender)); + env.close(); + } + + void + testRIPD3459(FeatureBitset features) + { + testcase("RIPD-3459 - LoanBroker incorrect debt total"); + + using namespace jtx; + + Account const issuer("issuer"); + Account const lender("lender"); + Account const borrower("borrower"); + + BrokerParameters const brokerParams{ + .vaultDeposit = 200'000, + .debtMax = 0, + .coverRateMin = TenthBips32{0}, + .managementFeeRate = TenthBips16{500}, + .coverRateLiquidation = TenthBips32{0}}; + LoanParameters const loanParams{ + .account = lender, + .counter = borrower, + .principalRequest = Number{100'000, -4}, + .interest = TenthBips32{100'000}, + .payTotal = 10}; + + auto const assetType = AssetType::MPT; + + Env env{*this, features}; + + auto loanResult = + createLoan(env, assetType, brokerParams, loanParams, issuer, lender, borrower); + + if (BEAST_EXPECT(loanResult); !loanResult.has_value()) + return; + + auto broker = std::get(*loanResult); + auto loanKeylet = std::get(*loanResult); + auto pseudoAcct = std::get(*loanResult); + + VerifyLoanStatus const verifyLoanStatus(env, broker, pseudoAcct, loanKeylet); + + if (auto const brokerSle = env.le(broker.brokerKeylet()); BEAST_EXPECT(brokerSle)) + { + if (auto const loanSle = env.le(loanKeylet); BEAST_EXPECT(loanSle)) + { + BEAST_EXPECT(brokerSle->at(sfDebtTotal) == loanSle->at(sfTotalValueOutstanding)); + } + } + + makeLoanPayments( + env, + broker, + loanParams, + loanKeylet, + verifyLoanStatus, + issuer, + lender, + borrower, + PaymentParameters{.showStepBalances = true}); + + if (auto const brokerSle = env.le(broker.brokerKeylet()); BEAST_EXPECT(brokerSle)) + { + if (auto const loanSle = env.le(loanKeylet); BEAST_EXPECT(loanSle)) + { + BEAST_EXPECT(brokerSle->at(sfDebtTotal) == loanSle->at(sfTotalValueOutstanding)); + BEAST_EXPECT(brokerSle->at(sfDebtTotal) == beast::kZero); + } + } + } + + void + testRIPD3901() + { + testcase("Crash with tfLoanOverpayment"); + using namespace jtx; + using namespace loan; + Account const lender{"lender"}; + Account const issuer{"issuer"}; + Account const borrower{"borrower"}; + Account const depositor{"depositor"}; + auto const txFee = Fee(XRP(100)); + + Env env(*this); + Vault const vault(env); + + env.fund(XRP(10'000), lender, issuer, borrower, depositor); + env.close(); + + auto [tx, vaultKeyLet] = vault.create({.owner = lender, .asset = xrpIssue()}); + env(tx, txFee); + env.close(); + + env(vault.deposit({.depositor = depositor, .id = vaultKeyLet.key, .amount = XRP(1'000)}), + txFee); + env.close(); + + auto const brokerKeyLet = keylet::loanBroker(lender.id(), env.seq(lender)); + + env(loanBroker::set(lender, vaultKeyLet.key), txFee); + env.close(); + + // BrokerInfo brokerInfo{xrpIssue(), keylet, vaultKeyLet, {}}; + + STAmount const debtMaximumRequest = XRPAmount(200'000); + + env(set(borrower, brokerKeyLet.key, debtMaximumRequest), + Sig(sfCounterpartySignature, lender), + kInterestRate(TenthBips32(50'000)), + kPaymentTotal(2), + kPaymentInterval(150), + Txflags(tfLoanOverpayment), + txFee); + env.close(); + + std::uint32_t const loanSequence = 1; + auto const loanKeylet = keylet::loan(brokerKeyLet.key, loanSequence); + + if (auto loan = env.le(loanKeylet); env.test.BEAST_EXPECT(loan)) + { + env(loan::pay(borrower, loanKeylet.key, XRPAmount(150'001)), + Txflags(tfLoanOverpayment), + txFee); + env.close(); + } + } + + void + testRIPD3902(FeatureBitset features) + { + testcase("RIPD-3902 - 1 IOU loan payments"); + + using namespace jtx; + + Account const issuer("issuer"); + Account const lender("lender"); + Account const borrower("borrower"); + + BrokerParameters const brokerParams{ + .vaultDeposit = 10, + .debtMax = 0, + .coverRateMin = TenthBips32{0}, + .managementFeeRate = TenthBips16{0}, + .coverRateLiquidation = TenthBips32{0}}; + LoanParameters const loanParams{ + .account = lender, + .counter = borrower, + .principalRequest = Number{1, 0}, + .interest = TenthBips32{100'000}, + .payTotal = 5, + .payInterval = 150, + .gracePd = 60}; + + auto const assetType = AssetType::IOU; + + Env env{*this, features}; + + auto loanResult = + createLoan(env, assetType, brokerParams, loanParams, issuer, lender, borrower); + + if (BEAST_EXPECT(loanResult); !loanResult.has_value()) + return; + + auto broker = std::get(*loanResult); + auto loanKeylet = std::get(*loanResult); + auto pseudoAcct = std::get(*loanResult); + + VerifyLoanStatus const verifyLoanStatus(env, broker, pseudoAcct, loanKeylet); + + makeLoanPayments( + env, + broker, + loanParams, + loanKeylet, + verifyLoanStatus, + issuer, + lender, + borrower, + PaymentParameters{.showStepBalances = true}); + } + + void + runAmendmentIndependent() + { + testRIPD3901(); + } + + // Tests run under each entry in amendmentCombinations(). + void + runAmendmentSensitive(FeatureBitset features) + { + testPoCUnsignedUnderflowOnFullPayAfterEarlyPeriodic(features); + testRIPD3831(features); + testRIPD3459(features); + testRIPD3902(features); + } + +public: + void + run() override + { + runAmendmentIndependent(); + for (auto const& features : jtx::amendmentCombinations( + {fixCleanup3_1_3, fixCleanup3_2_0, featureMPTokensV2}, all_)) + runAmendmentSensitive(features); + } +}; + +BEAST_DEFINE_TESTSUITE(LoanSecurity, tx, xrpl); + +} // namespace xrpl::test diff --git a/src/test/app/LoanTestBase.h b/src/test/app/LoanTestBase.h index b0ae00608b0..e560463f9d7 100644 --- a/src/test/app/LoanTestBase.h +++ b/src/test/app/LoanTestBase.h @@ -1626,6 +1626,1346 @@ class LoanTestBase : public beast::unit_test::Suite return "MPT"; return "Unknown"; } + + /** + * Wrapper to run a series of lifecycle tests for a given asset and loan + * amount + * + * Will be used in the future to vary the loan parameters. For now, it is + * only called once. + * + * Tests a bunch of LoanSet failure conditions before lifecycle. + */ + template + void + testCaseWrapper( + jtx::Env& env, + jtx::MPTTester& mptt, + std::array const& assets, + BrokerInfo const& broker, + Number const& loanAmount, + int interestExponent) + { + using namespace jtx; + using namespace Lending; + + auto const& asset = broker.asset.raw(); + auto const currencyLabel = getCurrencyLabel(asset); + auto const caseLabel = [&]() { + std::stringstream ss; + ss << "Lifecycle: " << loanAmount << " " << currencyLabel + << " Scale interest to: " << interestExponent << " "; + return ss.str(); + }(); + testcase << caseLabel; + + using namespace loan; + using namespace std::chrono_literals; + using d = NetClock::duration; + using tp = NetClock::time_point; + + Account const issuer{"issuer"}; + // For simplicity, lender will be the sole actor for the vault & + // brokers. + Account const lender{"lender"}; + // Borrower only wants to borrow + Account const borrower{"borrower"}; + // Evan will attempt to be naughty + Account const evan{"evan"}; + // Do not fund alice + Account const alice{"alice"}; + + Number const principalRequest = broker.asset(loanAmount).value(); + Number const maxCoveredLoanValue = broker.params.maxCoveredLoanValue(0); + BEAST_EXPECT(maxCoveredLoanValue == 1000 * 100 / 10); + Number const maxCoveredLoanRequest = broker.asset(maxCoveredLoanValue).value(); + Number const totalVaultRequest = broker.asset(broker.params.vaultDeposit).value(); + Number const debtMaximumRequest = broker.asset(broker.params.debtMax).value(); + + auto const loanSetFee = Fee(env.current()->fees().base * 2); + + auto const pseudoAcct = brokerPseudoAccount(env, broker, lender); + + auto const baseFee = env.current()->fees().base; + + auto badKeylet = keylet::vault(lender.id(), env.seq(lender)); + // Try some failure cases + // flags are checked first + env(set(evan, broker.brokerID, principalRequest, tfLoanSetMask), + Sig(sfCounterpartySignature, lender), + loanSetFee, + Ter(temINVALID_FLAG)); + + // field length validation + // sfData: good length, bad account + env(set(evan, broker.brokerID, principalRequest), + Sig(sfCounterpartySignature, borrower), + kData(std::string(kMaxDataPayloadLength, 'X')), + loanSetFee, + Ter(tefBAD_AUTH)); + // sfData: too long + env(set(evan, broker.brokerID, principalRequest), + Sig(sfCounterpartySignature, lender), + kData(std::string(kMaxDataPayloadLength + 1, 'Y')), + loanSetFee, + Ter(temINVALID)); + + // field range validation + // sfOverpaymentFee: good value, bad account + env(set(evan, broker.brokerID, principalRequest), + Sig(sfCounterpartySignature, borrower), + kOverpaymentFee(kMaxOverpaymentFee), + loanSetFee, + Ter(tefBAD_AUTH)); + // sfOverpaymentFee: too big + env(set(evan, broker.brokerID, principalRequest), + Sig(sfCounterpartySignature, lender), + kOverpaymentFee(kMaxOverpaymentFee + 1), + loanSetFee, + Ter(temINVALID)); + + // sfInterestRate: good value, bad account + env(set(evan, broker.brokerID, principalRequest), + Sig(sfCounterpartySignature, borrower), + kInterestRate(kMaxInterestRate), + loanSetFee, + Ter(tefBAD_AUTH)); + env(set(evan, broker.brokerID, principalRequest), + Sig(sfCounterpartySignature, borrower), + kInterestRate(TenthBips32(0)), + loanSetFee, + Ter(tefBAD_AUTH)); + // sfInterestRate: too big + env(set(evan, broker.brokerID, principalRequest), + Sig(sfCounterpartySignature, lender), + kInterestRate(kMaxInterestRate + 1), + loanSetFee, + Ter(temINVALID)); + // sfInterestRate: too small + env(set(evan, broker.brokerID, principalRequest), + Sig(sfCounterpartySignature, lender), + kInterestRate(TenthBips32(-1)), + loanSetFee, + Ter(temINVALID)); + + // sfLateInterestRate: good value, bad account + env(set(evan, broker.brokerID, principalRequest), + Sig(sfCounterpartySignature, borrower), + kLateInterestRate(kMaxLateInterestRate), + loanSetFee, + Ter(tefBAD_AUTH)); + env(set(evan, broker.brokerID, principalRequest), + Sig(sfCounterpartySignature, borrower), + kLateInterestRate(TenthBips32(0)), + loanSetFee, + Ter(tefBAD_AUTH)); + // sfLateInterestRate: too big + env(set(evan, broker.brokerID, principalRequest), + Sig(sfCounterpartySignature, lender), + kLateInterestRate(kMaxLateInterestRate + 1), + loanSetFee, + Ter(temINVALID)); + // sfLateInterestRate: too small + env(set(evan, broker.brokerID, principalRequest), + Sig(sfCounterpartySignature, lender), + kLateInterestRate(TenthBips32(-1)), + loanSetFee, + Ter(temINVALID)); + + // sfCloseInterestRate: good value, bad account + env(set(evan, broker.brokerID, principalRequest), + Sig(sfCounterpartySignature, borrower), + kCloseInterestRate(kMaxCloseInterestRate), + loanSetFee, + Ter(tefBAD_AUTH)); + env(set(evan, broker.brokerID, principalRequest), + Sig(sfCounterpartySignature, borrower), + kCloseInterestRate(TenthBips32(0)), + loanSetFee, + Ter(tefBAD_AUTH)); + // sfCloseInterestRate: too big + env(set(evan, broker.brokerID, principalRequest), + Sig(sfCounterpartySignature, lender), + kCloseInterestRate(kMaxCloseInterestRate + 1), + loanSetFee, + Ter(temINVALID)); + env(set(evan, broker.brokerID, principalRequest), + Sig(sfCounterpartySignature, lender), + kCloseInterestRate(TenthBips32(-1)), + loanSetFee, + Ter(temINVALID)); + + // sfOverpaymentInterestRate: good value, bad account + env(set(evan, broker.brokerID, principalRequest), + Sig(sfCounterpartySignature, borrower), + kOverpaymentInterestRate(kMaxOverpaymentInterestRate), + loanSetFee, + Ter(tefBAD_AUTH)); + env(set(evan, broker.brokerID, principalRequest), + Sig(sfCounterpartySignature, borrower), + kOverpaymentInterestRate(TenthBips32(0)), + loanSetFee, + Ter(tefBAD_AUTH)); + // sfOverpaymentInterestRate: too big + env(set(evan, broker.brokerID, principalRequest), + Sig(sfCounterpartySignature, lender), + kOverpaymentInterestRate(kMaxOverpaymentInterestRate + 1), + loanSetFee, + Ter(temINVALID)); + env(set(evan, broker.brokerID, principalRequest), + Sig(sfCounterpartySignature, lender), + kOverpaymentInterestRate(TenthBips32(-1)), + loanSetFee, + Ter(temINVALID)); + + // sfPaymentTotal: good value, bad account + env(set(evan, broker.brokerID, principalRequest), + Sig(sfCounterpartySignature, borrower), + kPaymentTotal(LoanSet::kMinPaymentTotal), + loanSetFee, + Ter(tefBAD_AUTH)); + // sfPaymentTotal: too small (there is no max) + env(set(evan, broker.brokerID, principalRequest), + Sig(sfCounterpartySignature, lender), + kPaymentTotal(LoanSet::kMinPaymentTotal - 1), + loanSetFee, + Ter(temINVALID)); + + // sfPaymentInterval: good value, bad account + env(set(evan, broker.brokerID, principalRequest), + Sig(sfCounterpartySignature, borrower), + kPaymentInterval(LoanSet::kMinPaymentInterval), + loanSetFee, + Ter(tefBAD_AUTH)); + // sfPaymentInterval: too small (there is no max) + env(set(evan, broker.brokerID, principalRequest), + Sig(sfCounterpartySignature, lender), + kPaymentInterval(LoanSet::kMinPaymentInterval - 1), + loanSetFee, + Ter(temINVALID)); + + // sfGracePeriod: good value, bad account + env(set(evan, broker.brokerID, principalRequest), + Sig(sfCounterpartySignature, borrower), + kPaymentInterval(LoanSet::kMinPaymentInterval * 2), + kGracePeriod(LoanSet::kMinPaymentInterval * 2), + loanSetFee, + Ter(tefBAD_AUTH)); + // sfGracePeriod: larger than paymentInterval + env(set(evan, broker.brokerID, principalRequest), + Sig(sfCounterpartySignature, lender), + kPaymentInterval(LoanSet::kMinPaymentInterval * 2), + kGracePeriod(LoanSet::kMinPaymentInterval * 3), + loanSetFee, + Ter(temINVALID)); + + // insufficient fee - single sign + env(set(borrower, broker.brokerID, principalRequest), + Sig(sfCounterpartySignature, lender), + Ter(telINSUF_FEE_P)); + // insufficient fee - multisign + env(signers(lender, 2, {{evan, 1}, {borrower, 1}})); + env(signers(borrower, 2, {{evan, 1}, {lender, 1}})); + env(set(borrower, broker.brokerID, principalRequest), + kCounterparty(lender), + Msig(evan, lender), + Msig(sfCounterpartySignature, evan, borrower), + Fee(env.current()->fees().base * 5 - 1), + Ter(telINSUF_FEE_P)); + // Bad multisign signatures for borrower (Account) + env(set(borrower, broker.brokerID, principalRequest), + kCounterparty(lender), + Msig(alice, issuer), + Msig(sfCounterpartySignature, evan, borrower), + Fee(env.current()->fees().base * 5), + Ter(tefBAD_SIGNATURE)); + // Bad multisign signatures for issuer (Counterparty) + env(set(borrower, broker.brokerID, principalRequest), + kCounterparty(lender), + Msig(evan, lender), + Msig(sfCounterpartySignature, alice, issuer), + Fee(env.current()->fees().base * 5 - 1), + Ter(tefBAD_SIGNATURE)); + env(signers(lender, kNone)); + env(signers(borrower, kNone)); + // multisign sufficient fee, but no signers set up + env(set(borrower, broker.brokerID, principalRequest), + kCounterparty(lender), + Msig(evan, lender), + Msig(sfCounterpartySignature, evan, borrower), + Fee(env.current()->fees().base * 5), + Ter(tefNOT_MULTI_SIGNING)); + // not the broker owner, no counterparty, not signed by broker + // owner + env(set(borrower, broker.brokerID, principalRequest), + Sig(sfCounterpartySignature, evan), + loanSetFee, + Ter(tefBAD_AUTH)); + // not the broker owner, counterparty is borrower + env(set(evan, broker.brokerID, principalRequest), + kCounterparty(borrower), + Sig(sfCounterpartySignature, borrower), + loanSetFee, + Ter(tecNO_PERMISSION)); + // not a LoanBroker object, no counterparty + env(set(lender, badKeylet.key, principalRequest), + Sig(sfCounterpartySignature, evan), + loanSetFee, + Ter(temBAD_SIGNER)); + // not a LoanBroker object, counterparty is valid + env(set(lender, badKeylet.key, principalRequest), + kCounterparty(borrower), + Sig(sfCounterpartySignature, borrower), + loanSetFee, + Ter(tecNO_ENTRY)); + // borrower doesn't exist + env(set(lender, broker.brokerID, principalRequest), + kCounterparty(alice), + Sig(sfCounterpartySignature, alice), + loanSetFee, + Ter(terNO_ACCOUNT)); + + // Request more funds than the vault has available + env(set(evan, broker.brokerID, totalVaultRequest + 1), + Sig(sfCounterpartySignature, lender), + loanSetFee, + Ter(tecINSUFFICIENT_FUNDS)); + + // Request more funds than the broker's first-loss capital can + // cover. + env(set(evan, broker.brokerID, maxCoveredLoanRequest + 1), + Sig(sfCounterpartySignature, lender), + loanSetFee, + Ter(tecINSUFFICIENT_FUNDS)); + + // Frozen trust line / locked MPT issuance + // XRP can not be frozen, but run through the loop anyway to test + // the tecLIMIT_EXCEEDED case + { + auto const brokerSle = env.le(keylet::loanBroker(broker.brokerID)); + if (!BEAST_EXPECT(brokerSle)) + return; + + auto const vaultPseudo = [&]() { + auto const vaultSle = env.le(keylet::vault(brokerSle->at(sfVaultID))); + if (!BEAST_EXPECT(vaultSle)) + { + // This will be wrong, but the test has failed anyway. + return Account{lender}; + } + auto vaultPseudo = Account("Vault pseudo-account", vaultSle->at(sfAccount)); + return vaultPseudo; + }(); + + auto const [freeze, deepfreeze, unfreeze, expectedResult] = + [&]() -> std::tuple< + std::function, + std::function, + std::function, + TER> { + // Freeze / lock the asset + std::function const empty; + if (broker.asset.native()) + { + // XRP can't be frozen + return std::make_tuple(empty, empty, empty, tesSUCCESS); + } + if (broker.asset.holds()) + { + auto freeze = [&](Account const& holder) { + env(trust(issuer, holder[iouCurrency_](0), tfSetFreeze)); + }; + auto deepfreeze = [&](Account const& holder) { + env(trust(issuer, holder[iouCurrency_](0), tfSetFreeze | tfSetDeepFreeze)); + }; + auto unfreeze = [&](Account const& holder) { + env(trust( + issuer, holder[iouCurrency_](0), tfClearFreeze | tfClearDeepFreeze)); + }; + return std::make_tuple(freeze, deepfreeze, unfreeze, tecFROZEN); + } + + auto freeze = [&](Account const& holder) { + mptt.set({.account = issuer, .holder = holder, .flags = tfMPTLock}); + }; + auto unfreeze = [&](Account const& holder) { + mptt.set({.account = issuer, .holder = holder, .flags = tfMPTUnlock}); + }; + return std::make_tuple(freeze, empty, unfreeze, tecLOCKED); + }(); + + // Try freezing the accounts that can't be frozen + if (freeze) + { + for (auto const& account : {vaultPseudo, evan}) + { + // Freeze the account + freeze(account); + + // Try to create a loan with a frozen line + env(set(evan, broker.brokerID, debtMaximumRequest), + Sig(sfCounterpartySignature, lender), + loanSetFee, + Ter(expectedResult)); + + // Unfreeze the account + BEAST_EXPECT(unfreeze); + unfreeze(account); + + // Ensure the line is unfrozen with a request that is fine + // except too it requests more principal than the broker can + // carry + env(set(evan, broker.brokerID, debtMaximumRequest + 1), + Sig(sfCounterpartySignature, lender), + loanSetFee, + Ter(tecLIMIT_EXCEEDED)); + } + } + + // Deep freeze the borrower, which prevents them from receiving + // funds + if (deepfreeze) + { + // Make sure evan has a trust line that so the issuer can + // freeze it. (Don't need to do this for the borrower, + // because LoanSet will create a line to the borrower + // automatically.) + env(trust(evan, issuer[iouCurrency_](100'000))); + + for (auto const& account : {// these accounts can't be frozen, which deep freeze + // implies + vaultPseudo, + evan, + // these accounts can't be deep frozen + lender}) + { + // Freeze evan + deepfreeze(account); + + // Try to create a loan with a deep frozen line + env(set(evan, broker.brokerID, debtMaximumRequest), + Sig(sfCounterpartySignature, lender), + loanSetFee, + Ter(expectedResult)); + + // Unfreeze evan + BEAST_EXPECT(unfreeze); + unfreeze(account); + + // Ensure the line is unfrozen with a request that is fine + // except too it requests more principal than the broker can + // carry + env(set(evan, broker.brokerID, debtMaximumRequest + 1), + Sig(sfCounterpartySignature, lender), + loanSetFee, + Ter(tecLIMIT_EXCEEDED)); + } + } + } + + // Finally! Create a loan + + auto coverAvailable = [&env, this](uint256 const& brokerID, Number const& expected) { + if (auto const brokerSle = env.le(keylet::loanBroker(brokerID)); + BEAST_EXPECT(brokerSle)) + { + auto const available = brokerSle->at(sfCoverAvailable); + BEAST_EXPECT(available == expected); + return available; + } + return Number{}; + }; + auto getDefaultInfo = [&env, this](LoanState const& state, BrokerInfo const& broker) { + if (auto const brokerSle = env.le(keylet::loanBroker(broker.brokerID)); + BEAST_EXPECT(brokerSle)) + { + BEAST_EXPECT( + state.loanScale >= + (broker.asset.integral() + ? 0 + : std::max( + broker.vaultScale(env), state.principalOutstanding.exponent()))); + NumberRoundModeGuard const mg(Number::RoundingMode::Upward); + auto const defaultAmount = roundToAsset( + broker.asset, + std::min( + tenthBipsOfValue( + tenthBipsOfValue( + brokerSle->at(sfDebtTotal), broker.params.coverRateMin), + broker.params.coverRateLiquidation), + state.totalValue - state.managementFeeOutstanding), + state.loanScale); + return std::make_pair(defaultAmount, brokerSle->at(sfOwner)); + } + return std::make_pair(Number{}, AccountID{}); + }; + auto replenishCover = [&env, &coverAvailable]( + BrokerInfo const& broker, + AccountID const& brokerAcct, + Number const& startingCoverAvailable, + Number const& amountToBeCovered) { + coverAvailable(broker.brokerID, startingCoverAvailable - amountToBeCovered); + env(loanBroker::coverDeposit( + brokerAcct, broker.brokerID, STAmount{broker.asset, amountToBeCovered})); + coverAvailable(broker.brokerID, startingCoverAvailable); + env.close(); + }; + + auto defaultImmediately = [&](std::uint32_t baseFlag, bool impair = true) { + return [&, impair, baseFlag]( + Keylet const& loanKeylet, VerifyLoanStatus const& verifyLoanStatus) { + // toEndOfLife + // + // Default the loan + + // Initialize values with the current state + auto state = getCurrentState(env, broker, loanKeylet, verifyLoanStatus); + BEAST_EXPECT(state.flags == baseFlag); + + auto const& broker = verifyLoanStatus.broker; + auto const startingCoverAvailable = coverAvailable( + broker.brokerID, broker.asset(broker.params.coverDeposit).number()); + + if (impair) + { + // Check the vault + bool const canImpair = canImpairLoan(env, broker, state); + // Impair the loan, if possible + env(manage(lender, loanKeylet.key, tfLoanImpair), + canImpair ? Ter(tesSUCCESS) : Ter(tecLIMIT_EXCEEDED)); + + if (canImpair) + { + state.flags |= tfLoanImpair; + state.nextPaymentDate = env.now().time_since_epoch().count(); + + // Once the loan is impaired, it can't be impaired again + env(manage(lender, loanKeylet.key, tfLoanImpair), Ter(tecNO_PERMISSION)); + } + verifyLoanStatus(state); + } + + auto const nextDueDate = tp{d{state.nextPaymentDate}}; + + // Can't default the loan yet. The grace period hasn't + // expired + env(manage(lender, loanKeylet.key, tfLoanDefault), Ter(tecTOO_SOON)); + + // Let some time pass so that the loan can be + // defaulted + env.close(nextDueDate + 60s); + + auto const [amountToBeCovered, brokerAcct] = getDefaultInfo(state, broker); + + // Default the loan + env(manage(lender, loanKeylet.key, tfLoanDefault)); + env.close(); + + // The LoanBroker just lost some of it's first-loss capital. + // Replenish it. + replenishCover(broker, brokerAcct, startingCoverAvailable, amountToBeCovered); + + state.flags |= tfLoanDefault; + state.paymentRemaining = 0; + state.totalValue = 0; + state.principalOutstanding = 0; + state.managementFeeOutstanding = 0; + state.nextPaymentDate = 0; + verifyLoanStatus(state); + + // Once a loan is defaulted, it can't be managed + env(manage(lender, loanKeylet.key, tfLoanUnimpair), Ter(tecNO_PERMISSION)); + env(manage(lender, loanKeylet.key, tfLoanImpair), Ter(tecNO_PERMISSION)); + // Can't make a payment on it either + env(pay(borrower, loanKeylet.key, broker.asset(300)), Ter(tecKILLED)); + }; + }; + + auto singlePayment = [&](Keylet const& loanKeylet, + VerifyLoanStatus const& verifyLoanStatus, + LoanState& state, + STAmount const& payoffAmount, + std::uint32_t numPayments, + std::uint32_t baseFlag, + std::uint32_t txFlags) { + // toEndOfLife + // + verifyLoanStatus(state); + + // Send some bogus pay transactions + env(pay(borrower, keylet::loan(uint256(0)).key, broker.asset(10), txFlags), + Ter(temINVALID)); + // broker.asset(80) is less than a single payment, but all these + // checks fail before that matters + env(pay(borrower, loanKeylet.key, broker.asset(-80), txFlags), Ter(temBAD_AMOUNT)); + env(pay(borrower, broker.brokerID, broker.asset(80), txFlags), Ter(tecNO_ENTRY)); + env(pay(evan, loanKeylet.key, broker.asset(80), txFlags), Ter(tecNO_PERMISSION)); + + // TODO: Write a general "isFlag" function? See STObject::isFlag. + // Maybe add a static overloaded member? + if (!(state.flags & lsfLoanOverpayment)) + { + // If the loan does not allow overpayments, send a payment that + // tries to make an overpayment. Do not include `txFlags`, so we + // don't end up duplicating the next test transaction. + // + // fixCleanup3_1_3 gates tfLoanOverpayment as a valid flag: + // with fix on → preflight passes, apply returns tecNO_PERMISSION; + // with fix off → preflight rejects the flag, returns temINVALID_FLAG. + bool const hasFix313 = env.current()->rules().enabled(fixCleanup3_1_3); + STAmount const overpayAmount{broker.asset, state.periodicPayment * Number{15, -1}}; + XRPAmount const overpayFee{ + baseFee * (Number{15, -1} / kLoanPaymentsPerFeeIncrement + 1)}; + env(pay(borrower, loanKeylet.key, overpayAmount, tfLoanOverpayment), + Fee(overpayFee), + Ter(hasFix313 ? TER{tecNO_PERMISSION} : TER{temINVALID_FLAG})); + + if (hasFix313) + { + env.disableFeature(fixCleanup3_1_3); + env(pay(borrower, loanKeylet.key, overpayAmount, tfLoanOverpayment), + Fee(overpayFee), + Ter(temINVALID_FLAG)); + env.enableFeature(fixCleanup3_1_3); + } + } + // Try to send a payment marked as multiple mutually exclusive + // payment types. Do not include `txFlags`, so we don't duplicate + // the prior test transaction. + env(pay(borrower, + loanKeylet.key, + broker.asset(state.periodicPayment * 2), + tfLoanLatePayment | tfLoanFullPayment), + Ter(temINVALID_FLAG)); + env(pay(borrower, + loanKeylet.key, + broker.asset(state.periodicPayment * 2), + tfLoanLatePayment | tfLoanOverpayment), + Ter(temINVALID_FLAG)); + env(pay(borrower, + loanKeylet.key, + broker.asset(state.periodicPayment * 2), + tfLoanOverpayment | tfLoanFullPayment), + Ter(temINVALID_FLAG)); + env(pay(borrower, + loanKeylet.key, + broker.asset(state.periodicPayment * 2), + tfLoanLatePayment | tfLoanOverpayment | tfLoanFullPayment), + Ter(temINVALID_FLAG)); + + { + auto const otherAsset = + broker.asset.raw() == assets[0].raw() ? assets[1] : assets[0]; + env(pay(borrower, loanKeylet.key, otherAsset(100), txFlags), Ter(tecWRONG_ASSET)); + } + + // Amount doesn't cover a single payment + env(pay(borrower, loanKeylet.key, STAmount{broker.asset, 1}, txFlags), + Ter(tecINSUFFICIENT_PAYMENT)); + + // Get the balance after these failed transactions take + // fees + auto const borrowerBalanceBeforePayment = env.balance(borrower, broker.asset); + + BEAST_EXPECT(payoffAmount > state.principalOutstanding); + // Try to pay a little extra to show that it's _not_ + // taken + auto const transactionAmount = payoffAmount + broker.asset(10); + + // Send a transaction that tries to pay more than the borrowers's + // balance + XRPAmount const badFee{ + baseFee * + (borrowerBalanceBeforePayment.number() * 2 / state.periodicPayment / + kLoanPaymentsPerFeeIncrement + + 1)}; + env(pay(borrower, + loanKeylet.key, + STAmount{broker.asset, borrowerBalanceBeforePayment.number() * 2}, + txFlags), + Fee(badFee), + Ter(tecINSUFFICIENT_FUNDS)); + + XRPAmount const goodFee{baseFee * (numPayments / kLoanPaymentsPerFeeIncrement + 1)}; + env(pay(borrower, loanKeylet.key, transactionAmount, txFlags), Fee(goodFee)); + + env.close(); + + // log << env.meta()->getJson() << std::endl; + + // Need to account for fees if the loan is in XRP + PrettyAmount adjustment = broker.asset(0); + if (broker.asset.native()) + { + adjustment = badFee + goodFee; + } + + state.paymentRemaining = 0; + state.principalOutstanding = 0; + state.totalValue = 0; + state.managementFeeOutstanding = 0; + state.previousPaymentDate = + state.nextPaymentDate + (state.paymentInterval * (numPayments - 1)); + state.nextPaymentDate = 0; + verifyLoanStatus(state); + + verifyLoanStatus.checkPayment( + state.loanScale, borrower, borrowerBalanceBeforePayment, payoffAmount, adjustment); + + // Can't impair or default a paid off loan + env(manage(lender, loanKeylet.key, tfLoanImpair), Ter(tecNO_PERMISSION)); + env(manage(lender, loanKeylet.key, tfLoanDefault), Ter(tecNO_PERMISSION)); + }; + + auto fullPayment = [&](std::uint32_t baseFlag) { + return [&, baseFlag]( + Keylet const& loanKeylet, VerifyLoanStatus const& verifyLoanStatus) { + // toEndOfLife + // + auto state = getCurrentState(env, broker, loanKeylet, verifyLoanStatus); + env.close(state.startDate + 20s); + auto const loanAge = (env.now() - state.startDate).count(); + BEAST_EXPECT(loanAge == 30); + + // Full payoff amount will consist of + // 1. principal outstanding (1000) + // 2. accrued interest (at 12%) + // 3. prepayment penalty (closeInterest at 3.6%) + // 4. close payment fee (4) + // Calculate these values without the helper functions + // to verify they're working correctly The numbers in + // the below BEAST_EXPECTs may not hold across assets. + Number const interval = state.paymentInterval; + auto const periodicRate = interval * Number(12, -2) / kSecondsInYear; + BEAST_EXPECT( + periodicRate == Number(2283105022831050228ULL, -24, Number::Normalized{})); + STAmount const principalOutstanding{broker.asset, state.principalOutstanding}; + STAmount const accruedInterest{ + broker.asset, state.principalOutstanding * periodicRate * loanAge / interval}; + BEAST_EXPECT(accruedInterest == broker.asset(Number(1141552511415525, -19))); + STAmount const prepaymentPenalty{ + broker.asset, state.principalOutstanding * Number(36, -3)}; + BEAST_EXPECT(prepaymentPenalty == broker.asset(36)); + STAmount const closePaymentFee = broker.asset(4); + auto const payoffAmount = roundToScale( + principalOutstanding + accruedInterest + prepaymentPenalty + closePaymentFee, + state.loanScale); + BEAST_EXPECT( + payoffAmount == + roundToAsset( + broker.asset, + broker.asset(Number(1040000114155251, -12)).number(), + state.loanScale)); + + // The terms of this loan actually make the early payoff + // more expensive than just making payments + BEAST_EXPECT( + payoffAmount > + state.paymentRemaining * (state.periodicPayment + broker.asset(2).value())); + + singlePayment( + loanKeylet, + verifyLoanStatus, + state, + payoffAmount, + 1, + baseFlag, + tfLoanFullPayment); + }; + }; + + auto combineAllPayments = [&](std::uint32_t baseFlag) { + return + [&, baseFlag](Keylet const& loanKeylet, VerifyLoanStatus const& verifyLoanStatus) { + // toEndOfLife + // + + auto state = getCurrentState(env, broker, loanKeylet, verifyLoanStatus); + env.close(); + + BEAST_EXPECT( + STAmount(broker.asset, state.periodicPayment) == + broker.asset(Number(8333457002039338267, -17))); + + // Make all the payments in one transaction + // service fee is 2 + auto const startingPayments = state.paymentRemaining; + STAmount const payoffAmount = [&]() { + NumberRoundModeGuard const mg(Number::RoundingMode::Upward); + auto const rawPayoff = + startingPayments * (state.periodicPayment + broker.asset(2).value()); + STAmount payoffAmount{broker.asset, rawPayoff}; + BEAST_EXPECTS( + payoffAmount == broker.asset(Number(1024014840244721, -12)), + to_string(payoffAmount)); + BEAST_EXPECT(payoffAmount > state.principalOutstanding); + + payoffAmount = roundToScale(payoffAmount, state.loanScale); + + return payoffAmount; + }(); + + auto const totalPayoffValue = + state.totalValue + startingPayments * broker.asset(2).value(); + STAmount const totalPayoffAmount{broker.asset, totalPayoffValue}; + + BEAST_EXPECTS( + totalPayoffAmount == payoffAmount, + "Payoff amount: " + to_string(payoffAmount) + + ". Total Value: " + to_string(totalPayoffAmount)); + + singlePayment( + loanKeylet, + verifyLoanStatus, + state, + payoffAmount, + state.paymentRemaining, + baseFlag, + 0); + }; + }; + + // There are a lot of fields that can be set on a loan, but most + // of them only affect the "math" when a payment is made. The + // only one that really affects behavior is the + // `tfLoanOverpayment` flag. + lifecycle( + caseLabel, + "Loan overpayment allowed - Impair and Default", + env, + loanAmount, + interestExponent, + lender, + borrower, + evan, + broker, + pseudoAcct, + tfLoanOverpayment, + defaultImmediately(lsfLoanOverpayment)); + + lifecycle( + caseLabel, + "Loan overpayment prohibited - Impair and Default", + env, + loanAmount, + interestExponent, + lender, + borrower, + evan, + broker, + pseudoAcct, + 0, + defaultImmediately(0)); + + lifecycle( + caseLabel, + "Loan overpayment allowed - Default without Impair", + env, + loanAmount, + interestExponent, + lender, + borrower, + evan, + broker, + pseudoAcct, + tfLoanOverpayment, + defaultImmediately(lsfLoanOverpayment, false)); + + lifecycle( + caseLabel, + "Loan overpayment prohibited - Default without Impair", + env, + loanAmount, + interestExponent, + lender, + borrower, + evan, + broker, + pseudoAcct, + 0, + defaultImmediately(0, false)); + + lifecycle( + caseLabel, + "Loan overpayment prohibited - Pay off immediately", + env, + loanAmount, + interestExponent, + lender, + borrower, + evan, + broker, + pseudoAcct, + 0, + fullPayment(0)); + + lifecycle( + caseLabel, + "Loan overpayment allowed - Pay off immediately", + env, + loanAmount, + interestExponent, + lender, + borrower, + evan, + broker, + pseudoAcct, + tfLoanOverpayment, + fullPayment(lsfLoanOverpayment)); + + lifecycle( + caseLabel, + "Loan overpayment prohibited - Combine all payments", + env, + loanAmount, + interestExponent, + lender, + borrower, + evan, + broker, + pseudoAcct, + 0, + combineAllPayments(0)); + + lifecycle( + caseLabel, + "Loan overpayment allowed - Combine all payments", + env, + loanAmount, + interestExponent, + lender, + borrower, + evan, + broker, + pseudoAcct, + tfLoanOverpayment, + combineAllPayments(lsfLoanOverpayment)); + + lifecycle( + caseLabel, + "Loan overpayment prohibited - Make payments", + env, + loanAmount, + interestExponent, + lender, + borrower, + evan, + broker, + pseudoAcct, + 0, + [&](Keylet const& loanKeylet, VerifyLoanStatus const& verifyLoanStatus) { + // toEndOfLife + // + // Draw and make multiple payments + auto state = getCurrentState(env, broker, loanKeylet, verifyLoanStatus); + BEAST_EXPECT(state.flags == 0); + env.close(); + + verifyLoanStatus(state); + + env.close(state.startDate + 20s); + auto const loanAge = (env.now() - state.startDate).count(); + BEAST_EXPECT(loanAge == 30); + + // Periodic payment amount will consist of + // 1. principal outstanding (1000) + // 2. interest interest rate (at 12%) + // 3. payment interval (600s) + // 4. loan service fee (2) + // Calculate these values without the helper functions + // to verify they're working correctly The numbers in + // the below BEAST_EXPECTs may not hold across assets. + Number const interval = state.paymentInterval; + auto const periodicRate = interval * Number(12, -2) / kSecondsInYear; + BEAST_EXPECT( + periodicRate == Number(2283105022831050228, -24, Number::Normalized{})); + STAmount const roundedPeriodicPayment{ + broker.asset, + roundPeriodicPayment(broker.asset, state.periodicPayment, state.loanScale)}; + + testcase << currencyLabel << " Payment components: " + << "Payments remaining, rawInterest, rawPrincipal, " + "rawMFee, trackedValueDelta, trackedPrincipalDelta, " + "trackedInterestDelta, trackedMgmtFeeDelta, special"; + + auto const serviceFee = broker.asset(2); + + BEAST_EXPECT( + roundedPeriodicPayment == + roundToScale( + broker.asset( + Number(8333457002039338267, -17), Number::RoundingMode::Upward), + state.loanScale, + Number::RoundingMode::Upward)); + // 83334570.01162141 + // Include the service fee + STAmount const totalDue = roundToScale( + roundedPeriodicPayment + serviceFee, + state.loanScale, + Number::RoundingMode::Upward); + // Only check the first payment since the rounding + // may drift as payments are made + BEAST_EXPECT( + totalDue == + roundToScale( + broker.asset( + Number(8533457002039338267, -17), Number::RoundingMode::Upward), + state.loanScale, + Number::RoundingMode::Upward)); + + { + auto const raw = computeTheoreticalLoanState( + env.current()->rules(), + state.periodicPayment, + periodicRate, + state.paymentRemaining, + broker.params.managementFeeRate); + auto const rounded = constructLoanState( + state.totalValue, + state.principalOutstanding, + state.managementFeeOutstanding); + testcase << currencyLabel << " Loan starting state: " << state.paymentRemaining + << ", " << raw.interestDue << ", " << raw.principalOutstanding << ", " + << raw.managementFeeDue << ", " << rounded.valueOutstanding << ", " + << rounded.principalOutstanding << ", " << rounded.interestDue << ", " + << rounded.managementFeeDue; + } + + // Try to pay a little extra to show that it's _not_ + // taken + STAmount const transactionAmount = + STAmount{broker.asset, totalDue} + broker.asset(10); + // Only check the first payment since the rounding + // may drift as payments are made + BEAST_EXPECT( + transactionAmount == + roundToScale( + broker.asset(Number(9533457002039400, -14), Number::RoundingMode::Upward), + state.loanScale, + Number::RoundingMode::Upward)); + + auto const initialState = state; + xrpl::detail::PaymentComponents totalPaid{ + .trackedValueDelta = 0, + .trackedPrincipalDelta = 0, + .trackedManagementFeeDelta = 0}; + Number totalInterestPaid = 0; + std::size_t totalPaymentsMade = 0; + + xrpl::LoanState currentTrueState = computeTheoreticalLoanState( + env.current()->rules(), + state.periodicPayment, + periodicRate, + state.paymentRemaining, + broker.params.managementFeeRate); + + while (state.paymentRemaining > 0) + { + // Compute the expected principal amount + auto const paymentComponents = xrpl::detail::computePaymentComponents( + env.current()->rules(), + broker.asset.raw(), + state.loanScale, + state.totalValue, + state.principalOutstanding, + state.managementFeeOutstanding, + state.periodicPayment, + periodicRate, + state.paymentRemaining, + broker.params.managementFeeRate); + + BEAST_EXPECTS( + paymentComponents.specialCase == xrpl::detail::PaymentSpecialCase::Final || + paymentComponents.trackedValueDelta <= roundedPeriodicPayment, + "Delta: " + to_string(paymentComponents.trackedValueDelta) + + ", periodic payment: " + to_string(roundedPeriodicPayment)); + + xrpl::LoanState const nextTrueState = computeTheoreticalLoanState( + env.current()->rules(), + state.periodicPayment, + periodicRate, + state.paymentRemaining - 1, + broker.params.managementFeeRate); + xrpl::detail::LoanStateDeltas const deltas = currentTrueState - nextTrueState; + + testcase << currencyLabel << " Payment components: " << state.paymentRemaining + << ", " << deltas.interest << ", " << deltas.principal << ", " + << deltas.managementFee << ", " << paymentComponents.trackedValueDelta + << ", " << paymentComponents.trackedPrincipalDelta << ", " + << paymentComponents.trackedInterestPart() << ", " + << paymentComponents.trackedManagementFeeDelta << ", " + << [&]() -> char const* { + if (paymentComponents.specialCase == + ::xrpl::detail::PaymentSpecialCase::Final) + return "final"; + if (paymentComponents.specialCase == + ::xrpl::detail::PaymentSpecialCase::Extra) + return "extra"; + return "none"; + }(); + + auto const totalDueAmount = STAmount{ + broker.asset, paymentComponents.trackedValueDelta + serviceFee.number()}; + + // Due to the rounding algorithms to keep the interest and + // principal in sync with "true" values, the computed amount + // may be a little less than the rounded fixed payment + // amount. For integral types, the difference should be < 3 + // (1 unit for each of the interest and management fee). For + // IOUs, the difference should be after the 8th digit. + Number const diff = totalDue - totalDueAmount; + BEAST_EXPECT( + paymentComponents.specialCase == xrpl::detail::PaymentSpecialCase::Final || + diff == beast::kZero || + (diff > beast::kZero && + ((broker.asset.integral() && (static_cast(diff) < 3)) || + (state.loanScale - diff.exponent() > 13)))); + + BEAST_EXPECT( + paymentComponents.trackedValueDelta == + paymentComponents.trackedPrincipalDelta + + paymentComponents.trackedInterestPart() + + paymentComponents.trackedManagementFeeDelta); + BEAST_EXPECT( + paymentComponents.specialCase == xrpl::detail::PaymentSpecialCase::Final || + paymentComponents.trackedValueDelta <= roundedPeriodicPayment); + + BEAST_EXPECT( + state.paymentRemaining < 12 || + roundToAsset( + broker.asset, + deltas.principal, + state.loanScale, + Number::RoundingMode::Upward) == + roundToScale( + broker.asset( + Number(8333228691531218890, -17), Number::RoundingMode::Upward), + state.loanScale, + Number::RoundingMode::Upward)); + BEAST_EXPECT( + paymentComponents.trackedPrincipalDelta >= beast::kZero && + paymentComponents.trackedPrincipalDelta <= state.principalOutstanding); + BEAST_EXPECT( + paymentComponents.specialCase != xrpl::detail::PaymentSpecialCase::Final || + paymentComponents.trackedPrincipalDelta == state.principalOutstanding); + BEAST_EXPECT( + paymentComponents.specialCase == xrpl::detail::PaymentSpecialCase::Final || + (state.periodicPayment.exponent() - + (deltas.principal + deltas.interest + deltas.managementFee - + state.periodicPayment) + .exponent()) > 14); + + auto const borrowerBalanceBeforePayment = env.balance(borrower, broker.asset); + + if (canImpairLoan(env, broker, state)) + { + // Making a payment will unimpair the loan + env(manage(lender, loanKeylet.key, tfLoanImpair)); + } + + env.close(); + + // Make the payment + env(pay(borrower, loanKeylet.key, transactionAmount)); + + env.close(); + + // Need to account for fees if the loan is in XRP + PrettyAmount adjustment = broker.asset(0); + if (broker.asset.native()) + { + adjustment = env.current()->fees().base; + } + + // Check the result + verifyLoanStatus.checkPayment( + state.loanScale, + borrower, + borrowerBalanceBeforePayment, + totalDueAmount, + adjustment); + + --state.paymentRemaining; + state.previousPaymentDate = state.nextPaymentDate; + if (paymentComponents.specialCase == xrpl::detail::PaymentSpecialCase::Final) + { + state.paymentRemaining = 0; + state.nextPaymentDate = 0; + } + else + { + state.nextPaymentDate += state.paymentInterval; + } + state.principalOutstanding -= paymentComponents.trackedPrincipalDelta; + state.managementFeeOutstanding -= paymentComponents.trackedManagementFeeDelta; + state.totalValue -= paymentComponents.trackedValueDelta; + + verifyLoanStatus(state); + + totalPaid.trackedValueDelta += paymentComponents.trackedValueDelta; + totalPaid.trackedPrincipalDelta += paymentComponents.trackedPrincipalDelta; + totalPaid.trackedManagementFeeDelta += + paymentComponents.trackedManagementFeeDelta; + totalInterestPaid += paymentComponents.trackedInterestPart(); + ++totalPaymentsMade; + + currentTrueState = nextTrueState; + } + + // Loan is paid off + BEAST_EXPECT(state.paymentRemaining == 0); + BEAST_EXPECT(state.principalOutstanding == 0); + + // Make sure all the payments add up + BEAST_EXPECT(totalPaid.trackedValueDelta == initialState.totalValue); + BEAST_EXPECT(totalPaid.trackedPrincipalDelta == initialState.principalOutstanding); + BEAST_EXPECT( + totalPaid.trackedManagementFeeDelta == initialState.managementFeeOutstanding); + // This is almost a tautology given the previous checks, but + // check it anyway for completeness. + BEAST_EXPECT( + totalInterestPaid == + initialState.totalValue - + (initialState.principalOutstanding + + initialState.managementFeeOutstanding)); + BEAST_EXPECT(totalPaymentsMade == initialState.paymentRemaining); + + // Can't impair or default a paid off loan + env(manage(lender, loanKeylet.key, tfLoanImpair), Ter(tecNO_PERMISSION)); + env(manage(lender, loanKeylet.key, tfLoanDefault), Ter(tecNO_PERMISSION)); + }); + +#if LOAN_TODO + // TODO + + /* + LoanPay fails with tecINVARIANT_FAILED error when loan_broker(also + borrower) tries to do the payment. Here's the scenario: Create a XRP + loan with loan broker as borrower, loan origination fee and loan service + fee. Loan broker makes the first payment with periodic payment and loan + service fee. + */ + + auto time = [&](std::string label, std::function timed) { + if (!BEAST_EXPECT(timed)) + return; + + using clock_type = std::chrono::steady_clock; + using duration_type = std::chrono::milliseconds; + + auto const start = clock_type::now(); + timed(); + auto const duration = + std::chrono::duration_cast(clock_type::now() - start); + + log << label << " took " << duration.count() << "ms" << std::endl; + + return duration; + }; + + lifecycle( + caseLabel, + "timing", + env, + loanAmount, + interestExponent, + lender, + borrower, + evan, + broker, + pseudoAcct, + tfLoanOverpayment, + [&](Keylet const& loanKeylet, VerifyLoanStatus const& verifyLoanStatus) { + // Estimate optimal values for kLoanPaymentsPerFeeIncrement and + // kLoanMaximumPaymentsPerTransaction. + using namespace loan; + + auto const state = getCurrentState(env, broker, verifyLoanStatus.keylet); + auto const serviceFee = broker.asset(2).value(); + + STAmount const totalDue{ + broker.asset, + roundPeriodicPayment( + broker.asset, state.periodicPayment + serviceFee, state.loanScale)}; + + // Make a single payment + time("single payment", [&]() { env(pay(borrower, loanKeylet.key, totalDue)); }); + env.close(); + + // Make all but the final payment + auto const numPayments = (state.paymentRemaining - 2); + STAmount const bigPayment{broker.asset, totalDue * numPayments}; + XRPAmount const bigFee{baseFee * (numPayments / kLoanPaymentsPerFeeIncrement + 1)}; + time("ten payments", [&]() { + env(pay(borrower, loanKeylet.key, bigPayment), Fee(bigFee)); + }); + env.close(); + + time("final payment", [&]() { + // Make the final payment + env(pay(borrower, loanKeylet.key, totalDue + STAmount{broker.asset, 1})); + }); + env.close(); + }); + + lifecycle( + caseLabel, + "Loan overpayment allowed - Explicit overpayment", + env, + loanAmount, + interestExponent, + lender, + borrower, + evan, + broker, + pseudoAcct, + tfLoanOverpayment, + [&](Keylet const& loanKeylet, VerifyLoanStatus const& verifyLoanStatus) { throw 0; }); + + lifecycle( + caseLabel, + "Loan overpayment prohibited - Late payment", + env, + loanAmount, + interestExponent, + lender, + borrower, + evan, + broker, + pseudoAcct, + tfLoanOverpayment, + [&](Keylet const& loanKeylet, VerifyLoanStatus const& verifyLoanStatus) { throw 0; }); + + lifecycle( + caseLabel, + "Loan overpayment allowed - Late payment", + env, + loanAmount, + interestExponent, + lender, + borrower, + evan, + broker, + pseudoAcct, + tfLoanOverpayment, + [&](Keylet const& loanKeylet, VerifyLoanStatus const& verifyLoanStatus) { throw 0; }); + + lifecycle( + caseLabel, + "Loan overpayment allowed - Late payment and overpayment", + env, + loanAmount, + interestExponent, + lender, + borrower, + evan, + broker, + pseudoAcct, + tfLoanOverpayment, + [&](Keylet const& loanKeylet, VerifyLoanStatus const& verifyLoanStatus) { throw 0; }); + +#endif + } }; } // namespace xrpl::test diff --git a/src/test/app/LoanValidation_test.cpp b/src/test/app/LoanValidation_test.cpp new file mode 100644 index 00000000000..de5ce4dc9dc --- /dev/null +++ b/src/test/app/LoanValidation_test.cpp @@ -0,0 +1,522 @@ +#include + +namespace xrpl::test { + +class LoanValidation_test : public LoanTestBase +{ +private: + void + testDisabled() + { + testcase("Disabled"); + // Lending Protocol depends on Single Asset Vault (SAV). Test + // combinations of the two amendments. + // Single Asset Vault depends on MPTokensV1, but don't test every combo + // of that. + using namespace jtx; + auto failAll = [this](FeatureBitset features) { + Env env(*this, features); + + Account const alice{"alice"}; + Account const bob{"bob"}; + env.fund(XRP(10000), alice, bob); + + auto const keylet = keylet::loanBroker(alice, env.seq(alice)); + + using namespace std::chrono_literals; + using namespace loan; + + // counter party signature is optional on LoanSet. Confirm that by + // sending transaction without one. + auto setTx = env.jt(set(alice, keylet.key, Number(10000)), Ter(temDISABLED)); + env(setTx); + + // All loan transactions are disabled. + // 1. LoanSet + setTx = env.jt(setTx, Sig(sfCounterpartySignature, bob), Ter(temDISABLED)); + env(setTx); + // Actual sequence will be based off the loan broker, but we + // obviously don't have one of those if the amendment is disabled + auto const loanKeylet = keylet::loan(keylet.key, env.seq(alice)); + // Other Loan transactions are disabled, too. + // 2. LoanDelete + env(del(alice, loanKeylet.key), Ter(temDISABLED)); + // 3. LoanManage + env(manage(alice, loanKeylet.key, tfLoanImpair), Ter(temDISABLED)); + // 4. LoanPay + env(pay(alice, loanKeylet.key, XRP(500)), Ter(temDISABLED)); + }; + failAll(all_ - featureMPTokensV1); + failAll(all_ - featureSingleAssetVault - featureLendingProtocol); + failAll(all_ - featureSingleAssetVault); + failAll(all_ - featureLendingProtocol); + } + + void + testInvalidLoanSet() + { + testcase("Invalid LoanSet"); + using namespace jtx; + using namespace loan; + Account const lender{"lender"}; + Account const issuer{"issuer"}; + Account const borrower{"borrower"}; + Account const sponsor{"sponsor"}; + auto const iou = issuer["IOU"]; + + auto testWrapper = [&](auto&& test) { + Env env(*this); + env.fund(XRP(1'000), lender, issuer, borrower, sponsor); + env(trust(lender, iou(10'000'000))); + env(pay(issuer, lender, iou(5'000'000))); + BrokerInfo const brokerInfo{createVaultAndBroker(env, issuer["IOU"], lender)}; + + auto const loanSetFee = Fee(env.current()->fees().base * 2); + Number const debtMaximumRequest = brokerInfo.asset(1'000).value(); + test(env, brokerInfo, loanSetFee, debtMaximumRequest); + }; + + // preflight: + testWrapper([&](Env& env, + BrokerInfo const& brokerInfo, + jtx::Fee const& loanSetFee, + Number const& debtMaximumRequest) { + for (auto const sponsorFlags : {spfSponsorReserve, spfSponsorReserve | spfSponsorFee}) + { + env(set(borrower, brokerInfo.brokerID, debtMaximumRequest), + sponsor::As(sponsor, sponsorFlags), + Sig(sfCounterpartySignature, lender), + loanSetFee, + Ter(temINVALID_FLAG)); + } + + // first temBAD_SIGNER: TODO + // invalid grace period + { + // zero grace period + env(set(borrower, brokerInfo.brokerID, debtMaximumRequest), + Sig(sfCounterpartySignature, lender), + kGracePeriod(0), + loanSetFee, + Ter(temINVALID)); + + // grace period less than default minimum + env(set(borrower, brokerInfo.brokerID, debtMaximumRequest), + Sig(sfCounterpartySignature, lender), + kGracePeriod(LoanSet::kDefaultGracePeriod - 1), + loanSetFee, + Ter(temINVALID)); + + // grace period greater than payment interval + env(set(borrower, brokerInfo.brokerID, debtMaximumRequest), + Sig(sfCounterpartySignature, lender), + kPaymentInterval(120), + kGracePeriod(121), + loanSetFee, + Ter(temINVALID)); + } + // empty/zero broker ID + { + auto jv = set(borrower, uint256{}, debtMaximumRequest); + + auto testZeroBrokerID = [&](std::string const& id, std::uint32_t flags = 0) { + // empty broker ID + jv[sfLoanBrokerID] = id; + env(jv, + Sig(sfCounterpartySignature, lender), + loanSetFee, + Txflags(flags), + Ter(temINVALID)); + }; + // empty broker ID + testZeroBrokerID(std::string("")); + // zero broker ID + // needs a flag to distinguish the parsed STTx from the prior + // test + testZeroBrokerID(to_string(uint256{}), tfFullyCanonicalSig); + } + + // preflightCheckSigningKey() failure: + // can it happen? the signature is checked before transactor + // executes + + JTx const tx = env.jt( + set(borrower, brokerInfo.brokerID, debtMaximumRequest), + Sig(sfCounterpartySignature, lender), + loanSetFee); + STTx local = *(tx.stx); + auto counterpartySig = local.getFieldObject(sfCounterpartySignature); + auto badPubKey = counterpartySig.getFieldVL(sfSigningPubKey); + badPubKey[20] ^= 0xAA; + counterpartySig.setFieldVL(sfSigningPubKey, badPubKey); + local.setFieldObject(sfCounterpartySignature, counterpartySig); + json::Value jvResult; + jvResult[jss::tx_blob] = strHex(local.getSerializer().slice()); + auto res = env.rpc("json", "submit", to_string(jvResult))["result"]; + BEAST_EXPECT( + res[jss::error] == "invalidTransaction" && + res[jss::error_exception] == + "fails local checks: Counterparty: Invalid signature."); + }); + + // preclaim: + testWrapper([&](Env& env, + BrokerInfo const& brokerInfo, + jtx::Fee const& loanSetFee, + Number const& debtMaximumRequest) { + // canAddHoldingFailure (IOU only, if MPT doesn't have + // MPTCanTransfer set, then can't create Vault/LoanBroker, + // and LoanSet will fail with different error + env(fclear(issuer, asfDefaultRipple)); + env.close(); + env(set(borrower, brokerInfo.brokerID, debtMaximumRequest), + Sig(sfCounterpartySignature, lender), + loanSetFee, + Ter(terNO_RIPPLE)); + }); + + // doApply: + testWrapper([&](Env& env, + BrokerInfo const& brokerInfo, + jtx::Fee const& loanSetFee, + Number const& debtMaximumRequest) { + auto const amt = + env.balance(borrower) - accountReserve(*env.current(), borrower.id(), env.journal); + env(pay(borrower, issuer, amt)); + + // tecINSUFFICIENT_RESERVE + env(set(borrower, brokerInfo.brokerID, debtMaximumRequest), + Sig(sfCounterpartySignature, lender), + loanSetFee, + Ter(tecINSUFFICIENT_RESERVE)); + + // addEmptyHolding failure + env(pay(issuer, borrower, amt)); + env(fset(issuer, asfGlobalFreeze)); + env.close(); + + env(set(borrower, brokerInfo.brokerID, debtMaximumRequest), + Sig(sfCounterpartySignature, lender), + loanSetFee, + Ter(tecFROZEN)); + }); + } + + void + testInvalidLoanDelete() + { + testcase("Invalid LoanDelete"); + using namespace jtx; + using namespace loan; + + // preflight: temINVALID, LoanID == zero + { + Account const alice{"alice"}; + Env env(*this); + env.fund(XRP(1'000), alice); + env.close(); + env(del(alice, beast::kZero), Ter(temINVALID)); + } + } + + void + testInvalidLoanManage() + { + testcase("Invalid LoanManage"); + using namespace jtx; + using namespace loan; + + // preflight: temINVALID, LoanID == zero + { + Account const alice{"alice"}; + Env env(*this); + env.fund(XRP(1'000), alice); + env.close(); + env(manage(alice, beast::kZero, tfLoanDefault), Ter(temINVALID)); + } + } + + void + testInvalidLoanPay() + { + testcase("Invalid LoanPay"); + using namespace jtx; + using namespace loan; + Account const lender{"lender"}; + Account const issuer{"issuer"}; + Account const borrower{"borrower"}; + auto const iou = issuer["IOU"]; + + // preclaim + Env env(*this); + env.fund(XRP(1'000), lender, issuer, borrower); + env(trust(lender, iou(10'000'000))); + env(pay(issuer, lender, iou(5'000'000))); + BrokerInfo brokerInfo{createVaultAndBroker(env, issuer["IOU"], lender)}; + + auto const loanSetFee = Fee(env.current()->fees().base * 2); + STAmount const debtMaximumRequest = brokerInfo.asset(1'000).value(); + + env(set(borrower, brokerInfo.brokerID, debtMaximumRequest), + Sig(sfCounterpartySignature, lender), + loanSetFee); + + env.close(); + + std::uint32_t const loanSequence = 1; + auto const loanKeylet = keylet::loan(brokerInfo.brokerID, loanSequence); + + env(fset(issuer, asfGlobalFreeze)); + env.close(); + + // preclaim: tecFROZEN + env(pay(borrower, loanKeylet.key, debtMaximumRequest), Ter(tecFROZEN)); + env.close(); + + env(fclear(issuer, asfGlobalFreeze)); + env.close(); + + auto const pseudoBroker = [&]() -> std::optional { + if (auto brokerSle = env.le(keylet::loanBroker(brokerInfo.brokerID)); + BEAST_EXPECT(brokerSle)) + { + return Account{"pseudo", brokerSle->at(sfAccount)}; + } + + return std::nullopt; + }(); + if (!pseudoBroker) + return; + + // Lender and pseudoaccount must both be frozen + env(trust(issuer, lender["IOU"](1'000), lender, tfSetFreeze | tfSetDeepFreeze)); + env(trust( + issuer, (*pseudoBroker)["IOU"](1'000), *pseudoBroker, tfSetFreeze | tfSetDeepFreeze)); + env.close(); + + // preclaim: tecFROZEN due to deep frozen + env(pay(borrower, loanKeylet.key, debtMaximumRequest), Ter(tecFROZEN)); + env.close(); + + // Only one needs to be unfrozen + env(trust(issuer, lender["IOU"](1'000), tfClearFreeze | tfClearDeepFreeze)); + env.close(); + + // The payment is late by this point + env(pay(borrower, loanKeylet.key, debtMaximumRequest), Ter(tecEXPIRED)); + env.close(); + env(pay(borrower, loanKeylet.key, debtMaximumRequest, tfLoanLatePayment)); + env.close(); + + // preclaim: tecKILLED + // note that tecKILLED in loanMakePayment() + // doesn't happen because of the preclaim check. + env(pay(borrower, loanKeylet.key, debtMaximumRequest), Ter(tecKILLED)); + } + + void + testRequireAuth() + { + testcase("Require Auth - Implicit Pseudo-account authorization"); + using namespace jtx; + using namespace loan; + Account const lender{"lender"}; + Account const issuer{"issuer"}; + Account const borrower{"borrower"}; + Env env(*this); + + env.fund(XRP(100'000), issuer, lender, borrower); + env.close(); + + auto asset = MPTTester({ + .env = env, + .issuer = issuer, + .holders = {lender, borrower}, + .flags = kMptDexFlags | tfMPTRequireAuth | tfMPTCanClawback | tfMPTCanLock, + .authHolder = true, + }); + + env(pay(issuer, lender, asset(5'000'000))); + BrokerInfo brokerInfo{createVaultAndBroker(env, asset, lender)}; + + auto const loanSetFee = Fee(env.current()->fees().base * 2); + STAmount const debtMaximumRequest = brokerInfo.asset(1'000).value(); + + auto forUnauthAuth = [&](auto&& doTx) { + for (auto const flag : {tfMPTUnauthorize, 0u}) + { + asset.authorize({.account = issuer, .holder = borrower, .flags = flag}); + env.close(); + doTx(flag == 0); + env.close(); + } + }; + + // Can't create a loan if the borrower is not authorized + forUnauthAuth([&](bool authorized) { + auto const err = !authorized ? Ter(tecNO_AUTH) : Ter(tesSUCCESS); + env(set(borrower, brokerInfo.brokerID, debtMaximumRequest), + Sig(sfCounterpartySignature, lender), + loanSetFee, + err); + }); + + static constexpr std::uint32_t kLoanSequence = 1; + auto const loanKeylet = keylet::loan(brokerInfo.brokerID, kLoanSequence); + + // Can't loan pay if the borrower is not authorized + forUnauthAuth([&](bool authorized) { + auto const err = !authorized ? Ter(tecNO_AUTH) : Ter(tesSUCCESS); + env(pay(borrower, loanKeylet.key, debtMaximumRequest), err); + }); + } + + void + testLimitExceeded() + { + testcase("RIPD-4125 - overpayment"); + + using namespace jtx; + + Account const issuer("issuer"); + Account const lender("lender"); + Account const borrower("borrower"); + + BrokerParameters const brokerParams{ + .vaultDeposit = 100'000, + .debtMax = 0, + .coverRateMin = TenthBips32{0}, + .managementFeeRate = TenthBips16{0}, + .coverRateLiquidation = TenthBips32{0}}; + LoanParameters const loanParams{ + .account = lender, + .counter = borrower, + .principalRequest = Number{200000, -6}, + .interest = TenthBips32{50000}, + .payTotal = 3, + .payInterval = 200, + .gracePd = 60, + .flags = tfLoanOverpayment, + }; + + auto const assetType = AssetType::XRP; + + Env env(*this, makeConfig(), all_, nullptr, beast::Severity::Warning); + + auto loanResult = + createLoan(env, assetType, brokerParams, loanParams, issuer, lender, borrower); + + if (BEAST_EXPECT(loanResult); !loanResult.has_value()) + return; + + auto broker = std::get(*loanResult); + auto loanKeylet = std::get(*loanResult); + auto pseudoAcct = std::get(*loanResult); + + VerifyLoanStatus const verifyLoanStatus(env, broker, pseudoAcct, loanKeylet); + + auto const state = getCurrentState(env, broker, loanKeylet); + + env(loan::pay( + borrower, + loanKeylet.key, + STAmount{broker.asset, state.periodicPayment * 3 / 2 + 1}, + tfLoanOverpayment)); + env.close(); + + PaymentParameters const paymentParams{ + .showStepBalances = false, + .validateBalances = true, + }; + + makeLoanPayments( + env, + broker, + loanParams, + loanKeylet, + verifyLoanStatus, + issuer, + lender, + borrower, + paymentParams); + } + + void + testWrongMaxDebtBehavior(FeatureBitset features) + { + // From FIND-003 + testcase << "Wrong Max Debt Behavior"; + + using namespace jtx; + using namespace std::chrono_literals; + Env env(*this, features); + + Account const issuer{"issuer"}; + Account const lender{"lender"}; + + BrokerParameters const brokerParams{.debtMax = 0}; + env.fund(XRP(brokerParams.vaultDeposit * 100), issuer, noripple(lender)); + env.close(); + + PrettyAsset const xrpAsset{xrpIssue(), 1'000'000}; + + BrokerInfo const broker{createVaultAndBroker(env, xrpAsset, lender, brokerParams)}; + + if (auto const brokerSle = env.le(keylet::loanBroker(broker.brokerID)); + BEAST_EXPECT(brokerSle)) + { + BEAST_EXPECT(brokerSle->at(sfDebtMaximum) == 0); + } + + using namespace loan; + + auto const loanSetFee = Fee(env.current()->fees().base * 2); + Number const principalRequest{1, 3}; + + auto createJson = env.json(set(lender, broker.brokerID, principalRequest), Fee(loanSetFee)); + + json::Value counterpartyJson{json::ValueType::Object}; + counterpartyJson[sfTxnSignature] = createJson[sfTxnSignature]; + counterpartyJson[sfSigningPubKey] = createJson[sfSigningPubKey]; + if (!BEAST_EXPECT(!createJson.isMember(jss::Signers))) + counterpartyJson[sfSigners] = createJson[sfSigners]; + + createJson = env.json(createJson, Json(sfCounterpartySignature, counterpartyJson)); + env(createJson); + + env.close(); + } + + void + runAmendmentIndependent() + { + testDisabled(); + testInvalidLoanSet(); + testInvalidLoanDelete(); + testInvalidLoanManage(); + testInvalidLoanPay(); + testRequireAuth(); + testLimitExceeded(); + } + + // Tests run under each entry in amendmentCombinations(). + void + runAmendmentSensitive(FeatureBitset features) + { + testWrongMaxDebtBehavior(features); + } + +public: + void + run() override + { + runAmendmentIndependent(); + for (auto const& features : jtx::amendmentCombinations( + {fixCleanup3_1_3, fixCleanup3_2_0, featureMPTokensV2}, all_)) + runAmendmentSensitive(features); + } +}; + +BEAST_DEFINE_TESTSUITE(LoanValidation, tx, xrpl); + +} // namespace xrpl::test diff --git a/src/test/app/Loan_test.cpp b/src/test/app/Loan_test.cpp index 55c79e45bb8..09033058430 100644 --- a/src/test/app/Loan_test.cpp +++ b/src/test/app/Loan_test.cpp @@ -5,1393 +5,6 @@ namespace xrpl::test { class Loan_test : public LoanTestBase { private: - void - testDisabled() - { - testcase("Disabled"); - // Lending Protocol depends on Single Asset Vault (SAV). Test - // combinations of the two amendments. - // Single Asset Vault depends on MPTokensV1, but don't test every combo - // of that. - using namespace jtx; - auto failAll = [this](FeatureBitset features) { - Env env(*this, features); - - Account const alice{"alice"}; - Account const bob{"bob"}; - env.fund(XRP(10000), alice, bob); - - auto const keylet = keylet::loanBroker(alice, env.seq(alice)); - - using namespace std::chrono_literals; - using namespace loan; - - // counter party signature is optional on LoanSet. Confirm that by - // sending transaction without one. - auto setTx = env.jt(set(alice, keylet.key, Number(10000)), Ter(temDISABLED)); - env(setTx); - - // All loan transactions are disabled. - // 1. LoanSet - setTx = env.jt(setTx, Sig(sfCounterpartySignature, bob), Ter(temDISABLED)); - env(setTx); - // Actual sequence will be based off the loan broker, but we - // obviously don't have one of those if the amendment is disabled - auto const loanKeylet = keylet::loan(keylet.key, env.seq(alice)); - // Other Loan transactions are disabled, too. - // 2. LoanDelete - env(del(alice, loanKeylet.key), Ter(temDISABLED)); - // 3. LoanManage - env(manage(alice, loanKeylet.key, tfLoanImpair), Ter(temDISABLED)); - // 4. LoanPay - env(pay(alice, loanKeylet.key, XRP(500)), Ter(temDISABLED)); - }; - failAll(all_ - featureMPTokensV1); - failAll(all_ - featureSingleAssetVault - featureLendingProtocol); - failAll(all_ - featureSingleAssetVault); - failAll(all_ - featureLendingProtocol); - } - - /** - * Wrapper to run a series of lifecycle tests for a given asset and loan - * amount - * - * Will be used in the future to vary the loan parameters. For now, it is - * only called once. - * - * Tests a bunch of LoanSet failure conditions before lifecycle. - */ - template - void - testCaseWrapper( - jtx::Env& env, - jtx::MPTTester& mptt, - std::array const& assets, - BrokerInfo const& broker, - Number const& loanAmount, - int interestExponent) - { - using namespace jtx; - using namespace Lending; - - auto const& asset = broker.asset.raw(); - auto const currencyLabel = getCurrencyLabel(asset); - auto const caseLabel = [&]() { - std::stringstream ss; - ss << "Lifecycle: " << loanAmount << " " << currencyLabel - << " Scale interest to: " << interestExponent << " "; - return ss.str(); - }(); - testcase << caseLabel; - - using namespace loan; - using namespace std::chrono_literals; - using d = NetClock::duration; - using tp = NetClock::time_point; - - Account const issuer{"issuer"}; - // For simplicity, lender will be the sole actor for the vault & - // brokers. - Account const lender{"lender"}; - // Borrower only wants to borrow - Account const borrower{"borrower"}; - // Evan will attempt to be naughty - Account const evan{"evan"}; - // Do not fund alice - Account const alice{"alice"}; - - Number const principalRequest = broker.asset(loanAmount).value(); - Number const maxCoveredLoanValue = broker.params.maxCoveredLoanValue(0); - BEAST_EXPECT(maxCoveredLoanValue == 1000 * 100 / 10); - Number const maxCoveredLoanRequest = broker.asset(maxCoveredLoanValue).value(); - Number const totalVaultRequest = broker.asset(broker.params.vaultDeposit).value(); - Number const debtMaximumRequest = broker.asset(broker.params.debtMax).value(); - - auto const loanSetFee = Fee(env.current()->fees().base * 2); - - auto const pseudoAcct = brokerPseudoAccount(env, broker, lender); - - auto const baseFee = env.current()->fees().base; - - auto badKeylet = keylet::vault(lender.id(), env.seq(lender)); - // Try some failure cases - // flags are checked first - env(set(evan, broker.brokerID, principalRequest, tfLoanSetMask), - Sig(sfCounterpartySignature, lender), - loanSetFee, - Ter(temINVALID_FLAG)); - - // field length validation - // sfData: good length, bad account - env(set(evan, broker.brokerID, principalRequest), - Sig(sfCounterpartySignature, borrower), - kData(std::string(kMaxDataPayloadLength, 'X')), - loanSetFee, - Ter(tefBAD_AUTH)); - // sfData: too long - env(set(evan, broker.brokerID, principalRequest), - Sig(sfCounterpartySignature, lender), - kData(std::string(kMaxDataPayloadLength + 1, 'Y')), - loanSetFee, - Ter(temINVALID)); - - // field range validation - // sfOverpaymentFee: good value, bad account - env(set(evan, broker.brokerID, principalRequest), - Sig(sfCounterpartySignature, borrower), - kOverpaymentFee(kMaxOverpaymentFee), - loanSetFee, - Ter(tefBAD_AUTH)); - // sfOverpaymentFee: too big - env(set(evan, broker.brokerID, principalRequest), - Sig(sfCounterpartySignature, lender), - kOverpaymentFee(kMaxOverpaymentFee + 1), - loanSetFee, - Ter(temINVALID)); - - // sfInterestRate: good value, bad account - env(set(evan, broker.brokerID, principalRequest), - Sig(sfCounterpartySignature, borrower), - kInterestRate(kMaxInterestRate), - loanSetFee, - Ter(tefBAD_AUTH)); - env(set(evan, broker.brokerID, principalRequest), - Sig(sfCounterpartySignature, borrower), - kInterestRate(TenthBips32(0)), - loanSetFee, - Ter(tefBAD_AUTH)); - // sfInterestRate: too big - env(set(evan, broker.brokerID, principalRequest), - Sig(sfCounterpartySignature, lender), - kInterestRate(kMaxInterestRate + 1), - loanSetFee, - Ter(temINVALID)); - // sfInterestRate: too small - env(set(evan, broker.brokerID, principalRequest), - Sig(sfCounterpartySignature, lender), - kInterestRate(TenthBips32(-1)), - loanSetFee, - Ter(temINVALID)); - - // sfLateInterestRate: good value, bad account - env(set(evan, broker.brokerID, principalRequest), - Sig(sfCounterpartySignature, borrower), - kLateInterestRate(kMaxLateInterestRate), - loanSetFee, - Ter(tefBAD_AUTH)); - env(set(evan, broker.brokerID, principalRequest), - Sig(sfCounterpartySignature, borrower), - kLateInterestRate(TenthBips32(0)), - loanSetFee, - Ter(tefBAD_AUTH)); - // sfLateInterestRate: too big - env(set(evan, broker.brokerID, principalRequest), - Sig(sfCounterpartySignature, lender), - kLateInterestRate(kMaxLateInterestRate + 1), - loanSetFee, - Ter(temINVALID)); - // sfLateInterestRate: too small - env(set(evan, broker.brokerID, principalRequest), - Sig(sfCounterpartySignature, lender), - kLateInterestRate(TenthBips32(-1)), - loanSetFee, - Ter(temINVALID)); - - // sfCloseInterestRate: good value, bad account - env(set(evan, broker.brokerID, principalRequest), - Sig(sfCounterpartySignature, borrower), - kCloseInterestRate(kMaxCloseInterestRate), - loanSetFee, - Ter(tefBAD_AUTH)); - env(set(evan, broker.brokerID, principalRequest), - Sig(sfCounterpartySignature, borrower), - kCloseInterestRate(TenthBips32(0)), - loanSetFee, - Ter(tefBAD_AUTH)); - // sfCloseInterestRate: too big - env(set(evan, broker.brokerID, principalRequest), - Sig(sfCounterpartySignature, lender), - kCloseInterestRate(kMaxCloseInterestRate + 1), - loanSetFee, - Ter(temINVALID)); - env(set(evan, broker.brokerID, principalRequest), - Sig(sfCounterpartySignature, lender), - kCloseInterestRate(TenthBips32(-1)), - loanSetFee, - Ter(temINVALID)); - - // sfOverpaymentInterestRate: good value, bad account - env(set(evan, broker.brokerID, principalRequest), - Sig(sfCounterpartySignature, borrower), - kOverpaymentInterestRate(kMaxOverpaymentInterestRate), - loanSetFee, - Ter(tefBAD_AUTH)); - env(set(evan, broker.brokerID, principalRequest), - Sig(sfCounterpartySignature, borrower), - kOverpaymentInterestRate(TenthBips32(0)), - loanSetFee, - Ter(tefBAD_AUTH)); - // sfOverpaymentInterestRate: too big - env(set(evan, broker.brokerID, principalRequest), - Sig(sfCounterpartySignature, lender), - kOverpaymentInterestRate(kMaxOverpaymentInterestRate + 1), - loanSetFee, - Ter(temINVALID)); - env(set(evan, broker.brokerID, principalRequest), - Sig(sfCounterpartySignature, lender), - kOverpaymentInterestRate(TenthBips32(-1)), - loanSetFee, - Ter(temINVALID)); - - // sfPaymentTotal: good value, bad account - env(set(evan, broker.brokerID, principalRequest), - Sig(sfCounterpartySignature, borrower), - kPaymentTotal(LoanSet::kMinPaymentTotal), - loanSetFee, - Ter(tefBAD_AUTH)); - // sfPaymentTotal: too small (there is no max) - env(set(evan, broker.brokerID, principalRequest), - Sig(sfCounterpartySignature, lender), - kPaymentTotal(LoanSet::kMinPaymentTotal - 1), - loanSetFee, - Ter(temINVALID)); - - // sfPaymentInterval: good value, bad account - env(set(evan, broker.brokerID, principalRequest), - Sig(sfCounterpartySignature, borrower), - kPaymentInterval(LoanSet::kMinPaymentInterval), - loanSetFee, - Ter(tefBAD_AUTH)); - // sfPaymentInterval: too small (there is no max) - env(set(evan, broker.brokerID, principalRequest), - Sig(sfCounterpartySignature, lender), - kPaymentInterval(LoanSet::kMinPaymentInterval - 1), - loanSetFee, - Ter(temINVALID)); - - // sfGracePeriod: good value, bad account - env(set(evan, broker.brokerID, principalRequest), - Sig(sfCounterpartySignature, borrower), - kPaymentInterval(LoanSet::kMinPaymentInterval * 2), - kGracePeriod(LoanSet::kMinPaymentInterval * 2), - loanSetFee, - Ter(tefBAD_AUTH)); - // sfGracePeriod: larger than paymentInterval - env(set(evan, broker.brokerID, principalRequest), - Sig(sfCounterpartySignature, lender), - kPaymentInterval(LoanSet::kMinPaymentInterval * 2), - kGracePeriod(LoanSet::kMinPaymentInterval * 3), - loanSetFee, - Ter(temINVALID)); - - // insufficient fee - single sign - env(set(borrower, broker.brokerID, principalRequest), - Sig(sfCounterpartySignature, lender), - Ter(telINSUF_FEE_P)); - // insufficient fee - multisign - env(signers(lender, 2, {{evan, 1}, {borrower, 1}})); - env(signers(borrower, 2, {{evan, 1}, {lender, 1}})); - env(set(borrower, broker.brokerID, principalRequest), - kCounterparty(lender), - Msig(evan, lender), - Msig(sfCounterpartySignature, evan, borrower), - Fee(env.current()->fees().base * 5 - 1), - Ter(telINSUF_FEE_P)); - // Bad multisign signatures for borrower (Account) - env(set(borrower, broker.brokerID, principalRequest), - kCounterparty(lender), - Msig(alice, issuer), - Msig(sfCounterpartySignature, evan, borrower), - Fee(env.current()->fees().base * 5), - Ter(tefBAD_SIGNATURE)); - // Bad multisign signatures for issuer (Counterparty) - env(set(borrower, broker.brokerID, principalRequest), - kCounterparty(lender), - Msig(evan, lender), - Msig(sfCounterpartySignature, alice, issuer), - Fee(env.current()->fees().base * 5 - 1), - Ter(tefBAD_SIGNATURE)); - env(signers(lender, kNone)); - env(signers(borrower, kNone)); - // multisign sufficient fee, but no signers set up - env(set(borrower, broker.brokerID, principalRequest), - kCounterparty(lender), - Msig(evan, lender), - Msig(sfCounterpartySignature, evan, borrower), - Fee(env.current()->fees().base * 5), - Ter(tefNOT_MULTI_SIGNING)); - // not the broker owner, no counterparty, not signed by broker - // owner - env(set(borrower, broker.brokerID, principalRequest), - Sig(sfCounterpartySignature, evan), - loanSetFee, - Ter(tefBAD_AUTH)); - // not the broker owner, counterparty is borrower - env(set(evan, broker.brokerID, principalRequest), - kCounterparty(borrower), - Sig(sfCounterpartySignature, borrower), - loanSetFee, - Ter(tecNO_PERMISSION)); - // not a LoanBroker object, no counterparty - env(set(lender, badKeylet.key, principalRequest), - Sig(sfCounterpartySignature, evan), - loanSetFee, - Ter(temBAD_SIGNER)); - // not a LoanBroker object, counterparty is valid - env(set(lender, badKeylet.key, principalRequest), - kCounterparty(borrower), - Sig(sfCounterpartySignature, borrower), - loanSetFee, - Ter(tecNO_ENTRY)); - // borrower doesn't exist - env(set(lender, broker.brokerID, principalRequest), - kCounterparty(alice), - Sig(sfCounterpartySignature, alice), - loanSetFee, - Ter(terNO_ACCOUNT)); - - // Request more funds than the vault has available - env(set(evan, broker.brokerID, totalVaultRequest + 1), - Sig(sfCounterpartySignature, lender), - loanSetFee, - Ter(tecINSUFFICIENT_FUNDS)); - - // Request more funds than the broker's first-loss capital can - // cover. - env(set(evan, broker.brokerID, maxCoveredLoanRequest + 1), - Sig(sfCounterpartySignature, lender), - loanSetFee, - Ter(tecINSUFFICIENT_FUNDS)); - - // Frozen trust line / locked MPT issuance - // XRP can not be frozen, but run through the loop anyway to test - // the tecLIMIT_EXCEEDED case - { - auto const brokerSle = env.le(keylet::loanBroker(broker.brokerID)); - if (!BEAST_EXPECT(brokerSle)) - return; - - auto const vaultPseudo = [&]() { - auto const vaultSle = env.le(keylet::vault(brokerSle->at(sfVaultID))); - if (!BEAST_EXPECT(vaultSle)) - { - // This will be wrong, but the test has failed anyway. - return Account{lender}; - } - auto vaultPseudo = Account("Vault pseudo-account", vaultSle->at(sfAccount)); - return vaultPseudo; - }(); - - auto const [freeze, deepfreeze, unfreeze, expectedResult] = - [&]() -> std::tuple< - std::function, - std::function, - std::function, - TER> { - // Freeze / lock the asset - std::function const empty; - if (broker.asset.native()) - { - // XRP can't be frozen - return std::make_tuple(empty, empty, empty, tesSUCCESS); - } - if (broker.asset.holds()) - { - auto freeze = [&](Account const& holder) { - env(trust(issuer, holder[iouCurrency_](0), tfSetFreeze)); - }; - auto deepfreeze = [&](Account const& holder) { - env(trust(issuer, holder[iouCurrency_](0), tfSetFreeze | tfSetDeepFreeze)); - }; - auto unfreeze = [&](Account const& holder) { - env(trust( - issuer, holder[iouCurrency_](0), tfClearFreeze | tfClearDeepFreeze)); - }; - return std::make_tuple(freeze, deepfreeze, unfreeze, tecFROZEN); - } - - auto freeze = [&](Account const& holder) { - mptt.set({.account = issuer, .holder = holder, .flags = tfMPTLock}); - }; - auto unfreeze = [&](Account const& holder) { - mptt.set({.account = issuer, .holder = holder, .flags = tfMPTUnlock}); - }; - return std::make_tuple(freeze, empty, unfreeze, tecLOCKED); - }(); - - // Try freezing the accounts that can't be frozen - if (freeze) - { - for (auto const& account : {vaultPseudo, evan}) - { - // Freeze the account - freeze(account); - - // Try to create a loan with a frozen line - env(set(evan, broker.brokerID, debtMaximumRequest), - Sig(sfCounterpartySignature, lender), - loanSetFee, - Ter(expectedResult)); - - // Unfreeze the account - BEAST_EXPECT(unfreeze); - unfreeze(account); - - // Ensure the line is unfrozen with a request that is fine - // except too it requests more principal than the broker can - // carry - env(set(evan, broker.brokerID, debtMaximumRequest + 1), - Sig(sfCounterpartySignature, lender), - loanSetFee, - Ter(tecLIMIT_EXCEEDED)); - } - } - - // Deep freeze the borrower, which prevents them from receiving - // funds - if (deepfreeze) - { - // Make sure evan has a trust line that so the issuer can - // freeze it. (Don't need to do this for the borrower, - // because LoanSet will create a line to the borrower - // automatically.) - env(trust(evan, issuer[iouCurrency_](100'000))); - - for (auto const& account : {// these accounts can't be frozen, which deep freeze - // implies - vaultPseudo, - evan, - // these accounts can't be deep frozen - lender}) - { - // Freeze evan - deepfreeze(account); - - // Try to create a loan with a deep frozen line - env(set(evan, broker.brokerID, debtMaximumRequest), - Sig(sfCounterpartySignature, lender), - loanSetFee, - Ter(expectedResult)); - - // Unfreeze evan - BEAST_EXPECT(unfreeze); - unfreeze(account); - - // Ensure the line is unfrozen with a request that is fine - // except too it requests more principal than the broker can - // carry - env(set(evan, broker.brokerID, debtMaximumRequest + 1), - Sig(sfCounterpartySignature, lender), - loanSetFee, - Ter(tecLIMIT_EXCEEDED)); - } - } - } - - // Finally! Create a loan - - auto coverAvailable = [&env, this](uint256 const& brokerID, Number const& expected) { - if (auto const brokerSle = env.le(keylet::loanBroker(brokerID)); - BEAST_EXPECT(brokerSle)) - { - auto const available = brokerSle->at(sfCoverAvailable); - BEAST_EXPECT(available == expected); - return available; - } - return Number{}; - }; - auto getDefaultInfo = [&env, this](LoanState const& state, BrokerInfo const& broker) { - if (auto const brokerSle = env.le(keylet::loanBroker(broker.brokerID)); - BEAST_EXPECT(brokerSle)) - { - BEAST_EXPECT( - state.loanScale >= - (broker.asset.integral() - ? 0 - : std::max( - broker.vaultScale(env), state.principalOutstanding.exponent()))); - NumberRoundModeGuard const mg(Number::RoundingMode::Upward); - auto const defaultAmount = roundToAsset( - broker.asset, - std::min( - tenthBipsOfValue( - tenthBipsOfValue( - brokerSle->at(sfDebtTotal), broker.params.coverRateMin), - broker.params.coverRateLiquidation), - state.totalValue - state.managementFeeOutstanding), - state.loanScale); - return std::make_pair(defaultAmount, brokerSle->at(sfOwner)); - } - return std::make_pair(Number{}, AccountID{}); - }; - auto replenishCover = [&env, &coverAvailable]( - BrokerInfo const& broker, - AccountID const& brokerAcct, - Number const& startingCoverAvailable, - Number const& amountToBeCovered) { - coverAvailable(broker.brokerID, startingCoverAvailable - amountToBeCovered); - env(loanBroker::coverDeposit( - brokerAcct, broker.brokerID, STAmount{broker.asset, amountToBeCovered})); - coverAvailable(broker.brokerID, startingCoverAvailable); - env.close(); - }; - - auto defaultImmediately = [&](std::uint32_t baseFlag, bool impair = true) { - return [&, impair, baseFlag]( - Keylet const& loanKeylet, VerifyLoanStatus const& verifyLoanStatus) { - // toEndOfLife - // - // Default the loan - - // Initialize values with the current state - auto state = getCurrentState(env, broker, loanKeylet, verifyLoanStatus); - BEAST_EXPECT(state.flags == baseFlag); - - auto const& broker = verifyLoanStatus.broker; - auto const startingCoverAvailable = coverAvailable( - broker.brokerID, broker.asset(broker.params.coverDeposit).number()); - - if (impair) - { - // Check the vault - bool const canImpair = canImpairLoan(env, broker, state); - // Impair the loan, if possible - env(manage(lender, loanKeylet.key, tfLoanImpair), - canImpair ? Ter(tesSUCCESS) : Ter(tecLIMIT_EXCEEDED)); - - if (canImpair) - { - state.flags |= tfLoanImpair; - state.nextPaymentDate = env.now().time_since_epoch().count(); - - // Once the loan is impaired, it can't be impaired again - env(manage(lender, loanKeylet.key, tfLoanImpair), Ter(tecNO_PERMISSION)); - } - verifyLoanStatus(state); - } - - auto const nextDueDate = tp{d{state.nextPaymentDate}}; - - // Can't default the loan yet. The grace period hasn't - // expired - env(manage(lender, loanKeylet.key, tfLoanDefault), Ter(tecTOO_SOON)); - - // Let some time pass so that the loan can be - // defaulted - env.close(nextDueDate + 60s); - - auto const [amountToBeCovered, brokerAcct] = getDefaultInfo(state, broker); - - // Default the loan - env(manage(lender, loanKeylet.key, tfLoanDefault)); - env.close(); - - // The LoanBroker just lost some of it's first-loss capital. - // Replenish it. - replenishCover(broker, brokerAcct, startingCoverAvailable, amountToBeCovered); - - state.flags |= tfLoanDefault; - state.paymentRemaining = 0; - state.totalValue = 0; - state.principalOutstanding = 0; - state.managementFeeOutstanding = 0; - state.nextPaymentDate = 0; - verifyLoanStatus(state); - - // Once a loan is defaulted, it can't be managed - env(manage(lender, loanKeylet.key, tfLoanUnimpair), Ter(tecNO_PERMISSION)); - env(manage(lender, loanKeylet.key, tfLoanImpair), Ter(tecNO_PERMISSION)); - // Can't make a payment on it either - env(pay(borrower, loanKeylet.key, broker.asset(300)), Ter(tecKILLED)); - }; - }; - - auto singlePayment = [&](Keylet const& loanKeylet, - VerifyLoanStatus const& verifyLoanStatus, - LoanState& state, - STAmount const& payoffAmount, - std::uint32_t numPayments, - std::uint32_t baseFlag, - std::uint32_t txFlags) { - // toEndOfLife - // - verifyLoanStatus(state); - - // Send some bogus pay transactions - env(pay(borrower, keylet::loan(uint256(0)).key, broker.asset(10), txFlags), - Ter(temINVALID)); - // broker.asset(80) is less than a single payment, but all these - // checks fail before that matters - env(pay(borrower, loanKeylet.key, broker.asset(-80), txFlags), Ter(temBAD_AMOUNT)); - env(pay(borrower, broker.brokerID, broker.asset(80), txFlags), Ter(tecNO_ENTRY)); - env(pay(evan, loanKeylet.key, broker.asset(80), txFlags), Ter(tecNO_PERMISSION)); - - // TODO: Write a general "isFlag" function? See STObject::isFlag. - // Maybe add a static overloaded member? - if (!(state.flags & lsfLoanOverpayment)) - { - // If the loan does not allow overpayments, send a payment that - // tries to make an overpayment. Do not include `txFlags`, so we - // don't end up duplicating the next test transaction. - // - // fixCleanup3_1_3 gates tfLoanOverpayment as a valid flag: - // with fix on → preflight passes, apply returns tecNO_PERMISSION; - // with fix off → preflight rejects the flag, returns temINVALID_FLAG. - bool const hasFix313 = env.current()->rules().enabled(fixCleanup3_1_3); - STAmount const overpayAmount{broker.asset, state.periodicPayment * Number{15, -1}}; - XRPAmount const overpayFee{ - baseFee * (Number{15, -1} / kLoanPaymentsPerFeeIncrement + 1)}; - env(pay(borrower, loanKeylet.key, overpayAmount, tfLoanOverpayment), - Fee(overpayFee), - Ter(hasFix313 ? TER{tecNO_PERMISSION} : TER{temINVALID_FLAG})); - - if (hasFix313) - { - env.disableFeature(fixCleanup3_1_3); - env(pay(borrower, loanKeylet.key, overpayAmount, tfLoanOverpayment), - Fee(overpayFee), - Ter(temINVALID_FLAG)); - env.enableFeature(fixCleanup3_1_3); - } - } - // Try to send a payment marked as multiple mutually exclusive - // payment types. Do not include `txFlags`, so we don't duplicate - // the prior test transaction. - env(pay(borrower, - loanKeylet.key, - broker.asset(state.periodicPayment * 2), - tfLoanLatePayment | tfLoanFullPayment), - Ter(temINVALID_FLAG)); - env(pay(borrower, - loanKeylet.key, - broker.asset(state.periodicPayment * 2), - tfLoanLatePayment | tfLoanOverpayment), - Ter(temINVALID_FLAG)); - env(pay(borrower, - loanKeylet.key, - broker.asset(state.periodicPayment * 2), - tfLoanOverpayment | tfLoanFullPayment), - Ter(temINVALID_FLAG)); - env(pay(borrower, - loanKeylet.key, - broker.asset(state.periodicPayment * 2), - tfLoanLatePayment | tfLoanOverpayment | tfLoanFullPayment), - Ter(temINVALID_FLAG)); - - { - auto const otherAsset = - broker.asset.raw() == assets[0].raw() ? assets[1] : assets[0]; - env(pay(borrower, loanKeylet.key, otherAsset(100), txFlags), Ter(tecWRONG_ASSET)); - } - - // Amount doesn't cover a single payment - env(pay(borrower, loanKeylet.key, STAmount{broker.asset, 1}, txFlags), - Ter(tecINSUFFICIENT_PAYMENT)); - - // Get the balance after these failed transactions take - // fees - auto const borrowerBalanceBeforePayment = env.balance(borrower, broker.asset); - - BEAST_EXPECT(payoffAmount > state.principalOutstanding); - // Try to pay a little extra to show that it's _not_ - // taken - auto const transactionAmount = payoffAmount + broker.asset(10); - - // Send a transaction that tries to pay more than the borrowers's - // balance - XRPAmount const badFee{ - baseFee * - (borrowerBalanceBeforePayment.number() * 2 / state.periodicPayment / - kLoanPaymentsPerFeeIncrement + - 1)}; - env(pay(borrower, - loanKeylet.key, - STAmount{broker.asset, borrowerBalanceBeforePayment.number() * 2}, - txFlags), - Fee(badFee), - Ter(tecINSUFFICIENT_FUNDS)); - - XRPAmount const goodFee{baseFee * (numPayments / kLoanPaymentsPerFeeIncrement + 1)}; - env(pay(borrower, loanKeylet.key, transactionAmount, txFlags), Fee(goodFee)); - - env.close(); - - // log << env.meta()->getJson() << std::endl; - - // Need to account for fees if the loan is in XRP - PrettyAmount adjustment = broker.asset(0); - if (broker.asset.native()) - { - adjustment = badFee + goodFee; - } - - state.paymentRemaining = 0; - state.principalOutstanding = 0; - state.totalValue = 0; - state.managementFeeOutstanding = 0; - state.previousPaymentDate = - state.nextPaymentDate + (state.paymentInterval * (numPayments - 1)); - state.nextPaymentDate = 0; - verifyLoanStatus(state); - - verifyLoanStatus.checkPayment( - state.loanScale, borrower, borrowerBalanceBeforePayment, payoffAmount, adjustment); - - // Can't impair or default a paid off loan - env(manage(lender, loanKeylet.key, tfLoanImpair), Ter(tecNO_PERMISSION)); - env(manage(lender, loanKeylet.key, tfLoanDefault), Ter(tecNO_PERMISSION)); - }; - - auto fullPayment = [&](std::uint32_t baseFlag) { - return [&, baseFlag]( - Keylet const& loanKeylet, VerifyLoanStatus const& verifyLoanStatus) { - // toEndOfLife - // - auto state = getCurrentState(env, broker, loanKeylet, verifyLoanStatus); - env.close(state.startDate + 20s); - auto const loanAge = (env.now() - state.startDate).count(); - BEAST_EXPECT(loanAge == 30); - - // Full payoff amount will consist of - // 1. principal outstanding (1000) - // 2. accrued interest (at 12%) - // 3. prepayment penalty (closeInterest at 3.6%) - // 4. close payment fee (4) - // Calculate these values without the helper functions - // to verify they're working correctly The numbers in - // the below BEAST_EXPECTs may not hold across assets. - Number const interval = state.paymentInterval; - auto const periodicRate = interval * Number(12, -2) / kSecondsInYear; - BEAST_EXPECT( - periodicRate == Number(2283105022831050228ULL, -24, Number::Normalized{})); - STAmount const principalOutstanding{broker.asset, state.principalOutstanding}; - STAmount const accruedInterest{ - broker.asset, state.principalOutstanding * periodicRate * loanAge / interval}; - BEAST_EXPECT(accruedInterest == broker.asset(Number(1141552511415525, -19))); - STAmount const prepaymentPenalty{ - broker.asset, state.principalOutstanding * Number(36, -3)}; - BEAST_EXPECT(prepaymentPenalty == broker.asset(36)); - STAmount const closePaymentFee = broker.asset(4); - auto const payoffAmount = roundToScale( - principalOutstanding + accruedInterest + prepaymentPenalty + closePaymentFee, - state.loanScale); - BEAST_EXPECT( - payoffAmount == - roundToAsset( - broker.asset, - broker.asset(Number(1040000114155251, -12)).number(), - state.loanScale)); - - // The terms of this loan actually make the early payoff - // more expensive than just making payments - BEAST_EXPECT( - payoffAmount > - state.paymentRemaining * (state.periodicPayment + broker.asset(2).value())); - - singlePayment( - loanKeylet, - verifyLoanStatus, - state, - payoffAmount, - 1, - baseFlag, - tfLoanFullPayment); - }; - }; - - auto combineAllPayments = [&](std::uint32_t baseFlag) { - return - [&, baseFlag](Keylet const& loanKeylet, VerifyLoanStatus const& verifyLoanStatus) { - // toEndOfLife - // - - auto state = getCurrentState(env, broker, loanKeylet, verifyLoanStatus); - env.close(); - - BEAST_EXPECT( - STAmount(broker.asset, state.periodicPayment) == - broker.asset(Number(8333457002039338267, -17))); - - // Make all the payments in one transaction - // service fee is 2 - auto const startingPayments = state.paymentRemaining; - STAmount const payoffAmount = [&]() { - NumberRoundModeGuard const mg(Number::RoundingMode::Upward); - auto const rawPayoff = - startingPayments * (state.periodicPayment + broker.asset(2).value()); - STAmount payoffAmount{broker.asset, rawPayoff}; - BEAST_EXPECTS( - payoffAmount == broker.asset(Number(1024014840244721, -12)), - to_string(payoffAmount)); - BEAST_EXPECT(payoffAmount > state.principalOutstanding); - - payoffAmount = roundToScale(payoffAmount, state.loanScale); - - return payoffAmount; - }(); - - auto const totalPayoffValue = - state.totalValue + startingPayments * broker.asset(2).value(); - STAmount const totalPayoffAmount{broker.asset, totalPayoffValue}; - - BEAST_EXPECTS( - totalPayoffAmount == payoffAmount, - "Payoff amount: " + to_string(payoffAmount) + - ". Total Value: " + to_string(totalPayoffAmount)); - - singlePayment( - loanKeylet, - verifyLoanStatus, - state, - payoffAmount, - state.paymentRemaining, - baseFlag, - 0); - }; - }; - - // There are a lot of fields that can be set on a loan, but most - // of them only affect the "math" when a payment is made. The - // only one that really affects behavior is the - // `tfLoanOverpayment` flag. - lifecycle( - caseLabel, - "Loan overpayment allowed - Impair and Default", - env, - loanAmount, - interestExponent, - lender, - borrower, - evan, - broker, - pseudoAcct, - tfLoanOverpayment, - defaultImmediately(lsfLoanOverpayment)); - - lifecycle( - caseLabel, - "Loan overpayment prohibited - Impair and Default", - env, - loanAmount, - interestExponent, - lender, - borrower, - evan, - broker, - pseudoAcct, - 0, - defaultImmediately(0)); - - lifecycle( - caseLabel, - "Loan overpayment allowed - Default without Impair", - env, - loanAmount, - interestExponent, - lender, - borrower, - evan, - broker, - pseudoAcct, - tfLoanOverpayment, - defaultImmediately(lsfLoanOverpayment, false)); - - lifecycle( - caseLabel, - "Loan overpayment prohibited - Default without Impair", - env, - loanAmount, - interestExponent, - lender, - borrower, - evan, - broker, - pseudoAcct, - 0, - defaultImmediately(0, false)); - - lifecycle( - caseLabel, - "Loan overpayment prohibited - Pay off immediately", - env, - loanAmount, - interestExponent, - lender, - borrower, - evan, - broker, - pseudoAcct, - 0, - fullPayment(0)); - - lifecycle( - caseLabel, - "Loan overpayment allowed - Pay off immediately", - env, - loanAmount, - interestExponent, - lender, - borrower, - evan, - broker, - pseudoAcct, - tfLoanOverpayment, - fullPayment(lsfLoanOverpayment)); - - lifecycle( - caseLabel, - "Loan overpayment prohibited - Combine all payments", - env, - loanAmount, - interestExponent, - lender, - borrower, - evan, - broker, - pseudoAcct, - 0, - combineAllPayments(0)); - - lifecycle( - caseLabel, - "Loan overpayment allowed - Combine all payments", - env, - loanAmount, - interestExponent, - lender, - borrower, - evan, - broker, - pseudoAcct, - tfLoanOverpayment, - combineAllPayments(lsfLoanOverpayment)); - - lifecycle( - caseLabel, - "Loan overpayment prohibited - Make payments", - env, - loanAmount, - interestExponent, - lender, - borrower, - evan, - broker, - pseudoAcct, - 0, - [&](Keylet const& loanKeylet, VerifyLoanStatus const& verifyLoanStatus) { - // toEndOfLife - // - // Draw and make multiple payments - auto state = getCurrentState(env, broker, loanKeylet, verifyLoanStatus); - BEAST_EXPECT(state.flags == 0); - env.close(); - - verifyLoanStatus(state); - - env.close(state.startDate + 20s); - auto const loanAge = (env.now() - state.startDate).count(); - BEAST_EXPECT(loanAge == 30); - - // Periodic payment amount will consist of - // 1. principal outstanding (1000) - // 2. interest interest rate (at 12%) - // 3. payment interval (600s) - // 4. loan service fee (2) - // Calculate these values without the helper functions - // to verify they're working correctly The numbers in - // the below BEAST_EXPECTs may not hold across assets. - Number const interval = state.paymentInterval; - auto const periodicRate = interval * Number(12, -2) / kSecondsInYear; - BEAST_EXPECT( - periodicRate == Number(2283105022831050228, -24, Number::Normalized{})); - STAmount const roundedPeriodicPayment{ - broker.asset, - roundPeriodicPayment(broker.asset, state.periodicPayment, state.loanScale)}; - - testcase << currencyLabel << " Payment components: " - << "Payments remaining, rawInterest, rawPrincipal, " - "rawMFee, trackedValueDelta, trackedPrincipalDelta, " - "trackedInterestDelta, trackedMgmtFeeDelta, special"; - - auto const serviceFee = broker.asset(2); - - BEAST_EXPECT( - roundedPeriodicPayment == - roundToScale( - broker.asset( - Number(8333457002039338267, -17), Number::RoundingMode::Upward), - state.loanScale, - Number::RoundingMode::Upward)); - // 83334570.01162141 - // Include the service fee - STAmount const totalDue = roundToScale( - roundedPeriodicPayment + serviceFee, - state.loanScale, - Number::RoundingMode::Upward); - // Only check the first payment since the rounding - // may drift as payments are made - BEAST_EXPECT( - totalDue == - roundToScale( - broker.asset( - Number(8533457002039338267, -17), Number::RoundingMode::Upward), - state.loanScale, - Number::RoundingMode::Upward)); - - { - auto const raw = computeTheoreticalLoanState( - env.current()->rules(), - state.periodicPayment, - periodicRate, - state.paymentRemaining, - broker.params.managementFeeRate); - auto const rounded = constructLoanState( - state.totalValue, - state.principalOutstanding, - state.managementFeeOutstanding); - testcase << currencyLabel << " Loan starting state: " << state.paymentRemaining - << ", " << raw.interestDue << ", " << raw.principalOutstanding << ", " - << raw.managementFeeDue << ", " << rounded.valueOutstanding << ", " - << rounded.principalOutstanding << ", " << rounded.interestDue << ", " - << rounded.managementFeeDue; - } - - // Try to pay a little extra to show that it's _not_ - // taken - STAmount const transactionAmount = - STAmount{broker.asset, totalDue} + broker.asset(10); - // Only check the first payment since the rounding - // may drift as payments are made - BEAST_EXPECT( - transactionAmount == - roundToScale( - broker.asset(Number(9533457002039400, -14), Number::RoundingMode::Upward), - state.loanScale, - Number::RoundingMode::Upward)); - - auto const initialState = state; - xrpl::detail::PaymentComponents totalPaid{ - .trackedValueDelta = 0, - .trackedPrincipalDelta = 0, - .trackedManagementFeeDelta = 0}; - Number totalInterestPaid = 0; - std::size_t totalPaymentsMade = 0; - - xrpl::LoanState currentTrueState = computeTheoreticalLoanState( - env.current()->rules(), - state.periodicPayment, - periodicRate, - state.paymentRemaining, - broker.params.managementFeeRate); - - while (state.paymentRemaining > 0) - { - // Compute the expected principal amount - auto const paymentComponents = xrpl::detail::computePaymentComponents( - env.current()->rules(), - broker.asset.raw(), - state.loanScale, - state.totalValue, - state.principalOutstanding, - state.managementFeeOutstanding, - state.periodicPayment, - periodicRate, - state.paymentRemaining, - broker.params.managementFeeRate); - - BEAST_EXPECTS( - paymentComponents.specialCase == xrpl::detail::PaymentSpecialCase::Final || - paymentComponents.trackedValueDelta <= roundedPeriodicPayment, - "Delta: " + to_string(paymentComponents.trackedValueDelta) + - ", periodic payment: " + to_string(roundedPeriodicPayment)); - - xrpl::LoanState const nextTrueState = computeTheoreticalLoanState( - env.current()->rules(), - state.periodicPayment, - periodicRate, - state.paymentRemaining - 1, - broker.params.managementFeeRate); - xrpl::detail::LoanStateDeltas const deltas = currentTrueState - nextTrueState; - - testcase << currencyLabel << " Payment components: " << state.paymentRemaining - << ", " << deltas.interest << ", " << deltas.principal << ", " - << deltas.managementFee << ", " << paymentComponents.trackedValueDelta - << ", " << paymentComponents.trackedPrincipalDelta << ", " - << paymentComponents.trackedInterestPart() << ", " - << paymentComponents.trackedManagementFeeDelta << ", " - << [&]() -> char const* { - if (paymentComponents.specialCase == - ::xrpl::detail::PaymentSpecialCase::Final) - return "final"; - if (paymentComponents.specialCase == - ::xrpl::detail::PaymentSpecialCase::Extra) - return "extra"; - return "none"; - }(); - - auto const totalDueAmount = STAmount{ - broker.asset, paymentComponents.trackedValueDelta + serviceFee.number()}; - - // Due to the rounding algorithms to keep the interest and - // principal in sync with "true" values, the computed amount - // may be a little less than the rounded fixed payment - // amount. For integral types, the difference should be < 3 - // (1 unit for each of the interest and management fee). For - // IOUs, the difference should be after the 8th digit. - Number const diff = totalDue - totalDueAmount; - BEAST_EXPECT( - paymentComponents.specialCase == xrpl::detail::PaymentSpecialCase::Final || - diff == beast::kZero || - (diff > beast::kZero && - ((broker.asset.integral() && (static_cast(diff) < 3)) || - (state.loanScale - diff.exponent() > 13)))); - - BEAST_EXPECT( - paymentComponents.trackedValueDelta == - paymentComponents.trackedPrincipalDelta + - paymentComponents.trackedInterestPart() + - paymentComponents.trackedManagementFeeDelta); - BEAST_EXPECT( - paymentComponents.specialCase == xrpl::detail::PaymentSpecialCase::Final || - paymentComponents.trackedValueDelta <= roundedPeriodicPayment); - - BEAST_EXPECT( - state.paymentRemaining < 12 || - roundToAsset( - broker.asset, - deltas.principal, - state.loanScale, - Number::RoundingMode::Upward) == - roundToScale( - broker.asset( - Number(8333228691531218890, -17), Number::RoundingMode::Upward), - state.loanScale, - Number::RoundingMode::Upward)); - BEAST_EXPECT( - paymentComponents.trackedPrincipalDelta >= beast::kZero && - paymentComponents.trackedPrincipalDelta <= state.principalOutstanding); - BEAST_EXPECT( - paymentComponents.specialCase != xrpl::detail::PaymentSpecialCase::Final || - paymentComponents.trackedPrincipalDelta == state.principalOutstanding); - BEAST_EXPECT( - paymentComponents.specialCase == xrpl::detail::PaymentSpecialCase::Final || - (state.periodicPayment.exponent() - - (deltas.principal + deltas.interest + deltas.managementFee - - state.periodicPayment) - .exponent()) > 14); - - auto const borrowerBalanceBeforePayment = env.balance(borrower, broker.asset); - - if (canImpairLoan(env, broker, state)) - { - // Making a payment will unimpair the loan - env(manage(lender, loanKeylet.key, tfLoanImpair)); - } - - env.close(); - - // Make the payment - env(pay(borrower, loanKeylet.key, transactionAmount)); - - env.close(); - - // Need to account for fees if the loan is in XRP - PrettyAmount adjustment = broker.asset(0); - if (broker.asset.native()) - { - adjustment = env.current()->fees().base; - } - - // Check the result - verifyLoanStatus.checkPayment( - state.loanScale, - borrower, - borrowerBalanceBeforePayment, - totalDueAmount, - adjustment); - - --state.paymentRemaining; - state.previousPaymentDate = state.nextPaymentDate; - if (paymentComponents.specialCase == xrpl::detail::PaymentSpecialCase::Final) - { - state.paymentRemaining = 0; - state.nextPaymentDate = 0; - } - else - { - state.nextPaymentDate += state.paymentInterval; - } - state.principalOutstanding -= paymentComponents.trackedPrincipalDelta; - state.managementFeeOutstanding -= paymentComponents.trackedManagementFeeDelta; - state.totalValue -= paymentComponents.trackedValueDelta; - - verifyLoanStatus(state); - - totalPaid.trackedValueDelta += paymentComponents.trackedValueDelta; - totalPaid.trackedPrincipalDelta += paymentComponents.trackedPrincipalDelta; - totalPaid.trackedManagementFeeDelta += - paymentComponents.trackedManagementFeeDelta; - totalInterestPaid += paymentComponents.trackedInterestPart(); - ++totalPaymentsMade; - - currentTrueState = nextTrueState; - } - - // Loan is paid off - BEAST_EXPECT(state.paymentRemaining == 0); - BEAST_EXPECT(state.principalOutstanding == 0); - - // Make sure all the payments add up - BEAST_EXPECT(totalPaid.trackedValueDelta == initialState.totalValue); - BEAST_EXPECT(totalPaid.trackedPrincipalDelta == initialState.principalOutstanding); - BEAST_EXPECT( - totalPaid.trackedManagementFeeDelta == initialState.managementFeeOutstanding); - // This is almost a tautology given the previous checks, but - // check it anyway for completeness. - BEAST_EXPECT( - totalInterestPaid == - initialState.totalValue - - (initialState.principalOutstanding + - initialState.managementFeeOutstanding)); - BEAST_EXPECT(totalPaymentsMade == initialState.paymentRemaining); - - // Can't impair or default a paid off loan - env(manage(lender, loanKeylet.key, tfLoanImpair), Ter(tecNO_PERMISSION)); - env(manage(lender, loanKeylet.key, tfLoanDefault), Ter(tecNO_PERMISSION)); - }); - -#if LOAN_TODO - // TODO - - /* - LoanPay fails with tecINVARIANT_FAILED error when loan_broker(also - borrower) tries to do the payment. Here's the scenario: Create a XRP - loan with loan broker as borrower, loan origination fee and loan service - fee. Loan broker makes the first payment with periodic payment and loan - service fee. - */ - - auto time = [&](std::string label, std::function timed) { - if (!BEAST_EXPECT(timed)) - return; - - using clock_type = std::chrono::steady_clock; - using duration_type = std::chrono::milliseconds; - - auto const start = clock_type::now(); - timed(); - auto const duration = - std::chrono::duration_cast(clock_type::now() - start); - - log << label << " took " << duration.count() << "ms" << std::endl; - - return duration; - }; - - lifecycle( - caseLabel, - "timing", - env, - loanAmount, - interestExponent, - lender, - borrower, - evan, - broker, - pseudoAcct, - tfLoanOverpayment, - [&](Keylet const& loanKeylet, VerifyLoanStatus const& verifyLoanStatus) { - // Estimate optimal values for kLoanPaymentsPerFeeIncrement and - // kLoanMaximumPaymentsPerTransaction. - using namespace loan; - - auto const state = getCurrentState(env, broker, verifyLoanStatus.keylet); - auto const serviceFee = broker.asset(2).value(); - - STAmount const totalDue{ - broker.asset, - roundPeriodicPayment( - broker.asset, state.periodicPayment + serviceFee, state.loanScale)}; - - // Make a single payment - time("single payment", [&]() { env(pay(borrower, loanKeylet.key, totalDue)); }); - env.close(); - - // Make all but the final payment - auto const numPayments = (state.paymentRemaining - 2); - STAmount const bigPayment{broker.asset, totalDue * numPayments}; - XRPAmount const bigFee{baseFee * (numPayments / kLoanPaymentsPerFeeIncrement + 1)}; - time("ten payments", [&]() { - env(pay(borrower, loanKeylet.key, bigPayment), Fee(bigFee)); - }); - env.close(); - - time("final payment", [&]() { - // Make the final payment - env(pay(borrower, loanKeylet.key, totalDue + STAmount{broker.asset, 1})); - }); - env.close(); - }); - - lifecycle( - caseLabel, - "Loan overpayment allowed - Explicit overpayment", - env, - loanAmount, - interestExponent, - lender, - borrower, - evan, - broker, - pseudoAcct, - tfLoanOverpayment, - [&](Keylet const& loanKeylet, VerifyLoanStatus const& verifyLoanStatus) { throw 0; }); - - lifecycle( - caseLabel, - "Loan overpayment prohibited - Late payment", - env, - loanAmount, - interestExponent, - lender, - borrower, - evan, - broker, - pseudoAcct, - tfLoanOverpayment, - [&](Keylet const& loanKeylet, VerifyLoanStatus const& verifyLoanStatus) { throw 0; }); - - lifecycle( - caseLabel, - "Loan overpayment allowed - Late payment", - env, - loanAmount, - interestExponent, - lender, - borrower, - evan, - broker, - pseudoAcct, - tfLoanOverpayment, - [&](Keylet const& loanKeylet, VerifyLoanStatus const& verifyLoanStatus) { throw 0; }); - - lifecycle( - caseLabel, - "Loan overpayment allowed - Late payment and overpayment", - env, - loanAmount, - interestExponent, - lender, - borrower, - evan, - broker, - pseudoAcct, - tfLoanOverpayment, - [&](Keylet const& loanKeylet, VerifyLoanStatus const& verifyLoanStatus) { throw 0; }); - -#endif - } - void testLoanSet(FeatureBitset features) { @@ -1949,2355 +562,16 @@ class Loan_test : public LoanTestBase nullptr); } +public: void - testLifecycle(FeatureBitset features) - { - testcase("Lifecycle"); - using namespace jtx; - - // Create 3 loan brokers: one for XRP, one for an IOU, and one for - // an MPT. That'll require three corresponding SAVs. - Env env(*this, features); - - Account const issuer{"issuer"}; - // For simplicity, lender will be the sole actor for the vault & - // brokers. - Account const lender{"lender"}; - // Borrower only wants to borrow - Account const borrower{"borrower"}; - // Evan will attempt to be naughty - Account const evan{"evan"}; - // Do not fund alice - Account const alice{"alice"}; - - // Fund the accounts and trust lines with the same amount so that - // tests can use the same values regardless of the asset. - env.fund(XRP(100'000'000), issuer, noripple(lender, borrower, evan)); - env.close(); - - // Create assets - PrettyAsset const xrpAsset{xrpIssue(), 1'000'000}; - PrettyAsset const iouAsset = issuer[iouCurrency_]; - env(trust(lender, iouAsset(10'000'000))); - env(trust(borrower, iouAsset(10'000'000))); - env(trust(evan, iouAsset(10'000'000))); - env(pay(issuer, evan, iouAsset(1'000'000))); - env(pay(issuer, lender, iouAsset(10'000'000))); - // Fund the borrower with enough to cover interest and fees - env(pay(issuer, borrower, iouAsset(10'000))); - env.close(); - - MPTTester mptt{env, issuer, kMptInitNoFund}; - mptt.create({.flags = tfMPTCanClawback | tfMPTCanTransfer | tfMPTCanLock}); - // Scale the MPT asset a little bit so we can get some interest - PrettyAsset const mptAsset{mptt.issuanceID(), 100}; - mptt.authorize({.account = lender}); - mptt.authorize({.account = borrower}); - mptt.authorize({.account = evan}); - env(pay(issuer, lender, mptAsset(10'000'000))); - env(pay(issuer, evan, mptAsset(1'000'000))); - // Fund the borrower with enough to cover interest and fees - env(pay(issuer, borrower, mptAsset(10'000))); - env.close(); - - std::array const assets{iouAsset, xrpAsset, mptAsset}; - - // Create vaults and loan brokers - std::vector brokers; - brokers.reserve(assets.size()); - for (auto const& asset : assets) - { - brokers.emplace_back(createVaultAndBroker( - env, asset, lender, BrokerParameters{.data = "spam spam spam spam"})); - } - - // Create and update Loans - for (auto const& broker : brokers) - { - for (int amountExponent = 3; amountExponent >= 3; --amountExponent) - { - Number const loanAmount{1, amountExponent}; - for (int interestExponent = 0; interestExponent >= 0; --interestExponent) - { - testCaseWrapper(env, mptt, assets, broker, loanAmount, interestExponent); - } - } - - if (auto brokerSle = env.le(keylet::loanBroker(broker.brokerID)); - BEAST_EXPECT(brokerSle)) - { - BEAST_EXPECT(brokerSle->at(sfOwnerCount) == 0); - BEAST_EXPECT(brokerSle->at(sfDebtTotal) == 0); - - auto const coverAvailable = brokerSle->at(sfCoverAvailable); - env(loanBroker::coverWithdraw( - lender, broker.brokerID, STAmount(broker.asset, coverAvailable))); - env.close(); - - brokerSle = env.le(keylet::loanBroker(broker.brokerID)); - BEAST_EXPECT(brokerSle && brokerSle->at(sfCoverAvailable) == 0); - } - // Verify we can delete the loan broker - env(loanBroker::del(lender, broker.brokerID)); - env.close(); - } - } - - void - testSelfLoan(FeatureBitset features) + run() override { - testcase << "Self Loan"; - - using namespace jtx; - using namespace std::chrono_literals; - // Create 3 loan brokers: one for XRP, one for an IOU, and one for - // an MPT. That'll require three corresponding SAVs. - Env env(*this, features); - - Account const issuer{"issuer"}; - // For simplicity, lender will be the sole actor for the vault & - // brokers. - Account const lender{"lender"}; - - // Fund the accounts and trust lines with the same amount so that - // tests can use the same values regardless of the asset. - env.fund(XRP(100'000'000), issuer, noripple(lender)); - env.close(); - - // Use an XRP asset for simplicity - PrettyAsset const xrpAsset{xrpIssue(), 1'000'000}; - - // Create vaults and loan brokers - BrokerInfo broker{createVaultAndBroker(env, xrpAsset, lender)}; - - using namespace loan; - - auto const loanSetFee = Fee(env.current()->fees().base * 2); - Number const principalRequest{1, 3}; - - // The LoanSet json can be created without a counterparty signature, - // but it will not pass preflight - auto createJson = env.json( - set(lender, broker.brokerID, broker.asset(principalRequest).value()), Fee(loanSetFee)); - env(createJson, Ter(temBAD_SIGNER)); - - // Adding an empty counterparty signature object also fails, but - // at the RPC level. - createJson = env.json(createJson, Json(sfCounterpartySignature, json::ValueType::Object)); - env(createJson, Ter(telENV_RPC_FAILED)); - - if (auto const jt = env.jt(createJson); BEAST_EXPECT(jt.stx)) - { - Serializer s; - jt.stx->add(s); - auto const jr = env.rpc("submit", strHex(s.slice())); - - BEAST_EXPECT(jr.isMember(jss::result)); - auto const jResult = jr[jss::result]; - BEAST_EXPECT(jResult[jss::error] == "invalidTransaction"); - BEAST_EXPECT( - jResult[jss::error_exception] == - "fails local checks: Transaction has bad signature."); - } - - // Copy the transaction signature into the counterparty signature. - json::Value counterpartyJson{json::ValueType::Object}; - counterpartyJson[sfTxnSignature] = createJson[sfTxnSignature]; - counterpartyJson[sfSigningPubKey] = createJson[sfSigningPubKey]; - if (!BEAST_EXPECT(!createJson.isMember(jss::Signers))) - counterpartyJson[sfSigners] = createJson[sfSigners]; - - // The duplicated signature works - createJson = env.json(createJson, Json(sfCounterpartySignature, counterpartyJson)); - env(createJson); - - env.close(); - - auto const startDate = env.current()->header().parentCloseTime; - - // Loan is successfully created - { - auto const res = env.rpc("account_objects", lender.human()); - auto const objects = res[jss::result][jss::account_objects]; - - std::map types; - BEAST_EXPECT(objects.size() == 4); - for (auto const& object : objects) - { - ++types[object[sfLedgerEntryType].asString()]; - } - BEAST_EXPECT(types.size() == 4); - for (std::string const type : {"MPToken", "Vault", "LoanBroker", "Loan"}) - { - BEAST_EXPECT(types[type] == 1); - } - } - auto const loanID = [&]() { - json::Value params(json::ValueType::Object); - params[jss::account] = lender.human(); - params[jss::type] = "Loan"; - auto const res = env.rpc("json", "account_objects", to_string(params)); - auto const objects = res[jss::result][jss::account_objects]; - - BEAST_EXPECT(objects.size() == 1); - - auto const loan = objects[0u]; - BEAST_EXPECT(loan[sfBorrower] == lender.human()); - // soeDEFAULT fields are not returned if they're in the default - // state - BEAST_EXPECT(!loan.isMember(sfCloseInterestRate)); - BEAST_EXPECT(!loan.isMember(sfClosePaymentFee)); - BEAST_EXPECT(loan[sfFlags] == 0); - BEAST_EXPECT(loan[sfGracePeriod] == 60); - BEAST_EXPECT(!loan.isMember(sfInterestRate)); - BEAST_EXPECT(!loan.isMember(sfLateInterestRate)); - BEAST_EXPECT(!loan.isMember(sfLatePaymentFee)); - BEAST_EXPECT(loan[sfLoanBrokerID] == to_string(broker.brokerID)); - BEAST_EXPECT(!loan.isMember(sfLoanOriginationFee)); - BEAST_EXPECT(loan[sfLoanSequence] == 1); - BEAST_EXPECT(!loan.isMember(sfLoanServiceFee)); - BEAST_EXPECT(loan[sfNextPaymentDueDate] == loan[sfStartDate].asUInt() + 60); - BEAST_EXPECT(!loan.isMember(sfOverpaymentFee)); - BEAST_EXPECT(!loan.isMember(sfOverpaymentInterestRate)); - BEAST_EXPECT(loan[sfPaymentInterval] == 60); - BEAST_EXPECT(loan[sfPeriodicPayment] == "1000000000"); - BEAST_EXPECT(loan[sfPaymentRemaining] == 1); - BEAST_EXPECT(!loan.isMember(sfPreviousPaymentDueDate)); - BEAST_EXPECT(loan[sfPrincipalOutstanding] == "1000000000"); - BEAST_EXPECT(loan[sfTotalValueOutstanding] == "1000000000"); - BEAST_EXPECT(!loan.isMember(sfLoanScale)); - BEAST_EXPECT(loan[sfStartDate].asUInt() == startDate.time_since_epoch().count()); - - return loan["index"].asString(); - }(); - auto const loanKeylet{keylet::loan(uint256{std::string_view(loanID)})}; - - env.close(startDate); - - // Make a payment - env(pay(lender, loanKeylet.key, broker.asset(1000))); - } - - void - testBatchBypassCounterparty(FeatureBitset features) - { - // From FIND-001 - testcase << "Batch Bypass Counterparty"; - - bool const lendingBatchEnabled = !std::ranges::any_of( - Batch::kDisabledTxTypes, - [](auto const& disabled) { return disabled == ttLOAN_BROKER_SET; }); - - using namespace jtx; - using namespace std::chrono_literals; - Env env(*this, features); - - Account const lender{"lender"}; - Account const borrower{"borrower"}; - - BrokerParameters const brokerParams; - env.fund(XRP(brokerParams.vaultDeposit * 100), lender, borrower); - env.close(); - - PrettyAsset const xrpAsset{xrpIssue(), 1'000'000}; - - BrokerInfo const broker{createVaultAndBroker(env, xrpAsset, lender, brokerParams)}; - - using namespace loan; - - auto const loanSetFee = Fee(env.current()->fees().base * 2); - Number const principalRequest{1, 3}; - - auto forgedLoanSet = set(borrower, broker.brokerID, principalRequest, 0); - - json::Value randomData{json::ValueType::Object}; - randomData[jss::SigningPubKey] = json::StaticString{"2600"}; - json::Value sigObject{json::ValueType::Object}; - sigObject[jss::SigningPubKey] = strHex(lender.pk().slice()); - Serializer ss; - ss.add32(HashPrefix::TxSign); - parse(randomData).addWithoutSigningFields(ss); - auto const sig = xrpl::sign(borrower.pk(), borrower.sk(), ss.slice()); - sigObject[jss::TxnSignature] = strHex(Slice{sig.data(), sig.size()}); - - forgedLoanSet[json::StaticString{"CounterpartySignature"}] = sigObject; - - // ? Fails because the lender hasn't signed the tx - env(env.json(forgedLoanSet, Fee(loanSetFee)), Ter(telENV_RPC_FAILED)); - - auto const seq = env.seq(borrower); - auto const batchFee = batch::calcBatchFee(env, 1, 2); - // ! Should fail because the lender hasn't signed the tx - env(batch::outer(borrower, seq, batchFee, tfAllOrNothing), - batch::Inner(forgedLoanSet, seq + 1), - batch::Inner(pay(borrower, lender, XRP(1)), seq + 2), - Ter(lendingBatchEnabled ? temBAD_SIGNATURE : temINVALID_INNER_BATCH)); - env.close(); - - // ? Check that the loan was NOT created - { - json::Value params(json::ValueType::Object); - params[jss::account] = borrower.human(); - params[jss::type] = "Loan"; - auto const res = env.rpc("json", "account_objects", to_string(params)); - auto const objects = res[jss::result][jss::account_objects]; - BEAST_EXPECT(objects.size() == 0); - } - } - - void - testWrongMaxDebtBehavior(FeatureBitset features) - { - // From FIND-003 - testcase << "Wrong Max Debt Behavior"; - - using namespace jtx; - using namespace std::chrono_literals; - Env env(*this, features); - - Account const issuer{"issuer"}; - Account const lender{"lender"}; - - BrokerParameters const brokerParams{.debtMax = 0}; - env.fund(XRP(brokerParams.vaultDeposit * 100), issuer, noripple(lender)); - env.close(); - - PrettyAsset const xrpAsset{xrpIssue(), 1'000'000}; - - BrokerInfo const broker{createVaultAndBroker(env, xrpAsset, lender, brokerParams)}; - - if (auto const brokerSle = env.le(keylet::loanBroker(broker.brokerID)); - BEAST_EXPECT(brokerSle)) - { - BEAST_EXPECT(brokerSle->at(sfDebtMaximum) == 0); - } - - using namespace loan; - - auto const loanSetFee = Fee(env.current()->fees().base * 2); - Number const principalRequest{1, 3}; - - auto createJson = env.json(set(lender, broker.brokerID, principalRequest), Fee(loanSetFee)); - - json::Value counterpartyJson{json::ValueType::Object}; - counterpartyJson[sfTxnSignature] = createJson[sfTxnSignature]; - counterpartyJson[sfSigningPubKey] = createJson[sfSigningPubKey]; - if (!BEAST_EXPECT(!createJson.isMember(jss::Signers))) - counterpartyJson[sfSigners] = createJson[sfSigners]; - - createJson = env.json(createJson, Json(sfCounterpartySignature, counterpartyJson)); - env(createJson); - - env.close(); - } - - void - testRPC(FeatureBitset features) - { - // This will expand as more test cases are added. Some functionality - // is tested in other test functions. - testcase("RPC"); - - using namespace jtx; - - Env env(*this, features); - - auto lowerFee = [&]() { - // Run the local fee back down. - while (env.app().getFeeTrack().lowerLocalFee()) - ; - }; - - auto const baseFee = env.current()->fees().base; - - Account const alice{"alice"}; - std::string const borrowerPass = "borrower"; - Account const borrower{borrowerPass, KeyType::Ed25519}; - auto const lenderPass = "lender"; - Account const lender{lenderPass, KeyType::Ed25519}; - - env.fund(XRP(1'000'000), alice, lender, borrower); - env.close(); - env(noop(lender)); - env(noop(lender)); - env(noop(lender)); - env(noop(lender)); - env(noop(lender)); - env.close(); - - { - testcase("RPC AccountSet"); - json::Value txJson{json::ValueType::Object}; - txJson[sfTransactionType] = "AccountSet"; - txJson[sfAccount] = borrower.human(); - - auto const signParams = [&]() { - json::Value signParams{json::ValueType::Object}; - signParams[jss::passphrase] = borrowerPass; - signParams[jss::key_type] = "ed25519"; - signParams[jss::tx_json] = txJson; - return signParams; - }(); - auto const jSign = env.rpc("json", "sign", to_string(signParams)); - BEAST_EXPECT(jSign.isMember(jss::result) && jSign[jss::result].isMember(jss::tx_json)); - auto txSignResult = jSign[jss::result][jss::tx_json]; - auto txSignBlob = jSign[jss::result][jss::tx_blob].asString(); - txSignResult.removeMember(jss::hash); - - auto const jtx = env.jt(txJson, Sig(borrower)); - BEAST_EXPECT(txSignResult == jtx.jv); - - lowerFee(); - auto const jSubmit = env.rpc("submit", txSignBlob); - BEAST_EXPECT( - jSubmit.isMember(jss::result) && - jSubmit[jss::result].isMember(jss::engine_result) && - jSubmit[jss::result][jss::engine_result].asString() == "tesSUCCESS"); - - lowerFee(); - env(jtx.jv, Sig(kNone), Seq(kNone), Fee(kNone), Ter(tefPAST_SEQ)); - } - - { - testcase("RPC LoanSet - illegal signature_target"); - - json::Value txJson{json::ValueType::Object}; - txJson[sfTransactionType] = "AccountSet"; - txJson[sfAccount] = borrower.human(); - - auto const borrowerSignParams = [&]() { - json::Value params{json::ValueType::Object}; - params[jss::passphrase] = borrowerPass; - params[jss::key_type] = "ed25519"; - params[jss::signature_target] = "Destination"; - params[jss::tx_json] = txJson; - return params; - }(); - auto const jSignBorrower = env.rpc("json", "sign", to_string(borrowerSignParams)); - BEAST_EXPECT( - jSignBorrower.isMember(jss::result) && - jSignBorrower[jss::result].isMember(jss::error) && - jSignBorrower[jss::result][jss::error] == "invalidParams" && - jSignBorrower[jss::result].isMember(jss::error_message) && - jSignBorrower[jss::result][jss::error_message] == "Destination"); - } - { - testcase("RPC LoanSet - sign and submit borrower initiated"); - // 1. Borrower creates the transaction - json::Value txJson{json::ValueType::Object}; - txJson[sfTransactionType] = "LoanSet"; - txJson[sfAccount] = borrower.human(); - txJson[sfCounterparty] = lender.human(); - txJson[sfLoanBrokerID] = - "FF924CD18A236C2B49CF8E80A351CEAC6A10171DC9F110025646894FEC" - "F83F" - "5C"; - txJson[sfPrincipalRequested] = "100000000"; - txJson[sfPaymentTotal] = 10000; - txJson[sfPaymentInterval] = 3600; - txJson[sfGracePeriod] = 300; - txJson[sfFlags] = 65536; // tfLoanOverpayment - txJson[sfFee] = to_string(24 * baseFee / 10); - - // 2. Borrower signs the transaction - auto const borrowerSignParams = [&]() { - json::Value params{json::ValueType::Object}; - params[jss::passphrase] = borrowerPass; - params[jss::key_type] = "ed25519"; - params[jss::tx_json] = txJson; - return params; - }(); - auto const jSignBorrower = env.rpc("json", "sign", to_string(borrowerSignParams)); - BEAST_EXPECTS( - jSignBorrower.isMember(jss::result) && - jSignBorrower[jss::result].isMember(jss::tx_json), - to_string(jSignBorrower)); - auto const txBorrowerSignResult = jSignBorrower[jss::result][jss::tx_json]; - auto const txBorrowerSignBlob = jSignBorrower[jss::result][jss::tx_blob].asString(); - - // 2a. Borrower attempts to submit the transaction. It doesn't - // work - { - lowerFee(); - auto const jSubmitBlob = env.rpc("submit", txBorrowerSignBlob); - BEAST_EXPECT(jSubmitBlob.isMember(jss::result)); - auto const jSubmitBlobResult = jSubmitBlob[jss::result]; - BEAST_EXPECT(jSubmitBlobResult.isMember(jss::tx_json)); - // Transaction fails because the CounterpartySignature is - // missing - BEAST_EXPECT( - jSubmitBlobResult.isMember(jss::engine_result) && - jSubmitBlobResult[jss::engine_result].asString() == "temBAD_SIGNER"); - } - - // 3. Borrower sends the signed transaction to the lender - // 4. Lender signs the transaction - auto const lenderSignParams = [&]() { - json::Value params{json::ValueType::Object}; - params[jss::passphrase] = lenderPass; - params[jss::key_type] = "ed25519"; - params[jss::signature_target] = "CounterpartySignature"; - params[jss::tx_json] = txBorrowerSignResult; - return params; - }(); - auto const jSignLender = env.rpc("json", "sign", to_string(lenderSignParams)); - BEAST_EXPECT( - jSignLender.isMember(jss::result) && - jSignLender[jss::result].isMember(jss::tx_json)); - auto const txLenderSignResult = jSignLender[jss::result][jss::tx_json]; - auto const txLenderSignBlob = jSignLender[jss::result][jss::tx_blob].asString(); - - // 5. Lender submits the signed transaction blob - lowerFee(); - auto const jSubmitBlob = env.rpc("submit", txLenderSignBlob); - BEAST_EXPECT(jSubmitBlob.isMember(jss::result)); - auto const jSubmitBlobResult = jSubmitBlob[jss::result]; - BEAST_EXPECT(jSubmitBlobResult.isMember(jss::tx_json)); - auto const jSubmitBlobTx = jSubmitBlobResult[jss::tx_json]; - // To get far enough to return tecNO_ENTRY means that the - // signatures all validated. Of course the transaction won't - // succeed because no Vault or Broker were created. - BEAST_EXPECTS( - jSubmitBlobResult.isMember(jss::engine_result) && - jSubmitBlobResult[jss::engine_result].asString() == "tecNO_ENTRY", - to_string(jSubmitBlobResult)); - - BEAST_EXPECT( - !jSubmitBlob.isMember(jss::error) && !jSubmitBlobResult.isMember(jss::error)); - - // 4-alt. Lender submits the transaction json originally - // received from the Borrower. It gets signed, but is now a - // duplicate, so fails. Borrower could done this instead of - // steps 4 and 5. - lowerFee(); - auto const jSubmitJson = env.rpc("json", "submit", to_string(lenderSignParams)); - BEAST_EXPECT(jSubmitJson.isMember(jss::result)); - auto const jSubmitJsonResult = jSubmitJson[jss::result]; - BEAST_EXPECT(jSubmitJsonResult.isMember(jss::tx_json)); - auto const jSubmitJsonTx = jSubmitJsonResult[jss::tx_json]; - // Since the previous tx claimed a fee, this duplicate is not - // going anywhere - BEAST_EXPECTS( - jSubmitJsonResult.isMember(jss::engine_result) && - jSubmitJsonResult[jss::engine_result].asString() == "tefPAST_SEQ", - to_string(jSubmitJsonResult)); - - BEAST_EXPECT( - !jSubmitJson.isMember(jss::error) && !jSubmitJsonResult.isMember(jss::error)); - - BEAST_EXPECT(jSubmitBlobTx == jSubmitJsonTx); - } - - { - testcase("RPC LoanSet - sign and submit lender initiated"); - // 1. Lender creates the transaction - json::Value txJson{json::ValueType::Object}; - txJson[sfTransactionType] = "LoanSet"; - txJson[sfAccount] = lender.human(); - txJson[sfCounterparty] = borrower.human(); - txJson[sfLoanBrokerID] = - "FF924CD18A236C2B49CF8E80A351CEAC6A10171DC9F110025646894FEC" - "F83F" - "5C"; - txJson[sfPrincipalRequested] = "100000000"; - txJson[sfPaymentTotal] = 10000; - txJson[sfPaymentInterval] = 3600; - txJson[sfGracePeriod] = 300; - txJson[sfFlags] = 65536; // tfLoanOverpayment - txJson[sfFee] = to_string(24 * baseFee / 10); - - // 2. Lender signs the transaction - auto const lenderSignParams = [&]() { - json::Value params{json::ValueType::Object}; - params[jss::passphrase] = lenderPass; - params[jss::key_type] = "ed25519"; - params[jss::tx_json] = txJson; - return params; - }(); - auto const jSignLender = env.rpc("json", "sign", to_string(lenderSignParams)); - BEAST_EXPECT( - jSignLender.isMember(jss::result) && - jSignLender[jss::result].isMember(jss::tx_json)); - auto const txLenderSignResult = jSignLender[jss::result][jss::tx_json]; - auto const txLenderSignBlob = jSignLender[jss::result][jss::tx_blob].asString(); - - // 2a. Lender attempts to submit the transaction. It doesn't - // work - { - lowerFee(); - auto const jSubmitBlob = env.rpc("submit", txLenderSignBlob); - BEAST_EXPECT(jSubmitBlob.isMember(jss::result)); - auto const jSubmitBlobResult = jSubmitBlob[jss::result]; - BEAST_EXPECT(jSubmitBlobResult.isMember(jss::tx_json)); - // Transaction fails because the CounterpartySignature is - // missing - BEAST_EXPECT( - jSubmitBlobResult.isMember(jss::engine_result) && - jSubmitBlobResult[jss::engine_result].asString() == "temBAD_SIGNER"); - } - - // 3. Lender sends the signed transaction to the Borrower - // 4. Borrower signs the transaction - auto const borrowerSignParams = [&]() { - json::Value params{json::ValueType::Object}; - params[jss::passphrase] = borrowerPass; - params[jss::key_type] = "ed25519"; - params[jss::signature_target] = "CounterpartySignature"; - params[jss::tx_json] = txLenderSignResult; - return params; - }(); - auto const jSignBorrower = env.rpc("json", "sign", to_string(borrowerSignParams)); - BEAST_EXPECT( - jSignBorrower.isMember(jss::result) && - jSignBorrower[jss::result].isMember(jss::tx_json)); - auto const txBorrowerSignResult = jSignBorrower[jss::result][jss::tx_json]; - auto const txBorrowerSignBlob = jSignBorrower[jss::result][jss::tx_blob].asString(); - - // 5. Borrower submits the signed transaction blob - lowerFee(); - auto const jSubmitBlob = env.rpc("submit", txBorrowerSignBlob); - BEAST_EXPECT(jSubmitBlob.isMember(jss::result)); - auto const jSubmitBlobResult = jSubmitBlob[jss::result]; - BEAST_EXPECT(jSubmitBlobResult.isMember(jss::tx_json)); - auto const jSubmitBlobTx = jSubmitBlobResult[jss::tx_json]; - // To get far enough to return tecNO_ENTRY means that the - // signatures all validated. Of course the transaction won't - // succeed because no Vault or Broker were created. - BEAST_EXPECTS( - jSubmitBlobResult.isMember(jss::engine_result) && - jSubmitBlobResult[jss::engine_result].asString() == "tecNO_ENTRY", - to_string(jSubmitBlobResult)); - - BEAST_EXPECT( - !jSubmitBlob.isMember(jss::error) && !jSubmitBlobResult.isMember(jss::error)); - - // 4-alt. Borrower submits the transaction json originally - // received from the Lender. It gets signed, but is now a - // duplicate, so fails. Lender could done this instead of steps - // 4 and 5. - lowerFee(); - auto const jSubmitJson = env.rpc("json", "submit", to_string(borrowerSignParams)); - BEAST_EXPECT(jSubmitJson.isMember(jss::result)); - auto const jSubmitJsonResult = jSubmitJson[jss::result]; - BEAST_EXPECT(jSubmitJsonResult.isMember(jss::tx_json)); - auto const jSubmitJsonTx = jSubmitJsonResult[jss::tx_json]; - // Since the previous tx claimed a fee, this duplicate is not - // going anywhere - BEAST_EXPECTS( - jSubmitJsonResult.isMember(jss::engine_result) && - jSubmitJsonResult[jss::engine_result].asString() == "tefPAST_SEQ", - to_string(jSubmitJsonResult)); - - BEAST_EXPECT( - !jSubmitJson.isMember(jss::error) && !jSubmitJsonResult.isMember(jss::error)); - - BEAST_EXPECT(jSubmitBlobTx == jSubmitJsonTx); - } - } - - void - testServiceFeeOnBrokerDeepFreeze() - { - testcase << "Service Fee On Broker Deep Freeze"; - using namespace jtx; - using namespace loan; - Account const issuer("issuer"); - Account const borrower("borrower"); - Account const broker("broker"); - auto const iou = issuer["IOU"]; - - for (bool const deepFreeze : {true, false}) - { - Env env(*this); - - auto getCoverBalance = [&](BrokerInfo const& brokerInfo, auto const& accountField) { - if (auto const le = env.le(keylet::loanBroker(brokerInfo.brokerID)); - BEAST_EXPECT(le)) - { - auto const account = le->at(accountField); - if (auto const sleLine = env.le(keylet::trustLine(account, iou)); - BEAST_EXPECT(sleLine)) - { - STAmount balance = sleLine->at(sfBalance); - if (account > issuer.id()) - balance.negate(); - return balance; - } - } - return STAmount{iou}; - }; - - env.fund(XRP(20'000), issuer, broker, borrower); - env.close(); - - env(trust(broker, iou(20'000'000))); - env(pay(issuer, broker, iou(10'000'000))); - env.close(); - - auto const brokerInfo = createVaultAndBroker(env, iou, broker); - - BEAST_EXPECT(getCoverBalance(brokerInfo, sfAccount) == iou(1'000)); - - auto const keylet = keylet::loan(brokerInfo.brokerID, 1); - - env(set(borrower, brokerInfo.brokerID, 10'000), - Sig(sfCounterpartySignature, broker), - kLoanServiceFee(iou(100).value()), - kPaymentInterval(100), - Fee(XRP(100))); - env.close(); - - env(trust(borrower, iou(20'000'000))); - // The borrower increases their limit and acquires some IOU so - // they can pay interest - env(pay(issuer, borrower, iou(500))); - env.close(); - - if (auto const le = env.le(keylet::loan(keylet.key)); BEAST_EXPECT(le)) - { - if (deepFreeze) - { - env(trust(issuer, broker["IOU"](0), tfSetFreeze | tfSetDeepFreeze)); - env.close(); - } - - env(pay(borrower, keylet.key, iou(10'100)), Fee(XRP(100))); - env.close(); - - if (deepFreeze) - { - // The fee goes to the broker pseudo-account - BEAST_EXPECT(getCoverBalance(brokerInfo, sfAccount) == iou(1'100)); - BEAST_EXPECT(getCoverBalance(brokerInfo, sfOwner) == iou(8'999'000)); - } - else - { - // The fee goes to the broker account - BEAST_EXPECT(getCoverBalance(brokerInfo, sfOwner) == iou(8'999'100)); - BEAST_EXPECT(getCoverBalance(brokerInfo, sfAccount) == iou(1'000)); - } - } - }; - } - - void - testIssuerLoan() - { - testcase << "Issuer Loan"; - - using namespace jtx; - using namespace loan; - Account const issuer("issuer"); - Account const borrower = issuer; - Account const lender("lender"); - Env env(*this); - - env.fund(XRP(1'000), issuer, lender); - - static constexpr std::int64_t kIssuerBalance = 10'000'000; - MPTTester const asset( - {.env = env, .issuer = issuer, .holders = {lender}, .pay = kIssuerBalance}); - - BrokerParameters const brokerParams{ - .debtMax = 200, - }; - auto const broker = createVaultAndBroker(env, asset, lender, brokerParams); - auto const loanSetFee = Fee(env.current()->fees().base * 2); - // Create Loan - env(set(borrower, broker.brokerID, 200), Sig(sfCounterpartySignature, lender), loanSetFee); - env.close(); - // Issuer should not create MPToken - BEAST_EXPECT(!env.le(keylet::mptoken(asset.issuanceID(), issuer))); - // Issuer "borrowed" 200, OutstandingAmount decreased by 200 - BEAST_EXPECT(env.balance(issuer, asset) == asset(-kIssuerBalance + 200)); - // Pay Loan - auto const loanKeylet = keylet::loan(broker.brokerID, 1); - env(pay(borrower, loanKeylet.key, asset(200))); - env.close(); - // Issuer "re-payed" 200, OutstandingAmount increased by 200 - BEAST_EXPECT(env.balance(issuer, asset) == asset(-kIssuerBalance)); - } - - void - testInvalidLoanDelete() - { - testcase("Invalid LoanDelete"); - using namespace jtx; - using namespace loan; - - // preflight: temINVALID, LoanID == zero - { - Account const alice{"alice"}; - Env env(*this); - env.fund(XRP(1'000), alice); - env.close(); - env(del(alice, beast::kZero), Ter(temINVALID)); - } - } - - void - testInvalidLoanManage() - { - testcase("Invalid LoanManage"); - using namespace jtx; - using namespace loan; - - // preflight: temINVALID, LoanID == zero - { - Account const alice{"alice"}; - Env env(*this); - env.fund(XRP(1'000), alice); - env.close(); - env(manage(alice, beast::kZero, tfLoanDefault), Ter(temINVALID)); - } - } - - void - testInvalidLoanPay() - { - testcase("Invalid LoanPay"); - using namespace jtx; - using namespace loan; - Account const lender{"lender"}; - Account const issuer{"issuer"}; - Account const borrower{"borrower"}; - auto const iou = issuer["IOU"]; - - // preclaim - Env env(*this); - env.fund(XRP(1'000), lender, issuer, borrower); - env(trust(lender, iou(10'000'000))); - env(pay(issuer, lender, iou(5'000'000))); - BrokerInfo brokerInfo{createVaultAndBroker(env, issuer["IOU"], lender)}; - - auto const loanSetFee = Fee(env.current()->fees().base * 2); - STAmount const debtMaximumRequest = brokerInfo.asset(1'000).value(); - - env(set(borrower, brokerInfo.brokerID, debtMaximumRequest), - Sig(sfCounterpartySignature, lender), - loanSetFee); - - env.close(); - - std::uint32_t const loanSequence = 1; - auto const loanKeylet = keylet::loan(brokerInfo.brokerID, loanSequence); - - env(fset(issuer, asfGlobalFreeze)); - env.close(); - - // preclaim: tecFROZEN - env(pay(borrower, loanKeylet.key, debtMaximumRequest), Ter(tecFROZEN)); - env.close(); - - env(fclear(issuer, asfGlobalFreeze)); - env.close(); - - auto const pseudoBroker = [&]() -> std::optional { - if (auto brokerSle = env.le(keylet::loanBroker(brokerInfo.brokerID)); - BEAST_EXPECT(brokerSle)) - { - return Account{"pseudo", brokerSle->at(sfAccount)}; - } - - return std::nullopt; - }(); - if (!pseudoBroker) - return; - - // Lender and pseudoaccount must both be frozen - env(trust(issuer, lender["IOU"](1'000), lender, tfSetFreeze | tfSetDeepFreeze)); - env(trust( - issuer, (*pseudoBroker)["IOU"](1'000), *pseudoBroker, tfSetFreeze | tfSetDeepFreeze)); - env.close(); - - // preclaim: tecFROZEN due to deep frozen - env(pay(borrower, loanKeylet.key, debtMaximumRequest), Ter(tecFROZEN)); - env.close(); - - // Only one needs to be unfrozen - env(trust(issuer, lender["IOU"](1'000), tfClearFreeze | tfClearDeepFreeze)); - env.close(); - - // The payment is late by this point - env(pay(borrower, loanKeylet.key, debtMaximumRequest), Ter(tecEXPIRED)); - env.close(); - env(pay(borrower, loanKeylet.key, debtMaximumRequest, tfLoanLatePayment)); - env.close(); - - // preclaim: tecKILLED - // note that tecKILLED in loanMakePayment() - // doesn't happen because of the preclaim check. - env(pay(borrower, loanKeylet.key, debtMaximumRequest), Ter(tecKILLED)); - } - - void - testInvalidLoanSet() - { - testcase("Invalid LoanSet"); - using namespace jtx; - using namespace loan; - Account const lender{"lender"}; - Account const issuer{"issuer"}; - Account const borrower{"borrower"}; - Account const sponsor{"sponsor"}; - auto const iou = issuer["IOU"]; - - auto testWrapper = [&](auto&& test) { - Env env(*this); - env.fund(XRP(1'000), lender, issuer, borrower, sponsor); - env(trust(lender, iou(10'000'000))); - env(pay(issuer, lender, iou(5'000'000))); - BrokerInfo const brokerInfo{createVaultAndBroker(env, issuer["IOU"], lender)}; - - auto const loanSetFee = Fee(env.current()->fees().base * 2); - Number const debtMaximumRequest = brokerInfo.asset(1'000).value(); - test(env, brokerInfo, loanSetFee, debtMaximumRequest); - }; - - // preflight: - testWrapper([&](Env& env, - BrokerInfo const& brokerInfo, - jtx::Fee const& loanSetFee, - Number const& debtMaximumRequest) { - for (auto const sponsorFlags : {spfSponsorReserve, spfSponsorReserve | spfSponsorFee}) - { - env(set(borrower, brokerInfo.brokerID, debtMaximumRequest), - sponsor::As(sponsor, sponsorFlags), - Sig(sfCounterpartySignature, lender), - loanSetFee, - Ter(temINVALID_FLAG)); - } - - // first temBAD_SIGNER: TODO - // invalid grace period - { - // zero grace period - env(set(borrower, brokerInfo.brokerID, debtMaximumRequest), - Sig(sfCounterpartySignature, lender), - kGracePeriod(0), - loanSetFee, - Ter(temINVALID)); - - // grace period less than default minimum - env(set(borrower, brokerInfo.brokerID, debtMaximumRequest), - Sig(sfCounterpartySignature, lender), - kGracePeriod(LoanSet::kDefaultGracePeriod - 1), - loanSetFee, - Ter(temINVALID)); - - // grace period greater than payment interval - env(set(borrower, brokerInfo.brokerID, debtMaximumRequest), - Sig(sfCounterpartySignature, lender), - kPaymentInterval(120), - kGracePeriod(121), - loanSetFee, - Ter(temINVALID)); - } - // empty/zero broker ID - { - auto jv = set(borrower, uint256{}, debtMaximumRequest); - - auto testZeroBrokerID = [&](std::string const& id, std::uint32_t flags = 0) { - // empty broker ID - jv[sfLoanBrokerID] = id; - env(jv, - Sig(sfCounterpartySignature, lender), - loanSetFee, - Txflags(flags), - Ter(temINVALID)); - }; - // empty broker ID - testZeroBrokerID(std::string("")); - // zero broker ID - // needs a flag to distinguish the parsed STTx from the prior - // test - testZeroBrokerID(to_string(uint256{}), tfFullyCanonicalSig); - } - - // preflightCheckSigningKey() failure: - // can it happen? the signature is checked before transactor - // executes - - JTx const tx = env.jt( - set(borrower, brokerInfo.brokerID, debtMaximumRequest), - Sig(sfCounterpartySignature, lender), - loanSetFee); - STTx local = *(tx.stx); - auto counterpartySig = local.getFieldObject(sfCounterpartySignature); - auto badPubKey = counterpartySig.getFieldVL(sfSigningPubKey); - badPubKey[20] ^= 0xAA; - counterpartySig.setFieldVL(sfSigningPubKey, badPubKey); - local.setFieldObject(sfCounterpartySignature, counterpartySig); - json::Value jvResult; - jvResult[jss::tx_blob] = strHex(local.getSerializer().slice()); - auto res = env.rpc("json", "submit", to_string(jvResult))["result"]; - BEAST_EXPECT( - res[jss::error] == "invalidTransaction" && - res[jss::error_exception] == - "fails local checks: Counterparty: Invalid signature."); - }); - - // preclaim: - testWrapper([&](Env& env, - BrokerInfo const& brokerInfo, - jtx::Fee const& loanSetFee, - Number const& debtMaximumRequest) { - // canAddHoldingFailure (IOU only, if MPT doesn't have - // MPTCanTransfer set, then can't create Vault/LoanBroker, - // and LoanSet will fail with different error - env(fclear(issuer, asfDefaultRipple)); - env.close(); - env(set(borrower, brokerInfo.brokerID, debtMaximumRequest), - Sig(sfCounterpartySignature, lender), - loanSetFee, - Ter(terNO_RIPPLE)); - }); - - // doApply: - testWrapper([&](Env& env, - BrokerInfo const& brokerInfo, - jtx::Fee const& loanSetFee, - Number const& debtMaximumRequest) { - auto const amt = - env.balance(borrower) - accountReserve(*env.current(), borrower.id(), env.journal); - env(pay(borrower, issuer, amt)); - - // tecINSUFFICIENT_RESERVE - env(set(borrower, brokerInfo.brokerID, debtMaximumRequest), - Sig(sfCounterpartySignature, lender), - loanSetFee, - Ter(tecINSUFFICIENT_RESERVE)); - - // addEmptyHolding failure - env(pay(issuer, borrower, amt)); - env(fset(issuer, asfGlobalFreeze)); - env.close(); - - env(set(borrower, brokerInfo.brokerID, debtMaximumRequest), - Sig(sfCounterpartySignature, lender), - loanSetFee, - Ter(tecFROZEN)); - }); - } - - void - testRequireAuth() - { - testcase("Require Auth - Implicit Pseudo-account authorization"); - using namespace jtx; - using namespace loan; - Account const lender{"lender"}; - Account const issuer{"issuer"}; - Account const borrower{"borrower"}; - Env env(*this); - - env.fund(XRP(100'000), issuer, lender, borrower); - env.close(); - - auto asset = MPTTester({ - .env = env, - .issuer = issuer, - .holders = {lender, borrower}, - .flags = kMptDexFlags | tfMPTRequireAuth | tfMPTCanClawback | tfMPTCanLock, - .authHolder = true, - }); - - env(pay(issuer, lender, asset(5'000'000))); - BrokerInfo brokerInfo{createVaultAndBroker(env, asset, lender)}; - - auto const loanSetFee = Fee(env.current()->fees().base * 2); - STAmount const debtMaximumRequest = brokerInfo.asset(1'000).value(); - - auto forUnauthAuth = [&](auto&& doTx) { - for (auto const flag : {tfMPTUnauthorize, 0u}) - { - asset.authorize({.account = issuer, .holder = borrower, .flags = flag}); - env.close(); - doTx(flag == 0); - env.close(); - } - }; - - // Can't create a loan if the borrower is not authorized - forUnauthAuth([&](bool authorized) { - auto const err = !authorized ? Ter(tecNO_AUTH) : Ter(tesSUCCESS); - env(set(borrower, brokerInfo.brokerID, debtMaximumRequest), - Sig(sfCounterpartySignature, lender), - loanSetFee, - err); - }); - - static constexpr std::uint32_t kLoanSequence = 1; - auto const loanKeylet = keylet::loan(brokerInfo.brokerID, kLoanSequence); - - // Can't loan pay if the borrower is not authorized - forUnauthAuth([&](bool authorized) { - auto const err = !authorized ? Ter(tecNO_AUTH) : Ter(tesSUCCESS); - env(pay(borrower, loanKeylet.key, debtMaximumRequest), err); - }); - } - - void - testLendingCanTradeDisabledNoImpact() - { - testcase("Lending: CanTrade disabled has no impact"); - using namespace jtx; - using namespace loan; - using namespace loanBroker; - - Env env(*this, all_); - - Account const issuer{"issuer"}; - Account const lender{"lender"}; - Account const borrower{"borrower"}; - - env.fund(XRP(1'000'000), issuer, lender, borrower); - env.close(); - - MPTTester mpt( - {.env = env, - .issuer = issuer, - .holders = {lender, borrower}, - .flags = tfMPTCanTransfer | tfMPTCanLock, - .mutableFlags = tmfMPTCanEnableCanTrade}); - PrettyAsset const asset = mpt.issuanceID(); - env(pay(issuer, lender, asset(10'000'000))); - env(pay(issuer, borrower, asset(100'000))); - env.close(); - - auto const broker = createVaultAndBroker(env, asset, lender); - - // CanTrade is not set - env(offer(lender, XRP(1), asset(10)), Ter{tecNO_PERMISSION}); - env.close(); - - auto const loanSetFee = Fee(env.current()->fees().base * 2); - - // New cover deposits still work. - env(coverDeposit(lender, broker.brokerID, asset(100))); - env.close(); - - // New loan issuance still works. - env(loan::set(borrower, broker.brokerID, 1'000), - Sig(sfCounterpartySignature, lender), - loanSetFee); - env.close(); - auto const loanKeylet = keylet::loan(broker.brokerID, 1); - BEAST_EXPECT(env.le(loanKeylet)); - - // Repayment still works. - env(pay(borrower, loanKeylet.key, asset(1'000))); - env.close(); - - // Cover withdrawal still works. - env(coverWithdraw(lender, broker.brokerID, asset(100))); - env.close(); - - // Enable CanTrade and verify the DEX path is restored. - mpt.set({.mutableFlags = tmfMPTSetCanTrade}); - env.close(); - - env(offer(lender, XRP(1), asset(10))); - env.close(); - } - - void - testBorrowerIsBroker() - { - testcase("Test Borrower is Broker"); - using namespace jtx; - using namespace loan; - Account const broker{"broker"}; - Account const issuer{"issuer"}; - Account const borrower{"borrower"}; - Account const depositor{"depositor"}; - - auto testLoanAsset = [&](auto&& getMaxDebt, auto const& borrower) { - Env env(*this); - Vault const vault(env); - - if (borrower == broker) - { - env.fund(XRP(10'000), broker, issuer, depositor); - } - else - { - env.fund(XRP(10'000), broker, borrower, issuer, depositor); - } - env.close(); - - auto const xrpFee = XRP(100); - auto const txFee = Fee(xrpFee); - - STAmount const debtMaximumRequest = getMaxDebt(env); - - auto const& asset = debtMaximumRequest.asset(); - auto const initialVault = asset(debtMaximumRequest * 100); - - auto [tx, vaultKeylet] = vault.create({.owner = broker, .asset = asset}); - env(tx, txFee); - env.close(); - - env(vault.deposit( - {.depositor = depositor, .id = vaultKeylet.key, .amount = initialVault}), - txFee); - env.close(); - - auto const brokerKeylet = keylet::loanBroker(broker.id(), env.seq(broker)); - - env(loanBroker::set(broker, vaultKeylet.key), txFee); - env.close(); - - auto const serviceFee = 101; - - env(set(broker, brokerKeylet.key, debtMaximumRequest), - kCounterparty(borrower), - Sig(sfCounterpartySignature, borrower), - kLoanServiceFee(serviceFee), - kPaymentTotal(10), - txFee); - env.close(); - - std::uint32_t const loanSequence = 1; - auto const loanKeylet = keylet::loan(brokerKeylet.key, loanSequence); - - auto const brokerBalanceBefore = env.balance(broker, asset); - - if (auto const loanSle = env.le(loanKeylet); env.test.BEAST_EXPECT(loanSle)) - { - auto const payment = loanSle->at(sfPeriodicPayment); - auto const totalPayment = payment + serviceFee; - env(loan::pay(borrower, loanKeylet.key, asset(totalPayment)), txFee); - env.close(); - if (auto const vaultSle = env.le(vaultKeylet); BEAST_EXPECT(vaultSle)) - { - auto const expected = [&]() { - // The service fee is transferred to the broker if - // a borrower is not the broker - if (borrower != broker) - return brokerBalanceBefore.number() + serviceFee; - // Since a borrower is the broker, the payment is - // transferred to the Vault from the broker but not - // the service fee. - // If the asset is XRP then the broker pays the txFee. - if (asset.native()) - return brokerBalanceBefore.number() - payment - xrpFee.number(); - return brokerBalanceBefore.number() - payment; - }(); - BEAST_EXPECT(env.balance(broker, asset).value() == asset(expected).value()); - } - } - }; - // Test when a borrower is the broker and is not to verify correct - // service fee transfer in both cases. - for (auto const& borrowerAcct : {broker, borrower}) - { - testLoanAsset( - [&](Env&) -> STAmount { return STAmount{XRPAmount{200'000}}; }, borrowerAcct); - testLoanAsset( - [&](Env& env) -> STAmount { - auto const iou = issuer["USD"]; - env(trust(broker, iou(1'000'000'000))); - env(trust(depositor, iou(1'000'000'000))); - env(pay(issuer, broker, iou(100'000'000))); - env(pay(issuer, depositor, iou(100'000'000))); - env.close(); - return iou(200'000); - }, - borrowerAcct); - testLoanAsset( - [&](Env& env) -> STAmount { - MPTTester const mpt( - {.env = env, - .issuer = issuer, - .holders = {broker, depositor}, - .pay = 100'000'000}); - return mpt(200'000); - }, - borrowerAcct); - } - } - - void - testIssuerIsBorrower(FeatureBitset features) - { - testcase("RIPD-4096 - Issuer as borrower"); - - using namespace jtx; - - Account const issuer("issuer"); - Account const lender("lender"); - - BrokerParameters const brokerParams{ - .vaultDeposit = 100'000, - .debtMax = 0, - .coverRateMin = TenthBips32{0}, - .managementFeeRate = TenthBips16{0}, - .coverRateLiquidation = TenthBips32{0}}; - LoanParameters const loanParams{ - .account = lender, .counter = issuer, .principalRequest = Number{10000}}; - - auto const assetType = AssetType::IOU; - - Env env{*this, features}; - - auto loanResult = - createLoan(env, assetType, brokerParams, loanParams, issuer, lender, issuer); - - if (BEAST_EXPECT(loanResult); !loanResult.has_value()) - return; - - auto broker = std::get(*loanResult); - auto loanKeylet = std::get(*loanResult); - auto pseudoAcct = std::get(*loanResult); - - VerifyLoanStatus const verifyLoanStatus(env, broker, pseudoAcct, loanKeylet); - - makeLoanPayments( - env, - broker, - loanParams, - loanKeylet, - verifyLoanStatus, - issuer, - lender, - issuer, - PaymentParameters{.showStepBalances = true}); - } - - void - testLimitExceeded() - { - testcase("RIPD-4125 - overpayment"); - - using namespace jtx; - - Account const issuer("issuer"); - Account const lender("lender"); - Account const borrower("borrower"); - - BrokerParameters const brokerParams{ - .vaultDeposit = 100'000, - .debtMax = 0, - .coverRateMin = TenthBips32{0}, - .managementFeeRate = TenthBips16{0}, - .coverRateLiquidation = TenthBips32{0}}; - LoanParameters const loanParams{ - .account = lender, - .counter = borrower, - .principalRequest = Number{200000, -6}, - .interest = TenthBips32{50000}, - .payTotal = 3, - .payInterval = 200, - .gracePd = 60, - .flags = tfLoanOverpayment, - }; - - auto const assetType = AssetType::XRP; - - Env env(*this, makeConfig(), all_, nullptr, beast::Severity::Warning); - - auto loanResult = - createLoan(env, assetType, brokerParams, loanParams, issuer, lender, borrower); - - if (BEAST_EXPECT(loanResult); !loanResult.has_value()) - return; - - auto broker = std::get(*loanResult); - auto loanKeylet = std::get(*loanResult); - auto pseudoAcct = std::get(*loanResult); - - VerifyLoanStatus const verifyLoanStatus(env, broker, pseudoAcct, loanKeylet); - - auto const state = getCurrentState(env, broker, loanKeylet); - - env(loan::pay( - borrower, - loanKeylet.key, - STAmount{broker.asset, state.periodicPayment * 3 / 2 + 1}, - tfLoanOverpayment)); - env.close(); - - PaymentParameters const paymentParams{ - .showStepBalances = false, - .validateBalances = true, - }; - - makeLoanPayments( - env, - broker, - loanParams, - loanKeylet, - verifyLoanStatus, - issuer, - lender, - borrower, - paymentParams); - } - - void - testOverpaymentManagementFee(FeatureBitset features) - { - testcase("testOverpaymentManagementFee"); - - using namespace jtx; - using namespace loan; - - Env env{*this, features}; - - Account const lender{"lender"}, borrower{"borrower"}; - - env.fund(XRP(10'000'000), lender, borrower); - env.close(); - - PrettyAsset const asset{xrpIssue(), 1000}; - - auto const result = createVaultAndBroker( - env, - asset, - lender, - { - .vaultDeposit = asset(100'000).value(), - .managementFeeRate = TenthBips16(10'000), - }); - - auto const loanSetFee = Fee(env.current()->fees().base * 2); - - auto const loanKeylet = keylet::loan( - result.brokerKeylet().key, (env.le(result.brokerKeylet()))->at(sfLoanSequence)); - env(loan::set( - borrower, result.brokerKeylet().key, asset(10'000).value(), tfLoanOverpayment), - Sig(sfCounterpartySignature, lender), - loan::kPaymentInterval(86400 * 30), - loan::kPaymentTotal(3), - loan::kOverpaymentInterestRate(TenthBips32(percentageToTenthBips(20))), - loanSetFee); - - // From calculator - auto const expectedOverpaymentManagementFee = Number{33333, 0}; - auto const loanBrokerBalanceBefore = env.balance(lender); - - auto const loanPayFee = Fee(env.current()->fees().base * 2); - env(pay(borrower, loanKeylet.key, asset(5'000).value(), tfLoanOverpayment), loanPayFee); - env.close(); - - BEAST_EXPECTS( - env.balance(lender) - loanBrokerBalanceBefore == expectedOverpaymentManagementFee, - "overpayment management fee missmatch; expected:" + - to_string(expectedOverpaymentManagementFee) + - " got: " + to_string(env.balance(lender) - loanBrokerBalanceBefore)); - } - - void - testLoanPayBrokerOwnerMissingTrustline(FeatureBitset features) - { - testcase << "LoanPay Broker Owner Missing Trustline (PoC)"; - using namespace jtx; - using namespace loan; - Account const issuer("issuer"); - Account const borrower("borrower"); - Account const broker("broker"); - auto const iou = issuer["IOU"]; - Env env(*this, features); - env.fund(XRP(20'000), issuer, broker, borrower); - env.close(); - // Set up trustlines and fund accounts - env(trust(broker, iou(20'000'000))); - env(trust(borrower, iou(20'000'000))); - env(pay(issuer, broker, iou(10'000'000))); - env(pay(issuer, borrower, iou(1'000))); - env.close(); - // Create vault and broker - auto const brokerInfo = createVaultAndBroker(env, iou, broker); - // Create a loan first (this creates debt) - auto const keylet = keylet::loan(brokerInfo.brokerID, 1); - env(set(borrower, brokerInfo.brokerID, 10'000), - Sig(sfCounterpartySignature, broker), - kLoanServiceFee(iou(100).value()), - kPaymentInterval(100), - Fee(XRP(100))); - env.close(); - // Ensure broker has sufficient cover so brokerPayee == brokerOwner - // We need coverAvailable >= (debtTotal * coverRateMinimum) - // Deposit enough cover to ensure the fee goes to broker owner - // The default coverRateMinimum is 10%, so for a 10,000 loan we need - // at least 1,000 cover. Default cover is 1,000, so we add more to be - // safe. - auto const additionalCover = iou(50'000).value(); - env(loanBroker::coverDeposit(broker, brokerInfo.brokerID, STAmount{iou, additionalCover})); - env.close(); - // Verify broker owner has a trustline - auto const brokerTrustline = keylet::trustLine(broker, iou); - BEAST_EXPECT(env.le(brokerTrustline) != nullptr); - // Broker owner deletes their trustline - // First, pay any positive balance to issuer to zero it out - auto const brokerBalance = env.balance(broker, iou); - env(pay(broker, issuer, brokerBalance)); - env.close(); - // Remove the trustline by setting limit to 0 - env(trust(broker, iou(0))); - env.close(); - // Verify trustline is deleted - BEAST_EXPECT(env.le(brokerTrustline) == nullptr); - // Now borrower tries to make a payment - // We should get a tesSUCCESS instead of a tecNO_LINE. - env(pay(borrower, keylet.key, iou(10'100)), Fee(XRP(100)), Ter(tesSUCCESS)); - env.close(); - // Verify trustline is still deleted - BEAST_EXPECT(env.le(brokerTrustline) == nullptr); - // Verify the service fee went to the broker pseudo-account - if (auto const brokerSle = env.le(keylet::loanBroker(brokerInfo.brokerID)); - BEAST_EXPECT(brokerSle)) - { - Account const pseudo("pseudo-account", brokerSle->at(sfAccount)); - auto const balance = env.balance(pseudo, iou); - // 1,000 default + 50,000 extra + 100 service fee from LoanPay - BEAST_EXPECTS(balance == iou(51'100), to_string(json::Value(balance))); - } - } - - void - testLoanPayBrokerOwnerUnauthorizedMPT(FeatureBitset features) - { - testcase << "LoanPay Broker Owner MPT unauthorized"; - using namespace jtx; - using namespace loan; - - Account const issuer("issuer"); - Account const borrower("borrower"); - Account const broker("broker"); - - Env env{*this, features}; - env.fund(XRP(20'000), issuer, broker, borrower); - env.close(); - - MPTTester mptt{env, issuer, kMptInitNoFund}; - mptt.create({.flags = tfMPTCanClawback | tfMPTCanTransfer | tfMPTCanLock}); - - PrettyAsset const mpt{mptt.issuanceID()}; - - // Authorize broker and borrower - mptt.authorize({.account = broker}); - mptt.authorize({.account = borrower}); - - env.close(); - - // Fund accounts - env(pay(issuer, broker, mpt(10'000'000))); - env(pay(issuer, borrower, mpt(1'000))); - env.close(); - - // Create vault and broker - auto const brokerInfo = createVaultAndBroker(env, mpt, broker); - // Create a loan first (this creates debt) - auto const keylet = keylet::loan(brokerInfo.brokerID, 1); - env(set(borrower, brokerInfo.brokerID, 10'000), - Sig(sfCounterpartySignature, broker), - kLoanServiceFee(mpt(100).value()), - kPaymentInterval(100), - Fee(XRP(100))); - env.close(); - // Ensure broker has sufficient cover so brokerPayee == brokerOwner - // We need coverAvailable >= (debtTotal * coverRateMinimum) - // Deposit enough cover to ensure the fee goes to broker owner - // The default coverRateMinimum is 10%, so for a 10,000 loan we need - // at least 1,000 cover. Default cover is 1,000, so we add more to be - // safe. - auto const additionalCover = mpt(50'000).value(); - env(loanBroker::coverDeposit(broker, brokerInfo.brokerID, STAmount{mpt, additionalCover})); - env.close(); - // Verify broker owner is authorized - auto const brokerMpt = keylet::mptoken(mptt.issuanceID(), broker); - BEAST_EXPECT(env.le(brokerMpt) != nullptr); - // Broker owner unauthorizes. - // First, pay any positive balance to issuer to zero it out - auto const brokerBalance = env.balance(broker, mpt); - env(pay(broker, issuer, brokerBalance)); - env.close(); - // Then, unauthorize the MPT. - mptt.authorize({.account = broker, .flags = tfMPTUnauthorize}); - env.close(); - // Verify the MPT is unauthorized. - BEAST_EXPECT(env.le(brokerMpt) == nullptr); - // Now borrower tries to make a payment - // We should get a tesSUCCESS instead of a tecNO_AUTH. - auto const borrowerBalance = env.balance(borrower, mpt); - env(pay(borrower, keylet.key, mpt(10'100)), Fee(XRP(100)), Ter(tesSUCCESS)); - env.close(); - // Verify the MPT is still unauthorized. - BEAST_EXPECT(env.le(brokerMpt) == nullptr); - // Verify the service fee went to the broker pseudo-account - if (auto const brokerSle = env.le(keylet::loanBroker(brokerInfo.brokerID)); - BEAST_EXPECT(brokerSle)) - { - Account const pseudo("pseudo-account", brokerSle->at(sfAccount)); - auto const balance = env.balance(pseudo, mpt); - // 1,000 default + 50,000 extra + 100 service fee from LoanPay - BEAST_EXPECTS(balance == mpt(51'100), to_string(json::Value(balance))); - } - } - - void - testLoanPayBrokerOwnerNoPermissionedDomainMPT(FeatureBitset features) - { - testcase << "LoanPay Broker Owner without permissioned domain of the MPT"; - using namespace jtx; - using namespace loan; - - Account const issuer("issuer"); - Account const borrower("borrower"); - Account const broker("broker"); - - Env env{*this, features}; - env.fund(XRP(20'000), issuer, broker, borrower); - env.close(); - - auto credType = "credential1"; - - pdomain::Credentials const credentials1 = {{.issuer = issuer, .credType = credType}}; - env(pdomain::setTx(issuer, credentials1)); - env.close(); - - auto domainID = pdomain::getNewDomain(env.meta()); - - env(credentials::create(broker, issuer, credType)); - env(credentials::accept(broker, issuer, credType)); - env.close(); - - env(credentials::create(borrower, issuer, credType)); - env(credentials::accept(borrower, issuer, credType)); - env.close(); - - MPTTester mptt{env, issuer, kMptInitNoFund}; - mptt.create({ - .flags = tfMPTCanClawback | tfMPTRequireAuth | tfMPTCanTransfer | tfMPTCanLock, - .domainID = domainID, - }); - - PrettyAsset const mpt{mptt.issuanceID()}; - - // Authorize broker and borrower - mptt.authorize({.account = broker}); - mptt.authorize({.account = borrower}); - - env.close(); - - // Fund accounts - env(pay(issuer, broker, mpt(10'000'000))); - env(pay(issuer, borrower, mpt(1'000))); - env.close(); - - // Create vault and broker - auto const brokerInfo = createVaultAndBroker(env, mpt, broker); - // Create a loan first (this creates debt) - auto const keylet = keylet::loan(brokerInfo.brokerID, 1); - env(set(borrower, brokerInfo.brokerID, 10'000), - Sig(sfCounterpartySignature, broker), - kLoanServiceFee(mpt(100).value()), - kPaymentInterval(100), - Fee(XRP(100))); - env.close(); - // Ensure broker has sufficient cover so brokerPayee == brokerOwner - // We need coverAvailable >= (debtTotal * coverRateMinimum) - // Deposit enough cover to ensure the fee goes to broker owner - // The default coverRateMinimum is 10%, so for a 10,000 loan we need - // at least 1,000 cover. Default cover is 1,000, so we add more to be - // safe. - auto const additionalCover = mpt(50'000).value(); - env(loanBroker::coverDeposit(broker, brokerInfo.brokerID, STAmount{mpt, additionalCover})); - env.close(); - // Verify broker owner is authorized - auto const brokerMpt = keylet::mptoken(mptt.issuanceID(), broker); - BEAST_EXPECT(env.le(brokerMpt) != nullptr); - // Remove the credentials for the Broker owner. - // First, pay any positive balance to issuer to zero it out - auto const brokerBalance = env.balance(broker, mpt); - env(pay(broker, issuer, brokerBalance)); - env.close(); - - env(credentials::deleteCred(broker, broker, issuer, credType)); - env.close(); - - // Make sure the broker is not authorized to hold the MPT after we - // deleted the credentials - env(pay(issuer, broker, mpt(1'000)), Ter(tecNO_AUTH)); - - // Now borrower tries to make a payment - // We should get a tesSUCCESS instead of a tecNO_AUTH. - auto const borrowerBalance = env.balance(borrower, mpt); - env(pay(borrower, keylet.key, mpt(10'100)), Fee(XRP(100)), Ter(tesSUCCESS)); - env.close(); - // Verify broker is still not authorized - env(pay(issuer, broker, mpt(1'000)), Ter(tecNO_AUTH)); - // Verify the service fee went to the broker pseudo-account - if (auto const brokerSle = env.le(keylet::loanBroker(brokerInfo.brokerID)); - BEAST_EXPECT(brokerSle)) - { - Account const pseudo("pseudo-account", brokerSle->at(sfAccount)); - auto const balance = env.balance(pseudo, mpt); - // 1,000 default + 50,000 extra + 100 service fee from LoanPay - BEAST_EXPECTS(balance == mpt(51'100), to_string(json::Value(balance))); - } - } - - void - testLoanSetBrokerOwnerNoPermissionedDomainMPT(FeatureBitset features) - { - testcase << "LoanSet Broker Owner without permissioned domain of the MPT"; - using namespace jtx; - using namespace loan; - - Account const issuer("issuer"); - Account const borrower("borrower"); - Account const broker("broker"); - - Env env{*this, features}; - env.fund(XRP(20'000), issuer, broker, borrower); - env.close(); - - auto credType = "credential1"; - - pdomain::Credentials const credentials1{{.issuer = issuer, .credType = credType}}; - env(pdomain::setTx(issuer, credentials1)); - env.close(); - - auto domainID = pdomain::getNewDomain(env.meta()); - - // Add credentials for the broker and borrower - env(credentials::create(broker, issuer, credType)); - env(credentials::accept(broker, issuer, credType)); - env.close(); - - env(credentials::create(borrower, issuer, credType)); - env(credentials::accept(borrower, issuer, credType)); - env.close(); - - MPTTester mptt{env, issuer, kMptInitNoFund}; - mptt.create({ - .flags = tfMPTCanClawback | tfMPTRequireAuth | tfMPTCanTransfer | tfMPTCanLock, - .domainID = domainID, - }); - - PrettyAsset const mpt{mptt.issuanceID()}; - - // Authorize broker and borrower - mptt.authorize({.account = broker}); - mptt.authorize({.account = borrower}); - env.close(); - - // Fund accounts - env(pay(issuer, broker, mpt(10'000'000))); - env(pay(issuer, borrower, mpt(1'000))); - env.close(); - - // Create vault and broker - auto const brokerInfo = createVaultAndBroker(env, mpt, broker); - - // Remove the credentials for the Broker owner. - // Clear the balance first. - auto const brokerBalance = env.balance(broker, mpt); - env(pay(broker, issuer, brokerBalance)); - env.close(); - // Delete the credentials - env(credentials::deleteCred(broker, broker, issuer, credType)); - env.close(); - - // Create a loan, this should fail for tecNO_AUTH - env(set(borrower, brokerInfo.brokerID, 10'000), - Sig(sfCounterpartySignature, broker), - kLoanServiceFee(mpt(100).value()), - kPaymentInterval(100), - Fee(XRP(100)), - Ter(tecNO_AUTH)); - env.close(); - } - - // Verify that LoanPay, LoanBrokerCoverWithdraw, and LoanSet all use the - // same vault-scale minimum cover when fixCleanup3_2_0 is enabled. - // Before the amendment, each transactor computed its minimum cover at a - // different precision (loanScale, debtScale, or the raw unrounded - // tenthBipsOfValue), which could lead to inconsistent decisions for the - // same broker state. After the amendment all three use - // minimumBrokerCover at vaultScale. - void - testMinimumBrokerCoverConsistency(FeatureBitset features) - { - using namespace jtx; - using namespace loan; - using namespace loanBroker; - - bool const withAmendment = features[fixCleanup3_2_0]; - - struct Ctx - { - jtx::Account issuer; - jtx::Account lender; - jtx::Account borrower; - jtx::PrettyAsset iou; - BrokerInfo broker; - BrokerParameters brokerParams; - }; - - // Shared setup, parametrized by vaultDeposit (the only varying setup - // field across the three scenarios). Each call runs in its own Env - // so multiple invocations within one scenario cannot interfere. - // The caller is responsible for invoking testcase(...) before the - // first runTest call of each scenario. - auto runTest = [&](Number vaultDeposit, auto&& body) { - Env env(*this, features); - - Account const issuer{"issuer"}; - Account const lender{"lender"}; - Account const borrower{"borrower"}; - - env.fund(XRP(1'000'000'000), issuer, lender, borrower); - env.close(); - - // Enable clawback on the issuer *before* any trust lines exist - // (asfAllowTrustLineClawback requires an empty owner directory). - env(fset(issuer, asfAllowTrustLineClawback)); - env.close(); - - PrettyAsset const iou = issuer[iouCurrency_]; - env(trust(lender, iou(1'000'000'000))); - env(trust(borrower, iou(1'000'000'000))); - env.close(); - env(pay(issuer, lender, iou(100'000'000))); - env(pay(issuer, borrower, iou(100'000'000))); - env.close(); - - // 13.37% — non-round rate produces a messier minimum. - BrokerParameters const brokerParams{ - .vaultDeposit = vaultDeposit, - .debtMax = 0, - .coverRateMin = TenthBips32{13'370}, - .coverDeposit = 5'000, - .managementFeeRate = TenthBips16{500}}; - - BrokerInfo const broker = createVaultAndBroker(env, iou, lender, brokerParams); - - body( - env, - Ctx{.issuer = issuer, - .lender = lender, - .borrower = borrower, - .iou = iou, - .broker = broker, - .brokerParams = brokerParams}); - }; - - // Scenario 1 — LoanPay - // - // Verify that LoanPay's minimum cover check uses vault scale (not - // loan scale). Before the amendment, different loans could produce - // different fee routing decisions for the same broker-level state. - // Small vault deposit => vaultScale = -12. - testcase("LoanPay minimum cover scale consistency"); - { - struct LoanKeylets - { - Keylet tiny; - Keylet big; - }; - - // Create the tiny + big loans and reduce cover via clawback so - // that subsequent LoanPay calls hit the minimum-cover boundary. - // Used by the two pay-and-check sub-tests below so each can run - // in its own Env. - auto setupLoansAndClawback = [&](Env& env, Ctx const& c) -> std::optional { - Asset const asset{c.iou}; - - // Create the TINY loan first (while vaultScale is still - // small). principal 0.01, 0% interest, 1 payment => - // loanScale = vaultScale. - auto const brokerSle1 = env.le(keylet::loanBroker(c.broker.brokerID)); - if (!BEAST_EXPECT(brokerSle1)) - return std::nullopt; - auto const tinyLoanSeq = brokerSle1->at(sfLoanSequence); - auto const tinyLoanKeylet = keylet::loan(c.broker.brokerID, tinyLoanSeq); - - env(set(c.borrower, c.broker.brokerID, Number{1, -2}), - Sig(sfCounterpartySignature, c.lender), - kInterestRate(TenthBips32{0}), - kPaymentTotal(1), - kPaymentInterval(86400 * 365), - Fee(XRP(10))); - env.close(); - - // Create the BIG loan second. 100% annual interest over 20 - // payments pushes totalValueOutstanding high enough that - // loanScale > vaultScale. - auto const brokerSle2 = env.le(keylet::loanBroker(c.broker.brokerID)); - if (!BEAST_EXPECT(brokerSle2)) - return std::nullopt; - auto const bigLoanSeq = brokerSle2->at(sfLoanSequence); - auto const bigLoanKeylet = keylet::loan(c.broker.brokerID, bigLoanSeq); - - env(set(c.borrower, c.broker.brokerID, Number{500}), - Sig(sfCounterpartySignature, c.lender), - kInterestRate(TenthBips32{100'000}), - kPaymentTotal(20), - kPaymentInterval(86400 * 365), - Fee(XRP(10))); - env.close(); - - // The tiny loan's scale is frozen at the vault's pre-big-loan - // scale, so it is strictly smaller than the big loan's. - // After the big loan is created the vault absorbs its value, - // pushing vaultScale up to match bigLoanScale. - auto const tinyLoanSle = env.le(tinyLoanKeylet); - auto const bigLoanSle = env.le(bigLoanKeylet); - auto const vaultSle = env.le(keylet::vault(c.broker.vaultID)); - if (!BEAST_EXPECT(tinyLoanSle) || !BEAST_EXPECT(bigLoanSle) || - !BEAST_EXPECT(vaultSle)) - return std::nullopt; - if (!BEAST_EXPECT(tinyLoanSle->at(sfLoanScale) == -12) || - !BEAST_EXPECT(bigLoanSle->at(sfLoanScale) == -11) || - !BEAST_EXPECT(getAssetsTotalScale(vaultSle) == -11)) - return std::nullopt; - - // Use issuer clawback to reduce cover to the minimum the - // clawback transactor allows. Compute the amount as - // initialCover - expectedCoverAfter so we exercise the exact - // clawback rather than relying on the transactor to clip - // down. - // - // Before the amendment the clawback minimum is the - // *unrounded* tenthBipsOfValue — strictly less than the - // rounded-at-vaultScale minimum LoanPay uses for the big - // loan. After the amendment both clawback and LoanPay use - // the same rounded minimum (via minimumBrokerCover), so - // cover lands exactly at that threshold. - Number const expectedCoverAfter = withAmendment ? Number{1330651855688460000, -15} - : Number{1330651855688458000, -15}; - Number const clawbackAmount = - Number{c.brokerParams.coverDeposit} - expectedCoverAfter; - - env(coverClawback(c.issuer), - kLoanBrokerId(c.broker.brokerID), - kAmount(STAmount{asset, clawbackAmount})); - env.close(); - - auto const brokerSle = env.le(keylet::loanBroker(c.broker.brokerID)); - if (!BEAST_EXPECT(brokerSle) || - !BEAST_EXPECT(brokerSle->at(sfCoverAvailable) == expectedCoverAfter)) - return std::nullopt; - - return LoanKeylets{.tiny = tinyLoanKeylet, .big = bigLoanKeylet}; - }; - - // Pay one loan and report whether the fee went to the broker's - // pseudo account (the fallback when cover < minimum) rather - // than to the owner. - auto feeGoesToPseudo = [&](Env& env, Ctx const& c, Keylet const& loanKeylet) -> bool { - Asset const asset{c.iou}; - auto const brokerSle = env.le(keylet::loanBroker(c.broker.brokerID)); - if (!BEAST_EXPECT(brokerSle)) - return false; - auto const pseudoAcct = Account("pseudo", brokerSle->at(sfAccount)); - auto const pseudoBefore = env.balance(pseudoAcct, c.iou); - - auto const payLoan = env.le(loanKeylet); - if (!BEAST_EXPECT(payLoan)) - return false; - auto const periodicPayment = payLoan->at(sfPeriodicPayment); - auto const serviceFee = payLoan->at(sfLoanServiceFee); - std::int32_t const loanScale = payLoan->at(sfLoanScale); - - auto const payment = roundPeriodicPayment(asset, periodicPayment, loanScale); - auto const payAmt = STAmount{asset, payment + serviceFee}; - - env(loan::pay(c.borrower, loanKeylet.key, payAmt), Fee(XRP(10))); - env.close(); - - auto const pseudoAfter = env.balance(pseudoAcct, c.iou); - return pseudoAfter.number() > pseudoBefore.number(); - }; - - // Pay the BIG loan in its own Env so its outcome cannot affect - // the TINY-loan check. With the fix, LoanPay and clawback use - // the same vaultScale minimum (cover == minAtVaultScale => - // fee to owner). Without the fix, LoanPay uses bigLoanScale=-11, - // rounds up to a larger minimum than what clawback used => - // cover < min => fee to pseudo. - runTest(/*vaultDeposit=*/1'000, [&](Env& env, Ctx const& c) { - auto const loans = setupLoansAndClawback(env, c); - if (!loans) - return; - BEAST_EXPECT(feeGoesToPseudo(env, c, loans->big) == !withAmendment); - }); - - // Pay the TINY loan in its own Env. Fee goes to the owner - // either way: - // - With the fix: LoanPay uses vaultScale=-11 (same as - // clawback) => owner. - // - Without the fix: LoanPay uses tinyLoanScale=-12, rounds - // up at -12 (a no-op) => min == cover => owner. - runTest(/*vaultDeposit=*/1'000, [&](Env& env, Ctx const& c) { - auto const loans = setupLoansAndClawback(env, c); - if (!loans) - return; - BEAST_EXPECT(!feeGoesToPseudo(env, c, loans->tiny)); - }); - } - - // Scenario 2 — LoanBrokerCoverWithdraw - // - // Verify that CoverWithdraw's minimum cover check uses vault scale - // (not scale(debtTotal, asset)). Before the amendment, CoverWithdraw - // used: - // roundToAsset(asset, tenthBipsOfValue(debt, rate), scale(debt, asset)) - // which could disagree with LoanPay's minimum (which used loanScale). - // - // Use a large vault deposit so that vaultScale (from AssetsTotal) is - // strictly larger than debtScale (from DebtTotal). With - // vaultDeposit = 100,000: after the big loan - // AssetsTotal ≈ 109,500 → vaultScale = -10 - // DebtTotal ≈ 10,000 → debtScale = -11 - // The one-order-of-magnitude gap makes roundToAsset at -10 truncate - // more aggressively than at -11, exposing the bug. - testcase("CoverWithdraw minimum cover scale consistency"); - runTest( - /*vaultDeposit=*/100'000, [&](Env& env, Ctx const& c) { - Asset const asset{c.iou}; - - // Create only the big loan to push DebtTotal up to ~10,000 - // while AssetsTotal stays around 109,500 (dominated by the - // large vault deposit). - env(set(c.borrower, c.broker.brokerID, Number{500}), - Sig(sfCounterpartySignature, c.lender), - kInterestRate(TenthBips32{100'000}), - kPaymentTotal(20), - kPaymentInterval(86400 * 365), - Fee(XRP(10))); - env.close(); - - // Read broker state and compute both old and new minimums. - auto const brokerSle = env.le(keylet::loanBroker(c.broker.brokerID)); - auto const vaultSle = env.le(keylet::vault(c.broker.vaultID)); - if (!BEAST_EXPECT(brokerSle) || !BEAST_EXPECT(vaultSle)) - return; - - auto const coverAvail = brokerSle->at(sfCoverAvailable); - auto const debtTotal = brokerSle->at(sfDebtTotal); - auto const vaultScale = getAssetsTotalScale(vaultSle); - auto const debtScale = scale(debtTotal, asset); - - // Sanity: debt scale differs from vault scale for this setup. - BEAST_EXPECT(debtScale < vaultScale); - - auto const oldMin = [&]() { - NumberRoundModeGuard const mg(Number::RoundingMode::Upward); - return roundToAsset( - asset, - tenthBipsOfValue(debtTotal, TenthBips32{c.brokerParams.coverRateMin}), - debtScale); - }(); - auto const newMin = minimumBrokerCover( - debtTotal, TenthBips32{c.brokerParams.coverRateMin}, vaultSle); - - // The new (vaultScale) minimum must be strictly larger than - // the old (debtScale) minimum — that is the gap the amendment - // closes. - Number const expectedNewMin{1330650518688500000, -15}; - Number const expectedOldMin{1330650518688472000, -15}; - BEAST_EXPECT(newMin == expectedNewMin); - BEAST_EXPECT(oldMin == expectedOldMin); - - // Try to withdraw so that remaining cover lands between the - // two minimums: oldMin < target < newMin. - auto const target = oldMin + (newMin - oldMin) / 2; - auto const withdrawAmount = STAmount{asset, coverAvail - target}; - - if (withAmendment) - { - // CoverWithdraw now uses vaultScale: target < newMin - // => FAILS. - env(coverWithdraw(c.lender, c.broker.brokerID, withdrawAmount), - Ter(tecINSUFFICIENT_FUNDS)); - } - else - { - // Old CoverWithdraw uses debtScale: target > oldMin - // => SUCCEEDS. - env(coverWithdraw(c.lender, c.broker.brokerID, withdrawAmount)); - } - env.close(); - }); - - // Scenario 3 — LoanSet - // - // Verify that LoanSet's minimum cover check uses vault scale (not the - // raw unrounded tenthBipsOfValue). Before the amendment, LoanSet - // used tenthBipsOfValue(newDebtTotal, coverRateMinimum) (no - // roundToAsset), while clawback/withdraw used different formulas. - // After the amendment all use minimumBrokerCover at vaultScale, and - // rounding at a coarser scale can absorb a tiny debt increase — - // allowing a loan that would otherwise be rejected. - testcase("LoanSet minimum cover scale consistency"); - runTest( - /*vaultDeposit=*/1'000, [&](Env& env, Ctx const& c) { - // Create the tiny loan (scale -12) AND the big loan (scale - // -11). Both loans are needed so that DebtTotal has a full - // 16-digit mantissa — a "messy" value where roundToAsset at - // vaultScale actually truncates digits and produces a - // different result from the raw tenthBipsOfValue. With only - // the big loan, DebtTotal has ~4 significant digits and - // rounding at scale -11 is a no-op, masking the amendment's - // effect. - env(set(c.borrower, c.broker.brokerID, Number{1, -2}), - Sig(sfCounterpartySignature, c.lender), - kInterestRate(TenthBips32{0}), - kPaymentTotal(1), - kPaymentInterval(86400 * 365), - Fee(XRP(10))); - env.close(); - - env(set(c.borrower, c.broker.brokerID, Number{500}), - Sig(sfCounterpartySignature, c.lender), - kInterestRate(TenthBips32{100'000}), - kPaymentTotal(20), - kPaymentInterval(86400 * 365), - Fee(XRP(10))); - env.close(); - - // Clawback to reduce cover to the clawback transactor's - // minimum. Pass the exact amount rather than relying on the - // transactor to clip down; the setup matches Scenario 1 so - // the same residual-cover values apply. - Number const expectedCoverAfter = withAmendment ? Number{1330651855688460000, -15} - : Number{1330651855688458000, -15}; - Number const clawbackAmount = - Number{c.brokerParams.coverDeposit} - expectedCoverAfter; - env(coverClawback(c.issuer), - kLoanBrokerId(c.broker.brokerID), - kAmount(c.iou(clawbackAmount))); - env.close(); - - // Verify scales. - auto const vaultSle = env.le(keylet::vault(c.broker.vaultID)); - if (!BEAST_EXPECT(vaultSle)) - return; - auto const vaultScale = getAssetsTotalScale(vaultSle); - BEAST_EXPECT(vaultScale == -11); - - // Now try to create a tiny additional loan. Principal is - // 1e-11 (the smallest value that survives the precision - // check at loanScale = vaultScale = -11), with 0% interest - // and 1 payment. - // - // The tiny debt increase adds ~1.337e-12 to the unrounded - // minimum. - // - Without the amendment: the old LoanSet formula rounds - // up during tenthBipsOfValue (16-digit Number - // normalisation), pushing the minimum past the cover left - // by clawback => tecINSUFFICIENT_FUNDS. - // - With the amendment: minimumBrokerCover rounds at - // vaultScale=-11, which absorbs the tiny increase — the - // rounded minimum stays the same => tesSUCCESS. - auto const tinyPrincipal = Number{1, -11}; - - if (withAmendment) - { - env(set(c.borrower, c.broker.brokerID, tinyPrincipal), - Sig(sfCounterpartySignature, c.lender), - kInterestRate(TenthBips32{0}), - kPaymentTotal(1), - kPaymentInterval(86400 * 365), - Fee(XRP(10))); - } - else - { - env(set(c.borrower, c.broker.brokerID, tinyPrincipal), - Sig(sfCounterpartySignature, c.lender), - kInterestRate(TenthBips32{0}), - kPaymentTotal(1), - kPaymentInterval(86400 * 365), - Fee(XRP(10)), - Ter(tecINSUFFICIENT_FUNDS)); - } - env.close(); - }); - } - - void - runAmendmentIndependent() - { - testDisabled(); - testInvalidLoanSet(); - testInvalidLoanDelete(); - testInvalidLoanManage(); - testInvalidLoanPay(); - testIssuerLoan(); - testServiceFeeOnBrokerDeepFreeze(); - testRequireAuth(); - testBorrowerIsBroker(); - testLimitExceeded(); - testLendingCanTradeDisabledNoImpact(); - } - - // Tests run under each entry in amendmentCombinations(). - void - runAmendmentSensitive(FeatureBitset features) - { - // Lifecycle - testLifecycle(features); - testLoanSet(features); - testSelfLoan(features); - testBatchBypassCounterparty(features); - testWrongMaxDebtBehavior(features); - - // RPC - testRPC(features); - - testOverpaymentManagementFee(features); - testIssuerIsBorrower(features); - testMinimumBrokerCoverConsistency(features); - - // Broker-owner permissions - testLoanPayBrokerOwnerMissingTrustline(features); - testLoanPayBrokerOwnerUnauthorizedMPT(features); - testLoanPayBrokerOwnerNoPermissionedDomainMPT(features); - testLoanSetBrokerOwnerNoPermissionedDomainMPT(features); - } - -public: - void - run() override - { - runAmendmentIndependent(); for (auto const& features : jtx::amendmentCombinations( {fixCleanup3_1_3, fixCleanup3_2_0, featureMPTokensV2}, all_)) - runAmendmentSensitive(features); - } -}; - -class LoanBatch_test : public LoanTestBase -{ -protected: - beast::xor_shift_engine engine_; - - std::uniform_int_distribution<> assetDist_{0, 2}; - std::uniform_int_distribution principalDist_{100'000, 1'000'000'000}; - std::uniform_int_distribution interestRateDist_{0, 10000}; - std::uniform_int_distribution<> paymentTotalDist_{12, 10000}; - std::uniform_int_distribution<> paymentIntervalDist_{60, 3600 * 24 * 30}; - std::uniform_int_distribution managementFeeRateDist_{0, 10'000}; - std::uniform_int_distribution<> serviceFeeDist_{0, 20}; - /* - # Generate parameters that are more likely to be valid - principal = Decimal(str(rand.randint(100000, - 100'000'000))).quantize(ROUND_TARGET) - - interest_rate = Decimal(rand.randint(1, 10000)) / - Decimal(100000) - - payment_total = rand.randint(12, 10000) - - payment_interval = Decimal(str(rand.randint(60, 2629746))) - - interest_fee = Decimal(rand.randint(0, 100000)) / - Decimal(100000) -*/ - - void - testRandomLoan() - { - using namespace jtx; - - Account const issuer("issuer"); - Account const lender("lender"); - Account const borrower("borrower"); - - // Determine all the random parameters at once - auto const assetType = static_cast(assetDist_(engine_)); - auto const principalRequest = principalDist_(engine_); - TenthBips16 const managementFeeRate{managementFeeRateDist_(engine_)}; - auto const serviceFee = serviceFeeDist_(engine_); - TenthBips32 interest{interestRateDist_(engine_)}; - auto const payTotal = paymentTotalDist_(engine_); - auto const payInterval = paymentIntervalDist_(engine_); - - BrokerParameters const brokerParams{ - .vaultDeposit = principalRequest * 10, - .debtMax = 0, - .coverRateMin = TenthBips32{0}, - .managementFeeRate = managementFeeRate}; - LoanParameters const loanParams{ - .account = lender, - .counter = borrower, - .principalRequest = principalRequest, - .serviceFee = serviceFee, - .interest = interest, - .payTotal = payTotal, - .payInterval = payInterval, - }; - - runLoan(assetType, brokerParams, loanParams, all_); - } - -public: - void - run() override - { - auto const numIterations = [s = arg()]() -> int { - int const defaultNum = 5; - if (s.empty()) - return defaultNum; - try - { - std::size_t pos = 0; - auto const r = stoi(s, &pos); - if (pos != s.size()) - return defaultNum; - return r; - } - catch (...) - { - return defaultNum; - } - }(); - - using namespace jtx; - - auto const updateInterval = std::min(numIterations / 5, 100); - - for (int i = 0; i < numIterations; ++i) - { - if (i % updateInterval == 0) - testcase << "Random Loan Test iteration " << (i + 1) << "/" << numIterations; - testRandomLoan(); - } - } -}; - -class LoanArbitrary_test : public LoanBatch_test -{ - void - run() override - { - using namespace jtx; - - BrokerParameters const brokerParams{ - .vaultDeposit = 10000, - .debtMax = 0, - .coverRateMin = TenthBips32{0}, - .managementFeeRate = TenthBips16{0}, - .coverRateLiquidation = TenthBips32{0}}; - LoanParameters const loanParams{ - .account = Account("lender"), - .counter = Account("borrower"), - .principalRequest = Number{200000, -6}, - .interest = TenthBips32{50000}, - .payTotal = 2, - .payInterval = 200}; - - runLoan(AssetType::XRP, brokerParams, loanParams, all_); + testLoanSet(features); } }; BEAST_DEFINE_TESTSUITE(Loan, tx, xrpl); -BEAST_DEFINE_TESTSUITE_MANUAL(LoanBatch, tx, xrpl); -BEAST_DEFINE_TESTSUITE_MANUAL(LoanArbitrary, tx, xrpl); } // namespace xrpl::test From 1a227d34c622b730aa2b93139e783177fe2842c4 Mon Sep 17 00:00:00 2001 From: Vito <5780819+Tapanito@users.noreply.github.com> Date: Fri, 24 Jul 2026 19:35:59 +0200 Subject: [PATCH 10/11] test: Fix includes flagged by clang-tidy in split Loan suites Run clang-tidy (misc-include-cleaner) scoped to the files touched by the Loan test split. Trims LoanTestBase.h down to only the includes its shared helpers actually need now that most test bodies moved out, and adds direct includes to each split file for symbols they use. Restores two includes the automated fixer incorrectly dropped (test/jtx/jtx_json.h for Json(...), test/jtx/sponsor.h for sponsor::As) which broke the build; clang-tidy's usage tracking doesn't reliably see through these. Verified with a full clean rebuild and test run: 1071 cases, 116951 assertions, 0 failures, unchanged from before this cleanup. --- src/test/app/LoanCashBasis_test.cpp | 26 ++++++++++++++ src/test/app/LoanCoverFreezeAuth_test.cpp | 27 +++++++++++++++ src/test/app/LoanInvariants_test.cpp | 30 ++++++++++++++++- src/test/app/LoanLifecycle_test.cpp | 41 +++++++++++++++++++++++ src/test/app/LoanMisc_test.cpp | 33 ++++++++++++++++++ src/test/app/LoanPay_test.cpp | 29 ++++++++++++++++ src/test/app/LoanRounding_test.cpp | 38 +++++++++++++++++++++ src/test/app/LoanSecurity_test.cpp | 27 +++++++++++++++ src/test/app/LoanTestBase.h | 28 ++-------------- src/test/app/LoanValidation_test.cpp | 36 ++++++++++++++++++++ src/test/app/Loan_test.cpp | 30 +++++++++++++++++ 11 files changed, 318 insertions(+), 27 deletions(-) diff --git a/src/test/app/LoanCashBasis_test.cpp b/src/test/app/LoanCashBasis_test.cpp index 2c437efb25e..e766b9b68ad 100644 --- a/src/test/app/LoanCashBasis_test.cpp +++ b/src/test/app/LoanCashBasis_test.cpp @@ -1,4 +1,30 @@ #include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include namespace xrpl::test { diff --git a/src/test/app/LoanCoverFreezeAuth_test.cpp b/src/test/app/LoanCoverFreezeAuth_test.cpp index 092740187ea..84c2598287c 100644 --- a/src/test/app/LoanCoverFreezeAuth_test.cpp +++ b/src/test/app/LoanCoverFreezeAuth_test.cpp @@ -1,4 +1,31 @@ #include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include namespace xrpl::test { diff --git a/src/test/app/LoanInvariants_test.cpp b/src/test/app/LoanInvariants_test.cpp index 0b895d03422..59e3540dd0f 100644 --- a/src/test/app/LoanInvariants_test.cpp +++ b/src/test/app/LoanInvariants_test.cpp @@ -1,4 +1,32 @@ #include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include namespace xrpl::test { @@ -299,7 +327,7 @@ class LoanInvariants_test : public LoanTestBase PrettyAsset const iouAsset = createFundedIouAsset(env, issuer, lender, borrower); - BrokerInfo broker{createVaultAndBroker(env, iouAsset, lender)}; + BrokerInfo const broker{createVaultAndBroker(env, iouAsset, lender)}; using namespace loan; diff --git a/src/test/app/LoanLifecycle_test.cpp b/src/test/app/LoanLifecycle_test.cpp index 7bc4c8ebf57..f2e3fcaf1e0 100644 --- a/src/test/app/LoanLifecycle_test.cpp +++ b/src/test/app/LoanLifecycle_test.cpp @@ -1,4 +1,45 @@ #include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include namespace xrpl::test { diff --git a/src/test/app/LoanMisc_test.cpp b/src/test/app/LoanMisc_test.cpp index 7c0831b87ca..bd8b03c5bbe 100644 --- a/src/test/app/LoanMisc_test.cpp +++ b/src/test/app/LoanMisc_test.cpp @@ -1,4 +1,37 @@ #include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include namespace xrpl::test { diff --git a/src/test/app/LoanPay_test.cpp b/src/test/app/LoanPay_test.cpp index f2719669f0d..2ca001d0096 100644 --- a/src/test/app/LoanPay_test.cpp +++ b/src/test/app/LoanPay_test.cpp @@ -1,4 +1,33 @@ #include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include namespace xrpl::test { diff --git a/src/test/app/LoanRounding_test.cpp b/src/test/app/LoanRounding_test.cpp index e3fe0dcac16..8e8bdc01445 100644 --- a/src/test/app/LoanRounding_test.cpp +++ b/src/test/app/LoanRounding_test.cpp @@ -1,4 +1,42 @@ #include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include namespace xrpl::test { diff --git a/src/test/app/LoanSecurity_test.cpp b/src/test/app/LoanSecurity_test.cpp index 5b6f3ee5d4e..4fd941bec17 100644 --- a/src/test/app/LoanSecurity_test.cpp +++ b/src/test/app/LoanSecurity_test.cpp @@ -1,4 +1,31 @@ #include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include namespace xrpl::test { diff --git a/src/test/app/LoanTestBase.h b/src/test/app/LoanTestBase.h index e560463f9d7..e38e93d3e57 100644 --- a/src/test/app/LoanTestBase.h +++ b/src/test/app/LoanTestBase.h @@ -4,28 +4,18 @@ // #include #include +#include #include #include -#include -#include -#include #include #include -#include #include #include -#include -#include #include -#include -#include #include -#include #include #include #include -#include -#include #include #include @@ -33,38 +23,28 @@ #include #include #include -#include #include #include -#include #include -#include #include #include #include #include #include -#include #include #include -#include +#include #include #include #include #include #include -#include -#include -#include #include #include -#include #include #include -#include #include #include -#include #include #include @@ -74,15 +54,11 @@ #include #include #include -#include -#include #include #include -#include #include #include #include -#include #include #include #include diff --git a/src/test/app/LoanValidation_test.cpp b/src/test/app/LoanValidation_test.cpp index de5ce4dc9dc..653b7e1050b 100644 --- a/src/test/app/LoanValidation_test.cpp +++ b/src/test/app/LoanValidation_test.cpp @@ -1,4 +1,40 @@ #include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include namespace xrpl::test { diff --git a/src/test/app/Loan_test.cpp b/src/test/app/Loan_test.cpp index 09033058430..cb3d0ff231d 100644 --- a/src/test/app/Loan_test.cpp +++ b/src/test/app/Loan_test.cpp @@ -1,4 +1,34 @@ #include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include namespace xrpl::test { From c285e2db7ecb07465686c058a98c62529eeb03c5 Mon Sep 17 00:00:00 2001 From: Vito <5780819+Tapanito@users.noreply.github.com> Date: Thu, 30 Jul 2026 18:10:00 +0200 Subject: [PATCH 11/11] fix: post-merge issues --- include/xrpl/beast/unit_test/suite.h | 14 ++++++ .../app/{ => lending}/LendingHelpers_test.cpp | 0 .../app/{ => lending}/LoanBroker_test.cpp | 0 .../app/{ => lending}/LoanCashBasis_test.cpp | 2 +- .../LoanCoverFreezeAuth_test.cpp | 2 +- .../app/{ => lending}/LoanInvariants_test.cpp | 2 +- .../app/{ => lending}/LoanLifecycle_test.cpp | 2 +- src/test/app/{ => lending}/LoanMisc_test.cpp | 2 +- src/test/app/{ => lending}/LoanPay_test.cpp | 2 +- .../app/{ => lending}/LoanRounding_test.cpp | 2 +- .../app/{ => lending}/LoanSecurity_test.cpp | 2 +- .../LoanSet_test.cpp} | 6 +-- src/test/app/{ => lending}/LoanTestBase.h | 0 .../app/{ => lending}/LoanValidation_test.cpp | 2 +- src/test/app/lending/Loan_test.cpp | 46 +++++++++++++++++++ 15 files changed, 72 insertions(+), 12 deletions(-) rename src/test/app/{ => lending}/LendingHelpers_test.cpp (100%) rename src/test/app/{ => lending}/LoanBroker_test.cpp (100%) rename src/test/app/{ => lending}/LoanCashBasis_test.cpp (99%) rename src/test/app/{ => lending}/LoanCoverFreezeAuth_test.cpp (99%) rename src/test/app/{ => lending}/LoanInvariants_test.cpp (99%) rename src/test/app/{ => lending}/LoanLifecycle_test.cpp (99%) rename src/test/app/{ => lending}/LoanMisc_test.cpp (99%) rename src/test/app/{ => lending}/LoanPay_test.cpp (99%) rename src/test/app/{ => lending}/LoanRounding_test.cpp (99%) rename src/test/app/{ => lending}/LoanSecurity_test.cpp (99%) rename src/test/app/{Loan_test.cpp => lending/LoanSet_test.cpp} (99%) rename src/test/app/{ => lending}/LoanTestBase.h (100%) rename src/test/app/{ => lending}/LoanValidation_test.cpp (99%) create mode 100644 src/test/app/lending/Loan_test.cpp diff --git a/include/xrpl/beast/unit_test/suite.h b/include/xrpl/beast/unit_test/suite.h index c20fe2522c3..0ba714fdf39 100644 --- a/include/xrpl/beast/unit_test/suite.h +++ b/include/xrpl/beast/unit_test/suite.h @@ -295,6 +295,20 @@ class Suite return runner_->arg(); } +protected: + /** + * Return the runner this suite is executing under. + * + * Lets a suite compose other suites (e.g. an aggregator that reruns a + * group of related suites under its own name) via `SuiteInfo::run`. + */ + Runner& + runner() const + { + return *runner_; + } + +public: /** * DEPRECATED * @return `true` if the test condition indicates success(a false value) diff --git a/src/test/app/LendingHelpers_test.cpp b/src/test/app/lending/LendingHelpers_test.cpp similarity index 100% rename from src/test/app/LendingHelpers_test.cpp rename to src/test/app/lending/LendingHelpers_test.cpp diff --git a/src/test/app/LoanBroker_test.cpp b/src/test/app/lending/LoanBroker_test.cpp similarity index 100% rename from src/test/app/LoanBroker_test.cpp rename to src/test/app/lending/LoanBroker_test.cpp diff --git a/src/test/app/LoanCashBasis_test.cpp b/src/test/app/lending/LoanCashBasis_test.cpp similarity index 99% rename from src/test/app/LoanCashBasis_test.cpp rename to src/test/app/lending/LoanCashBasis_test.cpp index e766b9b68ad..7af75c86bdf 100644 --- a/src/test/app/LoanCashBasis_test.cpp +++ b/src/test/app/lending/LoanCashBasis_test.cpp @@ -1,4 +1,4 @@ -#include +#include #include #include #include diff --git a/src/test/app/LoanCoverFreezeAuth_test.cpp b/src/test/app/lending/LoanCoverFreezeAuth_test.cpp similarity index 99% rename from src/test/app/LoanCoverFreezeAuth_test.cpp rename to src/test/app/lending/LoanCoverFreezeAuth_test.cpp index 84c2598287c..4bff8c4c9ae 100644 --- a/src/test/app/LoanCoverFreezeAuth_test.cpp +++ b/src/test/app/lending/LoanCoverFreezeAuth_test.cpp @@ -1,4 +1,4 @@ -#include +#include #include #include #include diff --git a/src/test/app/LoanInvariants_test.cpp b/src/test/app/lending/LoanInvariants_test.cpp similarity index 99% rename from src/test/app/LoanInvariants_test.cpp rename to src/test/app/lending/LoanInvariants_test.cpp index 59e3540dd0f..59eb51fd54d 100644 --- a/src/test/app/LoanInvariants_test.cpp +++ b/src/test/app/lending/LoanInvariants_test.cpp @@ -1,4 +1,4 @@ -#include +#include #include #include #include diff --git a/src/test/app/LoanLifecycle_test.cpp b/src/test/app/lending/LoanLifecycle_test.cpp similarity index 99% rename from src/test/app/LoanLifecycle_test.cpp rename to src/test/app/lending/LoanLifecycle_test.cpp index f2e3fcaf1e0..f2da88bfa70 100644 --- a/src/test/app/LoanLifecycle_test.cpp +++ b/src/test/app/lending/LoanLifecycle_test.cpp @@ -1,4 +1,4 @@ -#include +#include #include #include #include diff --git a/src/test/app/LoanMisc_test.cpp b/src/test/app/lending/LoanMisc_test.cpp similarity index 99% rename from src/test/app/LoanMisc_test.cpp rename to src/test/app/lending/LoanMisc_test.cpp index bd8b03c5bbe..38a93bec96c 100644 --- a/src/test/app/LoanMisc_test.cpp +++ b/src/test/app/lending/LoanMisc_test.cpp @@ -1,4 +1,4 @@ -#include +#include #include #include #include diff --git a/src/test/app/LoanPay_test.cpp b/src/test/app/lending/LoanPay_test.cpp similarity index 99% rename from src/test/app/LoanPay_test.cpp rename to src/test/app/lending/LoanPay_test.cpp index 2ca001d0096..448f5dd81d1 100644 --- a/src/test/app/LoanPay_test.cpp +++ b/src/test/app/lending/LoanPay_test.cpp @@ -1,4 +1,4 @@ -#include +#include #include #include #include diff --git a/src/test/app/LoanRounding_test.cpp b/src/test/app/lending/LoanRounding_test.cpp similarity index 99% rename from src/test/app/LoanRounding_test.cpp rename to src/test/app/lending/LoanRounding_test.cpp index 8e8bdc01445..b9338528f13 100644 --- a/src/test/app/LoanRounding_test.cpp +++ b/src/test/app/lending/LoanRounding_test.cpp @@ -1,4 +1,4 @@ -#include +#include #include #include #include diff --git a/src/test/app/LoanSecurity_test.cpp b/src/test/app/lending/LoanSecurity_test.cpp similarity index 99% rename from src/test/app/LoanSecurity_test.cpp rename to src/test/app/lending/LoanSecurity_test.cpp index 4fd941bec17..9aac0722ea7 100644 --- a/src/test/app/LoanSecurity_test.cpp +++ b/src/test/app/lending/LoanSecurity_test.cpp @@ -1,4 +1,4 @@ -#include +#include #include #include #include diff --git a/src/test/app/Loan_test.cpp b/src/test/app/lending/LoanSet_test.cpp similarity index 99% rename from src/test/app/Loan_test.cpp rename to src/test/app/lending/LoanSet_test.cpp index cb3d0ff231d..85528ee9a03 100644 --- a/src/test/app/Loan_test.cpp +++ b/src/test/app/lending/LoanSet_test.cpp @@ -1,4 +1,4 @@ -#include +#include #include #include #include @@ -32,7 +32,7 @@ namespace xrpl::test { -class Loan_test : public LoanTestBase +class LoanSet_test : public LoanTestBase { private: void @@ -602,6 +602,6 @@ class Loan_test : public LoanTestBase } }; -BEAST_DEFINE_TESTSUITE(Loan, tx, xrpl); +BEAST_DEFINE_TESTSUITE(LoanSet, tx, xrpl); } // namespace xrpl::test diff --git a/src/test/app/LoanTestBase.h b/src/test/app/lending/LoanTestBase.h similarity index 100% rename from src/test/app/LoanTestBase.h rename to src/test/app/lending/LoanTestBase.h diff --git a/src/test/app/LoanValidation_test.cpp b/src/test/app/lending/LoanValidation_test.cpp similarity index 99% rename from src/test/app/LoanValidation_test.cpp rename to src/test/app/lending/LoanValidation_test.cpp index 653b7e1050b..d2985c4c30e 100644 --- a/src/test/app/LoanValidation_test.cpp +++ b/src/test/app/lending/LoanValidation_test.cpp @@ -1,4 +1,4 @@ -#include +#include #include #include #include diff --git a/src/test/app/lending/Loan_test.cpp b/src/test/app/lending/Loan_test.cpp new file mode 100644 index 00000000000..717387665e3 --- /dev/null +++ b/src/test/app/lending/Loan_test.cpp @@ -0,0 +1,46 @@ +#include +#include + +#include +#include +#include + +namespace xrpl::test { + +/** + * Aggregator: running this suite ("Loan") reruns every topical Loan/Lending + * suite in one invocation. Each member suite below remains independently + * runnable under its own name. Declared manual so an unfiltered full test + * run doesn't execute every case twice. + */ +class Loan_test : public beast::unit_test::Suite +{ + void + run() override + { + static constexpr std::array kMembers{ + "LendingHelpers", + "LoanBroker", + "LoanCashBasis", + "LoanCoverFreezeAuth", + "LoanInvariants", + "LoanLifecycle", + "LoanMisc", + "LoanPay", + "LoanRounding", + "LoanSecurity", + "LoanSet", + "LoanValidation", + }; + + for (auto const& info : beast::unit_test::globalSuites()) + { + if (std::ranges::find(kMembers, info.name()) != kMembers.end()) + info.run(runner()); + } + } +}; + +BEAST_DEFINE_TESTSUITE_MANUAL(Loan, tx, xrpl); + +} // namespace xrpl::test