diff --git a/include/xrpl/basics/Number.h b/include/xrpl/basics/Number.h index 2911d06110..60ea23a0a5 100644 --- a/include/xrpl/basics/Number.h +++ b/include/xrpl/basics/Number.h @@ -32,6 +32,15 @@ class Number; std::string to_string(Number const& amount); +template +constexpr bool +isPowerOfTen(T value) +{ + while (value >= 10 && value % 10 == 0) + value /= 10; + return value == 1; +} + class Number { using rep = std::int64_t; @@ -41,7 +50,9 @@ class Number public: // The range for the mantissa when normalized constexpr static std::int64_t minMantissa = 1'000'000'000'000'000LL; - constexpr static std::int64_t maxMantissa = 9'999'999'999'999'999LL; + static_assert(isPowerOfTen(minMantissa)); + constexpr static std::int64_t maxMantissa = minMantissa * 10 - 1; + static_assert(maxMantissa == 9'999'999'999'999'999LL); // The range for the exponent when normalized constexpr static int minExponent = -32768; @@ -58,8 +69,6 @@ public: explicit Number(rep mantissa, int exponent); explicit constexpr Number(rep mantissa, int exponent, unchecked) noexcept; - static Number const zero; - constexpr rep mantissa() const noexcept; constexpr int @@ -153,22 +162,7 @@ public: } Number - truncate() const noexcept - { - if (exponent_ >= 0 || mantissa_ == 0) - return *this; - - Number ret = *this; - while (ret.exponent_ < 0 && ret.mantissa_ != 0) - { - ret.exponent_ += 1; - ret.mantissa_ /= rep(10); - } - // We are guaranteed that normalize() will never throw an exception - // because exponent is either negative or zero at this point. - ret.normalize(); - return ret; - } + truncate() const noexcept; friend constexpr bool operator>(Number const& x, Number const& y) noexcept @@ -213,6 +207,8 @@ private: class Guard; }; +constexpr static Number numZero{}; + inline constexpr Number::Number(rep mantissa, int exponent, unchecked) noexcept : mantissa_{mantissa}, exponent_{exponent} { diff --git a/include/xrpl/protocol/Protocol.h b/include/xrpl/protocol/Protocol.h index 5c443d387d..c8e0f77d7d 100644 --- a/include/xrpl/protocol/Protocol.h +++ b/include/xrpl/protocol/Protocol.h @@ -102,7 +102,9 @@ std::uint16_t constexpr maxTransferFee = 50000; * Example: 50% is 0.50 * bipsPerUnity = 5,000 bps. */ Bips32 constexpr bipsPerUnity(100 * 100); +static_assert(bipsPerUnity == Bips32{10'000}); TenthBips32 constexpr tenthBipsPerUnity(bipsPerUnity.value() * 10); +static_assert(tenthBipsPerUnity == TenthBips32(100'000)); constexpr Bips32 percentageToBips(std::uint32_t percentage) diff --git a/include/xrpl/protocol/STAmount.h b/include/xrpl/protocol/STAmount.h index 796a1fb930..fc1835fe72 100644 --- a/include/xrpl/protocol/STAmount.h +++ b/include/xrpl/protocol/STAmount.h @@ -66,16 +66,18 @@ public: static int const cMaxOffset = 80; // Maximum native value supported by the code - static std::uint64_t const cMinValue = 1'000'000'000'000'000ull; - static std::uint64_t const cMaxValue = 9'999'999'999'999'999ull; - static std::uint64_t const cMaxNative = 9'000'000'000'000'000'000ull; + constexpr static std::uint64_t cMinValue = 1'000'000'000'000'000ull; + static_assert(isPowerOfTen(cMinValue)); + constexpr static std::uint64_t cMaxValue = cMinValue * 10 - 1; + static_assert(cMaxValue == 9'999'999'999'999'999ull); + constexpr static std::uint64_t cMaxNative = 9'000'000'000'000'000'000ull; // Max native value on network. - static std::uint64_t const cMaxNativeN = 100'000'000'000'000'000ull; - static std::uint64_t const cIssuedCurrency = 0x8'000'000'000'000'000ull; - static std::uint64_t const cPositive = 0x4'000'000'000'000'000ull; - static std::uint64_t const cMPToken = 0x2'000'000'000'000'000ull; - static std::uint64_t const cValueMask = ~(cPositive | cMPToken); + constexpr static std::uint64_t cMaxNativeN = 100'000'000'000'000'000ull; + constexpr static std::uint64_t cIssuedCurrency = 0x8'000'000'000'000'000ull; + constexpr static std::uint64_t cPositive = 0x4'000'000'000'000'000ull; + constexpr static std::uint64_t cMPToken = 0x2'000'000'000'000'000ull; + constexpr static std::uint64_t cValueMask = ~(cPositive | cMPToken); static std::uint64_t const uRateOne; diff --git a/src/libxrpl/basics/Number.cpp b/src/libxrpl/basics/Number.cpp index 7c3ea2d3cc..228def4720 100644 --- a/src/libxrpl/basics/Number.cpp +++ b/src/libxrpl/basics/Number.cpp @@ -43,8 +43,6 @@ namespace ripple { thread_local Number::rounding_mode Number::mode_ = Number::to_nearest; -Number const Number::zero{}; - Number::rounding_mode Number::getround() { @@ -523,6 +521,24 @@ Number::operator rep() const return drops; } +Number +Number::truncate() const noexcept +{ + if (exponent_ >= 0 || mantissa_ == 0) + return *this; + + Number ret = *this; + while (ret.exponent_ < 0 && ret.mantissa_ != 0) + { + ret.exponent_ += 1; + ret.mantissa_ /= rep(10); + } + // We are guaranteed that normalize() will never throw an exception + // because exponent is either negative or zero at this point. + ret.normalize(); + return ret; +} + std::string to_string(Number const& amount) { diff --git a/src/test/app/Loan_test.cpp b/src/test/app/Loan_test.cpp index a0fdf9eb4e..261a139c72 100644 --- a/src/test/app/Loan_test.cpp +++ b/src/test/app/Loan_test.cpp @@ -17,6 +17,8 @@ */ //============================================================================== +#include +// #include #include @@ -24,7 +26,7 @@ #include #include -#include +#include #include namespace ripple { @@ -32,21 +34,13 @@ namespace test { 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::testable_amendments() | featureMPTokensV1 | featureSingleAssetVault | featureLendingProtocol}; - static constexpr auto const coverDepositParameter = 1000; - static constexpr auto const coverRateMinParameter = - percentageToTenthBips(10); - static constexpr auto const coverRateLiquidationParameter = - percentageToTenthBips(25); - static constexpr auto const maxCoveredLoanValue = 1000 * 100 / 10; - static constexpr auto const vaultDeposit = 1'000'000; - static constexpr auto const debtMaximumParameter = 25'000; - static constexpr TenthBips16 const managementFeeRateParameter{100}; std::string const iouCurrency{"IOU"}; void @@ -98,23 +92,52 @@ 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{}; + + Number + maxCoveredLoanValue(Number const& currentDebt) const + { + auto debtLimit = + coverDeposit * tenthBipsPerUnity.value() / coverRateMin.value(); + + return debtLimit - currentDebt; + } + + static BrokerParameters const& + defaults() + { + static BrokerParameters const result{}; + return result; + } + + // TODO: create an operator() which returns a transaction similar to + // LoanParameters + }; + struct BrokerInfo { jtx::PrettyAsset asset; uint256 brokerID; - TenthBips16 managementFeeRate; + BrokerParameters params; BrokerInfo( jtx::PrettyAsset const& asset_, uint256 const& brokerID_, - TenthBips16 mgmtRate) - : asset(asset_), brokerID(brokerID_), managementFeeRate(mgmtRate) + BrokerParameters const& p) + : asset(asset_), brokerID(brokerID_), params(p) { } }; struct LoanParameters { - BrokerInfo broker; // The account submitting the transaction. May be borrower or broker. jtx::Account account; // The counterparty. Should be the other of borrower or broker. @@ -123,42 +146,50 @@ class Loan_test : public beast::unit_test::suite // only signs. bool counterpartyExplicit = true; Number principalRequest; - STAmount setFee; - std::optional originationFee; - std::optional serviceFee; - std::optional lateFee; - std::optional closeFee; - std::optional overFee; - std::optional interest; - std::optional lateInterest; - std::optional closeInterest; - std::optional overpaymentInterest; - std::optional payTotal; - std::optional payInterval; - std::optional gracePd; - std::optional flags; + std::optional setFee{}; + std::optional originationFee{}; + std::optional serviceFee{}; + std::optional lateFee{}; + std::optional closeFee{}; + std::optional overFee{}; + std::optional interest{}; + std::optional lateInterest{}; + std::optional closeInterest{}; + std::optional overpaymentInterest{}; + std::optional payTotal{}; + std::optional payInterval{}; + std::optional gracePd{}; + std::optional flags{}; template jtx::JTx - operator()(jtx::Env& env, FN const&... fN) const + 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, principalRequest, flags.value_or(0))}; + account, + broker.brokerID, + broker.asset(principalRequest).number(), + flags.value_or(0))}; + sig(sfCounterpartySignature, counter)(env, jt); - fee{setFee}(env, jt); + + fee{setFee.value_or(env.current()->fees().base * 2)}(env, jt); + if (counterpartyExplicit) counterparty(counter)(env, jt); if (originationFee) - loanOriginationFee (*originationFee)(env, jt); + loanOriginationFee(broker.asset(*originationFee).number())( + env, jt); if (serviceFee) - loanServiceFee (*serviceFee)(env, jt); + loanServiceFee(broker.asset(*serviceFee).number())(env, jt); if (lateFee) - latePaymentFee (*lateFee)(env, jt); + latePaymentFee(broker.asset(*lateFee).number())(env, jt); if (closeFee) - closePaymentFee (*closeFee)(env, jt); + closePaymentFee(broker.asset(*closeFee).number())(env, jt); if (overFee) overpaymentFee (*overFee)(env, jt); if (interest) @@ -204,21 +235,18 @@ class Loan_test : public beast::unit_test::suite public: jtx::Env const& env; BrokerInfo const& broker; - Number const& loanAmount; jtx::Account const& pseudoAccount; - Keylet const& keylet; + Keylet const& loanKeylet; VerifyLoanStatus( jtx::Env const& env_, BrokerInfo const& broker_, - Number const& loanAmount_, jtx::Account const& pseudo_, Keylet const& keylet_) : env(env_) , broker(broker_) - , loanAmount(loanAmount_) , pseudoAccount(pseudo_) - , keylet(keylet_) + , loanKeylet(keylet_) { } @@ -316,7 +344,7 @@ class Loan_test : public beast::unit_test::suite std::uint32_t flags) const { using namespace jtx; - if (auto loan = env.le(keylet); env.test.BEAST_EXPECT(loan)) + if (auto loan = env.le(loanKeylet); env.test.BEAST_EXPECT(loan)) { env.test.BEAST_EXPECT( loan->at(sfPreviousPaymentDate) == previousPaymentDate); @@ -398,22 +426,17 @@ class Loan_test : public beast::unit_test::suite jtx::Env& env, jtx::PrettyAsset const& asset, jtx::Account const& lender, - std::optional debtMax = std::nullopt, - std::optional coverRateMin = std::nullopt) + BrokerParameters const& params = BrokerParameters::defaults()) { using namespace jtx; Vault vault{env}; - auto const deposit = asset(vaultDeposit); - auto const debtMaximumValue = debtMax - ? STAmount{asset.raw(), *debtMax} - : asset(debtMaximumParameter).value(); - auto const coverDepositValue = asset(coverDepositParameter).value(); + auto const deposit = asset(params.vaultDeposit); + auto const debtMaximumValue = asset(params.debtMax).value(); + auto const coverDepositValue = asset(params.coverDeposit).value(); - auto const coverRateMinValue = coverRateMin - ? TenthBips32(*coverRateMin) - : TenthBips32(coverRateMinParameter); + auto const coverRateMinValue = params.coverRateMin; auto [tx, vaultKeylet] = vault.create({.owner = lender, .asset = asset}); @@ -431,21 +454,20 @@ class Loan_test : public beast::unit_test::suite } auto const keylet = keylet::loanbroker(lender.id(), env.seq(lender)); - auto const testData = "spam spam spam spam"; using namespace loanBroker; env(set(lender, vaultKeylet.key), - data(testData), - managementFeeRate(managementFeeRateParameter), + data(params.data), + managementFeeRate(params.managementFeeRate), debtMaximum(debtMaximumValue), coverRateMinimum(coverRateMinValue), - coverRateLiquidation(TenthBips32(coverRateLiquidationParameter))); + coverRateLiquidation(TenthBips32(params.coverRateLiquidation))); env(coverDeposit(lender, keylet.key, coverDepositValue)); env.close(); - return {asset, keylet.key, managementFeeRateParameter}; + return {asset, keylet.key, params}; } /// Get the state without checking anything @@ -514,7 +536,7 @@ class Loan_test : public beast::unit_test::suite computeFee( broker.asset, state.totalValue - state.principalOutstanding, - managementFeeRateParameter, + broker.params.managementFeeRate, state.loanScale)); verifyLoanStatus(state); @@ -597,7 +619,7 @@ class Loan_test : public beast::unit_test::suite }(); VerifyLoanStatus const verifyLoanStatus( - env, broker, loanAmount, pseudoAcct, keylet); + env, broker, pseudoAcct, keylet); // No loans yet verifyLoanStatus.checkBroker(0, 0, TenthBips32{0}, 1, 0, 0); @@ -611,15 +633,6 @@ 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 = env.current()->fees().base * 2; - Number const principalRequest = broker.asset(loanAmount).value(); - auto const originationFee = broker.asset(1).value(); - auto const serviceFee = broker.asset(2).value(); - auto const lateFee = broker.asset(3).value(); - auto const closeFee = broker.asset(4).value(); - auto applyExponent = [interestExponent, this](TenthBips32 value) mutable { BEAST_EXPECT(value > TenthBips32(0)); @@ -640,43 +653,43 @@ class Loan_test : public beast::unit_test::suite return value; }; - auto const overFee = applyExponent(percentageToTenthBips(5) / 10); - auto const interest = applyExponent(percentageToTenthBips(12)); - // 2.4% - auto const lateInterest = applyExponent(percentageToTenthBips(24) / 10); - auto const closeInterest = - applyExponent(percentageToTenthBips(36) / 10); - auto const overpaymentInterest = - applyExponent(percentageToTenthBips(48) / 10); - auto const total = 12; - auto const interval = 600; - auto const grace = 60; + auto const borrowerOwnerCount = env.ownerCount(borrower); - auto const borrowerStartbalance = env.balance(borrower, broker.asset); - - // Use the defined values + auto const loanSetFee = env.current()->fees().base * 2; LoanParameters const loanParams{ - .broker = broker, .account = borrower, .counter = lender, .counterpartyExplicit = false, - .principalRequest = principalRequest, + .principalRequest = loanAmount, .setFee = loanSetFee, - .originationFee = originationFee, - .serviceFee = serviceFee, - .lateFee = lateFee, - .closeFee = closeFee, - .overFee = overFee, - .interest = interest, - .lateInterest = lateInterest, - .closeInterest = closeInterest, - .overpaymentInterest = overpaymentInterest, - .payTotal = total, - .payInterval = interval, - .gracePd = grace, + .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, }; - auto createJtx = loanParams(env); + 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); #if LOANCOMPLETE { auto createJtxOld = env.jt( @@ -723,8 +736,8 @@ class Loan_test : public beast::unit_test::suite BEAST_EXPECT( env.balance(borrower, broker.asset).value() == - borrowerStartbalance.value() + principalRequest - - originationFee - adjustment.value()); + borrowerStartbalance.value() + principalRequestAmount - + originationFeeAmount - adjustment.value()); } auto const loanFlags = createJtx.stx->isFlag(tfLoanOverpayment) @@ -741,27 +754,35 @@ class Loan_test : public beast::unit_test::suite 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) == originationFee); - BEAST_EXPECT(loan->at(sfLoanServiceFee) == serviceFee); - BEAST_EXPECT(loan->at(sfLatePaymentFee) == lateFee); - BEAST_EXPECT(loan->at(sfClosePaymentFee) == closeFee); - BEAST_EXPECT(loan->at(sfOverpaymentFee) == overFee); - BEAST_EXPECT(loan->at(sfInterestRate) == interest); - BEAST_EXPECT(loan->at(sfLateInterestRate) == lateInterest); - BEAST_EXPECT(loan->at(sfCloseInterestRate) == closeInterest); BEAST_EXPECT( - loan->at(sfOverpaymentInterestRate) == overpaymentInterest); + 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) == interval); - BEAST_EXPECT(loan->at(sfGracePeriod) == grace); + BEAST_EXPECT( + loan->at(sfPaymentInterval) == *loanParams.payInterval); + BEAST_EXPECT(loan->at(sfGracePeriod) == *loanParams.gracePd); BEAST_EXPECT(loan->at(sfPreviousPaymentDate) == 0); BEAST_EXPECT( - loan->at(sfNextPaymentDueDate) == startDate + interval); - BEAST_EXPECT(loan->at(sfPaymentRemaining) == total); + loan->at(sfNextPaymentDueDate) == + startDate + *loanParams.payInterval); + BEAST_EXPECT(loan->at(sfPaymentRemaining) == *loanParams.payTotal); BEAST_EXPECT( loan->at(sfLoanScale) == - (broker.asset.integral() ? 0 : principalRequest.exponent())); - BEAST_EXPECT(loan->at(sfPrincipalOutstanding) == principalRequest); + (broker.asset.integral() ? 0 + : principalRequestAmount.exponent())); + BEAST_EXPECT( + loan->at(sfPrincipalOutstanding) == principalRequestAmount); } auto state = getCurrentState(env, broker, keylet, verifyLoanStatus); @@ -772,15 +793,15 @@ class Loan_test : public beast::unit_test::suite state.interestRate, state.paymentInterval, state.paymentRemaining, - managementFeeRateParameter); + broker.params.managementFeeRate); verifyLoanStatus( 0, - startDate + interval, - total, - broker.asset.integral() ? 0 : principalRequest.exponent(), + startDate + *loanParams.payInterval, + *loanParams.payTotal, + broker.asset.integral() ? 0 : principalRequestAmount.exponent(), loanProperties.totalValueOutstanding, - principalRequest, + principalRequestAmount, loanProperties.managementFeeOwedToBroker, loanProperties.periodicPayment, loanFlags | 0); @@ -827,17 +848,17 @@ class Loan_test : public beast::unit_test::suite env(manage(lender, keylet.key, tfLoanUnimpair), canImpair ? ter(tesSUCCESS) : ter(tecNO_PERMISSION)); - auto const nextDueDate = startDate + interval; + auto const nextDueDate = startDate + *loanParams.payInterval; env.close(); verifyLoanStatus( 0, nextDueDate, - total, - broker.asset.integral() ? 0 : principalRequest.exponent(), + *loanParams.payTotal, + broker.asset.integral() ? 0 : principalRequestAmount.exponent(), loanProperties.totalValueOutstanding, - principalRequest, + principalRequestAmount, loanProperties.managementFeeOwedToBroker, loanProperties.periodicPayment, loanFlags | 0); @@ -888,7 +909,7 @@ class Loan_test : public beast::unit_test::suite } // No loans left - verifyLoanStatus.checkBroker(0, 0, interest, 1, 0, 0); + verifyLoanStatus.checkBroker(0, 0, *loanParams.interest, 1, 0, 0); BEAST_EXPECT( env.balance(borrower, broker.asset).value() == @@ -902,6 +923,16 @@ class Loan_test : public beast::unit_test::suite } } + std::string + getCurrencyLabel(Asset const& asset) + { + return ( + asset.native() ? "XRP" + : asset.holds() ? "IOU" + : asset.holds() ? "MPT" + : "Unknown"); + } + /** Wrapper to run a series of lifecycle tests for a given asset and loan * amount * @@ -923,13 +954,10 @@ class Loan_test : public beast::unit_test::suite using namespace jtx; auto const& asset = broker.asset.raw(); + auto const currencyLabel = getCurrencyLabel(asset); auto const caseLabel = [&]() { std::stringstream ss; - ss << "Lifecycle: " << loanAmount << " " - << (asset.native() ? "XRP" - : asset.holds() ? "IOU" - : asset.holds() ? "MPT" - : "Unknown") + ss << "Lifecycle: " << loanAmount << " " << currencyLabel << " Scale interest to: " << interestExponent << " "; return ss.str(); }(); @@ -952,11 +980,14 @@ class Loan_test : public beast::unit_test::suite 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(vaultDeposit).value(); + Number const totalVaultRequest = + broker.asset(broker.params.vaultDeposit).value(); Number const debtMaximumRequest = - broker.asset(debtMaximumParameter).value(); + broker.asset(broker.params.debtMax).value(); auto const loanSetFee = fee(env.current()->fees().base * 2); @@ -1392,8 +1423,8 @@ class Loan_test : public beast::unit_test::suite tenthBipsOfValue( tenthBipsOfValue( brokerSle->at(sfDebtTotal), - coverRateMinParameter), - coverRateLiquidationParameter), + broker.params.coverRateMin), + broker.params.coverRateLiquidation), state.totalValue - state.managementFeeOutstanding), state.loanScale); return std::make_pair(defaultAmount, brokerSle->at(sfOwner)); @@ -1432,7 +1463,7 @@ class Loan_test : public beast::unit_test::suite auto const& broker = verifyLoanStatus.broker; auto const startingCoverAvailable = coverAvailable( broker.brokerID, - broker.asset(coverDepositParameter).number()); + broker.asset(broker.params.coverDeposit).number()); if (impair) { @@ -1892,7 +1923,7 @@ class Loan_test : public beast::unit_test::suite broker.asset, state.periodicPayment, state.loanScale)}; testcase - << "\tPayment components: " + << currencyLabel << " Payment components: " << "Payments remaining, rawInterest, rawPrincipal, " "rawMFee, trackedValueDelta, trackedPrincipalDelta, " "trackedInterestDelta, trackedMgmtFeeDelta, special"; @@ -1927,19 +1958,20 @@ class Loan_test : public beast::unit_test::suite state.periodicPayment, periodicRate, state.paymentRemaining, - managementFeeRateParameter); + broker.params.managementFeeRate); auto const rounded = calculateRoundedLoanState( state.totalValue, state.principalOutstanding, state.managementFeeOutstanding); testcase - << "\tLoan starting state: " << state.paymentRemaining + << currencyLabel + << " Loan starting state: " << state.paymentRemaining << ", " << raw.interestDue << ", " << raw.principalOutstanding << ", " << raw.managementFeeDue << ", " << rounded.valueOutstanding << ", " << rounded.principalOutstanding << ", " - << rounded.interestOutstanding << ", " + << rounded.interestDue << ", " << rounded.managementFeeDue; } @@ -1963,12 +1995,13 @@ class Loan_test : public beast::unit_test::suite .trackedPrincipalDelta = 0, .trackedManagementFeeDelta = 0}; Number totalInterestPaid = 0; + std::size_t totalPaymentsMade = 0; ripple::LoanState currentTrueState = calculateRawLoanState( state.periodicPayment, periodicRate, state.paymentRemaining, - managementFeeRateParameter); + broker.params.managementFeeRate); while (state.paymentRemaining > 0) { @@ -1983,23 +2016,28 @@ class Loan_test : public beast::unit_test::suite state.periodicPayment, periodicRate, state.paymentRemaining, - managementFeeRateParameter); + broker.params.managementFeeRate); BEAST_EXPECT( - paymentComponents.trackedValueDelta <= - roundedPeriodicPayment); + paymentComponents.trackedValueDelta == + roundedPeriodicPayment || + (paymentComponents.specialCase == + detail::PaymentSpecialCase::final && + paymentComponents.trackedValueDelta < + roundedPeriodicPayment)); ripple::LoanState const nextTrueState = calculateRawLoanState( state.periodicPayment, periodicRate, state.paymentRemaining - 1, - managementFeeRateParameter); + broker.params.managementFeeRate); detail::LoanDeltas const deltas = currentTrueState - nextTrueState; testcase - << "\tPayment components: " << state.paymentRemaining + << currencyLabel + << " Payment components: " << state.paymentRemaining << ", " << deltas.interestDueDelta << ", " << deltas.principalDelta << ", " << deltas.managementFeeDueDelta << ", " @@ -2034,7 +2072,7 @@ class Loan_test : public beast::unit_test::suite (diff > beast::zero && ((broker.asset.integral() && (static_cast(diff) < 3)) || - (totalDue.exponent() - diff.exponent() > 8)))); + (state.loanScale - diff.exponent() > 13)))); BEAST_EXPECT( paymentComponents.trackedValueDelta == @@ -2132,6 +2170,7 @@ class Loan_test : public beast::unit_test::suite paymentComponents.trackedManagementFeeDelta; totalInterestPaid += paymentComponents.trackedInterestPart(); + ++totalPaymentsMade; currentTrueState = nextTrueState; } @@ -2156,6 +2195,8 @@ class Loan_test : public beast::unit_test::suite 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), @@ -2164,9 +2205,17 @@ class Loan_test : public beast::unit_test::suite ter(tecNO_PERMISSION)); }); -#if LOANCOMPLETE +#if LOANTODO // TODO + /* + LoanPay fails with tecINVARIANT_FAILED error when loan_broker(also + borrower) tries to do the payment. Here's the sceanrio: 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; @@ -2886,7 +2935,11 @@ class Loan_test : public beast::unit_test::suite std::vector brokers; for (auto const& asset : assets) { - brokers.emplace_back(createVaultAndBroker(env, asset, lender)); + brokers.emplace_back(createVaultAndBroker( + env, + asset, + lender, + BrokerParameters{.data = "spam spam spam spam"})); } // Create and update Loans @@ -2930,6 +2983,309 @@ class Loan_test : public beast::unit_test::suite } } + void + makeLoanPayments( + jtx::Env& env, + BrokerInfo const& broker, + LoanParameters const& loanParams, + Keylet const& loanKeylet, + VerifyLoanStatus const& verifyLoanStatus) + { + // Make all the individual payments + using namespace jtx; + using namespace jtx::loan; + using namespace std::chrono_literals; + using d = NetClock::duration; + + Account const issuer{"issuer"}; + Account const lender{"lender"}; + Account const borrower{"borrower"}; + // Account const evan{"evan"}; + // Account const alice{"alice"}; + + 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)); + + // Ensure the borrower has enough funds to make the payments (including + // tx fees, if necessary) + { + auto const borrowerBalance = env.balance(borrower, broker.asset); + + // 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 + + env.current()->fees().accountReserve( + env.ownerCount(borrower))) + : broker.asset(15).number()); + + auto const shortage = totalNeeded - borrowerBalance.number(); + + if (shortage > beast::zero) + env( + pay((broker.asset.native() ? env.master : issuer), + borrower, + STAmount{broker.asset, shortage})); + } + + // 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)}; + + 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::upward); + + { + auto const raw = calculateRawLoanState( + state.periodicPayment, + periodicRate, + state.paymentRemaining, + broker.params.managementFeeRate); + auto const rounded = calculateRoundedLoanState( + state.totalValue, + state.principalOutstanding, + state.managementFeeOutstanding); + log << currencyLabel + << " Loan starting state: " << state.paymentRemaining << ", " + << raw.interestDue << ", " << raw.principalOutstanding << ", " + << raw.managementFeeDue << ", " << rounded.valueOutstanding + << ", " << rounded.principalOutstanding << ", " + << rounded.interestDue << ", " << rounded.managementFeeDue + << std::endl; + } + + // Try to pay a little extra to show that it's _not_ + // taken + STAmount const transactionAmount = STAmount{broker.asset, totalDue} + + std::min(broker.asset(10).value(), + STAmount{broker.asset, totalDue / 20}); + + auto const borrowerInitialBalance = + env.balance(borrower, broker.asset).number(); + auto const initialState = state; + detail::PaymentComponents totalPaid{ + .trackedValueDelta = 0, + .trackedPrincipalDelta = 0, + .trackedManagementFeeDelta = 0}; + Number totalInterestPaid = 0; + Number totalFeesPaid = 0; + std::size_t totalPaymentsMade = 0; + + ripple::LoanState currentTrueState = calculateRawLoanState( + state.periodicPayment, + periodicRate, + state.paymentRemaining, + broker.params.managementFeeRate); + + auto validateBorrowerBalance = [&]() { + auto const totalSpent = + (totalPaid.trackedValueDelta + totalFeesPaid + + (broker.asset.native() ? Number(baseFee) * totalPaymentsMade + : numZero)); + BEAST_EXPECT( + env.balance(borrower, broker.asset).number() == + borrowerInitialBalance - totalSpent); + }; + while (state.paymentRemaining > 0) + { + validateBorrowerBalance(); + // Compute the expected principal amount + auto const paymentComponents = detail::computePaymentComponents( + 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 == + detail::PaymentSpecialCase::final && + paymentComponents.trackedValueDelta < roundedPeriodicPayment)); + BEAST_EXPECT( + paymentComponents.trackedValueDelta == + paymentComponents.trackedPrincipalDelta + + paymentComponents.trackedInterestPart() + + paymentComponents.trackedManagementFeeDelta); + + ripple::LoanState const nextTrueState = calculateRawLoanState( + state.periodicPayment, + periodicRate, + state.paymentRemaining - 1, + broker.params.managementFeeRate); + detail::LoanDeltas const deltas = currentTrueState - nextTrueState; + BEAST_EXPECT( + deltas.valueDelta() == + deltas.principalDelta + deltas.interestDueDelta + + deltas.managementFeeDueDelta); + BEAST_EXPECT( + paymentComponents.specialCase == + detail::PaymentSpecialCase::final || + deltas.valueDelta() == state.periodicPayment || + (state.loanScale - + (deltas.valueDelta() - state.periodicPayment).exponent()) > + 14); + + log << currencyLabel + << " Payment components: " << state.paymentRemaining << ", " + << deltas.interestDueDelta << ", " << deltas.principalDelta + << ", " << deltas.managementFeeDueDelta << ", " + << paymentComponents.trackedValueDelta << ", " + << paymentComponents.trackedPrincipalDelta << ", " + << paymentComponents.trackedInterestPart() << ", " + << paymentComponents.trackedManagementFeeDelta << ", " + << (paymentComponents.specialCase == + detail::PaymentSpecialCase::final + ? "final" + : paymentComponents.specialCase == + detail::PaymentSpecialCase::extra + ? "extra" + : "none") + << std::endl; + + auto const totalDueAmount = STAmount{ + broker.asset, paymentComponents.trackedValueDelta + serviceFee}; + + // 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 == + detail::PaymentSpecialCase::final || + diff == beast::zero || + (diff > beast::zero && + ((broker.asset.integral() && + (static_cast(diff) < 3)) || + (state.loanScale - diff.exponent() > 13)))); + + BEAST_EXPECT( + paymentComponents.trackedPrincipalDelta >= beast::zero && + paymentComponents.trackedPrincipalDelta <= + state.principalOutstanding); + BEAST_EXPECT( + paymentComponents.specialCase != + detail::PaymentSpecialCase::final || + paymentComponents.trackedPrincipalDelta == + state.principalOutstanding); + + auto const borrowerBalanceBeforePayment = + env.balance(borrower, broker.asset); + + // Make the payment + env(pay(borrower, loanKeylet.key, transactionAmount)); + + env.close( + d{(state.previousPaymentDate + state.nextPaymentDate) / 2}); + + // 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 == + detail::PaymentSpecialCase::final) + { + state.paymentRemaining = 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(); + totalFeesPaid += serviceFee; + ++totalPaymentsMade; + + currentTrueState = nextTrueState; + } + validateBorrowerBalance(); + + // 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); + } + void testSelfLoan() { @@ -3088,12 +3444,14 @@ class Loan_test : public beast::unit_test::suite Account const lender{"lender"}; Account const borrower{"borrower"}; - env.fund(XRP(vaultDeposit * 100), lender, borrower); + BrokerParameters brokerParams; + env.fund(XRP(brokerParams.vaultDeposit * 100), lender, borrower); env.close(); PrettyAsset const xrpAsset{xrpIssue(), 1'000'000}; - BrokerInfo broker{createVaultAndBroker(env, xrpAsset, lender)}; + BrokerInfo broker{ + createVaultAndBroker(env, xrpAsset, lender, brokerParams)}; using namespace loan; @@ -3139,15 +3497,6 @@ class Loan_test : public beast::unit_test::suite } } - BrokerInfo - createVaultAndBrokerNoMaxDebt( - jtx::Env& env, - jtx::PrettyAsset const& asset, - jtx::Account const& lender) - { - return createVaultAndBroker(env, asset, lender, Number(0)); - } - void testWrongMaxDebtBehavior() { @@ -3161,12 +3510,15 @@ class Loan_test : public beast::unit_test::suite Account const issuer{"issuer"}; Account const lender{"lender"}; - env.fund(XRP(vaultDeposit * 100), issuer, noripple(lender)); + BrokerParameters brokerParams{.debtMax = 0}; + env.fund( + XRP(brokerParams.vaultDeposit * 100), issuer, noripple(lender)); env.close(); PrettyAsset const xrpAsset{xrpIssue(), 1'000'000}; - BrokerInfo broker{createVaultAndBrokerNoMaxDebt(env, xrpAsset, lender)}; + BrokerInfo broker{ + createVaultAndBroker(env, xrpAsset, lender, brokerParams)}; if (auto const brokerSle = env.le(keylet::loanbroker(broker.brokerID)); BEAST_EXPECT(brokerSle)) @@ -3210,11 +3562,14 @@ class Loan_test : public beast::unit_test::suite Account const lender{"lender"}; Account const borrower{"borrower"}; - env.fund(XRP(vaultDeposit * 100), issuer, lender, borrower); + BrokerParameters brokerParams; + env.fund( + XRP(brokerParams.vaultDeposit * 100), issuer, lender, borrower); env.close(); PrettyAsset const xrpAsset{xrpIssue(), 1'000'000}; - BrokerInfo broker{createVaultAndBroker(env, xrpAsset, lender)}; + BrokerInfo broker{ + createVaultAndBroker(env, xrpAsset, lender, brokerParams)}; using namespace loan; @@ -3246,8 +3601,8 @@ class Loan_test : public beast::unit_test::suite 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 + // Fails in preclaim because principal requested can't be + // represented as XRP env(createJson, ter(tecPRECISION_LOSS)); env.close(); @@ -3258,8 +3613,8 @@ class Loan_test : public beast::unit_test::suite 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. + // Fails in doApply because the payment is too small to be + // represented as XRP. env(createJson, ter(tecPRECISION_LOSS)); env.close(); } @@ -3369,7 +3724,8 @@ class Loan_test : public beast::unit_test::suite txJson[sfAccount] = borrower.human(); txJson[sfCounterparty] = lender.human(); txJson[sfLoanBrokerID] = - "FF924CD18A236C2B49CF8E80A351CEAC6A10171DC9F110025646894FECF83F" + "FF924CD18A236C2B49CF8E80A351CEAC6A10171DC9F110025646894FEC" + "F83F" "5C"; txJson[sfPrincipalRequested] = "100000000"; txJson[sfPaymentTotal] = 10000; @@ -3487,7 +3843,8 @@ class Loan_test : public beast::unit_test::suite txJson[sfAccount] = lender.human(); txJson[sfCounterparty] = borrower.human(); txJson[sfLoanBrokerID] = - "FF924CD18A236C2B49CF8E80A351CEAC6A10171DC9F110025646894FECF83F" + "FF924CD18A236C2B49CF8E80A351CEAC6A10171DC9F110025646894FEC" + "F83F" "5C"; txJson[sfPrincipalRequested] = "100000000"; txJson[sfPaymentTotal] = 10000; @@ -3652,8 +4009,8 @@ class Loan_test : public beast::unit_test::suite env.close(); env(trust(borrower, IOU(20'000'000))); - // The borrower increases their limit and acquires some IOU so they - // can pay interest + // The borrower increases their limit and acquires some IOU so + // they can pay interest env(pay(issuer, borrower, IOU(500))); env.close(); @@ -3722,7 +4079,11 @@ class Loan_test : public beast::unit_test::suite .holders = {lender}, .pay = issuerBalance}); - auto const broker = createVaultAndBroker(env, asset, lender, 200); + 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), @@ -4025,8 +4386,8 @@ class Loan_test : public beast::unit_test::suite testLoanPayDebtDecreaseInvariant() { // From FIND-007 - testcase - << "LoanPay ripple::LoanPay::doApply : debtDecrease rounding good"; + testcase << "LoanPay ripple::LoanPay::doApply : debtDecrease " + "rounding good"; using namespace jtx; using namespace std::chrono_literals; @@ -4092,8 +4453,7 @@ class Loan_test : public beast::unit_test::suite return Account("Broker pseudo-account", brokerPseudo); }(); - VerifyLoanStatus verifyLoanStatus( - env, broker, Number{992481, -2}, pseudoAcct, keylet); + VerifyLoanStatus verifyLoanStatus(env, broker, pseudoAcct, keylet); auto const originalState = getCurrentState(env, broker, keylet); verifyLoanStatus(originalState); @@ -4282,8 +4642,8 @@ class Loan_test : public beast::unit_test::suite testLoanPayComputePeriodicPaymentValidTotalPrincipalPaidInvariant() { // From FIND-009 - testcase - << "ripple::loanComputePaymentParts : totalPrincipalPaid rounded"; + testcase << "ripple::loanComputePaymentParts : totalPrincipalPaid " + "rounded"; using namespace jtx; using namespace std::chrono_literals; @@ -4423,11 +4783,9 @@ class Loan_test : public beast::unit_test::suite BrokerInfo broker{createVaultAndBroker(env, iouAsset, lender)}; { auto const coverDepositValue = - broker.asset(coverDepositParameter).value(); + broker.asset(broker.params.coverDeposit * 10).value(); env(loanBroker::coverDeposit( - lender, - broker.brokerID, - STAmount{broker.asset, coverDepositValue * 10})); + lender, broker.brokerID, coverDepositValue)); env.close(); } @@ -4516,8 +4874,10 @@ class Loan_test : public beast::unit_test::suite env(payIssuerTx); env.close(); + BrokerParameters const brokerParams{ + .debtMax = Number{0}, .coverRateMin = TenthBips32{1}}; BrokerInfo broker{ - createVaultAndBroker(env, iouAsset, lender, Number(0), 1)}; + createVaultAndBroker(env, iouAsset, lender, brokerParams)}; using namespace loan; @@ -4724,10 +5084,593 @@ class Loan_test : public beast::unit_test::suite } } +#if LOANTODO + void + testCoverDepositAllowsNonTransferableMPT() + { + testcase("CoverDeposit accepts MPT without CanTransfer"); + using namespace jtx; + using namespace loanBroker; + + Env env(*this, all); + + Account const issuer{"issuer"}; + Account const alice{"alice"}; + + env.fund(XRP(100'000), issuer, alice); + env.close(); + + MPTTester mpt{env, issuer, mptInitNoFund}; + + mpt.create( + {.flags = tfMPTCanTransfer, + .mutableFlags = tmfMPTCanMutateCanTransfer}); + + env.close(); + + PrettyAsset const asset = mpt["BUG"]; + mpt.authorize({.account = alice}); + env.close(); + + // Issuer can fund the holder even if CanTransfer is not set. + env(pay(issuer, alice, asset(100))); + env.close(); + + Vault vault{env}; + auto const [createTx, vaultKeylet] = + vault.create({.owner = alice, .asset = asset}); + env(createTx); + env.close(); + + auto const brokerKeylet = + keylet::loanbroker(alice.id(), env.seq(alice)); + env(set(alice, vaultKeylet.key)); + env.close(); + + auto const brokerSle = env.le(brokerKeylet); + if (!BEAST_EXPECT(brokerSle)) + return; + + Account const pseudoAccount{ + "Loan Broker pseudo-account", brokerSle->at(sfAccount)}; + + // Remove CanTransfer after the broker is set up. + mpt.set({.mutableFlags = tmfMPTClearCanTransfer}); + env.close(); + + // Standard Payment path should forbid third-party transfers. + env(pay(alice, pseudoAccount, asset(1)), ter(tecNO_AUTH)); + env.close(); + + auto const depositAmount = asset(1); + env(coverDeposit(alice, brokerKeylet.key, depositAmount)); + BEAST_EXPECT(env.ter() == tesSUCCESS); + env.close(); + + if (auto const refreshed = env.le(brokerKeylet); + BEAST_EXPECT(refreshed)) + { + // with an MPT that cannot be transferred the covrAvailable should + // remain zero + BEAST_EXPECT(refreshed->at(sfCoverAvailable) == 0); + env.require(balance(pseudoAccount, depositAmount)); + } + } + + void + testLoanPayLateFullPaymentBypassesPenalties() + { + testcase("LoanPay full payment skips late penalties"); + using namespace jtx; + using namespace 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(); + + 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)) + 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), + loanServiceFee(serviceFee), + latePaymentFee(lateFee), + closePaymentFee(closeFee), + interestRate(percentageToTenthBips(12)), + lateInterestRate(percentageToTenthBips(24) / 10), + closeInterestRate(percentageToTenthBips(5)), + paymentTotal(12), + paymentInterval(600), + gracePeriod(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 = calculateRoundedLoanState( + state.totalValue, + state.principalOutstanding, + state.managementFeeOutstanding); + Number const totalInterestOutstanding = roundedLoanState.interestDue; + + auto const periodicRate = + loanPeriodicRate(interestRateValue, state.paymentInterval); + auto const rawLoanState = calculateRawLoanState( + 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 = calculateFullPaymentInterest( + rawLoanState.principalOutstanding, + periodicRate, + parentCloseTime, + state.paymentInterval, + state.previousPaymentDate, + startDateSeconds, + closeInterestRateValue); + + Number const roundedFullInterestAmount = + roundToAsset(broker.asset, fullPaymentInterest, state.loanScale); + Number const roundedFullManagementFee = computeFee( + 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 = computeFee( + 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 + testPoC_UnsignedUnderflowOnFullPayAfterEarlyPeriodic() + { + // --- PoC Summary ---------------------------------------------------- + // Scenario: Borrower makes one periodic payment early (before next due) + // so doPayment sets sfPreviousPaymentDate 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, all); + + 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 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), + loanOriginationFee(originationFee), + loanServiceFee(serviceFee), + latePaymentFee(lateFee), + closePaymentFee(closeFee), + overpaymentFee(percentageToTenthBips(5) / 10), + interestRate(interest), + lateInterestRate(lateInterest), + closeInterestRate(closeInterest), + overpaymentInterestRate(overpaymentInterest), + paymentTotal(total), + paymentInterval(interval), + gracePeriod(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 = detail::computePaymentComponents( + 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 = calculateFullPaymentInterest( + after.periodicPayment, + periodicRate2, + after.paymentRemaining, + 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 = computeFee( + 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 = calculateFullPaymentInterest( + after.periodicPayment, + periodicRate2, + after.paymentRemaining, + env.current()->parentCloseTime(), + after.paymentInterval, + prevClamped, + startSecs, + closeInterestRate); + auto const roundedInterestClamped = roundToAsset( + asset.raw(), fullPaymentInterestClamped, after.loanScale); + Number const roundedFullMgmtFeeClamped = computeFee( + 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); + 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 + testLoanCoverMinimumRoundingExploit() + { + 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, all); + + 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, + paymentInterval(600), + paymentTotal(1), + gracePeriod(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 + public: void run() override { +#if LOANTODO + testCoverDepositAllowsNonTransferableMPT(); + testLoanPayLateFullPaymentBypassesPenalties(); + testPoC_UnsignedUnderflowOnFullPayAfterEarlyPeriodic(); + testLoanCoverMinimumRoundingExploit(); +#endif + testIssuerLoan(); testDisabled(); testSelfLoan(); @@ -4756,7 +5699,295 @@ public: } }; +class LoanBatch_test : public Loan_test +{ +protected: + enum class AssetType { XRP = 0, IOU = 1, MPT = 2 }; + + 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 + runLoan( + AssetType assetType, + BrokerParameters const& brokerParams, + LoanParameters const& loanParams) + { + using namespace jtx; + + Account const issuer("issuer"); + Account const lender("lender"); + Account const borrower("borrower"); + + Env env(*this, all); + + // Enough to cover initial fees + env.fund( + env.current()->fees().accountReserve(10) * 10, + issuer, + noripple(lender, borrower)); + + // Make the asset + auto const asset = [&]() { + 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]}; + + env(trust(lender, asset(brokerParams.vaultDeposit))); + env(trust(borrower, asset(brokerParams.vaultDeposit))); + + return asset; + } + + case AssetType::MPT: { + MPTTester mptt{env, issuer, mptInitNoFund}; + 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 + mptt.authorize({.account = lender}); + mptt.authorize({.account = borrower}); + + env.close(); + + return asset; + } + + default: + throw std::runtime_error("Unknown asset type"); + } + }(); + + env.close(); + env( + pay((asset.native() ? env.master : issuer), + lender, + asset(brokerParams.vaultDeposit))); + // 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; + 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; + Keylet const& loanKeylet = *loanKeyletOpt; + + env(loanParams(env, broker)); + + env.close(); + + VerifyLoanStatus verifyLoanStatus(env, broker, pseudoAcct, loanKeylet); + + makeLoanPayments(env, broker, loanParams, loanKeylet, verifyLoanStatus); + } + + void + testRandomLoan() + { + using namespace jtx; + + Account const issuer("issuer"); + Account const lender("lender"); + Account const borrower("borrower"); + + // Determine all the random parameters at once + AssetType assetType = static_cast(assetDist(engine_)); + auto const principalRequest = principalDist(engine_); + TenthBips16 managementFeeRate{managementFeeRateDist(engine_)}; + auto const serviceFee = serviceFeeDist(engine_); + TenthBips32 interest{interestRateDist(engine_)}; + auto const payTotal = paymentTotalDist(engine_); + auto const payInterval = paymentIntervalDist(engine_); + + BrokerParameters brokerParams{ + .vaultDeposit = principalRequest * 10, + .debtMax = 0, + .coverRateMin = TenthBips32{0}, + .managementFeeRate = managementFeeRate}; + LoanParameters loanParams{ + .account = lender, + .counter = borrower, + .principalRequest = principalRequest, + .serviceFee = serviceFee, + .interest = interest, + .payTotal = payTotal, + .payInterval = payInterval, + }; + + runLoan(assetType, brokerParams, loanParams); + } + +public: + void + run() override + { + auto const argument = arg(); + auto const numIterations = [s = arg()]() -> int { + int defaultNum = 5; + if (s.empty()) + return defaultNum; + try + { + std::size_t pos; + 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; + +#if LOANCOMPLETE + BEAST_EXPECT( + std::numeric_limits::max() > INITIAL_XRP.drops()); + BEAST_EXPECT(Number::maxMantissa > INITIAL_XRP.drops()); + Number initalXrp{INITIAL_XRP}; + BEAST_EXPECT(initalXrp.exponent() <= 0); +#endif + /* + Progress: 27 completed, 50 total attempts | Rejected: periodic=0, +interest=23, +duration=0LoanParameters(principal=Decimal('1255438.00000000000000000000000'), +interest_rate=Decimal('0.01922'), payment_total=5816, +payment_interval=Decimal('29193'), interest_fee=Decimal('0.59195')) Single test +failed with assertion error: Both principal and interest rounded are zero 0 + 0 ++ 0 + */ + + BrokerParameters const brokerParams{ + .vaultDeposit = 1'000'000'000, + .debtMax = 0, + .coverRateMin = TenthBips32{0}, + .managementFeeRate = TenthBips16{5919}, + .coverRateLiquidation = TenthBips32{0}}; + LoanParameters const loanParams{ + .account = Account("lender"), + .counter = Account("borrower"), + .principalRequest = Number{1255438, -6}, + .interest = TenthBips32{1922}, + .payTotal = 5816, + .payInterval = 29193}; + + { + Env env(*this, beast::severities::kWarning); + + auto const asset = PrettyAsset{xrpIssue(), 1'000'000}; + auto const props = computeLoanProperties( + asset, + asset(loanParams.principalRequest).number(), + *loanParams.interest, + *loanParams.payInterval, + *loanParams.payTotal, + brokerParams.managementFeeRate); + log << "Loan properties:\n" + << "\tPeriodic Payment: " << props.periodicPayment << std::endl + << "\tTotal Value: " << props.totalValueOutstanding << std::endl + << "\tManagement Fee: " << props.managementFeeOwedToBroker + << 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(!LoanSet::checkGuards( + asset, + loanParams.principalRequest, + *loanParams.interest, + *loanParams.payTotal, + props, + env.journal)); + } + + runLoan(AssetType::XRP, brokerParams, loanParams); + } +}; + BEAST_DEFINE_TESTSUITE(Loan, tx, ripple); +BEAST_DEFINE_TESTSUITE_MANUAL(LoanBatch, tx, ripple); +BEAST_DEFINE_TESTSUITE_MANUAL(LoanArbitrary, tx, ripple); } // namespace test } // namespace ripple diff --git a/src/test/jtx/Env.h b/src/test/jtx/Env.h index 7181ec2a3e..d5a7185dbe 100644 --- a/src/test/jtx/Env.h +++ b/src/test/jtx/Env.h @@ -251,7 +251,9 @@ public: * * @param suite_ the current unit_test::suite */ - Env(beast::unit_test::suite& suite_) : Env(suite_, envconfig()) + Env(beast::unit_test::suite& suite_, + beast::severities::Severity thresh = beast::severities::kError) + : Env(suite_, envconfig(), nullptr, thresh) { } diff --git a/src/test/jtx/amount.h b/src/test/jtx/amount.h index f25f292531..c2bb9281a3 100644 --- a/src/test/jtx/amount.h +++ b/src/test/jtx/amount.h @@ -295,6 +295,21 @@ struct XRP_t return {TOut{v} * dropsPerXRP}; } + /** Returns an amount of XRP as PrettyAmount, + which is trivially convertable to STAmount + + @param v The Number of XRP (not drops). May be fractional. + */ + PrettyAmount + operator()(Number v) const + { + auto const c = dropsPerXRP.drops(); + auto const d = std::int64_t(v * c); + if (Number(d) / c != v) + Throw("unrepresentable"); + return {d}; + } + PrettyAmount operator()(double v) const { diff --git a/src/xrpld/app/misc/LendingHelpers.h b/src/xrpld/app/misc/LendingHelpers.h index 2e48ab86b0..683f1ceaab 100644 --- a/src/xrpld/app/misc/LendingHelpers.h +++ b/src/xrpld/app/misc/LendingHelpers.h @@ -98,14 +98,24 @@ struct LoanState Number valueOutstanding; /// Prinicipal still due to be paid by the borrower. Number principalOutstanding; - /// Interest still due to be paid by the borrower. - Number interestOutstanding; /// Interest still due to be paid TO the Vault. // This is a portion of interestOutstanding Number interestDue; /// Management fee still due to be paid TO the broker. // This is a portion of interestOutstanding Number managementFeeDue; + + /// Interest still due to be paid by the borrower. + Number + interestOutstanding() const + { + XRPL_ASSERT_PARTS( + interestDue + managementFeeDue == + valueOutstanding - principalOutstanding, + "ripple::LoanState::interestOutstanding", + "other values add up correctly"); + return interestDue + managementFeeDue; + } }; LoanState @@ -113,7 +123,7 @@ calculateRawLoanState( Number const& periodicPayment, Number const& periodicRate, std::uint32_t const paymentRemaining, - TenthBips16 const managementFeeRate); + TenthBips32 const managementFeeRate); LoanState calculateRawLoanState( @@ -121,7 +131,7 @@ calculateRawLoanState( TenthBips32 interestRate, std::uint32_t paymentInterval, std::uint32_t const paymentRemaining, - TenthBips16 const managementFeeRate); + TenthBips32 const managementFeeRate); LoanState calculateRoundedLoanState( @@ -136,7 +146,7 @@ Number computeFee( Asset const& asset, Number const& value, - TenthBips16 managementFeeRate, + TenthBips32 managementFeeRate, std::int32_t scale); Number @@ -195,10 +205,18 @@ struct PaymentComponents struct LoanDeltas { - Number valueDelta; Number principalDelta; Number interestDueDelta; Number managementFeeDueDelta; + + Number + valueDelta() const + { + return principalDelta + interestDueDelta + managementFeeDueDelta; + } + + void + nonNegative(); }; PaymentComponents @@ -218,6 +236,9 @@ computePaymentComponents( detail::LoanDeltas operator-(LoanState const& lhs, LoanState const& rhs); +LoanState +operator-(LoanState const& lhs, detail::LoanDeltas const& rhs); + Number valueMinusFee( Asset const& asset, @@ -232,7 +253,7 @@ computeLoanProperties( TenthBips32 interestRate, std::uint32_t paymentInterval, std::uint32_t paymentsRemaining, - TenthBips16 managementFeeRate); + TenthBips32 managementFeeRate); bool isRounded(Asset const& asset, Number const& value, std::int32_t scale); diff --git a/src/xrpld/app/misc/detail/LendingHelpers.cpp b/src/xrpld/app/misc/detail/LendingHelpers.cpp index 2fd61aa7ff..eacaaceb3a 100644 --- a/src/xrpld/app/misc/detail/LendingHelpers.cpp +++ b/src/xrpld/app/misc/detail/LendingHelpers.cpp @@ -467,7 +467,7 @@ struct PaymentComponentsPlus : public PaymentComponents PaymentComponentsPlus( PaymentComponents const& p, Number f, - Number v = Number{}) + Number v = numZero) : PaymentComponents(p) , untrackedManagementFee(f) , untrackedInterest(v) @@ -673,7 +673,7 @@ tryOverpayment( auto const newRounded = calculateRoundedLoanState( totalValueOutstanding, principalOutstanding, managementFeeOutstanding); auto const valueChange = - newRounded.interestOutstanding - rounded.interestOutstanding; + newRounded.interestOutstanding() - rounded.interestOutstanding(); XRPL_ASSERT_PARTS( valueChange < beast::zero, "ripple::detail::tryOverpayment", @@ -999,6 +999,17 @@ PaymentComponents::trackedInterestPart() const (trackedPrincipalDelta + trackedManagementFeeDelta); } +void +LoanDeltas::nonNegative() +{ + if (principalDelta < beast::zero) + principalDelta = numZero; + if (interestDueDelta < beast::zero) + interestDueDelta = numZero; + if (managementFeeDueDelta < beast::zero) + managementFeeDueDelta = numZero; +} + PaymentComponents computePaymentComponents( Asset const& asset, @@ -1035,8 +1046,6 @@ computePaymentComponents( roundToAsset(asset, trueTarget.valueOutstanding, scale), .principalOutstanding = roundToAsset(asset, trueTarget.principalOutstanding, scale), - .interestOutstanding = - roundToAsset(asset, trueTarget.interestOutstanding, scale), .interestDue = roundToAsset(asset, trueTarget.interestDue, scale), .managementFeeDue = roundToAsset(asset, trueTarget.managementFeeDue, scale)}; @@ -1044,67 +1053,38 @@ computePaymentComponents( totalValueOutstanding, principalOutstanding, managementFeeOutstanding); LoanDeltas deltas = currentLedgerState - roundedTarget; - - // It should be impossible for any of the deltas to be negative, but do - // defensive checks - if (deltas.principalDelta < beast::zero) - { - // LCOV_EXCL_START - UNREACHABLE( - "ripple::detail::computePaymentComponents : negative principal " - "delta"); - deltas.principalDelta = Number::zero; - // LCOV_EXCL_STOP - } - if (deltas.interestDueDelta < beast::zero) - { - // LCOV_EXCL_START - UNREACHABLE( - "ripple::detail::computePaymentComponents : negative interest " - "delta"); - deltas.interestDueDelta = Number::zero; - // LCOV_EXCL_STOP - } - if (deltas.managementFeeDueDelta < beast::zero) - { - // LCOV_EXCL_START - UNREACHABLE( - "ripple::detail::computePaymentComponents : negative management " - "fee delta"); - deltas.managementFeeDueDelta = Number::zero; - // LCOV_EXCL_STOP - } + deltas.nonNegative(); // Adjust the deltas if necessary for data integrity XRPL_ASSERT_PARTS( deltas.principalDelta <= currentLedgerState.principalOutstanding, "ripple::detail::computePaymentComponents", "principal delta not greater than outstanding"); + deltas.principalDelta = std::min( deltas.principalDelta, currentLedgerState.principalOutstanding); + XRPL_ASSERT_PARTS( deltas.interestDueDelta <= currentLedgerState.interestDue, "ripple::detail::computePaymentComponents", "interest due delta not greater than outstanding"); + deltas.interestDueDelta = std::min( {deltas.interestDueDelta, - std::max(Number::zero, roundedPeriodicPayment - deltas.principalDelta), + std::max(numZero, roundedPeriodicPayment - deltas.principalDelta), currentLedgerState.interestDue}); + XRPL_ASSERT_PARTS( deltas.managementFeeDueDelta <= currentLedgerState.managementFeeDue, "ripple::detail::computePaymentComponents", "management fee due delta not greater than outstanding"); + deltas.managementFeeDueDelta = std::min( {deltas.managementFeeDueDelta, roundedPeriodicPayment - (deltas.principalDelta + deltas.interestDueDelta), currentLedgerState.managementFeeDue}); - // In case any adjustments were made (or if the original rounding didn't - // quite add up right), recompute the total value delta - deltas.valueDelta = deltas.principalDelta + deltas.interestDueDelta + - deltas.managementFeeDueDelta; - if (paymentRemaining == 1 || totalValueOutstanding <= roundedPeriodicPayment) { @@ -1112,15 +1092,15 @@ computePaymentComponents( // parts. XRPL_ASSERT_PARTS( - deltas.valueDelta == totalValueOutstanding, + deltas.valueDelta() <= totalValueOutstanding, "ripple::detail::computePaymentComponents", "last payment total value agrees"); XRPL_ASSERT_PARTS( - deltas.principalDelta == principalOutstanding, + deltas.principalDelta <= principalOutstanding, "ripple::detail::computePaymentComponents", "last payment principal agrees"); XRPL_ASSERT_PARTS( - deltas.managementFeeDueDelta == managementFeeOutstanding, + deltas.managementFeeDueDelta <= managementFeeOutstanding, "ripple::detail::computePaymentComponents", "last payment management fee agrees"); @@ -1231,13 +1211,12 @@ computePaymentComponents( // trying to take more than the whole payment. The excess can be positive, // which indicates that we're not going to take the whole payment amount, // but if so, it must be small. - auto takeFrom = [](Number& total, Number& component, Number& excess) { + auto takeFrom = [](Number& component, Number& excess) { if (excess > beast::zero) { // Take as much of the excess as we can out of the provided part and // the total auto part = std::min(component, excess); - total -= part; component -= part; excess -= part; } @@ -1248,14 +1227,41 @@ computePaymentComponents( "ripple::detail::computePaymentComponents", "excess non-negative"); }; + auto giveTo = + [](Number& component, Number& shortage, Number const& maximum) { + if (shortage > beast::zero) + { + // Put as much of the shortage as we can into the provided part + // and the total + auto part = std::min(maximum - component, shortage); + component += part; + shortage -= part; + } + // If the shortage goes negative, we put too much, which should be + // impossible + XRPL_ASSERT_PARTS( + shortage >= beast::zero, + "ripple::detail::computePaymentComponents", + "excess non-negative"); + }; auto addressExcess = [&takeFrom](LoanDeltas& deltas, Number& excess) { - takeFrom(deltas.valueDelta, deltas.interestDueDelta, excess); - takeFrom(deltas.valueDelta, deltas.managementFeeDueDelta, excess); - takeFrom(deltas.valueDelta, deltas.principalDelta, excess); + // This order is based on where errors are the least problematic + takeFrom(deltas.interestDueDelta, excess); + takeFrom(deltas.managementFeeDueDelta, excess); + takeFrom(deltas.principalDelta, excess); + }; + auto addressShortage = [&giveTo]( + LoanDeltas& deltas, + Number& shortage, + LoanState const& current) { + giveTo(deltas.interestDueDelta, shortage, current.interestDue); + giveTo(deltas.principalDelta, shortage, current.principalOutstanding); + giveTo( + deltas.managementFeeDueDelta, shortage, current.managementFeeDue); }; Number totalOverpayment = - deltas.valueDelta - currentLedgerState.valueOutstanding; - if (totalOverpayment > 0) + deltas.valueDelta() - currentLedgerState.valueOutstanding; + if (totalOverpayment > beast::zero) { // LCOV_EXCL_START UNREACHABLE( @@ -1264,8 +1270,9 @@ computePaymentComponents( addressExcess(deltas, totalOverpayment); // LCOV_EXCL_STOP } + // Make sure the parts don't add up to too much - Number shortage = roundedPeriodicPayment - deltas.valueDelta; + Number shortage = roundedPeriodicPayment - deltas.valueDelta(); XRPL_ASSERT_PARTS( isRounded(asset, shortage, scale), @@ -1280,38 +1287,63 @@ computePaymentComponents( shortage = -excess; } + else if (shortage > beast::zero && totalOverpayment < beast::zero) + { + // If there's a shortage, and there's room in the loan itself, we can + // top up the parts to make the payment correct. + shortage = std::min(-totalOverpayment, shortage); + addressShortage(deltas, shortage, currentLedgerState); + } // The shortage should never be negative, which indicates that the parts are - // trying to take more than the whole payment. The shortage can be positive, - // which indicates that we're not going to take the whole payment amount, - // but if so, it must be small. + // trying to take more than the whole payment. The shortage should not be + // positive, either, which indicates that we're not going to take the whole + // payment amount. Only the last payment should be allowed to have a + // shortage, and that's handled in a special case above. XRPL_ASSERT_PARTS( - shortage == beast::zero || - (shortage > beast::zero && - ((asset.integral() && shortage < 3) || - (scale - shortage.exponent() > 14))), + shortage == beast::zero, "ripple::detail::computePaymentComponents", - "excess is extremely small"); + "no shortage or excess"); +#if LOANCOMPLETE + /* + // This used to be part of the above assert. It will eventually be removed + // if proved accurate + || + (shortage > beast::zero && + ((asset.integral() && shortage < 3) || + (scale - shortage.exponent() > 14))) + */ +#endif XRPL_ASSERT_PARTS( - deltas.valueDelta == + deltas.valueDelta() == deltas.principalDelta + deltas.interestDueDelta + deltas.managementFeeDueDelta, "ripple::detail::computePaymentComponents", "total value adds up"); XRPL_ASSERT_PARTS( - deltas.principalDelta >= beast::zero, + deltas.principalDelta >= beast::zero && + deltas.principalDelta <= currentLedgerState.principalOutstanding, "ripple::detail::computePaymentComponents", - "non-negative principal"); + "valid principal result"); XRPL_ASSERT_PARTS( - deltas.interestDueDelta >= beast::zero, + deltas.interestDueDelta >= beast::zero && + deltas.interestDueDelta <= currentLedgerState.interestDue, "ripple::detail::computePaymentComponents", - "non-negative interest"); + "valid interest result"); XRPL_ASSERT_PARTS( - deltas.managementFeeDueDelta >= beast::zero, + deltas.managementFeeDueDelta >= beast::zero && + deltas.managementFeeDueDelta <= currentLedgerState.managementFeeDue, "ripple::detail::computePaymentComponents", - "non-negative fee"); + "valid fee result"); + + XRPL_ASSERT_PARTS( + deltas.principalDelta + deltas.interestDueDelta + + deltas.managementFeeDueDelta > + beast::zero, + "ripple::detail::computePaymentComponents", + "payment parts add to payment"); return PaymentComponents{ #if LOANCOMPLETE @@ -1319,11 +1351,18 @@ computePaymentComponents( .rawPrincipal = rawPrincipal, .rawManagementFee = rawFee, #endif - // As a final safety check, don't return any negative values - .trackedValueDelta = std::max(deltas.valueDelta, Number::zero), - .trackedPrincipalDelta = std::max(deltas.principalDelta, Number::zero), - .trackedManagementFeeDelta = - std::max(deltas.managementFeeDueDelta, Number::zero), + // As a final safety check, ensure the value is non-negative, and won't + // make the corresponding item negative + .trackedValueDelta = std::clamp( + deltas.valueDelta(), numZero, currentLedgerState.valueOutstanding), + .trackedPrincipalDelta = std::clamp( + deltas.principalDelta, + numZero, + currentLedgerState.principalOutstanding), + .trackedManagementFeeDelta = std::clamp( + deltas.managementFeeDueDelta, + numZero, + currentLedgerState.managementFeeDue), }; } @@ -1381,28 +1420,23 @@ detail::LoanDeltas operator-(LoanState const& lhs, LoanState const& rhs) { detail::LoanDeltas result{ - .valueDelta = lhs.valueOutstanding - rhs.valueOutstanding, .principalDelta = lhs.principalOutstanding - rhs.principalOutstanding, .interestDueDelta = lhs.interestDue - rhs.interestDue, .managementFeeDueDelta = lhs.managementFeeDue - rhs.managementFeeDue, }; - XRPL_ASSERT_PARTS( - result.valueDelta >= 0, - "ripple::operator-(LoanState,LoanState)", - "valueDelta difference non-negative"); - XRPL_ASSERT_PARTS( - result.principalDelta >= 0, - "ripple::operator-(LoanState,LoanState)", - "principalDelta difference non-negative"); - XRPL_ASSERT_PARTS( - result.interestDueDelta >= 0, - "ripple::operator-(LoanState,LoanState)", - "interestDueDelta difference non-negative"); - XRPL_ASSERT_PARTS( - result.managementFeeDueDelta >= 0, - "ripple::operator-(LoanState,LoanState)", - "managementFeeDueDelta difference non-negative"); + return result; +} + +LoanState +operator-(LoanState const& lhs, detail::LoanDeltas const& rhs) +{ + LoanState result{ + .valueOutstanding = lhs.valueOutstanding - rhs.valueDelta(), + .principalOutstanding = lhs.principalOutstanding - rhs.principalDelta, + .interestDue = lhs.interestDue - rhs.interestDueDelta, + .managementFeeDue = lhs.managementFeeDue - rhs.managementFeeDueDelta, + }; return result; } @@ -1470,14 +1504,13 @@ calculateRawLoanState( Number const& periodicPayment, Number const& periodicRate, std::uint32_t const paymentRemaining, - TenthBips16 const managementFeeRate) + TenthBips32 const managementFeeRate) { if (paymentRemaining == 0) { return LoanState{ .valueOutstanding = 0, .principalOutstanding = 0, - .interestOutstanding = 0, .interestDue = 0, .managementFeeDue = 0}; } @@ -1493,7 +1526,6 @@ calculateRawLoanState( return LoanState{ .valueOutstanding = rawValueOutstanding, .principalOutstanding = rawPrincipalOutstanding, - .interestOutstanding = rawInterestOutstanding, .interestDue = rawInterestOutstanding - rawManagementFeeOutstanding, .managementFeeDue = rawManagementFeeOutstanding}; }; @@ -1504,7 +1536,7 @@ calculateRawLoanState( TenthBips32 interestRate, std::uint32_t paymentInterval, std::uint32_t const paymentRemaining, - TenthBips16 const managementFeeRate) + TenthBips32 const managementFeeRate) { return calculateRawLoanState( periodicPayment, @@ -1521,13 +1553,11 @@ calculateRoundedLoanState( { // This implementation is pretty trivial, but ensures the calculations are // consistent everywhere, and reduces copy/paste errors. - Number const interestOutstanding = - totalValueOutstanding - principalOutstanding; return { .valueOutstanding = totalValueOutstanding, .principalOutstanding = principalOutstanding, - .interestOutstanding = interestOutstanding, - .interestDue = interestOutstanding - managementFeeOutstanding, + .interestDue = totalValueOutstanding - principalOutstanding - + managementFeeOutstanding, .managementFeeDue = managementFeeOutstanding}; } @@ -1544,7 +1574,7 @@ Number computeFee( Asset const& asset, Number const& value, - TenthBips16 managementFeeRate, + TenthBips32 managementFeeRate, std::int32_t scale) { return roundToAsset( @@ -1571,7 +1601,7 @@ computeLoanProperties( TenthBips32 interestRate, std::uint32_t paymentInterval, std::uint32_t paymentsRemaining, - TenthBips16 managementFeeRate) + TenthBips32 managementFeeRate) { auto const periodicRate = loanPeriodicRate(interestRate, paymentInterval); XRPL_ASSERT( diff --git a/src/xrpld/app/tx/detail/LoanPay.cpp b/src/xrpld/app/tx/detail/LoanPay.cpp index ae39b6ccdb..e994227064 100644 --- a/src/xrpld/app/tx/detail/LoanPay.cpp +++ b/src/xrpld/app/tx/detail/LoanPay.cpp @@ -374,6 +374,10 @@ LoanPay::doApply() paymentParts->principalPaid + paymentParts->interestPaid; auto const totalPaidToVaultRounded = roundToAsset(asset, totalPaidToVaultRaw, vaultScale, Number::downward); + XRPL_ASSERT_PARTS( + !asset.integral() || totalPaidToVaultRaw == totalPaidToVaultRounded, + "ripple::LoanPay::doApply", + "rounding does nothing for integral asset"); auto const totalPaidToVaultForDebt = totalPaidToVaultRaw - paymentParts->valueChange; @@ -405,7 +409,21 @@ LoanPay::doApply() // Vault object state changes view.update(vaultSle); + Number const assetsAvailableBefore = *assetsAvailableProxy; + Number const pseudoAccountBalanceBefore = accountHolds( + view, + vaultPseudoAccount, + asset, + FreezeHandling::fhIGNORE_FREEZE, + AuthHandling::ahIGNORE_AUTH, + j_); + { + XRPL_ASSERT_PARTS( + assetsAvailableBefore == pseudoAccountBalanceBefore, + "ripple::LoanPay::doApply", + "vault pseudo balance agrees before"); + auto assetsTotalProxy = vaultSle->at(sfAssetsTotal); assetsAvailableProxy += totalPaidToVaultRounded; @@ -415,6 +433,13 @@ LoanPay::doApply() *assetsAvailableProxy <= *assetsTotalProxy, "ripple::LoanPay::doApply", "assets available must not be greater than assets outstanding"); + + if (*assetsAvailableProxy > *assetsTotalProxy) + { + // LCOV_EXCL_START + return tecINTERNAL; + // LCOV_EXCL_STOP + } } // Move funds @@ -488,6 +513,19 @@ LoanPay::doApply() WaiveTransferFee::Yes)) return ter; + Number const assetsAvailableAfter = *assetsAvailableProxy; + Number const pseudoAccountBalanceAfter = accountHolds( + view, + vaultPseudoAccount, + asset, + FreezeHandling::fhIGNORE_FREEZE, + AuthHandling::ahIGNORE_AUTH, + j_); + XRPL_ASSERT_PARTS( + assetsAvailableAfter == pseudoAccountBalanceAfter, + "ripple::LoanPay::doApply", + "vault pseudo balance agrees after"); + #if !NDEBUG auto const accountBalanceAfter = accountCanSend( view, account_, asset, fhIGNORE_FREEZE, ahIGNORE_AUTH, j_); diff --git a/src/xrpld/app/tx/detail/LoanSet.cpp b/src/xrpld/app/tx/detail/LoanSet.cpp index d8b0441b21..329d400f89 100644 --- a/src/xrpld/app/tx/detail/LoanSet.cpp +++ b/src/xrpld/app/tx/detail/LoanSet.cpp @@ -318,6 +318,87 @@ LoanSet::preclaim(PreclaimContext const& ctx) return tesSUCCESS; } +TER +LoanSet::checkGuards( + Asset const& vaultAsset, + Number const& principalRequested, + TenthBips32 interestRate, + std::uint32_t paymentTotal, + LoanProperties const& properties, + beast::Journal j) +{ + auto const totalInterestOutstanding = + properties.totalValueOutstanding - principalRequested; + // Guard 1: if there is no computed total interest over the life of the + // loan for a non-zero interest rate, we cannot properly amortize the + // loan + if (interestRate > TenthBips32{0} && totalInterestOutstanding <= 0) + { + // Unless this is a zero-interest loan, there must be some interest + // due on the loan, even if it's (measurable) dust + JLOG(j.warn()) << "Loan for " << principalRequested << " with " + << interestRate << "% interest has no interest due"; + return tecPRECISION_LOSS; + } + // Guard 1a: If there is any interest computed over the life of the + // loan, for a zero interest rate, something went sideways. + if (interestRate == TenthBips32{0} && totalInterestOutstanding > 0) + { + // LCOV_EXCL_START + JLOG(j.warn()) << "Loan for " << principalRequested + << " with 0% interest has interest due"; + return tecINTERNAL; + // LCOV_EXCL_STOP + } + + // Guard 2: if the principal portion of the first periodic payment is + // too small to be accurately represented with the given rounding mode, + // raise an error + if (properties.firstPaymentPrincipal <= 0) + { + // Check that some true (unrounded) principal is paid each period. + // Since the first payment pays the least principal, if it's good, + // they'll all be good. Note that the outstanding principal is + // rounded, and may not change right away. + JLOG(j.warn()) << "Loan is unable to pay principal."; + return tecPRECISION_LOSS; + } + + // Guard 3: If the periodic payment is so small that it can't even be + // rounded to a representable value, then the loan can't be paid. Also, + // avoids dividing by 0. + auto const roundedPayment = roundPeriodicPayment( + vaultAsset, properties.periodicPayment, properties.loanScale); + if (roundedPayment == beast::zero) + { + JLOG(j.warn()) << "Loan Periodic payment (" + << properties.periodicPayment << ") rounds to 0. "; + return tecPRECISION_LOSS; + } + + // Guard 4: if the rounded periodic payment is large enough that the + // loan can't be amortized in the specified number of payments, raise an + // error + { + NumberRoundModeGuard mg(Number::upward); + + if (std::int64_t const computedPayments{ + properties.totalValueOutstanding / roundedPayment}; + computedPayments != paymentTotal) + { + JLOG(j.warn()) << "Loan Periodic payment (" + << properties.periodicPayment << ") rounding (" + << roundedPayment << ") on a total value of " + << properties.totalValueOutstanding + << " can not complete the loan in the specified " + "number of payments (" + << computedPayments << " != " << paymentTotal << ")"; + return tecPRECISION_LOSS; + } + } + return tesSUCCESS; +} + TER LoanSet::doApply() { @@ -396,72 +477,14 @@ LoanSet::doApply() } } - auto const totalInterestOutstanding = - properties.totalValueOutstanding - principalRequested; - // Guard 1: if there is no computed total interest over the life of the loan - // for a non-zero interest rate, we cannot properly amortize the loan - if (interestRate > TenthBips32{0} && totalInterestOutstanding <= 0) - { - // Unless this is a zero-interest loan, there must be some interest due - // on the loan, even if it's (measurable) dust - JLOG(j_.warn()) << "Loan for " << principalRequested << " with " - << interestRate << "% interest has no interest due"; - return tecPRECISION_LOSS; - } - // Guard 1a: If there is any interest computed over the life of the loan, - // for a zero interest rate, something went sideways. - if (interestRate == TenthBips32{0} && totalInterestOutstanding > 0) - { - // LCOV_EXCL_START - JLOG(j_.warn()) << "Loan for " << principalRequested - << " with 0% interest has interest due"; - return tecINTERNAL; - // LCOV_EXCL_STOP - } - - // Guard 2: if the principal portion of the first periodic payment is too - // small to be accurately represented with the given rounding mode, raise an - // error - if (properties.firstPaymentPrincipal <= 0) - { - // Check that some true (unrounded) principal is paid each period. Since - // the first payment pays the least principal, if it's good, they'll - // all be good. Note that the outstanding principal is rounded, and - // may not change right away. - JLOG(j_.warn()) << "Loan is unable to pay principal."; - return tecPRECISION_LOSS; - } - - // Guard 3: If the periodic payment is so small that it can't even be - // rounded to a representable value, then the loan can't be paid. Also, - // avoids dividing by 0. - auto const roundedPayment = roundPeriodicPayment( - vaultAsset, properties.periodicPayment, properties.loanScale); - if (roundedPayment == beast::zero) - { - JLOG(j_.warn()) << "Loan Periodic payment (" - << properties.periodicPayment << ") rounds to 0. "; - return tecPRECISION_LOSS; - } - - // Guard 4: if the rounded periodic payment is large enough that the loan - // can't be amortized in the specified number of payments, raise an error - { - NumberRoundModeGuard mg(Number::upward); - - if (std::int64_t const computedPayments{ - properties.totalValueOutstanding / roundedPayment}; - computedPayments != paymentTotal) - { - JLOG(j_.warn()) - << "Loan Periodic payment (" << properties.periodicPayment - << ") rounding (" << roundedPayment - << ") will complete the " - "loan in less than the specified number of payments (" - << computedPayments << " < " << paymentTotal << ")"; - return tecPRECISION_LOSS; - } - } + if (auto const ret = checkGuards( + vaultAsset, + principalRequested, + interestRate, + paymentTotal, + properties, + j_)) + return ret; // Check that the other computed values are valid if (properties.managementFeeOwedToBroker < 0 || diff --git a/src/xrpld/app/tx/detail/LoanSet.h b/src/xrpld/app/tx/detail/LoanSet.h index 68c7f4ca3c..04d9eccaf2 100644 --- a/src/xrpld/app/tx/detail/LoanSet.h +++ b/src/xrpld/app/tx/detail/LoanSet.h @@ -20,6 +20,7 @@ #ifndef RIPPLE_TX_LOANSET_H_INCLUDED #define RIPPLE_TX_LOANSET_H_INCLUDED +#include #include namespace ripple { @@ -54,6 +55,15 @@ public: static TER preclaim(PreclaimContext const& ctx); + static TER + checkGuards( + Asset const& vaultAsset, + Number const& principalRequested, + TenthBips32 interestRate, + std::uint32_t paymentTotal, + LoanProperties const& properties, + beast::Journal j); + TER doApply() override;