#include // #include #include #include #include #include #include #include #include #include namespace xrpl { namespace test { class Loan_test : public beast::unit_test::suite { protected: // Ensure that all the features needed for Lending Protocol are included, // even if they are set to unsupported. FeatureBitset const all{ jtx::testable_amendments() | featureMPTokensV1 | featureSingleAssetVault | featureLendingProtocol}; std::string const iouCurrency{"IOU"}; void testDisabled() { testcase("Disabled"); // Lending Protocol depends on Single Asset Vault (SAV). Test // combinations of the two amendments. // Single Asset Vault depends on MPTokensV1, but don't test every combo // of that. using namespace jtx; auto failAll = [this](FeatureBitset features) { Env env(*this, features); Account const alice{"alice"}; Account const bob{"bob"}; env.fund(XRP(10000), alice, bob); auto const keylet = keylet::loanbroker(alice, env.seq(alice)); using namespace std::chrono_literals; using namespace loan; // counter party signature is optional on LoanSet. Confirm that by // sending transaction without one. auto setTx = env.jt(set(alice, keylet.key, Number(10000)), ter(temDISABLED)); env(setTx); // All loan transactions are disabled. // 1. LoanSet setTx = env.jt(setTx, sig(sfCounterpartySignature, bob), ter(temDISABLED)); env(setTx); // Actual sequence will be based off the loan broker, but we // obviously don't have one of those if the amendment is disabled auto const loanKeylet = keylet::loan(keylet.key, env.seq(alice)); // Other Loan transactions are disabled, too. // 2. LoanDelete env(del(alice, loanKeylet.key), ter(temDISABLED)); // 3. LoanManage env(manage(alice, loanKeylet.key, tfLoanImpair), ter(temDISABLED)); // 4. LoanPay env(pay(alice, loanKeylet.key, XRP(500)), ter(temDISABLED)); }; failAll(all - featureMPTokensV1); failAll(all - featureSingleAssetVault - featureLendingProtocol); failAll(all - featureSingleAssetVault); failAll(all - featureLendingProtocol); } struct BrokerParameters { Number vaultDeposit = 1'000'000; Number debtMax = 25'000; TenthBips32 coverRateMin = percentageToTenthBips(10); int coverDeposit = 1000; TenthBips16 managementFeeRate{100}; TenthBips32 coverRateLiquidation = percentageToTenthBips(25); std::string data = {}; // NOLINT(readability-redundant-member-init) std::uint32_t flags = 0; 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; // NOLINTBEGIN(readability-redundant-member-init) std::optional setFee = std::nullopt; std::optional originationFee = std::nullopt; std::optional serviceFee = std::nullopt; std::optional lateFee = std::nullopt; std::optional closeFee = std::nullopt; std::optional overFee = std::nullopt; std::optional interest = std::nullopt; std::optional lateInterest = std::nullopt; std::optional closeInterest = std::nullopt; std::optional overpaymentInterest = std::nullopt; std::optional payTotal = std::nullopt; std::optional payInterval = std::nullopt; std::optional gracePd = std::nullopt; std::optional flags = std::nullopt; // NOLINTEND(readability-redundant-member-init) template jtx::JTx operator()(jtx::Env& env, BrokerInfo const& broker, FN const&... fN) const { using namespace jtx; using namespace jtx::loan; JTx jt{loan::set( account, broker.brokerID, broker.asset(principalRequest).number(), flags.value_or(0))}; sig(sfCounterpartySignature, counter)(env, jt); fee{setFee.value_or(env.current()->fees().base * 2)}(env, jt); if (counterpartyExplicit) 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) != 0u) && ((flags & lsfLoanDefault) == 0u)) { 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); } static void topUpBorrower( jtx::Env& env, BrokerInfo const& broker, jtx::Account const& issuer, jtx::Account const& borrower, LoanState const& state, std::optional const& servFee) { using namespace jtx; STAmount const serviceFee = broker.asset(servFee.value_or(0)); // Ensure the borrower has enough funds to make the payments // (including tx fees, if necessary) auto const borrowerBalance = env.balance(borrower, broker.asset); auto const baseFee = env.current()->fees().base; // Add extra for transaction fees and reserves, if appropriate, or a // tiny amount for the extra paid in each transaction auto const totalNeeded = state.totalValue + (serviceFee * state.paymentRemaining) + (broker.asset.native() ? Number( baseFee * state.paymentRemaining + 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 << ", " << [&]() -> char const* { if (paymentComponents.specialCase == detail::PaymentSpecialCase::final) return "final"; if (paymentComponents.specialCase == detail::PaymentSpecialCase::extra) return "extra"; return "none"; }() << std::endl; } auto const totalDueAmount = STAmount{broker.asset, paymentComponents.trackedValueDelta + serviceFee}; if (paymentParams.validateBalances) { // Due to the rounding algorithms to keep the interest and // principal in sync with "true" values, the computed amount // may be a little less than the rounded fixed payment // amount. For integral types, the difference should be < 3 // (1 unit for each of the interest and management fee). For // IOUs, the difference should be dust. Number const diff = totalDue - totalDueAmount; BEAST_EXPECT( paymentComponents.specialCase == 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); !loanResult.has_value()) 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) != 0u) ? lender : borrower; env(del(deleter, keylet.key)); env.close(); PrettyAmount adjustment = broker.asset(0); if (deleter == borrower) { // Need to account for fees if the loan is in XRP if (broker.asset.native()) { adjustment = env.current()->fees().base; } } // No loans left verifyLoanStatus.checkBroker(0, 0, *loanParams.interest, 1, 0, 0); BEAST_EXPECT( env.balance(borrower, broker.asset).value() == borrowerStartingBalance.value() - adjustment); BEAST_EXPECT(env.ownerCount(borrower) == borrowerOwnerCount); if (auto const brokerSle = env.le(keylet::loanbroker(broker.brokerID)); BEAST_EXPECT(brokerSle)) { BEAST_EXPECT(brokerSle->at(sfOwnerCount) == 0); } } static std::string getCurrencyLabel(Asset const& asset) { if (asset.native()) return "XRP"; if (asset.holds()) return "IOU"; if (asset.holds()) return "MPT"; return "Unknown"; } /** Wrapper to run a series of lifecycle tests for a given asset and loan * amount * * Will be used in the future to vary the loan parameters. For now, it is * only called once. * * Tests a bunch of LoanSet failure conditions before lifecycle. */ template void testCaseWrapper( jtx::Env& env, jtx::MPTTester& mptt, std::array const& assets, BrokerInfo const& broker, Number const& loanAmount, int interestExponent) { using namespace jtx; using namespace Lending; auto const& asset = broker.asset.raw(); auto const currencyLabel = getCurrencyLabel(asset); auto const caseLabel = [&]() { std::stringstream ss; ss << "Lifecycle: " << loanAmount << " " << currencyLabel << " Scale interest to: " << interestExponent << " "; return ss.str(); }(); testcase << caseLabel; using namespace loan; using namespace std::chrono_literals; using d = NetClock::duration; using tp = NetClock::time_point; Account const issuer{"issuer"}; // For simplicity, lender will be the sole actor for the vault & // brokers. Account const lender{"lender"}; // Borrower only wants to borrow Account const borrower{"borrower"}; // Evan will attempt to be naughty Account const evan{"evan"}; // Do not fund alice Account const alice{"alice"}; Number const principalRequest = broker.asset(loanAmount).value(); Number const maxCoveredLoanValue = broker.params.maxCoveredLoanValue(0); BEAST_EXPECT(maxCoveredLoanValue == 1000 * 100 / 10); Number const maxCoveredLoanRequest = broker.asset(maxCoveredLoanValue).value(); Number const totalVaultRequest = broker.asset(broker.params.vaultDeposit).value(); Number const debtMaximumRequest = broker.asset(broker.params.debtMax).value(); auto const loanSetFee = fee(env.current()->fees().base * 2); auto const pseudoAcct = [&]() { auto const brokerSle = env.le(keylet::loanbroker(broker.brokerID)); if (!BEAST_EXPECT(brokerSle)) return Account{lender}; auto const brokerPseudo = brokerSle->at(sfAccount); return Account("Broker pseudo-account", brokerPseudo); }(); auto const baseFee = env.current()->fees().base; auto badKeylet = keylet::vault(lender.id(), env.seq(lender)); // Try some failure cases // flags are checked first env(set(evan, broker.brokerID, principalRequest, tfLoanSetMask), sig(sfCounterpartySignature, lender), loanSetFee, ter(temINVALID_FLAG)); // field length validation // sfData: good length, bad account env(set(evan, broker.brokerID, principalRequest), sig(sfCounterpartySignature, borrower), 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 Account{lender}; } auto vaultPseudo = Account("Vault pseudo-account", vaultSle->at(sfAccount)); return vaultPseudo; }(); auto const [freeze, deepfreeze, unfreeze, expectedResult] = [&]() -> std::tuple< std::function, std::function, std::function, TER> { // Freeze / lock the asset std::function empty; if (broker.asset.native()) { // XRP can't be frozen return std::make_tuple(empty, empty, empty, tesSUCCESS); } if (broker.asset.holds()) { auto freeze = [&](Account const& holder) { env(trust(issuer, holder[iouCurrency](0), tfSetFreeze)); }; auto deepfreeze = [&](Account const& holder) { env(trust(issuer, holder[iouCurrency](0), tfSetFreeze | tfSetDeepFreeze)); }; auto unfreeze = [&](Account const& holder) { env(trust( issuer, holder[iouCurrency](0), tfClearFreeze | tfClearDeepFreeze)); }; return std::make_tuple(freeze, deepfreeze, unfreeze, tecFROZEN); } auto freeze = [&](Account const& holder) { mptt.set({.account = issuer, .holder = holder, .flags = tfMPTLock}); }; auto unfreeze = [&](Account const& holder) { mptt.set({.account = issuer, .holder = holder, .flags = tfMPTUnlock}); }; return std::make_tuple(freeze, empty, unfreeze, tecLOCKED); }(); // Try freezing the accounts that can't be frozen if (freeze) { for (auto const& account : {vaultPseudo, evan}) { // Freeze the account freeze(account); // Try to create a loan with a frozen line env(set(evan, broker.brokerID, debtMaximumRequest), sig(sfCounterpartySignature, lender), loanSetFee, ter(expectedResult)); // Unfreeze the account BEAST_EXPECT(unfreeze); unfreeze(account); // Ensure the line is unfrozen with a request that is fine // except too it requests more principal than the broker can // carry env(set(evan, broker.brokerID, debtMaximumRequest + 1), sig(sfCounterpartySignature, lender), loanSetFee, ter(tecLIMIT_EXCEEDED)); } } // Deep freeze the borrower, which prevents them from receiving // funds if (deepfreeze) { // Make sure evan has a trust line that so the issuer can // freeze it. (Don't need to do this for the borrower, // because LoanSet will create a line to the borrower // automatically.) env(trust(evan, issuer[iouCurrency](100'000))); for (auto const& account : {// these accounts can't be frozen, which deep freeze // implies vaultPseudo, evan, // these accounts can't be deep frozen lender}) { // Freeze evan deepfreeze(account); // Try to create a loan with a deep frozen line env(set(evan, broker.brokerID, debtMaximumRequest), sig(sfCounterpartySignature, lender), loanSetFee, ter(expectedResult)); // Unfreeze evan BEAST_EXPECT(unfreeze); unfreeze(account); // Ensure the line is unfrozen with a request that is fine // except too it requests more principal than the broker can // carry env(set(evan, broker.brokerID, debtMaximumRequest + 1), sig(sfCounterpartySignature, lender), loanSetFee, ter(tecLIMIT_EXCEEDED)); } } } // Finally! Create a loan auto coverAvailable = [&env, this](uint256 const& brokerID, Number const& expected) { if (auto const brokerSle = env.le(keylet::loanbroker(brokerID)); BEAST_EXPECT(brokerSle)) { auto const available = brokerSle->at(sfCoverAvailable); BEAST_EXPECT(available == expected); return available; } return Number{}; }; auto getDefaultInfo = [&env, this](LoanState const& state, BrokerInfo const& broker) { if (auto const brokerSle = env.le(keylet::loanbroker(broker.brokerID)); BEAST_EXPECT(brokerSle)) { BEAST_EXPECT( state.loanScale >= (broker.asset.integral() ? 0 : std::max( broker.vaultScale(env), state.principalOutstanding.exponent()))); NumberRoundModeGuard 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 << ", " << [&]() -> char const* { if (paymentComponents.specialCase == detail::PaymentSpecialCase::final) return "final"; if (paymentComponents.specialCase == detail::PaymentSpecialCase::extra) return "extra"; return "none"; }(); auto const totalDueAmount = STAmount{ broker.asset, paymentComponents.trackedValueDelta + serviceFee.number()}; // Due to the rounding algorithms to keep the interest and // principal in sync with "true" values, the computed amount // may be a little less than the rounded fixed payment // amount. For integral types, the difference should be < 3 // (1 unit for each of the interest and management fee). For // IOUs, the difference should be after the 8th digit. Number const diff = totalDue - totalDueAmount; BEAST_EXPECT( paymentComponents.specialCase == 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 LOAN_TODO // TODO /* LoanPay fails with tecINVARIANT_FAILED error when loan_broker(also borrower) tries to do the payment. Here's the scenario: Create a XRP loan with loan broker as borrower, loan origination fee and loan service fee. Loan broker makes the first payment with periodic payment and loan service fee. */ auto time = [&](std::string label, std::function timed) { if (!BEAST_EXPECT(timed)) return; using clock_type = std::chrono::steady_clock; using duration_type = std::chrono::milliseconds; auto const start = clock_type::now(); timed(); auto const duration = std::chrono::duration_cast(clock_type::now() - start); log << label << " took " << duration.count() << "ms" << std::endl; return duration; }; lifecycle( caseLabel, "timing", env, loanAmount, interestExponent, lender, borrower, evan, broker, pseudoAcct, tfLoanOverpayment, [&](Keylet const& loanKeylet, VerifyLoanStatus const& verifyLoanStatus) { // Estimate optimal values for 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 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; brokers.reserve(assets.size()); for (auto const& asset : assets) { brokers.emplace_back(createVaultAndBroker(env, asset, lender)); } if (mptTest) (mptTest)(env, brokers[0], mptt); if (iouTest) (iouTest)(env, brokers[1]); }; testCase( [&, this](Env& env, BrokerInfo const& broker, auto&) { using namespace loan; Number const principalRequest = broker.asset(1'000).value(); testcase("MPT issuer is borrower, issuer submits"); env(set(issuer, broker.brokerID, principalRequest), 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)); }, 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)); }, 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; brokers.reserve(assets.size()); for (auto const& asset : assets) { brokers.emplace_back(createVaultAndBroker( env, asset, lender, BrokerParameters{.data = "spam spam spam spam"})); } // Create and update Loans for (auto const& broker : brokers) { for (int amountExponent = 3; amountExponent >= 3; --amountExponent) { Number const loanAmount{1, amountExponent}; for (int interestExponent = 0; interestExponent >= 0; --interestExponent) { testCaseWrapper(env, mptt, assets, broker, loanAmount, interestExponent); } } if (auto brokerSle = env.le(keylet::loanbroker(broker.brokerID)); BEAST_EXPECT(brokerSle)) { BEAST_EXPECT(brokerSle->at(sfOwnerCount) == 0); BEAST_EXPECT(brokerSle->at(sfDebtTotal) == 0); auto const coverAvailable = brokerSle->at(sfCoverAvailable); env(loanBroker::coverWithdraw( lender, broker.brokerID, STAmount(broker.asset, coverAvailable))); env.close(); brokerSle = env.le(keylet::loanbroker(broker.brokerID)); BEAST_EXPECT(brokerSle && brokerSle->at(sfCoverAvailable) == 0); } // Verify we can delete the loan broker env(loanBroker::del(lender, broker.brokerID)); env.close(); } } void testSelfLoan() { 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"; bool const lendingBatchEnabled = !std::any_of( Batch::disabledTxTypes.begin(), Batch::disabledTxTypes.end(), [](auto const& disabled) { return disabled == ttLOAN_BROKER_SET; }); 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(lendingBatchEnabled ? temBAD_SIGNATURE : temINVALID_INNER_BATCH)); 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)); env.close(); BEAST_EXPECT(!env.le(keylet)); Number const actualPrincipal{6}; createJson[sfPrincipalRequested] = actualPrincipal; createJson.removeMember(sfSequence.jsonName); createJson = env.json(createJson, sig(sfCounterpartySignature, lender)); // Fails in doApply because the payment is too small to be // represented as XRP. env(createJson, ter(tecPRECISION_LOSS)); env.close(); } void testRPC() { // 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"; Account borrower{borrowerPass, KeyType::ed25519}; auto const lenderPass = "lender"; 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))); env(pay(issuer, lender, IOU(5'000'000))); BrokerInfo brokerInfo{createVaultAndBroker(env, issuer["IOU"], lender)}; auto const loanSetFee = fee(env.current()->fees().base * 2); STAmount const debtMaximumRequest = brokerInfo.asset(1'000).value(); env(set(borrower, brokerInfo.brokerID, debtMaximumRequest), sig(sfCounterpartySignature, lender), loanSetFee); env.close(); std::uint32_t const loanSequence = 1; auto const loanKeylet = keylet::loan(brokerInfo.brokerID, loanSequence); env(fset(issuer, asfGlobalFreeze)); env.close(); // preclaim: tecFROZEN env(pay(borrower, loanKeylet.key, debtMaximumRequest), ter(tecFROZEN)); env.close(); env(fclear(issuer, asfGlobalFreeze)); env.close(); auto const pseudoBroker = [&]() -> std::optional { if (auto brokerSle = env.le(keylet::loanbroker(brokerInfo.brokerID)); BEAST_EXPECT(brokerSle)) { return Account{"pseudo", brokerSle->at(sfAccount)}; } return std::nullopt; }(); if (!pseudoBroker) return; // Lender and pseudoaccount must both be frozen env(trust(issuer, lender["IOU"](1'000), lender, tfSetFreeze | tfSetDeepFreeze)); env(trust( issuer, (*pseudoBroker)["IOU"](1'000), *pseudoBroker, tfSetFreeze | tfSetDeepFreeze)); env.close(); // preclaim: tecFROZEN due to deep frozen env(pay(borrower, loanKeylet.key, debtMaximumRequest), ter(tecFROZEN)); env.close(); // Only one needs to be unfrozen env(trust(issuer, lender["IOU"](1'000), tfClearFreeze | tfClearDeepFreeze)); env.close(); // The payment is late by this point env(pay(borrower, loanKeylet.key, debtMaximumRequest), ter(tecEXPIRED)); env.close(); env(pay(borrower, loanKeylet.key, debtMaximumRequest, tfLoanLatePayment)); env.close(); // preclaim: tecKILLED // note that tecKILLED in loanMakePayment() // doesn't happen because of the preclaim check. env(pay(borrower, loanKeylet.key, debtMaximumRequest), ter(tecKILLED)); } void testInvalidLoanSet() { testcase("Invalid LoanSet"); using namespace jtx; using namespace loan; Account const lender{"lender"}; Account const issuer{"issuer"}; Account const borrower{"borrower"}; 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 Account{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); env.close(); auto loanPayTx = env.json(pay(borrower, keylet.key, STAmount{broker.asset, Number{}})); loanPayTx["Amount"]["value"] = "0.000281284125490196"; env(loanPayTx, ter(tecINSUFFICIENT_PAYMENT)); env.close(); } void testDosLoanPay() { // 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))); } } #if LOAN_TODO void testLoanPayLateFullPaymentBypassesPenalties() { testcase("LoanPay full payment skips late penalties"); using namespace jtx; using namespace loan; using namespace std::chrono_literals; Env env(*this, all); Account const issuer{"issuer"}; Account const lender{"lender"}; Account const borrower{"borrower"}; env.fund(XRP(1'000'000), issuer, lender, borrower); env.close(); PrettyAsset const asset = issuer[iouCurrency]; env(trust(lender, asset(100'000'000))); env(trust(borrower, asset(100'000'000))); env(pay(issuer, lender, asset(50'000'000))); env(pay(issuer, borrower, asset(5'000'000))); env.close(); BrokerInfo broker{createVaultAndBroker(env, asset, lender)}; auto const loanSetFee = fee(env.current()->fees().base * 2); auto const brokerPreLoan = env.le(keylet::loanbroker(broker.brokerID)); if (BEAST_EXPECT(brokerPreLoan); !brokerPreLoan.has_value()) return; auto const loanSequence = brokerPreLoan->at(sfLoanSequence); auto const loanKeylet = keylet::loan(broker.brokerID, loanSequence); Number const principal = asset(1'000).value(); Number const serviceFee = asset(2).value(); Number const lateFee = asset(5).value(); Number const closeFee = asset(4).value(); env(set(borrower, broker.brokerID, principal), sig(sfCounterpartySignature, lender), loanServiceFee(serviceFee), latePaymentFee(lateFee), closePaymentFee(closeFee), interestRate(percentageToTenthBips(12)), lateInterestRate(percentageToTenthBips(24) / 10), closeInterestRate(percentageToTenthBips(5)), paymentTotal(12), paymentInterval(600), gracePeriod(0), fee(loanSetFee)); env.close(); auto state1 = getCurrentState(env, broker, loanKeylet); if (!BEAST_EXPECT(state1.paymentRemaining > 1)) return; using d = NetClock::duration; using tp = NetClock::time_point; auto const overdueClose = tp{d{state1.nextPaymentDate + state1.paymentInterval}}; env.close(overdueClose); auto const brokerSle = env.le(keylet::loanbroker(broker.brokerID)); auto const loanSle = env.le(loanKeylet); if (!BEAST_EXPECT(brokerSle && loanSle)) return; auto state = getCurrentState(env, broker, loanKeylet); TenthBips16 const managementFeeRate{brokerSle->at(sfManagementFeeRate)}; TenthBips32 const interestRateValue{loanSle->at(sfInterestRate)}; TenthBips32 const lateInterestRateValue{loanSle->at(sfLateInterestRate)}; TenthBips32 const closeInterestRateValue{loanSle->at(sfCloseInterestRate)}; Number const closePaymentFeeRounded = roundToAsset(broker.asset, loanSle->at(sfClosePaymentFee), state.loanScale); Number const latePaymentFeeRounded = roundToAsset(broker.asset, loanSle->at(sfLatePaymentFee), state.loanScale); auto const roundedLoanState = constructLoanState( state.totalValue, state.principalOutstanding, state.managementFeeOutstanding); Number const totalInterestOutstanding = roundedLoanState.interestDue; auto const periodicRate = loanPeriodicRate(interestRateValue, state.paymentInterval); auto const rawLoanState = computeTheoreticalLoanState( state.periodicPayment, periodicRate, state.paymentRemaining, managementFeeRate); auto const parentCloseTime = env.current()->parentCloseTime(); auto const startDateSeconds = static_cast(state.startDate.time_since_epoch().count()); Number const fullPaymentInterest = computeFullPaymentInterest( rawLoanState.principalOutstanding, periodicRate, parentCloseTime, state.paymentInterval, state.previousPaymentDate, startDateSeconds, closeInterestRateValue); Number const roundedFullInterestAmount = roundToAsset(broker.asset, fullPaymentInterest, state.loanScale); Number const roundedFullManagementFee = computeManagementFee( broker.asset, roundedFullInterestAmount, managementFeeRate, state.loanScale); Number const roundedFullInterest = roundedFullInterestAmount - roundedFullManagementFee; Number const trackedValueDelta = state.principalOutstanding + totalInterestOutstanding + state.managementFeeOutstanding; Number const untrackedManagementFee = closePaymentFeeRounded + roundedFullManagementFee - state.managementFeeOutstanding; Number const untrackedInterest = roundedFullInterest - totalInterestOutstanding; Number const baseFullDue = trackedValueDelta + untrackedInterest + untrackedManagementFee; BEAST_EXPECT(baseFullDue == roundToAsset(broker.asset, baseFullDue, state.loanScale)); auto const overdueSeconds = parentCloseTime.time_since_epoch().count() - state.nextPaymentDate; if (!BEAST_EXPECT(overdueSeconds > 0)) return; Number const overdueRate = loanPeriodicRate(lateInterestRateValue, overdueSeconds); Number const lateInterestRaw = state.principalOutstanding * overdueRate; Number const lateInterestRounded = roundToAsset(broker.asset, lateInterestRaw, state.loanScale); Number const lateManagementFeeRounded = computeManagementFee( broker.asset, lateInterestRounded, managementFeeRate, state.loanScale); Number const penaltyDue = lateInterestRounded + lateManagementFeeRounded + latePaymentFeeRounded; BEAST_EXPECT(penaltyDue > Number{}); auto const balanceBefore = env.balance(borrower, broker.asset).number(); STAmount const paymentAmount{broker.asset.raw(), baseFullDue}; env(pay(borrower, loanKeylet.key, paymentAmount, tfLoanFullPayment)); env.close(); if (auto const meta = env.meta(); BEAST_EXPECT(meta)) BEAST_EXPECT(meta->at(sfTransactionResult) == tesSUCCESS); auto const balanceAfter = env.balance(borrower, broker.asset).number(); Number const actualPaid = balanceBefore - balanceAfter; BEAST_EXPECT(actualPaid == baseFullDue); Number const expectedWithPenalty = baseFullDue + penaltyDue; BEAST_EXPECT(expectedWithPenalty > actualPaid); BEAST_EXPECT(expectedWithPenalty - actualPaid == penaltyDue); } void testLoanCoverMinimumRoundingExploit() { auto testLoanCoverMinimumRoundingExploit = [&, this](Number const& principalRequest) { testcase << "LoanBrokerCoverClawback drains cover via rounding" << " principalRequested=" << to_string(principalRequest); using namespace jtx; using namespace loan; using namespace loanBroker; Env env(*this, all); Account const issuer{"issuer"}; Account const lender{"lender"}; Account const borrower{"borrower"}; env.fund(XRP(1'000'000'000), issuer, lender, borrower); env.close(); env(fset(issuer, asfAllowTrustLineClawback)); env.close(); PrettyAsset const asset = issuer[iouCurrency]; env(trust(lender, asset(2'000'0000))); env(trust(borrower, asset(2'000'0000))); env.close(); env(pay(issuer, lender, asset(2'000'0000))); env.close(); BrokerParameters brokerParams{.debtMax = 0, .coverRateMin = TenthBips32{10'000}}; BrokerInfo broker{createVaultAndBroker(env, asset, lender, brokerParams)}; auto const loanSetFee = fee(env.current()->fees().base * 2); auto createTx = env.jt( set(borrower, broker.brokerID, principalRequest), sig(sfCounterpartySignature, lender), loanSetFee, paymentInterval(600), paymentTotal(1), gracePeriod(60)); env(createTx); env.close(); auto const brokerBefore = env.le(keylet::loanbroker(broker.brokerID)); BEAST_EXPECT(brokerBefore); if (!brokerBefore) return; Number const debtOutstanding = brokerBefore->at(sfDebtTotal); Number const coverAvailableBefore = brokerBefore->at(sfCoverAvailable); BEAST_EXPECT(debtOutstanding > Number{}); BEAST_EXPECT(coverAvailableBefore > Number{}); log << "debt=" << to_string(debtOutstanding) << " cover_available=" << to_string(coverAvailableBefore); env(coverClawback(issuer, 0), loanBrokerID(broker.brokerID)); env.close(); auto const brokerAfter = env.le(keylet::loanbroker(broker.brokerID)); BEAST_EXPECT(brokerAfter); if (!brokerAfter) return; Number const debtAfter = brokerAfter->at(sfDebtTotal); // the debt has not changed BEAST_EXPECT(debtAfter == debtOutstanding); Number const coverAvailableAfter = brokerAfter->at(sfCoverAvailable); // since the cover rate min != 0, the cover available should not // be zero BEAST_EXPECT(coverAvailableAfter != Number{}); }; // Call the lambda with different principal values testLoanCoverMinimumRoundingExploit(Number{1, -30}); // 1e-30 units testLoanCoverMinimumRoundingExploit(Number{1, -20}); // 1e-20 units testLoanCoverMinimumRoundingExploit(Number{1, -10}); // 1e-10 units testLoanCoverMinimumRoundingExploit(Number{1, 1}); // 1e-10 units } #endif 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; LoanParameters const loanParams{ .account = lender, .counter = borrower, .principalRequest = Number{100}, .interest = TenthBips32{1922}, .payTotal = 5816, .payInterval = 86400 * 6, .gracePd = 86400 * 5, }; env(loanParams(env, broker)); env.close(); // Wait for loan to be late enough to default env.close(std::chrono::seconds(86400 * 40)); // 40 days { auto const vaultSle = env.le(vaultKeylet); Number 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); } } void testRIPD3831() { 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); !loanResult.has_value()) return; auto broker = std::get(*loanResult); auto loanKeylet = std::get(*loanResult); using tp = NetClock::time_point; using d = NetClock::duration; auto state = getCurrentState(env, broker, loanKeylet); if (auto loan = env.le(loanKeylet); BEAST_EXPECT(loan)) { env.close(tp{d{loan->at(sfNextPaymentDueDate) + loan->at(sfGracePeriod) + 1}}); } topUpBorrower(env, broker, issuer, borrower, state, loanParams.serviceFee); using namespace jtx::loan; auto jv = pay(borrower, loanKeylet.key, drops(XRPAmount(state.totalValue))); { auto const submitParam = to_string(jv); auto const jr = env.rpc("submit", borrower.name(), submitParam); BEAST_EXPECT(jr.isMember(jss::result)); auto const jResult = jr[jss::result]; } env.close(); // Make sure the system keeps responding env(noop(borrower)); env.close(); env(noop(issuer)); env.close(); env(noop(lender)); env.close(); } void testRIPD3459() { testcase("RIPD-3459 - LoanBroker incorrect debt total"); using namespace jtx; Account const issuer("issuer"); Account const lender("lender"); Account const borrower("borrower"); BrokerParameters const brokerParams{ .vaultDeposit = 200'000, .debtMax = 0, .coverRateMin = TenthBips32{0}, .managementFeeRate = TenthBips16{500}, .coverRateLiquidation = TenthBips32{0}}; LoanParameters const loanParams{ .account = lender, .counter = borrower, .principalRequest = Number{100'000, -4}, .interest = TenthBips32{100'000}, .payTotal = 10}; auto const assetType = AssetType::MPT; Env env(*this, all); auto loanResult = createLoan(env, assetType, brokerParams, loanParams, issuer, lender, borrower); if (BEAST_EXPECT(loanResult); !loanResult.has_value()) return; auto broker = std::get(*loanResult); auto loanKeylet = std::get(*loanResult); auto pseudoAcct = std::get(*loanResult); VerifyLoanStatus 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); !loanResult.has_value()) 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"}; 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); !loanResult.has_value()) 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); !loanResult.has_value()) 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(trust(borrower, IOU(20'000'000))); env(pay(issuer, broker, IOU(10'000'000))); env(pay(issuer, borrower, IOU(1'000))); env.close(); // Create vault and broker auto const brokerInfo = createVaultAndBroker(env, IOU, broker); // Create a loan first (this creates debt) auto const keylet = keylet::loan(brokerInfo.brokerID, 1); env(set(borrower, brokerInfo.brokerID, 10'000), sig(sfCounterpartySignature, broker), loanServiceFee(IOU(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 = IOU(50'000).value(); env(loanBroker::coverDeposit(broker, brokerInfo.brokerID, STAmount{IOU, additionalCover})); env.close(); // Verify broker owner has a trustline auto const brokerTrustline = keylet::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)) { Account const pseudo("pseudo-account", brokerSle->at(sfAccount)); auto const balance = env.balance(pseudo, IOU); // 1,000 default + 50,000 extra + 100 service fee from LoanPay BEAST_EXPECTS(balance == IOU(51'100), to_string(Json::Value(balance))); } } void testLoanPayBrokerOwnerUnauthorizedMPT() { 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"; using namespace jtx; using namespace loan; using namespace loanBroker; Env env(*this, all); Account const issuer{"issuer"}; Account const lender{"lender"}; Account const borrowerA{"borrowerA"}; Account const borrowerB{"borrowerB"}; env.fund(XRP(1'000'000), issuer, lender, borrowerA, borrowerB); env.close(); PrettyAsset const asset = xrpIssue(); auto const vaultDepositAmount = asset(200'000); // Enough for 2 x 50k loans plus interest/fees auto const brokerInfo = createVaultAndBroker( env, asset, lender, { .vaultDeposit = vaultDepositAmount.value(), .debtMax = 0, .coverRateMin = TenthBips32(20000), // 20% .coverDeposit = 21'000, .managementFeeRate = TenthBips16(100), // 0.1% .coverRateLiquidation = TenthBips32(100000), }); auto const brokerKeylet = brokerInfo.brokerKeylet(); // Create two identical loans: each 50,000 XRP principal (scaled down to // avoid funding issues) Total DebtTotal will be ~100,000 XRP (principal // + interest) Formula will calculate cover as: 100% × (20% × 100,000) = // 20,000 XRP So we need FLC = 20,000 XRP to be fully consumed by first // default auto const principalAmount = Number(50'000); auto const loanPaymentInterval = 2592000; // 30 days auto const loanGracePeriod = 604800; // 7 days // Create Loan A auto loanATx = env.jt( set(borrowerA, brokerKeylet.key, principalAmount), sig(sfCounterpartySignature, lender), interestRate(TenthBips32(500)), // 5% paymentTotal(12), loan::paymentInterval(loanPaymentInterval), loan::gracePeriod(loanGracePeriod), fee(XRP(10))); // Sufficient fee for multi-sig transaction env(loanATx); env.close(); auto const loanAKeylet = keylet::loan(brokerKeylet.key, 1); // Create Loan B auto loanBTx = env.jt( set(borrowerB, brokerKeylet.key, principalAmount), sig(sfCounterpartySignature, lender), interestRate(TenthBips32(500)), // 5% paymentTotal(12), loan::paymentInterval(loanPaymentInterval), loan::gracePeriod(loanGracePeriod), fee(XRP(10))); // Sufficient fee for multi-sig transaction env(loanBTx); env.close(); auto const loanBKeylet = keylet::loan(brokerKeylet.key, 2); auto loanASle = env.le(loanAKeylet); if (!BEAST_EXPECT(loanASle)) return; // Advance time past grace period for both loans to be defaultable auto const loanANextDue = loanASle->at(sfNextPaymentDueDate); auto const loanAGrace = loanASle->at(sfGracePeriod); env.close(std::chrono::seconds{loanANextDue + loanAGrace + 60}); env(manage(lender, loanAKeylet.key, tfLoanDefault), ter(tesSUCCESS)); env.close(); // Verify Loan A is defaulted loanASle = env.le(loanAKeylet); if (!BEAST_EXPECT(loanASle)) return; BEAST_EXPECT(loanASle->isFlag(lsfLoanDefault)); BEAST_EXPECT(loanASle->at(sfPaymentRemaining) == 0); // Check broker state after first default (from committed ledger) auto brokerSle = env.le(brokerKeylet); if (!BEAST_EXPECT(brokerSle)) return; auto const afterFirstDebtTotal = brokerSle->at(sfDebtTotal); auto const afterFirstCoverAvailable = brokerSle->at(sfCoverAvailable); // DebtTotal should have decreased by Loan A's debt BEAST_EXPECT(afterFirstDebtTotal == 50'134); // CoverAvailable should have decreased significantly BEAST_EXPECT(afterFirstCoverAvailable == 946); env(manage(lender, loanBKeylet.key, tfLoanDefault), ter(tesSUCCESS)); brokerSle = env.le(brokerKeylet); if (!BEAST_EXPECT(brokerSle)) return; auto const afterSecondDebtTotal = brokerSle->at(sfDebtTotal); auto const afterSecondCoverAvailable = brokerSle->at(sfCoverAvailable); BEAST_EXPECT(afterSecondDebtTotal == 0); BEAST_EXPECT(afterSecondCoverAvailable == 0); } // Tests that vault withdrawals work correctly when the vault has unrealized // loss from an impaired loan, ensuring the invariant check properly // accounts for the loss. void testWithdrawReflectsUnrealizedLoss() { using namespace jtx; using namespace loan; using namespace std::chrono_literals; testcase("Vault withdraw reflects sfLossUnrealized"); // Test constants static constexpr std::int64_t INITIAL_FUNDING = 1'000'000; static constexpr std::int64_t LENDER_INITIAL_IOU = 5'000'000; static constexpr std::int64_t DEPOSITOR_INITIAL_IOU = 1'000'000; 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::uint32_t PAYMENT_INTERVAL = 600; static constexpr std::uint32_t PAYMENT_TOTAL = 2; Env env(*this, all); // Setup accounts Account const issuer{"issuer"}; Account const lender{"lender"}; Account const depositorA{"lpA"}; Account const depositorB{"lpB"}; Account const borrower{"borrowerA"}; env.fund(XRP(INITIAL_FUNDING), issuer, lender, depositorA, depositorB, borrower); env.close(); // Setup trust lines PrettyAsset const iouAsset = issuer[iouCurrency]; env(trust(lender, iouAsset(10'000'000))); env(trust(depositorA, iouAsset(10'000'000))); env(trust(depositorB, iouAsset(10'000'000))); env(trust(borrower, iouAsset(10'000'000))); env.close(); // Fund accounts with IOUs env(pay(issuer, lender, iouAsset(LENDER_INITIAL_IOU))); env(pay(issuer, depositorA, iouAsset(DEPOSITOR_INITIAL_IOU))); env(pay(issuer, depositorB, iouAsset(DEPOSITOR_INITIAL_IOU))); env(pay(issuer, borrower, iouAsset(BORROWER_INITIAL_IOU))); env.close(); // Create vault and broker, then add deposits from two depositors auto const broker = createVaultAndBroker(env, iouAsset, lender); Vault v{env}; env(v.deposit({ .depositor = depositorA, .id = broker.vaultKeylet().key, .amount = iouAsset(DEPOSIT_AMOUNT), }), ter(tesSUCCESS)); env(v.deposit({ .depositor = depositorB, .id = broker.vaultKeylet().key, .amount = iouAsset(DEPOSIT_AMOUNT), }), ter(tesSUCCESS)); env.close(); // Create a loan auto const sleBroker = env.le(keylet::loanbroker(broker.brokerID)); if (!BEAST_EXPECT(sleBroker)) return; auto const loanKeylet = keylet::loan(broker.brokerID, sleBroker->at(sfLoanSequence)); env(set(borrower, broker.brokerID, PRINCIPAL_AMOUNT), sig(sfCounterpartySignature, lender), paymentTotal(PAYMENT_TOTAL), paymentInterval(PAYMENT_INTERVAL), fee(env.current()->fees().base * 2), ter(tesSUCCESS)); env.close(); // Impair the loan to create unrealized loss env(manage(lender, loanKeylet.key, tfLoanImpair), ter(tesSUCCESS)); env.close(); // Verify unrealized loss is recorded in the vault auto const vaultAfterImpair = env.le(broker.vaultKeylet()); if (!BEAST_EXPECT(vaultAfterImpair)) return; BEAST_EXPECT( vaultAfterImpair->at(sfLossUnrealized) == broker.asset(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())); return token ? token->getFieldU64(sfMPTAmount) : 0; }; // Verify both depositors have equal shares auto const sharesLpA = getShareBalance(depositorA); auto const sharesLpB = getShareBalance(depositorB); BEAST_EXPECT(sharesLpA == EXPECTED_SHARES_PER_DEPOSITOR); BEAST_EXPECT(sharesLpB == EXPECTED_SHARES_PER_DEPOSITOR); BEAST_EXPECT(sharesLpA == sharesLpB); // Helper to attempt withdrawal auto const attemptWithdrawShares = [&](Account const& depositor, std::uint64_t shareAmount, TER expected) { STAmount const shareAmt{MPTIssue{shareAsset}, Number(shareAmount)}; env(v.withdraw( {.depositor = depositor, .id = broker.vaultKeylet().key, .amount = shareAmt}), ter(expected)); env.close(); }; // Regression test: Both depositors should successfully withdraw despite // unrealized loss. Previously failed with invariant violation: // "withdrawal must change vault and destination balance by equal // amount". This was caused by sharesToAssetsWithdraw rounding down, // creating a mismatch where vaultDeltaAssets * -1 != destinationDelta // when unrealized loss exists. attemptWithdrawShares(depositorA, sharesLpA, tesSUCCESS); attemptWithdrawShares(depositorB, sharesLpB, tesSUCCESS); } public: void run() override { #if LOAN_TODO testLoanPayLateFullPaymentBypassesPenalties(); testLoanCoverMinimumRoundingExploit(); #endif testWithdrawReflectsUnrealizedLoss(); 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(); } }; class LoanBatch_test : public Loan_test { protected: beast::xor_shift_engine engine_; std::uniform_int_distribution<> assetDist{0, 2}; std::uniform_int_distribution principalDist{100'000, 1'000'000'000}; std::uniform_int_distribution interestRateDist{0, 10000}; std::uniform_int_distribution<> paymentTotalDist{12, 10000}; std::uniform_int_distribution<> paymentIntervalDist{60, 3600 * 24 * 30}; std::uniform_int_distribution managementFeeRateDist{0, 10'000}; std::uniform_int_distribution<> serviceFeeDist{0, 20}; /* # Generate parameters that are more likely to be valid principal = Decimal(str(rand.randint(100000, 100'000'000))).quantize(ROUND_TARGET) interest_rate = Decimal(rand.randint(1, 10000)) / Decimal(100000) payment_total = rand.randint(12, 10000) payment_interval = Decimal(str(rand.randint(60, 2629746))) interest_fee = Decimal(rand.randint(0, 100000)) / Decimal(100000) */ void testRandomLoan() { using namespace jtx; Account const issuer("issuer"); Account const lender("lender"); Account const borrower("borrower"); // Determine all the random parameters at once AssetType assetType = static_cast(assetDist(engine_)); auto const principalRequest = principalDist(engine_); TenthBips16 managementFeeRate{managementFeeRateDist(engine_)}; auto const serviceFee = serviceFeeDist(engine_); TenthBips32 interest{interestRateDist(engine_)}; auto const payTotal = paymentTotalDist(engine_); auto const payInterval = paymentIntervalDist(engine_); BrokerParameters brokerParams{ .vaultDeposit = principalRequest * 10, .debtMax = 0, .coverRateMin = TenthBips32{0}, .managementFeeRate = managementFeeRate}; LoanParameters loanParams{ .account = lender, .counter = borrower, .principalRequest = principalRequest, .serviceFee = serviceFee, .interest = interest, .payTotal = payTotal, .payInterval = payInterval, }; runLoan(assetType, brokerParams, loanParams); } public: void run() override { auto const numIterations = [s = arg()]() -> int { int defaultNum = 5; if (s.empty()) return defaultNum; try { std::size_t pos = 0; auto const r = stoi(s, &pos); if (pos != s.size()) return defaultNum; return r; } catch (...) { return defaultNum; } }(); using namespace jtx; auto const updateInterval = std::min(numIterations / 5, 100); for (int i = 0; i < numIterations; ++i) { if (i % updateInterval == 0) testcase << "Random Loan Test iteration " << (i + 1) << "/" << numIterations; testRandomLoan(); } } }; class LoanArbitrary_test : public LoanBatch_test { void run() override { using namespace jtx; BrokerParameters const brokerParams{ .vaultDeposit = 10000, .debtMax = 0, .coverRateMin = TenthBips32{0}, .managementFeeRate = TenthBips16{0}, .coverRateLiquidation = TenthBips32{0}}; LoanParameters const loanParams{ .account = Account("lender"), .counter = Account("borrower"), .principalRequest = Number{200000, -6}, .interest = TenthBips32{50000}, .payTotal = 2, .payInterval = 200}; runLoan(AssetType::XRP, brokerParams, loanParams); } }; BEAST_DEFINE_TESTSUITE(Loan, tx, xrpl); BEAST_DEFINE_TESTSUITE_MANUAL(LoanBatch, tx, xrpl); BEAST_DEFINE_TESTSUITE_MANUAL(LoanArbitrary, tx, xrpl); } // namespace test } // namespace xrpl