From bcece6d6806bc1422b2a9dc6678eab40f64a876d Mon Sep 17 00:00:00 2001 From: Ed Hennis Date: Tue, 6 May 2025 18:19:55 -0400 Subject: [PATCH] Update to match latest spec: compute interest, LoanBroker reserves --- src/test/app/LoanBroker_test.cpp | 34 ++-- src/test/app/Loan_test.cpp | 79 +++++---- src/xrpld/app/misc/LendingHelpers.h | 121 +++++++++++++ src/xrpld/app/misc/detail/LendingHelpers.cpp | 29 ++++ .../app/tx/detail/LoanBrokerCoverDeposit.cpp | 5 +- .../app/tx/detail/LoanBrokerCoverWithdraw.cpp | 7 +- src/xrpld/app/tx/detail/LoanBrokerDelete.cpp | 13 +- src/xrpld/app/tx/detail/LoanBrokerSet.cpp | 28 +-- src/xrpld/app/tx/detail/LoanBrokerSet.h | 7 - src/xrpld/app/tx/detail/LoanDelete.cpp | 9 +- src/xrpld/app/tx/detail/LoanManage.cpp | 161 ++++++++++-------- src/xrpld/app/tx/detail/LoanSet.cpp | 99 +++++++---- src/xrpld/app/tx/detail/LoanSet.h | 15 -- 13 files changed, 397 insertions(+), 210 deletions(-) create mode 100644 src/xrpld/app/misc/LendingHelpers.h create mode 100644 src/xrpld/app/misc/detail/LendingHelpers.cpp diff --git a/src/test/app/LoanBroker_test.cpp b/src/test/app/LoanBroker_test.cpp index 26f3e24401..22fc4b6199 100644 --- a/src/test/app/LoanBroker_test.cpp +++ b/src/test/app/LoanBroker_test.cpp @@ -72,7 +72,7 @@ class LoanBroker_test : public beast::unit_test::suite using namespace loanBroker; // Can't create a loan broker regardless of whether the vault exists - env(set(alice, keylet.key), fee(increment), ter(temDISABLED)); + env(set(alice, keylet.key), ter(temDISABLED)); auto const brokerKeylet = keylet::loanbroker(alice.id(), env.seq(alice)); // Other LoanBroker transactions are disabled, too. @@ -129,7 +129,7 @@ class LoanBroker_test : public beast::unit_test::suite { // Start with default values - auto jtx = env.jt(set(alice, vault.vaultID), fee(increment)); + auto jtx = env.jt(set(alice, vault.vaultID)); // Modify as desired if (modifyJTx) jtx = modifyJTx(jtx); @@ -390,6 +390,8 @@ class LoanBroker_test : public beast::unit_test::suite env.close(); } + auto const aliceOriginalCount = env.ownerCount(alice); + // Create and update Loan Brokers for (auto const& vault : vaults) { @@ -397,73 +399,56 @@ class LoanBroker_test : public beast::unit_test::suite auto badKeylet = keylet::vault(alice.id(), env.seq(alice)); // Try some failure cases - // insufficient fee - env(set(evan, vault.vaultID), ter(telINSUF_FEE_P)); // not the vault owner - env(set(evan, vault.vaultID), - fee(increment), - ter(tecNO_PERMISSION)); + env(set(evan, vault.vaultID), ter(tecNO_PERMISSION)); // not a vault - env(set(alice, badKeylet.key), fee(increment), ter(tecNO_ENTRY)); + env(set(alice, badKeylet.key), ter(tecNO_ENTRY)); // flags are checked first - env(set(evan, vault.vaultID, ~tfUniversal), - fee(increment), - ter(temINVALID_FLAG)); + env(set(evan, vault.vaultID, ~tfUniversal), ter(temINVALID_FLAG)); // field length validation // sfData: good length, bad account env(set(evan, vault.vaultID), - fee(increment), data(std::string(maxDataPayloadLength, 'X')), ter(tecNO_PERMISSION)); // sfData: too long env(set(evan, vault.vaultID), - fee(increment), data(std::string(maxDataPayloadLength + 1, 'Y')), ter(temINVALID)); // sfManagementFeeRate: good value, bad account env(set(evan, vault.vaultID), managementFeeRate(maxManagementFeeRate), - fee(increment), ter(tecNO_PERMISSION)); // sfManagementFeeRate: too big env(set(evan, vault.vaultID), managementFeeRate(maxManagementFeeRate + TenthBips16(10)), - fee(increment), ter(temINVALID)); // sfCoverRateMinimum: good value, bad account env(set(evan, vault.vaultID), coverRateMinimum(maxCoverRate), - fee(increment), ter(tecNO_PERMISSION)); // sfCoverRateMinimum: too big env(set(evan, vault.vaultID), coverRateMinimum(maxCoverRate + 1), - fee(increment), ter(temINVALID)); // sfCoverRateLiquidation: good value, bad account env(set(evan, vault.vaultID), coverRateLiquidation(maxCoverRate), - fee(increment), ter(tecNO_PERMISSION)); // sfCoverRateLiquidation: too big env(set(evan, vault.vaultID), coverRateLiquidation(maxCoverRate + 1), - fee(increment), ter(temINVALID)); // sfDebtMaximum: good value, bad account env(set(evan, vault.vaultID), debtMaximum(Number(0)), - fee(increment), ter(tecNO_PERMISSION)); // sfDebtMaximum: overflow env(set(evan, vault.vaultID), debtMaximum(Number(1, 100)), - fee(increment), ter(temINVALID)); // sfDebtMaximum: negative env(set(evan, vault.vaultID), debtMaximum(Number(-1)), - fee(increment), ter(temINVALID)); std::string testData; @@ -486,6 +471,9 @@ class LoanBroker_test : public beast::unit_test::suite BEAST_EXPECT(broker->at(sfDebtMaximum) == 0); BEAST_EXPECT(broker->at(sfCoverRateMinimum) == 0); BEAST_EXPECT(broker->at(sfCoverRateLiquidation) == 0); + + BEAST_EXPECT( + env.ownerCount(alice) == aliceOriginalCount + 2); }, [&](SLE::const_ref broker) { // Modifications @@ -587,6 +575,8 @@ class LoanBroker_test : public beast::unit_test::suite BEAST_EXPECT(!broker->isFieldPresent(sfDebtMaximum)); }); } + + BEAST_EXPECT(env.ownerCount(alice) == aliceOriginalCount); } public: diff --git a/src/test/app/Loan_test.cpp b/src/test/app/Loan_test.cpp index b74eabcb12..73bce4ef02 100644 --- a/src/test/app/Loan_test.cpp +++ b/src/test/app/Loan_test.cpp @@ -23,6 +23,7 @@ #include #include +#include #include #include @@ -136,6 +137,8 @@ class Loan_test : public beast::unit_test::suite Number const& assetsAvailable, Number const& principalOutstanding, TenthBips32 interestRate, + std::uint32_t paymentInterval, + std::uint32_t paymentsRemaining, std::uint32_t ownerCount) const { using namespace jtx; @@ -144,10 +147,12 @@ class Loan_test : public beast::unit_test::suite { TenthBips16 const managementFeeRate{ brokerSle->at(sfManagementFeeRate)}; - auto const loanInterest = LoanInterestOutstanding( + auto const loanInterest = LoanInterestOutstandingToVault( broker.asset, principalOutstanding, interestRate, + paymentInterval, + paymentsRemaining, managementFeeRate); auto const expectedDebt = principalOutstanding + loanInterest; env.test.BEAST_EXPECT( @@ -204,8 +209,14 @@ class Loan_test : public beast::unit_test::suite env.test.BEAST_EXPECT(loan->at(sfFlags) == flags); auto const interestRate = TenthBips32{loan->at(sfInterestRate)}; + auto const paymentInterval = loan->at(sfPaymentInterval); checkBroker( - assetsAvailable, principalOutstanding, interestRate, 1); + assetsAvailable, + principalOutstanding, + interestRate, + paymentInterval, + paymentRemaining, + 1); if (auto brokerSle = env.le(keylet::loanbroker(broker.brokerID)); @@ -215,17 +226,20 @@ class Loan_test : public beast::unit_test::suite env.le(keylet::vault(brokerSle->at(sfVaultID))); env.test.BEAST_EXPECT(vaultSle)) { - if (flags & lsfLoanImpaired) + if ((flags & lsfLoanImpaired) && + !(flags & lsfLoanDefault)) { TenthBips32 const managementFeeRate{ brokerSle->at(sfManagementFeeRate)}; env.test.BEAST_EXPECT( vaultSle->at(sfLossUnrealized) == principalOutstanding + - LoanInterestOutstanding( + LoanInterestOutstandingToVault( broker.asset, principalOutstanding, interestRate, + paymentInterval, + paymentRemaining, managementFeeRate)); } else @@ -276,7 +290,7 @@ class Loan_test : public beast::unit_test::suite env, broker, pseudoAcct, keylet); // No loans yet - verifyLoanStatus.checkBroker(0, 0, TenthBips32{0}, 0); + verifyLoanStatus.checkBroker(0, 0, TenthBips32{0}, 1, 0, 0); if (!BEAST_EXPECT(loanSequence != 0)) return; @@ -294,6 +308,8 @@ class Loan_test : public beast::unit_test::suite using namespace loan; using namespace std::chrono_literals; + auto const borrowerOwnerCount = env.ownerCount(borrower); + auto const loanSetFee = fee(env.current()->fees().base * 2); Number const principalRequest = broker.asset(1000).value(); auto const startDate = env.now() + 3600s; @@ -409,18 +425,17 @@ class Loan_test : public beast::unit_test::suite 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, can't jump straight to default - env(manage(lender, keylet.key, tfLoanDefault), 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)); // Impair the loan env(manage(lender, keylet.key, tfLoanImpair)); // Unimpair the loan env(manage(lender, keylet.key, tfLoanUnimpair)); - auto const nextDueDate = hasExpired(*env.current(), interval) - ? env.current()->parentCloseTime().time_since_epoch().count() + - interval - : interval; + auto const nextDueDate = + startDate.time_since_epoch().count() + interval; env.close(); @@ -432,8 +447,6 @@ class Loan_test : public beast::unit_test::suite principalRequest, loanFlags | 0); - // TODO: Draw and make some payments - // Can't delete the loan yet. It has payments remaining. env(del(lender, keylet.key), ter(tecHAS_OBLIGATIONS)); @@ -469,11 +482,12 @@ class Loan_test : public beast::unit_test::suite env.close(); // No loans left - verifyLoanStatus.checkBroker(0, 0, interest, 0); + verifyLoanStatus.checkBroker(0, 0, interest, 1, 0, 0); BEAST_EXPECT( env.balance(borrower, broker.asset).value() == borrowerBalance.value() + assetsAvailable); + BEAST_EXPECT(env.ownerCount(borrower) == borrowerOwnerCount); if (auto const brokerSle = env.le(keylet::loanbroker(broker.brokerID)); BEAST_EXPECT(brokerSle)) @@ -962,13 +976,15 @@ class Loan_test : public beast::unit_test::suite Number assetsAvailable = 0; Number principalOutstanding = 0; std::uint32_t flags = 0; + std::uint32_t paymentInterval = 0; + if (auto loan = env.le(loanKeylet); BEAST_EXPECT(loan)) { previousPaymentDate = loan->at(sfPreviousPaymentDate); BEAST_EXPECT(previousPaymentDate == 0); nextPaymentDate = loan->at(sfNextPaymentDueDate); - BEAST_EXPECT(nextPaymentDate >= 600); - BEAST_EXPECT(nextPaymentDate < loan->at(sfStartDate)); + BEAST_EXPECT( + nextPaymentDate == loan->at(sfStartDate) + 600); paymentRemaining = loan->at(sfPaymentRemaining); BEAST_EXPECT(paymentRemaining == 12); assetsAvailable = loan->at(sfAssetsAvailable); @@ -977,6 +993,8 @@ class Loan_test : public beast::unit_test::suite principalOutstanding = loan->at(sfPrincipalOutstanding); BEAST_EXPECT( principalOutstanding == broker.asset(1000).value()); + paymentInterval = loan->at(sfPaymentInterval); + BEAST_EXPECT(paymentInterval == 600); flags = loan->at(sfFlags); BEAST_EXPECT(flags == baseFlag); } @@ -1024,7 +1042,6 @@ class Loan_test : public beast::unit_test::suite env(manage(lender, loanKeylet.key, tfLoanDefault)); flags |= tfLoanDefault; - flags &= ~tfLoanImpair; paymentRemaining = 0; assetsAvailable = 0; principalOutstanding = 0; @@ -1058,6 +1075,17 @@ class Loan_test : public beast::unit_test::suite tfLoanOverpayment, defaultBeforeStartDate(lsfLoanOverpayment)); + lifecycle( + "Loan overpayment prohibited - Default before start date", + env, + lender, + borrower, + evan, + broker, + pseudoAcct, + 0, + defaultBeforeStartDate(0)); + #if 0 lifecycle( "Loan overpayment allowed - Pay off", @@ -1072,24 +1100,13 @@ class Loan_test : public beast::unit_test::suite VerifyLoanStatus const& verifyLoanStatus) { // toEndOfLife // + // TODO: Draw and make some payments + // Make payments down to 0 // TODO: Try to impair a paid off loan }); -#endif - lifecycle( - "Loan overpayment prohibited - Default before start date", - env, - lender, - borrower, - evan, - broker, - pseudoAcct, - 0, - defaultBeforeStartDate(0)); - -#if 0 lifecycle( "Loan overpayment prohibited - Pay off", env, @@ -1103,6 +1120,8 @@ class Loan_test : public beast::unit_test::suite VerifyLoanStatus const& verifyLoanStatus) { // toEndOfLife // + // TODO: Draw and make some payments + // Make payments down to 0 // TODO: Try to impair a paid off loan diff --git a/src/xrpld/app/misc/LendingHelpers.h b/src/xrpld/app/misc/LendingHelpers.h new file mode 100644 index 0000000000..b0891f0a87 --- /dev/null +++ b/src/xrpld/app/misc/LendingHelpers.h @@ -0,0 +1,121 @@ +//------------------------------------------------------------------------------ +/* + This file is part of rippled: https://github.com/ripple/rippled + Copyright (c) 2025 Ripple Labs Inc. + + Permission to use, copy, modify, and/or distribute this software for any + purpose with or without fee is hereby granted, provided that the above + copyright notice and this permission notice appear in all copies. + + THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES + WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF + MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR + ANY SPECIAL , DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES + WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN + ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF + OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. +*/ +//============================================================================== + +#ifndef RIPPLE_APP_MISC_LENDINGHELPERS_H_INCLUDED +#define RIPPLE_APP_MISC_LENDINGHELPERS_H_INCLUDED + +#include +#include + +#include +#include + +namespace ripple { + +class PreflightContext; + +// Lending protocol has dependencies, so capture them here. +inline bool +LendingProtocolEnabled(PreflightContext const& ctx) +{ + return ctx.rules.enabled(featureLendingProtocol) && + VaultCreate::isEnabled(ctx); +} + +inline Number +LoanPeriodicRate(TenthBips32 interestRate, std::uint32_t paymentInterval) +{ + // Need floating point math for this one, since we're dividing by some large + // numbers + return tenthBipsOfValue(Number(paymentInterval), interestRate) / + (365 * 24 * 60 * 60); +} + +inline Number +LoanPeriodicPayment( + Number principalOutstanding, + TenthBips32 interestRate, + std::uint32_t paymentInterval, + std::uint32_t paymentsRemaining) +{ + if (principalOutstanding == 0 || paymentsRemaining == 0) + return 0; + Number const periodicRate = LoanPeriodicRate(interestRate, paymentInterval); + + // TODO: Need a better name + Number const timeFactor = power(1 + periodicRate, paymentsRemaining); + + return principalOutstanding * (periodicRate * timeFactor) / + (timeFactor - 1); +} + +inline Number +LoanTotalValueOutstanding( + Number principalOutstanding, + TenthBips32 interestRate, + std::uint32_t paymentInterval, + std::uint32_t paymentsRemaining) +{ + return LoanPeriodicPayment( + principalOutstanding, + interestRate, + paymentInterval, + paymentsRemaining) * + paymentsRemaining; +} + +inline Number +LoanTotalInterestOutstanding( + Number principalOutstanding, + TenthBips32 interestRate, + std::uint32_t paymentInterval, + std::uint32_t paymentsRemaining) +{ + return LoanTotalValueOutstanding( + principalOutstanding, + interestRate, + paymentInterval, + paymentsRemaining) - + principalOutstanding; +} + +template +Number +LoanInterestOutstandingToVault( + A const& asset, + Number principalOutstanding, + TenthBips32 interestRate, + std::uint32_t paymentInterval, + std::uint32_t paymentsRemaining, + TenthBips32 managementFeeRate) +{ + return roundToAsset( + asset, + tenthBipsOfValue( + LoanTotalInterestOutstanding( + principalOutstanding, + interestRate, + paymentInterval, + paymentsRemaining), + tenthBipsPerUnity - managementFeeRate)); +} + +} // namespace ripple + +#endif // RIPPLE_APP_MISC_LENDINGHELPERS_H_INCLUDED diff --git a/src/xrpld/app/misc/detail/LendingHelpers.cpp b/src/xrpld/app/misc/detail/LendingHelpers.cpp new file mode 100644 index 0000000000..78a9011f16 --- /dev/null +++ b/src/xrpld/app/misc/detail/LendingHelpers.cpp @@ -0,0 +1,29 @@ +//------------------------------------------------------------------------------ +/* + This file is part of rippled: https://github.com/ripple/rippled + Copyright (c) 2025 Ripple Labs Inc. + + Permission to use, copy, modify, and/or distribute this software for any + purpose with or without fee is hereby granted, provided that the above + copyright notice and this permission notice appear in all copies. + + THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES + WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF + MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR + ANY SPECIAL , DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES + WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN + ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF + OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. +*/ +//============================================================================== + +#include +// +#include +#include + +#include + +namespace ripple { + +} // namespace ripple diff --git a/src/xrpld/app/tx/detail/LoanBrokerCoverDeposit.cpp b/src/xrpld/app/tx/detail/LoanBrokerCoverDeposit.cpp index c051ff42fd..0ef285cf8a 100644 --- a/src/xrpld/app/tx/detail/LoanBrokerCoverDeposit.cpp +++ b/src/xrpld/app/tx/detail/LoanBrokerCoverDeposit.cpp @@ -18,7 +18,8 @@ //============================================================================== #include -#include +// +#include #include #include @@ -44,7 +45,7 @@ namespace ripple { bool LoanBrokerCoverDeposit::isEnabled(PreflightContext const& ctx) { - return lendingProtocolEnabled(ctx); + return LendingProtocolEnabled(ctx); } std::uint32_t diff --git a/src/xrpld/app/tx/detail/LoanBrokerCoverWithdraw.cpp b/src/xrpld/app/tx/detail/LoanBrokerCoverWithdraw.cpp index 9fb256f9ff..ead69cdb95 100644 --- a/src/xrpld/app/tx/detail/LoanBrokerCoverWithdraw.cpp +++ b/src/xrpld/app/tx/detail/LoanBrokerCoverWithdraw.cpp @@ -18,7 +18,8 @@ //============================================================================== #include -#include +// +#include #include #include @@ -44,7 +45,7 @@ namespace ripple { bool LoanBrokerCoverWithdraw::isEnabled(PreflightContext const& ctx) { - return lendingProtocolEnabled(ctx); + return LendingProtocolEnabled(ctx); } std::uint32_t @@ -122,7 +123,7 @@ LoanBrokerCoverWithdraw::preclaim(PreclaimContext const& ctx) if (accountHolds( ctx.view, - account, + pseudoAccountID, vaultAsset, FreezeHandling::fhZERO_IF_FROZEN, AuthHandling::ahZERO_IF_UNAUTHORIZED, diff --git a/src/xrpld/app/tx/detail/LoanBrokerDelete.cpp b/src/xrpld/app/tx/detail/LoanBrokerDelete.cpp index 9d0d9b9ec6..ef8fa5b671 100644 --- a/src/xrpld/app/tx/detail/LoanBrokerDelete.cpp +++ b/src/xrpld/app/tx/detail/LoanBrokerDelete.cpp @@ -18,7 +18,8 @@ //============================================================================== #include -#include +// +#include #include #include @@ -44,7 +45,7 @@ namespace ripple { bool LoanBrokerDelete::isEnabled(PreflightContext const& ctx) { - return lendingProtocolEnabled(ctx); + return LendingProtocolEnabled(ctx); } std::uint32_t @@ -168,6 +169,14 @@ LoanBrokerDelete::doApply() view().erase(broker); + { + auto owner = view().peek(keylet::account(account_)); + if (!owner) + return tefBAD_LEDGER; // LCOV_EXCL_LINE + + adjustOwnerCount(view(), owner, -2, j_); + } + return tesSUCCESS; } diff --git a/src/xrpld/app/tx/detail/LoanBrokerSet.cpp b/src/xrpld/app/tx/detail/LoanBrokerSet.cpp index c552336ea6..ffb83e90e1 100644 --- a/src/xrpld/app/tx/detail/LoanBrokerSet.cpp +++ b/src/xrpld/app/tx/detail/LoanBrokerSet.cpp @@ -18,6 +18,8 @@ //============================================================================== #include +// +#include #include #include #include @@ -43,17 +45,10 @@ namespace ripple { -bool -lendingProtocolEnabled(PreflightContext const& ctx) -{ - return ctx.rules.enabled(featureLendingProtocol) && - VaultCreate::isEnabled(ctx); -} - bool LoanBrokerSet::isEnabled(PreflightContext const& ctx) { - return lendingProtocolEnabled(ctx); + return LendingProtocolEnabled(ctx); } std::uint32_t @@ -92,15 +87,6 @@ LoanBrokerSet::doPreflight(PreflightContext const& ctx) return tesSUCCESS; } -XRPAmount -LoanBrokerSet::calculateBaseFee(ReadView const& view, STTx const& tx) -{ - // One reserve increment is typically much greater than one base fee. - if (!tx.isFieldPresent(sfLoanBrokerID)) - return calculateOwnerReserveFee(view, tx); - return Transactor::calculateBaseFee(view, tx); -} - TER LoanBrokerSet::preclaim(PreclaimContext const& ctx) { @@ -186,14 +172,10 @@ LoanBrokerSet::doApply() if (auto const ter = dirLink(view, vaultPseudoID, broker, sfVaultNode)) return ter; - /* We're already charging a higher fee, so we probably don't want to - also charge a reserve. - * - adjustOwnerCount(view, owner, 1, j_); - auto ownerCount = owner->at(sfOwnerCount); + adjustOwnerCount(view, owner, 2, j_); + auto const ownerCount = owner->at(sfOwnerCount); if (mPriorBalance < view.fees().accountReserve(ownerCount)) return tecINSUFFICIENT_RESERVE; - */ auto maybePseudo = createPseudoAccount(view, broker->key(), sfLoanBrokerID); diff --git a/src/xrpld/app/tx/detail/LoanBrokerSet.h b/src/xrpld/app/tx/detail/LoanBrokerSet.h index 6eef736e28..211fc5382e 100644 --- a/src/xrpld/app/tx/detail/LoanBrokerSet.h +++ b/src/xrpld/app/tx/detail/LoanBrokerSet.h @@ -24,10 +24,6 @@ namespace ripple { -// Lending protocol has dependencies, so capture them here. -bool -lendingProtocolEnabled(PreflightContext const& ctx); - class LoanBrokerSet : public Transactor { public: @@ -46,9 +42,6 @@ public: static NotTEC doPreflight(PreflightContext const& ctx); - static XRPAmount - calculateBaseFee(ReadView const& view, STTx const& tx); - static TER preclaim(PreclaimContext const& ctx); diff --git a/src/xrpld/app/tx/detail/LoanDelete.cpp b/src/xrpld/app/tx/detail/LoanDelete.cpp index ff756367e4..207986653a 100644 --- a/src/xrpld/app/tx/detail/LoanDelete.cpp +++ b/src/xrpld/app/tx/detail/LoanDelete.cpp @@ -19,7 +19,7 @@ #include // -#include +#include #include #include @@ -47,7 +47,7 @@ namespace ripple { bool LoanDelete::isEnabled(PreflightContext const& ctx) { - return lendingProtocolEnabled(ctx); + return LendingProtocolEnabled(ctx); } std::uint32_t @@ -110,6 +110,9 @@ LoanDelete::doApply() if (!loanSle) return tefBAD_LEDGER; // LCOV_EXCL_LINE auto const borrower = loanSle->at(sfBorrower); + auto const borrowerSle = view.peek(keylet::account(borrower)); + if (!borrowerSle) + return tefBAD_LEDGER; // LCOV_EXCL_LINE auto const brokerID = loanSle->at(sfLoanBrokerID); auto const brokerSle = view.peek(keylet::loanbroker(brokerID)); @@ -156,6 +159,8 @@ LoanDelete::doApply() // Decrement the LoanBroker's owner count. adjustOwnerCount(view, brokerSle, -1, j_); + // Decrement the borrower's owner count + adjustOwnerCount(view, borrowerSle, -1, j_); return tesSUCCESS; } diff --git a/src/xrpld/app/tx/detail/LoanManage.cpp b/src/xrpld/app/tx/detail/LoanManage.cpp index e55908a4f9..70d78abbe8 100644 --- a/src/xrpld/app/tx/detail/LoanManage.cpp +++ b/src/xrpld/app/tx/detail/LoanManage.cpp @@ -19,7 +19,7 @@ #include // -#include +#include #include #include #include @@ -48,7 +48,7 @@ namespace ripple { bool LoanManage::isEnabled(PreflightContext const& ctx) { - return lendingProtocolEnabled(ctx); + return LendingProtocolEnabled(ctx); } std::uint32_t @@ -99,6 +99,7 @@ LoanManage::preclaim(PreclaimContext const& ctx) // Impairment only allows certain transitions. // 1. Once it's in default, it can't be changed. // 2. It can get worse: unimpaired -> impaired -> default + // or unimpaired -> default // 3. It can get better: impaired -> unimpaired // 4. If it's in a state, it can't be put in that state again. if (loanSle->isFlag(lsfLoanDefault)) @@ -115,10 +116,10 @@ LoanManage::preclaim(PreclaimContext const& ctx) } if (!(loanSle->isFlag(lsfLoanImpaired) || loanSle->isFlag(lsfLoanDefault)) && - (tx.isFlag(tfLoanDefault) || tx.isFlag(tfLoanUnimpair))) + (tx.isFlag(tfLoanUnimpair))) { JLOG(ctx.j.warn()) - << "Loan is unimpaired. Only valid modification is to impair"; + << "Loan is unimpaired. Can not be unimpaired again."; return tecNO_PERMISSION; } if (loanSle->at(sfPaymentRemaining) == 0) @@ -133,8 +134,7 @@ LoanManage::preclaim(PreclaimContext const& ctx) loanSle->at(sfNextPaymentDueDate) + loanSle->at(sfGracePeriod))) { JLOG(ctx.j.warn()) - << "Loan is not in default. A loan can not be defaulted before the " - "next payment due date."; + << "A loan can not be defaulted before the next payment due date."; return tecTOO_SOON; } @@ -163,19 +163,20 @@ defaultLoan( SLE::ref vaultSle, Number const& principalOutstanding, Number const& interestOutstanding, + std::uint32_t paymentInterval, Asset const& vaultAsset, beast::Journal j) { // Calculate the amount of the Default that First-Loss Capital covers: TenthBips32 const managementFeeRate{brokerSle->at(sfManagementFeeRate)}; - auto debtTotalProxy = brokerSle->at(sfDebtTotal); + auto brokerDebtTotalProxy = brokerSle->at(sfDebtTotal); auto const totalDefaultAmount = principalOutstanding + interestOutstanding; // The default Amount equals the outstanding principal and interest, // excluding any funds unclaimed by the Borrower. - auto assetsAvailableProxy = loanSle->at(sfAssetsAvailable); - auto const defaultAmount = totalDefaultAmount - assetsAvailableProxy; + auto loanAssetsAvailableProxy = loanSle->at(sfAssetsAvailable); + auto const defaultAmount = totalDefaultAmount - loanAssetsAvailableProxy; // Apply the First-Loss Capital to the Default Amount TenthBips32 const coverRateMinimum{brokerSle->at(sfCoverRateMinimum)}; TenthBips32 const coverRateLiquidation{ @@ -184,71 +185,78 @@ defaultLoan( vaultAsset, std::min( tenthBipsOfValue( - tenthBipsOfValue(debtTotalProxy.value(), coverRateMinimum), + tenthBipsOfValue( + brokerDebtTotalProxy.value(), coverRateMinimum), coverRateLiquidation), defaultAmount)); - if (STAmount{vaultAsset, defaultCovered} != defaultCovered) - { - JLOG(j.warn()) - << "LoanManage: defaultCovered amount is not a valid amount"; - return tefBAD_LEDGER; - } - auto const defaultReturned = defaultCovered + assetsAvailableProxy; + auto const returnToVault = defaultCovered + loanAssetsAvailableProxy; auto const vaultDefaultAmount = defaultAmount - defaultCovered; - // Update the LoanBroker object: - - // Decrease the Debt of the LoanBroker: - if (debtTotalProxy < totalDefaultAmount) - { - JLOG(j.warn()) - << "LoanBroker debt total is less than the default amount"; - return tefBAD_LEDGER; - } - debtTotalProxy -= totalDefaultAmount; - // Decrease the First-Loss Capital Cover Available: - auto coverAvailableProxy = brokerSle->at(sfCoverAvailable); - if (coverAvailableProxy < defaultCovered) - { - JLOG(j.warn()) - << "LoanBroker cover available is less than amount covered"; - return tefBAD_LEDGER; - } - coverAvailableProxy -= defaultCovered; - view.update(brokerSle); - - // Update the Loan object: - loanSle->setFlag(lsfLoanDefault); - loanSle->clearFlag(lsfLoanImpaired); - loanSle->at(sfPaymentRemaining) = 0; - assetsAvailableProxy = 0; - loanSle->at(sfPrincipalOutstanding) = 0; - view.update(loanSle); - // Update the Vault object: - // Decrease the Total Value of the Vault: - auto vaultAssetsTotalProxy = vaultSle->at(sfAssetsTotal); - if (vaultAssetsTotalProxy < vaultDefaultAmount) { - JLOG(j.warn()) - << "Vault total assets is less than the vault default amount"; - return tefBAD_LEDGER; + // Decrease the Total Value of the Vault: + auto vaultAssetsTotalProxy = vaultSle->at(sfAssetsTotal); + if (vaultAssetsTotalProxy < vaultDefaultAmount) + { + // LCOV_EXCL_START + JLOG(j.warn()) + << "Vault total assets is less than the vault default amount"; + return tefBAD_LEDGER; + // LCOV_EXCL_STOP + } + vaultAssetsTotalProxy -= vaultDefaultAmount; + // Increase the Asset Available of the Vault by liquidated First-Loss + // Capital and any unclaimed funds amount: + vaultSle->at(sfAssetsAvailable) += returnToVault; + // The loss has been realized + if (loanSle->isFlag(lsfLoanImpaired)) + { + auto vaultLossUnrealizedProxy = vaultSle->at(sfLossUnrealized); + if (vaultLossUnrealizedProxy < totalDefaultAmount) + { + JLOG(j.warn()) + << "Vault unrealized loss is less than the default amount"; + return tefBAD_LEDGER; + } + vaultLossUnrealizedProxy -= totalDefaultAmount; + } + view.update(vaultSle); } - vaultAssetsTotalProxy -= vaultDefaultAmount; - // Increase the Asset Available of the Vault by liquidated First-Loss - // Capital and any unclaimed funds amount: - vaultSle->at(sfAssetsAvailable) += defaultReturned; - // The loss has been realized - auto vaultLossUnrealizedProxy = vaultSle->at(sfLossUnrealized); - if (vaultLossUnrealizedProxy < totalDefaultAmount) + + // Update the LoanBroker object: + { - JLOG(j.warn()) - << "Vault unrealized loss is less than the default amount"; - return tefBAD_LEDGER; + // Decrease the Debt of the LoanBroker: + if (brokerDebtTotalProxy < totalDefaultAmount) + { + // LCOV_EXCL_START + JLOG(j.warn()) + << "LoanBroker debt total is less than the default amount"; + return tefBAD_LEDGER; + // LCOV_EXCL_STOP + } + brokerDebtTotalProxy -= totalDefaultAmount; + // Decrease the First-Loss Capital Cover Available: + auto coverAvailableProxy = brokerSle->at(sfCoverAvailable); + if (coverAvailableProxy < defaultCovered) + { + // LCOV_EXCL_START + JLOG(j.warn()) + << "LoanBroker cover available is less than amount covered"; + return tefBAD_LEDGER; + // LCOV_EXCL_STOP + } + coverAvailableProxy -= defaultCovered; + view.update(brokerSle); } - vaultLossUnrealizedProxy -= totalDefaultAmount; - view.update(vaultSle); + + // Update the Loan object: + loanSle->setFlag(lsfLoanDefault); + loanSle->at(sfPaymentRemaining) = 0; + loanAssetsAvailableProxy = 0; + loanSle->at(sfPrincipalOutstanding) = 0; + view.update(loanSle); // Return funds from the LoanBroker pseudo-account to the // Vault pseudo-account: @@ -256,7 +264,7 @@ defaultLoan( view, brokerSle->at(sfAccount), vaultSle->at(sfAccount), - STAmount{vaultAsset, defaultReturned}, + STAmount{vaultAsset, returnToVault}, j, WaiveTransferFee::Yes); } @@ -269,6 +277,7 @@ impairLoan( SLE::ref vaultSle, Number const& principalOutstanding, Number const& interestOutstanding, + std::uint32_t paymentInterval, Asset const& vaultAsset, beast::Journal j) { @@ -279,12 +288,12 @@ impairLoan( // Update the Loan object loanSle->setFlag(lsfLoanImpaired); - auto nextDueProxy = loanSle->at(sfNextPaymentDueDate); - if (!hasExpired(view, nextDueProxy)) + auto loanNextDueProxy = loanSle->at(sfNextPaymentDueDate); + if (!hasExpired(view, loanNextDueProxy)) { // loan payment is not yet late - // move the next payment due date to now - nextDueProxy = view.parentCloseTime().time_since_epoch().count(); + loanNextDueProxy = view.parentCloseTime().time_since_epoch().count(); } view.update(loanSle); @@ -299,6 +308,7 @@ unimpairLoan( SLE::ref vaultSle, Number const& principalOutstanding, Number const& interestOutstanding, + std::uint32_t paymentInterval, Asset const& vaultAsset, beast::Journal j) { @@ -307,18 +317,20 @@ unimpairLoan( auto const lossReversed = principalOutstanding + interestOutstanding; if (vaultLossUnrealizedProxy < lossReversed) { + // LCOV_EXCL_START JLOG(j.warn()) << "Vault unrealized loss is less than the amount to be cleared"; return tefBAD_LEDGER; + // LCOV_EXCL_STOP } vaultLossUnrealizedProxy -= lossReversed; view.update(vaultSle); // Update the Loan object loanSle->clearFlag(lsfLoanImpaired); - auto const paymentInterval = loanSle->at(sfPaymentInterval); auto const normalPaymentDueDate = - loanSle->at(sfPreviousPaymentDate) + paymentInterval; + std::max(loanSle->at(sfPreviousPaymentDate), loanSle->at(sfStartDate)) + + paymentInterval; if (!hasExpired(view, normalPaymentDueDate)) { // loan was unimpaired within the payment interval @@ -360,10 +372,14 @@ LoanManage::doApply() auto const principalOutstanding = loanSle->at(sfPrincipalOutstanding); TenthBips32 const managementFeeRate{brokerSle->at(sfManagementFeeRate)}; - auto const interestOutstanding = LoanInterestOutstanding( + auto const paymentInterval = loanSle->at(sfPaymentInterval); + auto const paymentsRemaining = loanSle->at(sfPaymentRemaining); + auto const interestOutstanding = LoanInterestOutstandingToVault( vaultAsset, principalOutstanding.value(), interestRate, + paymentInterval, + paymentsRemaining, managementFeeRate); // Valid flag combinations are checked in preflight. No flags is valid - @@ -377,6 +393,7 @@ LoanManage::doApply() vaultSle, principalOutstanding, interestOutstanding, + paymentInterval, vaultAsset, j_)) return ter; @@ -390,6 +407,7 @@ LoanManage::doApply() vaultSle, principalOutstanding, interestOutstanding, + paymentInterval, vaultAsset, j_)) return ter; @@ -403,6 +421,7 @@ LoanManage::doApply() vaultSle, principalOutstanding, interestOutstanding, + paymentInterval, vaultAsset, j_)) return ter; diff --git a/src/xrpld/app/tx/detail/LoanSet.cpp b/src/xrpld/app/tx/detail/LoanSet.cpp index e5b770db2c..112a1bce13 100644 --- a/src/xrpld/app/tx/detail/LoanSet.cpp +++ b/src/xrpld/app/tx/detail/LoanSet.cpp @@ -19,7 +19,7 @@ #include // -#include +#include #include #include #include @@ -49,7 +49,7 @@ namespace ripple { bool LoanSet::isEnabled(PreflightContext const& ctx) { - return lendingProtocolEnabled(ctx); + return LendingProtocolEnabled(ctx); } std::uint32_t @@ -199,7 +199,7 @@ LoanSet::preclaim(PreclaimContext const& ctx) if (!vault) // Should be impossible return tefBAD_LEDGER; // LCOV_EXCL_LINE - auto const asset = vault->at(sfAsset); + Asset const asset = vault->at(sfAsset); if (auto const ter = canAddHolding(ctx.view, asset)) return ter; @@ -215,6 +215,8 @@ LoanSet::preclaim(PreclaimContext const& ctx) auto const issue = asset.get(); if (isDeepFrozen(ctx.view, borrower, issue.currency, issue.account)) return tecFROZEN; + if (isDeepFrozen(ctx.view, brokerPseudo, issue.currency, issue.account)) + return tecFROZEN; } auto const principalRequested = tx[sfPrincipalRequested]; @@ -225,23 +227,52 @@ LoanSet::preclaim(PreclaimContext const& ctx) << "Insufficient assets available in the Vault to fund the loan."; return tecINSUFFICIENT_FUNDS; } - auto const debtTotal = brokerSle->at(sfDebtTotal); - if (brokerSle->at(sfDebtMaximum) < debtTotal + principalRequested) + auto const newDebtTotal = brokerSle->at(sfDebtTotal) + principalRequested; + if (brokerSle->at(sfDebtMaximum) < newDebtTotal) { JLOG(ctx.j.warn()) << "Loan would exceed the maximum debt limit of the LoanBroker."; return tecLIMIT_EXCEEDED; } + TenthBips32 const coverRateMinimum{brokerSle->at(sfCoverRateMinimum)}; if (brokerSle->at(sfCoverAvailable) < - tenthBipsOfValue( - debtTotal + principalRequested, - TenthBips32(brokerSle->at(sfCoverRateMinimum)))) + tenthBipsOfValue(newDebtTotal, coverRateMinimum)) { JLOG(ctx.j.warn()) << "Insufficient first-loss capital to cover the loan."; return tecINSUFFICIENT_FUNDS; } + // Check that the lender will not make a profit on the lending fee if the + // loan defaults. (Not yet in spec. May not be included.) + TenthBips32 const interestRate{tx[~sfInterestRate].value_or(0)}; + auto const paymentInterval = + tx[~sfPaymentInterval].value_or(defaultPaymentInterval); + auto const paymentTotal = tx[~sfPaymentTotal].value_or(defaultPaymentTotal); + TenthBips32 const coverRateLiquidation{ + brokerSle->at(sfCoverRateLiquidation)}; + TenthBips32 const managementFeeRate{brokerSle->at(sfManagementFeeRate)}; + + auto const totalInterestToVault = LoanInterestOutstandingToVault( + asset, + principalRequested, + interestRate, + paymentInterval, + paymentTotal, + managementFeeRate); + + auto const maximumOriginationFee = tenthBipsOfValue( + tenthBipsOfValue(newDebtTotal, coverRateMinimum), coverRateLiquidation); + + if (auto const originationFee = tx[~sfLoanOriginationFee]; + originationFee && *originationFee > maximumOriginationFee) + { + JLOG(ctx.j.warn()) + << "Loan origination fee is too high. The lender will make a " + "profit on the lending fee if the loan defaults."; + return tecINSUFFICIENT_FUNDS; + } + return tesSUCCESS; } @@ -258,12 +289,14 @@ LoanSet::doApply() return tefBAD_LEDGER; // LCOV_EXCL_LINE auto const brokerOwner = brokerSle->at(sfOwner); auto const brokerOwnerSle = view.peek(keylet::account(brokerOwner)); + if (!brokerOwnerSle) + return tefBAD_LEDGER; // LCOV_EXCL_LINE auto const vaultSle = view.peek(keylet ::vault(brokerSle->at(sfVaultID))); if (!vaultSle) return tefBAD_LEDGER; // LCOV_EXCL_LINE auto const vaultPseudo = vaultSle->at(sfAccount); - auto const vaultAsset = vaultSle->at(sfAsset); + Asset const vaultAsset = vaultSle->at(sfAsset); auto const counterparty = tx[~sfCounterparty].value_or(brokerOwner); auto const borrower = counterparty == brokerOwner ? account_ : counterparty; @@ -275,6 +308,10 @@ LoanSet::doApply() auto const brokerPseudo = brokerSle->at(sfAccount); auto const brokerPseudoSle = view.peek(keylet::account(brokerPseudo)); + if (!brokerPseudoSle) + { + return tefBAD_LEDGER; // LCOV_EXCL_LINE + } auto const principalRequested = tx[sfPrincipalRequested]; TenthBips32 const interestRate{tx[~sfInterestRate].value_or(0)}; auto const originationFee = tx[~sfLoanOriginationFee]; @@ -292,19 +329,6 @@ LoanSet::doApply() // // 1. Transfer loanAssetsAvailable (principalRequested - originationFee) // from vault pseudo-account to LoanBroker pseudo-account. - // - // Create the holding if it doesn't already exist (necessary for MPTs) - if (auto const ter = addEmptyHolding( - view, - brokerPseudo, - brokerPseudoSle->at(sfBalance).value().xrp(), - vaultAsset, - j_); - !isTesSuccess(ter) && ter != tecDUPLICATE) - // ignore tecDUPLICATE. That means the holding already exists, and is - // fine here - return ter; - // 1a. Transfer the loanAssetsAvailable to the pseudo-account if (auto const ter = accountSend( view, vaultPseudo, @@ -317,7 +341,8 @@ LoanSet::doApply() // LoanBroker owner. if (originationFee) { - // Create the holding if it doesn't already exist (necessary for MPTs) + // Create the holding if it doesn't already exist (necessary for MPTs). + // The owner may have deleted their MPT / line at some point. if (auto const ter = addEmptyHolding( view, brokerOwner, @@ -338,23 +363,32 @@ LoanSet::doApply() return ter; } + auto const paymentInterval = + tx[~sfPaymentInterval].value_or(defaultPaymentInterval); + auto const paymentTotal = tx[~sfPaymentTotal].value_or(defaultPaymentTotal); TenthBips32 const managementFeeRate{brokerSle->at(sfManagementFeeRate)}; // The portion of the loan interest that will go to the vault (total // interest minus the management fee) - auto const loanInterestToVault = LoanInterestOutstanding( - vaultAsset, principalRequested, interestRate, managementFeeRate); + auto const loanInterestToVault = LoanInterestOutstandingToVault( + vaultAsset, + principalRequested, + interestRate, + paymentInterval, + paymentTotal, + managementFeeRate); auto const startDate = tx[sfStartDate]; - auto const paymentInterval = - tx[~sfPaymentInterval].value_or(defaultPaymentInterval); auto loanSequence = brokerSle->at(sfLoanSequence); // Create the loan auto loan = std::make_shared(keylet::loan(brokerID, *loanSequence)); // Prevent copy/paste errors - auto setLoanField = [&loan, &tx](auto const& field) { - loan->at(field) = tx[field].value_or(0); - }; + auto setLoanField = + [&loan, &tx](auto const& field, std::uint32_t const defValue = 0) { + // at() is smart enough to unseat a default field set to the default + // value + loan->at(field) = tx[field].value_or(defValue); + }; // Set required tx fields and pre-computed fields loan->at(sfPrincipalOutstanding) = principalRequested; @@ -375,12 +409,11 @@ LoanSet::doApply() setLoanField(~sfLateInterestRate); setLoanField(~sfCloseInterestRate); setLoanField(~sfOverpaymentInterestRate); - loan->at(sfGracePeriod) = tx[~sfGracePeriod].value_or(defaultGracePeriod); + setLoanField(~sfGracePeriod, defaultGracePeriod); // Set dynamic fields to their initial values loan->at(sfPreviousPaymentDate) = 0; loan->at(sfNextPaymentDueDate) = startDate + paymentInterval; - loan->at(sfPaymentRemaining) = - tx[~sfPaymentTotal].value_or(defaultPaymentTotal); + loan->at(sfPaymentRemaining) = paymentTotal; loan->at(sfAssetsAvailable) = loanAssetsAvailable; loan->at(sfPrincipalOutstanding) = principalRequested; view.insert(loan); diff --git a/src/xrpld/app/tx/detail/LoanSet.h b/src/xrpld/app/tx/detail/LoanSet.h index ec6c4f2d4d..888c1343ff 100644 --- a/src/xrpld/app/tx/detail/LoanSet.h +++ b/src/xrpld/app/tx/detail/LoanSet.h @@ -24,21 +24,6 @@ namespace ripple { -template -Number -LoanInterestOutstanding( - A const& asset, - Number principalOutstanding, - TenthBips32 interestRate, - TenthBips32 managementFeeRate) -{ - return roundToAsset( - asset, - tenthBipsOfValue( - tenthBipsOfValue(principalOutstanding, interestRate), - tenthBipsPerUnity - managementFeeRate)); -} - class LoanSet : public Transactor { public: