From 281dbd2873d9d71960389b99a28b3f53b21aa3d9 Mon Sep 17 00:00:00 2001 From: JCW Date: Wed, 11 Feb 2026 11:38:44 +0000 Subject: [PATCH] Add unit tests --- include/xrpl/basics/Number.h | 3 +- src/test/app/Invariants_test.cpp | 44 +- src/test/app/Loan/LoanBase.h | 2513 ++++++++++ src/test/app/Loan/LoanBatch_test.cpp | 6938 +++----------------------- src/test/app/Loan/Loan_test.cpp | 1344 +---- src/test/jtx/impl/vault.cpp | 3 +- src/test/jtx/vault.h | 1 + 7 files changed, 3108 insertions(+), 7738 deletions(-) create mode 100644 src/test/app/Loan/LoanBase.h diff --git a/include/xrpl/basics/Number.h b/include/xrpl/basics/Number.h index f6967005f6..d2a4620399 100644 --- a/include/xrpl/basics/Number.h +++ b/include/xrpl/basics/Number.h @@ -106,8 +106,7 @@ template concept Integral64 = std::is_same_v || std::is_same_v; template -concept CanUseAsScale = requires(Asset a, Number n) { STAmount(a, n); } && - requires(STAmount s) { s.exponent(); }; +concept CanUseAsScale = requires(Asset a, Number n) { STAmount(a, n); } && requires(STAmount s) { s.exponent(); }; /** Number is a floating point type that can represent a wide range of values. * diff --git a/src/test/app/Invariants_test.cpp b/src/test/app/Invariants_test.cpp index 98aa145d59..ff82d658b9 100644 --- a/src/test/app/Invariants_test.cpp +++ b/src/test/app/Invariants_test.cpp @@ -3728,8 +3728,7 @@ class Invariants_test : public beast::unit_test::suite NumberMantissaScaleGuard g{MantissaRange::large}; - auto makeDelta = - [&vaultAsset](Number const& n) -> ValidVault::DeltaInfo { + auto makeDelta = [&vaultAsset](Number const& n) -> ValidVault::DeltaInfo { return {n, n.scale(vaultAsset.raw())}; }; @@ -3742,24 +3741,17 @@ class Invariants_test : public beast::unit_test::suite { .name = "Mixed integer and Number values", .expectedMinScale = -15, - .values = - {makeDelta(1), makeDelta(-1), makeDelta(Number{10, -1})}, + .values = {makeDelta(1), makeDelta(-1), makeDelta(Number{10, -1})}, }, { .name = "Mixed scales", .expectedMinScale = -17, - .values = - {makeDelta(Number{1, -2}), - makeDelta(Number{5, -3}), - makeDelta(Number{3, -2})}, + .values = {makeDelta(Number{1, -2}), makeDelta(Number{5, -3}), makeDelta(Number{3, -2})}, }, { .name = "Equal scales", .expectedMinScale = -16, - .values = - {makeDelta(Number{1, -1}), - makeDelta(Number{5, -1}), - makeDelta(Number{1, -1})}, + .values = {makeDelta(Number{1, -1}), makeDelta(Number{5, -1}), makeDelta(Number{1, -1})}, }, { .name = "Mixed mantissa sizes", @@ -3776,24 +3768,20 @@ class Invariants_test : public beast::unit_test::suite { testcase("vault computeMinScale: " + tc.name); - auto const actualScale = - ValidVault::computeMinScale(vaultAsset, tc.values); + auto const actualScale = ValidVault::computeMinScale(vaultAsset, tc.values); BEAST_EXPECTS( actualScale == tc.expectedMinScale, - "expected: " + std::to_string(tc.expectedMinScale) + - ", actual: " + std::to_string(actualScale)); + "expected: " + std::to_string(tc.expectedMinScale) + ", actual: " + std::to_string(actualScale)); for (auto const& num : tc.values) { // None of these scales are far enough apart that rounding the // values would lose information, so check that the rounded // value matches the original. - auto const actualRounded = - roundToAsset(vaultAsset, num.delta, actualScale); + auto const actualRounded = roundToAsset(vaultAsset, num.delta, actualScale); BEAST_EXPECTS( actualRounded == num.delta, - "number " + to_string(num.delta) + " rounded to scale " + - std::to_string(actualScale) + " is " + + "number " + to_string(num.delta) + " rounded to scale " + std::to_string(actualScale) + " is " + to_string(actualRounded)); } } @@ -3817,13 +3805,11 @@ class Invariants_test : public beast::unit_test::suite { testcase("vault computeMinScale: " + tc.name); - auto const actualScale = - ValidVault::computeMinScale(vaultAsset, tc.values); + auto const actualScale = ValidVault::computeMinScale(vaultAsset, tc.values); BEAST_EXPECTS( actualScale == tc.expectedMinScale, - "expected: " + std::to_string(tc.expectedMinScale) + - ", actual: " + std::to_string(actualScale)); + "expected: " + std::to_string(tc.expectedMinScale) + ", actual: " + std::to_string(actualScale)); std::optional first; Number firstRounded; for (auto const& num : tc.values) @@ -3831,16 +3817,14 @@ class Invariants_test : public beast::unit_test::suite if (!first) { first = num.delta; - firstRounded = - roundToAsset(vaultAsset, num.delta, actualScale); + firstRounded = roundToAsset(vaultAsset, num.delta, actualScale); continue; } - auto const numRounded = - roundToAsset(vaultAsset, num.delta, actualScale); + auto const numRounded = roundToAsset(vaultAsset, num.delta, actualScale); BEAST_EXPECTS( numRounded != firstRounded, - "at a scale of " + std::to_string(actualScale) + " " + - to_string(num.delta) + " == " + to_string(*first)); + "at a scale of " + std::to_string(actualScale) + " " + to_string(num.delta) + + " == " + to_string(*first)); } } } diff --git a/src/test/app/Loan/LoanBase.h b/src/test/app/Loan/LoanBase.h new file mode 100644 index 0000000000..130c0e09ce --- /dev/null +++ b/src/test/app/Loan/LoanBase.h @@ -0,0 +1,2513 @@ +#pragma once + +#include +// +#include +#include + +#include +#include +#include +#include + +#include +#include + +#include + +namespace xrpl::test { +class LoanBase : 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}; + + std::string const iouCurrency{"IOU"}; + + struct BrokerParameters + { + Number vaultDeposit = 1'000'000; + Number debtMax = 25'000; + TenthBips32 coverRateMin = percentageToTenthBips(10); + int coverDeposit = 1000; + TenthBips16 managementFeeRate{100}; + TenthBips32 coverRateLiquidation = percentageToTenthBips(25); + std::string data{}; + std::uint32_t flags = 0; + + Number + maxCoveredLoanValue(Number const& currentDebt) const + { + NumberRoundModeGuard mg(Number::downward); + 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; + uint256 vaultID; + BrokerParameters params; + BrokerInfo( + jtx::PrettyAsset const& asset_, + Keylet const& brokerKeylet_, + Keylet const& vaultKeylet_, + BrokerParameters const& p) + : asset(asset_), brokerID(brokerKeylet_.key), vaultID(vaultKeylet_.key), params(p) + { + } + + Keylet + brokerKeylet() const + { + return keylet::loanbroker(brokerID); + } + Keylet + vaultKeylet() const + { + return keylet::vault(vaultID); + } + + int + vaultScale(jtx::Env const& env) const + { + using namespace jtx; + + auto const vaultSle = env.le(keylet::vault(vaultID)); + return getAssetsTotalScale(vaultSle); + } + }; + + struct LoanParameters + { + // The account submitting the transaction. May be borrower or broker. + jtx::Account account; + // The counterparty. Should be the other of borrower or broker. + jtx::Account counter; + // Whether the counterparty is specified in the `counterparty` field, or + // only signs. + bool counterpartyExplicit = true; + Number principalRequest; + 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{}; + + Json::Value + getTransaction(jtx::Env& env, BrokerInfo const& broker) const + { + using namespace jtx; + using namespace jtx::loan; + + JTx jt{loan::set(account, broker.brokerID, broker.asset(principalRequest).number(), flags.value_or(0))}; + + fee{setFee.value_or(env.current()->fees().base * 2)}(env, jt); + + if (counterpartyExplicit) + counterparty(counter)(env, jt); + if (originationFee) + loanOriginationFee(broker.asset(*originationFee).number())(env, jt); + if (serviceFee) + loanServiceFee(broker.asset(*serviceFee).number())(env, jt); + if (lateFee) + latePaymentFee(broker.asset(*lateFee).number())(env, jt); + if (closeFee) + closePaymentFee(broker.asset(*closeFee).number())(env, jt); + if (overFee) + overpaymentFee (*overFee)(env, jt); + if (interest) + interestRate (*interest)(env, jt); + if (lateInterest) + lateInterestRate (*lateInterest)(env, jt); + if (closeInterest) + closeInterestRate (*closeInterest)(env, jt); + if (overpaymentInterest) + overpaymentInterestRate (*overpaymentInterest)(env, jt); + if (payTotal) + paymentTotal (*payTotal)(env, jt); + if (payInterval) + paymentInterval (*payInterval)(env, jt); + if (gracePd) + gracePeriod (*gracePd)(env, jt); + + return jt.jv; + } + + template + jtx::JTx + operator()(jtx::Env& env, BrokerInfo const& broker, FN const&... fN) const + { + using namespace jtx; + using namespace jtx::loan; + + auto tx = getTransaction(env, broker, std::forward(fN)...); + auto jt = env.jt(tx, std::forward(fN)...); + sig(sfCounterpartySignature, counter)(env, jt); + return jt; + } + }; + + struct PaymentParameters + { + Number overpaymentFactor = Number{1}; + std::optional overpaymentExtra = std::nullopt; + std::uint32_t flags = 0; + bool showStepBalances = false; + bool validateBalances = true; + + static PaymentParameters const& + defaults() + { + static PaymentParameters const result{}; + return result; + } + }; + + struct LoanState + { + std::uint32_t previousPaymentDate = 0; + NetClock::time_point startDate = {}; + std::uint32_t nextPaymentDate = 0; + std::uint32_t paymentRemaining = 0; + std::int32_t const loanScale = 0; + Number totalValue = 0; + Number principalOutstanding = 0; + Number managementFeeOutstanding = 0; + Number periodicPayment = 0; + std::uint32_t flags = 0; + std::uint32_t const paymentInterval = 0; + TenthBips32 const interestRate{}; + }; + + /** Helper class to compare the expected state of a loan and loan broker + * against the data in the ledger. + */ + struct VerifyLoanStatus + { + public: + jtx::Env const& env; + BrokerInfo const& broker; + jtx::Account const& pseudoAccount; + Keylet const& loanKeylet; + + VerifyLoanStatus( + jtx::Env const& env_, + BrokerInfo const& broker_, + jtx::Account const& pseudo_, + Keylet const& keylet_) + : env(env_), broker(broker_), pseudoAccount(pseudo_), loanKeylet(keylet_) + { + } + + /** Checks the expected broker state against the ledger + */ + void + checkBroker( + Number const& principalOutstanding, + Number const& interestOwed, + TenthBips32 interestRate, + std::uint32_t paymentInterval, + std::uint32_t paymentsRemaining, + std::uint32_t ownerCount) const + { + using namespace jtx; + if (auto brokerSle = env.le(keylet::loanbroker(broker.brokerID)); env.test.BEAST_EXPECT(brokerSle)) + { + TenthBips16 const managementFeeRate{brokerSle->at(sfManagementFeeRate)}; + auto const brokerDebt = brokerSle->at(sfDebtTotal); + auto const expectedDebt = principalOutstanding + interestOwed; + env.test.BEAST_EXPECT(brokerDebt == expectedDebt); + env.test.BEAST_EXPECT( + env.balance(pseudoAccount, broker.asset).number() == brokerSle->at(sfCoverAvailable)); + env.test.BEAST_EXPECT(brokerSle->at(sfOwnerCount) == ownerCount); + + if (auto vaultSle = env.le(keylet::vault(brokerSle->at(sfVaultID))); env.test.BEAST_EXPECT(vaultSle)) + { + Account const vaultPseudo{"vaultPseudoAccount", vaultSle->at(sfAccount)}; + env.test.BEAST_EXPECT( + vaultSle->at(sfAssetsAvailable) == env.balance(vaultPseudo, broker.asset).number()); + if (ownerCount == 0) + { + // Allow some slop for rounding IOUs + + // TODO: This needs to be an exact match once all the + // other rounding issues are worked out. + auto const total = vaultSle->at(sfAssetsTotal); + auto const available = vaultSle->at(sfAssetsAvailable); + env.test.BEAST_EXPECT( + total == available || + (!broker.asset.integral() && available != 0 && + ((total - available) / available < Number(1, -6)))); + env.test.BEAST_EXPECT(vaultSle->at(sfLossUnrealized) == 0); + } + } + } + } + + void + checkPayment( + std::int32_t loanScale, + jtx::Account const& account, + jtx::PrettyAmount const& balanceBefore, + STAmount const& expectedPayment, + jtx::PrettyAmount const& adjustment) const + { + auto const borrowerScale = std::max(loanScale, balanceBefore.number().exponent()); + + STAmount const balanceChangeAmount{ + broker.asset, roundToAsset(broker.asset, expectedPayment + adjustment, borrowerScale)}; + { + auto const difference = roundToScale( + env.balance(account, broker.asset) - (balanceBefore - balanceChangeAmount), borrowerScale); + env.test.expect( + roundToScale(difference, loanScale) >= beast::zero, + "Balance before: " + to_string(balanceBefore.value()) + + ", expected change: " + to_string(balanceChangeAmount) + + ", difference (balance after - expected): " + to_string(difference), + __FILE__, + __LINE__); + } + } + + /** Checks both the loan and broker expect states against the ledger */ + void + operator()( + std::uint32_t previousPaymentDate, + std::uint32_t nextPaymentDate, + std::uint32_t paymentRemaining, + Number const& loanScale, + Number const& totalValue, + Number const& principalOutstanding, + Number const& managementFeeOutstanding, + Number const& periodicPayment, + std::uint32_t flags) const + { + using namespace jtx; + if (auto loan = env.le(loanKeylet); env.test.BEAST_EXPECT(loan)) + { + env.test.BEAST_EXPECT(loan->at(sfPreviousPaymentDueDate) == previousPaymentDate); + env.test.BEAST_EXPECT(loan->at(sfPaymentRemaining) == paymentRemaining); + env.test.BEAST_EXPECT(loan->at(sfNextPaymentDueDate) == nextPaymentDate); + env.test.BEAST_EXPECT(loan->at(sfLoanScale) == loanScale); + env.test.BEAST_EXPECT(loan->at(sfTotalValueOutstanding) == totalValue); + env.test.BEAST_EXPECT(loan->at(sfPrincipalOutstanding) == principalOutstanding); + env.test.BEAST_EXPECT(loan->at(sfManagementFeeOutstanding) == managementFeeOutstanding); + env.test.BEAST_EXPECT(loan->at(sfPeriodicPayment) == periodicPayment); + env.test.BEAST_EXPECT(loan->at(sfFlags) == flags); + + auto const ls = constructRoundedLoanState(loan); + + auto const interestRate = TenthBips32{loan->at(sfInterestRate)}; + auto const paymentInterval = loan->at(sfPaymentInterval); + checkBroker(principalOutstanding, ls.interestDue, interestRate, paymentInterval, paymentRemaining, 1); + + if (auto brokerSle = env.le(keylet::loanbroker(broker.brokerID)); env.test.BEAST_EXPECT(brokerSle)) + { + if (auto vaultSle = env.le(keylet::vault(brokerSle->at(sfVaultID))); + env.test.BEAST_EXPECT(vaultSle)) + { + if ((flags & lsfLoanImpaired) && !(flags & lsfLoanDefault)) + { + env.test.BEAST_EXPECT( + vaultSle->at(sfLossUnrealized) == totalValue - managementFeeOutstanding); + } + else + { + env.test.BEAST_EXPECT(vaultSle->at(sfLossUnrealized) == 0); + } + } + } + } + } + + /** Checks both the loan and broker expect states against the ledger */ + void + operator()(LoanState const& state) const + { + operator()( + state.previousPaymentDate, + state.nextPaymentDate, + state.paymentRemaining, + state.loanScale, + state.totalValue, + state.principalOutstanding, + state.managementFeeOutstanding, + state.periodicPayment, + state.flags); + }; + }; + + struct VaultAndBrokerTransactions + { + Json::Value vaultCreateTx; + Keylet vaultKeylet; + Json::Value vaultDepositTx; + Json::Value brokerSetTx; + Keylet brokerKeylet; + std::optional coverDepositTx; + jtx::PrettyAsset asset; + BrokerParameters params; + }; + + static VaultAndBrokerTransactions + createVaultAndBrokerTransactions( + jtx::Env& env, + jtx::PrettyAsset const& asset, + jtx::Account const& lender, + BrokerParameters const& params = BrokerParameters::defaults(), + std::optional lenderSeq = std::nullopt) + { + uint32_t sequence = lenderSeq ? *lenderSeq : env.seq(lender); + + using namespace jtx; + + Vault vault{env}; + + auto const deposit = asset(params.vaultDeposit); + auto const debtMaximumValue = asset(params.debtMax).value(); + auto const coverDepositValue = asset(params.coverDeposit).value(); + + auto const coverRateMinValue = params.coverRateMin; + + auto [vaultCreateTx, vaultKeylet] = vault.create({.owner = lender, .asset = asset, .sequence = sequence}); + + auto vaultDepositTx = vault.deposit({.depositor = lender, .id = vaultKeylet.key, .amount = deposit}); + + auto const brokerKeylet = keylet::loanbroker(lender.id(), sequence + 2); + + using namespace loanBroker; + + JTx brokerSetJtx = JTx{set(lender, vaultKeylet.key, params.flags)}; + data(params.data)(env, brokerSetJtx); + managementFeeRate(params.managementFeeRate)(env, brokerSetJtx); + debtMaximum(debtMaximumValue)(env, brokerSetJtx); + coverRateMinimum(coverRateMinValue)(env, brokerSetJtx); + coverRateLiquidation(TenthBips32(params.coverRateLiquidation))(env, brokerSetJtx); + + auto brokerSetTx = brokerSetJtx.jv; + + std::optional coverDepositTxOpt; + if (coverDepositValue != beast::zero) + coverDepositTxOpt = coverDeposit(lender, brokerKeylet.key, coverDepositValue); + + return { + .vaultCreateTx = vaultCreateTx, + .vaultKeylet = vaultKeylet, + .vaultDepositTx = vaultDepositTx, + .brokerSetTx = brokerSetTx, + .brokerKeylet = brokerKeylet, + .coverDepositTx = coverDepositTxOpt, + .asset = asset, + .params = params}; + } + + void + checkVaultAndBroker(jtx::Env& env, VaultAndBrokerTransactions const& txs) + { + using namespace jtx; + + auto const deposit = txs.asset(txs.params.vaultDeposit); + + // Check vault exists + BEAST_EXPECT(env.le(txs.vaultKeylet)); + + // Check vault deposit + if (auto const vault = env.le(keylet::vault(txs.vaultKeylet.key)); BEAST_EXPECT(vault)) + { + BEAST_EXPECT(vault->at(sfAssetsAvailable) == deposit.value()); + } + } + + BrokerInfo + createVaultAndBroker( + jtx::Env& env, + jtx::PrettyAsset const& asset, + jtx::Account const& lender, + BrokerParameters const& params = BrokerParameters::defaults()) + { + using namespace jtx; + + auto txs = createVaultAndBrokerTransactions(env, asset, lender, params); + + env(txs.vaultCreateTx); + env.close(); + + env(txs.vaultDepositTx); + env.close(); + + env(txs.brokerSetTx); + env.close(); + + if (txs.coverDepositTx) + env(*txs.coverDepositTx); + + env.close(); + + checkVaultAndBroker(env, txs); + + return {asset, txs.brokerKeylet, txs.vaultKeylet, params}; + } + + /// Get the state without checking anything + LoanState + getCurrentState(jtx::Env const& env, BrokerInfo const& broker, Keylet const& loanKeylet) + { + using d = NetClock::duration; + using tp = NetClock::time_point; + + // Lookup the current loan state + if (auto loan = env.le(loanKeylet); BEAST_EXPECT(loan)) + { + return LoanState{ + .previousPaymentDate = loan->at(sfPreviousPaymentDueDate), + .startDate = tp{d{loan->at(sfStartDate)}}, + .nextPaymentDate = loan->at(sfNextPaymentDueDate), + .paymentRemaining = loan->at(sfPaymentRemaining), + .loanScale = loan->at(sfLoanScale), + .totalValue = loan->at(sfTotalValueOutstanding), + .principalOutstanding = loan->at(sfPrincipalOutstanding), + .managementFeeOutstanding = loan->at(sfManagementFeeOutstanding), + .periodicPayment = loan->at(sfPeriodicPayment), + .flags = loan->at(sfFlags), + .paymentInterval = loan->at(sfPaymentInterval), + .interestRate = TenthBips32{loan->at(sfInterestRate)}, + }; + } + return LoanState{}; + } + + /// Get the state and check the values against the parameters used in + /// `lifecycle` + LoanState + getCurrentState( + jtx::Env const& env, + BrokerInfo const& broker, + Keylet const& loanKeylet, + VerifyLoanStatus const& verifyLoanStatus) + { + using namespace std::chrono_literals; + using d = NetClock::duration; + using tp = NetClock::time_point; + + auto const state = getCurrentState(env, broker, loanKeylet); + BEAST_EXPECT(state.previousPaymentDate == 0); + BEAST_EXPECT(tp{d{state.nextPaymentDate}} == state.startDate + 600s); + BEAST_EXPECT(state.paymentRemaining == 12); + BEAST_EXPECT(state.principalOutstanding == broker.asset(1000).value()); + BEAST_EXPECT( + state.loanScale >= + (broker.asset.integral() ? 0 : std::max(broker.vaultScale(env), state.principalOutstanding.exponent()))); + BEAST_EXPECT(state.paymentInterval == 600); + { + NumberRoundModeGuard mg(Number::upward); + BEAST_EXPECT( + state.totalValue == + roundToAsset(broker.asset, state.periodicPayment * state.paymentRemaining, state.loanScale)); + } + BEAST_EXPECT( + state.managementFeeOutstanding == + computeManagementFee( + broker.asset, + state.totalValue - state.principalOutstanding, + broker.params.managementFeeRate, + state.loanScale)); + + verifyLoanStatus(state); + + return state; + } + + bool + canImpairLoan(jtx::Env const& env, BrokerInfo const& broker, LoanState const& state) + { + if (auto const brokerSle = env.le(keylet::loanbroker(broker.brokerID)); BEAST_EXPECT(brokerSle)) + { + if (auto const vaultSle = env.le(keylet::vault(brokerSle->at(sfVaultID))); BEAST_EXPECT(vaultSle)) + { + // log << vaultSle->getJson() << std::endl; + auto const assetsUnavailable = vaultSle->at(sfAssetsTotal) - vaultSle->at(sfAssetsAvailable); + auto const unrealizedLoss = + vaultSle->at(sfLossUnrealized) + state.totalValue - state.managementFeeOutstanding; + + if (!BEAST_EXPECT(unrealizedLoss <= assetsUnavailable)) + { + return false; + } + } + } + return true; + } + + enum class AssetType { XRP = 0, IOU = 1, MPT = 2 }; + + // Specify the accounts as params to allow other accounts to be used + jtx::PrettyAsset + createAsset( + jtx::Env& env, + AssetType assetType, + BrokerParameters const& brokerParams, + jtx::Account const& issuer, + jtx::Account const& lender, + jtx::Account const& borrower) + { + using namespace jtx; + + switch (assetType) + { + case AssetType::XRP: + // TODO: remove the factor, and set up loans in drops + return PrettyAsset{xrpIssue(), 1'000'000}; + + case AssetType::IOU: { + PrettyAsset const asset{issuer[iouCurrency]}; + + auto const limit = asset(100 * (brokerParams.vaultDeposit + brokerParams.coverDeposit)); + if (lender != issuer) + env(trust(lender, limit)); + if (borrower != issuer) + env(trust(borrower, limit)); + + return asset; + } + + case AssetType::MPT: { + // Enough to cover initial fees + if (!env.le(keylet::account(issuer))) + env.fund(env.current()->fees().accountReserve(10) * 10, issuer); + if (!env.le(keylet::account(lender))) + env.fund(env.current()->fees().accountReserve(10) * 10, noripple(lender)); + if (!env.le(keylet::account(borrower))) + env.fund(env.current()->fees().accountReserve(10) * 10, noripple(borrower)); + + 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 + if (lender != issuer) + mptt.authorize({.account = lender}); + if (borrower != issuer) + mptt.authorize({.account = borrower}); + + env.close(); + + return asset; + } + + default: + throw std::runtime_error("Unknown asset type"); + } + } + + void + describeLoan( + jtx::Env& env, + BrokerParameters const& brokerParams, + LoanParameters const& loanParams, + AssetType assetType, + jtx::Account const& issuer, + jtx::Account const& lender, + jtx::Account const& borrower) + { + using namespace jtx; + + auto const asset = createAsset(env, assetType, brokerParams, issuer, lender, borrower); + auto const principal = asset(loanParams.principalRequest).number(); + auto const interest = loanParams.interest.value_or(TenthBips32{}); + auto const interval = loanParams.payInterval.value_or(LoanSet::defaultPaymentInterval); + auto const total = loanParams.payTotal.value_or(LoanSet::defaultPaymentTotal); + auto const feeRate = brokerParams.managementFeeRate; + auto const props = computeLoanProperties( + asset, principal, interest, interval, total, feeRate, asset(brokerParams.vaultDeposit).number().exponent()); + log << "Loan properties:\n" + << "\tPrincipal: " << principal << std::endl + << "\tInterest rate: " << interest << std::endl + << "\tPayment interval: " << interval << std::endl + << "\tManagement Fee Rate: " << feeRate << std::endl + << "\tTotal Payments: " << total << std::endl + << "\tPeriodic Payment: " << props.periodicPayment << std::endl + << "\tTotal Value: " << props.loanState.valueOutstanding << std::endl + << "\tManagement Fee: " << props.loanState.managementFeeDue << std::endl + << "\tLoan Scale: " << props.loanScale << std::endl + << "\tFirst payment principal: " << props.firstPaymentPrincipal << std::endl; + + // checkGuards returns a TER, so success is 0 + BEAST_EXPECT(!checkLoanGuards( + asset, + asset(loanParams.principalRequest).number(), + loanParams.interest.value_or(TenthBips32{}) != beast::zero, + loanParams.payTotal.value_or(LoanSet::defaultPaymentTotal), + props, + env.journal)); + } + + std::optional> + createLoan( + jtx::Env& env, + AssetType assetType, + BrokerParameters const& brokerParams, + LoanParameters const& loanParams, + jtx::Account const& issuer, + jtx::Account const& lender, + jtx::Account const& borrower) + { + using namespace jtx; + + // Enough to cover initial fees + env.fund(env.current()->fees().accountReserve(10) * 10, issuer); + if (lender != issuer) + env.fund(env.current()->fees().accountReserve(10) * 10, noripple(lender)); + if (borrower != issuer && borrower != lender) + env.fund(env.current()->fees().accountReserve(10) * 10, noripple(borrower)); + + describeLoan(env, brokerParams, loanParams, assetType, issuer, lender, borrower); + + // Make the asset + auto const asset = createAsset(env, assetType, brokerParams, issuer, lender, borrower); + + env.close(); + if (asset.native() || lender != issuer) + env( + pay((asset.native() ? env.master : issuer), + lender, + asset(brokerParams.vaultDeposit + brokerParams.coverDeposit))); + // Fund the borrower later once we know the total loan + // size + + BrokerInfo const broker = createVaultAndBroker(env, asset, lender, brokerParams); + + auto const pseudoAcctOpt = [&]() -> std::optional { + auto const brokerSle = env.le(keylet::loanbroker(broker.brokerID)); + if (!BEAST_EXPECT(brokerSle)) + return std::nullopt; + auto const brokerPseudo = brokerSle->at(sfAccount); + return Account("Broker pseudo-account", brokerPseudo); + }(); + if (!pseudoAcctOpt) + return std::nullopt; + Account const& pseudoAcct = *pseudoAcctOpt; + + auto const loanKeyletOpt = [&]() -> std::optional { + auto const brokerSle = env.le(keylet::loanbroker(broker.brokerID)); + if (!BEAST_EXPECT(brokerSle)) + return std::nullopt; + + // Broker has no loans + BEAST_EXPECT(brokerSle->at(sfOwnerCount) == 0); + + // The loan keylet is based on the LoanSequence of the + // _LOAN_BROKER_ object. + auto const loanSequence = brokerSle->at(sfLoanSequence); + return keylet::loan(broker.brokerID, loanSequence); + }(); + if (!loanKeyletOpt) + return std::nullopt; + Keylet const& loanKeylet = *loanKeyletOpt; + + env(loanParams(env, broker)); + + env.close(); + + return std::make_tuple(broker, loanKeylet, pseudoAcct); + } + + void + topUpBorrower( + jtx::Env& env, + BrokerInfo const& broker, + jtx::Account const& issuer, + jtx::Account const& borrower, + LoanState const& state, + std::optional const& servFee) + { + using namespace jtx; + + STAmount const serviceFee = broker.asset(servFee.value_or(0)); + + // Ensure the borrower has enough funds to make the payments + // (including tx fees, if necessary) + auto const borrowerBalance = env.balance(borrower, broker.asset); + + auto const baseFee = env.current()->fees().base; + + // Add extra for transaction fees and reserves, if appropriate, or a + // tiny amount for the extra paid in each transaction + auto const totalNeeded = state.totalValue + (serviceFee * state.paymentRemaining) + + (broker.asset.native() ? Number( + baseFee * state.paymentRemaining + + env.current()->fees().accountReserve(env.ownerCount(borrower))) + : broker.asset(15).number()); + + auto const shortage = totalNeeded - borrowerBalance.number(); + + if (shortage > beast::zero && (broker.asset.native() || issuer != borrower)) + env(pay((broker.asset.native() ? env.master : issuer), borrower, STAmount{broker.asset, shortage})); + } + + void + makeLoanPayments( + jtx::Env& env, + BrokerInfo const& broker, + LoanParameters const& loanParams, + Keylet const& loanKeylet, + VerifyLoanStatus const& verifyLoanStatus, + jtx::Account const& issuer, + jtx::Account const& lender, + jtx::Account const& borrower, + PaymentParameters const& paymentParams = PaymentParameters::defaults()) + { + // Make all the individual payments + using namespace jtx; + using namespace jtx::loan; + using namespace std::chrono_literals; + using d = NetClock::duration; + + bool const showStepBalances = paymentParams.showStepBalances; + + auto const currencyLabel = getCurrencyLabel(broker.asset); + + auto const baseFee = env.current()->fees().base; + + env.close(); + auto state = getCurrentState(env, broker, loanKeylet); + + verifyLoanStatus(state); + + STAmount const serviceFee = broker.asset(loanParams.serviceFee.value_or(0)); + + topUpBorrower(env, broker, issuer, borrower, state, loanParams.serviceFee); + + // Periodic payment amount will consist of + // 1. principal outstanding (1000) + // 2. interest interest rate (at 12%) + // 3. payment interval (600s) + // 4. loan service fee (2) + // Calculate these values without the helper functions + // to verify they're working correctly The numbers in + // the below BEAST_EXPECTs may not hold across assets. + auto const periodicRate = loanPeriodicRate(state.interestRate, state.paymentInterval); + STAmount const roundedPeriodicPayment{ + broker.asset, roundPeriodicPayment(broker.asset, state.periodicPayment, state.loanScale)}; + + if (!showStepBalances) + log << currencyLabel << " Payment components: " + << "Payments remaining, " + << "rawInterest, rawPrincipal, " + "rawMFee, " + << "trackedValueDelta, trackedPrincipalDelta, " + "trackedInterestDelta, trackedMgmtFeeDelta, special" + << std::endl; + + // Include the service fee + STAmount const totalDue = roundToScale(roundedPeriodicPayment + serviceFee, state.loanScale, Number::upward); + + auto currentRoundedState = + constructLoanState(state.totalValue, state.principalOutstanding, state.managementFeeOutstanding); + { + auto const raw = computeTheoreticalLoanState( + state.periodicPayment, periodicRate, state.paymentRemaining, broker.params.managementFeeRate); + + if (showStepBalances) + { + log << currencyLabel << " Starting loan balances: " + << "\n\tTotal value: " << currentRoundedState.valueOutstanding + << "\n\tPrincipal: " << currentRoundedState.principalOutstanding + << "\n\tInterest: " << currentRoundedState.interestDue + << "\n\tMgmt fee: " << currentRoundedState.managementFeeDue << "\n\tPayments remaining " + << state.paymentRemaining << std::endl; + } + else + { + log << currencyLabel << " Loan starting state: " << state.paymentRemaining << ", " << raw.interestDue + << ", " << raw.principalOutstanding << ", " << raw.managementFeeDue << ", " + << currentRoundedState.valueOutstanding << ", " << currentRoundedState.principalOutstanding << ", " + << currentRoundedState.interestDue << ", " << currentRoundedState.managementFeeDue << std::endl; + } + } + + // Try to pay a little extra to show that it's _not_ + // taken + auto const extraAmount = paymentParams.overpaymentExtra + ? broker.asset(*paymentParams.overpaymentExtra).value() + : std::min(broker.asset(10).value(), STAmount{broker.asset, totalDue / 20}); + + STAmount const transactionAmount = + STAmount{broker.asset, totalDue * paymentParams.overpaymentFactor} + extraAmount; + + auto const borrowerInitialBalance = env.balance(borrower, broker.asset).number(); + auto const initialState = state; + detail::PaymentComponents totalPaid{ + .trackedValueDelta = 0, .trackedPrincipalDelta = 0, .trackedManagementFeeDelta = 0}; + Number totalInterestPaid = 0; + Number totalFeesPaid = 0; + std::size_t totalPaymentsMade = 0; + + xrpl::LoanState currentTrueState = computeTheoreticalLoanState( + state.periodicPayment, periodicRate, state.paymentRemaining, broker.params.managementFeeRate); + + auto validateBorrowerBalance = [&]() { + if (borrower == issuer || !paymentParams.validateBalances) + return; + auto const totalSpent = + (totalPaid.trackedValueDelta + totalFeesPaid + + (broker.asset.native() ? Number(baseFee) * totalPaymentsMade : numZero)); + BEAST_EXPECT(env.balance(borrower, broker.asset).number() == borrowerInitialBalance - totalSpent); + }; + + auto const defaultRound = broker.asset.integral() ? 3 : 0; + auto truncate = [defaultRound](Number const& n, std::optional places = std::nullopt) { + auto const p = places.value_or(defaultRound); + if (p == 0) + return n; + auto const factor = Number{1, p}; + return (n * factor).truncate() / factor; + }; + while (state.paymentRemaining > 0) + { + validateBorrowerBalance(); + // Compute the expected principal amount + auto const paymentComponents = 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); + + xrpl::LoanState const nextTrueState = computeTheoreticalLoanState( + state.periodicPayment, periodicRate, state.paymentRemaining - 1, broker.params.managementFeeRate); + detail::LoanStateDeltas const deltas = currentTrueState - nextTrueState; + BEAST_EXPECT(deltas.total() == deltas.principal + deltas.interest + deltas.managementFee); + BEAST_EXPECT( + paymentComponents.specialCase == detail::PaymentSpecialCase::final || + deltas.total() == state.periodicPayment || + (state.loanScale - (deltas.total() - state.periodicPayment).exponent()) > 14); + + if (!showStepBalances) + log << currencyLabel << " Payment components: " << state.paymentRemaining << ", " + + << deltas.interest << ", " << deltas.principal << ", " << deltas.managementFee << ", " + << paymentComponents.trackedValueDelta << ", " << paymentComponents.trackedPrincipalDelta << ", " + << paymentComponents.trackedInterestPart() << ", " << paymentComponents.trackedManagementFeeDelta + << ", " + << (paymentComponents.specialCase == detail::PaymentSpecialCase::final ? "final" + : paymentComponents.specialCase == detail::PaymentSpecialCase::extra ? "extra" + : "none") + << std::endl; + + auto const totalDueAmount = STAmount{broker.asset, paymentComponents.trackedValueDelta + serviceFee}; + + if (paymentParams.validateBalances) + { + // Due to the rounding algorithms to keep the interest and + // principal in sync with "true" values, the computed amount + // may be a little less than the rounded fixed payment + // amount. For integral types, the difference should be < 3 + // (1 unit for each of the interest and management fee). For + // IOUs, the difference should be dust. + Number const diff = totalDue - totalDueAmount; + BEAST_EXPECT( + paymentComponents.specialCase == 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, paymentParams.flags)); + + env.close(d{state.paymentInterval / 2}); + + if (paymentParams.validateBalances) + { + // Need to account for fees if the loan is in XRP + PrettyAmount adjustment = broker.asset(0); + if (broker.asset.native()) + { + adjustment = env.current()->fees().base; + } + + // Check the result + verifyLoanStatus.checkPayment( + state.loanScale, borrower, borrowerBalanceBeforePayment, totalDueAmount, adjustment); + } + + if (showStepBalances) + { + auto const loanSle = env.le(loanKeylet); + if (!BEAST_EXPECT(loanSle)) + // No reason for this not to exist + return; + auto const current = constructRoundedLoanState(loanSle); + auto const errors = nextTrueState - current; + log << currencyLabel << " Loan balances: " + << "\n\tAmount taken: " << paymentComponents.trackedValueDelta + << "\n\tTotal value: " << current.valueOutstanding + << " (true: " << truncate(nextTrueState.valueOutstanding) << ", error: " << truncate(errors.total()) + << ")\n\tPrincipal: " << current.principalOutstanding + << " (true: " << truncate(nextTrueState.principalOutstanding) + << ", error: " << truncate(errors.principal) << ")\n\tInterest: " << current.interestDue + << " (true: " << truncate(nextTrueState.interestDue) << ", error: " << truncate(errors.interest) + << ")\n\tMgmt fee: " << current.managementFeeDue + << " (true: " << truncate(nextTrueState.managementFeeDue) + << ", error: " << truncate(errors.managementFee) << ")\n\tPayments remaining " + << loanSle->at(sfPaymentRemaining) << std::endl; + + currentRoundedState = current; + } + + --state.paymentRemaining; + state.previousPaymentDate = state.nextPaymentDate; + if (paymentComponents.specialCase == detail::PaymentSpecialCase::final) + { + state.paymentRemaining = 0; + state.nextPaymentDate = 0; + } + else + { + state.nextPaymentDate += state.paymentInterval; + } + state.principalOutstanding -= paymentComponents.trackedPrincipalDelta; + state.managementFeeOutstanding -= paymentComponents.trackedManagementFeeDelta; + state.totalValue -= paymentComponents.trackedValueDelta; + + if (paymentParams.validateBalances) + verifyLoanStatus(state); + + totalPaid.trackedValueDelta += paymentComponents.trackedValueDelta; + totalPaid.trackedPrincipalDelta += paymentComponents.trackedPrincipalDelta; + totalPaid.trackedManagementFeeDelta += paymentComponents.trackedManagementFeeDelta; + totalInterestPaid += paymentComponents.trackedInterestPart(); + totalFeesPaid += serviceFee; + ++totalPaymentsMade; + + currentTrueState = nextTrueState; + } + validateBorrowerBalance(); + + // Loan is paid off + BEAST_EXPECT(state.paymentRemaining == 0); + BEAST_EXPECT(state.principalOutstanding == 0); + + auto const initialInterestDue = + initialState.totalValue - (initialState.principalOutstanding + initialState.managementFeeOutstanding); + if (paymentParams.validateBalances) + { + // Make sure all the payments add up + BEAST_EXPECT(totalPaid.trackedValueDelta == initialState.totalValue); + BEAST_EXPECT(totalPaid.trackedPrincipalDelta == initialState.principalOutstanding); + BEAST_EXPECT(totalPaid.trackedManagementFeeDelta == initialState.managementFeeOutstanding); + // This is almost a tautology given the previous checks, but + // check it anyway for completeness. + BEAST_EXPECT(totalInterestPaid == initialInterestDue); + BEAST_EXPECT(totalPaymentsMade == initialState.paymentRemaining); + } + + if (showStepBalances) + { + auto const loanSle = env.le(loanKeylet); + if (!BEAST_EXPECT(loanSle)) + // No reason for this not to exist + return; + log << currencyLabel << " Total amounts paid: " + << "\n\tTotal value: " << totalPaid.trackedValueDelta + << " (initial: " << truncate(initialState.totalValue) + << ", error: " << truncate(initialState.totalValue - totalPaid.trackedValueDelta) + << ")\n\tPrincipal: " << totalPaid.trackedPrincipalDelta + << " (initial: " << truncate(initialState.principalOutstanding) + << ", error: " << truncate(initialState.principalOutstanding - totalPaid.trackedPrincipalDelta) + << ")\n\tInterest: " << totalInterestPaid << " (initial: " << truncate(initialInterestDue) + << ", error: " << truncate(initialInterestDue - totalInterestPaid) + << ")\n\tMgmt fee: " << totalPaid.trackedManagementFeeDelta + << " (initial: " << truncate(initialState.managementFeeOutstanding) + << ", error: " << truncate(initialState.managementFeeOutstanding - totalPaid.trackedManagementFeeDelta) + << ")\n\tTotal payments made: " << totalPaymentsMade << std::endl; + } + } + + void + runLoan(AssetType assetType, BrokerParameters const& brokerParams, LoanParameters const& loanParams) + { + using namespace jtx; + + Account const issuer("issuer"); + Account const lender("lender"); + Account const borrower("borrower"); + + Env env(*this, all); + + auto loanResult = createLoan(env, assetType, brokerParams, loanParams, issuer, lender, borrower); + if (!BEAST_EXPECT(loanResult)) + return; + + auto broker = std::get(*loanResult); + auto loanKeylet = std::get(*loanResult); + auto pseudoAcct = std::get(*loanResult); + + VerifyLoanStatus verifyLoanStatus(env, broker, pseudoAcct, loanKeylet); + + makeLoanPayments( + env, + broker, + loanParams, + loanKeylet, + verifyLoanStatus, + issuer, + lender, + borrower, + PaymentParameters{.showStepBalances = true}); + } + + /** Runs through the complete lifecycle of a loan + * + * 1. Create a loan. + * 2. Test a bunch of transaction failure conditions. + * 3. Use the `toEndOfLife` callback to take the loan to 0. How that is done + * depends on the callback. e.g. Default, Early payoff, make all the + * normal payments, etc. + * 4. Delete the loan. The loan will alternate between being deleted by the + * lender and the borrower. + */ + void + lifecycle( + std::string const& caseLabel, + char const* label, + jtx::Env& env, + Number const& loanAmount, + int interestExponent, + jtx::Account const& lender, + jtx::Account const& borrower, + jtx::Account const& evan, + BrokerInfo const& broker, + jtx::Account const& pseudoAcct, + std::uint32_t flags, + // The end of life callback is expected to take the loan to 0 payments + // remaining, one way or another + std::function toEndOfLife) + { + auto const [keylet, loanSequence] = [&]() { + auto const brokerSle = env.le(keylet::loanbroker(broker.brokerID)); + if (!BEAST_EXPECT(brokerSle)) + // will be invalid + return std::make_pair(keylet::loan(broker.brokerID), std::uint32_t(0)); + + // Broker has no loans + BEAST_EXPECT(brokerSle->at(sfOwnerCount) == 0); + + // The loan keylet is based on the LoanSequence of the _LOAN_BROKER_ + // object. + auto const loanSequence = brokerSle->at(sfLoanSequence); + return std::make_pair(keylet::loan(broker.brokerID, loanSequence), loanSequence); + }(); + + VerifyLoanStatus const verifyLoanStatus(env, broker, pseudoAcct, keylet); + + // No loans yet + verifyLoanStatus.checkBroker(0, 0, TenthBips32{0}, 1, 0, 0); + + if (!BEAST_EXPECT(loanSequence != 0)) + return; + + testcase << caseLabel << " " << label; + + using namespace jtx; + using namespace loan; + using namespace std::chrono_literals; + + auto applyExponent = [interestExponent, this](TenthBips32 value) mutable { + BEAST_EXPECT(value > TenthBips32(0)); + while (interestExponent > 0) + { + auto const oldValue = value; + value *= 10; + --interestExponent; + BEAST_EXPECT(value / 10 == oldValue); + } + while (interestExponent < 0) + { + auto const oldValue = value; + value /= 10; + ++interestExponent; + BEAST_EXPECT(value * 10 == oldValue); + } + return value; + }; + + auto const borrowerOwnerCount = env.ownerCount(borrower); + + auto const loanSetFee = env.current()->fees().base * 2; + LoanParameters const loanParams{ + .account = borrower, + .counter = lender, + .counterpartyExplicit = false, + .principalRequest = loanAmount, + .setFee = loanSetFee, + .originationFee = 1, + .serviceFee = 2, + .lateFee = 3, + .closeFee = 4, + .overFee = applyExponent(percentageToTenthBips(5) / 10), + .interest = applyExponent(percentageToTenthBips(12)), + // 2.4% + .lateInterest = applyExponent(percentageToTenthBips(24) / 10), + .closeInterest = applyExponent(percentageToTenthBips(36) / 10), + .overpaymentInterest = applyExponent(percentageToTenthBips(48) / 10), + .payTotal = 12, + .payInterval = 600, + .gracePd = 60, + .flags = flags, + }; + Number const principalRequestAmount = broker.asset(loanParams.principalRequest).value(); + auto const originationFeeAmount = broker.asset(*loanParams.originationFee).value(); + auto const serviceFeeAmount = broker.asset(*loanParams.serviceFee).value(); + auto const lateFeeAmount = broker.asset(*loanParams.lateFee).value(); + auto const closeFeeAmount = broker.asset(*loanParams.closeFee).value(); + + auto const borrowerStartbalance = env.balance(borrower, broker.asset); + + auto createJtx = loanParams(env, broker); + // Successfully create a Loan + env(createJtx); + + env.close(); + + auto const startDate = env.current()->header().parentCloseTime.time_since_epoch().count(); + + if (auto const brokerSle = env.le(keylet::loanbroker(broker.brokerID)); BEAST_EXPECT(brokerSle)) + { + BEAST_EXPECT(brokerSle->at(sfOwnerCount) == 1); + } + + { + // Need to account for fees if the loan is in XRP + PrettyAmount adjustment = broker.asset(0); + if (broker.asset.native()) + { + adjustment = 2 * env.current()->fees().base; + } + + BEAST_EXPECT( + env.balance(borrower, broker.asset).value() == + borrowerStartbalance.value() + principalRequestAmount - originationFeeAmount - adjustment.value()); + } + + auto const loanFlags = createJtx.stx->isFlag(tfLoanOverpayment) ? lsfLoanOverpayment : LedgerSpecificFlags(0); + + if (auto loan = env.le(keylet); BEAST_EXPECT(loan)) + { + // log << "loan after create: " << to_string(loan->getJson()) + // << std::endl; + BEAST_EXPECT(loan->isFlag(lsfLoanOverpayment) == createJtx.stx->isFlag(tfLoanOverpayment)); + BEAST_EXPECT(loan->at(sfLoanSequence) == loanSequence); + BEAST_EXPECT(loan->at(sfBorrower) == borrower.id()); + BEAST_EXPECT(loan->at(sfLoanBrokerID) == broker.brokerID); + BEAST_EXPECT(loan->at(sfLoanOriginationFee) == originationFeeAmount); + BEAST_EXPECT(loan->at(sfLoanServiceFee) == serviceFeeAmount); + BEAST_EXPECT(loan->at(sfLatePaymentFee) == lateFeeAmount); + BEAST_EXPECT(loan->at(sfClosePaymentFee) == closeFeeAmount); + BEAST_EXPECT(loan->at(sfOverpaymentFee) == *loanParams.overFee); + BEAST_EXPECT(loan->at(sfInterestRate) == *loanParams.interest); + BEAST_EXPECT(loan->at(sfLateInterestRate) == *loanParams.lateInterest); + BEAST_EXPECT(loan->at(sfCloseInterestRate) == *loanParams.closeInterest); + BEAST_EXPECT(loan->at(sfOverpaymentInterestRate) == *loanParams.overpaymentInterest); + BEAST_EXPECT(loan->at(sfStartDate) == startDate); + BEAST_EXPECT(loan->at(sfPaymentInterval) == *loanParams.payInterval); + BEAST_EXPECT(loan->at(sfGracePeriod) == *loanParams.gracePd); + BEAST_EXPECT(loan->at(sfPreviousPaymentDueDate) == 0); + BEAST_EXPECT(loan->at(sfNextPaymentDueDate) == startDate + *loanParams.payInterval); + BEAST_EXPECT(loan->at(sfPaymentRemaining) == *loanParams.payTotal); + BEAST_EXPECT( + loan->at(sfLoanScale) >= + (broker.asset.integral() ? 0 : std::max(broker.vaultScale(env), principalRequestAmount.exponent()))); + BEAST_EXPECT(loan->at(sfPrincipalOutstanding) == principalRequestAmount); + } + + auto state = getCurrentState(env, broker, keylet, verifyLoanStatus); + + auto const loanProperties = computeLoanProperties( + broker.asset.raw(), + state.principalOutstanding, + state.interestRate, + state.paymentInterval, + state.paymentRemaining, + broker.params.managementFeeRate, + state.loanScale); + + verifyLoanStatus( + 0, + startDate + *loanParams.payInterval, + *loanParams.payTotal, + state.loanScale, + loanProperties.loanState.valueOutstanding, + principalRequestAmount, + loanProperties.loanState.managementFeeDue, + loanProperties.periodicPayment, + loanFlags | 0); + + // Manage the loan + // no-op + env(manage(lender, keylet.key, 0)); + { + // no flags + auto jt = manage(lender, keylet.key, 0); + jt.removeMember(sfFlags.getName()); + env(jt); + } + // Only the lender can manage + env(manage(evan, keylet.key, 0), ter(tecNO_PERMISSION)); + // unknown flags + env(manage(lender, keylet.key, tfLoanManageMask), ter(temINVALID_FLAG)); + // combinations of flags are not allowed + env(manage(lender, keylet.key, tfLoanUnimpair | tfLoanImpair), ter(temINVALID_FLAG)); + env(manage(lender, keylet.key, tfLoanImpair | tfLoanDefault), ter(temINVALID_FLAG)); + env(manage(lender, keylet.key, tfLoanUnimpair | tfLoanDefault), ter(temINVALID_FLAG)); + env(manage(lender, keylet.key, tfLoanUnimpair | tfLoanImpair | tfLoanDefault), ter(temINVALID_FLAG)); + // invalid loan ID + env(manage(lender, broker.brokerID, tfLoanImpair), ter(tecNO_ENTRY)); + // Loan is unimpaired, can't unimpair it again + env(manage(lender, keylet.key, tfLoanUnimpair), ter(tecNO_PERMISSION)); + // Loan is unimpaired, it can go into default, but only after it's past + // due + env(manage(lender, keylet.key, tfLoanDefault), ter(tecTOO_SOON)); + + // Check the vault + bool const canImpair = canImpairLoan(env, broker, state); + // Impair the loan, if possible + env(manage(lender, keylet.key, tfLoanImpair), canImpair ? ter(tesSUCCESS) : ter(tecLIMIT_EXCEEDED)); + // Unimpair the loan + env(manage(lender, keylet.key, tfLoanUnimpair), canImpair ? ter(tesSUCCESS) : ter(tecNO_PERMISSION)); + + auto const nextDueDate = startDate + *loanParams.payInterval; + + env.close(); + + verifyLoanStatus( + 0, + nextDueDate, + *loanParams.payTotal, + loanProperties.loanScale, + loanProperties.loanState.valueOutstanding, + principalRequestAmount, + loanProperties.loanState.managementFeeDue, + loanProperties.periodicPayment, + loanFlags | 0); + + // Can't delete the loan yet. It has payments remaining. + env(del(lender, keylet.key), ter(tecHAS_OBLIGATIONS)); + + if (BEAST_EXPECT(toEndOfLife)) + toEndOfLife(keylet, verifyLoanStatus); + env.close(); + + // Verify the loan is at EOL + if (auto loan = env.le(keylet); BEAST_EXPECT(loan)) + { + BEAST_EXPECT(loan->at(sfPaymentRemaining) == 0); + BEAST_EXPECT(loan->at(sfPrincipalOutstanding) == 0); + } + auto const borrowerStartingBalance = env.balance(borrower, broker.asset); + + // Try to delete the loan broker with an active loan + env(loanBroker::del(lender, broker.brokerID), ter(tecHAS_OBLIGATIONS)); + // Ensure the above tx doesn't get ordered after the LoanDelete and + // delete our broker! + env.close(); + + // Test failure cases + env(del(lender, keylet.key, tfLoanOverpayment), ter(temINVALID_FLAG)); + env(del(evan, keylet.key), ter(tecNO_PERMISSION)); + env(del(lender, broker.brokerID), ter(tecNO_ENTRY)); + + // Delete the loan + // Either the borrower or the lender can delete the loan. Alternate + // between who does it across tests. + static unsigned deleteCounter = 0; + auto const deleter = ++deleteCounter % 2 ? lender : borrower; + env(del(deleter, keylet.key)); + env.close(); + + PrettyAmount adjustment = broker.asset(0); + if (deleter == borrower) + { + // Need to account for fees if the loan is in XRP + if (broker.asset.native()) + { + adjustment = env.current()->fees().base; + } + } + + // No loans left + verifyLoanStatus.checkBroker(0, 0, *loanParams.interest, 1, 0, 0); + + BEAST_EXPECT(env.balance(borrower, broker.asset).value() == borrowerStartingBalance.value() - adjustment); + BEAST_EXPECT(env.ownerCount(borrower) == borrowerOwnerCount); + + if (auto const brokerSle = env.le(keylet::loanbroker(broker.brokerID)); BEAST_EXPECT(brokerSle)) + { + BEAST_EXPECT(brokerSle->at(sfOwnerCount) == 0); + } + } + + 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 + * + * Will be used in the future to vary the loan parameters. For now, it is + * only called once. + * + * Tests a bunch of LoanSet failure conditions before lifecycle. + */ + template + void + testCaseWrapper( + jtx::Env& env, + jtx::MPTTester& mptt, + std::array const& assets, + BrokerInfo const& broker, + Number const& loanAmount, + int interestExponent) + { + using namespace jtx; + using namespace Lending; + + auto const& asset = broker.asset.raw(); + auto const currencyLabel = getCurrencyLabel(asset); + auto const caseLabel = [&]() { + std::stringstream ss; + ss << "Lifecycle: " << loanAmount << " " << currencyLabel << " Scale interest to: " << interestExponent + << " "; + return ss.str(); + }(); + testcase << caseLabel; + + using namespace loan; + using namespace std::chrono_literals; + using d = NetClock::duration; + using tp = NetClock::time_point; + + Account const issuer{"issuer"}; + // For simplicity, lender will be the sole actor for the vault & + // brokers. + Account const lender{"lender"}; + // Borrower only wants to borrow + Account const borrower{"borrower"}; + // Evan will attempt to be naughty + Account const evan{"evan"}; + // Do not fund alice + Account const alice{"alice"}; + + Number const principalRequest = broker.asset(loanAmount).value(); + Number const maxCoveredLoanValue = broker.params.maxCoveredLoanValue(0); + BEAST_EXPECT(maxCoveredLoanValue == 1000 * 100 / 10); + Number const maxCoveredLoanRequest = broker.asset(maxCoveredLoanValue).value(); + Number const totalVaultRequest = broker.asset(broker.params.vaultDeposit).value(); + Number const debtMaximumRequest = broker.asset(broker.params.debtMax).value(); + + auto const loanSetFee = fee(env.current()->fees().base * 2); + + auto const pseudoAcct = [&]() { + auto const brokerSle = env.le(keylet::loanbroker(broker.brokerID)); + if (!BEAST_EXPECT(brokerSle)) + return lender; + auto const brokerPseudo = brokerSle->at(sfAccount); + return Account("Broker pseudo-account", brokerPseudo); + }(); + + auto const baseFee = env.current()->fees().base; + + auto badKeylet = keylet::vault(lender.id(), env.seq(lender)); + // Try some failure cases + // flags are checked first + env(set(evan, broker.brokerID, principalRequest, tfLoanSetMask), + sig(sfCounterpartySignature, lender), + loanSetFee, + ter(temINVALID_FLAG)); + + // field length validation + // sfData: good length, bad account + env(set(evan, broker.brokerID, principalRequest), + sig(sfCounterpartySignature, borrower), + data(std::string(maxDataPayloadLength, 'X')), + loanSetFee, + ter(tefBAD_AUTH)); + // sfData: too long + env(set(evan, broker.brokerID, principalRequest), + sig(sfCounterpartySignature, lender), + data(std::string(maxDataPayloadLength + 1, 'Y')), + loanSetFee, + ter(temINVALID)); + + // field range validation + // sfOverpaymentFee: good value, bad account + env(set(evan, broker.brokerID, principalRequest), + sig(sfCounterpartySignature, borrower), + overpaymentFee(maxOverpaymentFee), + loanSetFee, + ter(tefBAD_AUTH)); + // sfOverpaymentFee: too big + env(set(evan, broker.brokerID, principalRequest), + sig(sfCounterpartySignature, lender), + overpaymentFee(maxOverpaymentFee + 1), + loanSetFee, + ter(temINVALID)); + + // sfInterestRate: good value, bad account + env(set(evan, broker.brokerID, principalRequest), + sig(sfCounterpartySignature, borrower), + interestRate(maxInterestRate), + loanSetFee, + ter(tefBAD_AUTH)); + env(set(evan, broker.brokerID, principalRequest), + sig(sfCounterpartySignature, borrower), + interestRate(TenthBips32(0)), + loanSetFee, + ter(tefBAD_AUTH)); + // sfInterestRate: too big + env(set(evan, broker.brokerID, principalRequest), + sig(sfCounterpartySignature, lender), + interestRate(maxInterestRate + 1), + loanSetFee, + ter(temINVALID)); + // sfInterestRate: too small + env(set(evan, broker.brokerID, principalRequest), + sig(sfCounterpartySignature, lender), + interestRate(TenthBips32(-1)), + loanSetFee, + ter(temINVALID)); + + // sfLateInterestRate: good value, bad account + env(set(evan, broker.brokerID, principalRequest), + sig(sfCounterpartySignature, borrower), + lateInterestRate(maxLateInterestRate), + loanSetFee, + ter(tefBAD_AUTH)); + env(set(evan, broker.brokerID, principalRequest), + sig(sfCounterpartySignature, borrower), + lateInterestRate(TenthBips32(0)), + loanSetFee, + ter(tefBAD_AUTH)); + // sfLateInterestRate: too big + env(set(evan, broker.brokerID, principalRequest), + sig(sfCounterpartySignature, lender), + lateInterestRate(maxLateInterestRate + 1), + loanSetFee, + ter(temINVALID)); + // sfLateInterestRate: too small + env(set(evan, broker.brokerID, principalRequest), + sig(sfCounterpartySignature, lender), + lateInterestRate(TenthBips32(-1)), + loanSetFee, + ter(temINVALID)); + + // sfCloseInterestRate: good value, bad account + env(set(evan, broker.brokerID, principalRequest), + sig(sfCounterpartySignature, borrower), + closeInterestRate(maxCloseInterestRate), + loanSetFee, + ter(tefBAD_AUTH)); + env(set(evan, broker.brokerID, principalRequest), + sig(sfCounterpartySignature, borrower), + closeInterestRate(TenthBips32(0)), + loanSetFee, + ter(tefBAD_AUTH)); + // sfCloseInterestRate: too big + env(set(evan, broker.brokerID, principalRequest), + sig(sfCounterpartySignature, lender), + closeInterestRate(maxCloseInterestRate + 1), + loanSetFee, + ter(temINVALID)); + env(set(evan, broker.brokerID, principalRequest), + sig(sfCounterpartySignature, lender), + closeInterestRate(TenthBips32(-1)), + loanSetFee, + ter(temINVALID)); + + // sfOverpaymentInterestRate: good value, bad account + env(set(evan, broker.brokerID, principalRequest), + sig(sfCounterpartySignature, borrower), + overpaymentInterestRate(maxOverpaymentInterestRate), + loanSetFee, + ter(tefBAD_AUTH)); + env(set(evan, broker.brokerID, principalRequest), + sig(sfCounterpartySignature, borrower), + overpaymentInterestRate(TenthBips32(0)), + loanSetFee, + ter(tefBAD_AUTH)); + // sfOverpaymentInterestRate: too big + env(set(evan, broker.brokerID, principalRequest), + sig(sfCounterpartySignature, lender), + overpaymentInterestRate(maxOverpaymentInterestRate + 1), + loanSetFee, + ter(temINVALID)); + env(set(evan, broker.brokerID, principalRequest), + sig(sfCounterpartySignature, lender), + overpaymentInterestRate(TenthBips32(-1)), + loanSetFee, + ter(temINVALID)); + + // sfPaymentTotal: good value, bad account + env(set(evan, broker.brokerID, principalRequest), + sig(sfCounterpartySignature, borrower), + paymentTotal(LoanSet::minPaymentTotal), + loanSetFee, + ter(tefBAD_AUTH)); + // sfPaymentTotal: too small (there is no max) + env(set(evan, broker.brokerID, principalRequest), + sig(sfCounterpartySignature, lender), + paymentTotal(LoanSet::minPaymentTotal - 1), + loanSetFee, + ter(temINVALID)); + + // sfPaymentInterval: good value, bad account + env(set(evan, broker.brokerID, principalRequest), + sig(sfCounterpartySignature, borrower), + paymentInterval(LoanSet::minPaymentInterval), + loanSetFee, + ter(tefBAD_AUTH)); + // sfPaymentInterval: too small (there is no max) + env(set(evan, broker.brokerID, principalRequest), + sig(sfCounterpartySignature, lender), + paymentInterval(LoanSet::minPaymentInterval - 1), + loanSetFee, + ter(temINVALID)); + + // sfGracePeriod: good value, bad account + env(set(evan, broker.brokerID, principalRequest), + sig(sfCounterpartySignature, borrower), + paymentInterval(LoanSet::minPaymentInterval * 2), + gracePeriod(LoanSet::minPaymentInterval * 2), + loanSetFee, + ter(tefBAD_AUTH)); + // sfGracePeriod: larger than paymentInterval + env(set(evan, broker.brokerID, principalRequest), + sig(sfCounterpartySignature, lender), + paymentInterval(LoanSet::minPaymentInterval * 2), + gracePeriod(LoanSet::minPaymentInterval * 3), + loanSetFee, + ter(temINVALID)); + + // insufficient fee - single sign + env(set(borrower, broker.brokerID, principalRequest), + sig(sfCounterpartySignature, lender), + ter(telINSUF_FEE_P)); + // insufficient fee - multisign + env(signers(lender, 2, {{evan, 1}, {borrower, 1}})); + env(signers(borrower, 2, {{evan, 1}, {lender, 1}})); + env(set(borrower, broker.brokerID, principalRequest), + counterparty(lender), + msig(evan, lender), + msig(sfCounterpartySignature, evan, borrower), + fee(env.current()->fees().base * 5 - 1), + ter(telINSUF_FEE_P)); + // Bad multisign signatures for borrower (Account) + env(set(borrower, broker.brokerID, principalRequest), + counterparty(lender), + msig(alice, issuer), + msig(sfCounterpartySignature, evan, borrower), + fee(env.current()->fees().base * 5), + ter(tefBAD_SIGNATURE)); + // Bad multisign signatures for issuer (Counterparty) + env(set(borrower, broker.brokerID, principalRequest), + counterparty(lender), + msig(evan, lender), + msig(sfCounterpartySignature, alice, issuer), + fee(env.current()->fees().base * 5 - 1), + ter(tefBAD_SIGNATURE)); + env(signers(lender, none)); + env(signers(borrower, none)); + // multisign sufficient fee, but no signers set up + env(set(borrower, broker.brokerID, principalRequest), + counterparty(lender), + msig(evan, lender), + msig(sfCounterpartySignature, evan, borrower), + fee(env.current()->fees().base * 5), + ter(tefNOT_MULTI_SIGNING)); + // not the broker owner, no counterparty, not signed by broker + // owner + env(set(borrower, broker.brokerID, principalRequest), + sig(sfCounterpartySignature, evan), + loanSetFee, + ter(tefBAD_AUTH)); + // not the broker owner, counterparty is borrower + env(set(evan, broker.brokerID, principalRequest), + counterparty(borrower), + sig(sfCounterpartySignature, borrower), + loanSetFee, + ter(tecNO_PERMISSION)); + // not a LoanBroker object, no counterparty + env(set(lender, badKeylet.key, principalRequest), + sig(sfCounterpartySignature, evan), + loanSetFee, + ter(temBAD_SIGNER)); + // not a LoanBroker object, counterparty is valid + env(set(lender, badKeylet.key, principalRequest), + counterparty(borrower), + sig(sfCounterpartySignature, borrower), + loanSetFee, + ter(tecNO_ENTRY)); + // borrower doesn't exist + env(set(lender, broker.brokerID, principalRequest), + counterparty(alice), + sig(sfCounterpartySignature, alice), + loanSetFee, + ter(terNO_ACCOUNT)); + + // Request more funds than the vault has available + env(set(evan, broker.brokerID, totalVaultRequest + 1), + sig(sfCounterpartySignature, lender), + loanSetFee, + ter(tecINSUFFICIENT_FUNDS)); + + // Request more funds than the broker's first-loss capital can + // cover. + env(set(evan, broker.brokerID, maxCoveredLoanRequest + 1), + sig(sfCounterpartySignature, lender), + loanSetFee, + ter(tecINSUFFICIENT_FUNDS)); + + // Frozen trust line / locked MPT issuance + // XRP can not be frozen, but run through the loop anyway to test + // the tecLIMIT_EXCEEDED case + { + auto const brokerSle = env.le(keylet::loanbroker(broker.brokerID)); + if (!BEAST_EXPECT(brokerSle)) + return; + + auto const vaultPseudo = [&]() { + auto const vaultSle = env.le(keylet::vault(brokerSle->at(sfVaultID))); + if (!BEAST_EXPECT(vaultSle)) + // This will be wrong, but the test has failed anyway. + return lender; + auto const vaultPseudo = Account("Vault pseudo-account", vaultSle->at(sfAccount)); + return vaultPseudo; + }(); + + auto const [freeze, deepfreeze, unfreeze, expectedResult] = + [&]() -> std::tuple< + std::function, + std::function, + std::function, + TER> { + // Freeze / lock the asset + std::function empty; + if (broker.asset.native()) + { + // XRP can't be frozen + return std::make_tuple(empty, empty, empty, tesSUCCESS); + } + else if (broker.asset.holds()) + { + auto freeze = [&](Account const& holder) { + env(trust(issuer, holder[iouCurrency](0), tfSetFreeze)); + }; + auto deepfreeze = [&](Account const& holder) { + env(trust(issuer, holder[iouCurrency](0), tfSetFreeze | tfSetDeepFreeze)); + }; + auto unfreeze = [&](Account const& holder) { + env(trust(issuer, holder[iouCurrency](0), tfClearFreeze | tfClearDeepFreeze)); + }; + return std::make_tuple(freeze, deepfreeze, unfreeze, tecFROZEN); + } + else + { + auto freeze = [&](Account const& holder) { + mptt.set({.account = issuer, .holder = holder, .flags = tfMPTLock}); + }; + auto unfreeze = [&](Account const& holder) { + mptt.set({.account = issuer, .holder = holder, .flags = tfMPTUnlock}); + }; + return std::make_tuple(freeze, empty, unfreeze, tecLOCKED); + } + }(); + + // Try freezing the accounts that can't be frozen + if (freeze) + { + for (auto const& account : {vaultPseudo, evan}) + { + // Freeze the account + freeze(account); + + // Try to create a loan with a frozen line + env(set(evan, broker.brokerID, debtMaximumRequest), + sig(sfCounterpartySignature, lender), + loanSetFee, + ter(expectedResult)); + + // Unfreeze the account + BEAST_EXPECT(unfreeze); + unfreeze(account); + + // Ensure the line is unfrozen with a request that is fine + // except too it requests more principal than the broker can + // carry + env(set(evan, broker.brokerID, debtMaximumRequest + 1), + sig(sfCounterpartySignature, lender), + loanSetFee, + ter(tecLIMIT_EXCEEDED)); + } + } + + // Deep freeze the borrower, which prevents them from receiving + // funds + if (deepfreeze) + { + // Make sure evan has a trust line that so the issuer can + // freeze it. (Don't need to do this for the borrower, + // because LoanSet will create a line to the borrower + // automatically.) + env(trust(evan, issuer[iouCurrency](100'000))); + + for (auto const& account : {// these accounts can't be frozen, which deep freeze + // implies + vaultPseudo, + evan, + // these accounts can't be deep frozen + lender}) + { + // Freeze evan + deepfreeze(account); + + // Try to create a loan with a deep frozen line + env(set(evan, broker.brokerID, debtMaximumRequest), + sig(sfCounterpartySignature, lender), + loanSetFee, + ter(expectedResult)); + + // Unfreeze evan + BEAST_EXPECT(unfreeze); + unfreeze(account); + + // Ensure the line is unfrozen with a request that is fine + // except too it requests more principal than the broker can + // carry + env(set(evan, broker.brokerID, debtMaximumRequest + 1), + sig(sfCounterpartySignature, lender), + loanSetFee, + ter(tecLIMIT_EXCEEDED)); + } + } + } + + // Finally! Create a loan + std::string testData; + + auto coverAvailable = [&env, this](uint256 const& brokerID, Number const& expected) { + if (auto const brokerSle = env.le(keylet::loanbroker(brokerID)); BEAST_EXPECT(brokerSle)) + { + auto const available = brokerSle->at(sfCoverAvailable); + BEAST_EXPECT(available == expected); + return available; + } + return Number{}; + }; + auto getDefaultInfo = [&env, this](LoanState const& state, BrokerInfo const& broker) { + if (auto const brokerSle = env.le(keylet::loanbroker(broker.brokerID)); BEAST_EXPECT(brokerSle)) + { + BEAST_EXPECT( + state.loanScale >= (broker.asset.integral() + ? 0 + : std::max(broker.vaultScale(env), state.principalOutstanding.exponent()))); + NumberRoundModeGuard mg(Number::upward); + auto const defaultAmount = roundToAsset( + broker.asset, + std::min( + tenthBipsOfValue( + tenthBipsOfValue(brokerSle->at(sfDebtTotal), broker.params.coverRateMin), + broker.params.coverRateLiquidation), + state.totalValue - state.managementFeeOutstanding), + state.loanScale); + return std::make_pair(defaultAmount, brokerSle->at(sfOwner)); + } + return std::make_pair(Number{}, AccountID{}); + }; + auto replenishCover = [&env, &coverAvailable]( + BrokerInfo const& broker, + AccountID const& brokerAcct, + Number const& startingCoverAvailable, + Number const& amountToBeCovered) { + coverAvailable(broker.brokerID, startingCoverAvailable - amountToBeCovered); + env(loanBroker::coverDeposit(brokerAcct, broker.brokerID, STAmount{broker.asset, amountToBeCovered})); + coverAvailable(broker.brokerID, startingCoverAvailable); + env.close(); + }; + + auto defaultImmediately = [&](std::uint32_t baseFlag, bool impair = true) { + return [&, impair, baseFlag](Keylet const& loanKeylet, VerifyLoanStatus const& verifyLoanStatus) { + // toEndOfLife + // + // Default the loan + + // Initialize values with the current state + auto state = getCurrentState(env, broker, loanKeylet, verifyLoanStatus); + BEAST_EXPECT(state.flags == baseFlag); + + auto const& broker = verifyLoanStatus.broker; + auto const startingCoverAvailable = + coverAvailable(broker.brokerID, broker.asset(broker.params.coverDeposit).number()); + + if (impair) + { + // Check the vault + bool const canImpair = canImpairLoan(env, broker, state); + // Impair the loan, if possible + env(manage(lender, loanKeylet.key, tfLoanImpair), + canImpair ? ter(tesSUCCESS) : ter(tecLIMIT_EXCEEDED)); + + if (canImpair) + { + state.flags |= tfLoanImpair; + state.nextPaymentDate = env.now().time_since_epoch().count(); + + // Once the loan is impaired, it can't be impaired again + env(manage(lender, loanKeylet.key, tfLoanImpair), ter(tecNO_PERMISSION)); + } + verifyLoanStatus(state); + } + + auto const nextDueDate = tp{d{state.nextPaymentDate}}; + + // Can't default the loan yet. The grace period hasn't + // expired + env(manage(lender, loanKeylet.key, tfLoanDefault), ter(tecTOO_SOON)); + + // Let some time pass so that the loan can be + // defaulted + env.close(nextDueDate + 60s); + + auto const [amountToBeCovered, brokerAcct] = getDefaultInfo(state, broker); + + // Default the loan + env(manage(lender, loanKeylet.key, tfLoanDefault)); + env.close(); + + // The LoanBroker just lost some of it's first-loss capital. + // Replenish it. + replenishCover(broker, brokerAcct, startingCoverAvailable, amountToBeCovered); + + state.flags |= tfLoanDefault; + state.paymentRemaining = 0; + state.totalValue = 0; + state.principalOutstanding = 0; + state.managementFeeOutstanding = 0; + state.nextPaymentDate = 0; + verifyLoanStatus(state); + + // Once a loan is defaulted, it can't be managed + env(manage(lender, loanKeylet.key, tfLoanUnimpair), ter(tecNO_PERMISSION)); + env(manage(lender, loanKeylet.key, tfLoanImpair), ter(tecNO_PERMISSION)); + // Can't make a payment on it either + env(pay(borrower, loanKeylet.key, broker.asset(300)), ter(tecKILLED)); + }; + }; + + auto singlePayment = [&](Keylet const& loanKeylet, + VerifyLoanStatus const& verifyLoanStatus, + LoanState& state, + STAmount const& payoffAmount, + std::uint32_t numPayments, + std::uint32_t baseFlag, + std::uint32_t txFlags) { + // toEndOfLife + // + verifyLoanStatus(state); + + // Send some bogus pay transactions + env(pay(borrower, keylet::loan(uint256(0)).key, broker.asset(10), txFlags), ter(temINVALID)); + // broker.asset(80) is less than a single payment, but all these + // checks fail before that matters + env(pay(borrower, loanKeylet.key, broker.asset(-80), txFlags), ter(temBAD_AMOUNT)); + env(pay(borrower, broker.brokerID, broker.asset(80), txFlags), ter(tecNO_ENTRY)); + env(pay(evan, loanKeylet.key, broker.asset(80), txFlags), ter(tecNO_PERMISSION)); + + // TODO: Write a general "isFlag" function? See STObject::isFlag. + // Maybe add a static overloaded member? + if (!(state.flags & lsfLoanOverpayment)) + { + // If the loan does not allow overpayments, send a payment that + // tries to make an overpayment. Do not include `txFlags`, so we + // don't end up duplicating the next test transaction. + env(pay(borrower, + loanKeylet.key, + STAmount{broker.asset, state.periodicPayment * Number{15, -1}}, + tfLoanOverpayment), + fee(XRPAmount{baseFee * (Number{15, -1} / loanPaymentsPerFeeIncrement + 1)}), + ter(temINVALID_FLAG)); + } + // Try to send a payment marked as multiple mutually exclusive + // payment types. Do not include `txFlags`, so we don't duplicate + // the prior test transaction. + env(pay(borrower, + loanKeylet.key, + broker.asset(state.periodicPayment * 2), + tfLoanLatePayment | tfLoanFullPayment), + ter(temINVALID_FLAG)); + env(pay(borrower, + loanKeylet.key, + broker.asset(state.periodicPayment * 2), + tfLoanLatePayment | tfLoanOverpayment), + ter(temINVALID_FLAG)); + env(pay(borrower, + loanKeylet.key, + broker.asset(state.periodicPayment * 2), + tfLoanOverpayment | tfLoanFullPayment), + ter(temINVALID_FLAG)); + env(pay(borrower, + loanKeylet.key, + broker.asset(state.periodicPayment * 2), + tfLoanLatePayment | tfLoanOverpayment | tfLoanFullPayment), + ter(temINVALID_FLAG)); + + { + auto const otherAsset = broker.asset.raw() == assets[0].raw() ? assets[1] : assets[0]; + env(pay(borrower, loanKeylet.key, otherAsset(100), txFlags), ter(tecWRONG_ASSET)); + } + + // Amount doesn't cover a single payment + env(pay(borrower, loanKeylet.key, STAmount{broker.asset, 1}, txFlags), ter(tecINSUFFICIENT_PAYMENT)); + + // Get the balance after these failed transactions take + // fees + auto const borrowerBalanceBeforePayment = env.balance(borrower, broker.asset); + + BEAST_EXPECT(payoffAmount > state.principalOutstanding); + // Try to pay a little extra to show that it's _not_ + // taken + auto const transactionAmount = payoffAmount + broker.asset(10); + + // Send a transaction that tries to pay more than the borrowers's + // balance + XRPAmount const badFee{ + baseFee * + (borrowerBalanceBeforePayment.number() * 2 / state.periodicPayment / loanPaymentsPerFeeIncrement + 1)}; + env(pay(borrower, + loanKeylet.key, + STAmount{broker.asset, borrowerBalanceBeforePayment.number() * 2}, + txFlags), + fee(badFee), + ter(tecINSUFFICIENT_FUNDS)); + + XRPAmount const goodFee{baseFee * (numPayments / loanPaymentsPerFeeIncrement + 1)}; + env(pay(borrower, loanKeylet.key, transactionAmount, txFlags), fee(goodFee)); + + env.close(); + + // log << env.meta()->getJson() << std::endl; + + // Need to account for fees if the loan is in XRP + PrettyAmount adjustment = broker.asset(0); + if (broker.asset.native()) + { + adjustment = badFee + goodFee; + } + + state.paymentRemaining = 0; + state.principalOutstanding = 0; + state.totalValue = 0; + state.managementFeeOutstanding = 0; + state.previousPaymentDate = state.nextPaymentDate + state.paymentInterval * (numPayments - 1); + state.nextPaymentDate = 0; + verifyLoanStatus(state); + + verifyLoanStatus.checkPayment( + state.loanScale, borrower, borrowerBalanceBeforePayment, payoffAmount, adjustment); + + // Can't impair or default a paid off loan + env(manage(lender, loanKeylet.key, tfLoanImpair), ter(tecNO_PERMISSION)); + env(manage(lender, loanKeylet.key, tfLoanDefault), ter(tecNO_PERMISSION)); + }; + + auto fullPayment = [&](std::uint32_t baseFlag) { + return [&, baseFlag](Keylet const& loanKeylet, VerifyLoanStatus const& verifyLoanStatus) { + // toEndOfLife + // + auto state = getCurrentState(env, broker, loanKeylet, verifyLoanStatus); + env.close(state.startDate + 20s); + auto const loanAge = (env.now() - state.startDate).count(); + BEAST_EXPECT(loanAge == 30); + + // Full payoff amount will consist of + // 1. principal outstanding (1000) + // 2. accrued interest (at 12%) + // 3. prepayment penalty (closeInterest at 3.6%) + // 4. close payment fee (4) + // Calculate these values without the helper functions + // to verify they're working correctly The numbers in + // the below BEAST_EXPECTs may not hold across assets. + Number const interval = state.paymentInterval; + auto const periodicRate = interval * Number(12, -2) / secondsInYear; + BEAST_EXPECT(periodicRate == Number(2283105022831050228ULL, -24, Number::normalized{})); + STAmount const principalOutstanding{broker.asset, state.principalOutstanding}; + STAmount const accruedInterest{ + broker.asset, state.principalOutstanding * periodicRate * loanAge / interval}; + BEAST_EXPECT(accruedInterest == broker.asset(Number(1141552511415525, -19))); + STAmount const prepaymentPenalty{broker.asset, state.principalOutstanding * Number(36, -3)}; + BEAST_EXPECT(prepaymentPenalty == broker.asset(36)); + STAmount const closePaymentFee = broker.asset(4); + auto const payoffAmount = roundToScale( + principalOutstanding + accruedInterest + prepaymentPenalty + closePaymentFee, state.loanScale); + BEAST_EXPECT( + payoffAmount == + roundToAsset(broker.asset, broker.asset(Number(1040000114155251, -12)).number(), state.loanScale)); + + // The terms of this loan actually make the early payoff + // more expensive than just making payments + BEAST_EXPECT(payoffAmount > state.paymentRemaining * (state.periodicPayment + broker.asset(2).value())); + + singlePayment(loanKeylet, verifyLoanStatus, state, payoffAmount, 1, baseFlag, tfLoanFullPayment); + }; + }; + + auto combineAllPayments = [&](std::uint32_t baseFlag) { + return [&, baseFlag](Keylet const& loanKeylet, VerifyLoanStatus const& verifyLoanStatus) { + // toEndOfLife + // + + auto state = getCurrentState(env, broker, loanKeylet, verifyLoanStatus); + env.close(); + + BEAST_EXPECT( + STAmount(broker.asset, state.periodicPayment) == broker.asset(Number(8333457002039338267, -17))); + + // Make all the payments in one transaction + // service fee is 2 + auto const startingPayments = state.paymentRemaining; + STAmount const payoffAmount = [&]() { + NumberRoundModeGuard mg(Number::upward); + auto const rawPayoff = startingPayments * (state.periodicPayment + broker.asset(2).value()); + STAmount payoffAmount{broker.asset, rawPayoff}; + BEAST_EXPECTS(payoffAmount == broker.asset(Number(1024014840244721, -12)), to_string(payoffAmount)); + BEAST_EXPECT(payoffAmount > state.principalOutstanding); + + payoffAmount = roundToScale(payoffAmount, state.loanScale); + + return payoffAmount; + }(); + + auto const totalPayoffValue = state.totalValue + startingPayments * broker.asset(2).value(); + STAmount const totalPayoffAmount{broker.asset, totalPayoffValue}; + + BEAST_EXPECTS( + totalPayoffAmount == payoffAmount, + "Payoff amount: " + to_string(payoffAmount) + ". Total Value: " + to_string(totalPayoffAmount)); + + singlePayment(loanKeylet, verifyLoanStatus, state, payoffAmount, state.paymentRemaining, baseFlag, 0); + }; + }; + + // There are a lot of fields that can be set on a loan, but most + // of them only affect the "math" when a payment is made. The + // only one that really affects behavior is the + // `tfLoanOverpayment` flag. + lifecycle( + caseLabel, + "Loan overpayment allowed - Impair and Default", + env, + loanAmount, + interestExponent, + lender, + borrower, + evan, + broker, + pseudoAcct, + tfLoanOverpayment, + defaultImmediately(lsfLoanOverpayment)); + + lifecycle( + caseLabel, + "Loan overpayment prohibited - Impair and Default", + env, + loanAmount, + interestExponent, + lender, + borrower, + evan, + broker, + pseudoAcct, + 0, + defaultImmediately(0)); + + lifecycle( + caseLabel, + "Loan overpayment allowed - Default without Impair", + env, + loanAmount, + interestExponent, + lender, + borrower, + evan, + broker, + pseudoAcct, + tfLoanOverpayment, + defaultImmediately(lsfLoanOverpayment, false)); + + lifecycle( + caseLabel, + "Loan overpayment prohibited - Default without Impair", + env, + loanAmount, + interestExponent, + lender, + borrower, + evan, + broker, + pseudoAcct, + 0, + defaultImmediately(0, false)); + + lifecycle( + caseLabel, + "Loan overpayment prohibited - Pay off immediately", + env, + loanAmount, + interestExponent, + lender, + borrower, + evan, + broker, + pseudoAcct, + 0, + fullPayment(0)); + + lifecycle( + caseLabel, + "Loan overpayment allowed - Pay off immediately", + env, + loanAmount, + interestExponent, + lender, + borrower, + evan, + broker, + pseudoAcct, + tfLoanOverpayment, + fullPayment(lsfLoanOverpayment)); + + lifecycle( + caseLabel, + "Loan overpayment prohibited - Combine all payments", + env, + loanAmount, + interestExponent, + lender, + borrower, + evan, + broker, + pseudoAcct, + 0, + combineAllPayments(0)); + + lifecycle( + caseLabel, + "Loan overpayment allowed - Combine all payments", + env, + loanAmount, + interestExponent, + lender, + borrower, + evan, + broker, + pseudoAcct, + tfLoanOverpayment, + combineAllPayments(lsfLoanOverpayment)); + + lifecycle( + caseLabel, + "Loan overpayment prohibited - Make payments", + env, + loanAmount, + interestExponent, + lender, + borrower, + evan, + broker, + pseudoAcct, + 0, + [&](Keylet const& loanKeylet, VerifyLoanStatus const& verifyLoanStatus) { + // toEndOfLife + // + // Draw and make multiple payments + auto state = getCurrentState(env, broker, loanKeylet, verifyLoanStatus); + BEAST_EXPECT(state.flags == 0); + env.close(); + + verifyLoanStatus(state); + + env.close(state.startDate + 20s); + auto const loanAge = (env.now() - state.startDate).count(); + BEAST_EXPECT(loanAge == 30); + + // Periodic payment amount will consist of + // 1. principal outstanding (1000) + // 2. interest interest rate (at 12%) + // 3. payment interval (600s) + // 4. loan service fee (2) + // Calculate these values without the helper functions + // to verify they're working correctly The numbers in + // the below BEAST_EXPECTs may not hold across assets. + Number const interval = state.paymentInterval; + auto const periodicRate = interval * Number(12, -2) / secondsInYear; + BEAST_EXPECT(periodicRate == Number(2283105022831050228, -24, Number::normalized{})); + STAmount const roundedPeriodicPayment{ + broker.asset, roundPeriodicPayment(broker.asset, state.periodicPayment, state.loanScale)}; + + testcase << currencyLabel << " Payment components: " + << "Payments remaining, rawInterest, rawPrincipal, " + "rawMFee, trackedValueDelta, trackedPrincipalDelta, " + "trackedInterestDelta, trackedMgmtFeeDelta, special"; + + auto const serviceFee = broker.asset(2); + + BEAST_EXPECT( + roundedPeriodicPayment == + roundToScale( + broker.asset(Number(8333457002039338267, -17), Number::upward), + state.loanScale, + Number::upward)); + // 83334570.01162141 + // Include the service fee + STAmount const totalDue = + roundToScale(roundedPeriodicPayment + serviceFee, state.loanScale, Number::upward); + // Only check the first payment since the rounding + // may drift as payments are made + BEAST_EXPECT( + totalDue == + roundToScale( + broker.asset(Number(8533457002039338267, -17), Number::upward), + state.loanScale, + Number::upward)); + + { + auto const raw = computeTheoreticalLoanState( + state.periodicPayment, periodicRate, state.paymentRemaining, broker.params.managementFeeRate); + auto const rounded = constructLoanState( + state.totalValue, state.principalOutstanding, state.managementFeeOutstanding); + testcase << currencyLabel << " Loan starting state: " << state.paymentRemaining << ", " + << raw.interestDue << ", " << raw.principalOutstanding << ", " << raw.managementFeeDue + << ", " << rounded.valueOutstanding << ", " << rounded.principalOutstanding << ", " + << rounded.interestDue << ", " << rounded.managementFeeDue; + } + + // Try to pay a little extra to show that it's _not_ + // taken + STAmount const transactionAmount = STAmount{broker.asset, totalDue} + broker.asset(10); + // Only check the first payment since the rounding + // may drift as payments are made + BEAST_EXPECT( + transactionAmount == + roundToScale( + broker.asset(Number(9533457002039400, -14), Number::upward), state.loanScale, Number::upward)); + + auto const initialState = state; + detail::PaymentComponents totalPaid{ + .trackedValueDelta = 0, .trackedPrincipalDelta = 0, .trackedManagementFeeDelta = 0}; + Number totalInterestPaid = 0; + std::size_t totalPaymentsMade = 0; + + xrpl::LoanState currentTrueState = computeTheoreticalLoanState( + state.periodicPayment, periodicRate, state.paymentRemaining, broker.params.managementFeeRate); + + while (state.paymentRemaining > 0) + { + // 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_EXPECTS( + paymentComponents.specialCase == detail::PaymentSpecialCase::final || + paymentComponents.trackedValueDelta <= roundedPeriodicPayment, + "Delta: " + to_string(paymentComponents.trackedValueDelta) + + ", periodic payment: " + to_string(roundedPeriodicPayment)); + + xrpl::LoanState const nextTrueState = computeTheoreticalLoanState( + state.periodicPayment, + periodicRate, + state.paymentRemaining - 1, + broker.params.managementFeeRate); + detail::LoanStateDeltas const deltas = currentTrueState - nextTrueState; + + testcase << currencyLabel << " Payment components: " << state.paymentRemaining << ", " + << deltas.interest << ", " << deltas.principal << ", " << deltas.managementFee << ", " + << paymentComponents.trackedValueDelta << ", " << paymentComponents.trackedPrincipalDelta + << ", " << paymentComponents.trackedInterestPart() << ", " + << paymentComponents.trackedManagementFeeDelta << ", " + << (paymentComponents.specialCase == detail::PaymentSpecialCase::final ? "final" + : paymentComponents.specialCase == detail::PaymentSpecialCase::extra ? "extra" + : "none"); + + auto const totalDueAmount = + STAmount{broker.asset, paymentComponents.trackedValueDelta + serviceFee.number()}; + + // Due to the rounding algorithms to keep the interest and + // principal in sync with "true" values, the computed amount + // may be a little less than the rounded fixed payment + // amount. For integral types, the difference should be < 3 + // (1 unit for each of the interest and management fee). For + // IOUs, the difference should be after the 8th digit. + Number const diff = totalDue - totalDueAmount; + BEAST_EXPECT( + paymentComponents.specialCase == detail::PaymentSpecialCase::final || diff == beast::zero || + (diff > beast::zero && + ((broker.asset.integral() && (static_cast(diff) < 3)) || + (state.loanScale - diff.exponent() > 13)))); + + BEAST_EXPECT( + paymentComponents.trackedValueDelta == + paymentComponents.trackedPrincipalDelta + paymentComponents.trackedInterestPart() + + paymentComponents.trackedManagementFeeDelta); + BEAST_EXPECT( + paymentComponents.specialCase == detail::PaymentSpecialCase::final || + paymentComponents.trackedValueDelta <= roundedPeriodicPayment); + + BEAST_EXPECT( + state.paymentRemaining < 12 || + roundToAsset(broker.asset, deltas.principal, state.loanScale, Number::upward) == + roundToScale( + broker.asset(Number(8333228691531218890, -17), Number::upward), + state.loanScale, + Number::upward)); + BEAST_EXPECT( + paymentComponents.trackedPrincipalDelta >= beast::zero && + paymentComponents.trackedPrincipalDelta <= state.principalOutstanding); + BEAST_EXPECT( + paymentComponents.specialCase != detail::PaymentSpecialCase::final || + paymentComponents.trackedPrincipalDelta == state.principalOutstanding); + BEAST_EXPECT( + paymentComponents.specialCase == detail::PaymentSpecialCase::final || + (state.periodicPayment.exponent() - + (deltas.principal + deltas.interest + deltas.managementFee - state.periodicPayment) + .exponent()) > 14); + + auto const borrowerBalanceBeforePayment = env.balance(borrower, broker.asset); + + if (canImpairLoan(env, broker, state)) + // Making a payment will unimpair the loan + env(manage(lender, loanKeylet.key, tfLoanImpair)); + + env.close(); + + // Make the payment + env(pay(borrower, loanKeylet.key, transactionAmount)); + + env.close(); + + // Need to account for fees if the loan is in XRP + PrettyAmount adjustment = broker.asset(0); + if (broker.asset.native()) + { + adjustment = env.current()->fees().base; + } + + // Check the result + verifyLoanStatus.checkPayment( + state.loanScale, borrower, borrowerBalanceBeforePayment, totalDueAmount, adjustment); + + --state.paymentRemaining; + state.previousPaymentDate = state.nextPaymentDate; + if (paymentComponents.specialCase == detail::PaymentSpecialCase::final) + { + state.paymentRemaining = 0; + state.nextPaymentDate = 0; + } + else + { + state.nextPaymentDate += state.paymentInterval; + } + state.principalOutstanding -= paymentComponents.trackedPrincipalDelta; + state.managementFeeOutstanding -= paymentComponents.trackedManagementFeeDelta; + state.totalValue -= paymentComponents.trackedValueDelta; + + verifyLoanStatus(state); + + totalPaid.trackedValueDelta += paymentComponents.trackedValueDelta; + totalPaid.trackedPrincipalDelta += paymentComponents.trackedPrincipalDelta; + totalPaid.trackedManagementFeeDelta += paymentComponents.trackedManagementFeeDelta; + totalInterestPaid += paymentComponents.trackedInterestPart(); + ++totalPaymentsMade; + + currentTrueState = nextTrueState; + } + + // Loan is paid off + BEAST_EXPECT(state.paymentRemaining == 0); + BEAST_EXPECT(state.principalOutstanding == 0); + + // Make sure all the payments add up + BEAST_EXPECT(totalPaid.trackedValueDelta == initialState.totalValue); + BEAST_EXPECT(totalPaid.trackedPrincipalDelta == initialState.principalOutstanding); + BEAST_EXPECT(totalPaid.trackedManagementFeeDelta == initialState.managementFeeOutstanding); + // This is almost a tautology given the previous checks, but + // check it anyway for completeness. + BEAST_EXPECT( + totalInterestPaid == + initialState.totalValue - + (initialState.principalOutstanding + initialState.managementFeeOutstanding)); + BEAST_EXPECT(totalPaymentsMade == initialState.paymentRemaining); + + // Can't impair or default a paid off loan + env(manage(lender, loanKeylet.key, tfLoanImpair), ter(tecNO_PERMISSION)); + env(manage(lender, loanKeylet.key, tfLoanDefault), ter(tecNO_PERMISSION)); + }); + } +}; +} // namespace xrpl::test diff --git a/src/test/app/Loan/LoanBatch_test.cpp b/src/test/app/Loan/LoanBatch_test.cpp index 863b0334ae..052835d9ba 100644 --- a/src/test/app/Loan/LoanBatch_test.cpp +++ b/src/test/app/Loan/LoanBatch_test.cpp @@ -1,14 +1,8 @@ #include // +#include #include -#include -#include -#include -#include -#include - -#include #include #include @@ -16,16 +10,9 @@ namespace xrpl { namespace test { -class LoanBatch_test : public beast::unit_test::suite +class LoanBatch_test : public LoanBase { 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}; - - std::string const iouCurrency{"IOU"}; - void testDisabled() { @@ -36,57 +23,34 @@ protected: // of that. using namespace jtx; auto failAll = [this](FeatureBitset features) { + using namespace loan; + using namespace std::chrono_literals; + Env env(*this, features); Account const alice{"alice"}; Account const bob{"bob"}; env.fund(XRP(10000), alice, bob); + env.close(); auto const keylet = keylet::loanbroker(alice, env.seq(alice)); - - using namespace std::chrono_literals; - - auto const batchFee = batch::calcBatchFee(env, 1, 2); + auto const loanKeylet = keylet::loan(keylet.key, env.seq(alice)); auto const aliceSeq = env.seq(alice); auto const bobSeq = env.seq(bob); + + auto const batchFee = batch::calcBatchFee(env, 1, 4); + + auto loanSet = set(alice, keylet.key, Number(10000)); + loanSet[sfCounterparty] = bob.human(); auto batchTxn = env.jt( - tesSUCCESS, - batch::outer(alice, aliceSeq, batchFee, tfAllOrNothing), - batch::inner(check::create(bob, alice, USD(10)), bobSeq), - batch::inner(check::cash(alice, chkID, USD(10)), aliceSeq + 1), - batch::sig(bob)); - env(batchTxn, jtx::ter(result)); - - auto const [txIDs, batchID] = batch::submitBatch(env, ); - env.close(); - - std::vector testCases = { - {0, "Batch", "tesSUCCESS", batchID, std::nullopt}, - {1, "CheckCreate", "tesSUCCESS", txIDs[0], batchID}, - {2, "CheckCash", "tesSUCCESS", txIDs[1], batchID}, - }; - validateClosedLedger(env, testCases); - - // counter party signature is optional on LoanSet. Confirm that by - // sending transaction without one. - auto setTx = env.jt(set(alice, keylet.key, Number(10000)), ter(temDISABLED)); - env(setTx); - - // All loan transactions are disabled. - // 1. LoanSet - setTx = env.jt(setTx, sig(sfCounterpartySignature, bob), ter(temDISABLED)); - env(setTx); - // Actual sequence will be based off the loan broker, but we - // obviously don't have one of those if the amendment is disabled - auto const loanKeylet = keylet::loan(keylet.key, env.seq(alice)); - // Other Loan transactions are disabled, too. - // 2. LoanDelete - env(del(alice, loanKeylet.key), ter(temDISABLED)); - // 3. LoanManage - env(manage(alice, loanKeylet.key, tfLoanImpair), ter(temDISABLED)); - // 4. LoanPay - env(pay(alice, loanKeylet.key, XRP(500)), ter(temDISABLED)); + batch::outer(bob, bobSeq, batchFee, tfAllOrNothing), + batch::inner(loanSet, aliceSeq), + batch::inner(del(alice, loanKeylet.key), aliceSeq + 1), + batch::inner(manage(alice, loanKeylet.key, tfLoanImpair), aliceSeq + 2), + batch::inner(pay(alice, loanKeylet.key, XRP(500)), aliceSeq + 3), + batch::sig(alice)); + env(batchTxn, ter(temINVALID_INNER_BATCH)); }; failAll(all - featureMPTokensV1); failAll(all - featureSingleAssetVault - featureLendingProtocol); @@ -94,5291 +58,76 @@ protected: failAll(all - featureLendingProtocol); } - struct BrokerParameters + void + testCreateAsset() { - 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{}; - std::uint32_t flags = 0; + testcase("CreateAsset"); + // Checks if a single asset vault can be created in a batch. - Number - maxCoveredLoanValue(Number const& currentDebt) const - { - NumberRoundModeGuard mg(Number::downward); - 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; - uint256 vaultID; - BrokerParameters params; - BrokerInfo( - jtx::PrettyAsset const& asset_, - Keylet const& brokerKeylet_, - Keylet const& vaultKeylet_, - BrokerParameters const& p) - : asset(asset_), brokerID(brokerKeylet_.key), vaultID(vaultKeylet_.key), params(p) - { - } - - Keylet - brokerKeylet() const - { - return keylet::loanbroker(brokerID); - } - Keylet - vaultKeylet() const - { - return keylet::vault(vaultID); - } - - int - vaultScale(jtx::Env const& env) const - { - using namespace jtx; - - auto const vaultSle = env.le(keylet::vault(vaultID)); - return getAssetsTotalScale(vaultSle); - } - }; - - struct LoanParameters - { - // The account submitting the transaction. May be borrower or broker. - jtx::Account account; - // The counterparty. Should be the other of borrower or broker. - jtx::Account counter; - // Whether the counterparty is specified in the `counterparty` field, or - // only signs. - bool counterpartyExplicit = true; - Number principalRequest; - 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, BrokerInfo const& broker, FN const&... fN) const - { - using namespace jtx; - using namespace jtx::loan; - - JTx jt{loan::set(account, broker.brokerID, broker.asset(principalRequest).number(), flags.value_or(0))}; - - sig(sfCounterpartySignature, counter)(env, jt); - - fee{setFee.value_or(env.current()->fees().base * 2)}(env, jt); - - if (counterpartyExplicit) - counterparty(counter)(env, jt); - if (originationFee) - loanOriginationFee(broker.asset(*originationFee).number())(env, jt); - if (serviceFee) - loanServiceFee(broker.asset(*serviceFee).number())(env, jt); - if (lateFee) - latePaymentFee(broker.asset(*lateFee).number())(env, jt); - if (closeFee) - closePaymentFee(broker.asset(*closeFee).number())(env, jt); - if (overFee) - overpaymentFee (*overFee)(env, jt); - if (interest) - interestRate (*interest)(env, jt); - if (lateInterest) - lateInterestRate (*lateInterest)(env, jt); - if (closeInterest) - closeInterestRate (*closeInterest)(env, jt); - if (overpaymentInterest) - overpaymentInterestRate (*overpaymentInterest)(env, jt); - if (payTotal) - paymentTotal (*payTotal)(env, jt); - if (payInterval) - paymentInterval (*payInterval)(env, jt); - if (gracePd) - gracePeriod (*gracePd)(env, jt); - - return env.jt(jt, fN...); - } - }; - - struct PaymentParameters - { - Number overpaymentFactor = Number{1}; - std::optional overpaymentExtra = std::nullopt; - std::uint32_t flags = 0; - bool showStepBalances = false; - bool validateBalances = true; - - static PaymentParameters const& - defaults() - { - static PaymentParameters const result{}; - return result; - } - }; - - struct LoanState - { - std::uint32_t previousPaymentDate = 0; - NetClock::time_point startDate = {}; - std::uint32_t nextPaymentDate = 0; - std::uint32_t paymentRemaining = 0; - std::int32_t const loanScale = 0; - Number totalValue = 0; - Number principalOutstanding = 0; - Number managementFeeOutstanding = 0; - Number periodicPayment = 0; - std::uint32_t flags = 0; - std::uint32_t const paymentInterval = 0; - TenthBips32 const interestRate{}; - }; - - /** Helper class to compare the expected state of a loan and loan broker - * against the data in the ledger. - */ - struct VerifyLoanStatus - { - public: - jtx::Env const& env; - BrokerInfo const& broker; - jtx::Account const& pseudoAccount; - Keylet const& loanKeylet; - - VerifyLoanStatus( - jtx::Env const& env_, - BrokerInfo const& broker_, - jtx::Account const& pseudo_, - Keylet const& keylet_) - : env(env_), broker(broker_), pseudoAccount(pseudo_), loanKeylet(keylet_) - { - } - - /** Checks the expected broker state against the ledger - */ - void - checkBroker( - Number const& principalOutstanding, - Number const& interestOwed, - TenthBips32 interestRate, - std::uint32_t paymentInterval, - std::uint32_t paymentsRemaining, - std::uint32_t ownerCount) const - { - using namespace jtx; - if (auto brokerSle = env.le(keylet::loanbroker(broker.brokerID)); env.test.BEAST_EXPECT(brokerSle)) - { - TenthBips16 const managementFeeRate{brokerSle->at(sfManagementFeeRate)}; - auto const brokerDebt = brokerSle->at(sfDebtTotal); - auto const expectedDebt = principalOutstanding + interestOwed; - env.test.BEAST_EXPECT(brokerDebt == expectedDebt); - env.test.BEAST_EXPECT( - env.balance(pseudoAccount, broker.asset).number() == brokerSle->at(sfCoverAvailable)); - env.test.BEAST_EXPECT(brokerSle->at(sfOwnerCount) == ownerCount); - - if (auto vaultSle = env.le(keylet::vault(brokerSle->at(sfVaultID))); env.test.BEAST_EXPECT(vaultSle)) - { - Account const vaultPseudo{"vaultPseudoAccount", vaultSle->at(sfAccount)}; - env.test.BEAST_EXPECT( - vaultSle->at(sfAssetsAvailable) == env.balance(vaultPseudo, broker.asset).number()); - if (ownerCount == 0) - { - // Allow some slop for rounding IOUs - - // TODO: This needs to be an exact match once all the - // other rounding issues are worked out. - auto const total = vaultSle->at(sfAssetsTotal); - auto const available = vaultSle->at(sfAssetsAvailable); - env.test.BEAST_EXPECT( - total == available || - (!broker.asset.integral() && available != 0 && - ((total - available) / available < Number(1, -6)))); - env.test.BEAST_EXPECT(vaultSle->at(sfLossUnrealized) == 0); - } - } - } - } - - void - checkPayment( - std::int32_t loanScale, - jtx::Account const& account, - jtx::PrettyAmount const& balanceBefore, - STAmount const& expectedPayment, - jtx::PrettyAmount const& adjustment) const - { - auto const borrowerScale = std::max(loanScale, balanceBefore.number().exponent()); - - STAmount const balanceChangeAmount{ - broker.asset, roundToAsset(broker.asset, expectedPayment + adjustment, borrowerScale)}; - { - auto const difference = roundToScale( - env.balance(account, broker.asset) - (balanceBefore - balanceChangeAmount), borrowerScale); - env.test.expect( - roundToScale(difference, loanScale) >= beast::zero, - "Balance before: " + to_string(balanceBefore.value()) + - ", expected change: " + to_string(balanceChangeAmount) + - ", difference (balance after - expected): " + to_string(difference), - __FILE__, - __LINE__); - } - } - - /** Checks both the loan and broker expect states against the ledger */ - void - operator()( - std::uint32_t previousPaymentDate, - std::uint32_t nextPaymentDate, - std::uint32_t paymentRemaining, - Number const& loanScale, - Number const& totalValue, - Number const& principalOutstanding, - Number const& managementFeeOutstanding, - Number const& periodicPayment, - std::uint32_t flags) const - { - using namespace jtx; - if (auto loan = env.le(loanKeylet); env.test.BEAST_EXPECT(loan)) - { - env.test.BEAST_EXPECT(loan->at(sfPreviousPaymentDueDate) == previousPaymentDate); - env.test.BEAST_EXPECT(loan->at(sfPaymentRemaining) == paymentRemaining); - env.test.BEAST_EXPECT(loan->at(sfNextPaymentDueDate) == nextPaymentDate); - env.test.BEAST_EXPECT(loan->at(sfLoanScale) == loanScale); - env.test.BEAST_EXPECT(loan->at(sfTotalValueOutstanding) == totalValue); - env.test.BEAST_EXPECT(loan->at(sfPrincipalOutstanding) == principalOutstanding); - env.test.BEAST_EXPECT(loan->at(sfManagementFeeOutstanding) == managementFeeOutstanding); - env.test.BEAST_EXPECT(loan->at(sfPeriodicPayment) == periodicPayment); - env.test.BEAST_EXPECT(loan->at(sfFlags) == flags); - - auto const ls = constructRoundedLoanState(loan); - - auto const interestRate = TenthBips32{loan->at(sfInterestRate)}; - auto const paymentInterval = loan->at(sfPaymentInterval); - checkBroker(principalOutstanding, ls.interestDue, interestRate, paymentInterval, paymentRemaining, 1); - - if (auto brokerSle = env.le(keylet::loanbroker(broker.brokerID)); env.test.BEAST_EXPECT(brokerSle)) - { - if (auto vaultSle = env.le(keylet::vault(brokerSle->at(sfVaultID))); - env.test.BEAST_EXPECT(vaultSle)) - { - if ((flags & lsfLoanImpaired) && !(flags & lsfLoanDefault)) - { - env.test.BEAST_EXPECT( - vaultSle->at(sfLossUnrealized) == totalValue - managementFeeOutstanding); - } - else - { - env.test.BEAST_EXPECT(vaultSle->at(sfLossUnrealized) == 0); - } - } - } - } - } - - /** Checks both the loan and broker expect states against the ledger */ - void - operator()(LoanState const& state) const - { - operator()( - state.previousPaymentDate, - state.nextPaymentDate, - state.paymentRemaining, - state.loanScale, - state.totalValue, - state.principalOutstanding, - state.managementFeeOutstanding, - state.periodicPayment, - state.flags); - }; - }; - - BrokerInfo - createVaultAndBroker( - jtx::Env& env, - jtx::PrettyAsset const& asset, - jtx::Account const& lender, - BrokerParameters const& params = BrokerParameters::defaults()) - { using namespace jtx; - Vault vault{env}; + Env env(*this, all); - auto const deposit = asset(params.vaultDeposit); - auto const debtMaximumValue = asset(params.debtMax).value(); - auto const coverDepositValue = asset(params.coverDeposit).value(); + Account const issuer{"issuer"}; + Account const broker{"broker"}; + Account const borrower{"borrower"}; - auto const coverRateMinValue = params.coverRateMin; - - auto [tx, vaultKeylet] = vault.create({.owner = lender, .asset = asset}); - env(tx); - env.close(); - BEAST_EXPECT(env.le(vaultKeylet)); - - env(vault.deposit({.depositor = lender, .id = vaultKeylet.key, .amount = deposit})); - env.close(); - if (auto const vault = env.le(keylet::vault(vaultKeylet.key)); BEAST_EXPECT(vault)) - { - BEAST_EXPECT(vault->at(sfAssetsAvailable) == deposit.value()); - } - - auto const keylet = keylet::loanbroker(lender.id(), env.seq(lender)); - - using namespace loanBroker; - env(set(lender, vaultKeylet.key, params.flags), - data(params.data), - managementFeeRate(params.managementFeeRate), - debtMaximum(debtMaximumValue), - coverRateMinimum(coverRateMinValue), - coverRateLiquidation(TenthBips32(params.coverRateLiquidation))); - - if (coverDepositValue != beast::zero) - env(coverDeposit(lender, keylet.key, coverDepositValue)); + auto const IOU = issuer["IOU"]; + env.fund(XRP(20'000), issuer, broker, borrower); env.close(); - return {asset, keylet, vaultKeylet, params}; - } + env(trust(broker, IOU(20'000'000))); + env(pay(issuer, broker, IOU(10'000'000))); + env.close(); - /// Get the state without checking anything - LoanState - getCurrentState(jtx::Env const& env, BrokerInfo const& broker, Keylet const& loanKeylet) - { - using d = NetClock::duration; - using tp = NetClock::time_point; + auto brokerSeq = env.seq(broker); + // The starting sequence should be brokerSeq + 1 because the batch + // outer transaction will consume the first sequence. + auto txns = createVaultAndBrokerTransactions(env, IOU, broker, BrokerParameters::defaults(), brokerSeq + 1); - // Lookup the current loan state - if (auto loan = env.le(loanKeylet); BEAST_EXPECT(loan)) - { - return LoanState{ - .previousPaymentDate = loan->at(sfPreviousPaymentDueDate), - .startDate = tp{d{loan->at(sfStartDate)}}, - .nextPaymentDate = loan->at(sfNextPaymentDueDate), - .paymentRemaining = loan->at(sfPaymentRemaining), - .loanScale = loan->at(sfLoanScale), - .totalValue = loan->at(sfTotalValueOutstanding), - .principalOutstanding = loan->at(sfPrincipalOutstanding), - .managementFeeOutstanding = loan->at(sfManagementFeeOutstanding), - .periodicPayment = loan->at(sfPeriodicPayment), - .flags = loan->at(sfFlags), - .paymentInterval = loan->at(sfPaymentInterval), - .interestRate = TenthBips32{loan->at(sfInterestRate)}, - }; - } - return LoanState{}; - } + auto const batchFee = batch::calcBatchFee(env, 0, 4); - /// Get the state and check the values against the parameters used in - /// `lifecycle` - LoanState - getCurrentState( - jtx::Env const& env, - BrokerInfo const& broker, - Keylet const& loanKeylet, - VerifyLoanStatus const& verifyLoanStatus) - { - using namespace std::chrono_literals; - using d = NetClock::duration; - using tp = NetClock::time_point; + auto batchTxn = env.jt( + batch::outer(broker, brokerSeq, batchFee, tfAllOrNothing), + batch::inner(txns.vaultCreateTx, brokerSeq + 1), + batch::inner(txns.vaultDepositTx, brokerSeq + 2), + batch::inner(txns.brokerSetTx, brokerSeq + 3), + batch::inner(*txns.coverDepositTx, brokerSeq + 4)); - auto const state = getCurrentState(env, broker, loanKeylet); - BEAST_EXPECT(state.previousPaymentDate == 0); - BEAST_EXPECT(tp{d{state.nextPaymentDate}} == state.startDate + 600s); - BEAST_EXPECT(state.paymentRemaining == 12); - BEAST_EXPECT(state.principalOutstanding == broker.asset(1000).value()); - BEAST_EXPECT( - state.loanScale >= - (broker.asset.integral() ? 0 : std::max(broker.vaultScale(env), state.principalOutstanding.exponent()))); - BEAST_EXPECT(state.paymentInterval == 600); - { - NumberRoundModeGuard mg(Number::upward); - BEAST_EXPECT( - state.totalValue == - roundToAsset(broker.asset, state.periodicPayment * state.paymentRemaining, state.loanScale)); - } - BEAST_EXPECT( - state.managementFeeOutstanding == - computeManagementFee( - broker.asset, - state.totalValue - state.principalOutstanding, - broker.params.managementFeeRate, - state.loanScale)); + env(batchTxn); + env.close(); - verifyLoanStatus(state); - - return state; - } - - bool - canImpairLoan(jtx::Env const& env, BrokerInfo const& broker, LoanState const& state) - { - if (auto const brokerSle = env.le(keylet::loanbroker(broker.brokerID)); BEAST_EXPECT(brokerSle)) - { - if (auto const vaultSle = env.le(keylet::vault(brokerSle->at(sfVaultID))); BEAST_EXPECT(vaultSle)) - { - // log << vaultSle->getJson() << std::endl; - auto const assetsUnavailable = vaultSle->at(sfAssetsTotal) - vaultSle->at(sfAssetsAvailable); - auto const unrealizedLoss = - vaultSle->at(sfLossUnrealized) + state.totalValue - state.managementFeeOutstanding; - - if (!BEAST_EXPECT(unrealizedLoss <= assetsUnavailable)) - { - return false; - } - } - } - return true; - } - - enum class AssetType { XRP = 0, IOU = 1, MPT = 2 }; - - // Specify the accounts as params to allow other accounts to be used - jtx::PrettyAsset - createAsset( - jtx::Env& env, - AssetType assetType, - BrokerParameters const& brokerParams, - jtx::Account const& issuer, - jtx::Account const& lender, - jtx::Account const& borrower) - { - using namespace jtx; - - switch (assetType) - { - case AssetType::XRP: - // TODO: remove the factor, and set up loans in drops - return PrettyAsset{xrpIssue(), 1'000'000}; - - case AssetType::IOU: { - PrettyAsset const asset{issuer[iouCurrency]}; - - auto const limit = asset(100 * (brokerParams.vaultDeposit + brokerParams.coverDeposit)); - if (lender != issuer) - env(trust(lender, limit)); - if (borrower != issuer) - env(trust(borrower, limit)); - - return asset; - } - - case AssetType::MPT: { - // Enough to cover initial fees - if (!env.le(keylet::account(issuer))) - env.fund(env.current()->fees().accountReserve(10) * 10, issuer); - if (!env.le(keylet::account(lender))) - env.fund(env.current()->fees().accountReserve(10) * 10, noripple(lender)); - if (!env.le(keylet::account(borrower))) - env.fund(env.current()->fees().accountReserve(10) * 10, noripple(borrower)); - - 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 - if (lender != issuer) - mptt.authorize({.account = lender}); - if (borrower != issuer) - mptt.authorize({.account = borrower}); - - env.close(); - - return asset; - } - - default: - throw std::runtime_error("Unknown asset type"); - } + checkVaultAndBroker(env, txns); } void - describeLoan( - jtx::Env& env, - BrokerParameters const& brokerParams, - LoanParameters const& loanParams, - AssetType assetType, - jtx::Account const& issuer, - jtx::Account const& lender, - jtx::Account const& borrower) + testLoanSetAndDelete() { - using namespace jtx; + testcase("LoanSetAndDelete"); + // Checks if LoanSet works in a batch. - auto const asset = createAsset(env, assetType, brokerParams, issuer, lender, borrower); - auto const principal = asset(loanParams.principalRequest).number(); - auto const interest = loanParams.interest.value_or(TenthBips32{}); - auto const interval = loanParams.payInterval.value_or(LoanSet::defaultPaymentInterval); - auto const total = loanParams.payTotal.value_or(LoanSet::defaultPaymentTotal); - auto const feeRate = brokerParams.managementFeeRate; - auto const props = computeLoanProperties( - asset, principal, interest, interval, total, feeRate, asset(brokerParams.vaultDeposit).number().exponent()); - log << "Loan properties:\n" - << "\tPrincipal: " << principal << std::endl - << "\tInterest rate: " << interest << std::endl - << "\tPayment interval: " << interval << std::endl - << "\tManagement Fee Rate: " << feeRate << std::endl - << "\tTotal Payments: " << total << std::endl - << "\tPeriodic Payment: " << props.periodicPayment << std::endl - << "\tTotal Value: " << props.loanState.valueOutstanding << std::endl - << "\tManagement Fee: " << props.loanState.managementFeeDue << std::endl - << "\tLoan Scale: " << props.loanScale << std::endl - << "\tFirst payment principal: " << props.firstPaymentPrincipal << std::endl; - - // checkGuards returns a TER, so success is 0 - BEAST_EXPECT(!checkLoanGuards( - asset, - asset(loanParams.principalRequest).number(), - loanParams.interest.value_or(TenthBips32{}) != beast::zero, - loanParams.payTotal.value_or(LoanSet::defaultPaymentTotal), - props, - env.journal)); - } - - std::optional> - createLoan( - jtx::Env& env, - AssetType assetType, - BrokerParameters const& brokerParams, - LoanParameters const& loanParams, - jtx::Account const& issuer, - jtx::Account const& lender, - jtx::Account const& borrower) - { - using namespace jtx; - - // Enough to cover initial fees - env.fund(env.current()->fees().accountReserve(10) * 10, issuer); - if (lender != issuer) - env.fund(env.current()->fees().accountReserve(10) * 10, noripple(lender)); - if (borrower != issuer && borrower != lender) - env.fund(env.current()->fees().accountReserve(10) * 10, noripple(borrower)); - - describeLoan(env, brokerParams, loanParams, assetType, issuer, lender, borrower); - - // Make the asset - auto const asset = createAsset(env, assetType, brokerParams, issuer, lender, borrower); - - env.close(); - if (asset.native() || lender != issuer) - env( - pay((asset.native() ? env.master : issuer), - lender, - asset(brokerParams.vaultDeposit + brokerParams.coverDeposit))); - // Fund the borrower later once we know the total loan - // size - - BrokerInfo const broker = createVaultAndBroker(env, asset, lender, brokerParams); - - auto const pseudoAcctOpt = [&]() -> std::optional { - auto const brokerSle = env.le(keylet::loanbroker(broker.brokerID)); - if (!BEAST_EXPECT(brokerSle)) - return std::nullopt; - auto const brokerPseudo = brokerSle->at(sfAccount); - return Account("Broker pseudo-account", brokerPseudo); - }(); - if (!pseudoAcctOpt) - return std::nullopt; - Account const& pseudoAcct = *pseudoAcctOpt; - - auto const loanKeyletOpt = [&]() -> std::optional { - auto const brokerSle = env.le(keylet::loanbroker(broker.brokerID)); - if (!BEAST_EXPECT(brokerSle)) - return std::nullopt; - - // Broker has no loans - BEAST_EXPECT(brokerSle->at(sfOwnerCount) == 0); - - // The loan keylet is based on the LoanSequence of the - // _LOAN_BROKER_ object. - auto const loanSequence = brokerSle->at(sfLoanSequence); - return keylet::loan(broker.brokerID, loanSequence); - }(); - if (!loanKeyletOpt) - return std::nullopt; - Keylet const& loanKeylet = *loanKeyletOpt; - - env(loanParams(env, broker)); - - env.close(); - - return std::make_tuple(broker, loanKeylet, pseudoAcct); - } - - void - topUpBorrower( - jtx::Env& env, - BrokerInfo const& broker, - jtx::Account const& issuer, - jtx::Account const& borrower, - LoanState const& state, - std::optional const& servFee) - { - using namespace jtx; - - STAmount const serviceFee = broker.asset(servFee.value_or(0)); - - // Ensure the borrower has enough funds to make the payments - // (including tx fees, if necessary) - auto const borrowerBalance = env.balance(borrower, broker.asset); - - auto const baseFee = env.current()->fees().base; - - // Add extra for transaction fees and reserves, if appropriate, or a - // tiny amount for the extra paid in each transaction - auto const totalNeeded = state.totalValue + (serviceFee * state.paymentRemaining) + - (broker.asset.native() ? Number( - baseFee * state.paymentRemaining + - env.current()->fees().accountReserve(env.ownerCount(borrower))) - : broker.asset(15).number()); - - auto const shortage = totalNeeded - borrowerBalance.number(); - - if (shortage > beast::zero && (broker.asset.native() || issuer != borrower)) - env(pay((broker.asset.native() ? env.master : issuer), borrower, STAmount{broker.asset, shortage})); - } - - void - makeLoanPayments( - jtx::Env& env, - BrokerInfo const& broker, - LoanParameters const& loanParams, - Keylet const& loanKeylet, - VerifyLoanStatus const& verifyLoanStatus, - jtx::Account const& issuer, - jtx::Account const& lender, - jtx::Account const& borrower, - PaymentParameters const& paymentParams = PaymentParameters::defaults()) - { - // Make all the individual payments using namespace jtx; using namespace jtx::loan; - using namespace std::chrono_literals; - using d = NetClock::duration; - - bool const showStepBalances = paymentParams.showStepBalances; - - auto const currencyLabel = getCurrencyLabel(broker.asset); - - auto const baseFee = env.current()->fees().base; - - env.close(); - auto state = getCurrentState(env, broker, loanKeylet); - - verifyLoanStatus(state); - - STAmount const serviceFee = broker.asset(loanParams.serviceFee.value_or(0)); - - topUpBorrower(env, broker, issuer, borrower, state, loanParams.serviceFee); - - // Periodic payment amount will consist of - // 1. principal outstanding (1000) - // 2. interest interest rate (at 12%) - // 3. payment interval (600s) - // 4. loan service fee (2) - // Calculate these values without the helper functions - // to verify they're working correctly The numbers in - // the below BEAST_EXPECTs may not hold across assets. - auto const periodicRate = loanPeriodicRate(state.interestRate, state.paymentInterval); - STAmount const roundedPeriodicPayment{ - broker.asset, roundPeriodicPayment(broker.asset, state.periodicPayment, state.loanScale)}; - - if (!showStepBalances) - log << currencyLabel << " Payment components: " - << "Payments remaining, " - << "rawInterest, rawPrincipal, " - "rawMFee, " - << "trackedValueDelta, trackedPrincipalDelta, " - "trackedInterestDelta, trackedMgmtFeeDelta, special" - << std::endl; - - // Include the service fee - STAmount const totalDue = roundToScale(roundedPeriodicPayment + serviceFee, state.loanScale, Number::upward); - - auto currentRoundedState = - constructLoanState(state.totalValue, state.principalOutstanding, state.managementFeeOutstanding); - { - auto const raw = computeTheoreticalLoanState( - state.periodicPayment, periodicRate, state.paymentRemaining, broker.params.managementFeeRate); - - if (showStepBalances) - { - log << currencyLabel << " Starting loan balances: " - << "\n\tTotal value: " << currentRoundedState.valueOutstanding - << "\n\tPrincipal: " << currentRoundedState.principalOutstanding - << "\n\tInterest: " << currentRoundedState.interestDue - << "\n\tMgmt fee: " << currentRoundedState.managementFeeDue << "\n\tPayments remaining " - << state.paymentRemaining << std::endl; - } - else - { - log << currencyLabel << " Loan starting state: " << state.paymentRemaining << ", " << raw.interestDue - << ", " << raw.principalOutstanding << ", " << raw.managementFeeDue << ", " - << currentRoundedState.valueOutstanding << ", " << currentRoundedState.principalOutstanding << ", " - << currentRoundedState.interestDue << ", " << currentRoundedState.managementFeeDue << std::endl; - } - } - - // Try to pay a little extra to show that it's _not_ - // taken - auto const extraAmount = paymentParams.overpaymentExtra - ? broker.asset(*paymentParams.overpaymentExtra).value() - : std::min(broker.asset(10).value(), STAmount{broker.asset, totalDue / 20}); - - STAmount const transactionAmount = - STAmount{broker.asset, totalDue * paymentParams.overpaymentFactor} + extraAmount; - - auto const borrowerInitialBalance = env.balance(borrower, broker.asset).number(); - auto const initialState = state; - detail::PaymentComponents totalPaid{ - .trackedValueDelta = 0, .trackedPrincipalDelta = 0, .trackedManagementFeeDelta = 0}; - Number totalInterestPaid = 0; - Number totalFeesPaid = 0; - std::size_t totalPaymentsMade = 0; - - xrpl::LoanState currentTrueState = computeTheoreticalLoanState( - state.periodicPayment, periodicRate, state.paymentRemaining, broker.params.managementFeeRate); - - auto validateBorrowerBalance = [&]() { - if (borrower == issuer || !paymentParams.validateBalances) - return; - auto const totalSpent = - (totalPaid.trackedValueDelta + totalFeesPaid + - (broker.asset.native() ? Number(baseFee) * totalPaymentsMade : numZero)); - BEAST_EXPECT(env.balance(borrower, broker.asset).number() == borrowerInitialBalance - totalSpent); - }; - - auto const defaultRound = broker.asset.integral() ? 3 : 0; - auto truncate = [defaultRound](Number const& n, std::optional places = std::nullopt) { - auto const p = places.value_or(defaultRound); - if (p == 0) - return n; - auto const factor = Number{1, p}; - return (n * factor).truncate() / factor; - }; - while (state.paymentRemaining > 0) - { - validateBorrowerBalance(); - // Compute the expected principal amount - auto const paymentComponents = 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); - - xrpl::LoanState const nextTrueState = computeTheoreticalLoanState( - state.periodicPayment, periodicRate, state.paymentRemaining - 1, broker.params.managementFeeRate); - detail::LoanStateDeltas const deltas = currentTrueState - nextTrueState; - BEAST_EXPECT(deltas.total() == deltas.principal + deltas.interest + deltas.managementFee); - BEAST_EXPECT( - paymentComponents.specialCase == detail::PaymentSpecialCase::final || - deltas.total() == state.periodicPayment || - (state.loanScale - (deltas.total() - state.periodicPayment).exponent()) > 14); - - if (!showStepBalances) - log << currencyLabel << " Payment components: " << state.paymentRemaining << ", " - - << deltas.interest << ", " << deltas.principal << ", " << deltas.managementFee << ", " - << paymentComponents.trackedValueDelta << ", " << paymentComponents.trackedPrincipalDelta << ", " - << paymentComponents.trackedInterestPart() << ", " << paymentComponents.trackedManagementFeeDelta - << ", " - << (paymentComponents.specialCase == detail::PaymentSpecialCase::final ? "final" - : paymentComponents.specialCase == detail::PaymentSpecialCase::extra ? "extra" - : "none") - << std::endl; - - auto const totalDueAmount = STAmount{broker.asset, paymentComponents.trackedValueDelta + serviceFee}; - - if (paymentParams.validateBalances) - { - // Due to the rounding algorithms to keep the interest and - // principal in sync with "true" values, the computed amount - // may be a little less than the rounded fixed payment - // amount. For integral types, the difference should be < 3 - // (1 unit for each of the interest and management fee). For - // IOUs, the difference should be dust. - Number const diff = totalDue - totalDueAmount; - BEAST_EXPECT( - paymentComponents.specialCase == 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, paymentParams.flags)); - - env.close(d{state.paymentInterval / 2}); - - if (paymentParams.validateBalances) - { - // Need to account for fees if the loan is in XRP - PrettyAmount adjustment = broker.asset(0); - if (broker.asset.native()) - { - adjustment = env.current()->fees().base; - } - - // Check the result - verifyLoanStatus.checkPayment( - state.loanScale, borrower, borrowerBalanceBeforePayment, totalDueAmount, adjustment); - } - - if (showStepBalances) - { - auto const loanSle = env.le(loanKeylet); - if (!BEAST_EXPECT(loanSle)) - // No reason for this not to exist - return; - auto const current = constructRoundedLoanState(loanSle); - auto const errors = nextTrueState - current; - log << currencyLabel << " Loan balances: " - << "\n\tAmount taken: " << paymentComponents.trackedValueDelta - << "\n\tTotal value: " << current.valueOutstanding - << " (true: " << truncate(nextTrueState.valueOutstanding) << ", error: " << truncate(errors.total()) - << ")\n\tPrincipal: " << current.principalOutstanding - << " (true: " << truncate(nextTrueState.principalOutstanding) - << ", error: " << truncate(errors.principal) << ")\n\tInterest: " << current.interestDue - << " (true: " << truncate(nextTrueState.interestDue) << ", error: " << truncate(errors.interest) - << ")\n\tMgmt fee: " << current.managementFeeDue - << " (true: " << truncate(nextTrueState.managementFeeDue) - << ", error: " << truncate(errors.managementFee) << ")\n\tPayments remaining " - << loanSle->at(sfPaymentRemaining) << std::endl; - - currentRoundedState = current; - } - - --state.paymentRemaining; - state.previousPaymentDate = state.nextPaymentDate; - if (paymentComponents.specialCase == detail::PaymentSpecialCase::final) - { - state.paymentRemaining = 0; - state.nextPaymentDate = 0; - } - else - { - state.nextPaymentDate += state.paymentInterval; - } - state.principalOutstanding -= paymentComponents.trackedPrincipalDelta; - state.managementFeeOutstanding -= paymentComponents.trackedManagementFeeDelta; - state.totalValue -= paymentComponents.trackedValueDelta; - - if (paymentParams.validateBalances) - verifyLoanStatus(state); - - totalPaid.trackedValueDelta += paymentComponents.trackedValueDelta; - totalPaid.trackedPrincipalDelta += paymentComponents.trackedPrincipalDelta; - totalPaid.trackedManagementFeeDelta += paymentComponents.trackedManagementFeeDelta; - totalInterestPaid += paymentComponents.trackedInterestPart(); - totalFeesPaid += serviceFee; - ++totalPaymentsMade; - - currentTrueState = nextTrueState; - } - validateBorrowerBalance(); - - // Loan is paid off - BEAST_EXPECT(state.paymentRemaining == 0); - BEAST_EXPECT(state.principalOutstanding == 0); - - auto const initialInterestDue = - initialState.totalValue - (initialState.principalOutstanding + initialState.managementFeeOutstanding); - if (paymentParams.validateBalances) - { - // Make sure all the payments add up - BEAST_EXPECT(totalPaid.trackedValueDelta == initialState.totalValue); - BEAST_EXPECT(totalPaid.trackedPrincipalDelta == initialState.principalOutstanding); - BEAST_EXPECT(totalPaid.trackedManagementFeeDelta == initialState.managementFeeOutstanding); - // This is almost a tautology given the previous checks, but - // check it anyway for completeness. - BEAST_EXPECT(totalInterestPaid == initialInterestDue); - BEAST_EXPECT(totalPaymentsMade == initialState.paymentRemaining); - } - - if (showStepBalances) - { - auto const loanSle = env.le(loanKeylet); - if (!BEAST_EXPECT(loanSle)) - // No reason for this not to exist - return; - log << currencyLabel << " Total amounts paid: " - << "\n\tTotal value: " << totalPaid.trackedValueDelta - << " (initial: " << truncate(initialState.totalValue) - << ", error: " << truncate(initialState.totalValue - totalPaid.trackedValueDelta) - << ")\n\tPrincipal: " << totalPaid.trackedPrincipalDelta - << " (initial: " << truncate(initialState.principalOutstanding) - << ", error: " << truncate(initialState.principalOutstanding - totalPaid.trackedPrincipalDelta) - << ")\n\tInterest: " << totalInterestPaid << " (initial: " << truncate(initialInterestDue) - << ", error: " << truncate(initialInterestDue - totalInterestPaid) - << ")\n\tMgmt fee: " << totalPaid.trackedManagementFeeDelta - << " (initial: " << truncate(initialState.managementFeeOutstanding) - << ", error: " << truncate(initialState.managementFeeOutstanding - totalPaid.trackedManagementFeeDelta) - << ")\n\tTotal payments made: " << totalPaymentsMade << std::endl; - } - } - - void - runLoan(AssetType assetType, BrokerParameters const& brokerParams, LoanParameters const& loanParams) - { - using namespace jtx; - - Account const issuer("issuer"); - Account const lender("lender"); - Account const borrower("borrower"); Env env(*this, all); - auto loanResult = createLoan(env, assetType, brokerParams, loanParams, issuer, lender, borrower); - if (!BEAST_EXPECT(loanResult)) - return; - - auto broker = std::get(*loanResult); - auto loanKeylet = std::get(*loanResult); - auto pseudoAcct = std::get(*loanResult); - - VerifyLoanStatus verifyLoanStatus(env, broker, pseudoAcct, loanKeylet); - - makeLoanPayments( - env, - broker, - loanParams, - loanKeylet, - verifyLoanStatus, - issuer, - lender, - borrower, - PaymentParameters{.showStepBalances = true}); - } - - /** Runs through the complete lifecycle of a loan - * - * 1. Create a loan. - * 2. Test a bunch of transaction failure conditions. - * 3. Use the `toEndOfLife` callback to take the loan to 0. How that is done - * depends on the callback. e.g. Default, Early payoff, make all the - * normal payments, etc. - * 4. Delete the loan. The loan will alternate between being deleted by the - * lender and the borrower. - */ - void - lifecycle( - std::string const& caseLabel, - char const* label, - jtx::Env& env, - Number const& loanAmount, - int interestExponent, - jtx::Account const& lender, - jtx::Account const& borrower, - jtx::Account const& evan, - BrokerInfo const& broker, - jtx::Account const& pseudoAcct, - std::uint32_t flags, - // The end of life callback is expected to take the loan to 0 payments - // remaining, one way or another - std::function toEndOfLife) - { - auto const [keylet, loanSequence] = [&]() { - auto const brokerSle = env.le(keylet::loanbroker(broker.brokerID)); - if (!BEAST_EXPECT(brokerSle)) - // will be invalid - return std::make_pair(keylet::loan(broker.brokerID), std::uint32_t(0)); - - // Broker has no loans - BEAST_EXPECT(brokerSle->at(sfOwnerCount) == 0); - - // The loan keylet is based on the LoanSequence of the _LOAN_BROKER_ - // object. - auto const loanSequence = brokerSle->at(sfLoanSequence); - return std::make_pair(keylet::loan(broker.brokerID, loanSequence), loanSequence); - }(); - - VerifyLoanStatus const verifyLoanStatus(env, broker, pseudoAcct, keylet); - - // No loans yet - verifyLoanStatus.checkBroker(0, 0, TenthBips32{0}, 1, 0, 0); - - if (!BEAST_EXPECT(loanSequence != 0)) - return; - - testcase << caseLabel << " " << label; - - using namespace jtx; - using namespace loan; - using namespace std::chrono_literals; - - auto applyExponent = [interestExponent, this](TenthBips32 value) mutable { - BEAST_EXPECT(value > TenthBips32(0)); - while (interestExponent > 0) - { - auto const oldValue = value; - value *= 10; - --interestExponent; - BEAST_EXPECT(value / 10 == oldValue); - } - while (interestExponent < 0) - { - auto const oldValue = value; - value /= 10; - ++interestExponent; - BEAST_EXPECT(value * 10 == oldValue); - } - return value; - }; - - auto const borrowerOwnerCount = env.ownerCount(borrower); - - auto const loanSetFee = env.current()->fees().base * 2; - LoanParameters const loanParams{ - .account = borrower, - .counter = lender, - .counterpartyExplicit = false, - .principalRequest = loanAmount, - .setFee = loanSetFee, - .originationFee = 1, - .serviceFee = 2, - .lateFee = 3, - .closeFee = 4, - .overFee = applyExponent(percentageToTenthBips(5) / 10), - .interest = applyExponent(percentageToTenthBips(12)), - // 2.4% - .lateInterest = applyExponent(percentageToTenthBips(24) / 10), - .closeInterest = applyExponent(percentageToTenthBips(36) / 10), - .overpaymentInterest = applyExponent(percentageToTenthBips(48) / 10), - .payTotal = 12, - .payInterval = 600, - .gracePd = 60, - .flags = flags, - }; - Number const principalRequestAmount = broker.asset(loanParams.principalRequest).value(); - auto const originationFeeAmount = broker.asset(*loanParams.originationFee).value(); - auto const serviceFeeAmount = broker.asset(*loanParams.serviceFee).value(); - auto const lateFeeAmount = broker.asset(*loanParams.lateFee).value(); - auto const closeFeeAmount = broker.asset(*loanParams.closeFee).value(); - - auto const borrowerStartbalance = env.balance(borrower, broker.asset); - - auto createJtx = loanParams(env, broker); - // Successfully create a Loan - env(createJtx); - - env.close(); - - auto const startDate = env.current()->header().parentCloseTime.time_since_epoch().count(); - - if (auto const brokerSle = env.le(keylet::loanbroker(broker.brokerID)); BEAST_EXPECT(brokerSle)) - { - BEAST_EXPECT(brokerSle->at(sfOwnerCount) == 1); - } - - { - // Need to account for fees if the loan is in XRP - PrettyAmount adjustment = broker.asset(0); - if (broker.asset.native()) - { - adjustment = 2 * env.current()->fees().base; - } - - BEAST_EXPECT( - env.balance(borrower, broker.asset).value() == - borrowerStartbalance.value() + principalRequestAmount - originationFeeAmount - adjustment.value()); - } - - auto const loanFlags = createJtx.stx->isFlag(tfLoanOverpayment) ? lsfLoanOverpayment : LedgerSpecificFlags(0); - - if (auto loan = env.le(keylet); BEAST_EXPECT(loan)) - { - // log << "loan after create: " << to_string(loan->getJson()) - // << std::endl; - BEAST_EXPECT(loan->isFlag(lsfLoanOverpayment) == createJtx.stx->isFlag(tfLoanOverpayment)); - BEAST_EXPECT(loan->at(sfLoanSequence) == loanSequence); - BEAST_EXPECT(loan->at(sfBorrower) == borrower.id()); - BEAST_EXPECT(loan->at(sfLoanBrokerID) == broker.brokerID); - BEAST_EXPECT(loan->at(sfLoanOriginationFee) == originationFeeAmount); - BEAST_EXPECT(loan->at(sfLoanServiceFee) == serviceFeeAmount); - BEAST_EXPECT(loan->at(sfLatePaymentFee) == lateFeeAmount); - BEAST_EXPECT(loan->at(sfClosePaymentFee) == closeFeeAmount); - BEAST_EXPECT(loan->at(sfOverpaymentFee) == *loanParams.overFee); - BEAST_EXPECT(loan->at(sfInterestRate) == *loanParams.interest); - BEAST_EXPECT(loan->at(sfLateInterestRate) == *loanParams.lateInterest); - BEAST_EXPECT(loan->at(sfCloseInterestRate) == *loanParams.closeInterest); - BEAST_EXPECT(loan->at(sfOverpaymentInterestRate) == *loanParams.overpaymentInterest); - BEAST_EXPECT(loan->at(sfStartDate) == startDate); - BEAST_EXPECT(loan->at(sfPaymentInterval) == *loanParams.payInterval); - BEAST_EXPECT(loan->at(sfGracePeriod) == *loanParams.gracePd); - BEAST_EXPECT(loan->at(sfPreviousPaymentDueDate) == 0); - BEAST_EXPECT(loan->at(sfNextPaymentDueDate) == startDate + *loanParams.payInterval); - BEAST_EXPECT(loan->at(sfPaymentRemaining) == *loanParams.payTotal); - BEAST_EXPECT( - loan->at(sfLoanScale) >= - (broker.asset.integral() ? 0 : std::max(broker.vaultScale(env), principalRequestAmount.exponent()))); - BEAST_EXPECT(loan->at(sfPrincipalOutstanding) == principalRequestAmount); - } - - auto state = getCurrentState(env, broker, keylet, verifyLoanStatus); - - auto const loanProperties = computeLoanProperties( - broker.asset.raw(), - state.principalOutstanding, - state.interestRate, - state.paymentInterval, - state.paymentRemaining, - broker.params.managementFeeRate, - state.loanScale); - - verifyLoanStatus( - 0, - startDate + *loanParams.payInterval, - *loanParams.payTotal, - state.loanScale, - loanProperties.loanState.valueOutstanding, - principalRequestAmount, - loanProperties.loanState.managementFeeDue, - loanProperties.periodicPayment, - loanFlags | 0); - - // Manage the loan - // no-op - env(manage(lender, keylet.key, 0)); - { - // no flags - auto jt = manage(lender, keylet.key, 0); - jt.removeMember(sfFlags.getName()); - env(jt); - } - // Only the lender can manage - env(manage(evan, keylet.key, 0), ter(tecNO_PERMISSION)); - // unknown flags - env(manage(lender, keylet.key, tfLoanManageMask), ter(temINVALID_FLAG)); - // combinations of flags are not allowed - env(manage(lender, keylet.key, tfLoanUnimpair | tfLoanImpair), ter(temINVALID_FLAG)); - env(manage(lender, keylet.key, tfLoanImpair | tfLoanDefault), ter(temINVALID_FLAG)); - env(manage(lender, keylet.key, tfLoanUnimpair | tfLoanDefault), ter(temINVALID_FLAG)); - env(manage(lender, keylet.key, tfLoanUnimpair | tfLoanImpair | tfLoanDefault), ter(temINVALID_FLAG)); - // invalid loan ID - env(manage(lender, broker.brokerID, tfLoanImpair), ter(tecNO_ENTRY)); - // Loan is unimpaired, can't unimpair it again - env(manage(lender, keylet.key, tfLoanUnimpair), ter(tecNO_PERMISSION)); - // Loan is unimpaired, it can go into default, but only after it's past - // due - env(manage(lender, keylet.key, tfLoanDefault), ter(tecTOO_SOON)); - - // Check the vault - bool const canImpair = canImpairLoan(env, broker, state); - // Impair the loan, if possible - env(manage(lender, keylet.key, tfLoanImpair), canImpair ? ter(tesSUCCESS) : ter(tecLIMIT_EXCEEDED)); - // Unimpair the loan - env(manage(lender, keylet.key, tfLoanUnimpair), canImpair ? ter(tesSUCCESS) : ter(tecNO_PERMISSION)); - - auto const nextDueDate = startDate + *loanParams.payInterval; - - env.close(); - - verifyLoanStatus( - 0, - nextDueDate, - *loanParams.payTotal, - loanProperties.loanScale, - loanProperties.loanState.valueOutstanding, - principalRequestAmount, - loanProperties.loanState.managementFeeDue, - loanProperties.periodicPayment, - loanFlags | 0); - - // Can't delete the loan yet. It has payments remaining. - env(del(lender, keylet.key), ter(tecHAS_OBLIGATIONS)); - - if (BEAST_EXPECT(toEndOfLife)) - toEndOfLife(keylet, verifyLoanStatus); - env.close(); - - // Verify the loan is at EOL - if (auto loan = env.le(keylet); BEAST_EXPECT(loan)) - { - BEAST_EXPECT(loan->at(sfPaymentRemaining) == 0); - BEAST_EXPECT(loan->at(sfPrincipalOutstanding) == 0); - } - auto const borrowerStartingBalance = env.balance(borrower, broker.asset); - - // Try to delete the loan broker with an active loan - env(loanBroker::del(lender, broker.brokerID), ter(tecHAS_OBLIGATIONS)); - // Ensure the above tx doesn't get ordered after the LoanDelete and - // delete our broker! - env.close(); - - // Test failure cases - env(del(lender, keylet.key, tfLoanOverpayment), ter(temINVALID_FLAG)); - env(del(evan, keylet.key), ter(tecNO_PERMISSION)); - env(del(lender, broker.brokerID), ter(tecNO_ENTRY)); - - // Delete the loan - // Either the borrower or the lender can delete the loan. Alternate - // between who does it across tests. - static unsigned deleteCounter = 0; - auto const deleter = ++deleteCounter % 2 ? lender : borrower; - env(del(deleter, keylet.key)); - env.close(); - - PrettyAmount adjustment = broker.asset(0); - if (deleter == borrower) - { - // Need to account for fees if the loan is in XRP - if (broker.asset.native()) - { - adjustment = env.current()->fees().base; - } - } - - // No loans left - verifyLoanStatus.checkBroker(0, 0, *loanParams.interest, 1, 0, 0); - - BEAST_EXPECT(env.balance(borrower, broker.asset).value() == borrowerStartingBalance.value() - adjustment); - BEAST_EXPECT(env.ownerCount(borrower) == borrowerOwnerCount); - - if (auto const brokerSle = env.le(keylet::loanbroker(broker.brokerID)); BEAST_EXPECT(brokerSle)) - { - BEAST_EXPECT(brokerSle->at(sfOwnerCount) == 0); - } - } - - 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 - * - * Will be used in the future to vary the loan parameters. For now, it is - * only called once. - * - * Tests a bunch of LoanSet failure conditions before lifecycle. - */ - template - void - testCaseWrapper( - jtx::Env& env, - jtx::MPTTester& mptt, - std::array const& assets, - BrokerInfo const& broker, - Number const& loanAmount, - int interestExponent) - { - using namespace jtx; - using namespace Lending; - - auto const& asset = broker.asset.raw(); - auto const currencyLabel = getCurrencyLabel(asset); - auto const caseLabel = [&]() { - std::stringstream ss; - ss << "Lifecycle: " << loanAmount << " " << currencyLabel << " Scale interest to: " << interestExponent - << " "; - return ss.str(); - }(); - testcase << caseLabel; - - using namespace loan; - using namespace std::chrono_literals; - using d = NetClock::duration; - using tp = NetClock::time_point; - Account const issuer{"issuer"}; - // For simplicity, lender will be the sole actor for the vault & - // brokers. - Account const lender{"lender"}; - // Borrower only wants to borrow - Account const borrower{"borrower"}; - // Evan will attempt to be naughty - Account const evan{"evan"}; - // Do not fund alice - Account const alice{"alice"}; - - Number const principalRequest = broker.asset(loanAmount).value(); - Number const maxCoveredLoanValue = broker.params.maxCoveredLoanValue(0); - BEAST_EXPECT(maxCoveredLoanValue == 1000 * 100 / 10); - Number const maxCoveredLoanRequest = broker.asset(maxCoveredLoanValue).value(); - Number const totalVaultRequest = broker.asset(broker.params.vaultDeposit).value(); - Number const debtMaximumRequest = broker.asset(broker.params.debtMax).value(); - - auto const loanSetFee = fee(env.current()->fees().base * 2); - - auto const pseudoAcct = [&]() { - auto const brokerSle = env.le(keylet::loanbroker(broker.brokerID)); - if (!BEAST_EXPECT(brokerSle)) - return lender; - auto const brokerPseudo = brokerSle->at(sfAccount); - return Account("Broker pseudo-account", brokerPseudo); - }(); - - auto const baseFee = env.current()->fees().base; - - auto badKeylet = keylet::vault(lender.id(), env.seq(lender)); - // Try some failure cases - // flags are checked first - env(set(evan, broker.brokerID, principalRequest, tfLoanSetMask), - sig(sfCounterpartySignature, lender), - loanSetFee, - ter(temINVALID_FLAG)); - - // field length validation - // sfData: good length, bad account - env(set(evan, broker.brokerID, principalRequest), - sig(sfCounterpartySignature, borrower), - data(std::string(maxDataPayloadLength, 'X')), - loanSetFee, - ter(tefBAD_AUTH)); - // sfData: too long - env(set(evan, broker.brokerID, principalRequest), - sig(sfCounterpartySignature, lender), - data(std::string(maxDataPayloadLength + 1, 'Y')), - loanSetFee, - ter(temINVALID)); - - // field range validation - // sfOverpaymentFee: good value, bad account - env(set(evan, broker.brokerID, principalRequest), - sig(sfCounterpartySignature, borrower), - overpaymentFee(maxOverpaymentFee), - loanSetFee, - ter(tefBAD_AUTH)); - // sfOverpaymentFee: too big - env(set(evan, broker.brokerID, principalRequest), - sig(sfCounterpartySignature, lender), - overpaymentFee(maxOverpaymentFee + 1), - loanSetFee, - ter(temINVALID)); - - // sfInterestRate: good value, bad account - env(set(evan, broker.brokerID, principalRequest), - sig(sfCounterpartySignature, borrower), - interestRate(maxInterestRate), - loanSetFee, - ter(tefBAD_AUTH)); - env(set(evan, broker.brokerID, principalRequest), - sig(sfCounterpartySignature, borrower), - interestRate(TenthBips32(0)), - loanSetFee, - ter(tefBAD_AUTH)); - // sfInterestRate: too big - env(set(evan, broker.brokerID, principalRequest), - sig(sfCounterpartySignature, lender), - interestRate(maxInterestRate + 1), - loanSetFee, - ter(temINVALID)); - // sfInterestRate: too small - env(set(evan, broker.brokerID, principalRequest), - sig(sfCounterpartySignature, lender), - interestRate(TenthBips32(-1)), - loanSetFee, - ter(temINVALID)); - - // sfLateInterestRate: good value, bad account - env(set(evan, broker.brokerID, principalRequest), - sig(sfCounterpartySignature, borrower), - lateInterestRate(maxLateInterestRate), - loanSetFee, - ter(tefBAD_AUTH)); - env(set(evan, broker.brokerID, principalRequest), - sig(sfCounterpartySignature, borrower), - lateInterestRate(TenthBips32(0)), - loanSetFee, - ter(tefBAD_AUTH)); - // sfLateInterestRate: too big - env(set(evan, broker.brokerID, principalRequest), - sig(sfCounterpartySignature, lender), - lateInterestRate(maxLateInterestRate + 1), - loanSetFee, - ter(temINVALID)); - // sfLateInterestRate: too small - env(set(evan, broker.brokerID, principalRequest), - sig(sfCounterpartySignature, lender), - lateInterestRate(TenthBips32(-1)), - loanSetFee, - ter(temINVALID)); - - // sfCloseInterestRate: good value, bad account - env(set(evan, broker.brokerID, principalRequest), - sig(sfCounterpartySignature, borrower), - closeInterestRate(maxCloseInterestRate), - loanSetFee, - ter(tefBAD_AUTH)); - env(set(evan, broker.brokerID, principalRequest), - sig(sfCounterpartySignature, borrower), - closeInterestRate(TenthBips32(0)), - loanSetFee, - ter(tefBAD_AUTH)); - // sfCloseInterestRate: too big - env(set(evan, broker.brokerID, principalRequest), - sig(sfCounterpartySignature, lender), - closeInterestRate(maxCloseInterestRate + 1), - loanSetFee, - ter(temINVALID)); - env(set(evan, broker.brokerID, principalRequest), - sig(sfCounterpartySignature, lender), - closeInterestRate(TenthBips32(-1)), - loanSetFee, - ter(temINVALID)); - - // sfOverpaymentInterestRate: good value, bad account - env(set(evan, broker.brokerID, principalRequest), - sig(sfCounterpartySignature, borrower), - overpaymentInterestRate(maxOverpaymentInterestRate), - loanSetFee, - ter(tefBAD_AUTH)); - env(set(evan, broker.brokerID, principalRequest), - sig(sfCounterpartySignature, borrower), - overpaymentInterestRate(TenthBips32(0)), - loanSetFee, - ter(tefBAD_AUTH)); - // sfOverpaymentInterestRate: too big - env(set(evan, broker.brokerID, principalRequest), - sig(sfCounterpartySignature, lender), - overpaymentInterestRate(maxOverpaymentInterestRate + 1), - loanSetFee, - ter(temINVALID)); - env(set(evan, broker.brokerID, principalRequest), - sig(sfCounterpartySignature, lender), - overpaymentInterestRate(TenthBips32(-1)), - loanSetFee, - ter(temINVALID)); - - // sfPaymentTotal: good value, bad account - env(set(evan, broker.brokerID, principalRequest), - sig(sfCounterpartySignature, borrower), - paymentTotal(LoanSet::minPaymentTotal), - loanSetFee, - ter(tefBAD_AUTH)); - // sfPaymentTotal: too small (there is no max) - env(set(evan, broker.brokerID, principalRequest), - sig(sfCounterpartySignature, lender), - paymentTotal(LoanSet::minPaymentTotal - 1), - loanSetFee, - ter(temINVALID)); - - // sfPaymentInterval: good value, bad account - env(set(evan, broker.brokerID, principalRequest), - sig(sfCounterpartySignature, borrower), - paymentInterval(LoanSet::minPaymentInterval), - loanSetFee, - ter(tefBAD_AUTH)); - // sfPaymentInterval: too small (there is no max) - env(set(evan, broker.brokerID, principalRequest), - sig(sfCounterpartySignature, lender), - paymentInterval(LoanSet::minPaymentInterval - 1), - loanSetFee, - ter(temINVALID)); - - // sfGracePeriod: good value, bad account - env(set(evan, broker.brokerID, principalRequest), - sig(sfCounterpartySignature, borrower), - paymentInterval(LoanSet::minPaymentInterval * 2), - gracePeriod(LoanSet::minPaymentInterval * 2), - loanSetFee, - ter(tefBAD_AUTH)); - // sfGracePeriod: larger than paymentInterval - env(set(evan, broker.brokerID, principalRequest), - sig(sfCounterpartySignature, lender), - paymentInterval(LoanSet::minPaymentInterval * 2), - gracePeriod(LoanSet::minPaymentInterval * 3), - loanSetFee, - ter(temINVALID)); - - // insufficient fee - single sign - env(set(borrower, broker.brokerID, principalRequest), - sig(sfCounterpartySignature, lender), - ter(telINSUF_FEE_P)); - // insufficient fee - multisign - env(signers(lender, 2, {{evan, 1}, {borrower, 1}})); - env(signers(borrower, 2, {{evan, 1}, {lender, 1}})); - env(set(borrower, broker.brokerID, principalRequest), - counterparty(lender), - msig(evan, lender), - msig(sfCounterpartySignature, evan, borrower), - fee(env.current()->fees().base * 5 - 1), - ter(telINSUF_FEE_P)); - // Bad multisign signatures for borrower (Account) - env(set(borrower, broker.brokerID, principalRequest), - counterparty(lender), - msig(alice, issuer), - msig(sfCounterpartySignature, evan, borrower), - fee(env.current()->fees().base * 5), - ter(tefBAD_SIGNATURE)); - // Bad multisign signatures for issuer (Counterparty) - env(set(borrower, broker.brokerID, principalRequest), - counterparty(lender), - msig(evan, lender), - msig(sfCounterpartySignature, alice, issuer), - fee(env.current()->fees().base * 5 - 1), - ter(tefBAD_SIGNATURE)); - env(signers(lender, none)); - env(signers(borrower, none)); - // multisign sufficient fee, but no signers set up - env(set(borrower, broker.brokerID, principalRequest), - counterparty(lender), - msig(evan, lender), - msig(sfCounterpartySignature, evan, borrower), - fee(env.current()->fees().base * 5), - ter(tefNOT_MULTI_SIGNING)); - // not the broker owner, no counterparty, not signed by broker - // owner - env(set(borrower, broker.brokerID, principalRequest), - sig(sfCounterpartySignature, evan), - loanSetFee, - ter(tefBAD_AUTH)); - // not the broker owner, counterparty is borrower - env(set(evan, broker.brokerID, principalRequest), - counterparty(borrower), - sig(sfCounterpartySignature, borrower), - loanSetFee, - ter(tecNO_PERMISSION)); - // not a LoanBroker object, no counterparty - env(set(lender, badKeylet.key, principalRequest), - sig(sfCounterpartySignature, evan), - loanSetFee, - ter(temBAD_SIGNER)); - // not a LoanBroker object, counterparty is valid - env(set(lender, badKeylet.key, principalRequest), - counterparty(borrower), - sig(sfCounterpartySignature, borrower), - loanSetFee, - ter(tecNO_ENTRY)); - // borrower doesn't exist - env(set(lender, broker.brokerID, principalRequest), - counterparty(alice), - sig(sfCounterpartySignature, alice), - loanSetFee, - ter(terNO_ACCOUNT)); - - // Request more funds than the vault has available - env(set(evan, broker.brokerID, totalVaultRequest + 1), - sig(sfCounterpartySignature, lender), - loanSetFee, - ter(tecINSUFFICIENT_FUNDS)); - - // Request more funds than the broker's first-loss capital can - // cover. - env(set(evan, broker.brokerID, maxCoveredLoanRequest + 1), - sig(sfCounterpartySignature, lender), - loanSetFee, - ter(tecINSUFFICIENT_FUNDS)); - - // Frozen trust line / locked MPT issuance - // XRP can not be frozen, but run through the loop anyway to test - // the tecLIMIT_EXCEEDED case - { - auto const brokerSle = env.le(keylet::loanbroker(broker.brokerID)); - if (!BEAST_EXPECT(brokerSle)) - return; - - auto const vaultPseudo = [&]() { - auto const vaultSle = env.le(keylet::vault(brokerSle->at(sfVaultID))); - if (!BEAST_EXPECT(vaultSle)) - // This will be wrong, but the test has failed anyway. - return lender; - auto const vaultPseudo = Account("Vault pseudo-account", vaultSle->at(sfAccount)); - return vaultPseudo; - }(); - - auto const [freeze, deepfreeze, unfreeze, expectedResult] = - [&]() -> std::tuple< - std::function, - std::function, - std::function, - TER> { - // Freeze / lock the asset - std::function empty; - if (broker.asset.native()) - { - // XRP can't be frozen - return std::make_tuple(empty, empty, empty, tesSUCCESS); - } - else if (broker.asset.holds()) - { - auto freeze = [&](Account const& holder) { - env(trust(issuer, holder[iouCurrency](0), tfSetFreeze)); - }; - auto deepfreeze = [&](Account const& holder) { - env(trust(issuer, holder[iouCurrency](0), tfSetFreeze | tfSetDeepFreeze)); - }; - auto unfreeze = [&](Account const& holder) { - env(trust(issuer, holder[iouCurrency](0), tfClearFreeze | tfClearDeepFreeze)); - }; - return std::make_tuple(freeze, deepfreeze, unfreeze, tecFROZEN); - } - else - { - auto freeze = [&](Account const& holder) { - mptt.set({.account = issuer, .holder = holder, .flags = tfMPTLock}); - }; - auto unfreeze = [&](Account const& holder) { - mptt.set({.account = issuer, .holder = holder, .flags = tfMPTUnlock}); - }; - return std::make_tuple(freeze, empty, unfreeze, tecLOCKED); - } - }(); - - // Try freezing the accounts that can't be frozen - if (freeze) - { - for (auto const& account : {vaultPseudo, evan}) - { - // Freeze the account - freeze(account); - - // Try to create a loan with a frozen line - env(set(evan, broker.brokerID, debtMaximumRequest), - sig(sfCounterpartySignature, lender), - loanSetFee, - ter(expectedResult)); - - // Unfreeze the account - BEAST_EXPECT(unfreeze); - unfreeze(account); - - // Ensure the line is unfrozen with a request that is fine - // except too it requests more principal than the broker can - // carry - env(set(evan, broker.brokerID, debtMaximumRequest + 1), - sig(sfCounterpartySignature, lender), - loanSetFee, - ter(tecLIMIT_EXCEEDED)); - } - } - - // Deep freeze the borrower, which prevents them from receiving - // funds - if (deepfreeze) - { - // Make sure evan has a trust line that so the issuer can - // freeze it. (Don't need to do this for the borrower, - // because LoanSet will create a line to the borrower - // automatically.) - env(trust(evan, issuer[iouCurrency](100'000))); - - for (auto const& account : {// these accounts can't be frozen, which deep freeze - // implies - vaultPseudo, - evan, - // these accounts can't be deep frozen - lender}) - { - // Freeze evan - deepfreeze(account); - - // Try to create a loan with a deep frozen line - env(set(evan, broker.brokerID, debtMaximumRequest), - sig(sfCounterpartySignature, lender), - loanSetFee, - ter(expectedResult)); - - // Unfreeze evan - BEAST_EXPECT(unfreeze); - unfreeze(account); - - // Ensure the line is unfrozen with a request that is fine - // except too it requests more principal than the broker can - // carry - env(set(evan, broker.brokerID, debtMaximumRequest + 1), - sig(sfCounterpartySignature, lender), - loanSetFee, - ter(tecLIMIT_EXCEEDED)); - } - } - } - - // Finally! Create a loan - std::string testData; - - auto coverAvailable = [&env, this](uint256 const& brokerID, Number const& expected) { - if (auto const brokerSle = env.le(keylet::loanbroker(brokerID)); BEAST_EXPECT(brokerSle)) - { - auto const available = brokerSle->at(sfCoverAvailable); - BEAST_EXPECT(available == expected); - return available; - } - return Number{}; - }; - auto getDefaultInfo = [&env, this](LoanState const& state, BrokerInfo const& broker) { - if (auto const brokerSle = env.le(keylet::loanbroker(broker.brokerID)); BEAST_EXPECT(brokerSle)) - { - BEAST_EXPECT( - state.loanScale >= (broker.asset.integral() - ? 0 - : std::max(broker.vaultScale(env), state.principalOutstanding.exponent()))); - NumberRoundModeGuard mg(Number::upward); - auto const defaultAmount = roundToAsset( - broker.asset, - std::min( - tenthBipsOfValue( - tenthBipsOfValue(brokerSle->at(sfDebtTotal), broker.params.coverRateMin), - broker.params.coverRateLiquidation), - state.totalValue - state.managementFeeOutstanding), - state.loanScale); - return std::make_pair(defaultAmount, brokerSle->at(sfOwner)); - } - return std::make_pair(Number{}, AccountID{}); - }; - auto replenishCover = [&env, &coverAvailable]( - BrokerInfo const& broker, - AccountID const& brokerAcct, - Number const& startingCoverAvailable, - Number const& amountToBeCovered) { - coverAvailable(broker.brokerID, startingCoverAvailable - amountToBeCovered); - env(loanBroker::coverDeposit(brokerAcct, broker.brokerID, STAmount{broker.asset, amountToBeCovered})); - coverAvailable(broker.brokerID, startingCoverAvailable); - env.close(); - }; - - auto defaultImmediately = [&](std::uint32_t baseFlag, bool impair = true) { - return [&, impair, baseFlag](Keylet const& loanKeylet, VerifyLoanStatus const& verifyLoanStatus) { - // toEndOfLife - // - // Default the loan - - // Initialize values with the current state - auto state = getCurrentState(env, broker, loanKeylet, verifyLoanStatus); - BEAST_EXPECT(state.flags == baseFlag); - - auto const& broker = verifyLoanStatus.broker; - auto const startingCoverAvailable = - coverAvailable(broker.brokerID, broker.asset(broker.params.coverDeposit).number()); - - if (impair) - { - // Check the vault - bool const canImpair = canImpairLoan(env, broker, state); - // Impair the loan, if possible - env(manage(lender, loanKeylet.key, tfLoanImpair), - canImpair ? ter(tesSUCCESS) : ter(tecLIMIT_EXCEEDED)); - - if (canImpair) - { - state.flags |= tfLoanImpair; - state.nextPaymentDate = env.now().time_since_epoch().count(); - - // Once the loan is impaired, it can't be impaired again - env(manage(lender, loanKeylet.key, tfLoanImpair), ter(tecNO_PERMISSION)); - } - verifyLoanStatus(state); - } - - auto const nextDueDate = tp{d{state.nextPaymentDate}}; - - // Can't default the loan yet. The grace period hasn't - // expired - env(manage(lender, loanKeylet.key, tfLoanDefault), ter(tecTOO_SOON)); - - // Let some time pass so that the loan can be - // defaulted - env.close(nextDueDate + 60s); - - auto const [amountToBeCovered, brokerAcct] = getDefaultInfo(state, broker); - - // Default the loan - env(manage(lender, loanKeylet.key, tfLoanDefault)); - env.close(); - - // The LoanBroker just lost some of it's first-loss capital. - // Replenish it. - replenishCover(broker, brokerAcct, startingCoverAvailable, amountToBeCovered); - - state.flags |= tfLoanDefault; - state.paymentRemaining = 0; - state.totalValue = 0; - state.principalOutstanding = 0; - state.managementFeeOutstanding = 0; - state.nextPaymentDate = 0; - verifyLoanStatus(state); - - // Once a loan is defaulted, it can't be managed - env(manage(lender, loanKeylet.key, tfLoanUnimpair), ter(tecNO_PERMISSION)); - env(manage(lender, loanKeylet.key, tfLoanImpair), ter(tecNO_PERMISSION)); - // Can't make a payment on it either - env(pay(borrower, loanKeylet.key, broker.asset(300)), ter(tecKILLED)); - }; - }; - - auto singlePayment = [&](Keylet const& loanKeylet, - VerifyLoanStatus const& verifyLoanStatus, - LoanState& state, - STAmount const& payoffAmount, - std::uint32_t numPayments, - std::uint32_t baseFlag, - std::uint32_t txFlags) { - // toEndOfLife - // - verifyLoanStatus(state); - - // Send some bogus pay transactions - env(pay(borrower, keylet::loan(uint256(0)).key, broker.asset(10), txFlags), ter(temINVALID)); - // broker.asset(80) is less than a single payment, but all these - // checks fail before that matters - env(pay(borrower, loanKeylet.key, broker.asset(-80), txFlags), ter(temBAD_AMOUNT)); - env(pay(borrower, broker.brokerID, broker.asset(80), txFlags), ter(tecNO_ENTRY)); - env(pay(evan, loanKeylet.key, broker.asset(80), txFlags), ter(tecNO_PERMISSION)); - - // TODO: Write a general "isFlag" function? See STObject::isFlag. - // Maybe add a static overloaded member? - if (!(state.flags & lsfLoanOverpayment)) - { - // If the loan does not allow overpayments, send a payment that - // tries to make an overpayment. Do not include `txFlags`, so we - // don't end up duplicating the next test transaction. - env(pay(borrower, - loanKeylet.key, - STAmount{broker.asset, state.periodicPayment * Number{15, -1}}, - tfLoanOverpayment), - fee(XRPAmount{baseFee * (Number{15, -1} / loanPaymentsPerFeeIncrement + 1)}), - ter(temINVALID_FLAG)); - } - // Try to send a payment marked as multiple mutually exclusive - // payment types. Do not include `txFlags`, so we don't duplicate - // the prior test transaction. - env(pay(borrower, - loanKeylet.key, - broker.asset(state.periodicPayment * 2), - tfLoanLatePayment | tfLoanFullPayment), - ter(temINVALID_FLAG)); - env(pay(borrower, - loanKeylet.key, - broker.asset(state.periodicPayment * 2), - tfLoanLatePayment | tfLoanOverpayment), - ter(temINVALID_FLAG)); - env(pay(borrower, - loanKeylet.key, - broker.asset(state.periodicPayment * 2), - tfLoanOverpayment | tfLoanFullPayment), - ter(temINVALID_FLAG)); - env(pay(borrower, - loanKeylet.key, - broker.asset(state.periodicPayment * 2), - tfLoanLatePayment | tfLoanOverpayment | tfLoanFullPayment), - ter(temINVALID_FLAG)); - - { - auto const otherAsset = broker.asset.raw() == assets[0].raw() ? assets[1] : assets[0]; - env(pay(borrower, loanKeylet.key, otherAsset(100), txFlags), ter(tecWRONG_ASSET)); - } - - // Amount doesn't cover a single payment - env(pay(borrower, loanKeylet.key, STAmount{broker.asset, 1}, txFlags), ter(tecINSUFFICIENT_PAYMENT)); - - // Get the balance after these failed transactions take - // fees - auto const borrowerBalanceBeforePayment = env.balance(borrower, broker.asset); - - BEAST_EXPECT(payoffAmount > state.principalOutstanding); - // Try to pay a little extra to show that it's _not_ - // taken - auto const transactionAmount = payoffAmount + broker.asset(10); - - // Send a transaction that tries to pay more than the borrowers's - // balance - XRPAmount const badFee{ - baseFee * - (borrowerBalanceBeforePayment.number() * 2 / state.periodicPayment / loanPaymentsPerFeeIncrement + 1)}; - env(pay(borrower, - loanKeylet.key, - STAmount{broker.asset, borrowerBalanceBeforePayment.number() * 2}, - txFlags), - fee(badFee), - ter(tecINSUFFICIENT_FUNDS)); - - XRPAmount const goodFee{baseFee * (numPayments / loanPaymentsPerFeeIncrement + 1)}; - env(pay(borrower, loanKeylet.key, transactionAmount, txFlags), fee(goodFee)); - - env.close(); - - // log << env.meta()->getJson() << std::endl; - - // Need to account for fees if the loan is in XRP - PrettyAmount adjustment = broker.asset(0); - if (broker.asset.native()) - { - adjustment = badFee + goodFee; - } - - state.paymentRemaining = 0; - state.principalOutstanding = 0; - state.totalValue = 0; - state.managementFeeOutstanding = 0; - state.previousPaymentDate = state.nextPaymentDate + state.paymentInterval * (numPayments - 1); - state.nextPaymentDate = 0; - verifyLoanStatus(state); - - verifyLoanStatus.checkPayment( - state.loanScale, borrower, borrowerBalanceBeforePayment, payoffAmount, adjustment); - - // Can't impair or default a paid off loan - env(manage(lender, loanKeylet.key, tfLoanImpair), ter(tecNO_PERMISSION)); - env(manage(lender, loanKeylet.key, tfLoanDefault), ter(tecNO_PERMISSION)); - }; - - auto fullPayment = [&](std::uint32_t baseFlag) { - return [&, baseFlag](Keylet const& loanKeylet, VerifyLoanStatus const& verifyLoanStatus) { - // toEndOfLife - // - auto state = getCurrentState(env, broker, loanKeylet, verifyLoanStatus); - env.close(state.startDate + 20s); - auto const loanAge = (env.now() - state.startDate).count(); - BEAST_EXPECT(loanAge == 30); - - // Full payoff amount will consist of - // 1. principal outstanding (1000) - // 2. accrued interest (at 12%) - // 3. prepayment penalty (closeInterest at 3.6%) - // 4. close payment fee (4) - // Calculate these values without the helper functions - // to verify they're working correctly The numbers in - // the below BEAST_EXPECTs may not hold across assets. - Number const interval = state.paymentInterval; - auto const periodicRate = interval * Number(12, -2) / secondsInYear; - BEAST_EXPECT(periodicRate == Number(2283105022831050228ULL, -24, Number::normalized{})); - STAmount const principalOutstanding{broker.asset, state.principalOutstanding}; - STAmount const accruedInterest{ - broker.asset, state.principalOutstanding * periodicRate * loanAge / interval}; - BEAST_EXPECT(accruedInterest == broker.asset(Number(1141552511415525, -19))); - STAmount const prepaymentPenalty{broker.asset, state.principalOutstanding * Number(36, -3)}; - BEAST_EXPECT(prepaymentPenalty == broker.asset(36)); - STAmount const closePaymentFee = broker.asset(4); - auto const payoffAmount = roundToScale( - principalOutstanding + accruedInterest + prepaymentPenalty + closePaymentFee, state.loanScale); - BEAST_EXPECT( - payoffAmount == - roundToAsset(broker.asset, broker.asset(Number(1040000114155251, -12)).number(), state.loanScale)); - - // The terms of this loan actually make the early payoff - // more expensive than just making payments - BEAST_EXPECT(payoffAmount > state.paymentRemaining * (state.periodicPayment + broker.asset(2).value())); - - singlePayment(loanKeylet, verifyLoanStatus, state, payoffAmount, 1, baseFlag, tfLoanFullPayment); - }; - }; - - auto combineAllPayments = [&](std::uint32_t baseFlag) { - return [&, baseFlag](Keylet const& loanKeylet, VerifyLoanStatus const& verifyLoanStatus) { - // toEndOfLife - // - - auto state = getCurrentState(env, broker, loanKeylet, verifyLoanStatus); - env.close(); - - BEAST_EXPECT( - STAmount(broker.asset, state.periodicPayment) == broker.asset(Number(8333457002039338267, -17))); - - // Make all the payments in one transaction - // service fee is 2 - auto const startingPayments = state.paymentRemaining; - STAmount const payoffAmount = [&]() { - NumberRoundModeGuard mg(Number::upward); - auto const rawPayoff = startingPayments * (state.periodicPayment + broker.asset(2).value()); - STAmount payoffAmount{broker.asset, rawPayoff}; - BEAST_EXPECTS(payoffAmount == broker.asset(Number(1024014840244721, -12)), to_string(payoffAmount)); - BEAST_EXPECT(payoffAmount > state.principalOutstanding); - - payoffAmount = roundToScale(payoffAmount, state.loanScale); - - return payoffAmount; - }(); - - auto const totalPayoffValue = state.totalValue + startingPayments * broker.asset(2).value(); - STAmount const totalPayoffAmount{broker.asset, totalPayoffValue}; - - BEAST_EXPECTS( - totalPayoffAmount == payoffAmount, - "Payoff amount: " + to_string(payoffAmount) + ". Total Value: " + to_string(totalPayoffAmount)); - - singlePayment(loanKeylet, verifyLoanStatus, state, payoffAmount, state.paymentRemaining, baseFlag, 0); - }; - }; - - // There are a lot of fields that can be set on a loan, but most - // of them only affect the "math" when a payment is made. The - // only one that really affects behavior is the - // `tfLoanOverpayment` flag. - lifecycle( - caseLabel, - "Loan overpayment allowed - Impair and Default", - env, - loanAmount, - interestExponent, - lender, - borrower, - evan, - broker, - pseudoAcct, - tfLoanOverpayment, - defaultImmediately(lsfLoanOverpayment)); - - lifecycle( - caseLabel, - "Loan overpayment prohibited - Impair and Default", - env, - loanAmount, - interestExponent, - lender, - borrower, - evan, - broker, - pseudoAcct, - 0, - defaultImmediately(0)); - - lifecycle( - caseLabel, - "Loan overpayment allowed - Default without Impair", - env, - loanAmount, - interestExponent, - lender, - borrower, - evan, - broker, - pseudoAcct, - tfLoanOverpayment, - defaultImmediately(lsfLoanOverpayment, false)); - - lifecycle( - caseLabel, - "Loan overpayment prohibited - Default without Impair", - env, - loanAmount, - interestExponent, - lender, - borrower, - evan, - broker, - pseudoAcct, - 0, - defaultImmediately(0, false)); - - lifecycle( - caseLabel, - "Loan overpayment prohibited - Pay off immediately", - env, - loanAmount, - interestExponent, - lender, - borrower, - evan, - broker, - pseudoAcct, - 0, - fullPayment(0)); - - lifecycle( - caseLabel, - "Loan overpayment allowed - Pay off immediately", - env, - loanAmount, - interestExponent, - lender, - borrower, - evan, - broker, - pseudoAcct, - tfLoanOverpayment, - fullPayment(lsfLoanOverpayment)); - - lifecycle( - caseLabel, - "Loan overpayment prohibited - Combine all payments", - env, - loanAmount, - interestExponent, - lender, - borrower, - evan, - broker, - pseudoAcct, - 0, - combineAllPayments(0)); - - lifecycle( - caseLabel, - "Loan overpayment allowed - Combine all payments", - env, - loanAmount, - interestExponent, - lender, - borrower, - evan, - broker, - pseudoAcct, - tfLoanOverpayment, - combineAllPayments(lsfLoanOverpayment)); - - lifecycle( - caseLabel, - "Loan overpayment prohibited - Make payments", - env, - loanAmount, - interestExponent, - lender, - borrower, - evan, - broker, - pseudoAcct, - 0, - [&](Keylet const& loanKeylet, VerifyLoanStatus const& verifyLoanStatus) { - // toEndOfLife - // - // Draw and make multiple payments - auto state = getCurrentState(env, broker, loanKeylet, verifyLoanStatus); - BEAST_EXPECT(state.flags == 0); - env.close(); - - verifyLoanStatus(state); - - env.close(state.startDate + 20s); - auto const loanAge = (env.now() - state.startDate).count(); - BEAST_EXPECT(loanAge == 30); - - // Periodic payment amount will consist of - // 1. principal outstanding (1000) - // 2. interest interest rate (at 12%) - // 3. payment interval (600s) - // 4. loan service fee (2) - // Calculate these values without the helper functions - // to verify they're working correctly The numbers in - // the below BEAST_EXPECTs may not hold across assets. - Number const interval = state.paymentInterval; - auto const periodicRate = interval * Number(12, -2) / secondsInYear; - BEAST_EXPECT(periodicRate == Number(2283105022831050228, -24, Number::normalized{})); - STAmount const roundedPeriodicPayment{ - broker.asset, roundPeriodicPayment(broker.asset, state.periodicPayment, state.loanScale)}; - - testcase << currencyLabel << " Payment components: " - << "Payments remaining, rawInterest, rawPrincipal, " - "rawMFee, trackedValueDelta, trackedPrincipalDelta, " - "trackedInterestDelta, trackedMgmtFeeDelta, special"; - - auto const serviceFee = broker.asset(2); - - BEAST_EXPECT( - roundedPeriodicPayment == - roundToScale( - broker.asset(Number(8333457002039338267, -17), Number::upward), - state.loanScale, - Number::upward)); - // 83334570.01162141 - // Include the service fee - STAmount const totalDue = - roundToScale(roundedPeriodicPayment + serviceFee, state.loanScale, Number::upward); - // Only check the first payment since the rounding - // may drift as payments are made - BEAST_EXPECT( - totalDue == - roundToScale( - broker.asset(Number(8533457002039338267, -17), Number::upward), - state.loanScale, - Number::upward)); - - { - auto const raw = computeTheoreticalLoanState( - state.periodicPayment, periodicRate, state.paymentRemaining, broker.params.managementFeeRate); - auto const rounded = constructLoanState( - state.totalValue, state.principalOutstanding, state.managementFeeOutstanding); - testcase << currencyLabel << " Loan starting state: " << state.paymentRemaining << ", " - << raw.interestDue << ", " << raw.principalOutstanding << ", " << raw.managementFeeDue - << ", " << rounded.valueOutstanding << ", " << rounded.principalOutstanding << ", " - << rounded.interestDue << ", " << rounded.managementFeeDue; - } - - // Try to pay a little extra to show that it's _not_ - // taken - STAmount const transactionAmount = STAmount{broker.asset, totalDue} + broker.asset(10); - // Only check the first payment since the rounding - // may drift as payments are made - BEAST_EXPECT( - transactionAmount == - roundToScale( - broker.asset(Number(9533457002039400, -14), Number::upward), state.loanScale, Number::upward)); - - auto const initialState = state; - detail::PaymentComponents totalPaid{ - .trackedValueDelta = 0, .trackedPrincipalDelta = 0, .trackedManagementFeeDelta = 0}; - Number totalInterestPaid = 0; - std::size_t totalPaymentsMade = 0; - - xrpl::LoanState currentTrueState = computeTheoreticalLoanState( - state.periodicPayment, periodicRate, state.paymentRemaining, broker.params.managementFeeRate); - - while (state.paymentRemaining > 0) - { - // 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_EXPECTS( - paymentComponents.specialCase == detail::PaymentSpecialCase::final || - paymentComponents.trackedValueDelta <= roundedPeriodicPayment, - "Delta: " + to_string(paymentComponents.trackedValueDelta) + - ", periodic payment: " + to_string(roundedPeriodicPayment)); - - xrpl::LoanState const nextTrueState = computeTheoreticalLoanState( - state.periodicPayment, - periodicRate, - state.paymentRemaining - 1, - broker.params.managementFeeRate); - detail::LoanStateDeltas const deltas = currentTrueState - nextTrueState; - - testcase << currencyLabel << " Payment components: " << state.paymentRemaining << ", " - << deltas.interest << ", " << deltas.principal << ", " << deltas.managementFee << ", " - << paymentComponents.trackedValueDelta << ", " << paymentComponents.trackedPrincipalDelta - << ", " << paymentComponents.trackedInterestPart() << ", " - << paymentComponents.trackedManagementFeeDelta << ", " - << (paymentComponents.specialCase == detail::PaymentSpecialCase::final ? "final" - : paymentComponents.specialCase == detail::PaymentSpecialCase::extra ? "extra" - : "none"); - - auto const totalDueAmount = - STAmount{broker.asset, paymentComponents.trackedValueDelta + serviceFee.number()}; - - // Due to the rounding algorithms to keep the interest and - // principal in sync with "true" values, the computed amount - // may be a little less than the rounded fixed payment - // amount. For integral types, the difference should be < 3 - // (1 unit for each of the interest and management fee). For - // IOUs, the difference should be after the 8th digit. - Number const diff = totalDue - totalDueAmount; - BEAST_EXPECT( - paymentComponents.specialCase == detail::PaymentSpecialCase::final || diff == beast::zero || - (diff > beast::zero && - ((broker.asset.integral() && (static_cast(diff) < 3)) || - (state.loanScale - diff.exponent() > 13)))); - - BEAST_EXPECT( - paymentComponents.trackedValueDelta == - paymentComponents.trackedPrincipalDelta + paymentComponents.trackedInterestPart() + - paymentComponents.trackedManagementFeeDelta); - BEAST_EXPECT( - paymentComponents.specialCase == detail::PaymentSpecialCase::final || - paymentComponents.trackedValueDelta <= roundedPeriodicPayment); - - BEAST_EXPECT( - state.paymentRemaining < 12 || - roundToAsset(broker.asset, deltas.principal, state.loanScale, Number::upward) == - roundToScale( - broker.asset(Number(8333228691531218890, -17), Number::upward), - state.loanScale, - Number::upward)); - BEAST_EXPECT( - paymentComponents.trackedPrincipalDelta >= beast::zero && - paymentComponents.trackedPrincipalDelta <= state.principalOutstanding); - BEAST_EXPECT( - paymentComponents.specialCase != detail::PaymentSpecialCase::final || - paymentComponents.trackedPrincipalDelta == state.principalOutstanding); - BEAST_EXPECT( - paymentComponents.specialCase == detail::PaymentSpecialCase::final || - (state.periodicPayment.exponent() - - (deltas.principal + deltas.interest + deltas.managementFee - state.periodicPayment) - .exponent()) > 14); - - auto const borrowerBalanceBeforePayment = env.balance(borrower, broker.asset); - - if (canImpairLoan(env, broker, state)) - // Making a payment will unimpair the loan - env(manage(lender, loanKeylet.key, tfLoanImpair)); - - env.close(); - - // Make the payment - env(pay(borrower, loanKeylet.key, transactionAmount)); - - env.close(); - - // Need to account for fees if the loan is in XRP - PrettyAmount adjustment = broker.asset(0); - if (broker.asset.native()) - { - adjustment = env.current()->fees().base; - } - - // Check the result - verifyLoanStatus.checkPayment( - state.loanScale, borrower, borrowerBalanceBeforePayment, totalDueAmount, adjustment); - - --state.paymentRemaining; - state.previousPaymentDate = state.nextPaymentDate; - if (paymentComponents.specialCase == detail::PaymentSpecialCase::final) - { - state.paymentRemaining = 0; - state.nextPaymentDate = 0; - } - else - { - state.nextPaymentDate += state.paymentInterval; - } - state.principalOutstanding -= paymentComponents.trackedPrincipalDelta; - state.managementFeeOutstanding -= paymentComponents.trackedManagementFeeDelta; - state.totalValue -= paymentComponents.trackedValueDelta; - - verifyLoanStatus(state); - - totalPaid.trackedValueDelta += paymentComponents.trackedValueDelta; - totalPaid.trackedPrincipalDelta += paymentComponents.trackedPrincipalDelta; - totalPaid.trackedManagementFeeDelta += paymentComponents.trackedManagementFeeDelta; - totalInterestPaid += paymentComponents.trackedInterestPart(); - ++totalPaymentsMade; - - currentTrueState = nextTrueState; - } - - // Loan is paid off - BEAST_EXPECT(state.paymentRemaining == 0); - BEAST_EXPECT(state.principalOutstanding == 0); - - // Make sure all the payments add up - BEAST_EXPECT(totalPaid.trackedValueDelta == initialState.totalValue); - BEAST_EXPECT(totalPaid.trackedPrincipalDelta == initialState.principalOutstanding); - BEAST_EXPECT(totalPaid.trackedManagementFeeDelta == initialState.managementFeeOutstanding); - // This is almost a tautology given the previous checks, but - // check it anyway for completeness. - BEAST_EXPECT( - totalInterestPaid == - initialState.totalValue - - (initialState.principalOutstanding + initialState.managementFeeOutstanding)); - BEAST_EXPECT(totalPaymentsMade == initialState.paymentRemaining); - - // Can't impair or default a paid off loan - env(manage(lender, loanKeylet.key, tfLoanImpair), ter(tecNO_PERMISSION)); - env(manage(lender, loanKeylet.key, tfLoanDefault), ter(tecNO_PERMISSION)); - }); - -#if LOANTODO - // TODO - - /* - LoanPay fails with tecINVARIANT_FAILED error when loan_broker(also - borrower) tries to do the payment. Here's the scenario: Create a XRP - loan with loan broker as borrower, loan origination fee and loan service - fee. Loan broker makes the first payment with periodic payment and loan - service fee. - */ - - auto time = [&](std::string label, std::function timed) { - if (!BEAST_EXPECT(timed)) - return; - - using clock_type = std::chrono::steady_clock; - using duration_type = std::chrono::milliseconds; - - auto const start = clock_type::now(); - timed(); - auto const duration = std::chrono::duration_cast(clock_type::now() - start); - - log << label << " took " << duration.count() << "ms" << std::endl; - - return duration; - }; - - lifecycle( - caseLabel, - "timing", - env, - loanAmount, - interestExponent, - lender, - borrower, - evan, - broker, - pseudoAcct, - tfLoanOverpayment, - [&](Keylet const& loanKeylet, VerifyLoanStatus const& verifyLoanStatus) { - // Estimate optimal values for loanPaymentsPerFeeIncrement and - // loanMaximumPaymentsPerTransaction. - using namespace loan; - - auto const state = getCurrentState(env, broker, verifyLoanStatus.keylet); - auto const serviceFee = broker.asset(2).value(); - - STAmount const totalDue{ - broker.asset, - roundPeriodicPayment(broker.asset, state.periodicPayment + serviceFee, state.loanScale)}; - - // Make a single payment - time("single payment", [&]() { env(pay(borrower, loanKeylet.key, totalDue)); }); - env.close(); - - // Make all but the final payment - auto const numPayments = (state.paymentRemaining - 2); - STAmount const bigPayment{broker.asset, totalDue * numPayments}; - XRPAmount const bigFee{baseFee * (numPayments / loanPaymentsPerFeeIncrement + 1)}; - time("ten payments", [&]() { env(pay(borrower, loanKeylet.key, bigPayment), fee(bigFee)); }); - env.close(); - - time("final payment", [&]() { - // Make the final payment - env(pay(borrower, loanKeylet.key, totalDue + STAmount{broker.asset, 1})); - }); - env.close(); - }); - - lifecycle( - caseLabel, - "Loan overpayment allowed - Explicit overpayment", - env, - loanAmount, - interestExponent, - lender, - borrower, - evan, - broker, - pseudoAcct, - tfLoanOverpayment, - [&](Keylet const& loanKeylet, VerifyLoanStatus const& verifyLoanStatus) { throw 0; }); - - lifecycle( - caseLabel, - "Loan overpayment prohibited - Late payment", - env, - loanAmount, - interestExponent, - lender, - borrower, - evan, - broker, - pseudoAcct, - tfLoanOverpayment, - [&](Keylet const& loanKeylet, VerifyLoanStatus const& verifyLoanStatus) { throw 0; }); - - lifecycle( - caseLabel, - "Loan overpayment allowed - Late payment", - env, - loanAmount, - interestExponent, - lender, - borrower, - evan, - broker, - pseudoAcct, - tfLoanOverpayment, - [&](Keylet const& loanKeylet, VerifyLoanStatus const& verifyLoanStatus) { throw 0; }); - - lifecycle( - caseLabel, - "Loan overpayment allowed - Late payment and overpayment", - env, - loanAmount, - interestExponent, - lender, - borrower, - evan, - broker, - pseudoAcct, - tfLoanOverpayment, - [&](Keylet const& loanKeylet, VerifyLoanStatus const& verifyLoanStatus) { throw 0; }); - -#endif - } - - void - testLoanSet() - { - using namespace jtx; - - Account const issuer{"issuer"}; - Account const lender{"lender"}; + Account const broker{"broker"}; Account const borrower{"borrower"}; - struct CaseArgs - { - bool requireAuth = false; - bool authorizeBorrower = false; - int initialXRP = 1'000'000; - }; - - auto const testCase = [&, this]( - std::function mptTest, - std::function iouTest, - CaseArgs args = {}) { - Env env(*this, all); - env.fund(XRP(args.initialXRP), issuer, lender, borrower); - env.close(); - if (args.requireAuth) - { - env(fset(issuer, asfRequireAuth)); - env.close(); - } - - // We need two different asset types, MPT and IOU. Prepare MPT - // first - MPTTester mptt{env, issuer, mptInitNoFund}; - - auto const none = LedgerSpecificFlags(0); - mptt.create({.flags = tfMPTCanTransfer | tfMPTCanLock | (args.requireAuth ? tfMPTRequireAuth : none)}); - env.close(); - PrettyAsset mptAsset = mptt.issuanceID(); - mptt.authorize({.account = lender}); - mptt.authorize({.account = borrower}); - env.close(); - if (args.requireAuth) - { - mptt.authorize({.account = issuer, .holder = lender}); - if (args.authorizeBorrower) - mptt.authorize({.account = issuer, .holder = borrower}); - env.close(); - } - - env(pay(issuer, lender, mptAsset(10'000'000))); - env.close(); - - // Prepare IOU - PrettyAsset const iouAsset = issuer[iouCurrency]; - env(trust(lender, iouAsset(10'000'000))); - env(trust(borrower, iouAsset(10'000'000))); - env.close(); - if (args.requireAuth) - { - env(trust(issuer, iouAsset(0), lender, tfSetfAuth)); - env(pay(issuer, lender, iouAsset(10'000'000))); - if (args.authorizeBorrower) - { - env(trust(issuer, iouAsset(0), borrower, tfSetfAuth)); - env(pay(issuer, borrower, iouAsset(10'000))); - } - } - else - { - env(pay(issuer, lender, iouAsset(10'000'000))); - env(pay(issuer, borrower, iouAsset(10'000))); - } - env.close(); - - // Create vaults and loan brokers - std::array const assets{mptAsset, iouAsset}; - std::vector brokers; - for (auto const& asset : assets) - { - brokers.emplace_back(createVaultAndBroker(env, asset, lender)); - } - - if (mptTest) - (mptTest)(env, brokers[0], mptt); - if (iouTest) - (iouTest)(env, brokers[1]); - }; - - testCase( - [&, this](Env& env, BrokerInfo const& broker, auto&) { - using namespace loan; - Number const principalRequest = broker.asset(1'000).value(); - - testcase("MPT issuer is borrower, issuer submits"); - env(set(issuer, broker.brokerID, principalRequest), - counterparty(lender), - sig(sfCounterpartySignature, lender), - fee(env.current()->fees().base * 5)); - - testcase("MPT issuer is borrower, lender submits"); - env(set(lender, broker.brokerID, principalRequest), - counterparty(issuer), - sig(sfCounterpartySignature, issuer), - fee(env.current()->fees().base * 5)); - }, - [&, this](Env& env, BrokerInfo const& broker) { - using namespace loan; - Number const principalRequest = broker.asset(1'000).value(); - - testcase("IOU issuer is borrower, issuer submits"); - env(set(issuer, broker.brokerID, principalRequest), - counterparty(lender), - sig(sfCounterpartySignature, lender), - fee(env.current()->fees().base * 5)); - - testcase("IOU issuer is borrower, lender submits"); - env(set(lender, broker.brokerID, principalRequest), - counterparty(issuer), - sig(sfCounterpartySignature, issuer), - fee(env.current()->fees().base * 5)); - }, - CaseArgs{.requireAuth = true}); - - testCase( - [&, this](Env& env, BrokerInfo const& broker, auto&) { - using namespace loan; - Number const principalRequest = broker.asset(1'000).value(); - - testcase("MPT unauthorized borrower, borrower submits"); - env(set(borrower, broker.brokerID, principalRequest), - counterparty(lender), - sig(sfCounterpartySignature, lender), - fee(env.current()->fees().base * 5), - ter{tecNO_AUTH}); - - testcase("MPT unauthorized borrower, lender submits"); - env(set(lender, broker.brokerID, principalRequest), - counterparty(borrower), - sig(sfCounterpartySignature, borrower), - fee(env.current()->fees().base * 5), - ter{tecNO_AUTH}); - }, - [&, this](Env& env, BrokerInfo const& broker) { - using namespace loan; - Number const principalRequest = broker.asset(1'000).value(); - - testcase("IOU unauthorized borrower, borrower submits"); - env(set(borrower, broker.brokerID, principalRequest), - counterparty(lender), - sig(sfCounterpartySignature, lender), - fee(env.current()->fees().base * 5), - ter{tecNO_AUTH}); - - testcase("IOU unauthorized borrower, lender submits"); - env(set(lender, broker.brokerID, principalRequest), - counterparty(borrower), - sig(sfCounterpartySignature, borrower), - fee(env.current()->fees().base * 5), - ter{tecNO_AUTH}); - }, - CaseArgs{.requireAuth = true}); - - auto const [acctReserve, incReserve] = [this]() -> std::pair { - Env env{*this, testable_amendments()}; - return { - env.current()->fees().accountReserve(0).drops() / DROPS_PER_XRP.drops(), - env.current()->fees().increment.drops() / DROPS_PER_XRP.drops()}; - }(); - - testCase( - [&, this](Env& env, BrokerInfo const& broker, MPTTester& mptt) { - using namespace loan; - Number const principalRequest = broker.asset(1'000).value(); - - testcase( - "MPT authorized borrower, borrower submits, borrower has " - "no reserve"); - mptt.authorize({.account = borrower, .flags = tfMPTUnauthorize}); - env.close(); - - auto const mptoken = keylet::mptoken(mptt.issuanceID(), borrower); - auto const sleMPT1 = env.le(mptoken); - BEAST_EXPECT(sleMPT1 == nullptr); - - // Burn some XRP - env(noop(borrower), fee(XRP(acctReserve * 2 + incReserve * 2))); - env.close(); - - // Cannot create loan, not enough reserve to create MPToken - env(set(borrower, broker.brokerID, principalRequest), - counterparty(lender), - sig(sfCounterpartySignature, lender), - fee(env.current()->fees().base * 5), - ter{tecINSUFFICIENT_RESERVE}); - env.close(); - - // Can create loan now, will implicitly create MPToken - env(pay(issuer, borrower, XRP(incReserve))); - env.close(); - env(set(borrower, broker.brokerID, principalRequest), - counterparty(lender), - sig(sfCounterpartySignature, lender), - fee(env.current()->fees().base * 5)); - env.close(); - - auto const sleMPT2 = env.le(mptoken); - BEAST_EXPECT(sleMPT2 != nullptr); - }, - {}, - CaseArgs{.initialXRP = acctReserve * 2 + incReserve * 8 + 1}); - - testCase( - {}, - [&, this](Env& env, BrokerInfo const& broker) { - using namespace loan; - Number const principalRequest = broker.asset(1'000).value(); - - testcase( - "IOU authorized borrower, borrower submits, borrower has " - "no reserve"); - // Remove trust line from borrower to issuer - env.trust(broker.asset(0), borrower); - env.close(); - - env(pay(borrower, issuer, broker.asset(10'000))); - env.close(); - auto const trustline = keylet::line(borrower, broker.asset.raw().get()); - auto const sleLine1 = env.le(trustline); - BEAST_EXPECT(sleLine1 == nullptr); - - // Burn some XRP - env(noop(borrower), fee(XRP(acctReserve * 2 + incReserve * 2))); - env.close(); - - // Cannot create loan, not enough reserve to create trust line - env(set(borrower, broker.brokerID, principalRequest), - counterparty(lender), - sig(sfCounterpartySignature, lender), - fee(env.current()->fees().base * 5), - ter{tecNO_LINE_INSUF_RESERVE}); - env.close(); - - // Can create loan now, will implicitly create trust line - env(pay(issuer, borrower, XRP(incReserve))); - env.close(); - env(set(borrower, broker.brokerID, principalRequest), - counterparty(lender), - sig(sfCounterpartySignature, lender), - fee(env.current()->fees().base * 5)); - env.close(); - - auto const sleLine2 = env.le(trustline); - BEAST_EXPECT(sleLine2 != nullptr); - }, - CaseArgs{.initialXRP = acctReserve * 2 + incReserve * 8 + 1}); - - testCase( - [&, this](Env& env, BrokerInfo const& broker, MPTTester& mptt) { - using namespace loan; - Number const principalRequest = broker.asset(1'000).value(); - - testcase( - "MPT authorized borrower, borrower submits, lender has " - "no reserve"); - auto const mptoken = keylet::mptoken(mptt.issuanceID(), lender); - auto const sleMPT1 = env.le(mptoken); - BEAST_EXPECT(sleMPT1 != nullptr); - - env(pay(lender, issuer, broker.asset(sleMPT1->at(sfMPTAmount)))); - env.close(); - - mptt.authorize({.account = lender, .flags = tfMPTUnauthorize}); - env.close(); - - auto const sleMPT2 = env.le(mptoken); - BEAST_EXPECT(sleMPT2 == nullptr); - - // Burn some XRP - env(noop(lender), fee(XRP(incReserve))); - env.close(); - - // Cannot create loan, not enough reserve to create MPToken - env(set(borrower, broker.brokerID, principalRequest), - loanOriginationFee(broker.asset(1).value()), - counterparty(lender), - sig(sfCounterpartySignature, lender), - fee(env.current()->fees().base * 5), - ter{tecINSUFFICIENT_RESERVE}); - env.close(); - - // Can create loan now, will implicitly create MPToken - env(pay(issuer, lender, XRP(incReserve))); - env.close(); - env(set(borrower, broker.brokerID, principalRequest), - loanOriginationFee(broker.asset(1).value()), - counterparty(lender), - sig(sfCounterpartySignature, lender), - fee(env.current()->fees().base * 5)); - env.close(); - - auto const sleMPT3 = env.le(mptoken); - BEAST_EXPECT(sleMPT3 != nullptr); - }, - {}, - CaseArgs{.initialXRP = acctReserve * 2 + incReserve * 8 + 1}); - - testCase( - {}, - [&, this](Env& env, BrokerInfo const& broker) { - using namespace loan; - Number const principalRequest = broker.asset(1'000).value(); - - testcase( - "IOU authorized borrower, borrower submits, lender has no " - "reserve"); - // Remove trust line from lender to issuer - env.trust(broker.asset(0), lender); - env.close(); - - auto const trustline = keylet::line(lender, broker.asset.raw().get()); - auto const sleLine1 = env.le(trustline); - BEAST_EXPECT(sleLine1 != nullptr); - - env(pay(lender, issuer, broker.asset(abs(sleLine1->at(sfBalance).value())))); - env.close(); - auto const sleLine2 = env.le(trustline); - BEAST_EXPECT(sleLine2 == nullptr); - - // Burn some XRP - env(noop(lender), fee(XRP(incReserve))); - env.close(); - - // Cannot create loan, not enough reserve to create trust line - env(set(borrower, broker.brokerID, principalRequest), - loanOriginationFee(broker.asset(1).value()), - counterparty(lender), - sig(sfCounterpartySignature, lender), - fee(env.current()->fees().base * 5), - ter{tecNO_LINE_INSUF_RESERVE}); - env.close(); - - // Can create loan now, will implicitly create trust line - env(pay(issuer, lender, XRP(incReserve))); - env.close(); - env(set(borrower, broker.brokerID, principalRequest), - loanOriginationFee(broker.asset(1).value()), - counterparty(lender), - sig(sfCounterpartySignature, lender), - fee(env.current()->fees().base * 5)); - env.close(); - - auto const sleLine3 = env.le(trustline); - BEAST_EXPECT(sleLine3 != nullptr); - }, - CaseArgs{.initialXRP = acctReserve * 2 + incReserve * 8 + 1}); - - testCase( - [&, this](Env& env, BrokerInfo const& broker, MPTTester& mptt) { - using namespace loan; - Number const principalRequest = broker.asset(1'000).value(); - - testcase("MPT authorized borrower, unauthorized lender"); - auto const mptoken = keylet::mptoken(mptt.issuanceID(), lender); - auto const sleMPT1 = env.le(mptoken); - BEAST_EXPECT(sleMPT1 != nullptr); - - env(pay(lender, issuer, broker.asset(sleMPT1->at(sfMPTAmount)))); - env.close(); - - mptt.authorize({.account = lender, .flags = tfMPTUnauthorize}); - env.close(); - - auto const sleMPT2 = env.le(mptoken); - BEAST_EXPECT(sleMPT2 == nullptr); - - // Cannot create loan, lender not authorized to receive fee - env(set(borrower, broker.brokerID, principalRequest), - loanOriginationFee(broker.asset(1).value()), - counterparty(lender), - sig(sfCounterpartySignature, lender), - fee(env.current()->fees().base * 5), - ter{tecNO_AUTH}); - env.close(); - - // Cannot create loan, even without an origination fee - env(set(borrower, broker.brokerID, principalRequest), - counterparty(lender), - sig(sfCounterpartySignature, lender), - fee(env.current()->fees().base * 5), - ter{tecNO_AUTH}); - env.close(); - - // No MPToken for lender - no authorization and no payment - auto const sleMPT3 = env.le(mptoken); - BEAST_EXPECT(sleMPT3 == nullptr); - }, - {}, - CaseArgs{.requireAuth = true, .authorizeBorrower = true}); - - testCase( - [&, this](Env& env, BrokerInfo const& broker, auto&) { - using namespace loan; - Number const principalRequest = broker.asset(1'000).value(); - - testcase("MPT authorized borrower, borrower submits"); - env(set(borrower, broker.brokerID, principalRequest), - counterparty(lender), - sig(sfCounterpartySignature, lender), - fee(env.current()->fees().base * 5)); - }, - [&, this](Env& env, BrokerInfo const& broker) { - using namespace loan; - Number const principalRequest = broker.asset(1'000).value(); - - testcase("IOU authorized borrower, borrower submits"); - env(set(borrower, broker.brokerID, principalRequest), - counterparty(lender), - sig(sfCounterpartySignature, lender), - fee(env.current()->fees().base * 5)); - }, - CaseArgs{.requireAuth = true, .authorizeBorrower = true}); - - testCase( - [&, this](Env& env, BrokerInfo const& broker, auto&) { - using namespace loan; - Number const principalRequest = broker.asset(1'000).value(); - - testcase("MPT authorized borrower, lender submits"); - env(set(lender, broker.brokerID, principalRequest), - counterparty(borrower), - sig(sfCounterpartySignature, borrower), - fee(env.current()->fees().base * 5)); - }, - [&, this](Env& env, BrokerInfo const& broker) { - using namespace loan; - Number const principalRequest = broker.asset(1'000).value(); - - testcase("IOU authorized borrower, lender submits"); - env(set(lender, broker.brokerID, principalRequest), - counterparty(borrower), - sig(sfCounterpartySignature, borrower), - fee(env.current()->fees().base * 5)); - }, - CaseArgs{.requireAuth = true, .authorizeBorrower = true}); - - jtx::Account const alice{"alice"}; - jtx::Account const bella{"bella"}; - auto const msigSetup = [&](Env& env, Account const& account) { - Json::Value tx1 = signers(account, 2, {{alice, 1}, {bella, 1}}); - env(tx1); - env.close(); - }; - - testCase( - [&, this](Env& env, BrokerInfo const& broker, auto&) { - using namespace loan; - msigSetup(env, lender); - Number const principalRequest = broker.asset(1'000).value(); - - testcase( - "MPT authorized borrower, borrower submits, lender " - "multisign"); - env(set(borrower, broker.brokerID, principalRequest), - counterparty(lender), - msig(sfCounterpartySignature, alice, bella), - fee(env.current()->fees().base * 5)); - }, - [&, this](Env& env, BrokerInfo const& broker) { - using namespace loan; - msigSetup(env, lender); - Number const principalRequest = broker.asset(1'000).value(); - - testcase( - "IOU authorized borrower, borrower submits, lender " - "multisign"); - env(set(borrower, broker.brokerID, principalRequest), - counterparty(lender), - msig(sfCounterpartySignature, alice, bella), - fee(env.current()->fees().base * 5)); - }, - CaseArgs{.requireAuth = true, .authorizeBorrower = true}); - - testCase( - [&, this](Env& env, BrokerInfo const& broker, auto&) { - using namespace loan; - msigSetup(env, borrower); - Number const principalRequest = broker.asset(1'000).value(); - - testcase( - "MPT authorized borrower, lender submits, borrower " - "multisign"); - env(set(lender, broker.brokerID, principalRequest), - counterparty(borrower), - msig(sfCounterpartySignature, alice, bella), - fee(env.current()->fees().base * 5)); - }, - [&, this](Env& env, BrokerInfo const& broker) { - using namespace loan; - msigSetup(env, borrower); - Number const principalRequest = broker.asset(1'000).value(); - - testcase( - "IOU authorized borrower, lender submits, borrower " - "multisign"); - env(set(lender, broker.brokerID, principalRequest), - counterparty(borrower), - msig(sfCounterpartySignature, alice, bella), - fee(env.current()->fees().base * 5)); - }, - CaseArgs{.requireAuth = true, .authorizeBorrower = true}); - - testCase( - [&, this](Env& env, BrokerInfo const& broker, auto&) { - using namespace loan; - Number const principalRequest = broker.asset(1'000).value(); - Vault vault{env}; - auto tx = vault.set({.owner = lender, .id = broker.vaultID}); - tx[sfAssetsMaximum] = BrokerParameters::defaults().vaultDeposit; - env(tx); - env.close(); - - testcase("Vault at maximum value"); - env(set(issuer, broker.brokerID, principalRequest), - counterparty(lender), - interestRate(TenthBips32(10'000)), - sig(sfCounterpartySignature, lender), - fee(env.current()->fees().base * 5), - ter(tecLIMIT_EXCEEDED), - THISLINE); - }, - nullptr); - - testCase( - [&, this](Env& env, BrokerInfo const& broker, auto&) { - using namespace loan; - Number const principalRequest = broker.asset(1'000).value(); - Vault vault{env}; - auto tx = vault.set({.owner = lender, .id = broker.vaultID}); - tx[sfAssetsMaximum] = BrokerParameters::defaults().vaultDeposit + broker.asset(1).number(); - env(tx); - env.close(); - - testcase("Vault maximum value exceeded"); - env(set(issuer, broker.brokerID, principalRequest), - counterparty(lender), - interestRate(TenthBips32(100'000)), - sig(sfCounterpartySignature, lender), - fee(env.current()->fees().base * 5), - paymentTotal(2), - paymentInterval(3600 * 24), - ter(tecLIMIT_EXCEEDED), - THISLINE); - }, - nullptr); - } - - void - testLifecycle() - { - testcase("Lifecycle"); - using namespace jtx; - - // Create 3 loan brokers: one for XRP, one for an IOU, and one for - // an MPT. That'll require three corresponding SAVs. - Env env(*this, all); - - Account const issuer{"issuer"}; - // For simplicity, lender will be the sole actor for the vault & - // brokers. - Account const lender{"lender"}; - // Borrower only wants to borrow - Account const borrower{"borrower"}; - // Evan will attempt to be naughty - Account const evan{"evan"}; - // Do not fund alice - Account const alice{"alice"}; - - // Fund the accounts and trust lines with the same amount so that - // tests can use the same values regardless of the asset. - env.fund(XRP(100'000'000), issuer, noripple(lender, borrower, evan)); - env.close(); - - // Create assets - PrettyAsset const xrpAsset{xrpIssue(), 1'000'000}; - PrettyAsset const iouAsset = issuer[iouCurrency]; - env(trust(lender, iouAsset(10'000'000))); - env(trust(borrower, iouAsset(10'000'000))); - env(trust(evan, iouAsset(10'000'000))); - env(pay(issuer, evan, iouAsset(1'000'000))); - env(pay(issuer, lender, iouAsset(10'000'000))); - // Fund the borrower with enough to cover interest and fees - env(pay(issuer, borrower, iouAsset(10'000))); - env.close(); - - MPTTester mptt{env, issuer, mptInitNoFund}; - mptt.create({.flags = tfMPTCanClawback | tfMPTCanTransfer | tfMPTCanLock}); - // Scale the MPT asset a little bit so we can get some interest - PrettyAsset const mptAsset{mptt.issuanceID(), 100}; - mptt.authorize({.account = lender}); - mptt.authorize({.account = borrower}); - mptt.authorize({.account = evan}); - env(pay(issuer, lender, mptAsset(10'000'000))); - env(pay(issuer, evan, mptAsset(1'000'000))); - // Fund the borrower with enough to cover interest and fees - env(pay(issuer, borrower, mptAsset(10'000))); - env.close(); - - std::array const assets{iouAsset, xrpAsset, mptAsset}; - - // Create vaults and loan brokers - std::vector brokers; - for (auto const& asset : assets) - { - brokers.emplace_back( - createVaultAndBroker(env, asset, lender, BrokerParameters{.data = "spam spam spam spam"})); - } - - // Create and update Loans - for (auto const& broker : brokers) - { - for (int amountExponent = 3; amountExponent >= 3; --amountExponent) - { - Number const loanAmount{1, amountExponent}; - for (int interestExponent = 0; interestExponent >= 0; --interestExponent) - { - testCaseWrapper(env, mptt, assets, broker, loanAmount, interestExponent); - } - } - - if (auto brokerSle = env.le(keylet::loanbroker(broker.brokerID)); BEAST_EXPECT(brokerSle)) - { - BEAST_EXPECT(brokerSle->at(sfOwnerCount) == 0); - BEAST_EXPECT(brokerSle->at(sfDebtTotal) == 0); - - auto const coverAvailable = brokerSle->at(sfCoverAvailable); - env(loanBroker::coverWithdraw(lender, broker.brokerID, STAmount(broker.asset, coverAvailable))); - env.close(); - - brokerSle = env.le(keylet::loanbroker(broker.brokerID)); - BEAST_EXPECT(brokerSle && brokerSle->at(sfCoverAvailable) == 0); - } - // Verify we can delete the loan broker - env(loanBroker::del(lender, broker.brokerID)); - env.close(); - } - } - - void - testSelfLoan() - { - testcase << "Self Loan"; - - using namespace jtx; - using namespace std::chrono_literals; - // Create 3 loan brokers: one for XRP, one for an IOU, and one for - // an MPT. That'll require three corresponding SAVs. - Env env(*this, all); - - Account const issuer{"issuer"}; - // For simplicity, lender will be the sole actor for the vault & - // brokers. - Account const lender{"lender"}; - - // Fund the accounts and trust lines with the same amount so that - // tests can use the same values regardless of the asset. - env.fund(XRP(100'000'000), issuer, noripple(lender)); - env.close(); - - // Use an XRP asset for simplicity - PrettyAsset const xrpAsset{xrpIssue(), 1'000'000}; - - // Create vaults and loan brokers - BrokerInfo broker{createVaultAndBroker(env, xrpAsset, lender)}; - - using namespace loan; - - auto const loanSetFee = fee(env.current()->fees().base * 2); - Number const principalRequest{1, 3}; - - // The LoanSet json can be created without a counterparty signature, - // but it will not pass preflight - auto createJson = - env.json(set(lender, broker.brokerID, broker.asset(principalRequest).value()), fee(loanSetFee)); - env(createJson, ter(temBAD_SIGNER)); - - // Adding an empty counterparty signature object also fails, but - // at the RPC level. - createJson = env.json(createJson, json(sfCounterpartySignature, Json::objectValue)); - env(createJson, ter(telENV_RPC_FAILED)); - - if (auto const jt = env.jt(createJson); BEAST_EXPECT(jt.stx)) - { - Serializer s; - jt.stx->add(s); - auto const jr = env.rpc("submit", strHex(s.slice())); - - BEAST_EXPECT(jr.isMember(jss::result)); - auto const jResult = jr[jss::result]; - BEAST_EXPECT(jResult[jss::error] == "invalidTransaction"); - BEAST_EXPECT(jResult[jss::error_exception] == "fails local checks: Transaction has bad signature."); - } - - // Copy the transaction signature into the counterparty signature. - Json::Value counterpartyJson{Json::objectValue}; - counterpartyJson[sfTxnSignature] = createJson[sfTxnSignature]; - counterpartyJson[sfSigningPubKey] = createJson[sfSigningPubKey]; - if (!BEAST_EXPECT(!createJson.isMember(jss::Signers))) - counterpartyJson[sfSigners] = createJson[sfSigners]; - - // The duplicated signature works - createJson = env.json(createJson, json(sfCounterpartySignature, counterpartyJson)); - env(createJson); - - env.close(); - - auto const startDate = env.current()->header().parentCloseTime; - - // Loan is successfully created - { - auto const res = env.rpc("account_objects", lender.human()); - auto const objects = res[jss::result][jss::account_objects]; - - std::map types; - BEAST_EXPECT(objects.size() == 4); - for (auto const& object : objects) - { - ++types[object[sfLedgerEntryType].asString()]; - } - BEAST_EXPECT(types.size() == 4); - for (std::string const type : {"MPToken", "Vault", "LoanBroker", "Loan"}) - { - BEAST_EXPECT(types[type] == 1); - } - } - auto const loanID = [&]() { - Json::Value params(Json::objectValue); - params[jss::account] = lender.human(); - params[jss::type] = "Loan"; - auto const res = env.rpc("json", "account_objects", to_string(params)); - auto const objects = res[jss::result][jss::account_objects]; - - BEAST_EXPECT(objects.size() == 1); - - auto const loan = objects[0u]; - BEAST_EXPECT(loan[sfBorrower] == lender.human()); - // soeDEFAULT fields are not returned if they're in the default - // state - BEAST_EXPECT(!loan.isMember(sfCloseInterestRate)); - BEAST_EXPECT(!loan.isMember(sfClosePaymentFee)); - BEAST_EXPECT(loan[sfFlags] == 0); - BEAST_EXPECT(loan[sfGracePeriod] == 60); - BEAST_EXPECT(!loan.isMember(sfInterestRate)); - BEAST_EXPECT(!loan.isMember(sfLateInterestRate)); - BEAST_EXPECT(!loan.isMember(sfLatePaymentFee)); - BEAST_EXPECT(loan[sfLoanBrokerID] == to_string(broker.brokerID)); - BEAST_EXPECT(!loan.isMember(sfLoanOriginationFee)); - BEAST_EXPECT(loan[sfLoanSequence] == 1); - BEAST_EXPECT(!loan.isMember(sfLoanServiceFee)); - BEAST_EXPECT(loan[sfNextPaymentDueDate] == loan[sfStartDate].asUInt() + 60); - BEAST_EXPECT(!loan.isMember(sfOverpaymentFee)); - BEAST_EXPECT(!loan.isMember(sfOverpaymentInterestRate)); - BEAST_EXPECT(loan[sfPaymentInterval] == 60); - BEAST_EXPECT(loan[sfPeriodicPayment] == "1000000000"); - BEAST_EXPECT(loan[sfPaymentRemaining] == 1); - BEAST_EXPECT(!loan.isMember(sfPreviousPaymentDueDate)); - BEAST_EXPECT(loan[sfPrincipalOutstanding] == "1000000000"); - BEAST_EXPECT(loan[sfTotalValueOutstanding] == "1000000000"); - BEAST_EXPECT(!loan.isMember(sfLoanScale)); - BEAST_EXPECT(loan[sfStartDate].asUInt() == startDate.time_since_epoch().count()); - - return loan["index"].asString(); - }(); - auto const loanKeylet{keylet::loan(uint256{std::string_view(loanID)})}; - - env.close(startDate); - - // Make a payment - env(pay(lender, loanKeylet.key, broker.asset(1000))); - } - - void - testBatchBypassCounterparty() - { - // From FIND-001 - testcase << "Batch Bypass Counterparty"; - - using namespace jtx; - using namespace std::chrono_literals; - Env env(*this, all); - - Account const lender{"lender"}; - Account const borrower{"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, brokerParams)}; - - using namespace loan; - - auto const loanSetFee = fee(env.current()->fees().base * 2); - Number const principalRequest{1, 3}; - - auto forgedLoanSet = set(borrower, broker.brokerID, principalRequest, 0); - - Json::Value randomData{Json::objectValue}; - randomData[jss::SigningPubKey] = Json::StaticString{"2600"}; - Json::Value sigObject{Json::objectValue}; - sigObject[jss::SigningPubKey] = strHex(lender.pk().slice()); - Serializer ss; - ss.add32(HashPrefix::txSign); - parse(randomData).addWithoutSigningFields(ss); - auto const sig = xrpl::sign(borrower.pk(), borrower.sk(), ss.slice()); - sigObject[jss::TxnSignature] = strHex(Slice{sig.data(), sig.size()}); - - forgedLoanSet[Json::StaticString{"CounterpartySignature"}] = sigObject; - - // ? Fails because the lender hasn't signed the tx - env(env.json(forgedLoanSet, fee(loanSetFee)), ter(telENV_RPC_FAILED)); - - auto const seq = env.seq(borrower); - auto const batchFee = batch::calcBatchFee(env, 1, 2); - // ! Should fail because the lender hasn't signed the tx - env(batch::outer(borrower, seq, batchFee, tfAllOrNothing), - batch::inner(forgedLoanSet, seq + 1), - batch::inner(pay(borrower, lender, XRP(1)), seq + 2), - ter(temBAD_SIGNATURE)); - env.close(); - - // ? Check that the loan was NOT created - { - Json::Value params(Json::objectValue); - params[jss::account] = borrower.human(); - params[jss::type] = "Loan"; - auto const res = env.rpc("json", "account_objects", to_string(params)); - auto const objects = res[jss::result][jss::account_objects]; - BEAST_EXPECT(objects.size() == 0); - } - } - - void - testWrongMaxDebtBehavior() - { - // From FIND-003 - testcase << "Wrong Max Debt Behavior"; - - using namespace jtx; - using namespace std::chrono_literals; - Env env(*this, all); - - Account const issuer{"issuer"}; - Account const lender{"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{createVaultAndBroker(env, xrpAsset, lender, brokerParams)}; - - if (auto const brokerSle = env.le(keylet::loanbroker(broker.brokerID)); BEAST_EXPECT(brokerSle)) - { - BEAST_EXPECT(brokerSle->at(sfDebtMaximum) == 0); - } - - using namespace loan; - - auto const loanSetFee = fee(env.current()->fees().base * 2); - Number const principalRequest{1, 3}; - - auto createJson = env.json(set(lender, broker.brokerID, principalRequest), fee(loanSetFee)); - - Json::Value counterpartyJson{Json::objectValue}; - counterpartyJson[sfTxnSignature] = createJson[sfTxnSignature]; - counterpartyJson[sfSigningPubKey] = createJson[sfSigningPubKey]; - if (!BEAST_EXPECT(!createJson.isMember(jss::Signers))) - counterpartyJson[sfSigners] = createJson[sfSigners]; - - createJson = env.json(createJson, json(sfCounterpartySignature, counterpartyJson)); - env(createJson); - - env.close(); - } - - void - testLoanPayComputePeriodicPaymentValidRateInvariant() - { - // From FIND-012 - testcase << "LoanPay xrpl::detail::computePeriodicPayment : " - "valid rate"; - - using namespace jtx; - using namespace std::chrono_literals; - Env env(*this, all); - - Account const issuer{"issuer"}; - Account const lender{"lender"}; - Account const borrower{"borrower"}; - - 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, brokerParams)}; - - using namespace loan; - - auto const loanSetFee = fee(env.current()->fees().base * 2); - Number const principalRequest{640562, -5}; - - Number const serviceFee{2462611968}; - std::uint32_t const numPayments{4294967295 / 800}; - - auto createJson = env.json( - set(borrower, broker.brokerID, principalRequest), - fee(loanSetFee), - loanServiceFee(serviceFee), - paymentTotal(numPayments), - json(sfCounterpartySignature, Json::objectValue)); - - createJson["CloseInterestRate"] = 55374; - createJson["ClosePaymentFee"] = "3825205248"; - createJson["LatePaymentFee"] = "237"; - createJson["LoanOriginationFee"] = "0"; - createJson["OverpaymentFee"] = 35167; - createJson["OverpaymentInterestRate"] = 1360; - createJson["PaymentInterval"] = 727; - - auto const brokerStateBefore = env.le(keylet::loanbroker(broker.brokerID)); - auto const loanSequence = brokerStateBefore->at(sfLoanSequence); - auto const keylet = keylet::loan(broker.brokerID, loanSequence); - - createJson = env.json(createJson, sig(sfCounterpartySignature, lender)); - // Fails in preclaim because principal requested can't be - // represented as XRP - env(createJson, ter(tecPRECISION_LOSS), THISLINE); - env.close(); - - BEAST_EXPECT(!env.le(keylet)); - - Number const actualPrincipal{6}; - - createJson[sfPrincipalRequested] = actualPrincipal; - createJson.removeMember(sfSequence.jsonName); - createJson = env.json(createJson, sig(sfCounterpartySignature, lender)); - // Fails in doApply because the payment is too small to be - // represented as XRP. - env(createJson, ter(tecPRECISION_LOSS), THISLINE); - env.close(); - } - - void - testRPC() - { - // This will expand as more test cases are added. Some functionality - // is tested in other test functions. - testcase("RPC"); - - using namespace jtx; - - Env env(*this, all); - - auto lowerFee = [&]() { - // Run the local fee back down. - while (env.app().getFeeTrack().lowerLocalFee()) - ; - }; - - auto const baseFee = env.current()->fees().base; - - Account const alice{"alice"}; - std::string const borrowerPass = "borrower"; - std::string const borrowerSeed = "ssBRAsLpH4778sLNYC4ik1JBJsBVf"; - Account borrower{borrowerPass, KeyType::ed25519}; - auto const lenderPass = "lender"; - std::string const lenderSeed = "shPTCZGwTEhJrYT8NbcNkeaa8pzPM"; - Account lender{lenderPass, KeyType::ed25519}; - - env.fund(XRP(1'000'000), alice, lender, borrower); - env.close(); - env(noop(lender)); - env(noop(lender)); - env(noop(lender)); - env(noop(lender)); - env(noop(lender)); - env.close(); - - { - testcase("RPC AccountSet"); - Json::Value txJson{Json::objectValue}; - txJson[sfTransactionType] = "AccountSet"; - txJson[sfAccount] = borrower.human(); - - auto const signParams = [&]() { - Json::Value signParams{Json::objectValue}; - signParams[jss::passphrase] = borrowerPass; - signParams[jss::key_type] = "ed25519"; - signParams[jss::tx_json] = txJson; - return signParams; - }(); - auto const jSign = env.rpc("json", "sign", to_string(signParams)); - BEAST_EXPECT(jSign.isMember(jss::result) && jSign[jss::result].isMember(jss::tx_json)); - auto txSignResult = jSign[jss::result][jss::tx_json]; - auto txSignBlob = jSign[jss::result][jss::tx_blob].asString(); - txSignResult.removeMember(jss::hash); - - auto const jtx = env.jt(txJson, sig(borrower)); - BEAST_EXPECT(txSignResult == jtx.jv); - - lowerFee(); - auto const jSubmit = env.rpc("submit", txSignBlob); - BEAST_EXPECT( - jSubmit.isMember(jss::result) && jSubmit[jss::result].isMember(jss::engine_result) && - jSubmit[jss::result][jss::engine_result].asString() == "tesSUCCESS"); - - lowerFee(); - env(jtx.jv, sig(none), seq(none), fee(none), ter(tefPAST_SEQ)); - } - - { - testcase("RPC LoanSet - illegal signature_target"); - - Json::Value txJson{Json::objectValue}; - txJson[sfTransactionType] = "AccountSet"; - txJson[sfAccount] = borrower.human(); - - auto const borrowerSignParams = [&]() { - Json::Value params{Json::objectValue}; - params[jss::passphrase] = borrowerPass; - params[jss::key_type] = "ed25519"; - params[jss::signature_target] = "Destination"; - params[jss::tx_json] = txJson; - return params; - }(); - auto const jSignBorrower = env.rpc("json", "sign", to_string(borrowerSignParams)); - BEAST_EXPECT( - jSignBorrower.isMember(jss::result) && jSignBorrower[jss::result].isMember(jss::error) && - jSignBorrower[jss::result][jss::error] == "invalidParams" && - jSignBorrower[jss::result].isMember(jss::error_message) && - jSignBorrower[jss::result][jss::error_message] == "Destination"); - } - { - testcase("RPC LoanSet - sign and submit borrower initiated"); - // 1. Borrower creates the transaction - Json::Value txJson{Json::objectValue}; - txJson[sfTransactionType] = "LoanSet"; - txJson[sfAccount] = borrower.human(); - txJson[sfCounterparty] = lender.human(); - txJson[sfLoanBrokerID] = - "FF924CD18A236C2B49CF8E80A351CEAC6A10171DC9F110025646894FEC" - "F83F" - "5C"; - txJson[sfPrincipalRequested] = "100000000"; - txJson[sfPaymentTotal] = 10000; - txJson[sfPaymentInterval] = 3600; - txJson[sfGracePeriod] = 300; - txJson[sfFlags] = 65536; // tfLoanOverpayment - txJson[sfFee] = to_string(24 * baseFee / 10); - - // 2. Borrower signs the transaction - auto const borrowerSignParams = [&]() { - Json::Value params{Json::objectValue}; - params[jss::passphrase] = borrowerPass; - params[jss::key_type] = "ed25519"; - params[jss::tx_json] = txJson; - return params; - }(); - auto const jSignBorrower = env.rpc("json", "sign", to_string(borrowerSignParams)); - BEAST_EXPECTS( - jSignBorrower.isMember(jss::result) && jSignBorrower[jss::result].isMember(jss::tx_json), - to_string(jSignBorrower)); - auto const txBorrowerSignResult = jSignBorrower[jss::result][jss::tx_json]; - auto const txBorrowerSignBlob = jSignBorrower[jss::result][jss::tx_blob].asString(); - - // 2a. Borrower attempts to submit the transaction. It doesn't - // work - { - lowerFee(); - auto const jSubmitBlob = env.rpc("submit", txBorrowerSignBlob); - BEAST_EXPECT(jSubmitBlob.isMember(jss::result)); - auto const jSubmitBlobResult = jSubmitBlob[jss::result]; - BEAST_EXPECT(jSubmitBlobResult.isMember(jss::tx_json)); - // Transaction fails because the CounterpartySignature is - // missing - BEAST_EXPECT( - jSubmitBlobResult.isMember(jss::engine_result) && - jSubmitBlobResult[jss::engine_result].asString() == "temBAD_SIGNER"); - } - - // 3. Borrower sends the signed transaction to the lender - // 4. Lender signs the transaction - auto const lenderSignParams = [&]() { - Json::Value params{Json::objectValue}; - params[jss::passphrase] = lenderPass; - params[jss::key_type] = "ed25519"; - params[jss::signature_target] = "CounterpartySignature"; - params[jss::tx_json] = txBorrowerSignResult; - return params; - }(); - auto const jSignLender = env.rpc("json", "sign", to_string(lenderSignParams)); - BEAST_EXPECT(jSignLender.isMember(jss::result) && jSignLender[jss::result].isMember(jss::tx_json)); - auto const txLenderSignResult = jSignLender[jss::result][jss::tx_json]; - auto const txLenderSignBlob = jSignLender[jss::result][jss::tx_blob].asString(); - - // 5. Lender submits the signed transaction blob - lowerFee(); - auto const jSubmitBlob = env.rpc("submit", txLenderSignBlob); - BEAST_EXPECT(jSubmitBlob.isMember(jss::result)); - auto const jSubmitBlobResult = jSubmitBlob[jss::result]; - BEAST_EXPECT(jSubmitBlobResult.isMember(jss::tx_json)); - auto const jSubmitBlobTx = jSubmitBlobResult[jss::tx_json]; - // To get far enough to return tecNO_ENTRY means that the - // signatures all validated. Of course the transaction won't - // succeed because no Vault or Broker were created. - BEAST_EXPECTS( - jSubmitBlobResult.isMember(jss::engine_result) && - jSubmitBlobResult[jss::engine_result].asString() == "tecNO_ENTRY", - to_string(jSubmitBlobResult)); - - BEAST_EXPECT(!jSubmitBlob.isMember(jss::error) && !jSubmitBlobResult.isMember(jss::error)); - - // 4-alt. Lender submits the transaction json originally - // received from the Borrower. It gets signed, but is now a - // duplicate, so fails. Borrower could done this instead of - // steps 4 and 5. - lowerFee(); - auto const jSubmitJson = env.rpc("json", "submit", to_string(lenderSignParams)); - BEAST_EXPECT(jSubmitJson.isMember(jss::result)); - auto const jSubmitJsonResult = jSubmitJson[jss::result]; - BEAST_EXPECT(jSubmitJsonResult.isMember(jss::tx_json)); - auto const jSubmitJsonTx = jSubmitJsonResult[jss::tx_json]; - // Since the previous tx claimed a fee, this duplicate is not - // going anywhere - BEAST_EXPECTS( - jSubmitJsonResult.isMember(jss::engine_result) && - jSubmitJsonResult[jss::engine_result].asString() == "tefPAST_SEQ", - to_string(jSubmitJsonResult)); - - BEAST_EXPECT(!jSubmitJson.isMember(jss::error) && !jSubmitJsonResult.isMember(jss::error)); - - BEAST_EXPECT(jSubmitBlobTx == jSubmitJsonTx); - } - - { - testcase("RPC LoanSet - sign and submit lender initiated"); - // 1. Lender creates the transaction - Json::Value txJson{Json::objectValue}; - txJson[sfTransactionType] = "LoanSet"; - txJson[sfAccount] = lender.human(); - txJson[sfCounterparty] = borrower.human(); - txJson[sfLoanBrokerID] = - "FF924CD18A236C2B49CF8E80A351CEAC6A10171DC9F110025646894FEC" - "F83F" - "5C"; - txJson[sfPrincipalRequested] = "100000000"; - txJson[sfPaymentTotal] = 10000; - txJson[sfPaymentInterval] = 3600; - txJson[sfGracePeriod] = 300; - txJson[sfFlags] = 65536; // tfLoanOverpayment - txJson[sfFee] = to_string(24 * baseFee / 10); - - // 2. Lender signs the transaction - auto const lenderSignParams = [&]() { - Json::Value params{Json::objectValue}; - params[jss::passphrase] = lenderPass; - params[jss::key_type] = "ed25519"; - params[jss::tx_json] = txJson; - return params; - }(); - auto const jSignLender = env.rpc("json", "sign", to_string(lenderSignParams)); - BEAST_EXPECT(jSignLender.isMember(jss::result) && jSignLender[jss::result].isMember(jss::tx_json)); - auto const txLenderSignResult = jSignLender[jss::result][jss::tx_json]; - auto const txLenderSignBlob = jSignLender[jss::result][jss::tx_blob].asString(); - - // 2a. Lender attempts to submit the transaction. It doesn't - // work - { - lowerFee(); - auto const jSubmitBlob = env.rpc("submit", txLenderSignBlob); - BEAST_EXPECT(jSubmitBlob.isMember(jss::result)); - auto const jSubmitBlobResult = jSubmitBlob[jss::result]; - BEAST_EXPECT(jSubmitBlobResult.isMember(jss::tx_json)); - // Transaction fails because the CounterpartySignature is - // missing - BEAST_EXPECT( - jSubmitBlobResult.isMember(jss::engine_result) && - jSubmitBlobResult[jss::engine_result].asString() == "temBAD_SIGNER"); - } - - // 3. Lender sends the signed transaction to the Borrower - // 4. Borrower signs the transaction - auto const borrowerSignParams = [&]() { - Json::Value params{Json::objectValue}; - params[jss::passphrase] = borrowerPass; - params[jss::key_type] = "ed25519"; - params[jss::signature_target] = "CounterpartySignature"; - params[jss::tx_json] = txLenderSignResult; - return params; - }(); - auto const jSignBorrower = env.rpc("json", "sign", to_string(borrowerSignParams)); - BEAST_EXPECT(jSignBorrower.isMember(jss::result) && jSignBorrower[jss::result].isMember(jss::tx_json)); - auto const txBorrowerSignResult = jSignBorrower[jss::result][jss::tx_json]; - auto const txBorrowerSignBlob = jSignBorrower[jss::result][jss::tx_blob].asString(); - - // 5. Borrower submits the signed transaction blob - lowerFee(); - auto const jSubmitBlob = env.rpc("submit", txBorrowerSignBlob); - BEAST_EXPECT(jSubmitBlob.isMember(jss::result)); - auto const jSubmitBlobResult = jSubmitBlob[jss::result]; - BEAST_EXPECT(jSubmitBlobResult.isMember(jss::tx_json)); - auto const jSubmitBlobTx = jSubmitBlobResult[jss::tx_json]; - // To get far enough to return tecNO_ENTRY means that the - // signatures all validated. Of course the transaction won't - // succeed because no Vault or Broker were created. - BEAST_EXPECTS( - jSubmitBlobResult.isMember(jss::engine_result) && - jSubmitBlobResult[jss::engine_result].asString() == "tecNO_ENTRY", - to_string(jSubmitBlobResult)); - - BEAST_EXPECT(!jSubmitBlob.isMember(jss::error) && !jSubmitBlobResult.isMember(jss::error)); - - // 4-alt. Borrower submits the transaction json originally - // received from the Lender. It gets signed, but is now a - // duplicate, so fails. Lender could done this instead of steps - // 4 and 5. - lowerFee(); - auto const jSubmitJson = env.rpc("json", "submit", to_string(borrowerSignParams)); - BEAST_EXPECT(jSubmitJson.isMember(jss::result)); - auto const jSubmitJsonResult = jSubmitJson[jss::result]; - BEAST_EXPECT(jSubmitJsonResult.isMember(jss::tx_json)); - auto const jSubmitJsonTx = jSubmitJsonResult[jss::tx_json]; - // Since the previous tx claimed a fee, this duplicate is not - // going anywhere - BEAST_EXPECTS( - jSubmitJsonResult.isMember(jss::engine_result) && - jSubmitJsonResult[jss::engine_result].asString() == "tefPAST_SEQ", - to_string(jSubmitJsonResult)); - - BEAST_EXPECT(!jSubmitJson.isMember(jss::error) && !jSubmitJsonResult.isMember(jss::error)); - - BEAST_EXPECT(jSubmitBlobTx == jSubmitJsonTx); - } - } - - void - testServiceFeeOnBrokerDeepFreeze() - { - testcase << "Service Fee On Broker Deep Freeze"; - using namespace jtx; - using namespace loan; - Account const issuer("issuer"); - Account const borrower("borrower"); - Account const broker("broker"); auto const IOU = issuer["IOU"]; - for (bool const deepFreeze : {true, false}) - { - Env env(*this); - - auto getCoverBalance = [&](BrokerInfo const& brokerInfo, auto const& accountField) { - if (auto const le = env.le(keylet::loanbroker(brokerInfo.brokerID)); BEAST_EXPECT(le)) - { - auto const account = le->at(accountField); - if (auto const sleLine = env.le(keylet::line(account, IOU)); BEAST_EXPECT(sleLine)) - { - STAmount balance = sleLine->at(sfBalance); - if (account > issuer.id()) - balance.negate(); - return balance; - } - } - return STAmount{IOU}; - }; - - env.fund(XRP(20'000), issuer, broker, borrower); - env.close(); - - env(trust(broker, IOU(20'000'000))); - env(pay(issuer, broker, IOU(10'000'000))); - env.close(); - - auto const brokerInfo = createVaultAndBroker(env, IOU, broker); - - BEAST_EXPECT(getCoverBalance(brokerInfo, sfAccount) == IOU(1'000)); - - auto const keylet = keylet::loan(brokerInfo.brokerID, 1); - - env(set(borrower, brokerInfo.brokerID, 10'000), - sig(sfCounterpartySignature, broker), - loanServiceFee(IOU(100).value()), - paymentInterval(100), - fee(XRP(100))); - env.close(); - - env(trust(borrower, IOU(20'000'000))); - // The borrower increases their limit and acquires some IOU so - // they can pay interest - env(pay(issuer, borrower, IOU(500))); - env.close(); - - if (auto const le = env.le(keylet::loan(keylet.key)); BEAST_EXPECT(le)) - { - if (deepFreeze) - { - env(trust(issuer, broker["IOU"](0), tfSetFreeze | tfSetDeepFreeze)); - env.close(); - } - - env(pay(borrower, keylet.key, IOU(10'100)), fee(XRP(100))); - env.close(); - - if (deepFreeze) - { - // The fee goes to the broker pseudo-account - BEAST_EXPECT(getCoverBalance(brokerInfo, sfAccount) == IOU(1'100)); - BEAST_EXPECT(getCoverBalance(brokerInfo, sfOwner) == IOU(8'999'000)); - } - else - { - // The fee goes to the broker account - BEAST_EXPECT(getCoverBalance(brokerInfo, sfOwner) == IOU(8'999'100)); - BEAST_EXPECT(getCoverBalance(brokerInfo, sfAccount) == IOU(1'000)); - } - } - }; - } - - void - testIssuerLoan() - { - testcase << "Issuer Loan"; - - using namespace jtx; - using namespace loan; - Account const issuer("issuer"); - Account const borrower = issuer; - Account const lender("lender"); - Env env(*this); - - env.fund(XRP(1'000), issuer, lender); - - std::int64_t constexpr issuerBalance = 10'000'000; - MPTTester asset({.env = env, .issuer = issuer, .holders = {lender}, .pay = issuerBalance}); - - BrokerParameters const brokerParams{ - .debtMax = 200, - }; - auto const broker = createVaultAndBroker(env, asset, lender, brokerParams); - auto const loanSetFee = fee(env.current()->fees().base * 2); - // Create Loan - env(set(borrower, broker.brokerID, 200), sig(sfCounterpartySignature, lender), loanSetFee); - env.close(); - // Issuer should not create MPToken - BEAST_EXPECT(!env.le(keylet::mptoken(asset.issuanceID(), issuer))); - // Issuer "borrowed" 200, OutstandingAmount decreased by 200 - BEAST_EXPECT(env.balance(issuer, asset) == asset(-issuerBalance + 200)); - // Pay Loan - auto const loanKeylet = keylet::loan(broker.brokerID, 1); - env(pay(borrower, loanKeylet.key, asset(200))); - env.close(); - // Issuer "re-payed" 200, OutstandingAmount increased by 200 - BEAST_EXPECT(env.balance(issuer, asset) == asset(-issuerBalance)); - } - - void - testInvalidLoanDelete() - { - testcase("Invalid LoanDelete"); - using namespace jtx; - using namespace loan; - - // preflight: temINVALID, LoanID == zero - { - Account const alice{"alice"}; - Env env(*this); - env.fund(XRP(1'000), alice); - env.close(); - env(del(alice, beast::zero), ter(temINVALID)); - } - } - - void - testInvalidLoanManage() - { - testcase("Invalid LoanManage"); - using namespace jtx; - using namespace loan; - - // preflight: temINVALID, LoanID == zero - { - Account const alice{"alice"}; - Env env(*this); - env.fund(XRP(1'000), alice); - env.close(); - env(manage(alice, beast::zero, tfLoanDefault), ter(temINVALID)); - } - } - - void - testInvalidLoanPay() - { - testcase("Invalid LoanPay"); - using namespace jtx; - using namespace loan; - Account const lender{"lender"}; - Account const issuer{"issuer"}; - Account const borrower{"borrower"}; - auto const IOU = issuer["IOU"]; - - // preclaim - Env env(*this); - env.fund(XRP(1'000), lender, issuer, borrower); - env(trust(lender, IOU(10'000'000)), THISLINE); - env(pay(issuer, lender, IOU(5'000'000)), THISLINE); - BrokerInfo brokerInfo{createVaultAndBroker(env, issuer["IOU"], lender)}; - - auto const loanSetFee = fee(env.current()->fees().base * 2); - STAmount const debtMaximumRequest = brokerInfo.asset(1'000).value(); - - env(set(borrower, brokerInfo.brokerID, debtMaximumRequest), - sig(sfCounterpartySignature, lender), - loanSetFee, - THISLINE); - + env.fund(XRP(20'000), issuer, broker, borrower); env.close(); - std::uint32_t const loanSequence = 1; - auto const loanKeylet = keylet::loan(brokerInfo.brokerID, loanSequence); - - env(fset(issuer, asfGlobalFreeze), THISLINE); + env(trust(broker, IOU(20'000'000))); + env(pay(issuer, broker, IOU(10'000'000))); env.close(); - - // preclaim: tecFROZEN - env(pay(borrower, loanKeylet.key, debtMaximumRequest), ter(tecFROZEN), THISLINE); - env.close(); - - env(fclear(issuer, asfGlobalFreeze), THISLINE); - env.close(); - - auto const pseudoBroker = [&]() -> std::optional { - if (auto brokerSle = env.le(keylet::loanbroker(brokerInfo.brokerID)); BEAST_EXPECT(brokerSle)) - { - return Account{"pseudo", brokerSle->at(sfAccount)}; - } - else - { - return std::nullopt; - } - }(); - if (!pseudoBroker) - return; - - // Lender and pseudoaccount must both be frozen - env(trust(issuer, lender["IOU"](1'000), lender, tfSetFreeze | tfSetDeepFreeze), THISLINE); - env(trust(issuer, (*pseudoBroker)["IOU"](1'000), *pseudoBroker, tfSetFreeze | tfSetDeepFreeze), THISLINE); - env.close(); - - // preclaim: tecFROZEN due to deep frozen - env(pay(borrower, loanKeylet.key, debtMaximumRequest), ter(tecFROZEN), THISLINE); - env.close(); - - // Only one needs to be unfrozen - env(trust(issuer, lender["IOU"](1'000), tfClearFreeze | tfClearDeepFreeze), THISLINE); - env.close(); - - // The payment is late by this point - env(pay(borrower, loanKeylet.key, debtMaximumRequest), ter(tecEXPIRED), THISLINE); - env.close(); - env(pay(borrower, loanKeylet.key, debtMaximumRequest, tfLoanLatePayment), THISLINE); - env.close(); - - // preclaim: tecKILLED - // note that tecKILLED in loanMakePayment() - // doesn't happen because of the preclaim check. - env(pay(borrower, loanKeylet.key, debtMaximumRequest), ter(tecKILLED), THISLINE); - } - - void - testInvalidLoanSet() - { - testcase("Invalid LoanSet"); - using namespace jtx; - using namespace loan; - Account const lender{"lender"}; - Account const issuer{"issuer"}; - Account const borrower{"borrower"}; - auto const IOU = issuer["IOU"]; - - auto testWrapper = [&](auto&& test) { - Env env(*this); - env.fund(XRP(1'000), lender, issuer, borrower); - env(trust(lender, IOU(10'000'000))); - env(pay(issuer, lender, IOU(5'000'000))); - BrokerInfo brokerInfo{createVaultAndBroker(env, issuer["IOU"], lender)}; - - auto const loanSetFee = fee(env.current()->fees().base * 2); - Number const debtMaximumRequest = brokerInfo.asset(1'000).value(); - test(env, brokerInfo, loanSetFee, debtMaximumRequest); - }; - - // preflight: - testWrapper( - [&](Env& env, BrokerInfo const& brokerInfo, jtx::fee const& loanSetFee, Number const& debtMaximumRequest) { - // first temBAD_SIGNER: TODO - // invalid grace period - { - // zero grace period - env(set(borrower, brokerInfo.brokerID, debtMaximumRequest), - sig(sfCounterpartySignature, lender), - gracePeriod(0), - loanSetFee, - ter(temINVALID)); - - // grace period less than default minimum - env(set(borrower, brokerInfo.brokerID, debtMaximumRequest), - sig(sfCounterpartySignature, lender), - gracePeriod(LoanSet::defaultGracePeriod - 1), - loanSetFee, - ter(temINVALID)); - - // grace period greater than payment interval - env(set(borrower, brokerInfo.brokerID, debtMaximumRequest), - sig(sfCounterpartySignature, lender), - paymentInterval(120), - gracePeriod(121), - loanSetFee, - ter(temINVALID)); - } - // empty/zero broker ID - { - auto jv = set(borrower, uint256{}, debtMaximumRequest); - - auto testZeroBrokerID = [&](std::string const& id, std::uint32_t flags = 0) { - // empty broker ID - jv[sfLoanBrokerID] = id; - env(jv, sig(sfCounterpartySignature, lender), loanSetFee, txflags(flags), ter(temINVALID)); - }; - // empty broker ID - testZeroBrokerID(std::string("")); - // zero broker ID - // needs a flag to distinguish the parsed STTx from the prior - // test - testZeroBrokerID(to_string(uint256{}), tfFullyCanonicalSig); - } - - // preflightCheckSigningKey() failure: - // can it happen? the signature is checked before transactor - // executes - - JTx tx = env.jt( - set(borrower, brokerInfo.brokerID, debtMaximumRequest), - sig(sfCounterpartySignature, lender), - loanSetFee); - STTx local = *(tx.stx); - auto counterpartySig = local.getFieldObject(sfCounterpartySignature); - auto badPubKey = counterpartySig.getFieldVL(sfSigningPubKey); - badPubKey[20] ^= 0xAA; - counterpartySig.setFieldVL(sfSigningPubKey, badPubKey); - local.setFieldObject(sfCounterpartySignature, counterpartySig); - Json::Value jvResult; - jvResult[jss::tx_blob] = strHex(local.getSerializer().slice()); - auto res = env.rpc("json", "submit", to_string(jvResult))["result"]; - BEAST_EXPECT( - res[jss::error] == "invalidTransaction" && - res[jss::error_exception] == "fails local checks: Counterparty: Invalid signature."); - }); - - // preclaim: - testWrapper( - [&](Env& env, BrokerInfo const& brokerInfo, jtx::fee const& loanSetFee, Number const& debtMaximumRequest) { - // canAddHoldingFailure (IOU only, if MPT doesn't have - // MPTCanTransfer set, then can't create Vault/LoanBroker, - // and LoanSet will fail with different error - env(fclear(issuer, asfDefaultRipple)); - env.close(); - env(set(borrower, brokerInfo.brokerID, debtMaximumRequest), - sig(sfCounterpartySignature, lender), - loanSetFee, - ter(terNO_RIPPLE)); - }); - - // doApply: - testWrapper( - [&](Env& env, BrokerInfo const& brokerInfo, jtx::fee const& loanSetFee, Number const& debtMaximumRequest) { - auto const amt = env.balance(borrower) - env.current()->fees().accountReserve(env.ownerCount(borrower)); - env(pay(borrower, issuer, amt)); - - // tecINSUFFICIENT_RESERVE - env(set(borrower, brokerInfo.brokerID, debtMaximumRequest), - sig(sfCounterpartySignature, lender), - loanSetFee, - ter(tecINSUFFICIENT_RESERVE)); - - // addEmptyHolding failure - env(pay(issuer, borrower, amt)); - env(fset(issuer, asfGlobalFreeze)); - env.close(); - - env(set(borrower, brokerInfo.brokerID, debtMaximumRequest), - sig(sfCounterpartySignature, lender), - loanSetFee, - ter(tecFROZEN)); - }); - } - - void - testAccountSendMptMinAmountInvariant() - { - // (From FIND-006) - testcase << "LoanSet trigger xrpl::accountSendMPT : minimum amount " - "and MPT"; - - using namespace jtx; - using namespace std::chrono_literals; - Env env(*this, all); - - Account const issuer{"issuer"}; - Account const lender{"lender"}; - Account const borrower{"borrower"}; - - env.fund(XRP(1'000'000), issuer, lender, borrower); - env.close(); - - MPTTester mptt{env, issuer, mptInitNoFund}; - mptt.create({.flags = tfMPTCanClawback | tfMPTCanTransfer | tfMPTCanLock}); - PrettyAsset const mptAsset = mptt.issuanceID(); - mptt.authorize({.account = lender}); - mptt.authorize({.account = borrower}); - env(pay(issuer, lender, mptAsset(2'000'000))); - env(pay(issuer, borrower, mptAsset(1'000))); - env.close(); - - BrokerInfo broker{createVaultAndBroker(env, mptAsset, lender)}; - - using namespace loan; - - auto const loanSetFee = fee(env.current()->fees().base * 2); - Number const principalRequest{1, 3}; - - auto createJson = env.json( - set(borrower, broker.brokerID, principalRequest), - fee(loanSetFee), - json(sfCounterpartySignature, Json::objectValue)); - - createJson["CloseInterestRate"] = 76671; - createJson["ClosePaymentFee"] = "2061925410"; - createJson["GracePeriod"] = 434; - createJson["InterestRate"] = 50302; - createJson["LateInterestRate"] = 30322; - createJson["LatePaymentFee"] = "294427911"; - createJson["LoanOriginationFee"] = "3250635102"; - createJson["LoanServiceFee"] = "9557386"; - createJson["OverpaymentFee"] = 51249; - createJson["OverpaymentInterestRate"] = 14304; - createJson["PaymentInterval"] = 434; - createJson["PaymentTotal"] = "2891743748"; - createJson["PrincipalRequested"] = "8516.98"; - - auto const brokerStateBefore = env.le(keylet::loanbroker(broker.brokerID)); - - createJson = env.json(createJson, sig(sfCounterpartySignature, lender)); - env(createJson, ter(temINVALID)); - env.close(); - } - - void - testLoanPayDebtDecreaseInvariant() - { - // From FIND-007 - testcase << "LoanPay xrpl::LoanPay::doApply : debtDecrease " - "rounding good"; - - using namespace jtx; - using namespace std::chrono_literals; - using namespace Lending; - Env env(*this, 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 iouAsset = issuer[iouCurrency]; - auto trustLenderTx = env.json(trust(lender, iouAsset(1'000'000'000))); - env(trustLenderTx); - auto trustBorrowerTx = env.json(trust(borrower, iouAsset(1'000'000'000))); - env(trustBorrowerTx); - auto payLenderTx = pay(issuer, lender, iouAsset(100'000'000)); - env(payLenderTx); - auto payIssuerTx = pay(issuer, borrower, iouAsset(1'000'000)); - env(payIssuerTx); - env.close(); - - BrokerInfo broker{createVaultAndBroker(env, iouAsset, lender)}; - - using namespace loan; - - auto const baseFee = env.current()->fees().base; - auto const loanSetFee = fee(baseFee * 2); - Number const principalRequest{1, 3}; - - auto createJson = env.json( - set(borrower, broker.brokerID, principalRequest), - fee(loanSetFee), - json(sfCounterpartySignature, Json::objectValue)); - - createJson["ClosePaymentFee"] = "0"; - createJson["GracePeriod"] = 60; - createJson["InterestRate"] = 24346; - createJson["LateInterestRate"] = 65535; - createJson["LatePaymentFee"] = "0"; - createJson["LoanOriginationFee"] = "218"; - createJson["LoanServiceFee"] = "0"; - createJson["PaymentInterval"] = 60; - createJson["PaymentTotal"] = 5678; - createJson["PrincipalRequested"] = "9924.81"; - - auto const brokerStateBefore = env.le(keylet::loanbroker(broker.brokerID)); - auto const loanSequence = brokerStateBefore->at(sfLoanSequence); - auto const keylet = keylet::loan(broker.brokerID, loanSequence); - - createJson = env.json(createJson, sig(sfCounterpartySignature, lender)); - env(createJson, ter(tesSUCCESS)); - env.close(); - - auto const pseudoAcct = [&]() { - auto const brokerSle = env.le(keylet::loanbroker(broker.brokerID)); - if (!BEAST_EXPECT(brokerSle)) - return lender; - auto const brokerPseudo = brokerSle->at(sfAccount); - return Account("Broker pseudo-account", brokerPseudo); - }(); - - VerifyLoanStatus verifyLoanStatus(env, broker, pseudoAcct, keylet); - auto const originalState = getCurrentState(env, broker, keylet); - verifyLoanStatus(originalState); - - Number const payment{3'269'349'176'470'588, -12}; - XRPAmount const payFee{baseFee * ((payment / originalState.periodicPayment) / loanPaymentsPerFeeIncrement + 1)}; - auto loanPayTx = env.json(pay(borrower, keylet.key, STAmount{broker.asset, payment}), fee(payFee)); - BEAST_EXPECT(to_string(payment) == "3269.349176470588"); - env(loanPayTx, ter(tesSUCCESS)); - env.close(); - - auto const newState = getCurrentState(env, broker, keylet); - BEAST_EXPECT(isRounded(broker.asset, newState.managementFeeOutstanding, originalState.loanScale)); - BEAST_EXPECT(newState.managementFeeOutstanding < originalState.managementFeeOutstanding); - BEAST_EXPECT(isRounded(broker.asset, newState.totalValue, originalState.loanScale)); - BEAST_EXPECT(isRounded(broker.asset, newState.principalOutstanding, originalState.loanScale)); - } - - void - testLoanPayComputePeriodicPaymentValidTotalInterestInvariant() - { - // From FIND-010 - testcase << "xrpl::loanComputePaymentParts : valid total interest"; - - using namespace jtx; - using namespace std::chrono_literals; - Env env(*this, all); - - Account const issuer{"issuer"}; - Account const lender{"lender"}; - Account const borrower{"borrower"}; - - env.fund(XRP(1'000'000), issuer, lender, borrower); - env.close(); - - PrettyAsset const iouAsset = issuer[iouCurrency]; - auto trustLenderTx = env.json(trust(lender, iouAsset(1'000'000'000))); - env(trustLenderTx); - auto trustBorrowerTx = env.json(trust(borrower, iouAsset(1'000'000'000))); - env(trustBorrowerTx); - auto payLenderTx = pay(issuer, lender, iouAsset(100'000'000)); - env(payLenderTx); - auto payIssuerTx = pay(issuer, borrower, iouAsset(1'000'000)); - env(payIssuerTx); - env.close(); - - BrokerInfo broker{createVaultAndBroker(env, iouAsset, lender)}; - - using namespace loan; - - auto const loanSetFee = fee(env.current()->fees().base * 2); - Number const principalRequest{1, 3}; - - auto createJson = env.json( - set(borrower, broker.brokerID, principalRequest), - fee(loanSetFee), - json(sfCounterpartySignature, Json::objectValue)); - - createJson["CloseInterestRate"] = 47299; - createJson["ClosePaymentFee"] = "3985819770"; - createJson["InterestRate"] = 92; - createJson["LatePaymentFee"] = "3866894865"; - createJson["LoanOriginationFee"] = "0"; - createJson["LoanServiceFee"] = "2348810240"; - createJson["OverpaymentFee"] = 58545; - createJson["PaymentInterval"] = 60; - createJson["PaymentTotal"] = 1; - createJson["PrincipalRequested"] = "0.000763058"; - - auto const brokerStateBefore = env.le(keylet::loanbroker(broker.brokerID)); - auto const loanSequence = brokerStateBefore->at(sfLoanSequence); - auto const keylet = keylet::loan(broker.brokerID, loanSequence); - - createJson = env.json(createJson, sig(sfCounterpartySignature, lender)); - env(createJson, THISLINE); - env.close(); - - auto loanPayTx = env.json(pay(borrower, keylet.key, STAmount{broker.asset, Number{}})); - loanPayTx["Amount"]["value"] = "0.000281284125490196"; - env(loanPayTx, ter(tecINSUFFICIENT_PAYMENT), THISLINE); - env.close(); - } - - void - testDosLoanPay() - { - // From FIND-005 - testcase << "DoS LoanPay"; - - using namespace jtx; - using namespace std::chrono_literals; - using namespace Lending; - 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 iouAsset = issuer[iouCurrency]; - env(trust(lender, iouAsset(100'000'000))); - env(trust(borrower, iouAsset(100'000'000))); - env(pay(issuer, lender, iouAsset(10'000'000))); - env(pay(issuer, borrower, iouAsset(1'000))); - env.close(); - - BrokerInfo broker{createVaultAndBroker(env, iouAsset, lender)}; - - using namespace loan; - - auto const loanSetFee = fee(env.current()->fees().base * 2); - Number const principalRequest{1, 3}; - auto const baseFee = env.current()->fees().base; - - auto createJson = env.json( - set(borrower, broker.brokerID, principalRequest), - fee(loanSetFee), - json(sfCounterpartySignature, Json::objectValue)); - - createJson["ClosePaymentFee"] = "0"; - createJson["GracePeriod"] = 60; - createJson["InterestRate"] = 20930; - createJson["LateInterestRate"] = 77049; - createJson["LatePaymentFee"] = "0"; - createJson["LoanServiceFee"] = "0"; - createJson["OverpaymentFee"] = 7; - createJson["OverpaymentInterestRate"] = 66653; - createJson["PaymentInterval"] = 60; - createJson["PaymentTotal"] = 3239184; - createJson["PrincipalRequested"] = "3959.37"; - - auto const brokerStateBefore = env.le(keylet::loanbroker(broker.brokerID)); - auto const loanSequence = brokerStateBefore->at(sfLoanSequence); - auto const keylet = keylet::loan(broker.brokerID, loanSequence); - - createJson = env.json(createJson, sig(sfCounterpartySignature, lender)); - env(createJson, ter(tesSUCCESS)); - env.close(); - - auto const stateBefore = getCurrentState(env, broker, keylet); - BEAST_EXPECT(stateBefore.paymentRemaining == 3239184); - BEAST_EXPECT(stateBefore.paymentRemaining > loanMaximumPaymentsPerTransaction); - - auto loanPayTx = env.json(pay(borrower, keylet.key, STAmount{broker.asset, Number{}})); - Number const amount{395937, -2}; - loanPayTx["Amount"]["value"] = to_string(amount); - XRPAmount const payFee{ - baseFee * std::int64_t(amount / stateBefore.periodicPayment / loanPaymentsPerFeeIncrement + 1)}; - env(loanPayTx, ter(tesSUCCESS), fee(payFee)); - env.close(); - - auto const stateAfter = getCurrentState(env, broker, keylet); - BEAST_EXPECT(stateAfter.paymentRemaining == stateBefore.paymentRemaining - loanMaximumPaymentsPerTransaction); - } - - void - testLoanPayComputePeriodicPaymentValidTotalPrincipalPaidInvariant() - { - // From FIND-009 - testcase << "xrpl::loanComputePaymentParts : totalPrincipalPaid " - "rounded"; - - using namespace jtx; - using namespace std::chrono_literals; - using namespace Lending; - 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 iouAsset = issuer[iouCurrency]; - auto trustLenderTx = env.json(trust(lender, iouAsset(1'000'000'000))); - env(trustLenderTx); - auto trustBorrowerTx = env.json(trust(borrower, iouAsset(1'000'000'000))); - env(trustBorrowerTx); - auto payLenderTx = pay(issuer, lender, iouAsset(100'000'000)); - env(payLenderTx); - auto payIssuerTx = pay(issuer, borrower, iouAsset(1'000'000)); - env(payIssuerTx); - env.close(); - - BrokerInfo broker{createVaultAndBroker(env, iouAsset, lender)}; - - using namespace loan; - - auto const loanSetFee = fee(env.current()->fees().base * 2); - Number const principalRequest{1, 3}; - - auto createJson = env.json( - set(borrower, broker.brokerID, principalRequest), - fee(loanSetFee), - json(sfCounterpartySignature, Json::objectValue)); - - createJson["ClosePaymentFee"] = "0"; - createJson["InterestRate"] = 24346; - createJson["LateInterestRate"] = 65535; - createJson["LatePaymentFee"] = "0"; - createJson["LoanOriginationFee"] = "218"; - createJson["LoanServiceFee"] = "0"; - createJson["PaymentInterval"] = 60; - createJson["PaymentTotal"] = 5678; - createJson["PrincipalRequested"] = "9924.81"; - - auto const brokerStateBefore = env.le(keylet::loanbroker(broker.brokerID)); - auto const loanSequence = brokerStateBefore->at(sfLoanSequence); - auto const keylet = keylet::loan(broker.brokerID, loanSequence); - - createJson = env.json(createJson, sig(sfCounterpartySignature, lender)); - env(createJson, ter(tesSUCCESS)); - env.close(); - - auto const baseFee = env.current()->fees().base; - - auto const stateBefore = getCurrentState(env, broker, keylet); - - { - auto loanPayTx = env.json(pay(borrower, keylet.key, STAmount{broker.asset, Number{}})); - Number const amount{3074'745'058'823'529, -12}; - BEAST_EXPECT(to_string(amount) == "3074.745058823529"); - XRPAmount const payFee{baseFee * (amount / stateBefore.periodicPayment / loanPaymentsPerFeeIncrement + 1)}; - loanPayTx["Amount"]["value"] = to_string(amount); - env(loanPayTx, fee(payFee), ter(tesSUCCESS)); - env.close(); - } - - { - auto loanPayTx = env.json(pay(borrower, keylet.key, STAmount{broker.asset, Number{}})); - Number const amount{6732'118'170'944'051, -12}; - BEAST_EXPECT(to_string(amount) == "6732.118170944051"); - XRPAmount const payFee{baseFee * (amount / stateBefore.periodicPayment / loanPaymentsPerFeeIncrement + 1)}; - loanPayTx["Amount"]["value"] = to_string(amount); - env(loanPayTx, fee(payFee), ter(tesSUCCESS)); - env.close(); - } - - auto const stateAfter = getCurrentState(env, broker, keylet); - // Total interest outstanding is non-negative - BEAST_EXPECT(stateAfter.totalValue >= stateAfter.principalOutstanding); - // Principal paid is non-negative - BEAST_EXPECT(stateBefore.principalOutstanding >= stateAfter.principalOutstanding); - // Total value change is non-negative - BEAST_EXPECT(stateBefore.totalValue >= stateAfter.totalValue); - // Value delta is larger or same as principal delta (meaning - // non-negative interest paid) - BEAST_EXPECT( - (stateBefore.totalValue - stateAfter.totalValue) >= - (stateBefore.principalOutstanding - stateAfter.principalOutstanding)); - } - - void - testLoanPayComputePeriodicPaymentValidTotalInterestPaidInvariant() - { - // From FIND-008 - testcase << "xrpl::loanComputePaymentParts : loanValueChange rounded"; - - using namespace jtx; - using namespace std::chrono_literals; - using namespace Lending; - 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 iouAsset = issuer[iouCurrency]; - auto trustLenderTx = env.json(trust(lender, iouAsset(1'000'000'000))); - env(trustLenderTx); - auto trustBorrowerTx = env.json(trust(borrower, iouAsset(1'000'000'000))); - env(trustBorrowerTx); - auto payLenderTx = pay(issuer, lender, iouAsset(100'000'000)); - env(payLenderTx); - auto payIssuerTx = pay(issuer, borrower, iouAsset(10'000'000)); - env(payIssuerTx); - env.close(); - - BrokerInfo broker{createVaultAndBroker(env, iouAsset, lender)}; - { - auto const coverDepositValue = broker.asset(broker.params.coverDeposit * 10).value(); - env(loanBroker::coverDeposit(lender, broker.brokerID, coverDepositValue)); - env.close(); - } - - using namespace loan; - - auto const loanSetFee = fee(env.current()->fees().base * 2); - Number const principalRequest{1, 3}; - - auto createJson = env.json( - set(borrower, broker.brokerID, principalRequest), - fee(loanSetFee), - json(sfCounterpartySignature, Json::objectValue)); - - createJson["ClosePaymentFee"] = "0"; - createJson["InterestRate"] = 12833; - createJson["LateInterestRate"] = 77048; - createJson["LatePaymentFee"] = "0"; - createJson["LoanOriginationFee"] = "218"; - createJson["LoanServiceFee"] = "0"; - createJson["PaymentInterval"] = 752; - createJson["PaymentTotal"] = 5678; - createJson["PrincipalRequested"] = "9924.81"; - - auto const brokerStateBefore = env.le(keylet::loanbroker(broker.brokerID)); - auto const loanSequence = brokerStateBefore->at(sfLoanSequence); - auto const keylet = keylet::loan(broker.brokerID, loanSequence); - - createJson = env.json(createJson, sig(sfCounterpartySignature, lender)); - env(createJson, ter(tesSUCCESS)); - env.close(); - - auto const baseFee = env.current()->fees().base; - - auto const stateBefore = getCurrentState(env, broker, keylet); - BEAST_EXPECT(stateBefore.paymentRemaining == 5678); - BEAST_EXPECT(stateBefore.paymentRemaining > loanMaximumPaymentsPerTransaction); - - auto loanPayTx = env.json(pay(borrower, keylet.key, STAmount{broker.asset, Number{}})); - Number const amount{9924'81, -2}; - BEAST_EXPECT(to_string(amount) == "9924.81"); - XRPAmount const payFee{baseFee * (amount / stateBefore.periodicPayment / loanPaymentsPerFeeIncrement + 1)}; - loanPayTx["Amount"]["value"] = to_string(amount); - env(loanPayTx, fee(payFee), ter(tesSUCCESS)); - env.close(); - - auto const stateAfter = getCurrentState(env, broker, keylet); - BEAST_EXPECT(stateAfter.paymentRemaining == stateBefore.paymentRemaining - loanMaximumPaymentsPerTransaction); - } - - void - testLoanNextPaymentDueDateOverflow() - { - // For FIND-013 - testcase << "Prevent nextPaymentDueDate overflow"; - - using namespace jtx; - using namespace std::chrono_literals; - using namespace Lending; - 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 iouAsset = issuer[iouCurrency]; - auto trustLenderTx = env.json(trust(lender, iouAsset(1'000'000'000))); - env(trustLenderTx); - auto trustBorrowerTx = env.json(trust(borrower, iouAsset(1'000'000'000))); - env(trustBorrowerTx); - auto payLenderTx = pay(issuer, lender, iouAsset(100'000'000)); - env(payLenderTx); - auto payIssuerTx = pay(issuer, borrower, iouAsset(10'000'000)); - env(payIssuerTx); - env.close(); - - BrokerParameters const brokerParams{.debtMax = Number{0}, .coverRateMin = TenthBips32{1}}; - BrokerInfo broker{createVaultAndBroker(env, iouAsset, lender, brokerParams)}; - - using namespace loan; - - auto const loanSetFee = fee(env.current()->fees().base * 2); - - using timeType = decltype(sfNextPaymentDueDate)::type::value_type; - static_assert(std::is_same_v); - timeType constexpr maxTime = std::numeric_limits::max(); - static_assert(maxTime == 4'294'967'295); - - auto const baseJson = [&]() { - auto createJson = env.json( - set(borrower, broker.brokerID, Number{55524'81, -2}), - fee(loanSetFee), - closePaymentFee(0), - gracePeriod(LoanSet::defaultGracePeriod), - interestRate(TenthBips32(12833)), - lateInterestRate(TenthBips32(77048)), - latePaymentFee(0), - loanOriginationFee(218), - json(sfCounterpartySignature, Json::objectValue)); - - createJson.removeMember(sfSequence.getJsonName()); - - return createJson; - }(); - - auto const baseFee = env.current()->fees().base; - - auto parentCloseTime = [&]() { return env.current()->parentCloseTime().time_since_epoch().count(); }; - auto maxLoanTime = [&]() { - auto const startDate = parentCloseTime(); - - BEAST_EXPECT(startDate >= 50); - - return maxTime - startDate; - }; - - { - // straight-up overflow: interval - auto const interval = maxLoanTime() + 1; - auto const total = 1; - auto createJson = env.json(baseJson, paymentInterval(interval), paymentTotal(total)); - - env(createJson, sig(sfCounterpartySignature, lender), ter(tecKILLED)); - env.close(); - } - { - // straight-up overflow: total - // min interval is 60 - auto const interval = 60; - auto const total = maxLoanTime() + 1; - auto createJson = env.json(baseJson, paymentInterval(interval), paymentTotal(total)); - - env(createJson, sig(sfCounterpartySignature, lender), ter(tecKILLED)); - env.close(); - } - { - // straight-up overflow: grace period - // min interval is 60 - auto const interval = maxLoanTime() + 1; - auto const total = 1; - auto const grace = interval; - auto createJson = env.json(baseJson, paymentInterval(interval), paymentTotal(total), gracePeriod(grace)); - - // The grace period can't be larger than the interval. - env(createJson, sig(sfCounterpartySignature, lender), ter(tecKILLED)); - env.close(); - } - { - // Overflow with multiplication of a few large intervals - auto const interval = 1'000'000'000; - auto const total = 10; - auto createJson = env.json(baseJson, paymentInterval(interval), paymentTotal(total)); - - env(createJson, sig(sfCounterpartySignature, lender), ter(tecKILLED)); - env.close(); - } - { - // Overflow with multiplication of many small payments - // min interval is 60 - auto const interval = 60; - auto const total = 1'000'000'000; - auto createJson = env.json(baseJson, paymentInterval(interval), paymentTotal(total)); - - env(createJson, sig(sfCounterpartySignature, lender), ter(tecKILLED)); - env.close(); - } - { - // Overflow with an absurdly large grace period - // min interval is 60 - auto const total = 60; - auto const interval = (maxLoanTime() - total) / total; - auto const grace = interval; - auto createJson = env.json(baseJson, paymentInterval(interval), paymentTotal(total), gracePeriod(grace)); - - env(createJson, sig(sfCounterpartySignature, lender), ter(tecKILLED)); - env.close(); - } - { - // Start date when the ledger is closed will be larger - auto const brokerStateBefore = env.le(keylet::loanbroker(broker.brokerID)); - auto const loanSequence = brokerStateBefore->at(sfLoanSequence); - auto const keylet = keylet::loan(broker.brokerID, loanSequence); - - auto const grace = 100; - auto const interval = maxLoanTime() - grace; - auto const total = 1; - auto createJson = env.json(baseJson, paymentInterval(interval), paymentTotal(total), gracePeriod(grace)); - - env(createJson, sig(sfCounterpartySignature, lender), ter(tesSUCCESS)); - env.close(); - - // The transaction is killed in the closed ledger - auto const meta = env.meta(); - if (BEAST_EXPECT(meta)) - { - BEAST_EXPECT(meta->at(sfTransactionResult) == tecKILLED); - } - - // If the transaction had succeeded, the loan would exist - auto const loanSle = env.le(keylet); - // but it doesn't - BEAST_EXPECT(!loanSle); - } - { - // Start date when the ledger is closed will be larger - auto const brokerStateBefore = env.le(keylet::loanbroker(broker.brokerID)); - auto const loanSequence = brokerStateBefore->at(sfLoanSequence); - auto const keylet = keylet::loan(broker.brokerID, loanSequence); - - auto const closeStartDate = (parentCloseTime() / 10 + 1) * 10; - auto const grace = 5'000; - auto const interval = maxTime - closeStartDate - grace; - auto const total = 1; - auto createJson = env.json(baseJson, paymentInterval(interval), paymentTotal(total), gracePeriod(grace)); - - env(createJson, sig(sfCounterpartySignature, lender), ter(tesSUCCESS)); - env.close(); - - // The transaction succeeds in the closed ledger - auto const meta = env.meta(); - if (BEAST_EXPECT(meta)) - { - BEAST_EXPECT(meta->at(sfTransactionResult) == tesSUCCESS); - } - - // This loan exists - auto const afterState = getCurrentState(env, broker, keylet); - BEAST_EXPECT(afterState.nextPaymentDate == maxTime - grace); - BEAST_EXPECT(afterState.previousPaymentDate == 0); - BEAST_EXPECT(afterState.paymentRemaining == 1); - } - - { - // Ensure the borrower has funds to pay back the loan - env(pay(issuer, borrower, iouAsset(Number{1'055'524'81, -2}))); - - // Start date when the ledger is closed will be larger - auto const closeStartDate = (parentCloseTime() / 10 + 1) * 10; - auto const grace = 5'000; - auto const maxLoanTime = maxTime - closeStartDate - grace; - auto const total = [&]() { - if (maxLoanTime % 5 == 0) - return 5; - if (maxLoanTime % 3 == 0) - return 3; - if (maxLoanTime % 2 == 0) - return 2; - return 0; - }(); - if (!BEAST_EXPECT(total != 0)) - return; - - auto const brokerState = env.le(keylet::loanbroker(broker.brokerID)); - // Intentionally shadow the outer values - auto const loanSequence = brokerState->at(sfLoanSequence); - auto const keylet = keylet::loan(broker.brokerID, loanSequence); - - auto const interval = maxLoanTime / total; - auto createJson = env.json(baseJson, paymentInterval(interval), paymentTotal(total), gracePeriod(grace)); - - env(createJson, sig(sfCounterpartySignature, lender), ter(tesSUCCESS)); - env.close(); - - // This loan exists - auto const beforeState = getCurrentState(env, broker, keylet); - BEAST_EXPECT(beforeState.nextPaymentDate == closeStartDate + interval); - BEAST_EXPECT(beforeState.previousPaymentDate == 0); - BEAST_EXPECT(beforeState.paymentRemaining == total); - BEAST_EXPECT(beforeState.periodicPayment > 0); - - // pay all but the last payment - { - NumberRoundModeGuard mg{Number::upward}; - Number const payment = beforeState.periodicPayment * (total - 1); - XRPAmount const payFee{baseFee * ((total - 1) / loanPaymentsPerFeeIncrement + 1)}; - STAmount const paymentAmount = roundToScale(STAmount{broker.asset, payment}, beforeState.loanScale); - auto loanPayTx = env.json(pay(borrower, keylet.key, paymentAmount), fee(payFee)); - env(loanPayTx, ter(tesSUCCESS)); - env.close(); - } - - // The loan is on the last payment - auto const afterState = getCurrentState(env, broker, keylet); - BEAST_EXPECT(afterState.paymentRemaining == 1); - BEAST_EXPECT(afterState.nextPaymentDate == maxTime - grace); - BEAST_EXPECT(afterState.previousPaymentDate == maxTime - grace - interval); - } - } - - void - testRequireAuth() - { - testcase("Require Auth - Implicit Pseudo-account authorization"); - using namespace jtx; - using namespace loan; - Account const lender{"lender"}; - Account const issuer{"issuer"}; - Account const borrower{"borrower"}; - Env env(*this); - - env.fund(XRP(100'000), issuer, lender, borrower); - env.close(); - - auto asset = MPTTester({ - .env = env, - .issuer = issuer, - .holders = {lender, borrower}, - .flags = MPTDEXFlags | tfMPTRequireAuth | tfMPTCanClawback | tfMPTCanLock, - .authHolder = true, - }); - - env(pay(issuer, lender, asset(5'000'000))); - BrokerInfo brokerInfo{createVaultAndBroker(env, asset, lender)}; - - auto const loanSetFee = fee(env.current()->fees().base * 2); - STAmount const debtMaximumRequest = brokerInfo.asset(1'000).value(); - - auto forUnauthAuth = [&](auto&& doTx) { - for (auto const flag : {tfMPTUnauthorize, 0u}) - { - asset.authorize({.account = issuer, .holder = borrower, .flags = flag}); - env.close(); - doTx(flag == 0); - env.close(); - } - }; - - // Can't create a loan if the borrower is not authorized - forUnauthAuth([&](bool authorized) { - auto const err = !authorized ? ter(tecNO_AUTH) : ter(tesSUCCESS); - env(set(borrower, brokerInfo.brokerID, debtMaximumRequest), - sig(sfCounterpartySignature, lender), - loanSetFee, - err); - }); - - std::uint32_t constexpr loanSequence = 1; - auto const loanKeylet = keylet::loan(brokerInfo.brokerID, loanSequence); - - // Can't loan pay if the borrower is not authorized - forUnauthAuth([&](bool authorized) { - auto const err = !authorized ? ter(tecNO_AUTH) : ter(tesSUCCESS); - env(pay(borrower, loanKeylet.key, debtMaximumRequest), err); - }); - } - - void - testCoverDepositWithdrawNonTransferableMPT() - { - testcase("CoverDeposit and CoverWithdraw reject 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["MPT"]; - 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(); - - // Cover cannot be transferred to broker account - auto const depositAmount = asset(1); - env(coverDeposit(alice, brokerKeylet.key, depositAmount), ter{tecNO_AUTH}); - env.close(); - - if (auto const refreshed = env.le(brokerKeylet); BEAST_EXPECT(refreshed)) - { - BEAST_EXPECT(refreshed->at(sfCoverAvailable) == 0); - env.require(balance(pseudoAccount, asset(0))); - } - - // Set CanTransfer again and transfer some deposit - mpt.set({.mutableFlags = tmfMPTSetCanTransfer}); - env.close(); - - env(coverDeposit(alice, brokerKeylet.key, depositAmount)); - env.close(); - - if (auto const refreshed = env.le(brokerKeylet); BEAST_EXPECT(refreshed)) - { - BEAST_EXPECT(refreshed->at(sfCoverAvailable) == 1); - env.require(balance(pseudoAccount, depositAmount)); - } - - // Remove CanTransfer after the deposit - mpt.set({.mutableFlags = tmfMPTClearCanTransfer}); - env.close(); - - // Cover cannot be transferred from broker account - env(coverWithdraw(alice, brokerKeylet.key, depositAmount), ter{tecNO_AUTH}); - env.close(); - - // Set CanTransfer again and withdraw - mpt.set({.mutableFlags = tmfMPTSetCanTransfer}); - env.close(); - - env(coverWithdraw(alice, brokerKeylet.key, depositAmount)); - env.close(); - - if (auto const refreshed = env.le(brokerKeylet); BEAST_EXPECT(refreshed)) - { - BEAST_EXPECT(refreshed->at(sfCoverAvailable) == 0); - env.require(balance(pseudoAccount, asset(0))); - } - } - - void - testPoC_UnsignedUnderflowOnFullPayAfterEarlyPeriodic() - { - // --- PoC Summary ---------------------------------------------------- - // Scenario: Borrower makes one periodic payment early (before next due) - // so doPayment sets sfPreviousPaymentDueDate to the (future) - // sfNextPaymentDueDate and advances sfNextPaymentDueDate by one - // interval. Borrower then immediately performs a full-payment - // (tfLoanFullPayment). Why it matters: Full-payment interest accrual - // uses - // delta = now - max(prevPaymentDate, startDate) - // with an unsigned clock representation (uint32). If prevPaymentDate is - // in the future, the subtraction underflows to a very large positive - // number. This inflates roundedFullInterest and total full-close due, - // and LoanPay applies the inflated valueChange to the vault - // (sfAssetsTotal), increasing NAV. - // -------------------------------------------------------------------- - testcase("PoC: Unsigned-underflow full-pay accrual after early periodic"); - - using namespace jtx; - using namespace loan; - using namespace std::chrono_literals; - - Env env(*this, 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 = computeFullPaymentInterest( - detail::loanPrincipalFromPeriodicPayment(after.periodicPayment, periodicRate2, after.paymentRemaining), - periodicRate2, - env.current()->parentCloseTime(), - after.paymentInterval, - after.previousPaymentDate, - static_cast(after.startDate.time_since_epoch().count()), - closeInterestRate); - - // Round to asset scale and split interest/fee parts - auto const roundedInterest = roundToAsset(asset.raw(), fullPaymentInterest, after.loanScale); - Number const roundedFullMgmtFee = - computeManagementFee(asset.raw(), roundedInterest, managementFeeRate, after.loanScale); - Number const roundedFullInterest = roundedInterest - roundedFullMgmtFee; - - // Show both signed and unsigned deltas to highlight the underflow. - auto const nowSecs = static_cast(env.current()->parentCloseTime().time_since_epoch().count()); - auto const startSecs = static_cast(after.startDate.time_since_epoch().count()); - auto const lastPaymentDate = std::max(after.previousPaymentDate, startSecs); - auto const signedDelta = static_cast(nowSecs) - static_cast(lastPaymentDate); - auto const unsignedDelta = static_cast(nowSecs - lastPaymentDate); - log << "PoC window: prev=" << after.previousPaymentDate << " start=" << startSecs << " now=" << nowSecs - << " signedDelta=" << signedDelta << " unsignedDelta=" << unsignedDelta << std::endl; - - // Reference (clamped) computation: emulate a non-negative accrual - // window by clamping prevPaymentDate to 'now' for the full-pay path. - auto const prevClamped = std::min(after.previousPaymentDate, nowSecs); - auto const fullPaymentInterestClamped = computeFullPaymentInterest( - detail::loanPrincipalFromPeriodicPayment(after.periodicPayment, periodicRate2, after.paymentRemaining), - periodicRate2, - env.current()->parentCloseTime(), - after.paymentInterval, - prevClamped, - startSecs, - closeInterestRate); - auto const roundedInterestClamped = roundToAsset(asset.raw(), fullPaymentInterestClamped, after.loanScale); - Number const roundedFullMgmtFeeClamped = - computeManagementFee(asset.raw(), roundedInterestClamped, managementFeeRate, after.loanScale); - Number const roundedFullInterestClamped = roundedInterestClamped - roundedFullMgmtFeeClamped; - STAmount const fullDueClamped{ - asset, - after.principalOutstanding + roundedFullInterestClamped + roundedFullMgmtFeeClamped + closePaymentFee}; - - // Collect vault NAV before closing payment - auto const vaultId2 = brokerSle2 ? brokerSle2->at(sfVaultID) : uint256{}; - auto const vaultKey2 = keylet::vault(vaultId2); - auto const vaultBefore = env.le(vaultKey2); - BEAST_EXPECT(vaultBefore); - Number const assetsTotalBefore = vaultBefore ? vaultBefore->at(sfAssetsTotal) : Number{}; - - STAmount const fullDue{ - asset, after.principalOutstanding + roundedFullInterest + roundedFullMgmtFee + closePaymentFee}; - - log << "PoC payoff: principalOutstanding=" << after.principalOutstanding - << " roundedFullInterest=" << roundedFullInterest << " roundedFullMgmtFee=" << roundedFullMgmtFee - << " closeFee=" << closePaymentFee << " fullDue=" << to_string(fullDue.getJson()) << std::endl; - log << "PoC reference (clamped): roundedFullInterestClamped=" << roundedFullInterestClamped - << " roundedFullMgmtFeeClamped=" << roundedFullMgmtFeeClamped - << " fullDueClamped=" << to_string(fullDueClamped.getJson()) << std::endl; - - env(pay(borrower, loanKeylet.key, fullDue), txflags(tfLoanFullPayment)); - env.close(); - - // Sanity: underflow present (unsigned delta very large relative to - // interval) - BEAST_EXPECT(unsignedDelta > after.paymentInterval); - - // Compare vault NAV before/after the full close - auto const vaultAfter = env.le(vaultKey2); - BEAST_EXPECT(vaultAfter); - if (vaultAfter) - { - auto const assetsTotalAfter = vaultAfter->at(sfAssetsTotal); - log << "PoC NAV: assetsTotalBefore=" << assetsTotalBefore << " assetsTotalAfter=" << assetsTotalAfter - << " delta=" << (assetsTotalAfter - assetsTotalBefore) << std::endl; - - // Value-based proof: underflowed window yields a payoff larger than - // the clamped (non-underflow) reference. - BEAST_EXPECT(fullDue == fullDueClamped); - if (fullDue > fullDueClamped) - log << "PoC delta: overcharge (fullDue > clamped)" << std::endl; - } - - // Loan should be paid off - auto const finalLoan = env.le(loanKeylet); - BEAST_EXPECT(finalLoan); - if (finalLoan) - { - BEAST_EXPECT(finalLoan->at(sfPaymentRemaining) == 0); - BEAST_EXPECT(finalLoan->at(sfPrincipalOutstanding) == 0); - } - } - - void - testDustManipulation() - { - testcase("Dust manipulation"); - - using namespace jtx; - using namespace std::chrono_literals; - Env env(*this, all); - - // Setup: Create accounts - Account issuer{"issuer"}; - Account lender{"lender"}; - Account borrower{"borrower"}; - Account victim{"victim"}; - - env.fund(XRP(1'000'000'00), issuer, lender, borrower, victim); - env.close(); - - // Step 1: Create vault with IOU asset - auto asset = issuer["USD"]; - env(trust(lender, asset(100000))); - env(trust(borrower, asset(100000))); - env(trust(victim, asset(100000))); - env(pay(issuer, lender, asset(50000))); - env(pay(issuer, borrower, asset(50000))); - env(pay(issuer, victim, asset(50000))); - env.close(); - - BrokerParameters brokerParams{ - .vaultDeposit = 10000, - .debtMax = Number{0}, - .coverRateMin = TenthBips32{1000}, - .coverRateLiquidation = TenthBips32{2500}}; - - auto broker = createVaultAndBroker(env, asset, lender, brokerParams); - - auto const loanKeyletOpt = [&]() -> std::optional { - auto const brokerSle = env.le(keylet::loanbroker(broker.brokerID)); - if (!BEAST_EXPECT(brokerSle)) - return std::nullopt; - - // Broker has no loans - BEAST_EXPECT(brokerSle->at(sfOwnerCount) == 0); - - // The loan keylet is based on the LoanSequence of the - // _LOAN_BROKER_ object. - auto const loanSequence = brokerSle->at(sfLoanSequence); - return keylet::loan(broker.brokerID, loanSequence); - }(); - if (!loanKeyletOpt) - return; - - auto const& vaultKeylet = broker.vaultKeylet(); - - { - auto const vaultSle = env.le(vaultKeylet); - Number assetsTotal = vaultSle->at(sfAssetsTotal); - Number assetsAvail = vaultSle->at(sfAssetsAvailable); - - log << "Before loan creation:" << std::endl; - log << " AssetsTotal: " << assetsTotal << std::endl; - log << " AssetsAvailable: " << assetsAvail << std::endl; - log << " Difference: " << (assetsTotal - assetsAvail) << std::endl; - - // before the loan the assets total and available should be equal - BEAST_EXPECT(assetsAvail == assetsTotal); - BEAST_EXPECT(assetsAvail == broker.asset(brokerParams.vaultDeposit).number()); - } - - Keylet const& loanKeylet = *loanKeyletOpt; + auto brokerInfo = createVaultAndBroker(env, IOU, broker); LoanParameters const loanParams{ - .account = lender, + .account = broker, .counter = borrower, .principalRequest = Number{100}, .interest = TenthBips32{1922}, @@ -5387,1131 +136,578 @@ protected: .gracePd = 86400 * 5, }; - env(loanParams(env, broker)); + auto loanSetTx = loanParams.getTransaction(env, brokerInfo); + + // Get the loan keylet that will be created by the LoanSet + auto const brokerSeq = env.seq(broker); + + // The loan keylet is based on the broker's LoanSequence + auto const brokerSle = env.le(keylet::loanbroker(brokerInfo.brokerID)); + auto const loanSequence = brokerSle->at(sfLoanSequence); + auto const loanKeylet = keylet::loan(brokerInfo.brokerID, loanSequence); + + // Create the loan delete transaction + auto loanDelTx = del(broker, loanKeylet.key); + + // Calculate batch fee: 1 signer (borrower) + 2 transactions + auto const batchFee = batch::calcBatchFee(env, 1, 2); + + // Create the batch transaction with both LoanSet and LoanDelete + auto batchTxn = env.jt( + batch::outer(broker, brokerSeq, batchFee, tfAllOrNothing), + batch::inner(loanSetTx, brokerSeq + 1), + batch::inner(loanDelTx, brokerSeq + 2), + batch::sig(borrower)); + + env(batchTxn); env.close(); - // Wait for loan to be late enough to default - env.close(std::chrono::seconds(86400 * 40)); // 40 days - - { - auto const vaultSle = env.le(vaultKeylet); - Number assetsTotal = vaultSle->at(sfAssetsTotal); - Number assetsAvail = vaultSle->at(sfAssetsAvailable); - - log << "After loan creation:" << std::endl; - log << " AssetsTotal: " << assetsTotal << std::endl; - log << " AssetsAvailable: " << assetsAvail << std::endl; - log << " Difference: " << (assetsTotal - assetsAvail) << std::endl; - - auto const loanSle = env.le(loanKeylet); - if (!BEAST_EXPECT(loanSle)) - return; - auto const state = constructRoundedLoanState(loanSle); - - log << "Loan state:" << std::endl; - log << " ValueOutstanding: " << state.valueOutstanding << std::endl; - log << " PrincipalOutstanding: " << state.principalOutstanding << std::endl; - log << " InterestOutstanding: " << state.interestOutstanding() << std::endl; - log << " InterestDue: " << state.interestDue << std::endl; - log << " FeeDue: " << state.managementFeeDue << std::endl; - - // after loan creation the assets total and available should - // reflect the value of the loan - BEAST_EXPECT(assetsAvail < assetsTotal); - BEAST_EXPECT(assetsAvail == broker.asset(brokerParams.vaultDeposit - loanParams.principalRequest).number()); - BEAST_EXPECT(assetsTotal == broker.asset(brokerParams.vaultDeposit + state.interestDue).number()); - } - - // Step 7: Trigger default (dust adjustment will occur) - env(jtx::loan::manage(lender, loanKeylet.key, tfLoanDefault)); - env.close(); - - // Step 8: Verify phantom assets created - { - auto const vaultSle2 = env.le(vaultKeylet); - Number assetsTotal2 = vaultSle2->at(sfAssetsTotal); - Number assetsAvail2 = vaultSle2->at(sfAssetsAvailable); - - log << "After default:" << std::endl; - log << " AssetsTotal: " << assetsTotal2 << std::endl; - log << " AssetsAvailable: " << assetsAvail2 << std::endl; - log << " Difference: " << (assetsTotal2 - assetsAvail2) << std::endl; - - // after a default the assets total and available should be equal - BEAST_EXPECT(assetsAvail2 == assetsTotal2); - } + // Verify the loan is not there. + BEAST_EXPECT(!env.le(loanKeylet)); } void - testRIPD3831() + testLoanSetAndImpair() { + testcase("LoanSetAndManage"); + // Creates a loan and impairs it in a single batch. + // When a loan is impaired, NextPaymentDueDate is set to currentTime. + using namespace jtx; - - testcase("RIPD-3831"); - - Account const issuer("issuer"); - Account const lender("lender"); - Account const borrower("borrower"); - - BrokerParameters const brokerParams{ - .vaultDeposit = 100000, - .debtMax = 0, - .coverRateMin = TenthBips32{0}, - // .managementFeeRate = TenthBips16{5919}, - .coverRateLiquidation = TenthBips32{0}}; - LoanParameters const loanParams{ - .account = lender, - .counter = borrower, - .principalRequest = Number{200'000, -6}, - .lateFee = Number{200, -6}, - .interest = TenthBips32{50'000}, - .payTotal = 10, - .payInterval = 150}; - - auto const assetType = AssetType::XRP; - - Env env(*this, all); - - auto loanResult = createLoan(env, assetType, brokerParams, loanParams, issuer, lender, borrower); - - if (!BEAST_EXPECT(loanResult)) - return; - - auto broker = std::get(*loanResult); - auto loanKeylet = std::get(*loanResult); - - using tp = NetClock::time_point; - using d = NetClock::duration; - - auto state = getCurrentState(env, broker, loanKeylet); - if (auto loan = env.le(loanKeylet); BEAST_EXPECT(loan)) - { - env.close(tp{d{loan->at(sfNextPaymentDueDate) + loan->at(sfGracePeriod) + 1}}); - } - - topUpBorrower(env, broker, issuer, borrower, state, loanParams.serviceFee); - using namespace jtx::loan; - - auto jv = pay(borrower, loanKeylet.key, drops(XRPAmount(state.totalValue))); - - { - auto const submitParam = to_string(jv); - auto const jr = env.rpc("submit", borrower.name(), submitParam); - - BEAST_EXPECT(jr.isMember(jss::result)); - auto const jResult = jr[jss::result]; - } - - env.close(); - - // Make sure the system keeps responding - env(noop(borrower)); - env.close(); - env(noop(issuer)); - env.close(); - env(noop(lender)); - env.close(); - } - - void - testRIPD3459() - { - testcase("RIPD-3459 - LoanBroker incorrect debt total"); - - using namespace jtx; - - Account const issuer("issuer"); - Account const lender("lender"); - Account const borrower("borrower"); - - BrokerParameters const brokerParams{ - .vaultDeposit = 200'000, - .debtMax = 0, - .coverRateMin = TenthBips32{0}, - .managementFeeRate = TenthBips16{500}, - .coverRateLiquidation = TenthBips32{0}}; - LoanParameters const loanParams{ - .account = lender, - .counter = borrower, - .principalRequest = Number{100'000, -4}, - .interest = TenthBips32{100'000}, - .payTotal = 10}; - - auto const assetType = AssetType::MPT; + using namespace std::chrono_literals; Env env(*this, all); - auto loanResult = createLoan(env, assetType, brokerParams, loanParams, issuer, lender, borrower); - - if (!BEAST_EXPECT(loanResult)) - return; - - auto broker = std::get(*loanResult); - auto loanKeylet = std::get(*loanResult); - auto pseudoAcct = std::get(*loanResult); - - VerifyLoanStatus verifyLoanStatus(env, broker, pseudoAcct, loanKeylet); - - if (auto const brokerSle = env.le(broker.brokerKeylet()); BEAST_EXPECT(brokerSle)) - { - if (auto const loanSle = env.le(loanKeylet); BEAST_EXPECT(loanSle)) - { - BEAST_EXPECT(brokerSle->at(sfDebtTotal) == loanSle->at(sfTotalValueOutstanding)); - } - } - - makeLoanPayments( - env, - broker, - loanParams, - loanKeylet, - verifyLoanStatus, - issuer, - lender, - borrower, - PaymentParameters{.showStepBalances = true}); - - if (auto const brokerSle = env.le(broker.brokerKeylet()); BEAST_EXPECT(brokerSle)) - { - if (auto const loanSle = env.le(loanKeylet); BEAST_EXPECT(loanSle)) - { - BEAST_EXPECT(brokerSle->at(sfDebtTotal) == loanSle->at(sfTotalValueOutstanding)); - BEAST_EXPECT(brokerSle->at(sfDebtTotal) == beast::zero); - } - } - } - - void - testRIPD3901() - { - testcase("Crash with tfLoanOverpayment"); - using namespace jtx; - using namespace loan; - Account const lender{"lender"}; Account const issuer{"issuer"}; - Account const borrower{"borrower"}; - Account const depositor{"depositor"}; - auto const txfee = fee(XRP(100)); - - Env env(*this); - Vault vault(env); - - env.fund(XRP(10'000), lender, issuer, borrower, depositor); - env.close(); - - auto [tx, vaultKeyLet] = vault.create({.owner = lender, .asset = xrpIssue()}); - env(tx, txfee); - env.close(); - - env(vault.deposit({.depositor = depositor, .id = vaultKeyLet.key, .amount = XRP(1'000)}), txfee); - env.close(); - - auto const brokerKeyLet = keylet::loanbroker(lender.id(), env.seq(lender)); - - env(loanBroker::set(lender, vaultKeyLet.key), txfee); - env.close(); - - // BrokerInfo brokerInfo{xrpIssue(), keylet, vaultKeyLet, {}}; - - STAmount const debtMaximumRequest = XRPAmount(200'000); - - env(set(borrower, brokerKeyLet.key, debtMaximumRequest), - sig(sfCounterpartySignature, lender), - interestRate(TenthBips32(50'000)), - paymentTotal(2), - paymentInterval(150), - txflags(tfLoanOverpayment), - txfee); - env.close(); - - std::uint32_t const loanSequence = 1; - auto const loanKeylet = keylet::loan(brokerKeyLet.key, loanSequence); - - if (auto loan = env.le(loanKeylet); env.test.BEAST_EXPECT(loan)) - { - env(loan::pay(borrower, loanKeylet.key, XRPAmount(150'001)), txflags(tfLoanOverpayment), txfee); - env.close(); - } - } - - void - testRoundingAllowsUndercoverage() - { - testcase("Minimum cover rounding allows undercoverage (XRP)"); - - using namespace jtx; - using namespace loanBroker; - - Env env(*this, all); - - Account const lender{"lender"}; - Account const borrower{"borrower"}; - - env.fund(XRP(200'000), lender, borrower); - env.close(); - - // Vault with XRP asset - Vault vault{env}; - auto [vaultCreate, vaultKeylet] = vault.create({.owner = lender, .asset = xrpIssue()}); - env(vaultCreate); - env.close(); - BEAST_EXPECT(env.le(vaultKeylet)); - - // Seed the vault with XRP so it can fund the loan principal - PrettyAsset const xrpAsset{xrpIssue(), 1}; - - BrokerParameters const brokerParams{ - .vaultDeposit = 1'000, - .debtMax = Number{0}, - .coverRateMin = TenthBips32{10'000}, - .coverDeposit = 82, - }; - - auto const brokerInfo = createVaultAndBroker(env, xrpAsset, lender, brokerParams); - // Create a loan with principal 804 XRP and 0% interest (so - // DebtTotal increases by exactly 804) - env(loan::set(borrower, brokerInfo.brokerID, xrpAsset(804).value()), - loan::interestRate(TenthBips32(0)), - sig(sfCounterpartySignature, lender), - fee(env.current()->fees().base * 2)); - BEAST_EXPECT(env.ter() == tesSUCCESS); - env.close(); - - // Verify DebtTotal is exactly 804 - if (auto const brokerSle = env.le(keylet::loanbroker(brokerInfo.brokerID)); BEAST_EXPECT(brokerSle)) - { - log << *brokerSle << std::endl; - BEAST_EXPECT(brokerSle->at(sfDebtTotal) == Number(804)); - } - - // Attempt to withdraw 2 XRP to self, leaving 80 XRP CoverAvailable. - // The minimum is 80.4 XRP, which rounds up to 81 XRP, so this fails. - env(coverWithdraw(lender, brokerInfo.brokerID, xrpAsset(2).value()), ter(tecINSUFFICIENT_FUNDS)); - BEAST_EXPECT(env.ter() == tecINSUFFICIENT_FUNDS); - env.close(); - - // Attempt to withdraw 1 XRP to self, leaving 81 XRP CoverAvailable. - // because that leaves sufficient cover, this succeeds - env(coverWithdraw(lender, brokerInfo.brokerID, xrpAsset(1).value())); - BEAST_EXPECT(env.ter() == tesSUCCESS); - env.close(); - - // Validate CoverAvailable == 80 XRP and DebtTotal remains 804 - if (auto const brokerSle = env.le(keylet::loanbroker(brokerInfo.brokerID)); BEAST_EXPECT(brokerSle)) - { - log << *brokerSle << std::endl; - BEAST_EXPECT(brokerSle->at(sfCoverAvailable) == xrpAsset(81).value()); - BEAST_EXPECT(brokerSle->at(sfDebtTotal) == Number(804)); - - // Also demonstrate that the true minimum (804 * 10%) exceeds 80 - auto const theoreticalMin = tenthBipsOfValue(Number(804), TenthBips32(10'000)); - log << "Theoretical min cover: " << theoreticalMin << std::endl; - BEAST_EXPECT(Number(804, -1) == theoreticalMin); - } - } - - void - testRIPD3902() - { - testcase("RIPD-3902 - 1 IOU loan payments"); - - using namespace jtx; - - Account const issuer("issuer"); - Account const lender("lender"); - Account const borrower("borrower"); - - BrokerParameters const brokerParams{ - .vaultDeposit = 10, - .debtMax = 0, - .coverRateMin = TenthBips32{0}, - .managementFeeRate = TenthBips16{0}, - .coverRateLiquidation = TenthBips32{0}}; - LoanParameters const loanParams{ - .account = lender, - .counter = borrower, - .principalRequest = Number{1, 0}, - .interest = TenthBips32{100'000}, - .payTotal = 5, - .payInterval = 150, - .gracePd = 60}; - - auto const assetType = AssetType::IOU; - - Env env(*this, all); - - auto loanResult = createLoan(env, assetType, brokerParams, loanParams, issuer, lender, borrower); - - if (!BEAST_EXPECT(loanResult)) - return; - - auto broker = std::get(*loanResult); - auto loanKeylet = std::get(*loanResult); - auto pseudoAcct = std::get(*loanResult); - - VerifyLoanStatus verifyLoanStatus(env, broker, pseudoAcct, loanKeylet); - - makeLoanPayments( - env, - broker, - loanParams, - loanKeylet, - verifyLoanStatus, - issuer, - lender, - borrower, - PaymentParameters{.showStepBalances = true}); - } - - void - testBorrowerIsBroker() - { - testcase("Test Borrower is Broker"); - using namespace jtx; - using namespace loan; Account const broker{"broker"}; - Account const issuer{"issuer"}; - Account const borrower_{"borrower"}; - Account const depositor{"depositor"}; + Account const borrower{"borrower"}; - auto testLoanAsset = [&](auto&& getMaxDebt, auto const& borrower) { - Env env(*this); - Vault vault(env); - - if (borrower == broker) - env.fund(XRP(10'000), broker, issuer, depositor); - else - env.fund(XRP(10'000), broker, borrower, issuer, depositor); - env.close(); - - auto const xrpFee = XRP(100); - auto const txFee = fee(xrpFee); - - STAmount const debtMaximumRequest = getMaxDebt(env); - - auto const& asset = debtMaximumRequest.asset(); - auto const initialVault = asset(debtMaximumRequest * 100); - - auto [tx, vaultKeylet] = vault.create({.owner = broker, .asset = asset}); - env(tx, txFee); - env.close(); - - env(vault.deposit({.depositor = depositor, .id = vaultKeylet.key, .amount = initialVault}), txFee); - env.close(); - - auto const brokerKeylet = keylet::loanbroker(broker.id(), env.seq(broker)); - - env(loanBroker::set(broker, vaultKeylet.key), txFee); - env.close(); - - auto const serviceFee = 101; - - env(set(broker, brokerKeylet.key, debtMaximumRequest), - counterparty(borrower), - sig(sfCounterpartySignature, borrower), - loanServiceFee(serviceFee), - paymentTotal(10), - txFee); - env.close(); - - std::uint32_t const loanSequence = 1; - auto const loanKeylet = keylet::loan(brokerKeylet.key, loanSequence); - - auto const brokerBalanceBefore = env.balance(broker, asset); - - if (auto const loanSle = env.le(loanKeylet); env.test.BEAST_EXPECT(loanSle)) - { - auto const payment = loanSle->at(sfPeriodicPayment); - auto const totalPayment = payment + serviceFee; - env(loan::pay(borrower, loanKeylet.key, asset(totalPayment)), txFee); - env.close(); - if (auto const vaultSle = env.le(vaultKeylet); BEAST_EXPECT(vaultSle)) - { - auto const expected = [&]() { - // The service fee is transferred to the broker if - // a borrower is not the broker - if (borrower != broker) - return brokerBalanceBefore.number() + serviceFee; - // Since a borrower is the broker, the payment is - // transferred to the Vault from the broker but not - // the service fee. - // If the asset is XRP then the broker pays the txfee. - if (asset.native()) - return brokerBalanceBefore.number() - payment - xrpFee.number(); - return brokerBalanceBefore.number() - payment; - }(); - BEAST_EXPECT(env.balance(broker, asset).value() == asset(expected).value()); - } - } - }; - // Test when a borrower is the broker and is not to verify correct - // service fee transfer in both cases. - for (auto const& borrowerAcct : {broker, borrower_}) - { - testLoanAsset([&](Env&) -> STAmount { return STAmount{XRPAmount{200'000}}; }, borrowerAcct); - testLoanAsset( - [&](Env& env) -> STAmount { - auto const IOU = issuer["USD"]; - env(trust(broker, IOU(1'000'000'000))); - env(trust(depositor, IOU(1'000'000'000))); - env(pay(issuer, broker, IOU(100'000'000))); - env(pay(issuer, depositor, IOU(100'000'000))); - env.close(); - return IOU(200'000); - }, - borrowerAcct); - testLoanAsset( - [&](Env& env) -> STAmount { - MPTTester mpt({.env = env, .issuer = issuer, .holders = {broker, depositor}, .pay = 100'000'000}); - return mpt(200'000); - }, - borrowerAcct); - } - } - - void - testIssuerIsBorrower() - { - testcase("RIPD-4096 - Issuer as borrower"); - - using namespace jtx; - - Account const issuer("issuer"); - Account const lender("lender"); - - BrokerParameters const brokerParams{ - .vaultDeposit = 100'000, - .debtMax = 0, - .coverRateMin = TenthBips32{0}, - .managementFeeRate = TenthBips16{0}, - .coverRateLiquidation = TenthBips32{0}}; - LoanParameters const loanParams{.account = lender, .counter = issuer, .principalRequest = Number{10000}}; - - auto const assetType = AssetType::IOU; - - Env env(*this, all); - - auto loanResult = createLoan(env, assetType, brokerParams, loanParams, issuer, lender, issuer); - - if (!BEAST_EXPECT(loanResult)) - return; - - auto broker = std::get(*loanResult); - auto loanKeylet = std::get(*loanResult); - auto pseudoAcct = std::get(*loanResult); - - VerifyLoanStatus verifyLoanStatus(env, broker, pseudoAcct, loanKeylet); - - makeLoanPayments( - env, - broker, - loanParams, - loanKeylet, - verifyLoanStatus, - issuer, - lender, - issuer, - PaymentParameters{.showStepBalances = true}); - } - - void - testLimitExceeded() - { - testcase("RIPD-4125 - overpayment"); - - using namespace jtx; - - Account const issuer("issuer"); - Account const lender("lender"); - Account const borrower("borrower"); - - BrokerParameters const brokerParams{ - .vaultDeposit = 100'000, - .debtMax = 0, - .coverRateMin = TenthBips32{0}, - .managementFeeRate = TenthBips16{0}, - .coverRateLiquidation = TenthBips32{0}}; - LoanParameters const loanParams{ - .account = lender, - .counter = borrower, - .principalRequest = Number{200000, -6}, - .interest = TenthBips32{50000}, - .payTotal = 3, - .payInterval = 200, - .gracePd = 60, - .flags = tfLoanOverpayment, - }; - - auto const assetType = AssetType::XRP; - - Env env(*this, makeConfig(), all, nullptr, beast::severities::Severity::kWarning); - - auto loanResult = createLoan(env, assetType, brokerParams, loanParams, issuer, lender, borrower); - - if (!BEAST_EXPECT(loanResult)) - return; - - auto broker = std::get(*loanResult); - auto loanKeylet = std::get(*loanResult); - auto pseudoAcct = std::get(*loanResult); - - VerifyLoanStatus verifyLoanStatus(env, broker, pseudoAcct, loanKeylet); - - auto const state = getCurrentState(env, broker, loanKeylet); - - env(loan::pay( - borrower, loanKeylet.key, STAmount{broker.asset, state.periodicPayment * 3 / 2 + 1}, tfLoanOverpayment)); - env.close(); - - PaymentParameters paymentParams{ - .showStepBalances = false, - .validateBalances = true, - }; - - makeLoanPayments( - env, broker, loanParams, loanKeylet, verifyLoanStatus, issuer, lender, borrower, paymentParams); - } - - void - testOverpaymentManagementFee() - { - testcase("testOverpaymentManagementFee"); - - using namespace jtx; - using namespace loan; - - Env env(*this, all); - - Account const lender{"lender"}, borrower{"borrower"}; - - env.fund(XRP(10'000'000), lender, borrower); - env.close(); - - PrettyAsset const asset{xrpIssue(), 1000}; - - auto const result = createVaultAndBroker( - env, - asset, - lender, - { - .vaultDeposit = asset(100'000).value(), - .managementFeeRate = TenthBips16(10'000), - }); - - auto const loanSetFee = fee(env.current()->fees().base * 2); - - auto const loanKeylet = - keylet::loan(result.brokerKeylet().key, (env.le(result.brokerKeylet()))->at(sfLoanSequence)); - env(loan::set(borrower, result.brokerKeylet().key, asset(10'000).value(), tfLoanOverpayment), - sig(sfCounterpartySignature, lender), - loan::paymentInterval(86400 * 30), - loan::paymentTotal(3), - loan::overpaymentInterestRate(TenthBips32(percentageToTenthBips(20))), - loanSetFee); - - // From calculator - auto const expectedOverpaymentManagementFee = Number{33333, 0}; - auto const loanBrokerBalanceBefore = env.balance(lender); - - auto const loanPayFee = fee(env.current()->fees().base * 2); - env(pay(borrower, loanKeylet.key, asset(5'000).value(), tfLoanOverpayment), loanPayFee); - env.close(); - - BEAST_EXPECTS( - env.balance(lender) - loanBrokerBalanceBefore == expectedOverpaymentManagementFee, - "overpayment management fee missmatch; expected:" + to_string(expectedOverpaymentManagementFee) + - " got: " + to_string(env.balance(lender) - loanBrokerBalanceBefore)); - } - - void - testLoanPayBrokerOwnerMissingTrustline() - { - testcase << "LoanPay Broker Owner Missing Trustline (PoC)"; - using namespace jtx; - using namespace loan; - Account const issuer("issuer"); - Account const borrower("borrower"); - Account const broker("broker"); auto const IOU = issuer["IOU"]; - Env env(*this, all); + env.fund(XRP(20'000), issuer, broker, borrower); env.close(); - // Set up trustlines and fund accounts + + env(trust(broker, IOU(20'000'000))); + env(pay(issuer, broker, IOU(10'000'000))); + env.close(); + auto brokerInfo = createVaultAndBroker(env, IOU, broker); + + LoanParameters const loanParams{ + .account = broker, + .counter = borrower, + .principalRequest = Number{100}, + .interest = TenthBips32{1922}, + .payTotal = 5816, + .payInterval = 86400 * 6, // 6 days + .gracePd = 86400 * 5, // 5 days grace period + }; + + auto loanSetTx = loanParams.getTransaction(env, brokerInfo); + + // Get the loan keylet that will be created by the LoanSet + auto const brokerSeq = env.seq(broker); + + // The loan keylet is based on the broker's LoanSequence + auto const brokerSle = env.le(keylet::loanbroker(brokerInfo.brokerID)); + auto const loanSequence = brokerSle->at(sfLoanSequence); + auto const loanKeylet = keylet::loan(brokerInfo.brokerID, loanSequence); + + // Create the manage transaction to impair the loan + auto impairTx = manage(broker, loanKeylet.key, tfLoanImpair); + + // Calculate batch fee: 1 signer (borrower) + 2 transactions + auto const batchFee = batch::calcBatchFee(env, 1, 2); + + // Create the batch transaction with LoanSet and impair + auto batchTxn = env.jt( + batch::outer(broker, brokerSeq, batchFee, tfAllOrNothing), + batch::inner(loanSetTx, brokerSeq + 1), + batch::inner(impairTx, brokerSeq + 2), + batch::sig(borrower)); + + auto currentTime = env.now().time_since_epoch().count(); + env(batchTxn); + env.close(); + + // Verify the loan was created and impaired + auto const finalLoanSle = env.le(loanKeylet); + BEAST_EXPECT(finalLoanSle); + BEAST_EXPECT(finalLoanSle->isFlag(lsfLoanImpaired)); + // When impaired, NextPaymentDueDate should be set to current time + BEAST_EXPECT(finalLoanSle->at(sfNextPaymentDueDate) == currentTime); + + // Verify Vault.LossUnrealized was increased + auto const finalBrokerSle = env.le(keylet::loanbroker(brokerInfo.brokerID)); + BEAST_EXPECT(finalBrokerSle); + if (finalBrokerSle) + { + auto const vaultKeylet = keylet::vault(finalBrokerSle->at(sfVaultID)); + auto const vaultSle = env.le(vaultKeylet); + BEAST_EXPECT(vaultSle); + if (vaultSle) + { + // LossUnrealized = TotalValueOutstanding - ManagementFeeOutstanding + auto const expectedLoss = + finalLoanSle->at(sfTotalValueOutstanding) - finalLoanSle->at(sfManagementFeeOutstanding); + BEAST_EXPECT(vaultSle->at(sfLossUnrealized) == expectedLoss); + } + } + } + + void + testLoanDefaultWithdrawAndPay() + { + testcase("LoanDefaultWithdrawAndPay"); + // Creates a loan, advances time to make it defaultable, then in a batch: + // defaults the loan, withdraws the DefaultCovered amount, and makes a payment. + + using namespace jtx; + using namespace jtx::loan; + using namespace std::chrono_literals; + + Env env(*this, all); + + Account const issuer{"issuer"}; + Account const broker{"broker"}; + Account const borrower{"borrower"}; + Account const recipient{"recipient"}; + + auto const IOU = issuer[iouCurrency]; + + env.fund(XRP(20'000), issuer, broker, borrower, recipient); + env.close(); + + env(trust(broker, IOU(20'000'000))); + env(trust(recipient, IOU(20'000'000))); + env(pay(issuer, broker, IOU(10'000'000))); + env.close(); + + // Create vault and broker with specific parameters to ensure DefaultCovered > vault available + BrokerParameters brokerParams = BrokerParameters::defaults(); + brokerParams.vaultDeposit = 100; // Small vault deposit + brokerParams.coverDeposit = 1000; // Large cover deposit + auto brokerInfo = createVaultAndBroker(env, IOU, broker, brokerParams); + + LoanParameters const loanParams{ + .account = broker, + .counter = borrower, + .principalRequest = Number{100}, + .interest = TenthBips32{1922}, + .payTotal = 5816, + .payInterval = 86400 * 6, // 6 days + .gracePd = 86400 * 5, // 5 days grace period + }; + + // Create the loan first (not in batch) + env(loanParams(env, brokerInfo)); + env.close(); + + // Get the loan keylet + auto const brokerSle = env.le(keylet::loanbroker(brokerInfo.brokerID)); + auto const loanSequence = brokerSle->at(sfLoanSequence) - 1; + auto const loanKeylet = keylet::loan(brokerInfo.brokerID, loanSequence); + + // Verify loan exists + BEAST_EXPECT(env.le(loanKeylet)); + + // Advance time past the payment due date + grace period to make the loan defaultable + auto const loanSle = env.le(loanKeylet); + auto const nextPaymentDue = loanSle->at(sfNextPaymentDueDate); + auto const gracePeriod = loanSle->at(sfGracePeriod); + env.close(std::chrono::seconds{nextPaymentDue + gracePeriod + 60}); + + // Calculate DefaultCovered before defaulting + auto const totalValue = loanSle->at(sfTotalValueOutstanding); + auto const managementFee = loanSle->at(sfManagementFeeOutstanding); + auto const defaultAmount = totalValue - managementFee; + + auto const debtTotal = brokerSle->at(sfDebtTotal); + auto const coverRateMin = TenthBips32{brokerSle->at(sfCoverRateMinimum)}; + auto const coverRateLiq = TenthBips32{brokerSle->at(sfCoverRateLiquidation)}; + auto const coverAvailable = brokerSle->at(sfCoverAvailable); + + // MinimumCover = DebtTotal x CoverRateMinimum + Number const minimumCover = debtTotal * coverRateMin.value() / tenthBipsPerUnity.value(); + // DefaultCovered = min(MinimumCover x CoverRateLiquidation, DefaultAmount, CoverAvailable) + Number const defaultCovered = + std::min({minimumCover * coverRateLiq.value() / tenthBipsPerUnity.value(), defaultAmount, coverAvailable}); + + // Get vault available before default + auto const vaultSle = env.le(keylet::vault(brokerSle->at(sfVaultID))); + auto const vaultAvailableBefore = vaultSle->at(sfAssetsAvailable); + + // Verify DefaultCovered will be greater than vault available + BEAST_EXPECT(defaultCovered > vaultAvailableBefore); + + // Now batch: default, withdraw DefaultCovered, and make a payment + auto const brokerSeq = env.seq(broker); + + auto defaultTx = manage(broker, loanKeylet.key, tfLoanDefault); + + // Withdraw the DefaultCovered amount from vault + Vault vault{env}; + auto withdrawTx = + vault.withdraw({.depositor = broker, .id = brokerInfo.vaultID, .amount = brokerInfo.asset(defaultCovered)}); + + // Make a payment to recipient + auto paymentTx = pay(broker, recipient, brokerInfo.asset(defaultCovered / 2)); + + // Calculate batch fee: 0 signers (broker signs outer) + 3 transactions + auto const batchFee = batch::calcBatchFee(env, 0, 3); + + // Create the batch transaction + auto batchTxn = env.jt( + batch::outer(broker, brokerSeq, batchFee, tfAllOrNothing), + batch::inner(defaultTx, brokerSeq + 1), + batch::inner(withdrawTx, brokerSeq + 2), + batch::inner(paymentTx, brokerSeq + 3)); + + env(batchTxn); + env.close(); + + // Verify the loan is defaulted + auto const finalLoanSle = env.le(loanKeylet); + BEAST_EXPECT(finalLoanSle); + if (finalLoanSle) + { + BEAST_EXPECT(finalLoanSle->isFlag(lsfLoanDefault)); + BEAST_EXPECT(finalLoanSle->at(sfPaymentRemaining) == 0); + BEAST_EXPECT(finalLoanSle->at(sfTotalValueOutstanding) == 0); + BEAST_EXPECT(finalLoanSle->at(sfPrincipalOutstanding) == 0); + BEAST_EXPECT(finalLoanSle->at(sfManagementFeeOutstanding) == 0); + BEAST_EXPECT(finalLoanSle->at(sfNextPaymentDueDate) == 0); + } + + // Verify vault state after default and withdrawal + auto const finalVaultSle = env.le(keylet::vault(brokerInfo.vaultID)); + BEAST_EXPECT(finalVaultSle); + if (finalVaultSle) + { + // Vault should have received DefaultCovered, then withdrawn it + auto const expectedAvailable = vaultAvailableBefore + defaultCovered - defaultCovered; + BEAST_EXPECT(finalVaultSle->at(sfAssetsAvailable) == expectedAvailable); + } + + // Verify recipient received payment + BEAST_EXPECT(env.balance(recipient, IOU) == brokerInfo.asset(defaultCovered / 2)); + } + + void + testRiskFreeArbitrage() + { + testcase("RiskFreeArbitrage"); + // Demonstrates risk-free arbitrage using batch transactions: + // 1. Borrow funds from a loan + // 2. Buy XRP at one price + // 3. Sell XRP at a higher price + // 4. Repay the loan with interest + // All in a single atomic batch - if any step fails, everything reverts. + + using namespace jtx; + using namespace jtx::loan; + using namespace std::chrono_literals; + + Env env(*this, all); + + Account const issuer{"issuer"}; + Account const broker{"broker"}; + Account const borrower{"borrower"}; + Account const marketMaker1{"mm1"}; // Sells XRP at $2.50 + Account const marketMaker2{"mm2"}; // Buys XRP at $2.52 + + auto const IOU = issuer[iouCurrency]; + + env.fund(XRP(50'000), issuer, broker, borrower, marketMaker1, marketMaker2); + env.close(); + + // Set up trust lines env(trust(broker, IOU(20'000'000))); env(trust(borrower, IOU(20'000'000))); - env(pay(issuer, broker, IOU(10'000'000))); - env(pay(issuer, borrower, IOU(1'000))); + env(trust(marketMaker1, IOU(20'000'000))); + env(trust(marketMaker2, IOU(20'000'000))); env.close(); + + // Fund accounts + env(pay(issuer, broker, IOU(15'000'000))); + env(pay(issuer, marketMaker1, IOU(11'000'000))); + env(pay(issuer, marketMaker2, IOU(11'000'000))); + env.close(); + // Create vault and broker - auto const brokerInfo = createVaultAndBroker(env, IOU, broker); - // Create a loan first (this creates debt) - auto const keylet = keylet::loan(brokerInfo.brokerID, 1); - env(set(borrower, brokerInfo.brokerID, 10'000), - sig(sfCounterpartySignature, broker), - loanServiceFee(IOU(100).value()), - paymentInterval(100), - fee(XRP(100))); + auto brokerInfo = createVaultAndBroker(env, IOU, broker); + + // Get the loan keylet BEFORE creating the loan + auto const brokerSle = env.le(keylet::loanbroker(brokerInfo.brokerID)); + auto const loanSequence = brokerSle->at(sfLoanSequence); + auto const loanKeylet = keylet::loan(brokerInfo.brokerID, loanSequence); + + // Market makers create offers: + // MM1: Sells 400 XRP for $1000 (price: $2.50 per XRP) + // MM2: Buys 400 XRP for $1008 (price: $2.52 per XRP) + env(offer(marketMaker1, IOU(1000), XRP(400))); + env(offer(marketMaker2, XRP(400), IOU(1008))); env.close(); - // Ensure broker has sufficient cover so brokerPayee == brokerOwner - // We need coverAvailable >= (debtTotal * coverRateMinimum) - // Deposit enough cover to ensure the fee goes to broker owner - // The default coverRateMinimum is 10%, so for a 10,000 loan we need - // at least 1,000 cover. Default cover is 1,000, so we add more to be - // safe. - auto const additionalCover = IOU(50'000).value(); - env(loanBroker::coverDeposit(broker, brokerInfo.brokerID, STAmount{IOU, additionalCover})); + + // Record vault state before batch + auto const initialVaultSle = env.le(keylet::vault(brokerInfo.vaultID)); + auto const initialVaultPseudoAccountID = initialVaultSle->at(sfAccount); + Account const initialVaultPseudoAccount("VaultPseudo", initialVaultPseudoAccountID); + auto const initialVaultBalance = env.balance(initialVaultPseudoAccount, IOU); + auto const initialAssetsAvailable = initialVaultSle->at(sfAssetsAvailable); + + // Now create the arbitrage batch - ALL IN ONE ATOMIC TRANSACTION: + // 1. Create loan (borrow $1000) + // 2. Buy 400 XRP at $2.50 (cost: $1000) + // 3. Sell 400 XRP at $2.52 (revenue: $1008) + // 4. Repay loan principal + interest ($1000 + $1 = $1001) + // Profit: $1008 - $1001 = $7 + + auto const borrowerSeq = env.seq(borrower); + + // Transaction 1: Create the loan (borrower receives $1000) + LoanParameters const loanParams{ + .account = borrower, + .counter = broker, + .principalRequest = Number{1000}, + .interest = TenthBips32{100}, // 0.1% interest + .payTotal = 1, + .payInterval = 86400, // 1 day + .gracePd = 86400, // 1 day grace period + }; + auto loanSetTx = loanParams.getTransaction(env, brokerInfo); + + // Transaction 2: Buy 400 XRP for $1000 from MM1 + auto buyTx = offer(borrower, XRP(400), IOU(1000)); + + // Transaction 3: Sell 400 XRP for $1008 to MM2 + auto sellTx = offer(borrower, IOU(1008), XRP(400)); + + // Transaction 4: Repay the loan + // We know the total will be $1001 (principal $1000 + interest $1) + auto payTx = pay(borrower, loanKeylet.key, IOU(1001)); + + // Calculate batch fee: 1 signer (broker as counterparty) + 4 transactions + auto const batchFee = batch::calcBatchFee(env, 1, 4); + + // Create the batch transaction with tfAllOrNothing + auto batchTxn = env.jt( + batch::outer(borrower, borrowerSeq, batchFee, tfAllOrNothing), + batch::inner(loanSetTx, borrowerSeq + 1), + batch::inner(buyTx, borrowerSeq + 2), + batch::inner(sellTx, borrowerSeq + 3), + batch::inner(payTx, borrowerSeq + 4), + batch::sig(broker)); + + env(batchTxn); env.close(); - // Verify broker owner has a trustline - auto const brokerTrustline = keylet::line(broker, IOU); - BEAST_EXPECT(env.le(brokerTrustline) != nullptr); - // Broker owner deletes their trustline - // First, pay any positive balance to issuer to zero it out - auto const brokerBalance = env.balance(broker, IOU); - env(pay(broker, issuer, brokerBalance)); - env.close(); - // Remove the trustline by setting limit to 0 - env(trust(broker, IOU(0))); - env.close(); - // Verify trustline is deleted - BEAST_EXPECT(env.le(brokerTrustline) == nullptr); - // Now borrower tries to make a payment - // We should get a tesSUCCESS instead of a tecNO_LINE. - env(pay(borrower, keylet.key, IOU(10'100)), fee(XRP(100)), ter(tesSUCCESS)); - env.close(); - // Verify trustline is still deleted - BEAST_EXPECT(env.le(brokerTrustline) == nullptr); - // Verify the service fee went to the broker pseudo-account - if (auto const brokerSle = env.le(keylet::loanbroker(brokerInfo.brokerID)); BEAST_EXPECT(brokerSle)) + + // Verify the arbitrage was successful: + // 1. Borrower should have profit (~$7) + // 2. Loan should be fully paid + // 3. Market makers' offers should be consumed + // 4. Vault should have received the interest payment + + auto const finalLoanSle = env.le(loanKeylet); + BEAST_EXPECT(finalLoanSle); + if (finalLoanSle) { - Account const pseudo("pseudo-account", brokerSle->at(sfAccount)); - auto const balance = env.balance(pseudo, IOU); - // 1,000 default + 50,000 extra + 100 service fee from LoanPay - BEAST_EXPECTS(balance == IOU(51'100), to_string(Json::Value(balance))); + // Loan should be fully paid (or nearly so, depending on rounding) + BEAST_EXPECT(finalLoanSle->at(sfTotalValueOutstanding) < IOU(1).value()); + } + + // Borrower should have profit: + // Started with $1000 from loan, spent $1000 on XRP, earned $1008 from selling XRP, paid $1001 to repay loan + // Net: $1000 - $1000 + $1008 - $1001 = $7 profit + auto const borrowerBalance = env.balance(borrower, IOU); + BEAST_EXPECT(borrowerBalance > IOU(6) && borrowerBalance < IOU(8)); + + // Verify offers were consumed + env.require(offers(marketMaker1, 0)); + env.require(offers(marketMaker2, 0)); + + // Verify vault received the interest payment + // The vault should have received $1001 (principal $1000 + interest $1) + auto const finalVaultSle = env.le(keylet::vault(brokerInfo.vaultID)); + BEAST_EXPECT(finalVaultSle); + if (finalVaultSle) + { + auto const finalVaultPseudoAccountID = finalVaultSle->at(sfAccount); + BEAST_EXPECT(finalVaultPseudoAccountID == initialVaultPseudoAccountID); + + // Check vault pseudo-account balance increased + auto const finalVaultBalance = env.balance(initialVaultPseudoAccount, IOU); + auto const vaultBalanceIncrease = finalVaultBalance - initialVaultBalance; + BEAST_EXPECT(vaultBalanceIncrease > IOU(0) && vaultBalanceIncrease < IOU(3)); + + // Check AssetsAvailable increased + auto const finalAssetsAvailable = finalVaultSle->at(sfAssetsAvailable); + auto const assetsAvailableIncrease = finalAssetsAvailable - initialAssetsAvailable; + BEAST_EXPECT(assetsAvailableIncrease > Number{0} && assetsAvailableIncrease < Number{2}); } } void - testLoanPayBrokerOwnerUnauthorizedMPT() + testRiskFreeArbitrageFails() { - testcase << "LoanPay Broker Owner MPT unauthorized"; - using namespace jtx; - using namespace loan; - - Account const issuer("issuer"); - Account const borrower("borrower"); - Account const broker("broker"); - - Env env(*this, all); - env.fund(XRP(20'000), issuer, broker, borrower); - env.close(); - - MPTTester mptt{env, issuer, mptInitNoFund}; - mptt.create({.flags = tfMPTCanClawback | tfMPTCanTransfer | tfMPTCanLock}); - - PrettyAsset const MPT{mptt.issuanceID()}; - - // Authorize broker and borrower - mptt.authorize({.account = broker}); - mptt.authorize({.account = borrower}); - - env.close(); - - // Fund accounts - env(pay(issuer, broker, MPT(10'000'000))); - env(pay(issuer, borrower, MPT(1'000))); - env.close(); - - // Create vault and broker - auto const brokerInfo = createVaultAndBroker(env, MPT, broker); - // Create a loan first (this creates debt) - auto const keylet = keylet::loan(brokerInfo.brokerID, 1); - env(set(borrower, brokerInfo.brokerID, 10'000), - sig(sfCounterpartySignature, broker), - loanServiceFee(MPT(100).value()), - paymentInterval(100), - fee(XRP(100))); - env.close(); - // Ensure broker has sufficient cover so brokerPayee == brokerOwner - // We need coverAvailable >= (debtTotal * coverRateMinimum) - // Deposit enough cover to ensure the fee goes to broker owner - // The default coverRateMinimum is 10%, so for a 10,000 loan we need - // at least 1,000 cover. Default cover is 1,000, so we add more to be - // safe. - auto const additionalCover = MPT(50'000).value(); - env(loanBroker::coverDeposit(broker, brokerInfo.brokerID, STAmount{MPT, additionalCover})); - env.close(); - // Verify broker owner is authorized - auto const brokerMpt = keylet::mptoken(mptt.issuanceID(), broker); - BEAST_EXPECT(env.le(brokerMpt) != nullptr); - // Broker owner unauthorizes. - // First, pay any positive balance to issuer to zero it out - auto const brokerBalance = env.balance(broker, MPT); - env(pay(broker, issuer, brokerBalance)); - env.close(); - // Then, unauthorize the MPT. - mptt.authorize({.account = broker, .flags = tfMPTUnauthorize}); - env.close(); - // Verify the MPT is unauthorized. - BEAST_EXPECT(env.le(brokerMpt) == nullptr); - // Now borrower tries to make a payment - // We should get a tesSUCCESS instead of a tecNO_AUTH. - auto const borrowerBalance = env.balance(borrower, MPT); - env(pay(borrower, keylet.key, MPT(10'100)), fee(XRP(100)), ter(tesSUCCESS)); - env.close(); - // Verify the MPT is still unauthorized. - BEAST_EXPECT(env.le(brokerMpt) == nullptr); - // Verify the service fee went to the broker pseudo-account - if (auto const brokerSle = env.le(keylet::loanbroker(brokerInfo.brokerID)); BEAST_EXPECT(brokerSle)) - { - Account const pseudo("pseudo-account", brokerSle->at(sfAccount)); - auto const balance = env.balance(pseudo, MPT); - // 1,000 default + 50,000 extra + 100 service fee from LoanPay - BEAST_EXPECTS(balance == MPT(51'100), to_string(Json::Value(balance))); - } - } - - void - testLoanPayBrokerOwnerNoPermissionedDomainMPT() - { - testcase << "LoanPay Broker Owner without permissioned domain of the MPT"; - using namespace jtx; - using namespace loan; - - Account const issuer("issuer"); - Account const borrower("borrower"); - Account const broker("broker"); - - Env env(*this, all); - env.fund(XRP(20'000), issuer, broker, borrower); - env.close(); - - auto credType = "credential1"; - - pdomain::Credentials const credentials1{{issuer, credType}}; - env(pdomain::setTx(issuer, credentials1)); - env.close(); - - auto domainID = pdomain::getNewDomain(env.meta()); - - env(credentials::create(broker, issuer, credType)); - env(credentials::accept(broker, issuer, credType)); - env.close(); - - env(credentials::create(borrower, issuer, credType)); - env(credentials::accept(borrower, issuer, credType)); - env.close(); - - MPTTester mptt{env, issuer, mptInitNoFund}; - mptt.create({ - .flags = tfMPTCanClawback | tfMPTRequireAuth | tfMPTCanTransfer | tfMPTCanLock, - .domainID = domainID, - }); - - PrettyAsset const MPT{mptt.issuanceID()}; - - // Authorize broker and borrower - mptt.authorize({.account = broker}); - mptt.authorize({.account = borrower}); - - env.close(); - - // Fund accounts - env(pay(issuer, broker, MPT(10'000'000))); - env(pay(issuer, borrower, MPT(1'000))); - env.close(); - - // Create vault and broker - auto const brokerInfo = createVaultAndBroker(env, MPT, broker); - // Create a loan first (this creates debt) - auto const keylet = keylet::loan(brokerInfo.brokerID, 1); - env(set(borrower, brokerInfo.brokerID, 10'000), - sig(sfCounterpartySignature, broker), - loanServiceFee(MPT(100).value()), - paymentInterval(100), - fee(XRP(100))); - env.close(); - // Ensure broker has sufficient cover so brokerPayee == brokerOwner - // We need coverAvailable >= (debtTotal * coverRateMinimum) - // Deposit enough cover to ensure the fee goes to broker owner - // The default coverRateMinimum is 10%, so for a 10,000 loan we need - // at least 1,000 cover. Default cover is 1,000, so we add more to be - // safe. - auto const additionalCover = MPT(50'000).value(); - env(loanBroker::coverDeposit(broker, brokerInfo.brokerID, STAmount{MPT, additionalCover})); - env.close(); - // Verify broker owner is authorized - auto const brokerMpt = keylet::mptoken(mptt.issuanceID(), broker); - BEAST_EXPECT(env.le(brokerMpt) != nullptr); - // Remove the credentials for the Broker owner. - // First, pay any positive balance to issuer to zero it out - auto const brokerBalance = env.balance(broker, MPT); - env(pay(broker, issuer, brokerBalance)); - env.close(); - - env(credentials::deleteCred(broker, broker, issuer, credType)); - env.close(); - - // Make sure the broker is not authorized to hold the MPT after we - // deleted the credentials - env(pay(issuer, broker, MPT(1'000)), ter(tecNO_AUTH)); - - // Now borrower tries to make a payment - // We should get a tesSUCCESS instead of a tecNO_AUTH. - auto const borrowerBalance = env.balance(borrower, MPT); - env(pay(borrower, keylet.key, MPT(10'100)), fee(XRP(100)), ter(tesSUCCESS)); - env.close(); - // Verify broker is still not authorized - env(pay(issuer, broker, MPT(1'000)), ter(tecNO_AUTH)); - // Verify the service fee went to the broker pseudo-account - if (auto const brokerSle = env.le(keylet::loanbroker(brokerInfo.brokerID)); BEAST_EXPECT(brokerSle)) - { - Account const pseudo("pseudo-account", brokerSle->at(sfAccount)); - auto const balance = env.balance(pseudo, MPT); - // 1,000 default + 50,000 extra + 100 service fee from LoanPay - BEAST_EXPECTS(balance == MPT(51'100), to_string(Json::Value(balance))); - } - } - - void - testLoanSetBrokerOwnerNoPermissionedDomainMPT() - { - testcase << "LoanSet Broker Owner without permissioned domain of the MPT"; - using namespace jtx; - using namespace loan; - - Account const issuer("issuer"); - Account const borrower("borrower"); - Account const broker("broker"); - - Env env(*this, all); - env.fund(XRP(20'000), issuer, broker, borrower); - env.close(); - - auto credType = "credential1"; - - pdomain::Credentials const credentials1{{issuer, credType}}; - env(pdomain::setTx(issuer, credentials1)); - env.close(); - - auto domainID = pdomain::getNewDomain(env.meta()); - - // Add credentials for the broker and borrower - env(credentials::create(broker, issuer, credType)); - env(credentials::accept(broker, issuer, credType)); - env.close(); - - env(credentials::create(borrower, issuer, credType)); - env(credentials::accept(borrower, issuer, credType)); - env.close(); - - MPTTester mptt{env, issuer, mptInitNoFund}; - mptt.create({ - .flags = tfMPTCanClawback | tfMPTRequireAuth | tfMPTCanTransfer | tfMPTCanLock, - .domainID = domainID, - }); - - PrettyAsset const MPT{mptt.issuanceID()}; - - // Authorize broker and borrower - mptt.authorize({.account = broker}); - mptt.authorize({.account = borrower}); - env.close(); - - // Fund accounts - env(pay(issuer, broker, MPT(10'000'000))); - env(pay(issuer, borrower, MPT(1'000))); - env.close(); - - // Create vault and broker - auto const brokerInfo = createVaultAndBroker(env, MPT, broker); - - // Remove the credentials for the Broker owner. - // Clear the balance first. - auto const brokerBalance = env.balance(broker, MPT); - env(pay(broker, issuer, brokerBalance)); - env.close(); - // Delete the credentials - env(credentials::deleteCred(broker, broker, issuer, credType)); - env.close(); - - // Create a loan, this should fail for tecNO_AUTH - env(set(borrower, brokerInfo.brokerID, 10'000), - sig(sfCounterpartySignature, broker), - loanServiceFee(MPT(100).value()), - paymentInterval(100), - fee(XRP(100)), - ter(tecNO_AUTH)); - env.close(); - } - - void - testSequentialFLCDepletion() - { - testcase << "First-Loss Capital Depletion on Sequential Defaults"; + testcase("RiskFreeArbitrageFails"); + // Demonstrates that batch transactions with tfAllOrNothing revert completely + // when any transaction fails. In this case, we make it impossible to sell + // the XRP at the higher price, which causes the entire batch to fail, + // including the loan creation. using namespace jtx; - using namespace loan; - using namespace loanBroker; + using namespace jtx::loan; + using namespace std::chrono_literals; Env env(*this, all); Account const issuer{"issuer"}; - Account const lender{"lender"}; - Account const borrowerA{"borrowerA"}; - Account const borrowerB{"borrowerB"}; + Account const broker{"broker"}; + Account const borrower{"borrower"}; + Account const marketMaker1{"mm1"}; // Sells XRP at $2.50 - env.fund(XRP(1'000'000), issuer, lender, borrowerA, borrowerB); + auto const IOU = issuer[iouCurrency]; + + env.fund(XRP(50'000), issuer, broker, borrower, marketMaker1); env.close(); - PrettyAsset const asset = xrpIssue(); - auto const vaultDepositAmount = asset(200'000); // Enough for 2 x 50k loans plus interest/fees - - auto const brokerInfo = createVaultAndBroker( - env, - asset, - lender, - { - .vaultDeposit = vaultDepositAmount.value(), - .debtMax = 0, - .coverRateMin = TenthBips32(20000), // 20% - .coverDeposit = 21'000, - .managementFeeRate = TenthBips16(100), // 0.1% - .coverRateLiquidation = TenthBips32(100000), - }); - auto const brokerKeylet = brokerInfo.brokerKeylet(); - - // Create two identical loans: each 50,000 XRP principal (scaled down to - // avoid funding issues) Total DebtTotal will be ~100,000 XRP (principal - // + interest) Formula will calculate cover as: 100% × (20% × 100,000) = - // 20,000 XRP So we need FLC = 20,000 XRP to be fully consumed by first - // default - auto const principalAmount = Number(50'000); - auto const loanPaymentInterval = 2592000; // 30 days - auto const loanGracePeriod = 604800; // 7 days - - // Create Loan A - auto loanATx = env.jt( - set(borrowerA, brokerKeylet.key, principalAmount), - sig(sfCounterpartySignature, lender), - interestRate(TenthBips32(500)), // 5% - paymentTotal(12), - loan::paymentInterval(loanPaymentInterval), - loan::gracePeriod(loanGracePeriod), - fee(XRP(10))); // Sufficient fee for multi-sig transaction - env(loanATx); + // Set up trust lines + env(trust(broker, IOU(20'000'000))); + env(trust(borrower, IOU(20'000'000))); + env(trust(marketMaker1, IOU(20'000'000))); env.close(); - auto const loanAKeylet = keylet::loan(brokerKeylet.key, 1); - - // Create Loan B - auto loanBTx = env.jt( - set(borrowerB, brokerKeylet.key, principalAmount), - sig(sfCounterpartySignature, lender), - interestRate(TenthBips32(500)), // 5% - paymentTotal(12), - loan::paymentInterval(loanPaymentInterval), - loan::gracePeriod(loanGracePeriod), - fee(XRP(10))); // Sufficient fee for multi-sig transaction - env(loanBTx); + // Fund accounts + env(pay(issuer, broker, IOU(15'000'000))); + env(pay(issuer, marketMaker1, IOU(11'000'000))); env.close(); - auto const loanBKeylet = keylet::loan(brokerKeylet.key, 2); + // Create vault and broker + auto brokerInfo = createVaultAndBroker(env, IOU, broker); - auto loanASle = env.le(loanAKeylet); - if (!BEAST_EXPECT(loanASle)) - return; + // Get the loan keylet BEFORE creating the loan + auto const brokerSle = env.le(keylet::loanbroker(brokerInfo.brokerID)); + auto const loanSequence = brokerSle->at(sfLoanSequence); + auto const loanKeylet = keylet::loan(brokerInfo.brokerID, loanSequence); - // Advance time past grace period for both loans to be defaultable - auto const loanANextDue = loanASle->at(sfNextPaymentDueDate); - auto const loanAGrace = loanASle->at(sfGracePeriod); - env.close(std::chrono::seconds{loanANextDue + loanAGrace + 60}); - - env(manage(lender, loanAKeylet.key, tfLoanDefault), ter(tesSUCCESS)); + // Market maker creates only ONE offer: + // MM1: Sells 400 XRP for $1000 (price: $2.50 per XRP) + // NOTE: No MM2 offer to buy XRP at higher price! + env(offer(marketMaker1, IOU(1000), XRP(400))); env.close(); - // Verify Loan A is defaulted - loanASle = env.le(loanAKeylet); - if (!BEAST_EXPECT(loanASle)) - return; - BEAST_EXPECT(loanASle->isFlag(lsfLoanDefault)); - BEAST_EXPECT(loanASle->at(sfPaymentRemaining) == 0); + // Record initial state + auto const initialBorrowerBalance = env.balance(borrower, IOU); + auto const initialBrokerSle = env.le(keylet::loanbroker(brokerInfo.brokerID)); + auto const initialLoanSequence = initialBrokerSle->at(sfLoanSequence); - // Check broker state after first default (from committed ledger) - auto brokerSle = env.le(brokerKeylet); - if (!BEAST_EXPECT(brokerSle)) - return; - auto const afterFirstDebtTotal = brokerSle->at(sfDebtTotal); - auto const afterFirstCoverAvailable = brokerSle->at(sfCoverAvailable); + // Record vault state before batch + auto const initialVaultSle = env.le(keylet::vault(brokerInfo.vaultID)); + auto const initialVaultPseudoAccountID = initialVaultSle->at(sfAccount); + Account const initialVaultPseudoAccount("VaultPseudo", initialVaultPseudoAccountID); + auto const initialVaultBalance = env.balance(initialVaultPseudoAccount, IOU); + auto const initialAssetsAvailable = initialVaultSle->at(sfAssetsAvailable); - // DebtTotal should have decreased by Loan A's debt - BEAST_EXPECT(afterFirstDebtTotal == 50'134); + // Try to create the arbitrage batch - this should FAIL: + // 1. Create loan (borrow $1000) + // 2. Buy 400 XRP at $2.50 (cost: $1000) - succeeds + // 3. Sell 400 XRP at $2.52 (revenue: $1008) - FAILS (no offer available) + // 4. Repay loan principal + interest ($1000 + $1 = $1001) + // Because of tfAllOrNothing, the entire batch should revert! - // CoverAvailable should have decreased significantly - BEAST_EXPECT(afterFirstCoverAvailable == 946); + auto const borrowerSeq = env.seq(borrower); - env(manage(lender, loanBKeylet.key, tfLoanDefault), ter(tesSUCCESS)); + // Transaction 1: Create the loan (borrower receives $1000) + LoanParameters const loanParams{ + .account = borrower, + .counter = broker, + .principalRequest = Number{1000}, + .interest = TenthBips32{100}, // 0.1% interest + .payTotal = 1, + .payInterval = 86400, // 1 day + .gracePd = 86400, // 1 day grace period + }; + auto loanSetTx = loanParams.getTransaction(env, brokerInfo); - brokerSle = env.le(brokerKeylet); - if (!BEAST_EXPECT(brokerSle)) - return; - auto const afterSecondDebtTotal = brokerSle->at(sfDebtTotal); - auto const afterSecondCoverAvailable = brokerSle->at(sfCoverAvailable); + // Transaction 2: Buy 400 XRP for $1000 from MM1 + auto buyTx = offer(borrower, XRP(400), IOU(1000)); + buyTx[jss::Flags] = tfFillOrKill; - BEAST_EXPECT(afterSecondDebtTotal == 0); + // Transaction 3: Try to sell 400 XRP for $1008 - this will FAIL + auto sellTx = offer(borrower, XRP(400), IOU(1008)); + sellTx[jss::Flags] = tfFillOrKill; - BEAST_EXPECT(afterSecondCoverAvailable == 0); + // Transaction 4: Repay the loan + auto payTx = pay(borrower, loanKeylet.key, IOU(1001)); + + // Calculate batch fee: 1 signer (broker as counterparty) + 4 transactions + auto const batchFee = batch::calcBatchFee(env, 1, 4); + + // Create the batch transaction with tfAllOrNothing + auto batchTxn = env.jt( + batch::outer(borrower, borrowerSeq, batchFee, tfAllOrNothing), + batch::inner(loanSetTx, borrowerSeq + 1), + batch::inner(buyTx, borrowerSeq + 2), + batch::inner(sellTx, borrowerSeq + 3), + batch::inner(payTx, borrowerSeq + 4), + batch::sig(broker)); + + env(batchTxn); + env.close(); + + // Verify that EVERYTHING was reverted: + // 1. Loan was NOT created + // 2. Borrower balance unchanged (except for fee) + // 3. LoanSequence unchanged + // 4. Market maker's offer still exists + // 5. Vault received NOTHING + + // Loan should NOT exist + auto const finalLoanSle = env.le(loanKeylet); + BEAST_EXPECT(!finalLoanSle); + + // Borrower balance should be unchanged (except for batch fee) + auto const finalBorrowerBalance = env.balance(borrower, IOU); + BEAST_EXPECT(finalBorrowerBalance == initialBorrowerBalance); + + // LoanSequence should be unchanged (loan was never created) + auto const finalBrokerSle = env.le(keylet::loanbroker(brokerInfo.brokerID)); + auto const finalLoanSequence = finalBrokerSle->at(sfLoanSequence); + BEAST_EXPECT(finalLoanSequence == initialLoanSequence); + + // Market maker's offer should still exist (not consumed) + env.require(offers(marketMaker1, 1)); + + // Verify vault received NOTHING (no loan was created, so no repayment) + auto const finalVaultSle = env.le(keylet::vault(brokerInfo.vaultID)); + BEAST_EXPECT(finalVaultSle); + if (finalVaultSle) + { + auto const finalVaultPseudoAccountID = finalVaultSle->at(sfAccount); + BEAST_EXPECT(finalVaultPseudoAccountID == initialVaultPseudoAccountID); + + // Vault pseudo-account balance should be unchanged + auto const finalVaultBalance = env.balance(initialVaultPseudoAccount, IOU); + BEAST_EXPECT(finalVaultBalance == initialVaultBalance); + + // AssetsAvailable should be unchanged + auto const finalAssetsAvailable = finalVaultSle->at(sfAssetsAvailable); + BEAST_EXPECT(finalAssetsAvailable == initialAssetsAvailable); + } } public: void run() override { - testInvalidLoanSet(); - - testCoverDepositWithdrawNonTransferableMPT(); - testPoC_UnsignedUnderflowOnFullPayAfterEarlyPeriodic(); - testDisabled(); - testSelfLoan(); - testIssuerLoan(); - testLoanSet(); - testLifecycle(); - testServiceFeeOnBrokerDeepFreeze(); - - testRPC(); - testInvalidLoanDelete(); - testInvalidLoanManage(); - testInvalidLoanPay(); - - testBatchBypassCounterparty(); - testLoanPayComputePeriodicPaymentValidRateInvariant(); - testAccountSendMptMinAmountInvariant(); - testLoanPayDebtDecreaseInvariant(); - testWrongMaxDebtBehavior(); - testLoanPayComputePeriodicPaymentValidTotalInterestInvariant(); - testDosLoanPay(); - testLoanPayComputePeriodicPaymentValidTotalPrincipalPaidInvariant(); - testLoanPayComputePeriodicPaymentValidTotalInterestPaidInvariant(); - testLoanNextPaymentDueDateOverflow(); - - testRequireAuth(); - testDustManipulation(); - - testRIPD3831(); - testRIPD3459(); - testRIPD3901(); - testRIPD3902(); - testRoundingAllowsUndercoverage(); - testBorrowerIsBroker(); - testIssuerIsBorrower(); - testLimitExceeded(); - testOverpaymentManagementFee(); - testLoanPayBrokerOwnerMissingTrustline(); - testLoanPayBrokerOwnerUnauthorizedMPT(); - testLoanPayBrokerOwnerNoPermissionedDomainMPT(); - testLoanSetBrokerOwnerNoPermissionedDomainMPT(); - testSequentialFLCDepletion(); + testCreateAsset(); + testLoanSetAndDelete(); + testLoanSetAndImpair(); + testLoanDefaultWithdrawAndPay(); + testRiskFreeArbitrage(); + testRiskFreeArbitrageFails(); } }; diff --git a/src/test/app/Loan/Loan_test.cpp b/src/test/app/Loan/Loan_test.cpp index 09dcca3e08..50a2eee02d 100644 --- a/src/test/app/Loan/Loan_test.cpp +++ b/src/test/app/Loan/Loan_test.cpp @@ -1,5 +1,6 @@ #include // +#include #include #include @@ -17,16 +18,9 @@ namespace xrpl { namespace test { -class Loan_test : public beast::unit_test::suite +class Loan_test : public LoanBase { 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}; - - std::string const iouCurrency{"IOU"}; - void testDisabled() { @@ -74,1307 +68,6 @@ protected: 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{}; - std::uint32_t flags = 0; - - Number - maxCoveredLoanValue(Number const& currentDebt) const - { - NumberRoundModeGuard mg(Number::downward); - 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; - uint256 vaultID; - BrokerParameters params; - BrokerInfo( - jtx::PrettyAsset const& asset_, - Keylet const& brokerKeylet_, - Keylet const& vaultKeylet_, - BrokerParameters const& p) - : asset(asset_), brokerID(brokerKeylet_.key), vaultID(vaultKeylet_.key), params(p) - { - } - - Keylet - brokerKeylet() const - { - return keylet::loanbroker(brokerID); - } - Keylet - vaultKeylet() const - { - return keylet::vault(vaultID); - } - - int - vaultScale(jtx::Env const& env) const - { - using namespace jtx; - - auto const vaultSle = env.le(keylet::vault(vaultID)); - return getAssetsTotalScale(vaultSle); - } - }; - - struct LoanParameters - { - // The account submitting the transaction. May be borrower or broker. - jtx::Account account; - // The counterparty. Should be the other of borrower or broker. - jtx::Account counter; - // Whether the counterparty is specified in the `counterparty` field, or - // only signs. - bool counterpartyExplicit = true; - Number principalRequest; - 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, BrokerInfo const& broker, FN const&... fN) const - { - using namespace jtx; - using namespace jtx::loan; - - JTx jt{loan::set(account, broker.brokerID, broker.asset(principalRequest).number(), flags.value_or(0))}; - - sig(sfCounterpartySignature, counter)(env, jt); - - fee{setFee.value_or(env.current()->fees().base * 2)}(env, jt); - - if (counterpartyExplicit) - counterparty(counter)(env, jt); - if (originationFee) - loanOriginationFee(broker.asset(*originationFee).number())(env, jt); - if (serviceFee) - loanServiceFee(broker.asset(*serviceFee).number())(env, jt); - if (lateFee) - latePaymentFee(broker.asset(*lateFee).number())(env, jt); - if (closeFee) - closePaymentFee(broker.asset(*closeFee).number())(env, jt); - if (overFee) - overpaymentFee (*overFee)(env, jt); - if (interest) - interestRate (*interest)(env, jt); - if (lateInterest) - lateInterestRate (*lateInterest)(env, jt); - if (closeInterest) - closeInterestRate (*closeInterest)(env, jt); - if (overpaymentInterest) - overpaymentInterestRate (*overpaymentInterest)(env, jt); - if (payTotal) - paymentTotal (*payTotal)(env, jt); - if (payInterval) - paymentInterval (*payInterval)(env, jt); - if (gracePd) - gracePeriod (*gracePd)(env, jt); - - return env.jt(jt, fN...); - } - }; - - struct PaymentParameters - { - Number overpaymentFactor = Number{1}; - std::optional overpaymentExtra = std::nullopt; - std::uint32_t flags = 0; - bool showStepBalances = false; - bool validateBalances = true; - - static PaymentParameters const& - defaults() - { - static PaymentParameters const result{}; - return result; - } - }; - - struct LoanState - { - std::uint32_t previousPaymentDate = 0; - NetClock::time_point startDate = {}; - std::uint32_t nextPaymentDate = 0; - std::uint32_t paymentRemaining = 0; - std::int32_t const loanScale = 0; - Number totalValue = 0; - Number principalOutstanding = 0; - Number managementFeeOutstanding = 0; - Number periodicPayment = 0; - std::uint32_t flags = 0; - std::uint32_t const paymentInterval = 0; - TenthBips32 const interestRate{}; - }; - - /** Helper class to compare the expected state of a loan and loan broker - * against the data in the ledger. - */ - struct VerifyLoanStatus - { - public: - jtx::Env const& env; - BrokerInfo const& broker; - jtx::Account const& pseudoAccount; - Keylet const& loanKeylet; - - VerifyLoanStatus( - jtx::Env const& env_, - BrokerInfo const& broker_, - jtx::Account const& pseudo_, - Keylet const& keylet_) - : env(env_), broker(broker_), pseudoAccount(pseudo_), loanKeylet(keylet_) - { - } - - /** Checks the expected broker state against the ledger - */ - void - checkBroker( - Number const& principalOutstanding, - Number const& interestOwed, - TenthBips32 interestRate, - std::uint32_t paymentInterval, - std::uint32_t paymentsRemaining, - std::uint32_t ownerCount) const - { - using namespace jtx; - if (auto brokerSle = env.le(keylet::loanbroker(broker.brokerID)); env.test.BEAST_EXPECT(brokerSle)) - { - TenthBips16 const managementFeeRate{brokerSle->at(sfManagementFeeRate)}; - auto const brokerDebt = brokerSle->at(sfDebtTotal); - auto const expectedDebt = principalOutstanding + interestOwed; - env.test.BEAST_EXPECT(brokerDebt == expectedDebt); - env.test.BEAST_EXPECT( - env.balance(pseudoAccount, broker.asset).number() == brokerSle->at(sfCoverAvailable)); - env.test.BEAST_EXPECT(brokerSle->at(sfOwnerCount) == ownerCount); - - if (auto vaultSle = env.le(keylet::vault(brokerSle->at(sfVaultID))); env.test.BEAST_EXPECT(vaultSle)) - { - Account const vaultPseudo{"vaultPseudoAccount", vaultSle->at(sfAccount)}; - env.test.BEAST_EXPECT( - vaultSle->at(sfAssetsAvailable) == env.balance(vaultPseudo, broker.asset).number()); - if (ownerCount == 0) - { - // Allow some slop for rounding IOUs - - // TODO: This needs to be an exact match once all the - // other rounding issues are worked out. - auto const total = vaultSle->at(sfAssetsTotal); - auto const available = vaultSle->at(sfAssetsAvailable); - env.test.BEAST_EXPECT( - total == available || - (!broker.asset.integral() && available != 0 && - ((total - available) / available < Number(1, -6)))); - env.test.BEAST_EXPECT(vaultSle->at(sfLossUnrealized) == 0); - } - } - } - } - - void - checkPayment( - std::int32_t loanScale, - jtx::Account const& account, - jtx::PrettyAmount const& balanceBefore, - STAmount const& expectedPayment, - jtx::PrettyAmount const& adjustment) const - { - auto const borrowerScale = std::max(loanScale, balanceBefore.number().exponent()); - - STAmount const balanceChangeAmount{ - broker.asset, roundToAsset(broker.asset, expectedPayment + adjustment, borrowerScale)}; - { - auto const difference = roundToScale( - env.balance(account, broker.asset) - (balanceBefore - balanceChangeAmount), borrowerScale); - env.test.expect( - roundToScale(difference, loanScale) >= beast::zero, - "Balance before: " + to_string(balanceBefore.value()) + - ", expected change: " + to_string(balanceChangeAmount) + - ", difference (balance after - expected): " + to_string(difference), - __FILE__, - __LINE__); - } - } - - /** Checks both the loan and broker expect states against the ledger */ - void - operator()( - std::uint32_t previousPaymentDate, - std::uint32_t nextPaymentDate, - std::uint32_t paymentRemaining, - Number const& loanScale, - Number const& totalValue, - Number const& principalOutstanding, - Number const& managementFeeOutstanding, - Number const& periodicPayment, - std::uint32_t flags) const - { - using namespace jtx; - if (auto loan = env.le(loanKeylet); env.test.BEAST_EXPECT(loan)) - { - env.test.BEAST_EXPECT(loan->at(sfPreviousPaymentDueDate) == previousPaymentDate); - env.test.BEAST_EXPECT(loan->at(sfPaymentRemaining) == paymentRemaining); - env.test.BEAST_EXPECT(loan->at(sfNextPaymentDueDate) == nextPaymentDate); - env.test.BEAST_EXPECT(loan->at(sfLoanScale) == loanScale); - env.test.BEAST_EXPECT(loan->at(sfTotalValueOutstanding) == totalValue); - env.test.BEAST_EXPECT(loan->at(sfPrincipalOutstanding) == principalOutstanding); - env.test.BEAST_EXPECT(loan->at(sfManagementFeeOutstanding) == managementFeeOutstanding); - env.test.BEAST_EXPECT(loan->at(sfPeriodicPayment) == periodicPayment); - env.test.BEAST_EXPECT(loan->at(sfFlags) == flags); - - auto const ls = constructRoundedLoanState(loan); - - auto const interestRate = TenthBips32{loan->at(sfInterestRate)}; - auto const paymentInterval = loan->at(sfPaymentInterval); - checkBroker(principalOutstanding, ls.interestDue, interestRate, paymentInterval, paymentRemaining, 1); - - if (auto brokerSle = env.le(keylet::loanbroker(broker.brokerID)); env.test.BEAST_EXPECT(brokerSle)) - { - if (auto vaultSle = env.le(keylet::vault(brokerSle->at(sfVaultID))); - env.test.BEAST_EXPECT(vaultSle)) - { - if ((flags & lsfLoanImpaired) && !(flags & lsfLoanDefault)) - { - env.test.BEAST_EXPECT( - vaultSle->at(sfLossUnrealized) == totalValue - managementFeeOutstanding); - } - else - { - env.test.BEAST_EXPECT(vaultSle->at(sfLossUnrealized) == 0); - } - } - } - } - } - - /** Checks both the loan and broker expect states against the ledger */ - void - operator()(LoanState const& state) const - { - operator()( - state.previousPaymentDate, - state.nextPaymentDate, - state.paymentRemaining, - state.loanScale, - state.totalValue, - state.principalOutstanding, - state.managementFeeOutstanding, - state.periodicPayment, - state.flags); - }; - }; - - BrokerInfo - createVaultAndBroker( - jtx::Env& env, - jtx::PrettyAsset const& asset, - jtx::Account const& lender, - BrokerParameters const& params = BrokerParameters::defaults()) - { - using namespace jtx; - - Vault vault{env}; - - auto const deposit = asset(params.vaultDeposit); - auto const debtMaximumValue = asset(params.debtMax).value(); - auto const coverDepositValue = asset(params.coverDeposit).value(); - - auto const coverRateMinValue = params.coverRateMin; - - auto [tx, vaultKeylet] = vault.create({.owner = lender, .asset = asset}); - env(tx); - env.close(); - BEAST_EXPECT(env.le(vaultKeylet)); - - env(vault.deposit({.depositor = lender, .id = vaultKeylet.key, .amount = deposit})); - env.close(); - if (auto const vault = env.le(keylet::vault(vaultKeylet.key)); BEAST_EXPECT(vault)) - { - BEAST_EXPECT(vault->at(sfAssetsAvailable) == deposit.value()); - } - - auto const keylet = keylet::loanbroker(lender.id(), env.seq(lender)); - - using namespace loanBroker; - env(set(lender, vaultKeylet.key, params.flags), - data(params.data), - managementFeeRate(params.managementFeeRate), - debtMaximum(debtMaximumValue), - coverRateMinimum(coverRateMinValue), - coverRateLiquidation(TenthBips32(params.coverRateLiquidation))); - - if (coverDepositValue != beast::zero) - env(coverDeposit(lender, keylet.key, coverDepositValue)); - - env.close(); - - return {asset, keylet, vaultKeylet, params}; - } - - /// Get the state without checking anything - LoanState - getCurrentState(jtx::Env const& env, BrokerInfo const& broker, Keylet const& loanKeylet) - { - using d = NetClock::duration; - using tp = NetClock::time_point; - - // Lookup the current loan state - if (auto loan = env.le(loanKeylet); BEAST_EXPECT(loan)) - { - return LoanState{ - .previousPaymentDate = loan->at(sfPreviousPaymentDueDate), - .startDate = tp{d{loan->at(sfStartDate)}}, - .nextPaymentDate = loan->at(sfNextPaymentDueDate), - .paymentRemaining = loan->at(sfPaymentRemaining), - .loanScale = loan->at(sfLoanScale), - .totalValue = loan->at(sfTotalValueOutstanding), - .principalOutstanding = loan->at(sfPrincipalOutstanding), - .managementFeeOutstanding = loan->at(sfManagementFeeOutstanding), - .periodicPayment = loan->at(sfPeriodicPayment), - .flags = loan->at(sfFlags), - .paymentInterval = loan->at(sfPaymentInterval), - .interestRate = TenthBips32{loan->at(sfInterestRate)}, - }; - } - return LoanState{}; - } - - /// Get the state and check the values against the parameters used in - /// `lifecycle` - LoanState - getCurrentState( - jtx::Env const& env, - BrokerInfo const& broker, - Keylet const& loanKeylet, - VerifyLoanStatus const& verifyLoanStatus) - { - using namespace std::chrono_literals; - using d = NetClock::duration; - using tp = NetClock::time_point; - - auto const state = getCurrentState(env, broker, loanKeylet); - BEAST_EXPECT(state.previousPaymentDate == 0); - BEAST_EXPECT(tp{d{state.nextPaymentDate}} == state.startDate + 600s); - BEAST_EXPECT(state.paymentRemaining == 12); - BEAST_EXPECT(state.principalOutstanding == broker.asset(1000).value()); - BEAST_EXPECT( - state.loanScale >= - (broker.asset.integral() ? 0 : std::max(broker.vaultScale(env), state.principalOutstanding.exponent()))); - BEAST_EXPECT(state.paymentInterval == 600); - { - NumberRoundModeGuard mg(Number::upward); - BEAST_EXPECT( - state.totalValue == - roundToAsset(broker.asset, state.periodicPayment * state.paymentRemaining, state.loanScale)); - } - BEAST_EXPECT( - state.managementFeeOutstanding == - computeManagementFee( - broker.asset, - state.totalValue - state.principalOutstanding, - broker.params.managementFeeRate, - state.loanScale)); - - verifyLoanStatus(state); - - return state; - } - - bool - canImpairLoan(jtx::Env const& env, BrokerInfo const& broker, LoanState const& state) - { - if (auto const brokerSle = env.le(keylet::loanbroker(broker.brokerID)); BEAST_EXPECT(brokerSle)) - { - if (auto const vaultSle = env.le(keylet::vault(brokerSle->at(sfVaultID))); BEAST_EXPECT(vaultSle)) - { - // log << vaultSle->getJson() << std::endl; - auto const assetsUnavailable = vaultSle->at(sfAssetsTotal) - vaultSle->at(sfAssetsAvailable); - auto const unrealizedLoss = - vaultSle->at(sfLossUnrealized) + state.totalValue - state.managementFeeOutstanding; - - if (!BEAST_EXPECT(unrealizedLoss <= assetsUnavailable)) - { - return false; - } - } - } - return true; - } - - enum class AssetType { XRP = 0, IOU = 1, MPT = 2 }; - - // Specify the accounts as params to allow other accounts to be used - jtx::PrettyAsset - createAsset( - jtx::Env& env, - AssetType assetType, - BrokerParameters const& brokerParams, - jtx::Account const& issuer, - jtx::Account const& lender, - jtx::Account const& borrower) - { - using namespace jtx; - - switch (assetType) - { - case AssetType::XRP: - // TODO: remove the factor, and set up loans in drops - return PrettyAsset{xrpIssue(), 1'000'000}; - - case AssetType::IOU: { - PrettyAsset const asset{issuer[iouCurrency]}; - - auto const limit = asset(100 * (brokerParams.vaultDeposit + brokerParams.coverDeposit)); - if (lender != issuer) - env(trust(lender, limit)); - if (borrower != issuer) - env(trust(borrower, limit)); - - return asset; - } - - case AssetType::MPT: { - // Enough to cover initial fees - if (!env.le(keylet::account(issuer))) - env.fund(env.current()->fees().accountReserve(10) * 10, issuer); - if (!env.le(keylet::account(lender))) - env.fund(env.current()->fees().accountReserve(10) * 10, noripple(lender)); - if (!env.le(keylet::account(borrower))) - env.fund(env.current()->fees().accountReserve(10) * 10, noripple(borrower)); - - 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 - if (lender != issuer) - mptt.authorize({.account = lender}); - if (borrower != issuer) - mptt.authorize({.account = borrower}); - - env.close(); - - return asset; - } - - default: - throw std::runtime_error("Unknown asset type"); - } - } - - void - describeLoan( - jtx::Env& env, - BrokerParameters const& brokerParams, - LoanParameters const& loanParams, - AssetType assetType, - jtx::Account const& issuer, - jtx::Account const& lender, - jtx::Account const& borrower) - { - using namespace jtx; - - auto const asset = createAsset(env, assetType, brokerParams, issuer, lender, borrower); - auto const principal = asset(loanParams.principalRequest).number(); - auto const interest = loanParams.interest.value_or(TenthBips32{}); - auto const interval = loanParams.payInterval.value_or(LoanSet::defaultPaymentInterval); - auto const total = loanParams.payTotal.value_or(LoanSet::defaultPaymentTotal); - auto const feeRate = brokerParams.managementFeeRate; - auto const props = computeLoanProperties( - asset, principal, interest, interval, total, feeRate, asset(brokerParams.vaultDeposit).number().exponent()); - log << "Loan properties:\n" - << "\tPrincipal: " << principal << std::endl - << "\tInterest rate: " << interest << std::endl - << "\tPayment interval: " << interval << std::endl - << "\tManagement Fee Rate: " << feeRate << std::endl - << "\tTotal Payments: " << total << std::endl - << "\tPeriodic Payment: " << props.periodicPayment << std::endl - << "\tTotal Value: " << props.loanState.valueOutstanding << std::endl - << "\tManagement Fee: " << props.loanState.managementFeeDue << std::endl - << "\tLoan Scale: " << props.loanScale << std::endl - << "\tFirst payment principal: " << props.firstPaymentPrincipal << std::endl; - - // checkGuards returns a TER, so success is 0 - BEAST_EXPECT(!checkLoanGuards( - asset, - asset(loanParams.principalRequest).number(), - loanParams.interest.value_or(TenthBips32{}) != beast::zero, - loanParams.payTotal.value_or(LoanSet::defaultPaymentTotal), - props, - env.journal)); - } - - std::optional> - createLoan( - jtx::Env& env, - AssetType assetType, - BrokerParameters const& brokerParams, - LoanParameters const& loanParams, - jtx::Account const& issuer, - jtx::Account const& lender, - jtx::Account const& borrower) - { - using namespace jtx; - - // Enough to cover initial fees - env.fund(env.current()->fees().accountReserve(10) * 10, issuer); - if (lender != issuer) - env.fund(env.current()->fees().accountReserve(10) * 10, noripple(lender)); - if (borrower != issuer && borrower != lender) - env.fund(env.current()->fees().accountReserve(10) * 10, noripple(borrower)); - - describeLoan(env, brokerParams, loanParams, assetType, issuer, lender, borrower); - - // Make the asset - auto const asset = createAsset(env, assetType, brokerParams, issuer, lender, borrower); - - env.close(); - if (asset.native() || lender != issuer) - env( - pay((asset.native() ? env.master : issuer), - lender, - asset(brokerParams.vaultDeposit + brokerParams.coverDeposit))); - // Fund the borrower later once we know the total loan - // size - - BrokerInfo const broker = createVaultAndBroker(env, asset, lender, brokerParams); - - auto const pseudoAcctOpt = [&]() -> std::optional { - auto const brokerSle = env.le(keylet::loanbroker(broker.brokerID)); - if (!BEAST_EXPECT(brokerSle)) - return std::nullopt; - auto const brokerPseudo = brokerSle->at(sfAccount); - return Account("Broker pseudo-account", brokerPseudo); - }(); - if (!pseudoAcctOpt) - return std::nullopt; - Account const& pseudoAcct = *pseudoAcctOpt; - - auto const loanKeyletOpt = [&]() -> std::optional { - auto const brokerSle = env.le(keylet::loanbroker(broker.brokerID)); - if (!BEAST_EXPECT(brokerSle)) - return std::nullopt; - - // Broker has no loans - BEAST_EXPECT(brokerSle->at(sfOwnerCount) == 0); - - // The loan keylet is based on the LoanSequence of the - // _LOAN_BROKER_ object. - auto const loanSequence = brokerSle->at(sfLoanSequence); - return keylet::loan(broker.brokerID, loanSequence); - }(); - if (!loanKeyletOpt) - return std::nullopt; - Keylet const& loanKeylet = *loanKeyletOpt; - - env(loanParams(env, broker)); - - env.close(); - - return std::make_tuple(broker, loanKeylet, pseudoAcct); - } - - void - topUpBorrower( - jtx::Env& env, - BrokerInfo const& broker, - jtx::Account const& issuer, - jtx::Account const& borrower, - LoanState const& state, - std::optional const& servFee) - { - using namespace jtx; - - STAmount const serviceFee = broker.asset(servFee.value_or(0)); - - // Ensure the borrower has enough funds to make the payments - // (including tx fees, if necessary) - auto const borrowerBalance = env.balance(borrower, broker.asset); - - auto const baseFee = env.current()->fees().base; - - // Add extra for transaction fees and reserves, if appropriate, or a - // tiny amount for the extra paid in each transaction - auto const totalNeeded = state.totalValue + (serviceFee * state.paymentRemaining) + - (broker.asset.native() ? Number( - baseFee * state.paymentRemaining + - env.current()->fees().accountReserve(env.ownerCount(borrower))) - : broker.asset(15).number()); - - auto const shortage = totalNeeded - borrowerBalance.number(); - - if (shortage > beast::zero && (broker.asset.native() || issuer != borrower)) - env(pay((broker.asset.native() ? env.master : issuer), borrower, STAmount{broker.asset, shortage})); - } - - void - makeLoanPayments( - jtx::Env& env, - BrokerInfo const& broker, - LoanParameters const& loanParams, - Keylet const& loanKeylet, - VerifyLoanStatus const& verifyLoanStatus, - jtx::Account const& issuer, - jtx::Account const& lender, - jtx::Account const& borrower, - PaymentParameters const& paymentParams = PaymentParameters::defaults()) - { - // Make all the individual payments - using namespace jtx; - using namespace jtx::loan; - using namespace std::chrono_literals; - using d = NetClock::duration; - - bool const showStepBalances = paymentParams.showStepBalances; - - auto const currencyLabel = getCurrencyLabel(broker.asset); - - auto const baseFee = env.current()->fees().base; - - env.close(); - auto state = getCurrentState(env, broker, loanKeylet); - - verifyLoanStatus(state); - - STAmount const serviceFee = broker.asset(loanParams.serviceFee.value_or(0)); - - topUpBorrower(env, broker, issuer, borrower, state, loanParams.serviceFee); - - // Periodic payment amount will consist of - // 1. principal outstanding (1000) - // 2. interest interest rate (at 12%) - // 3. payment interval (600s) - // 4. loan service fee (2) - // Calculate these values without the helper functions - // to verify they're working correctly The numbers in - // the below BEAST_EXPECTs may not hold across assets. - auto const periodicRate = loanPeriodicRate(state.interestRate, state.paymentInterval); - STAmount const roundedPeriodicPayment{ - broker.asset, roundPeriodicPayment(broker.asset, state.periodicPayment, state.loanScale)}; - - if (!showStepBalances) - log << currencyLabel << " Payment components: " - << "Payments remaining, " - << "rawInterest, rawPrincipal, " - "rawMFee, " - << "trackedValueDelta, trackedPrincipalDelta, " - "trackedInterestDelta, trackedMgmtFeeDelta, special" - << std::endl; - - // Include the service fee - STAmount const totalDue = roundToScale(roundedPeriodicPayment + serviceFee, state.loanScale, Number::upward); - - auto currentRoundedState = - constructLoanState(state.totalValue, state.principalOutstanding, state.managementFeeOutstanding); - { - auto const raw = computeTheoreticalLoanState( - state.periodicPayment, periodicRate, state.paymentRemaining, broker.params.managementFeeRate); - - if (showStepBalances) - { - log << currencyLabel << " Starting loan balances: " - << "\n\tTotal value: " << currentRoundedState.valueOutstanding - << "\n\tPrincipal: " << currentRoundedState.principalOutstanding - << "\n\tInterest: " << currentRoundedState.interestDue - << "\n\tMgmt fee: " << currentRoundedState.managementFeeDue << "\n\tPayments remaining " - << state.paymentRemaining << std::endl; - } - else - { - log << currencyLabel << " Loan starting state: " << state.paymentRemaining << ", " << raw.interestDue - << ", " << raw.principalOutstanding << ", " << raw.managementFeeDue << ", " - << currentRoundedState.valueOutstanding << ", " << currentRoundedState.principalOutstanding << ", " - << currentRoundedState.interestDue << ", " << currentRoundedState.managementFeeDue << std::endl; - } - } - - // Try to pay a little extra to show that it's _not_ - // taken - auto const extraAmount = paymentParams.overpaymentExtra - ? broker.asset(*paymentParams.overpaymentExtra).value() - : std::min(broker.asset(10).value(), STAmount{broker.asset, totalDue / 20}); - - STAmount const transactionAmount = - STAmount{broker.asset, totalDue * paymentParams.overpaymentFactor} + extraAmount; - - auto const borrowerInitialBalance = env.balance(borrower, broker.asset).number(); - auto const initialState = state; - detail::PaymentComponents totalPaid{ - .trackedValueDelta = 0, .trackedPrincipalDelta = 0, .trackedManagementFeeDelta = 0}; - Number totalInterestPaid = 0; - Number totalFeesPaid = 0; - std::size_t totalPaymentsMade = 0; - - xrpl::LoanState currentTrueState = computeTheoreticalLoanState( - state.periodicPayment, periodicRate, state.paymentRemaining, broker.params.managementFeeRate); - - auto validateBorrowerBalance = [&]() { - if (borrower == issuer || !paymentParams.validateBalances) - return; - auto const totalSpent = - (totalPaid.trackedValueDelta + totalFeesPaid + - (broker.asset.native() ? Number(baseFee) * totalPaymentsMade : numZero)); - BEAST_EXPECT(env.balance(borrower, broker.asset).number() == borrowerInitialBalance - totalSpent); - }; - - auto const defaultRound = broker.asset.integral() ? 3 : 0; - auto truncate = [defaultRound](Number const& n, std::optional places = std::nullopt) { - auto const p = places.value_or(defaultRound); - if (p == 0) - return n; - auto const factor = Number{1, p}; - return (n * factor).truncate() / factor; - }; - while (state.paymentRemaining > 0) - { - validateBorrowerBalance(); - // Compute the expected principal amount - auto const paymentComponents = 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); - - xrpl::LoanState const nextTrueState = computeTheoreticalLoanState( - state.periodicPayment, periodicRate, state.paymentRemaining - 1, broker.params.managementFeeRate); - detail::LoanStateDeltas const deltas = currentTrueState - nextTrueState; - BEAST_EXPECT(deltas.total() == deltas.principal + deltas.interest + deltas.managementFee); - BEAST_EXPECT( - paymentComponents.specialCase == detail::PaymentSpecialCase::final || - deltas.total() == state.periodicPayment || - (state.loanScale - (deltas.total() - state.periodicPayment).exponent()) > 14); - - if (!showStepBalances) - log << currencyLabel << " Payment components: " << state.paymentRemaining << ", " - - << deltas.interest << ", " << deltas.principal << ", " << deltas.managementFee << ", " - << paymentComponents.trackedValueDelta << ", " << paymentComponents.trackedPrincipalDelta << ", " - << paymentComponents.trackedInterestPart() << ", " << paymentComponents.trackedManagementFeeDelta - << ", " - << (paymentComponents.specialCase == detail::PaymentSpecialCase::final ? "final" - : paymentComponents.specialCase == detail::PaymentSpecialCase::extra ? "extra" - : "none") - << std::endl; - - auto const totalDueAmount = STAmount{broker.asset, paymentComponents.trackedValueDelta + serviceFee}; - - if (paymentParams.validateBalances) - { - // Due to the rounding algorithms to keep the interest and - // principal in sync with "true" values, the computed amount - // may be a little less than the rounded fixed payment - // amount. For integral types, the difference should be < 3 - // (1 unit for each of the interest and management fee). For - // IOUs, the difference should be dust. - Number const diff = totalDue - totalDueAmount; - BEAST_EXPECT( - paymentComponents.specialCase == 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, paymentParams.flags)); - - env.close(d{state.paymentInterval / 2}); - - if (paymentParams.validateBalances) - { - // Need to account for fees if the loan is in XRP - PrettyAmount adjustment = broker.asset(0); - if (broker.asset.native()) - { - adjustment = env.current()->fees().base; - } - - // Check the result - verifyLoanStatus.checkPayment( - state.loanScale, borrower, borrowerBalanceBeforePayment, totalDueAmount, adjustment); - } - - if (showStepBalances) - { - auto const loanSle = env.le(loanKeylet); - if (!BEAST_EXPECT(loanSle)) - // No reason for this not to exist - return; - auto const current = constructRoundedLoanState(loanSle); - auto const errors = nextTrueState - current; - log << currencyLabel << " Loan balances: " - << "\n\tAmount taken: " << paymentComponents.trackedValueDelta - << "\n\tTotal value: " << current.valueOutstanding - << " (true: " << truncate(nextTrueState.valueOutstanding) << ", error: " << truncate(errors.total()) - << ")\n\tPrincipal: " << current.principalOutstanding - << " (true: " << truncate(nextTrueState.principalOutstanding) - << ", error: " << truncate(errors.principal) << ")\n\tInterest: " << current.interestDue - << " (true: " << truncate(nextTrueState.interestDue) << ", error: " << truncate(errors.interest) - << ")\n\tMgmt fee: " << current.managementFeeDue - << " (true: " << truncate(nextTrueState.managementFeeDue) - << ", error: " << truncate(errors.managementFee) << ")\n\tPayments remaining " - << loanSle->at(sfPaymentRemaining) << std::endl; - - currentRoundedState = current; - } - - --state.paymentRemaining; - state.previousPaymentDate = state.nextPaymentDate; - if (paymentComponents.specialCase == detail::PaymentSpecialCase::final) - { - state.paymentRemaining = 0; - state.nextPaymentDate = 0; - } - else - { - state.nextPaymentDate += state.paymentInterval; - } - state.principalOutstanding -= paymentComponents.trackedPrincipalDelta; - state.managementFeeOutstanding -= paymentComponents.trackedManagementFeeDelta; - state.totalValue -= paymentComponents.trackedValueDelta; - - if (paymentParams.validateBalances) - verifyLoanStatus(state); - - totalPaid.trackedValueDelta += paymentComponents.trackedValueDelta; - totalPaid.trackedPrincipalDelta += paymentComponents.trackedPrincipalDelta; - totalPaid.trackedManagementFeeDelta += paymentComponents.trackedManagementFeeDelta; - totalInterestPaid += paymentComponents.trackedInterestPart(); - totalFeesPaid += serviceFee; - ++totalPaymentsMade; - - currentTrueState = nextTrueState; - } - validateBorrowerBalance(); - - // Loan is paid off - BEAST_EXPECT(state.paymentRemaining == 0); - BEAST_EXPECT(state.principalOutstanding == 0); - - auto const initialInterestDue = - initialState.totalValue - (initialState.principalOutstanding + initialState.managementFeeOutstanding); - if (paymentParams.validateBalances) - { - // Make sure all the payments add up - BEAST_EXPECT(totalPaid.trackedValueDelta == initialState.totalValue); - BEAST_EXPECT(totalPaid.trackedPrincipalDelta == initialState.principalOutstanding); - BEAST_EXPECT(totalPaid.trackedManagementFeeDelta == initialState.managementFeeOutstanding); - // This is almost a tautology given the previous checks, but - // check it anyway for completeness. - BEAST_EXPECT(totalInterestPaid == initialInterestDue); - BEAST_EXPECT(totalPaymentsMade == initialState.paymentRemaining); - } - - if (showStepBalances) - { - auto const loanSle = env.le(loanKeylet); - if (!BEAST_EXPECT(loanSle)) - // No reason for this not to exist - return; - log << currencyLabel << " Total amounts paid: " - << "\n\tTotal value: " << totalPaid.trackedValueDelta - << " (initial: " << truncate(initialState.totalValue) - << ", error: " << truncate(initialState.totalValue - totalPaid.trackedValueDelta) - << ")\n\tPrincipal: " << totalPaid.trackedPrincipalDelta - << " (initial: " << truncate(initialState.principalOutstanding) - << ", error: " << truncate(initialState.principalOutstanding - totalPaid.trackedPrincipalDelta) - << ")\n\tInterest: " << totalInterestPaid << " (initial: " << truncate(initialInterestDue) - << ", error: " << truncate(initialInterestDue - totalInterestPaid) - << ")\n\tMgmt fee: " << totalPaid.trackedManagementFeeDelta - << " (initial: " << truncate(initialState.managementFeeOutstanding) - << ", error: " << truncate(initialState.managementFeeOutstanding - totalPaid.trackedManagementFeeDelta) - << ")\n\tTotal payments made: " << totalPaymentsMade << std::endl; - } - } - - void - runLoan(AssetType assetType, BrokerParameters const& brokerParams, LoanParameters const& loanParams) - { - using namespace jtx; - - Account const issuer("issuer"); - Account const lender("lender"); - Account const borrower("borrower"); - - Env env(*this, all); - - auto loanResult = createLoan(env, assetType, brokerParams, loanParams, issuer, lender, borrower); - if (!BEAST_EXPECT(loanResult)) - return; - - auto broker = std::get(*loanResult); - auto loanKeylet = std::get(*loanResult); - auto pseudoAcct = std::get(*loanResult); - - VerifyLoanStatus verifyLoanStatus(env, broker, pseudoAcct, loanKeylet); - - makeLoanPayments( - env, - broker, - loanParams, - loanKeylet, - verifyLoanStatus, - issuer, - lender, - borrower, - PaymentParameters{.showStepBalances = true}); - } - - /** Runs through the complete lifecycle of a loan - * - * 1. Create a loan. - * 2. Test a bunch of transaction failure conditions. - * 3. Use the `toEndOfLife` callback to take the loan to 0. How that is done - * depends on the callback. e.g. Default, Early payoff, make all the - * normal payments, etc. - * 4. Delete the loan. The loan will alternate between being deleted by the - * lender and the borrower. - */ - void - lifecycle( - std::string const& caseLabel, - char const* label, - jtx::Env& env, - Number const& loanAmount, - int interestExponent, - jtx::Account const& lender, - jtx::Account const& borrower, - jtx::Account const& evan, - BrokerInfo const& broker, - jtx::Account const& pseudoAcct, - std::uint32_t flags, - // The end of life callback is expected to take the loan to 0 payments - // remaining, one way or another - std::function toEndOfLife) - { - auto const [keylet, loanSequence] = [&]() { - auto const brokerSle = env.le(keylet::loanbroker(broker.brokerID)); - if (!BEAST_EXPECT(brokerSle)) - // will be invalid - return std::make_pair(keylet::loan(broker.brokerID), std::uint32_t(0)); - - // Broker has no loans - BEAST_EXPECT(brokerSle->at(sfOwnerCount) == 0); - - // The loan keylet is based on the LoanSequence of the _LOAN_BROKER_ - // object. - auto const loanSequence = brokerSle->at(sfLoanSequence); - return std::make_pair(keylet::loan(broker.brokerID, loanSequence), loanSequence); - }(); - - VerifyLoanStatus const verifyLoanStatus(env, broker, pseudoAcct, keylet); - - // No loans yet - verifyLoanStatus.checkBroker(0, 0, TenthBips32{0}, 1, 0, 0); - - if (!BEAST_EXPECT(loanSequence != 0)) - return; - - testcase << caseLabel << " " << label; - - using namespace jtx; - using namespace loan; - using namespace std::chrono_literals; - - auto applyExponent = [interestExponent, this](TenthBips32 value) mutable { - BEAST_EXPECT(value > TenthBips32(0)); - while (interestExponent > 0) - { - auto const oldValue = value; - value *= 10; - --interestExponent; - BEAST_EXPECT(value / 10 == oldValue); - } - while (interestExponent < 0) - { - auto const oldValue = value; - value /= 10; - ++interestExponent; - BEAST_EXPECT(value * 10 == oldValue); - } - return value; - }; - - auto const borrowerOwnerCount = env.ownerCount(borrower); - - auto const loanSetFee = env.current()->fees().base * 2; - LoanParameters const loanParams{ - .account = borrower, - .counter = lender, - .counterpartyExplicit = false, - .principalRequest = loanAmount, - .setFee = loanSetFee, - .originationFee = 1, - .serviceFee = 2, - .lateFee = 3, - .closeFee = 4, - .overFee = applyExponent(percentageToTenthBips(5) / 10), - .interest = applyExponent(percentageToTenthBips(12)), - // 2.4% - .lateInterest = applyExponent(percentageToTenthBips(24) / 10), - .closeInterest = applyExponent(percentageToTenthBips(36) / 10), - .overpaymentInterest = applyExponent(percentageToTenthBips(48) / 10), - .payTotal = 12, - .payInterval = 600, - .gracePd = 60, - .flags = flags, - }; - Number const principalRequestAmount = broker.asset(loanParams.principalRequest).value(); - auto const originationFeeAmount = broker.asset(*loanParams.originationFee).value(); - auto const serviceFeeAmount = broker.asset(*loanParams.serviceFee).value(); - auto const lateFeeAmount = broker.asset(*loanParams.lateFee).value(); - auto const closeFeeAmount = broker.asset(*loanParams.closeFee).value(); - - auto const borrowerStartbalance = env.balance(borrower, broker.asset); - - auto createJtx = loanParams(env, broker); - // Successfully create a Loan - env(createJtx); - - env.close(); - - auto const startDate = env.current()->header().parentCloseTime.time_since_epoch().count(); - - if (auto const brokerSle = env.le(keylet::loanbroker(broker.brokerID)); BEAST_EXPECT(brokerSle)) - { - BEAST_EXPECT(brokerSle->at(sfOwnerCount) == 1); - } - - { - // Need to account for fees if the loan is in XRP - PrettyAmount adjustment = broker.asset(0); - if (broker.asset.native()) - { - adjustment = 2 * env.current()->fees().base; - } - - BEAST_EXPECT( - env.balance(borrower, broker.asset).value() == - borrowerStartbalance.value() + principalRequestAmount - originationFeeAmount - adjustment.value()); - } - - auto const loanFlags = createJtx.stx->isFlag(tfLoanOverpayment) ? lsfLoanOverpayment : LedgerSpecificFlags(0); - - if (auto loan = env.le(keylet); BEAST_EXPECT(loan)) - { - // log << "loan after create: " << to_string(loan->getJson()) - // << std::endl; - BEAST_EXPECT(loan->isFlag(lsfLoanOverpayment) == createJtx.stx->isFlag(tfLoanOverpayment)); - BEAST_EXPECT(loan->at(sfLoanSequence) == loanSequence); - BEAST_EXPECT(loan->at(sfBorrower) == borrower.id()); - BEAST_EXPECT(loan->at(sfLoanBrokerID) == broker.brokerID); - BEAST_EXPECT(loan->at(sfLoanOriginationFee) == originationFeeAmount); - BEAST_EXPECT(loan->at(sfLoanServiceFee) == serviceFeeAmount); - BEAST_EXPECT(loan->at(sfLatePaymentFee) == lateFeeAmount); - BEAST_EXPECT(loan->at(sfClosePaymentFee) == closeFeeAmount); - BEAST_EXPECT(loan->at(sfOverpaymentFee) == *loanParams.overFee); - BEAST_EXPECT(loan->at(sfInterestRate) == *loanParams.interest); - BEAST_EXPECT(loan->at(sfLateInterestRate) == *loanParams.lateInterest); - BEAST_EXPECT(loan->at(sfCloseInterestRate) == *loanParams.closeInterest); - BEAST_EXPECT(loan->at(sfOverpaymentInterestRate) == *loanParams.overpaymentInterest); - BEAST_EXPECT(loan->at(sfStartDate) == startDate); - BEAST_EXPECT(loan->at(sfPaymentInterval) == *loanParams.payInterval); - BEAST_EXPECT(loan->at(sfGracePeriod) == *loanParams.gracePd); - BEAST_EXPECT(loan->at(sfPreviousPaymentDueDate) == 0); - BEAST_EXPECT(loan->at(sfNextPaymentDueDate) == startDate + *loanParams.payInterval); - BEAST_EXPECT(loan->at(sfPaymentRemaining) == *loanParams.payTotal); - BEAST_EXPECT( - loan->at(sfLoanScale) >= - (broker.asset.integral() ? 0 : std::max(broker.vaultScale(env), principalRequestAmount.exponent()))); - BEAST_EXPECT(loan->at(sfPrincipalOutstanding) == principalRequestAmount); - } - - auto state = getCurrentState(env, broker, keylet, verifyLoanStatus); - - auto const loanProperties = computeLoanProperties( - broker.asset.raw(), - state.principalOutstanding, - state.interestRate, - state.paymentInterval, - state.paymentRemaining, - broker.params.managementFeeRate, - state.loanScale); - - verifyLoanStatus( - 0, - startDate + *loanParams.payInterval, - *loanParams.payTotal, - state.loanScale, - loanProperties.loanState.valueOutstanding, - principalRequestAmount, - loanProperties.loanState.managementFeeDue, - loanProperties.periodicPayment, - loanFlags | 0); - - // Manage the loan - // no-op - env(manage(lender, keylet.key, 0)); - { - // no flags - auto jt = manage(lender, keylet.key, 0); - jt.removeMember(sfFlags.getName()); - env(jt); - } - // Only the lender can manage - env(manage(evan, keylet.key, 0), ter(tecNO_PERMISSION)); - // unknown flags - env(manage(lender, keylet.key, tfLoanManageMask), ter(temINVALID_FLAG)); - // combinations of flags are not allowed - env(manage(lender, keylet.key, tfLoanUnimpair | tfLoanImpair), ter(temINVALID_FLAG)); - env(manage(lender, keylet.key, tfLoanImpair | tfLoanDefault), ter(temINVALID_FLAG)); - env(manage(lender, keylet.key, tfLoanUnimpair | tfLoanDefault), ter(temINVALID_FLAG)); - env(manage(lender, keylet.key, tfLoanUnimpair | tfLoanImpair | tfLoanDefault), ter(temINVALID_FLAG)); - // invalid loan ID - env(manage(lender, broker.brokerID, tfLoanImpair), ter(tecNO_ENTRY)); - // Loan is unimpaired, can't unimpair it again - env(manage(lender, keylet.key, tfLoanUnimpair), ter(tecNO_PERMISSION)); - // Loan is unimpaired, it can go into default, but only after it's past - // due - env(manage(lender, keylet.key, tfLoanDefault), ter(tecTOO_SOON)); - - // Check the vault - bool const canImpair = canImpairLoan(env, broker, state); - // Impair the loan, if possible - env(manage(lender, keylet.key, tfLoanImpair), canImpair ? ter(tesSUCCESS) : ter(tecLIMIT_EXCEEDED)); - // Unimpair the loan - env(manage(lender, keylet.key, tfLoanUnimpair), canImpair ? ter(tesSUCCESS) : ter(tecNO_PERMISSION)); - - auto const nextDueDate = startDate + *loanParams.payInterval; - - env.close(); - - verifyLoanStatus( - 0, - nextDueDate, - *loanParams.payTotal, - loanProperties.loanScale, - loanProperties.loanState.valueOutstanding, - principalRequestAmount, - loanProperties.loanState.managementFeeDue, - loanProperties.periodicPayment, - loanFlags | 0); - - // Can't delete the loan yet. It has payments remaining. - env(del(lender, keylet.key), ter(tecHAS_OBLIGATIONS)); - - if (BEAST_EXPECT(toEndOfLife)) - toEndOfLife(keylet, verifyLoanStatus); - env.close(); - - // Verify the loan is at EOL - if (auto loan = env.le(keylet); BEAST_EXPECT(loan)) - { - BEAST_EXPECT(loan->at(sfPaymentRemaining) == 0); - BEAST_EXPECT(loan->at(sfPrincipalOutstanding) == 0); - } - auto const borrowerStartingBalance = env.balance(borrower, broker.asset); - - // Try to delete the loan broker with an active loan - env(loanBroker::del(lender, broker.brokerID), ter(tecHAS_OBLIGATIONS)); - // Ensure the above tx doesn't get ordered after the LoanDelete and - // delete our broker! - env.close(); - - // Test failure cases - env(del(lender, keylet.key, tfLoanOverpayment), ter(temINVALID_FLAG)); - env(del(evan, keylet.key), ter(tecNO_PERMISSION)); - env(del(lender, broker.brokerID), ter(tecNO_ENTRY)); - - // Delete the loan - // Either the borrower or the lender can delete the loan. Alternate - // between who does it across tests. - static unsigned deleteCounter = 0; - auto const deleter = ++deleteCounter % 2 ? lender : borrower; - env(del(deleter, keylet.key)); - env.close(); - - PrettyAmount adjustment = broker.asset(0); - if (deleter == borrower) - { - // Need to account for fees if the loan is in XRP - if (broker.asset.native()) - { - adjustment = env.current()->fees().base; - } - } - - // No loans left - verifyLoanStatus.checkBroker(0, 0, *loanParams.interest, 1, 0, 0); - - BEAST_EXPECT(env.balance(borrower, broker.asset).value() == borrowerStartingBalance.value() - adjustment); - BEAST_EXPECT(env.ownerCount(borrower) == borrowerOwnerCount); - - if (auto const brokerSle = env.le(keylet::loanbroker(broker.brokerID)); BEAST_EXPECT(brokerSle)) - { - BEAST_EXPECT(brokerSle->at(sfOwnerCount) == 0); - } - } - - 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 * @@ -6691,8 +5384,7 @@ protected: static constexpr std::int64_t BORROWER_INITIAL_IOU = 100'000; static constexpr std::int64_t DEPOSIT_AMOUNT = 5'000; static constexpr std::int64_t PRINCIPAL_AMOUNT = 99; - static constexpr std::uint64_t EXPECTED_SHARES_PER_DEPOSITOR = - 5'000'000'000; + static constexpr std::uint64_t EXPECTED_SHARES_PER_DEPOSITOR = 5'000'000'000; static constexpr std::uint32_t PAYMENT_INTERVAL = 600; static constexpr std::uint32_t PAYMENT_TOTAL = 2; @@ -6705,13 +5397,7 @@ protected: Account const depositorB{"lpB"}; Account const borrower{"borrowerA"}; - env.fund( - XRP(INITIAL_FUNDING), - issuer, - lender, - depositorA, - depositorB, - borrower); + env.fund(XRP(INITIAL_FUNDING), issuer, lender, depositorA, depositorB, borrower); env.close(); // Setup trust lines @@ -6752,8 +5438,7 @@ protected: if (!BEAST_EXPECT(sleBroker)) return; - auto const loanKeylet = - keylet::loan(broker.brokerID, sleBroker->at(sfLoanSequence)); + auto const loanKeylet = keylet::loan(broker.brokerID, sleBroker->at(sfLoanSequence)); env(set(borrower, broker.brokerID, PRINCIPAL_AMOUNT), sig(sfCounterpartySignature, lender), @@ -6772,16 +5457,12 @@ protected: if (!BEAST_EXPECT(vaultAfterImpair)) return; - BEAST_EXPECT( - vaultAfterImpair->at(sfLossUnrealized) == - broker.asset(PRINCIPAL_AMOUNT).value()); + BEAST_EXPECT(vaultAfterImpair->at(sfLossUnrealized) == broker.asset(PRINCIPAL_AMOUNT).value()); // Helper to get share balance for a depositor auto const shareAsset = vaultAfterImpair->at(sfShareMPTID); - auto const getShareBalance = - [&](Account const& depositor) -> std::uint64_t { - auto const token = - env.le(keylet::mptoken(shareAsset, depositor.id())); + auto const getShareBalance = [&](Account const& depositor) -> std::uint64_t { + auto const token = env.le(keylet::mptoken(shareAsset, depositor.id())); return token ? token->getFieldU64(sfMPTAmount) : 0; }; @@ -6793,14 +5474,9 @@ protected: BEAST_EXPECT(sharesLpA == sharesLpB); // Helper to attempt withdrawal - auto const attemptWithdrawShares = [&](Account const& depositor, - std::uint64_t shareAmount, - TER expected) { + auto const attemptWithdrawShares = [&](Account const& depositor, std::uint64_t shareAmount, TER expected) { STAmount const shareAmt{MPTIssue{shareAsset}, Number(shareAmount)}; - env(v.withdraw( - {.depositor = depositor, - .id = broker.vaultKeylet().key, - .amount = shareAmt}), + env(v.withdraw({.depositor = depositor, .id = broker.vaultKeylet().key, .amount = shareAmt}), ter(expected)); env.close(); }; diff --git a/src/test/jtx/impl/vault.cpp b/src/test/jtx/impl/vault.cpp index 90250aece0..7366d95445 100644 --- a/src/test/jtx/impl/vault.cpp +++ b/src/test/jtx/impl/vault.cpp @@ -14,7 +14,8 @@ namespace jtx { std::tuple Vault::create(CreateArgs const& args) { - auto keylet = keylet::vault(args.owner.id(), env.seq(args.owner)); + auto sequence = args.sequence ? *args.sequence : env.seq(args.owner); + auto keylet = keylet::vault(args.owner.id(), sequence); Json::Value jv; jv[jss::TransactionType] = jss::VaultCreate; jv[jss::Account] = args.owner.human(); diff --git a/src/test/jtx/vault.h b/src/test/jtx/vault.h index 65a5706354..c2a32792ca 100644 --- a/src/test/jtx/vault.h +++ b/src/test/jtx/vault.h @@ -26,6 +26,7 @@ struct Vault Account owner; Asset asset; std::optional flags{}; + std::optional sequence; }; /** Return a VaultCreate transaction and the Vault's expected keylet. */