diff --git a/include/xrpl/beast/unit_test/suite.h b/include/xrpl/beast/unit_test/suite.h index c20fe2522c3..0ba714fdf39 100644 --- a/include/xrpl/beast/unit_test/suite.h +++ b/include/xrpl/beast/unit_test/suite.h @@ -295,6 +295,20 @@ class Suite return runner_->arg(); } +protected: + /** + * Return the runner this suite is executing under. + * + * Lets a suite compose other suites (e.g. an aggregator that reruns a + * group of related suites under its own name) via `SuiteInfo::run`. + */ + Runner& + runner() const + { + return *runner_; + } + +public: /** * DEPRECATED * @return `true` if the test condition indicates success(a false value) diff --git a/src/test/app/Loan_test.cpp b/src/test/app/Loan_test.cpp deleted file mode 100644 index 8a6f1669df8..00000000000 --- a/src/test/app/Loan_test.cpp +++ /dev/null @@ -1,9756 +0,0 @@ -#include -// -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -#include - -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -namespace xrpl::test { - -class Loan_test : public beast::unit_test::Suite -{ -protected: - // Ensure that all the features needed for Lending Protocol are included, - // even if they are set to unsupported. - // - // featureLendingProtocolV1_1 is excluded from the default set: it changes - // Vault/LoanBroker accounting (AssetsTotal/DebtTotal/LossUnrealized), and - // most of this file's tests assert whole-life-specific expected values - // for those fields. Tests that specifically exercise the amendment opt - // it back in explicitly (e.g. `all_ | featureLendingProtocolV1_1`). - FeatureBitset const all_{jtx::testableAmendments() - featureLendingProtocolV1_1}; - std::string const iouCurrency_{"IOU"}; - - 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; - // If set, the vault is created with this sfScale value. Useful for - // tests that need finer loanScale to exercise rounding edge cases. - std::optional vaultScale = - std::nullopt; // NOLINT(readability-redundant-member-init) - - [[nodiscard]] Number - maxCoveredLoanValue(Number const& currentDebt) const - { - NumberRoundModeGuard const mg(Number::RoundingMode::Downward); - auto debtLimit = coverDeposit * kTenthBipsPerUnity.value() / coverRateMin.value(); - - return debtLimit - currentDebt; - } - - static BrokerParameters const& - defaults() - { - static BrokerParameters const kResult{}; - return kResult; - } - - // TODO: create an operator() which returns a transaction similar to - // LoanParameters - }; - - struct BrokerInfo - { - jtx::PrettyAsset asset; - uint256 brokerID; - uint256 vaultID; - BrokerParameters params; - BrokerInfo( - jtx::PrettyAsset const& asset, - Keylet const& brokerKeylet, - Keylet const& vaultKeylet, - BrokerParameters p) - : asset(asset) - , brokerID(brokerKeylet.key) - , vaultID(vaultKeylet.key) - , params(std::move(p)) - { - } - - [[nodiscard]] Keylet - brokerKeylet() const - { - return keylet::loanBroker(brokerID); - } - [[nodiscard]] Keylet - vaultKeylet() const - { - return keylet::vault(vaultID); - } - - [[nodiscard]] int - vaultScale(jtx::Env const& env) const - { - using namespace jtx; - - auto const vaultSle = env.le(keylet::vault(vaultID)); - return getAssetsTotalScale(vaultSle); - } - }; - - struct LoanParameters - { - // The account submitting the transaction. May be borrower or broker. - jtx::Account account; - // The counterparty. Should be the other of borrower or broker. - jtx::Account counter; - // Whether the counterparty is specified in the `counterparty` field, or - // only signs. - bool counterpartyExplicit = true; - Number principalRequest; - // NOLINTBEGIN(readability-redundant-member-init) - std::optional setFee = std::nullopt; - std::optional originationFee = std::nullopt; - std::optional serviceFee = std::nullopt; - std::optional lateFee = std::nullopt; - std::optional closeFee = std::nullopt; - std::optional overFee = std::nullopt; - std::optional interest = std::nullopt; - std::optional lateInterest = std::nullopt; - std::optional closeInterest = std::nullopt; - std::optional overpaymentInterest = std::nullopt; - std::optional payTotal = std::nullopt; - std::optional payInterval = std::nullopt; - std::optional gracePd = std::nullopt; - std::optional flags = std::nullopt; - // NOLINTEND(readability-redundant-member-init) - - template - jtx::JTx - operator()(jtx::Env& env, BrokerInfo const& broker, FN const&... fN) const - { - using namespace jtx; - using namespace jtx::loan; - - JTx jt{loan::set( - account, - broker.brokerID, - broker.asset(principalRequest).number(), - flags.value_or(0))}; - - Sig(sfCounterpartySignature, counter)(env, jt); - - Fee{setFee.value_or(env.current()->fees().base * 2)}(env, jt); - - if (counterpartyExplicit) - kCounterparty(counter)(env, jt); - if (originationFee) - kLoanOriginationFee(broker.asset(*originationFee).number())(env, jt); - if (serviceFee) - kLoanServiceFee(broker.asset(*serviceFee).number())(env, jt); - if (lateFee) - kLatePaymentFee(broker.asset(*lateFee).number())(env, jt); - if (closeFee) - kClosePaymentFee(broker.asset(*closeFee).number())(env, jt); - if (overFee) - kOverpaymentFee (*overFee)(env, jt); - if (interest) - kInterestRate (*interest)(env, jt); - if (lateInterest) - kLateInterestRate (*lateInterest)(env, jt); - if (closeInterest) - kCloseInterestRate (*closeInterest)(env, jt); - if (overpaymentInterest) - kOverpaymentInterestRate (*overpaymentInterest)(env, jt); - if (payTotal) - kPaymentTotal (*payTotal)(env, jt); - if (payInterval) - kPaymentInterval (*payInterval)(env, jt); - if (gracePd) - kGracePeriod (*gracePd)(env, jt); - - return env.jt(jt, fN...); - } - }; - - struct PaymentParameters - { - Number overpaymentFactor = Number{1}; - std::optional overpaymentExtra = std::nullopt; - std::uint32_t flags = 0; - bool showStepBalances = false; - bool validateBalances = true; - - static PaymentParameters const& - defaults() - { - static PaymentParameters const kResult{}; - return kResult; - } - }; - - struct LoanState - { - std::uint32_t previousPaymentDate = 0; - NetClock::time_point startDate; - std::uint32_t nextPaymentDate = 0; - std::uint32_t paymentRemaining = 0; - std::int32_t const loanScale = 0; - Number totalValue = 0; - Number principalOutstanding = 0; - Number managementFeeOutstanding = 0; - Number periodicPayment = 0; - std::uint32_t flags = 0; - std::uint32_t const paymentInterval = 0; - TenthBips32 const interestRate{}; - }; - - /** - * Helper class to compare the expected state of a loan and loan broker - * against the data in the ledger. - */ - struct VerifyLoanStatus - { - public: - jtx::Env const& env; - BrokerInfo const& broker; - jtx::Account const& pseudoAccount; - Keylet const& loanKeylet; - - VerifyLoanStatus( - jtx::Env const& env, - BrokerInfo const& broker, - jtx::Account const& pseudo, - Keylet const& keylet) - : env(env), broker(broker), pseudoAccount(pseudo), loanKeylet(keylet) - { - } - - /** - * Checks the expected broker state against the ledger - */ - void - checkBroker( - Number const& principalOutstanding, - Number const& interestOwed, - TenthBips32 interestRate, - std::uint32_t paymentInterval, - std::uint32_t paymentsRemaining, - std::uint32_t ownerCount) const - { - using namespace jtx; - if (auto brokerSle = env.le(keylet::loanBroker(broker.brokerID)); - env.test.BEAST_EXPECT(brokerSle)) - { - TenthBips16 const managementFeeRate{brokerSle->at(sfManagementFeeRate)}; - auto const brokerDebt = brokerSle->at(sfDebtTotal); - - if (auto vaultSle = env.le(keylet::vault(brokerSle->at(sfVaultID))); - env.test.BEAST_EXPECT(vaultSle)) - { - auto const expectedDebt = - env.current()->rules().enabled(featureLendingProtocolV1_1) && - getVaultVersion(vaultSle) == VaultVersion::CashBasis - ? principalOutstanding - : principalOutstanding + interestOwed; - env.test.BEAST_EXPECT(brokerDebt == expectedDebt); - env.test.BEAST_EXPECT( - env.balance(pseudoAccount, broker.asset).number() == - brokerSle->at(sfCoverAvailable)); - env.test.BEAST_EXPECT(brokerSle->at(sfOwnerCount) == ownerCount); - - Account const vaultPseudo{"vaultPseudoAccount", vaultSle->at(sfAccount)}; - env.test.BEAST_EXPECT( - vaultSle->at(sfAssetsAvailable) == - env.balance(vaultPseudo, broker.asset).number()); - if (ownerCount == 0) - { - // The Vault must be perfectly balanced if there - // are no loans outstanding - auto const total = vaultSle->at(sfAssetsTotal); - auto const available = vaultSle->at(sfAssetsAvailable); - env.test.BEAST_EXPECT(total == available); - env.test.BEAST_EXPECT(vaultSle->at(sfLossUnrealized) == 0); - } - } - } - } - - void - checkPayment( - std::int32_t loanScale, - jtx::Account const& account, - jtx::PrettyAmount const& balanceBefore, - STAmount const& expectedPayment, - jtx::PrettyAmount const& adjustment) const - { - auto const borrowerScale = std::max(loanScale, balanceBefore.number().exponent()); - - STAmount const balanceChangeAmount{ - broker.asset, - roundToAsset(broker.asset, expectedPayment + adjustment, borrowerScale)}; - { - auto const difference = roundToScale( - env.balance(account, broker.asset) - (balanceBefore - balanceChangeAmount), - borrowerScale); - env.test.expect( - roundToScale(difference, loanScale) >= beast::kZero, - "Balance before: " + to_string(balanceBefore.value()) + - ", expected change: " + to_string(balanceChangeAmount) + - ", difference (balance after - expected): " + to_string(difference), - __FILE__, - __LINE__); - } - } - - /** - * Checks both the loan and broker expect states against the ledger - */ - void - operator()( - std::uint32_t previousPaymentDate, - std::uint32_t nextPaymentDate, - std::uint32_t paymentRemaining, - Number const& loanScale, - Number const& totalValue, - Number const& principalOutstanding, - Number const& managementFeeOutstanding, - Number const& periodicPayment, - std::uint32_t flags) const - { - using namespace jtx; - if (auto loan = env.le(loanKeylet); env.test.BEAST_EXPECT(loan)) - { - env.test.BEAST_EXPECT(loan->at(sfPreviousPaymentDueDate) == previousPaymentDate); - env.test.BEAST_EXPECT(loan->at(sfPaymentRemaining) == paymentRemaining); - env.test.BEAST_EXPECT(loan->at(sfNextPaymentDueDate) == nextPaymentDate); - env.test.BEAST_EXPECT(loan->at(sfLoanScale) == loanScale); - env.test.BEAST_EXPECT(loan->at(sfTotalValueOutstanding) == totalValue); - env.test.BEAST_EXPECT(loan->at(sfPrincipalOutstanding) == principalOutstanding); - env.test.BEAST_EXPECT( - loan->at(sfManagementFeeOutstanding) == managementFeeOutstanding); - env.test.BEAST_EXPECT(loan->at(sfPeriodicPayment) == periodicPayment); - env.test.BEAST_EXPECT(loan->at(sfFlags) == flags); - - auto const ls = constructLoanState(loan); - - auto const interestRate = TenthBips32{loan->at(sfInterestRate)}; - auto const paymentInterval = loan->at(sfPaymentInterval); - checkBroker( - principalOutstanding, - ls.interestDue, - interestRate, - paymentInterval, - paymentRemaining, - 1); - - if (auto brokerSle = env.le(keylet::loanBroker(broker.brokerID)); - env.test.BEAST_EXPECT(brokerSle)) - { - if (auto vaultSle = env.le(keylet::vault(brokerSle->at(sfVaultID))); - env.test.BEAST_EXPECT(vaultSle)) - { - if (((flags & lsfLoanImpaired) != 0u) && ((flags & lsfLoanDefault) == 0u)) - { - env.test.BEAST_EXPECT( - vaultSle->at(sfLossUnrealized) == - (env.current()->rules().enabled(featureLendingProtocolV1_1) && - getVaultVersion(vaultSle) == VaultVersion::CashBasis - ? principalOutstanding - : totalValue - managementFeeOutstanding)); - } - else - { - env.test.BEAST_EXPECT(vaultSle->at(sfLossUnrealized) == 0); - } - } - } - } - } - - /** - * Checks both the loan and broker expect states against the ledger - */ - void - operator()(LoanState const& state) const - { - operator()( - state.previousPaymentDate, - state.nextPaymentDate, - state.paymentRemaining, - state.loanScale, - state.totalValue, - state.principalOutstanding, - state.managementFeeOutstanding, - state.periodicPayment, - state.flags); - }; - }; - - BrokerInfo - createVaultAndBroker( - jtx::Env& env, - jtx::PrettyAsset const& asset, - jtx::Account const& lender, - BrokerParameters const& params = BrokerParameters::defaults()) - { - using namespace jtx; - - Vault const vault{env}; - - auto const deposit = asset(params.vaultDeposit); - auto const debtMaximumValue = asset(params.debtMax).value(); - auto const coverDepositValue = asset(params.coverDeposit).value(); - - auto const coverRateMinValue = params.coverRateMin; - - auto [tx, vaultKeylet] = vault.create({.owner = lender, .asset = asset}); - if (params.vaultScale) - tx[sfScale] = *params.vaultScale; - env(tx); - env.close(); - BEAST_EXPECT(env.le(vaultKeylet)); - - env(vault.deposit({.depositor = lender, .id = vaultKeylet.key, .amount = deposit})); - env.close(); - if (auto const vault = env.le(keylet::vault(vaultKeylet.key)); BEAST_EXPECT(vault)) - { - BEAST_EXPECT(vault->at(sfAssetsAvailable) == deposit.value()); - } - - auto const keylet = keylet::loanBroker(lender.id(), env.seq(lender)); - - using namespace loanBroker; - env(set(lender, vaultKeylet.key, params.flags), - kData(params.data), - kManagementFeeRate(params.managementFeeRate), - kDebtMaximum(debtMaximumValue), - kCoverRateMinimum(coverRateMinValue), - kCoverRateLiquidation(TenthBips32(params.coverRateLiquidation))); - - if (coverDepositValue != beast::kZero) - env(coverDeposit(lender, keylet.key, coverDepositValue)); - - env.close(); - - return {asset, keylet, vaultKeylet, params}; - } - - /** - * Get the state without checking anything - */ - LoanState - getCurrentState(jtx::Env const& env, BrokerInfo const& broker, Keylet const& loanKeylet) - { - using d = NetClock::duration; - using tp = NetClock::time_point; - - // Lookup the current loan state - if (auto loan = env.le(loanKeylet); BEAST_EXPECT(loan)) - { - return LoanState{ - .previousPaymentDate = loan->at(sfPreviousPaymentDueDate), - .startDate = tp{d{loan->at(sfStartDate)}}, - .nextPaymentDate = loan->at(sfNextPaymentDueDate), - .paymentRemaining = loan->at(sfPaymentRemaining), - .loanScale = loan->at(sfLoanScale), - .totalValue = loan->at(sfTotalValueOutstanding), - .principalOutstanding = loan->at(sfPrincipalOutstanding), - .managementFeeOutstanding = loan->at(sfManagementFeeOutstanding), - .periodicPayment = loan->at(sfPeriodicPayment), - .flags = loan->at(sfFlags), - .paymentInterval = loan->at(sfPaymentInterval), - .interestRate = TenthBips32{loan->at(sfInterestRate)}, - }; - } - return LoanState{}; - } - - /** - * Get the state and check the values against the parameters used in - * `lifecycle` - */ - LoanState - getCurrentState( - jtx::Env const& env, - BrokerInfo const& broker, - Keylet const& loanKeylet, - VerifyLoanStatus const& verifyLoanStatus) - { - using namespace std::chrono_literals; - using d = NetClock::duration; - using tp = NetClock::time_point; - - auto const state = getCurrentState(env, broker, loanKeylet); - BEAST_EXPECT(state.previousPaymentDate == 0); - BEAST_EXPECT(tp{d{state.nextPaymentDate}} == state.startDate + 600s); - BEAST_EXPECT(state.paymentRemaining == 12); - BEAST_EXPECT(state.principalOutstanding == broker.asset(1000).value()); - BEAST_EXPECT( - state.loanScale >= - (broker.asset.integral() - ? 0 - : std::max(broker.vaultScale(env), state.principalOutstanding.exponent()))); - BEAST_EXPECT(state.paymentInterval == 600); - { - NumberRoundModeGuard const mg(Number::RoundingMode::Upward); - BEAST_EXPECT( - state.totalValue == - roundToAsset( - broker.asset, state.periodicPayment * state.paymentRemaining, state.loanScale)); - } - BEAST_EXPECT( - state.managementFeeOutstanding == - computeManagementFee( - broker.asset, - state.totalValue - state.principalOutstanding, - broker.params.managementFeeRate, - state.loanScale)); - - verifyLoanStatus(state); - - return state; - } - - bool - canImpairLoan(jtx::Env const& env, BrokerInfo const& broker, LoanState const& state) - { - if (auto const brokerSle = env.le(keylet::loanBroker(broker.brokerID)); - BEAST_EXPECT(brokerSle)) - { - if (auto const vaultSle = env.le(keylet::vault(brokerSle->at(sfVaultID))); - BEAST_EXPECT(vaultSle)) - { - // log << vaultSle->getJson() << std::endl; - auto const assetsUnavailable = - vaultSle->at(sfAssetsTotal) - vaultSle->at(sfAssetsAvailable); - auto const unrealizedLoss = vaultSle->at(sfLossUnrealized) + - (env.current()->rules().enabled(featureLendingProtocolV1_1) && - getVaultVersion(vaultSle) == VaultVersion::CashBasis - ? state.principalOutstanding - : state.totalValue - state.managementFeeOutstanding); - - if (!BEAST_EXPECT(unrealizedLoss <= assetsUnavailable)) - { - return false; - } - } - } - return true; - } - - enum class AssetType { XRP = 0, IOU = 1, MPT = 2 }; - - // Specify the accounts as params to allow other accounts to be used - jtx::PrettyAsset - createAsset( - jtx::Env& env, - AssetType assetType, - BrokerParameters const& brokerParams, - jtx::Account const& issuer, - jtx::Account const& lender, - jtx::Account const& borrower) - { - using namespace jtx; - - switch (assetType) - { - case AssetType::XRP: - // TODO: remove the factor, and set up loans in drops - return PrettyAsset{xrpIssue(), 1'000'000}; - - case AssetType::IOU: { - PrettyAsset const asset{issuer[iouCurrency_]}; - - auto const limit = - asset(100 * (brokerParams.vaultDeposit + brokerParams.coverDeposit)); - if (lender != issuer) - env(trust(lender, limit)); - if (borrower != issuer) - env(trust(borrower, limit)); - - return asset; - } - - case AssetType::MPT: { - // Enough to cover initial fees - if (!env.le(keylet::account(issuer))) - env.fund(env.current()->fees().accountReserve(10, 1) * 10, issuer); - if (!env.le(keylet::account(lender))) - env.fund(env.current()->fees().accountReserve(10, 1) * 10, noripple(lender)); - if (!env.le(keylet::account(borrower))) - env.fund(env.current()->fees().accountReserve(10, 1) * 10, noripple(borrower)); - - MPTTester mptt{env, issuer, kMptInitNoFund}; - mptt.create({.flags = tfMPTCanClawback | tfMPTCanTransfer | tfMPTCanLock}); - // Scale the MPT asset so interest is interesting - PrettyAsset const asset{mptt.issuanceID(), 10'000}; - // Need to do the authorization here because mptt isn't - // accessible outside - if (lender != issuer) - mptt.authorize({.account = lender}); - if (borrower != issuer) - mptt.authorize({.account = borrower}); - - env.close(); - - return asset; - } - - default: - throw std::runtime_error("Unknown asset type"); - } - } - - void - describeLoan( - jtx::Env& env, - BrokerParameters const& brokerParams, - LoanParameters const& loanParams, - AssetType assetType, - jtx::Account const& issuer, - jtx::Account const& lender, - jtx::Account const& borrower) - { - using namespace jtx; - - auto const asset = createAsset(env, assetType, brokerParams, issuer, lender, borrower); - auto const principal = asset(loanParams.principalRequest).number(); - auto const interest = loanParams.interest.value_or(TenthBips32{}); - auto const interval = loanParams.payInterval.value_or(LoanSet::kDefaultPaymentInterval); - auto const total = loanParams.payTotal.value_or(LoanSet::kDefaultPaymentTotal); - auto const feeRate = brokerParams.managementFeeRate; - auto const props = computeLoanProperties( - env.current()->rules(), - asset, - principal, - interest, - interval, - total, - feeRate, - asset(brokerParams.vaultDeposit).number().exponent()); - log << "Loan properties:\n" - << "\tPrincipal: " << principal << std::endl - << "\tInterest rate: " << interest << std::endl - << "\tPayment interval: " << interval << std::endl - << "\tManagement Fee Rate: " << feeRate << std::endl - << "\tTotal Payments: " << total << std::endl - << "\tPeriodic Payment: " << props.periodicPayment << std::endl - << "\tTotal Value: " << props.loanState.valueOutstanding << std::endl - << "\tManagement Fee: " << props.loanState.managementFeeDue << std::endl - << "\tLoan Scale: " << props.loanScale << std::endl - << "\tFirst payment principal: " << props.firstPaymentPrincipal << std::endl; - - // checkGuards returns a TER, so success is 0 - BEAST_EXPECT(!checkLoanGuards( - asset, - asset(loanParams.principalRequest).number(), - loanParams.interest.value_or(TenthBips32{}) != beast::kZero, - loanParams.payTotal.value_or(LoanSet::kDefaultPaymentTotal), - props, - env.journal)); - } - - std::optional> - createLoan( - jtx::Env& env, - AssetType assetType, - BrokerParameters const& brokerParams, - LoanParameters const& loanParams, - jtx::Account const& issuer, - jtx::Account const& lender, - jtx::Account const& borrower) - { - using namespace jtx; - - // Enough to cover initial fees - env.fund(env.current()->fees().accountReserve(10, 1) * 10, issuer); - if (lender != issuer) - env.fund(env.current()->fees().accountReserve(10, 1) * 10, noripple(lender)); - if (borrower != issuer && borrower != lender) - env.fund(env.current()->fees().accountReserve(10, 1) * 10, noripple(borrower)); - - describeLoan(env, brokerParams, loanParams, assetType, issuer, lender, borrower); - - // Make the asset - auto const asset = createAsset(env, assetType, brokerParams, issuer, lender, borrower); - - env.close(); - if (asset.native() || lender != issuer) - { - env( - pay((asset.native() ? env.master : issuer), - lender, - asset(brokerParams.vaultDeposit + brokerParams.coverDeposit))); - } - // Fund the borrower later once we know the total loan - // size - - BrokerInfo const broker = createVaultAndBroker(env, asset, lender, brokerParams); - - auto const pseudoAcctOpt = [&]() -> std::optional { - auto const brokerSle = env.le(keylet::loanBroker(broker.brokerID)); - if (!BEAST_EXPECT(brokerSle)) - return std::nullopt; - auto const brokerPseudo = brokerSle->at(sfAccount); - return Account("Broker pseudo-account", brokerPseudo); - }(); - if (!pseudoAcctOpt) - return std::nullopt; - Account const& pseudoAcct = *pseudoAcctOpt; - - auto const loanKeyletOpt = [&]() -> std::optional { - auto const brokerSle = env.le(keylet::loanBroker(broker.brokerID)); - if (!BEAST_EXPECT(brokerSle)) - return std::nullopt; - - // Broker has no loans - BEAST_EXPECT(brokerSle->at(sfOwnerCount) == 0); - - // The loan keylet is based on the LoanSequence of the - // _LOAN_BROKER_ object. - auto const loanSequence = brokerSle->at(sfLoanSequence); - return keylet::loan(broker.brokerID, loanSequence); - }(); - if (!loanKeyletOpt) - return std::nullopt; - Keylet const& loanKeylet = *loanKeyletOpt; - - env(loanParams(env, broker)); - - env.close(); - - return std::make_tuple(broker, loanKeylet, pseudoAcct); - } - - static void - topUpBorrower( - jtx::Env& env, - BrokerInfo const& broker, - jtx::Account const& issuer, - jtx::Account const& borrower, - LoanState const& state, - std::optional const& servFee) - { - using namespace jtx; - - STAmount const serviceFee = broker.asset(servFee.value_or(0)); - - // Ensure the borrower has enough funds to make the payments - // (including tx fees, if necessary) - auto const borrowerBalance = env.balance(borrower, broker.asset); - - auto const baseFee = env.current()->fees().base; - - // Add extra for transaction fees and reserves, if appropriate, or a - // tiny amount for the extra paid in each transaction - auto const totalNeeded = state.totalValue + (serviceFee * state.paymentRemaining) + - (broker.asset.native() ? Number( - baseFee * state.paymentRemaining + - accountReserve(*env.current(), borrower.id(), env.journal)) - : broker.asset(15).number()); - - auto const shortage = totalNeeded - borrowerBalance.number(); - - if (shortage > beast::kZero && (broker.asset.native() || issuer != borrower)) - { - env( - pay((broker.asset.native() ? env.master : issuer), - borrower, - STAmount{broker.asset, shortage})); - } - } - - void - makeLoanPayments( - jtx::Env& env, - BrokerInfo const& broker, - LoanParameters const& loanParams, - Keylet const& loanKeylet, - VerifyLoanStatus const& verifyLoanStatus, - jtx::Account const& issuer, - jtx::Account const& lender, - jtx::Account const& borrower, - PaymentParameters const& paymentParams = PaymentParameters::defaults()) - { - // Make all the individual payments - using namespace jtx; - using namespace jtx::loan; - using namespace std::chrono_literals; - using d = NetClock::duration; - - bool const showStepBalances = paymentParams.showStepBalances; - - auto const currencyLabel = getCurrencyLabel(broker.asset); - - auto const baseFee = env.current()->fees().base; - - env.close(); - auto state = getCurrentState(env, broker, loanKeylet); - - verifyLoanStatus(state); - - STAmount const serviceFee = broker.asset(loanParams.serviceFee.value_or(0)); - - topUpBorrower(env, broker, issuer, borrower, state, loanParams.serviceFee); - - // Periodic payment amount will consist of - // 1. principal outstanding (1000) - // 2. interest interest rate (at 12%) - // 3. payment interval (600s) - // 4. loan service fee (2) - // Calculate these values without the helper functions - // to verify they're working correctly The numbers in - // the below BEAST_EXPECTs may not hold across assets. - auto const periodicRate = loanPeriodicRate(state.interestRate, state.paymentInterval); - STAmount const roundedPeriodicPayment{ - broker.asset, - roundPeriodicPayment(broker.asset, state.periodicPayment, state.loanScale)}; - - if (!showStepBalances) - { - log << currencyLabel << " Payment components: " - << "Payments remaining, " - << "rawInterest, rawPrincipal, " - "rawMFee, " - << "trackedValueDelta, trackedPrincipalDelta, " - "trackedInterestDelta, trackedMgmtFeeDelta, special" - << std::endl; - } - - // Include the service fee - STAmount const totalDue = roundToScale( - roundedPeriodicPayment + serviceFee, state.loanScale, Number::RoundingMode::Upward); - - auto currentRoundedState = constructLoanState( - state.totalValue, state.principalOutstanding, state.managementFeeOutstanding); - { - auto const raw = computeTheoreticalLoanState( - env.current()->rules(), - state.periodicPayment, - periodicRate, - state.paymentRemaining, - broker.params.managementFeeRate); - - if (showStepBalances) - { - log << currencyLabel << " Starting loan balances: " - << "\n\tTotal value: " << currentRoundedState.valueOutstanding - << "\n\tPrincipal: " << currentRoundedState.principalOutstanding - << "\n\tInterest: " << currentRoundedState.interestDue - << "\n\tMgmt fee: " << currentRoundedState.managementFeeDue - << "\n\tPayments remaining " << state.paymentRemaining << std::endl; - } - else - { - log << currencyLabel << " Loan starting state: " << state.paymentRemaining << ", " - << raw.interestDue << ", " << raw.principalOutstanding << ", " - << raw.managementFeeDue << ", " << currentRoundedState.valueOutstanding << ", " - << currentRoundedState.principalOutstanding << ", " - << currentRoundedState.interestDue << ", " - << currentRoundedState.managementFeeDue << std::endl; - } - } - - // Try to pay a little extra to show that it's _not_ - // taken - auto const extraAmount = paymentParams.overpaymentExtra - ? broker.asset(*paymentParams.overpaymentExtra).value() - : std::min(broker.asset(10).value(), STAmount{broker.asset, totalDue / 20}); - - STAmount const transactionAmount = - STAmount{broker.asset, totalDue * paymentParams.overpaymentFactor} + extraAmount; - - auto const borrowerInitialBalance = env.balance(borrower, broker.asset).number(); - auto const initialState = state; - xrpl::detail::PaymentComponents totalPaid{ - .trackedValueDelta = 0, .trackedPrincipalDelta = 0, .trackedManagementFeeDelta = 0}; - Number totalInterestPaid = 0; - Number totalFeesPaid = 0; - std::size_t totalPaymentsMade = 0; - - xrpl::LoanState currentTrueState = computeTheoreticalLoanState( - env.current()->rules(), - state.periodicPayment, - periodicRate, - state.paymentRemaining, - broker.params.managementFeeRate); - - auto validateBorrowerBalance = [&]() { - if (borrower == issuer || !paymentParams.validateBalances) - return; - auto const totalSpent = - (totalPaid.trackedValueDelta + totalFeesPaid + - (broker.asset.native() ? Number(baseFee) * totalPaymentsMade : kNumZero)); - BEAST_EXPECT( - env.balance(borrower, broker.asset).number() == - borrowerInitialBalance - totalSpent); - }; - - auto const defaultRound = broker.asset.integral() ? 3 : 0; - auto truncate = [defaultRound](Number const& n, std::optional places = std::nullopt) { - auto const p = places.value_or(defaultRound); - if (p == 0) - return n; - auto const factor = Number{1, p}; - return (n * factor).truncate() / factor; - }; - while (state.paymentRemaining > 0) - { - validateBorrowerBalance(); - // Compute the expected principal amount - auto const paymentComponents = xrpl::detail::computePaymentComponents( - env.current()->rules(), - broker.asset.raw(), - state.loanScale, - state.totalValue, - state.principalOutstanding, - state.managementFeeOutstanding, - state.periodicPayment, - periodicRate, - state.paymentRemaining, - broker.params.managementFeeRate); - - BEAST_EXPECT( - paymentComponents.trackedValueDelta <= roundedPeriodicPayment || - (paymentComponents.specialCase == xrpl::detail::PaymentSpecialCase::Final && - paymentComponents.trackedValueDelta >= roundedPeriodicPayment)); - BEAST_EXPECT( - paymentComponents.trackedValueDelta == - paymentComponents.trackedPrincipalDelta + paymentComponents.trackedInterestPart() + - paymentComponents.trackedManagementFeeDelta); - - xrpl::LoanState const nextTrueState = computeTheoreticalLoanState( - env.current()->rules(), - state.periodicPayment, - periodicRate, - state.paymentRemaining - 1, - broker.params.managementFeeRate); - xrpl::detail::LoanStateDeltas const deltas = currentTrueState - nextTrueState; - BEAST_EXPECT( - deltas.total() == deltas.principal + deltas.interest + deltas.managementFee); - BEAST_EXPECT( - paymentComponents.specialCase == xrpl::detail::PaymentSpecialCase::Final || - deltas.total() == state.periodicPayment || - (state.loanScale - (deltas.total() - state.periodicPayment).exponent()) > 14); - - if (!showStepBalances) - { - log << currencyLabel << " Payment components: " << state.paymentRemaining << ", " - - << deltas.interest << ", " << deltas.principal << ", " << deltas.managementFee - << ", " << paymentComponents.trackedValueDelta << ", " - << paymentComponents.trackedPrincipalDelta << ", " - << paymentComponents.trackedInterestPart() << ", " - << paymentComponents.trackedManagementFeeDelta << ", " << [&]() -> char const* { - if (paymentComponents.specialCase == ::xrpl::detail::PaymentSpecialCase::Final) - return "final"; - if (paymentComponents.specialCase == ::xrpl::detail::PaymentSpecialCase::Extra) - return "extra"; - return "none"; - }() << std::endl; - } - - auto const totalDueAmount = - STAmount{broker.asset, paymentComponents.trackedValueDelta + serviceFee}; - - if (paymentParams.validateBalances) - { - // Due to the rounding algorithms to keep the interest and - // principal in sync with "true" values, the computed amount - // may be a little less than the rounded fixed payment - // amount. For integral types, the difference should be < 3 - // (1 unit for each of the interest and management fee). For - // IOUs, the difference should be dust. - Number const diff = totalDue - totalDueAmount; - BEAST_EXPECT( - paymentComponents.specialCase == xrpl::detail::PaymentSpecialCase::Final || - diff == beast::kZero || - (diff > beast::kZero && - ((broker.asset.integral() && (static_cast(diff) < 3)) || - (state.loanScale - diff.exponent() > 13)))); - - BEAST_EXPECT( - paymentComponents.trackedPrincipalDelta >= beast::kZero && - paymentComponents.trackedPrincipalDelta <= state.principalOutstanding); - BEAST_EXPECT( - paymentComponents.specialCase != xrpl::detail::PaymentSpecialCase::Final || - paymentComponents.trackedPrincipalDelta == state.principalOutstanding); - } - - auto const borrowerBalanceBeforePayment = env.balance(borrower, broker.asset); - - // Make the payment - env(pay(borrower, loanKeylet.key, transactionAmount, paymentParams.flags)); - - env.close(d{state.paymentInterval / 2}); - - if (paymentParams.validateBalances) - { - // Need to account for fees if the loan is in XRP - PrettyAmount adjustment = broker.asset(0); - if (broker.asset.native()) - { - adjustment = env.current()->fees().base; - } - - // Check the result - verifyLoanStatus.checkPayment( - state.loanScale, - borrower, - borrowerBalanceBeforePayment, - totalDueAmount, - adjustment); - } - - if (showStepBalances) - { - auto const loanSle = env.le(loanKeylet); - if (!BEAST_EXPECT(loanSle)) - { - // No reason for this not to exist - return; - } - auto const current = constructLoanState(loanSle); - auto const errors = nextTrueState - current; - log << currencyLabel << " Loan balances: " - << "\n\tAmount taken: " << paymentComponents.trackedValueDelta - << "\n\tTotal value: " << current.valueOutstanding - << " (true: " << truncate(nextTrueState.valueOutstanding) - << ", error: " << truncate(errors.total()) - << ")\n\tPrincipal: " << current.principalOutstanding - << " (true: " << truncate(nextTrueState.principalOutstanding) - << ", error: " << truncate(errors.principal) - << ")\n\tInterest: " << current.interestDue - << " (true: " << truncate(nextTrueState.interestDue) - << ", error: " << truncate(errors.interest) - << ")\n\tMgmt fee: " << current.managementFeeDue - << " (true: " << truncate(nextTrueState.managementFeeDue) - << ", error: " << truncate(errors.managementFee) << ")\n\tPayments remaining " - << loanSle->at(sfPaymentRemaining) << std::endl; - - currentRoundedState = current; - } - - --state.paymentRemaining; - state.previousPaymentDate = state.nextPaymentDate; - if (paymentComponents.specialCase == xrpl::detail::PaymentSpecialCase::Final) - { - state.paymentRemaining = 0; - state.nextPaymentDate = 0; - } - else - { - state.nextPaymentDate += state.paymentInterval; - } - state.principalOutstanding -= paymentComponents.trackedPrincipalDelta; - state.managementFeeOutstanding -= paymentComponents.trackedManagementFeeDelta; - state.totalValue -= paymentComponents.trackedValueDelta; - - if (paymentParams.validateBalances) - verifyLoanStatus(state); - - totalPaid.trackedValueDelta += paymentComponents.trackedValueDelta; - totalPaid.trackedPrincipalDelta += paymentComponents.trackedPrincipalDelta; - totalPaid.trackedManagementFeeDelta += paymentComponents.trackedManagementFeeDelta; - totalInterestPaid += paymentComponents.trackedInterestPart(); - totalFeesPaid += serviceFee; - ++totalPaymentsMade; - - currentTrueState = nextTrueState; - } - validateBorrowerBalance(); - - // Loan is paid off - BEAST_EXPECT(state.paymentRemaining == 0); - BEAST_EXPECT(state.principalOutstanding == 0); - - auto const initialInterestDue = initialState.totalValue - - (initialState.principalOutstanding + initialState.managementFeeOutstanding); - if (paymentParams.validateBalances) - { - // Make sure all the payments add up - BEAST_EXPECT(totalPaid.trackedValueDelta == initialState.totalValue); - BEAST_EXPECT(totalPaid.trackedPrincipalDelta == initialState.principalOutstanding); - BEAST_EXPECT( - totalPaid.trackedManagementFeeDelta == initialState.managementFeeOutstanding); - // This is almost a tautology given the previous checks, but - // check it anyway for completeness. - BEAST_EXPECT(totalInterestPaid == initialInterestDue); - BEAST_EXPECT(totalPaymentsMade == initialState.paymentRemaining); - } - - if (showStepBalances) - { - auto const loanSle = env.le(loanKeylet); - if (!BEAST_EXPECT(loanSle)) - { - // No reason for this not to exist - return; - } - log << currencyLabel << " Total amounts paid: " - << "\n\tTotal value: " << totalPaid.trackedValueDelta - << " (initial: " << truncate(initialState.totalValue) - << ", error: " << truncate(initialState.totalValue - totalPaid.trackedValueDelta) - << ")\n\tPrincipal: " << totalPaid.trackedPrincipalDelta - << " (initial: " << truncate(initialState.principalOutstanding) << ", error: " - << truncate(initialState.principalOutstanding - totalPaid.trackedPrincipalDelta) - << ")\n\tInterest: " << totalInterestPaid - << " (initial: " << truncate(initialInterestDue) - << ", error: " << truncate(initialInterestDue - totalInterestPaid) - << ")\n\tMgmt fee: " << totalPaid.trackedManagementFeeDelta - << " (initial: " << truncate(initialState.managementFeeOutstanding) << ", error: " - << truncate( - initialState.managementFeeOutstanding - totalPaid.trackedManagementFeeDelta) - << ")\n\tTotal payments made: " << totalPaymentsMade << std::endl; - } - } - - void - runLoan( - AssetType assetType, - BrokerParameters const& brokerParams, - LoanParameters const& loanParams, - FeatureBitset features) - { - using namespace jtx; - - Account const issuer("issuer"); - Account const lender("lender"); - Account const borrower("borrower"); - - Env env(*this, features); - - auto loanResult = - createLoan(env, assetType, brokerParams, loanParams, issuer, lender, borrower); - if (BEAST_EXPECT(loanResult); !loanResult.has_value()) - return; - - auto broker = std::get(*loanResult); - auto loanKeylet = std::get(*loanResult); - auto pseudoAcct = std::get(*loanResult); - - VerifyLoanStatus const verifyLoanStatus(env, broker, pseudoAcct, loanKeylet); - - makeLoanPayments( - env, - broker, - loanParams, - loanKeylet, - verifyLoanStatus, - issuer, - lender, - borrower, - PaymentParameters{.showStepBalances = true}); - } - - /** - * Runs through the complete lifecycle of a loan - * - * 1. Create a loan. - * 2. Test a bunch of transaction failure conditions. - * 3. Use the `toEndOfLife` callback to take the loan to 0. How that is done - * depends on the callback. e.g. Default, Early payoff, make all the - * normal payments, etc. - * 4. Delete the loan. The loan will alternate between being deleted by the - * lender and the borrower. - */ - void - lifecycle( - std::string const& caseLabel, - char const* label, - jtx::Env& env, - Number const& loanAmount, - int interestExponent, - jtx::Account const& lender, - jtx::Account const& borrower, - jtx::Account const& evan, - BrokerInfo const& broker, - jtx::Account const& pseudoAcct, - std::uint32_t flags, - // The end of life callback is expected to take the loan to 0 payments - // remaining, one way or another - std::function - toEndOfLife) - { - auto const [keylet, loanSequence] = [&]() { - auto const brokerSle = env.le(keylet::loanBroker(broker.brokerID)); - if (!BEAST_EXPECT(brokerSle)) - { - // will be invalid - return std::make_pair(keylet::loan(broker.brokerID), std::uint32_t(0)); - } - - // Broker has no loans - BEAST_EXPECT(brokerSle->at(sfOwnerCount) == 0); - - // The loan keylet is based on the LoanSequence of the _LOAN_BROKER_ - // object. - auto const loanSequence = brokerSle->at(sfLoanSequence); - return std::make_pair(keylet::loan(broker.brokerID, loanSequence), loanSequence); - }(); - - VerifyLoanStatus const verifyLoanStatus(env, broker, pseudoAcct, keylet); - - // No loans yet - verifyLoanStatus.checkBroker(0, 0, TenthBips32{0}, 1, 0, 0); - - if (!BEAST_EXPECT(loanSequence != 0)) - return; - - testcase << caseLabel << " " << label; - - using namespace jtx; - using namespace loan; - using namespace std::chrono_literals; - - auto applyExponent = [interestExponent, this](TenthBips32 value) mutable { - BEAST_EXPECT(value > TenthBips32(0)); - while (interestExponent > 0) - { - auto const oldValue = value; - value *= 10; - --interestExponent; - BEAST_EXPECT(value / 10 == oldValue); - } - while (interestExponent < 0) - { - auto const oldValue = value; - value /= 10; - ++interestExponent; - BEAST_EXPECT(value * 10 == oldValue); - } - return value; - }; - - auto const borrowerOwnerCount = env.ownerCount(borrower); - - auto const loanSetFee = env.current()->fees().base * 2; - LoanParameters const loanParams{ - .account = borrower, - .counter = lender, - .counterpartyExplicit = false, - .principalRequest = loanAmount, - .setFee = loanSetFee, - .originationFee = 1, - .serviceFee = 2, - .lateFee = 3, - .closeFee = 4, - .overFee = applyExponent(percentageToTenthBips(5) / 10), - .interest = applyExponent(percentageToTenthBips(12)), - // 2.4% - .lateInterest = applyExponent(percentageToTenthBips(24) / 10), - .closeInterest = applyExponent(percentageToTenthBips(36) / 10), - .overpaymentInterest = applyExponent(percentageToTenthBips(48) / 10), - .payTotal = 12, - .payInterval = 600, - .gracePd = 60, - .flags = flags, - }; - Number const principalRequestAmount = broker.asset(loanParams.principalRequest).value(); - auto const originationFeeAmount = broker.asset(*loanParams.originationFee).value(); - auto const serviceFeeAmount = broker.asset(*loanParams.serviceFee).value(); - auto const lateFeeAmount = broker.asset(*loanParams.lateFee).value(); - auto const closeFeeAmount = broker.asset(*loanParams.closeFee).value(); - - auto const borrowerStartbalance = env.balance(borrower, broker.asset); - - auto createJtx = loanParams(env, broker); - // Successfully create a Loan - env(createJtx); - - env.close(); - - auto const startDate = env.current()->header().parentCloseTime.time_since_epoch().count(); - - if (auto const brokerSle = env.le(keylet::loanBroker(broker.brokerID)); - BEAST_EXPECT(brokerSle)) - { - BEAST_EXPECT(brokerSle->at(sfOwnerCount) == 1); - } - - { - // Need to account for fees if the loan is in XRP - PrettyAmount adjustment = broker.asset(0); - if (broker.asset.native()) - { - adjustment = 2 * env.current()->fees().base; - } - - BEAST_EXPECT( - env.balance(borrower, broker.asset).value() == - borrowerStartbalance.value() + principalRequestAmount - originationFeeAmount - - adjustment.value()); - } - - auto const loanFlags = - createJtx.stx->isFlag(tfLoanOverpayment) ? lsfLoanOverpayment : LedgerSpecificFlags(0); - - if (auto loan = env.le(keylet); BEAST_EXPECT(loan)) - { - // log << "loan after create: " << to_string(loan->getJson()) - // << std::endl; - BEAST_EXPECT( - loan->isFlag(lsfLoanOverpayment) == createJtx.stx->isFlag(tfLoanOverpayment)); - BEAST_EXPECT(loan->at(sfLoanSequence) == loanSequence); - BEAST_EXPECT(loan->at(sfBorrower) == borrower.id()); - BEAST_EXPECT(loan->at(sfLoanBrokerID) == broker.brokerID); - BEAST_EXPECT(loan->at(sfLoanOriginationFee) == originationFeeAmount); - BEAST_EXPECT(loan->at(sfLoanServiceFee) == serviceFeeAmount); - BEAST_EXPECT(loan->at(sfLatePaymentFee) == lateFeeAmount); - BEAST_EXPECT(loan->at(sfClosePaymentFee) == closeFeeAmount); - BEAST_EXPECT(loan->at(sfOverpaymentFee) == *loanParams.overFee); - BEAST_EXPECT(loan->at(sfInterestRate) == *loanParams.interest); - BEAST_EXPECT(loan->at(sfLateInterestRate) == *loanParams.lateInterest); - BEAST_EXPECT(loan->at(sfCloseInterestRate) == *loanParams.closeInterest); - BEAST_EXPECT(loan->at(sfOverpaymentInterestRate) == *loanParams.overpaymentInterest); - BEAST_EXPECT(loan->at(sfStartDate) == startDate); - BEAST_EXPECT(loan->at(sfPaymentInterval) == *loanParams.payInterval); - BEAST_EXPECT(loan->at(sfGracePeriod) == *loanParams.gracePd); - BEAST_EXPECT(loan->at(sfPreviousPaymentDueDate) == 0); - BEAST_EXPECT(loan->at(sfNextPaymentDueDate) == startDate + *loanParams.payInterval); - BEAST_EXPECT(loan->at(sfPaymentRemaining) == *loanParams.payTotal); - BEAST_EXPECT( - loan->at(sfLoanScale) >= - (broker.asset.integral() - ? 0 - : std::max(broker.vaultScale(env), principalRequestAmount.exponent()))); - BEAST_EXPECT(loan->at(sfPrincipalOutstanding) == principalRequestAmount); - } - - auto state = getCurrentState(env, broker, keylet, verifyLoanStatus); - - auto const loanProperties = computeLoanProperties( - env.current()->rules(), - broker.asset.raw(), - state.principalOutstanding, - state.interestRate, - state.paymentInterval, - state.paymentRemaining, - broker.params.managementFeeRate, - state.loanScale); - - verifyLoanStatus( - 0, - startDate + *loanParams.payInterval, - *loanParams.payTotal, - state.loanScale, - loanProperties.loanState.valueOutstanding, - principalRequestAmount, - loanProperties.loanState.managementFeeDue, - loanProperties.periodicPayment, - loanFlags | 0); - - // Manage the loan - // no-op - env(manage(lender, keylet.key, 0)); - { - // no flags - auto jt = manage(lender, keylet.key, 0); - jt.removeMember(sfFlags.getName()); - env(jt); - } - // Only the lender can manage - env(manage(evan, keylet.key, 0), Ter(tecNO_PERMISSION)); - // unknown flags - env(manage(lender, keylet.key, tfLoanManageMask), Ter(temINVALID_FLAG)); - // combinations of flags are not allowed - env(manage(lender, keylet.key, tfLoanUnimpair | tfLoanImpair), Ter(temINVALID_FLAG)); - env(manage(lender, keylet.key, tfLoanImpair | tfLoanDefault), Ter(temINVALID_FLAG)); - env(manage(lender, keylet.key, tfLoanUnimpair | tfLoanDefault), Ter(temINVALID_FLAG)); - env(manage(lender, keylet.key, tfLoanUnimpair | tfLoanImpair | tfLoanDefault), - Ter(temINVALID_FLAG)); - // invalid loan ID - env(manage(lender, broker.brokerID, tfLoanImpair), Ter(tecNO_ENTRY)); - // Loan is unimpaired, can't unimpair it again - env(manage(lender, keylet.key, tfLoanUnimpair), Ter(tecNO_PERMISSION)); - // Loan is unimpaired, it can go into default, but only after it's past - // due - env(manage(lender, keylet.key, tfLoanDefault), Ter(tecTOO_SOON)); - - // Check the vault - bool const canImpair = canImpairLoan(env, broker, state); - // Impair the loan, if possible - env(manage(lender, keylet.key, tfLoanImpair), - canImpair ? Ter(tesSUCCESS) : Ter(tecLIMIT_EXCEEDED)); - // Unimpair the loan - env(manage(lender, keylet.key, tfLoanUnimpair), - canImpair ? Ter(tesSUCCESS) : Ter(tecNO_PERMISSION)); - - auto const nextDueDate = startDate + *loanParams.payInterval; - - env.close(); - - verifyLoanStatus( - 0, - nextDueDate, - *loanParams.payTotal, - loanProperties.loanScale, - loanProperties.loanState.valueOutstanding, - principalRequestAmount, - loanProperties.loanState.managementFeeDue, - loanProperties.periodicPayment, - loanFlags | 0); - - // Can't delete the loan yet. It has payments remaining. - env(del(lender, keylet.key), Ter(tecHAS_OBLIGATIONS)); - - if (BEAST_EXPECT(toEndOfLife)) - toEndOfLife(keylet, verifyLoanStatus); - env.close(); - - // Verify the loan is at EOL - if (auto loan = env.le(keylet); BEAST_EXPECT(loan)) - { - BEAST_EXPECT(loan->at(sfPaymentRemaining) == 0); - BEAST_EXPECT(loan->at(sfPrincipalOutstanding) == 0); - } - auto const borrowerStartingBalance = env.balance(borrower, broker.asset); - - // Try to delete the loan broker with an active loan - env(loanBroker::del(lender, broker.brokerID), Ter(tecHAS_OBLIGATIONS)); - // Ensure the above tx doesn't get ordered after the LoanDelete and - // delete our broker! - env.close(); - - // Test failure cases - env(del(lender, keylet.key, tfLoanOverpayment), Ter(temINVALID_FLAG)); - env(del(evan, keylet.key), Ter(tecNO_PERMISSION)); - env(del(lender, broker.brokerID), Ter(tecNO_ENTRY)); - - // Delete the loan - // Either the borrower or the lender can delete the loan. Alternate - // between who does it across tests. - static unsigned kDeleteCounter = 0; - auto const deleter = ((++kDeleteCounter % 2) != 0u) ? lender : borrower; - env(del(deleter, keylet.key)); - env.close(); - - PrettyAmount adjustment = broker.asset(0); - if (deleter == borrower) - { - // Need to account for fees if the loan is in XRP - if (broker.asset.native()) - { - adjustment = env.current()->fees().base; - } - } - - // No loans left - verifyLoanStatus.checkBroker(0, 0, *loanParams.interest, 1, 0, 0); - - BEAST_EXPECT( - env.balance(borrower, broker.asset).value() == - borrowerStartingBalance.value() - adjustment); - BEAST_EXPECT(env.ownerCount(borrower) == borrowerOwnerCount); - - if (auto const brokerSle = env.le(keylet::loanBroker(broker.brokerID)); - BEAST_EXPECT(brokerSle)) - { - BEAST_EXPECT(brokerSle->at(sfOwnerCount) == 0); - } - } - - static std::string - getCurrencyLabel(Asset const& asset) - { - if (asset.native()) - return "XRP"; - if (asset.holds()) - return "IOU"; - if (asset.holds()) - return "MPT"; - return "Unknown"; - } - - /** - * Wrapper to run a series of lifecycle tests for a given asset and loan - * amount - * - * Will be used in the future to vary the loan parameters. For now, it is - * only called once. - * - * Tests a bunch of LoanSet failure conditions before lifecycle. - */ - template - void - testCaseWrapper( - jtx::Env& env, - jtx::MPTTester& mptt, - std::array const& assets, - BrokerInfo const& broker, - Number const& loanAmount, - int interestExponent) - { - using namespace jtx; - using namespace Lending; - - auto const& asset = broker.asset.raw(); - auto const currencyLabel = getCurrencyLabel(asset); - auto const caseLabel = [&]() { - std::stringstream ss; - ss << "Lifecycle: " << loanAmount << " " << currencyLabel - << " Scale interest to: " << interestExponent << " "; - return ss.str(); - }(); - testcase << caseLabel; - - using namespace loan; - using namespace std::chrono_literals; - using d = NetClock::duration; - using tp = NetClock::time_point; - - Account const issuer{"issuer"}; - // For simplicity, lender will be the sole actor for the vault & - // brokers. - Account const lender{"lender"}; - // Borrower only wants to borrow - Account const borrower{"borrower"}; - // Evan will attempt to be naughty - Account const evan{"evan"}; - // Do not fund alice - Account const alice{"alice"}; - - Number const principalRequest = broker.asset(loanAmount).value(); - Number const maxCoveredLoanValue = broker.params.maxCoveredLoanValue(0); - BEAST_EXPECT(maxCoveredLoanValue == 1000 * 100 / 10); - Number const maxCoveredLoanRequest = broker.asset(maxCoveredLoanValue).value(); - Number const totalVaultRequest = broker.asset(broker.params.vaultDeposit).value(); - Number const debtMaximumRequest = broker.asset(broker.params.debtMax).value(); - - auto const loanSetFee = Fee(env.current()->fees().base * 2); - - auto const pseudoAcct = [&]() { - auto const brokerSle = env.le(keylet::loanBroker(broker.brokerID)); - if (!BEAST_EXPECT(brokerSle)) - return Account{lender}; - auto const brokerPseudo = brokerSle->at(sfAccount); - return Account("Broker pseudo-account", brokerPseudo); - }(); - - auto const baseFee = env.current()->fees().base; - - auto badKeylet = keylet::vault(lender.id(), env.seq(lender)); - // Try some failure cases - // flags are checked first - env(set(evan, broker.brokerID, principalRequest, tfLoanSetMask), - Sig(sfCounterpartySignature, lender), - loanSetFee, - Ter(temINVALID_FLAG)); - - // field length validation - // sfData: good length, bad account - env(set(evan, broker.brokerID, principalRequest), - Sig(sfCounterpartySignature, borrower), - kData(std::string(kMaxDataPayloadLength, 'X')), - loanSetFee, - Ter(tefBAD_AUTH)); - // sfData: too long - env(set(evan, broker.brokerID, principalRequest), - Sig(sfCounterpartySignature, lender), - kData(std::string(kMaxDataPayloadLength + 1, 'Y')), - loanSetFee, - Ter(temINVALID)); - - // field range validation - // sfOverpaymentFee: good value, bad account - env(set(evan, broker.brokerID, principalRequest), - Sig(sfCounterpartySignature, borrower), - kOverpaymentFee(kMaxOverpaymentFee), - loanSetFee, - Ter(tefBAD_AUTH)); - // sfOverpaymentFee: too big - env(set(evan, broker.brokerID, principalRequest), - Sig(sfCounterpartySignature, lender), - kOverpaymentFee(kMaxOverpaymentFee + 1), - loanSetFee, - Ter(temINVALID)); - - // sfInterestRate: good value, bad account - env(set(evan, broker.brokerID, principalRequest), - Sig(sfCounterpartySignature, borrower), - kInterestRate(kMaxInterestRate), - loanSetFee, - Ter(tefBAD_AUTH)); - env(set(evan, broker.brokerID, principalRequest), - Sig(sfCounterpartySignature, borrower), - kInterestRate(TenthBips32(0)), - loanSetFee, - Ter(tefBAD_AUTH)); - // sfInterestRate: too big - env(set(evan, broker.brokerID, principalRequest), - Sig(sfCounterpartySignature, lender), - kInterestRate(kMaxInterestRate + 1), - loanSetFee, - Ter(temINVALID)); - // sfInterestRate: too small - env(set(evan, broker.brokerID, principalRequest), - Sig(sfCounterpartySignature, lender), - kInterestRate(TenthBips32(-1)), - loanSetFee, - Ter(temINVALID)); - - // sfLateInterestRate: good value, bad account - env(set(evan, broker.brokerID, principalRequest), - Sig(sfCounterpartySignature, borrower), - kLateInterestRate(kMaxLateInterestRate), - loanSetFee, - Ter(tefBAD_AUTH)); - env(set(evan, broker.brokerID, principalRequest), - Sig(sfCounterpartySignature, borrower), - kLateInterestRate(TenthBips32(0)), - loanSetFee, - Ter(tefBAD_AUTH)); - // sfLateInterestRate: too big - env(set(evan, broker.brokerID, principalRequest), - Sig(sfCounterpartySignature, lender), - kLateInterestRate(kMaxLateInterestRate + 1), - loanSetFee, - Ter(temINVALID)); - // sfLateInterestRate: too small - env(set(evan, broker.brokerID, principalRequest), - Sig(sfCounterpartySignature, lender), - kLateInterestRate(TenthBips32(-1)), - loanSetFee, - Ter(temINVALID)); - - // sfCloseInterestRate: good value, bad account - env(set(evan, broker.brokerID, principalRequest), - Sig(sfCounterpartySignature, borrower), - kCloseInterestRate(kMaxCloseInterestRate), - loanSetFee, - Ter(tefBAD_AUTH)); - env(set(evan, broker.brokerID, principalRequest), - Sig(sfCounterpartySignature, borrower), - kCloseInterestRate(TenthBips32(0)), - loanSetFee, - Ter(tefBAD_AUTH)); - // sfCloseInterestRate: too big - env(set(evan, broker.brokerID, principalRequest), - Sig(sfCounterpartySignature, lender), - kCloseInterestRate(kMaxCloseInterestRate + 1), - loanSetFee, - Ter(temINVALID)); - env(set(evan, broker.brokerID, principalRequest), - Sig(sfCounterpartySignature, lender), - kCloseInterestRate(TenthBips32(-1)), - loanSetFee, - Ter(temINVALID)); - - // sfOverpaymentInterestRate: good value, bad account - env(set(evan, broker.brokerID, principalRequest), - Sig(sfCounterpartySignature, borrower), - kOverpaymentInterestRate(kMaxOverpaymentInterestRate), - loanSetFee, - Ter(tefBAD_AUTH)); - env(set(evan, broker.brokerID, principalRequest), - Sig(sfCounterpartySignature, borrower), - kOverpaymentInterestRate(TenthBips32(0)), - loanSetFee, - Ter(tefBAD_AUTH)); - // sfOverpaymentInterestRate: too big - env(set(evan, broker.brokerID, principalRequest), - Sig(sfCounterpartySignature, lender), - kOverpaymentInterestRate(kMaxOverpaymentInterestRate + 1), - loanSetFee, - Ter(temINVALID)); - env(set(evan, broker.brokerID, principalRequest), - Sig(sfCounterpartySignature, lender), - kOverpaymentInterestRate(TenthBips32(-1)), - loanSetFee, - Ter(temINVALID)); - - // sfPaymentTotal: good value, bad account - env(set(evan, broker.brokerID, principalRequest), - Sig(sfCounterpartySignature, borrower), - kPaymentTotal(LoanSet::kMinPaymentTotal), - loanSetFee, - Ter(tefBAD_AUTH)); - // sfPaymentTotal: too small (there is no max) - env(set(evan, broker.brokerID, principalRequest), - Sig(sfCounterpartySignature, lender), - kPaymentTotal(LoanSet::kMinPaymentTotal - 1), - loanSetFee, - Ter(temINVALID)); - - // sfPaymentInterval: good value, bad account - env(set(evan, broker.brokerID, principalRequest), - Sig(sfCounterpartySignature, borrower), - kPaymentInterval(LoanSet::kMinPaymentInterval), - loanSetFee, - Ter(tefBAD_AUTH)); - // sfPaymentInterval: too small (there is no max) - env(set(evan, broker.brokerID, principalRequest), - Sig(sfCounterpartySignature, lender), - kPaymentInterval(LoanSet::kMinPaymentInterval - 1), - loanSetFee, - Ter(temINVALID)); - - // sfGracePeriod: good value, bad account - env(set(evan, broker.brokerID, principalRequest), - Sig(sfCounterpartySignature, borrower), - kPaymentInterval(LoanSet::kMinPaymentInterval * 2), - kGracePeriod(LoanSet::kMinPaymentInterval * 2), - loanSetFee, - Ter(tefBAD_AUTH)); - // sfGracePeriod: larger than paymentInterval - env(set(evan, broker.brokerID, principalRequest), - Sig(sfCounterpartySignature, lender), - kPaymentInterval(LoanSet::kMinPaymentInterval * 2), - kGracePeriod(LoanSet::kMinPaymentInterval * 3), - loanSetFee, - Ter(temINVALID)); - - // insufficient fee - single sign - env(set(borrower, broker.brokerID, principalRequest), - Sig(sfCounterpartySignature, lender), - Ter(telINSUF_FEE_P)); - // insufficient fee - multisign - env(signers(lender, 2, {{evan, 1}, {borrower, 1}})); - env(signers(borrower, 2, {{evan, 1}, {lender, 1}})); - env(set(borrower, broker.brokerID, principalRequest), - kCounterparty(lender), - Msig(evan, lender), - Msig(sfCounterpartySignature, evan, borrower), - Fee(env.current()->fees().base * 5 - 1), - Ter(telINSUF_FEE_P)); - // Bad multisign signatures for borrower (Account) - env(set(borrower, broker.brokerID, principalRequest), - kCounterparty(lender), - Msig(alice, issuer), - Msig(sfCounterpartySignature, evan, borrower), - Fee(env.current()->fees().base * 5), - Ter(tefBAD_SIGNATURE)); - // Bad multisign signatures for issuer (Counterparty) - env(set(borrower, broker.brokerID, principalRequest), - kCounterparty(lender), - Msig(evan, lender), - Msig(sfCounterpartySignature, alice, issuer), - Fee(env.current()->fees().base * 5 - 1), - Ter(tefBAD_SIGNATURE)); - env(signers(lender, kNone)); - env(signers(borrower, kNone)); - // multisign sufficient fee, but no signers set up - env(set(borrower, broker.brokerID, principalRequest), - kCounterparty(lender), - Msig(evan, lender), - Msig(sfCounterpartySignature, evan, borrower), - Fee(env.current()->fees().base * 5), - Ter(tefNOT_MULTI_SIGNING)); - // not the broker owner, no counterparty, not signed by broker - // owner - env(set(borrower, broker.brokerID, principalRequest), - Sig(sfCounterpartySignature, evan), - loanSetFee, - Ter(tefBAD_AUTH)); - // not the broker owner, counterparty is borrower - env(set(evan, broker.brokerID, principalRequest), - kCounterparty(borrower), - Sig(sfCounterpartySignature, borrower), - loanSetFee, - Ter(tecNO_PERMISSION)); - // not a LoanBroker object, no counterparty - env(set(lender, badKeylet.key, principalRequest), - Sig(sfCounterpartySignature, evan), - loanSetFee, - Ter(temBAD_SIGNER)); - // not a LoanBroker object, counterparty is valid - env(set(lender, badKeylet.key, principalRequest), - kCounterparty(borrower), - Sig(sfCounterpartySignature, borrower), - loanSetFee, - Ter(tecNO_ENTRY)); - // borrower doesn't exist - env(set(lender, broker.brokerID, principalRequest), - kCounterparty(alice), - Sig(sfCounterpartySignature, alice), - loanSetFee, - Ter(terNO_ACCOUNT)); - - // Request more funds than the vault has available - env(set(evan, broker.brokerID, totalVaultRequest + 1), - Sig(sfCounterpartySignature, lender), - loanSetFee, - Ter(tecINSUFFICIENT_FUNDS)); - - // Request more funds than the broker's first-loss capital can - // cover. - env(set(evan, broker.brokerID, maxCoveredLoanRequest + 1), - Sig(sfCounterpartySignature, lender), - loanSetFee, - Ter(tecINSUFFICIENT_FUNDS)); - - // Frozen trust line / locked MPT issuance - // XRP can not be frozen, but run through the loop anyway to test - // the tecLIMIT_EXCEEDED case - { - auto const brokerSle = env.le(keylet::loanBroker(broker.brokerID)); - if (!BEAST_EXPECT(brokerSle)) - return; - - auto const vaultPseudo = [&]() { - auto const vaultSle = env.le(keylet::vault(brokerSle->at(sfVaultID))); - if (!BEAST_EXPECT(vaultSle)) - { - // This will be wrong, but the test has failed anyway. - return Account{lender}; - } - auto vaultPseudo = Account("Vault pseudo-account", vaultSle->at(sfAccount)); - return vaultPseudo; - }(); - - auto const [freeze, deepfreeze, unfreeze, expectedResult] = - [&]() -> std::tuple< - std::function, - std::function, - std::function, - TER> { - // Freeze / lock the asset - std::function const empty; - if (broker.asset.native()) - { - // XRP can't be frozen - return std::make_tuple(empty, empty, empty, tesSUCCESS); - } - if (broker.asset.holds()) - { - auto freeze = [&](Account const& holder) { - env(trust(issuer, holder[iouCurrency_](0), tfSetFreeze)); - }; - auto deepfreeze = [&](Account const& holder) { - env(trust(issuer, holder[iouCurrency_](0), tfSetFreeze | tfSetDeepFreeze)); - }; - auto unfreeze = [&](Account const& holder) { - env(trust( - issuer, holder[iouCurrency_](0), tfClearFreeze | tfClearDeepFreeze)); - }; - return std::make_tuple(freeze, deepfreeze, unfreeze, tecFROZEN); - } - - auto freeze = [&](Account const& holder) { - mptt.set({.account = issuer, .holder = holder, .flags = tfMPTLock}); - }; - auto unfreeze = [&](Account const& holder) { - mptt.set({.account = issuer, .holder = holder, .flags = tfMPTUnlock}); - }; - return std::make_tuple(freeze, empty, unfreeze, tecLOCKED); - }(); - - // Try freezing the accounts that can't be frozen - if (freeze) - { - for (auto const& account : {vaultPseudo, evan}) - { - // Freeze the account - freeze(account); - - // Try to create a loan with a frozen line - env(set(evan, broker.brokerID, debtMaximumRequest), - Sig(sfCounterpartySignature, lender), - loanSetFee, - Ter(expectedResult)); - - // Unfreeze the account - BEAST_EXPECT(unfreeze); - unfreeze(account); - - // Ensure the line is unfrozen with a request that is fine - // except too it requests more principal than the broker can - // carry - env(set(evan, broker.brokerID, debtMaximumRequest + 1), - Sig(sfCounterpartySignature, lender), - loanSetFee, - Ter(tecLIMIT_EXCEEDED)); - } - } - - // Deep freeze the borrower, which prevents them from receiving - // funds - if (deepfreeze) - { - // Make sure evan has a trust line that so the issuer can - // freeze it. (Don't need to do this for the borrower, - // because LoanSet will create a line to the borrower - // automatically.) - env(trust(evan, issuer[iouCurrency_](100'000))); - - for (auto const& account : {// these accounts can't be frozen, which deep freeze - // implies - vaultPseudo, - evan, - // these accounts can't be deep frozen - lender}) - { - // Freeze evan - deepfreeze(account); - - // Try to create a loan with a deep frozen line - env(set(evan, broker.brokerID, debtMaximumRequest), - Sig(sfCounterpartySignature, lender), - loanSetFee, - Ter(expectedResult)); - - // Unfreeze evan - BEAST_EXPECT(unfreeze); - unfreeze(account); - - // Ensure the line is unfrozen with a request that is fine - // except too it requests more principal than the broker can - // carry - env(set(evan, broker.brokerID, debtMaximumRequest + 1), - Sig(sfCounterpartySignature, lender), - loanSetFee, - Ter(tecLIMIT_EXCEEDED)); - } - } - } - - // Finally! Create a loan - - auto coverAvailable = [&env, this](uint256 const& brokerID, Number const& expected) { - if (auto const brokerSle = env.le(keylet::loanBroker(brokerID)); - BEAST_EXPECT(brokerSle)) - { - auto const available = brokerSle->at(sfCoverAvailable); - BEAST_EXPECT(available == expected); - return available; - } - return Number{}; - }; - auto getDefaultInfo = [&env, this](LoanState const& state, BrokerInfo const& broker) { - if (auto const brokerSle = env.le(keylet::loanBroker(broker.brokerID)); - BEAST_EXPECT(brokerSle)) - { - BEAST_EXPECT( - state.loanScale >= - (broker.asset.integral() - ? 0 - : std::max( - broker.vaultScale(env), state.principalOutstanding.exponent()))); - NumberRoundModeGuard const mg(Number::RoundingMode::Upward); - auto const defaultAmount = roundToAsset( - broker.asset, - std::min( - tenthBipsOfValue( - tenthBipsOfValue( - brokerSle->at(sfDebtTotal), broker.params.coverRateMin), - broker.params.coverRateLiquidation), - state.totalValue - state.managementFeeOutstanding), - state.loanScale); - return std::make_pair(defaultAmount, brokerSle->at(sfOwner)); - } - return std::make_pair(Number{}, AccountID{}); - }; - auto replenishCover = [&env, &coverAvailable]( - BrokerInfo const& broker, - AccountID const& brokerAcct, - Number const& startingCoverAvailable, - Number const& amountToBeCovered) { - coverAvailable(broker.brokerID, startingCoverAvailable - amountToBeCovered); - env(loanBroker::coverDeposit( - brokerAcct, broker.brokerID, STAmount{broker.asset, amountToBeCovered})); - coverAvailable(broker.brokerID, startingCoverAvailable); - env.close(); - }; - - auto defaultImmediately = [&](std::uint32_t baseFlag, bool impair = true) { - return [&, impair, baseFlag]( - Keylet const& loanKeylet, VerifyLoanStatus const& verifyLoanStatus) { - // toEndOfLife - // - // Default the loan - - // Initialize values with the current state - auto state = getCurrentState(env, broker, loanKeylet, verifyLoanStatus); - BEAST_EXPECT(state.flags == baseFlag); - - auto const& broker = verifyLoanStatus.broker; - auto const startingCoverAvailable = coverAvailable( - broker.brokerID, broker.asset(broker.params.coverDeposit).number()); - - if (impair) - { - // Check the vault - bool const canImpair = canImpairLoan(env, broker, state); - // Impair the loan, if possible - env(manage(lender, loanKeylet.key, tfLoanImpair), - canImpair ? Ter(tesSUCCESS) : Ter(tecLIMIT_EXCEEDED)); - - if (canImpair) - { - state.flags |= tfLoanImpair; - state.nextPaymentDate = env.now().time_since_epoch().count(); - - // Once the loan is impaired, it can't be impaired again - env(manage(lender, loanKeylet.key, tfLoanImpair), Ter(tecNO_PERMISSION)); - } - verifyLoanStatus(state); - } - - auto const nextDueDate = tp{d{state.nextPaymentDate}}; - - // Can't default the loan yet. The grace period hasn't - // expired - env(manage(lender, loanKeylet.key, tfLoanDefault), Ter(tecTOO_SOON)); - - // Let some time pass so that the loan can be - // defaulted - env.close(nextDueDate + 60s); - - auto const [amountToBeCovered, brokerAcct] = getDefaultInfo(state, broker); - - // Default the loan - env(manage(lender, loanKeylet.key, tfLoanDefault)); - env.close(); - - // The LoanBroker just lost some of it's first-loss capital. - // Replenish it. - replenishCover(broker, brokerAcct, startingCoverAvailable, amountToBeCovered); - - state.flags |= tfLoanDefault; - state.paymentRemaining = 0; - state.totalValue = 0; - state.principalOutstanding = 0; - state.managementFeeOutstanding = 0; - state.nextPaymentDate = 0; - verifyLoanStatus(state); - - // Once a loan is defaulted, it can't be managed - env(manage(lender, loanKeylet.key, tfLoanUnimpair), Ter(tecNO_PERMISSION)); - env(manage(lender, loanKeylet.key, tfLoanImpair), Ter(tecNO_PERMISSION)); - // Can't make a payment on it either - env(pay(borrower, loanKeylet.key, broker.asset(300)), Ter(tecKILLED)); - }; - }; - - auto singlePayment = [&](Keylet const& loanKeylet, - VerifyLoanStatus const& verifyLoanStatus, - LoanState& state, - STAmount const& payoffAmount, - std::uint32_t numPayments, - std::uint32_t baseFlag, - std::uint32_t txFlags) { - // toEndOfLife - // - verifyLoanStatus(state); - - // Send some bogus pay transactions - env(pay(borrower, keylet::loan(uint256(0)).key, broker.asset(10), txFlags), - Ter(temINVALID)); - // broker.asset(80) is less than a single payment, but all these - // checks fail before that matters - env(pay(borrower, loanKeylet.key, broker.asset(-80), txFlags), Ter(temBAD_AMOUNT)); - env(pay(borrower, broker.brokerID, broker.asset(80), txFlags), Ter(tecNO_ENTRY)); - env(pay(evan, loanKeylet.key, broker.asset(80), txFlags), Ter(tecNO_PERMISSION)); - - // TODO: Write a general "isFlag" function? See STObject::isFlag. - // Maybe add a static overloaded member? - if (!(state.flags & lsfLoanOverpayment)) - { - // If the loan does not allow overpayments, send a payment that - // tries to make an overpayment. Do not include `txFlags`, so we - // don't end up duplicating the next test transaction. - // - // fixCleanup3_1_3 gates tfLoanOverpayment as a valid flag: - // with fix on → preflight passes, apply returns tecNO_PERMISSION; - // with fix off → preflight rejects the flag, returns temINVALID_FLAG. - bool const hasFix313 = env.current()->rules().enabled(fixCleanup3_1_3); - STAmount const overpayAmount{broker.asset, state.periodicPayment * Number{15, -1}}; - XRPAmount const overpayFee{ - baseFee * (Number{15, -1} / kLoanPaymentsPerFeeIncrement + 1)}; - env(pay(borrower, loanKeylet.key, overpayAmount, tfLoanOverpayment), - Fee(overpayFee), - Ter(hasFix313 ? TER{tecNO_PERMISSION} : TER{temINVALID_FLAG})); - - if (hasFix313) - { - env.disableFeature(fixCleanup3_1_3); - env(pay(borrower, loanKeylet.key, overpayAmount, tfLoanOverpayment), - Fee(overpayFee), - Ter(temINVALID_FLAG)); - env.enableFeature(fixCleanup3_1_3); - } - } - // Try to send a payment marked as multiple mutually exclusive - // payment types. Do not include `txFlags`, so we don't duplicate - // the prior test transaction. - env(pay(borrower, - loanKeylet.key, - broker.asset(state.periodicPayment * 2), - tfLoanLatePayment | tfLoanFullPayment), - Ter(temINVALID_FLAG)); - env(pay(borrower, - loanKeylet.key, - broker.asset(state.periodicPayment * 2), - tfLoanLatePayment | tfLoanOverpayment), - Ter(temINVALID_FLAG)); - env(pay(borrower, - loanKeylet.key, - broker.asset(state.periodicPayment * 2), - tfLoanOverpayment | tfLoanFullPayment), - Ter(temINVALID_FLAG)); - env(pay(borrower, - loanKeylet.key, - broker.asset(state.periodicPayment * 2), - tfLoanLatePayment | tfLoanOverpayment | tfLoanFullPayment), - Ter(temINVALID_FLAG)); - - { - auto const otherAsset = - broker.asset.raw() == assets[0].raw() ? assets[1] : assets[0]; - env(pay(borrower, loanKeylet.key, otherAsset(100), txFlags), Ter(tecWRONG_ASSET)); - } - - // Amount doesn't cover a single payment - env(pay(borrower, loanKeylet.key, STAmount{broker.asset, 1}, txFlags), - Ter(tecINSUFFICIENT_PAYMENT)); - - // Get the balance after these failed transactions take - // fees - auto const borrowerBalanceBeforePayment = env.balance(borrower, broker.asset); - - BEAST_EXPECT(payoffAmount > state.principalOutstanding); - // Try to pay a little extra to show that it's _not_ - // taken - auto const transactionAmount = payoffAmount + broker.asset(10); - - // Send a transaction that tries to pay more than the borrowers's - // balance - XRPAmount const badFee{ - baseFee * - (borrowerBalanceBeforePayment.number() * 2 / state.periodicPayment / - kLoanPaymentsPerFeeIncrement + - 1)}; - env(pay(borrower, - loanKeylet.key, - STAmount{broker.asset, borrowerBalanceBeforePayment.number() * 2}, - txFlags), - Fee(badFee), - Ter(tecINSUFFICIENT_FUNDS)); - - XRPAmount const goodFee{baseFee * (numPayments / kLoanPaymentsPerFeeIncrement + 1)}; - env(pay(borrower, loanKeylet.key, transactionAmount, txFlags), Fee(goodFee)); - - env.close(); - - // log << env.meta()->getJson() << std::endl; - - // Need to account for fees if the loan is in XRP - PrettyAmount adjustment = broker.asset(0); - if (broker.asset.native()) - { - adjustment = badFee + goodFee; - } - - state.paymentRemaining = 0; - state.principalOutstanding = 0; - state.totalValue = 0; - state.managementFeeOutstanding = 0; - state.previousPaymentDate = - state.nextPaymentDate + (state.paymentInterval * (numPayments - 1)); - state.nextPaymentDate = 0; - verifyLoanStatus(state); - - verifyLoanStatus.checkPayment( - state.loanScale, borrower, borrowerBalanceBeforePayment, payoffAmount, adjustment); - - // Can't impair or default a paid off loan - env(manage(lender, loanKeylet.key, tfLoanImpair), Ter(tecNO_PERMISSION)); - env(manage(lender, loanKeylet.key, tfLoanDefault), Ter(tecNO_PERMISSION)); - }; - - auto fullPayment = [&](std::uint32_t baseFlag) { - return [&, baseFlag]( - Keylet const& loanKeylet, VerifyLoanStatus const& verifyLoanStatus) { - // toEndOfLife - // - auto state = getCurrentState(env, broker, loanKeylet, verifyLoanStatus); - env.close(state.startDate + 20s); - auto const loanAge = (env.now() - state.startDate).count(); - BEAST_EXPECT(loanAge == 30); - - // Full payoff amount will consist of - // 1. principal outstanding (1000) - // 2. accrued interest (at 12%) - // 3. prepayment penalty (closeInterest at 3.6%) - // 4. close payment fee (4) - // Calculate these values without the helper functions - // to verify they're working correctly The numbers in - // the below BEAST_EXPECTs may not hold across assets. - Number const interval = state.paymentInterval; - auto const periodicRate = interval * Number(12, -2) / kSecondsInYear; - BEAST_EXPECT( - periodicRate == Number(2283105022831050228ULL, -24, Number::Normalized{})); - STAmount const principalOutstanding{broker.asset, state.principalOutstanding}; - STAmount const accruedInterest{ - broker.asset, state.principalOutstanding * periodicRate * loanAge / interval}; - BEAST_EXPECT(accruedInterest == broker.asset(Number(1141552511415525, -19))); - STAmount const prepaymentPenalty{ - broker.asset, state.principalOutstanding * Number(36, -3)}; - BEAST_EXPECT(prepaymentPenalty == broker.asset(36)); - STAmount const closePaymentFee = broker.asset(4); - auto const payoffAmount = roundToScale( - principalOutstanding + accruedInterest + prepaymentPenalty + closePaymentFee, - state.loanScale); - BEAST_EXPECT( - payoffAmount == - roundToAsset( - broker.asset, - broker.asset(Number(1040000114155251, -12)).number(), - state.loanScale)); - - // The terms of this loan actually make the early payoff - // more expensive than just making payments - BEAST_EXPECT( - payoffAmount > - state.paymentRemaining * (state.periodicPayment + broker.asset(2).value())); - - singlePayment( - loanKeylet, - verifyLoanStatus, - state, - payoffAmount, - 1, - baseFlag, - tfLoanFullPayment); - }; - }; - - auto combineAllPayments = [&](std::uint32_t baseFlag) { - return - [&, baseFlag](Keylet const& loanKeylet, VerifyLoanStatus const& verifyLoanStatus) { - // toEndOfLife - // - - auto state = getCurrentState(env, broker, loanKeylet, verifyLoanStatus); - env.close(); - - BEAST_EXPECT( - STAmount(broker.asset, state.periodicPayment) == - broker.asset(Number(8333457002039338267, -17))); - - // Make all the payments in one transaction - // service fee is 2 - auto const startingPayments = state.paymentRemaining; - STAmount const payoffAmount = [&]() { - NumberRoundModeGuard const mg(Number::RoundingMode::Upward); - auto const rawPayoff = - startingPayments * (state.periodicPayment + broker.asset(2).value()); - STAmount payoffAmount{broker.asset, rawPayoff}; - BEAST_EXPECTS( - payoffAmount == broker.asset(Number(1024014840244721, -12)), - to_string(payoffAmount)); - BEAST_EXPECT(payoffAmount > state.principalOutstanding); - - payoffAmount = roundToScale(payoffAmount, state.loanScale); - - return payoffAmount; - }(); - - auto const totalPayoffValue = - state.totalValue + startingPayments * broker.asset(2).value(); - STAmount const totalPayoffAmount{broker.asset, totalPayoffValue}; - - BEAST_EXPECTS( - totalPayoffAmount == payoffAmount, - "Payoff amount: " + to_string(payoffAmount) + - ". Total Value: " + to_string(totalPayoffAmount)); - - singlePayment( - loanKeylet, - verifyLoanStatus, - state, - payoffAmount, - state.paymentRemaining, - baseFlag, - 0); - }; - }; - - // There are a lot of fields that can be set on a loan, but most - // of them only affect the "math" when a payment is made. The - // only one that really affects behavior is the - // `tfLoanOverpayment` flag. - lifecycle( - caseLabel, - "Loan overpayment allowed - Impair and Default", - env, - loanAmount, - interestExponent, - lender, - borrower, - evan, - broker, - pseudoAcct, - tfLoanOverpayment, - defaultImmediately(lsfLoanOverpayment)); - - lifecycle( - caseLabel, - "Loan overpayment prohibited - Impair and Default", - env, - loanAmount, - interestExponent, - lender, - borrower, - evan, - broker, - pseudoAcct, - 0, - defaultImmediately(0)); - - lifecycle( - caseLabel, - "Loan overpayment allowed - Default without Impair", - env, - loanAmount, - interestExponent, - lender, - borrower, - evan, - broker, - pseudoAcct, - tfLoanOverpayment, - defaultImmediately(lsfLoanOverpayment, false)); - - lifecycle( - caseLabel, - "Loan overpayment prohibited - Default without Impair", - env, - loanAmount, - interestExponent, - lender, - borrower, - evan, - broker, - pseudoAcct, - 0, - defaultImmediately(0, false)); - - lifecycle( - caseLabel, - "Loan overpayment prohibited - Pay off immediately", - env, - loanAmount, - interestExponent, - lender, - borrower, - evan, - broker, - pseudoAcct, - 0, - fullPayment(0)); - - lifecycle( - caseLabel, - "Loan overpayment allowed - Pay off immediately", - env, - loanAmount, - interestExponent, - lender, - borrower, - evan, - broker, - pseudoAcct, - tfLoanOverpayment, - fullPayment(lsfLoanOverpayment)); - - lifecycle( - caseLabel, - "Loan overpayment prohibited - Combine all payments", - env, - loanAmount, - interestExponent, - lender, - borrower, - evan, - broker, - pseudoAcct, - 0, - combineAllPayments(0)); - - lifecycle( - caseLabel, - "Loan overpayment allowed - Combine all payments", - env, - loanAmount, - interestExponent, - lender, - borrower, - evan, - broker, - pseudoAcct, - tfLoanOverpayment, - combineAllPayments(lsfLoanOverpayment)); - - lifecycle( - caseLabel, - "Loan overpayment prohibited - Make payments", - env, - loanAmount, - interestExponent, - lender, - borrower, - evan, - broker, - pseudoAcct, - 0, - [&](Keylet const& loanKeylet, VerifyLoanStatus const& verifyLoanStatus) { - // toEndOfLife - // - // Draw and make multiple payments - auto state = getCurrentState(env, broker, loanKeylet, verifyLoanStatus); - BEAST_EXPECT(state.flags == 0); - env.close(); - - verifyLoanStatus(state); - - env.close(state.startDate + 20s); - auto const loanAge = (env.now() - state.startDate).count(); - BEAST_EXPECT(loanAge == 30); - - // Periodic payment amount will consist of - // 1. principal outstanding (1000) - // 2. interest interest rate (at 12%) - // 3. payment interval (600s) - // 4. loan service fee (2) - // Calculate these values without the helper functions - // to verify they're working correctly The numbers in - // the below BEAST_EXPECTs may not hold across assets. - Number const interval = state.paymentInterval; - auto const periodicRate = interval * Number(12, -2) / kSecondsInYear; - BEAST_EXPECT( - periodicRate == Number(2283105022831050228, -24, Number::Normalized{})); - STAmount const roundedPeriodicPayment{ - broker.asset, - roundPeriodicPayment(broker.asset, state.periodicPayment, state.loanScale)}; - - testcase << currencyLabel << " Payment components: " - << "Payments remaining, rawInterest, rawPrincipal, " - "rawMFee, trackedValueDelta, trackedPrincipalDelta, " - "trackedInterestDelta, trackedMgmtFeeDelta, special"; - - auto const serviceFee = broker.asset(2); - - BEAST_EXPECT( - roundedPeriodicPayment == - roundToScale( - broker.asset( - Number(8333457002039338267, -17), Number::RoundingMode::Upward), - state.loanScale, - Number::RoundingMode::Upward)); - // 83334570.01162141 - // Include the service fee - STAmount const totalDue = roundToScale( - roundedPeriodicPayment + serviceFee, - state.loanScale, - Number::RoundingMode::Upward); - // Only check the first payment since the rounding - // may drift as payments are made - BEAST_EXPECT( - totalDue == - roundToScale( - broker.asset( - Number(8533457002039338267, -17), Number::RoundingMode::Upward), - state.loanScale, - Number::RoundingMode::Upward)); - - { - auto const raw = computeTheoreticalLoanState( - env.current()->rules(), - state.periodicPayment, - periodicRate, - state.paymentRemaining, - broker.params.managementFeeRate); - auto const rounded = constructLoanState( - state.totalValue, - state.principalOutstanding, - state.managementFeeOutstanding); - testcase << currencyLabel << " Loan starting state: " << state.paymentRemaining - << ", " << raw.interestDue << ", " << raw.principalOutstanding << ", " - << raw.managementFeeDue << ", " << rounded.valueOutstanding << ", " - << rounded.principalOutstanding << ", " << rounded.interestDue << ", " - << rounded.managementFeeDue; - } - - // Try to pay a little extra to show that it's _not_ - // taken - STAmount const transactionAmount = - STAmount{broker.asset, totalDue} + broker.asset(10); - // Only check the first payment since the rounding - // may drift as payments are made - BEAST_EXPECT( - transactionAmount == - roundToScale( - broker.asset(Number(9533457002039400, -14), Number::RoundingMode::Upward), - state.loanScale, - Number::RoundingMode::Upward)); - - auto const initialState = state; - xrpl::detail::PaymentComponents totalPaid{ - .trackedValueDelta = 0, - .trackedPrincipalDelta = 0, - .trackedManagementFeeDelta = 0}; - Number totalInterestPaid = 0; - std::size_t totalPaymentsMade = 0; - - xrpl::LoanState currentTrueState = computeTheoreticalLoanState( - env.current()->rules(), - state.periodicPayment, - periodicRate, - state.paymentRemaining, - broker.params.managementFeeRate); - - while (state.paymentRemaining > 0) - { - // Compute the expected principal amount - auto const paymentComponents = xrpl::detail::computePaymentComponents( - env.current()->rules(), - broker.asset.raw(), - state.loanScale, - state.totalValue, - state.principalOutstanding, - state.managementFeeOutstanding, - state.periodicPayment, - periodicRate, - state.paymentRemaining, - broker.params.managementFeeRate); - - BEAST_EXPECTS( - paymentComponents.specialCase == xrpl::detail::PaymentSpecialCase::Final || - paymentComponents.trackedValueDelta <= roundedPeriodicPayment, - "Delta: " + to_string(paymentComponents.trackedValueDelta) + - ", periodic payment: " + to_string(roundedPeriodicPayment)); - - xrpl::LoanState const nextTrueState = computeTheoreticalLoanState( - env.current()->rules(), - state.periodicPayment, - periodicRate, - state.paymentRemaining - 1, - broker.params.managementFeeRate); - xrpl::detail::LoanStateDeltas const deltas = currentTrueState - nextTrueState; - - testcase << currencyLabel << " Payment components: " << state.paymentRemaining - << ", " << deltas.interest << ", " << deltas.principal << ", " - << deltas.managementFee << ", " << paymentComponents.trackedValueDelta - << ", " << paymentComponents.trackedPrincipalDelta << ", " - << paymentComponents.trackedInterestPart() << ", " - << paymentComponents.trackedManagementFeeDelta << ", " - << [&]() -> char const* { - if (paymentComponents.specialCase == - ::xrpl::detail::PaymentSpecialCase::Final) - return "final"; - if (paymentComponents.specialCase == - ::xrpl::detail::PaymentSpecialCase::Extra) - return "extra"; - return "none"; - }(); - - auto const totalDueAmount = STAmount{ - broker.asset, paymentComponents.trackedValueDelta + serviceFee.number()}; - - // Due to the rounding algorithms to keep the interest and - // principal in sync with "true" values, the computed amount - // may be a little less than the rounded fixed payment - // amount. For integral types, the difference should be < 3 - // (1 unit for each of the interest and management fee). For - // IOUs, the difference should be after the 8th digit. - Number const diff = totalDue - totalDueAmount; - BEAST_EXPECT( - paymentComponents.specialCase == xrpl::detail::PaymentSpecialCase::Final || - diff == beast::kZero || - (diff > beast::kZero && - ((broker.asset.integral() && (static_cast(diff) < 3)) || - (state.loanScale - diff.exponent() > 13)))); - - BEAST_EXPECT( - paymentComponents.trackedValueDelta == - paymentComponents.trackedPrincipalDelta + - paymentComponents.trackedInterestPart() + - paymentComponents.trackedManagementFeeDelta); - BEAST_EXPECT( - paymentComponents.specialCase == xrpl::detail::PaymentSpecialCase::Final || - paymentComponents.trackedValueDelta <= roundedPeriodicPayment); - - BEAST_EXPECT( - state.paymentRemaining < 12 || - roundToAsset( - broker.asset, - deltas.principal, - state.loanScale, - Number::RoundingMode::Upward) == - roundToScale( - broker.asset( - Number(8333228691531218890, -17), Number::RoundingMode::Upward), - state.loanScale, - Number::RoundingMode::Upward)); - BEAST_EXPECT( - paymentComponents.trackedPrincipalDelta >= beast::kZero && - paymentComponents.trackedPrincipalDelta <= state.principalOutstanding); - BEAST_EXPECT( - paymentComponents.specialCase != xrpl::detail::PaymentSpecialCase::Final || - paymentComponents.trackedPrincipalDelta == state.principalOutstanding); - BEAST_EXPECT( - paymentComponents.specialCase == xrpl::detail::PaymentSpecialCase::Final || - (state.periodicPayment.exponent() - - (deltas.principal + deltas.interest + deltas.managementFee - - state.periodicPayment) - .exponent()) > 14); - - auto const borrowerBalanceBeforePayment = env.balance(borrower, broker.asset); - - if (canImpairLoan(env, broker, state)) - { - // Making a payment will unimpair the loan - env(manage(lender, loanKeylet.key, tfLoanImpair)); - } - - env.close(); - - // Make the payment - env(pay(borrower, loanKeylet.key, transactionAmount)); - - env.close(); - - // Need to account for fees if the loan is in XRP - PrettyAmount adjustment = broker.asset(0); - if (broker.asset.native()) - { - adjustment = env.current()->fees().base; - } - - // Check the result - verifyLoanStatus.checkPayment( - state.loanScale, - borrower, - borrowerBalanceBeforePayment, - totalDueAmount, - adjustment); - - --state.paymentRemaining; - state.previousPaymentDate = state.nextPaymentDate; - if (paymentComponents.specialCase == xrpl::detail::PaymentSpecialCase::Final) - { - state.paymentRemaining = 0; - state.nextPaymentDate = 0; - } - else - { - state.nextPaymentDate += state.paymentInterval; - } - state.principalOutstanding -= paymentComponents.trackedPrincipalDelta; - state.managementFeeOutstanding -= paymentComponents.trackedManagementFeeDelta; - state.totalValue -= paymentComponents.trackedValueDelta; - - verifyLoanStatus(state); - - totalPaid.trackedValueDelta += paymentComponents.trackedValueDelta; - totalPaid.trackedPrincipalDelta += paymentComponents.trackedPrincipalDelta; - totalPaid.trackedManagementFeeDelta += - paymentComponents.trackedManagementFeeDelta; - totalInterestPaid += paymentComponents.trackedInterestPart(); - ++totalPaymentsMade; - - currentTrueState = nextTrueState; - } - - // Loan is paid off - BEAST_EXPECT(state.paymentRemaining == 0); - BEAST_EXPECT(state.principalOutstanding == 0); - - // Make sure all the payments add up - BEAST_EXPECT(totalPaid.trackedValueDelta == initialState.totalValue); - BEAST_EXPECT(totalPaid.trackedPrincipalDelta == initialState.principalOutstanding); - BEAST_EXPECT( - totalPaid.trackedManagementFeeDelta == initialState.managementFeeOutstanding); - // This is almost a tautology given the previous checks, but - // check it anyway for completeness. - BEAST_EXPECT( - totalInterestPaid == - initialState.totalValue - - (initialState.principalOutstanding + - initialState.managementFeeOutstanding)); - BEAST_EXPECT(totalPaymentsMade == initialState.paymentRemaining); - - // Can't impair or default a paid off loan - env(manage(lender, loanKeylet.key, tfLoanImpair), Ter(tecNO_PERMISSION)); - env(manage(lender, loanKeylet.key, tfLoanDefault), Ter(tecNO_PERMISSION)); - }); - -#if LOAN_TODO - // TODO - - /* - LoanPay fails with tecINVARIANT_FAILED error when loan_broker(also - borrower) tries to do the payment. Here's the scenario: Create a XRP - loan with loan broker as borrower, loan origination fee and loan service - fee. Loan broker makes the first payment with periodic payment and loan - service fee. - */ - - auto time = [&](std::string label, std::function timed) { - if (!BEAST_EXPECT(timed)) - return; - - using clock_type = std::chrono::steady_clock; - using duration_type = std::chrono::milliseconds; - - auto const start = clock_type::now(); - timed(); - auto const duration = - std::chrono::duration_cast(clock_type::now() - start); - - log << label << " took " << duration.count() << "ms" << std::endl; - - return duration; - }; - - lifecycle( - caseLabel, - "timing", - env, - loanAmount, - interestExponent, - lender, - borrower, - evan, - broker, - pseudoAcct, - tfLoanOverpayment, - [&](Keylet const& loanKeylet, VerifyLoanStatus const& verifyLoanStatus) { - // Estimate optimal values for kLoanPaymentsPerFeeIncrement and - // kLoanMaximumPaymentsPerTransaction. - using namespace loan; - - auto const state = getCurrentState(env, broker, verifyLoanStatus.keylet); - auto const serviceFee = broker.asset(2).value(); - - STAmount const totalDue{ - broker.asset, - roundPeriodicPayment( - broker.asset, state.periodicPayment + serviceFee, state.loanScale)}; - - // Make a single payment - time("single payment", [&]() { env(pay(borrower, loanKeylet.key, totalDue)); }); - env.close(); - - // Make all but the final payment - auto const numPayments = (state.paymentRemaining - 2); - STAmount const bigPayment{broker.asset, totalDue * numPayments}; - XRPAmount const bigFee{baseFee * (numPayments / kLoanPaymentsPerFeeIncrement + 1)}; - time("ten payments", [&]() { - env(pay(borrower, loanKeylet.key, bigPayment), Fee(bigFee)); - }); - env.close(); - - time("final payment", [&]() { - // Make the final payment - env(pay(borrower, loanKeylet.key, totalDue + STAmount{broker.asset, 1})); - }); - env.close(); - }); - - lifecycle( - caseLabel, - "Loan overpayment allowed - Explicit overpayment", - env, - loanAmount, - interestExponent, - lender, - borrower, - evan, - broker, - pseudoAcct, - tfLoanOverpayment, - [&](Keylet const& loanKeylet, VerifyLoanStatus const& verifyLoanStatus) { throw 0; }); - - lifecycle( - caseLabel, - "Loan overpayment prohibited - Late payment", - env, - loanAmount, - interestExponent, - lender, - borrower, - evan, - broker, - pseudoAcct, - tfLoanOverpayment, - [&](Keylet const& loanKeylet, VerifyLoanStatus const& verifyLoanStatus) { throw 0; }); - - lifecycle( - caseLabel, - "Loan overpayment allowed - Late payment", - env, - loanAmount, - interestExponent, - lender, - borrower, - evan, - broker, - pseudoAcct, - tfLoanOverpayment, - [&](Keylet const& loanKeylet, VerifyLoanStatus const& verifyLoanStatus) { throw 0; }); - - lifecycle( - caseLabel, - "Loan overpayment allowed - Late payment and overpayment", - env, - loanAmount, - interestExponent, - lender, - borrower, - evan, - broker, - pseudoAcct, - tfLoanOverpayment, - [&](Keylet const& loanKeylet, VerifyLoanStatus const& verifyLoanStatus) { throw 0; }); - -#endif - } - - void - testLoanSet(FeatureBitset features) - { - using namespace jtx; - - Account const issuer{"issuer"}; - Account const lender{"lender"}; - Account const borrower{"borrower"}; - - struct CaseArgs - { - bool requireAuth = false; - bool authorizeBorrower = false; - int initialXRP = 1'000'000; - }; - - auto const testCase = [&, this]( - std::function mptTest, - std::function iouTest, - CaseArgs args = {}) { - Env env(*this, features); - env.fund(XRP(args.initialXRP), issuer, lender, borrower); - env.close(); - if (args.requireAuth) - { - env(fset(issuer, asfRequireAuth)); - env.close(); - } - - // We need two different asset types, MPT and IOU. Prepare MPT - // first - MPTTester mptt{env, issuer, kMptInitNoFund}; - - auto const kNone = LedgerSpecificFlags(0); - mptt.create( - {.flags = tfMPTCanTransfer | tfMPTCanLock | - (args.requireAuth ? tfMPTRequireAuth : kNone)}); - env.close(); - PrettyAsset const mptAsset = mptt.issuanceID(); - mptt.authorize({.account = lender}); - mptt.authorize({.account = borrower}); - env.close(); - if (args.requireAuth) - { - mptt.authorize({.account = issuer, .holder = lender}); - if (args.authorizeBorrower) - mptt.authorize({.account = issuer, .holder = borrower}); - env.close(); - } - - env(pay(issuer, lender, mptAsset(10'000'000))); - env.close(); - - // Prepare IOU - PrettyAsset const iouAsset = issuer[iouCurrency_]; - env(trust(lender, iouAsset(10'000'000))); - env(trust(borrower, iouAsset(10'000'000))); - env.close(); - if (args.requireAuth) - { - env(trust(issuer, iouAsset(0), lender, tfSetfAuth)); - env(pay(issuer, lender, iouAsset(10'000'000))); - if (args.authorizeBorrower) - { - env(trust(issuer, iouAsset(0), borrower, tfSetfAuth)); - env(pay(issuer, borrower, iouAsset(10'000))); - } - } - else - { - env(pay(issuer, lender, iouAsset(10'000'000))); - env(pay(issuer, borrower, iouAsset(10'000))); - } - env.close(); - - // Create vaults and loan brokers - std::array const assets{mptAsset, iouAsset}; - std::vector brokers; - brokers.reserve(assets.size()); - for (auto const& asset : assets) - { - brokers.emplace_back(createVaultAndBroker(env, asset, lender)); - } - - if (mptTest) - mptTest(env, brokers[0], mptt); - if (iouTest) - iouTest(env, brokers[1]); - }; - - testCase( - [&, this](Env& env, BrokerInfo const& broker, auto&) { - using namespace loan; - Number const principalRequest = broker.asset(1'000).value(); - - testcase("MPT issuer is borrower, issuer submits"); - env(set(issuer, broker.brokerID, principalRequest), - kCounterparty(lender), - Sig(sfCounterpartySignature, lender), - Fee(env.current()->fees().base * 5)); - - testcase("MPT issuer is borrower, lender submits"); - env(set(lender, broker.brokerID, principalRequest), - kCounterparty(issuer), - Sig(sfCounterpartySignature, issuer), - Fee(env.current()->fees().base * 5)); - }, - [&, this](Env& env, BrokerInfo const& broker) { - using namespace loan; - Number const principalRequest = broker.asset(1'000).value(); - - testcase("IOU issuer is borrower, issuer submits"); - env(set(issuer, broker.brokerID, principalRequest), - kCounterparty(lender), - Sig(sfCounterpartySignature, lender), - Fee(env.current()->fees().base * 5)); - - testcase("IOU issuer is borrower, lender submits"); - env(set(lender, broker.brokerID, principalRequest), - kCounterparty(issuer), - Sig(sfCounterpartySignature, issuer), - Fee(env.current()->fees().base * 5)); - }, - CaseArgs{.requireAuth = true}); - - testCase( - [&, this](Env& env, BrokerInfo const& broker, auto&) { - using namespace loan; - Number const principalRequest = broker.asset(1'000).value(); - - testcase("MPT unauthorized borrower, borrower submits"); - env(set(borrower, broker.brokerID, principalRequest), - kCounterparty(lender), - Sig(sfCounterpartySignature, lender), - Fee(env.current()->fees().base * 5), - Ter{tecNO_AUTH}); - - testcase("MPT unauthorized borrower, lender submits"); - env(set(lender, broker.brokerID, principalRequest), - kCounterparty(borrower), - Sig(sfCounterpartySignature, borrower), - Fee(env.current()->fees().base * 5), - Ter{tecNO_AUTH}); - }, - [&, this](Env& env, BrokerInfo const& broker) { - using namespace loan; - Number const principalRequest = broker.asset(1'000).value(); - - testcase("IOU unauthorized borrower, borrower submits"); - env(set(borrower, broker.brokerID, principalRequest), - kCounterparty(lender), - Sig(sfCounterpartySignature, lender), - Fee(env.current()->fees().base * 5), - Ter{tecNO_AUTH}); - - testcase("IOU unauthorized borrower, lender submits"); - env(set(lender, broker.brokerID, principalRequest), - kCounterparty(borrower), - Sig(sfCounterpartySignature, borrower), - Fee(env.current()->fees().base * 5), - Ter{tecNO_AUTH}); - }, - CaseArgs{.requireAuth = true}); - - auto const [acctReserve, incReserve] = [this]() -> std::pair { - Env const env{*this, testableAmendments()}; - return { - env.current()->fees().accountReserve(0, 1).drops() / kDropsPerXrp.drops(), - env.current()->fees().increment.drops() / kDropsPerXrp.drops()}; - }(); - - testCase( - [&, this](Env& env, BrokerInfo const& broker, MPTTester& mptt) { - using namespace loan; - Number const principalRequest = broker.asset(1'000).value(); - - testcase( - "MPT authorized borrower, borrower submits, borrower has " - "no reserve"); - mptt.authorize({.account = borrower, .flags = tfMPTUnauthorize}); - env.close(); - - auto const mptoken = keylet::mptoken(mptt.issuanceID(), borrower); - auto const sleMPT1 = env.le(mptoken); - BEAST_EXPECT(sleMPT1 == nullptr); - - // Burn some XRP - env(noop(borrower), Fee(XRP((acctReserve * 2) + (incReserve * 2)))); - env.close(); - - // Cannot create loan, not enough reserve to create MPToken - env(set(borrower, broker.brokerID, principalRequest), - kCounterparty(lender), - Sig(sfCounterpartySignature, lender), - Fee(env.current()->fees().base * 5), - Ter{tecINSUFFICIENT_RESERVE}); - env.close(); - - // Can create loan now, will implicitly create MPToken - env(pay(issuer, borrower, XRP(incReserve))); - env.close(); - env(set(borrower, broker.brokerID, principalRequest), - kCounterparty(lender), - Sig(sfCounterpartySignature, lender), - Fee(env.current()->fees().base * 5)); - env.close(); - - auto const sleMPT2 = env.le(mptoken); - BEAST_EXPECT(sleMPT2 != nullptr); - }, - {}, - CaseArgs{.initialXRP = (acctReserve * 2) + (incReserve * 8) + 1}); - - testCase( - {}, - [&, this](Env& env, BrokerInfo const& broker) { - using namespace loan; - Number const principalRequest = broker.asset(1'000).value(); - - testcase( - "IOU authorized borrower, borrower submits, borrower has " - "no reserve"); - // Remove trust line from borrower to issuer - env.trust(broker.asset(0), borrower); - env.close(); - - env(pay(borrower, issuer, broker.asset(10'000))); - env.close(); - auto const trustline = keylet::trustLine(borrower, broker.asset.raw().get()); - auto const sleLine1 = env.le(trustline); - BEAST_EXPECT(sleLine1 == nullptr); - - // Burn some XRP - env(noop(borrower), Fee(XRP((acctReserve * 2) + (incReserve * 2)))); - env.close(); - - // Cannot create loan, not enough reserve to create trust line - env(set(borrower, broker.brokerID, principalRequest), - kCounterparty(lender), - Sig(sfCounterpartySignature, lender), - Fee(env.current()->fees().base * 5), - Ter{tecNO_LINE_INSUF_RESERVE}); - env.close(); - - // Can create loan now, will implicitly create trust line - env(pay(issuer, borrower, XRP(incReserve))); - env.close(); - env(set(borrower, broker.brokerID, principalRequest), - kCounterparty(lender), - Sig(sfCounterpartySignature, lender), - Fee(env.current()->fees().base * 5)); - env.close(); - - auto const sleLine2 = env.le(trustline); - BEAST_EXPECT(sleLine2 != nullptr); - }, - CaseArgs{.initialXRP = (acctReserve * 2) + (incReserve * 8) + 1}); - - testCase( - [&, this](Env& env, BrokerInfo const& broker, MPTTester& mptt) { - using namespace loan; - Number const principalRequest = broker.asset(1'000).value(); - - testcase( - "MPT authorized borrower, borrower submits, lender has " - "no reserve"); - auto const mptoken = keylet::mptoken(mptt.issuanceID(), lender); - auto const sleMPT1 = env.le(mptoken); - BEAST_EXPECT(sleMPT1 != nullptr); - - env(pay(lender, issuer, broker.asset(sleMPT1->at(sfMPTAmount)))); - env.close(); - - mptt.authorize({.account = lender, .flags = tfMPTUnauthorize}); - env.close(); - - auto const sleMPT2 = env.le(mptoken); - BEAST_EXPECT(sleMPT2 == nullptr); - - // Burn some XRP - env(noop(lender), Fee(XRP(incReserve))); - env.close(); - - // Cannot create loan, not enough reserve to create MPToken - env(set(borrower, broker.brokerID, principalRequest), - kLoanOriginationFee(broker.asset(1).value()), - kCounterparty(lender), - Sig(sfCounterpartySignature, lender), - Fee(env.current()->fees().base * 5), - Ter{tecINSUFFICIENT_RESERVE}); - env.close(); - - // Can create loan now, will implicitly create MPToken - env(pay(issuer, lender, XRP(incReserve))); - env.close(); - env(set(borrower, broker.brokerID, principalRequest), - kLoanOriginationFee(broker.asset(1).value()), - kCounterparty(lender), - Sig(sfCounterpartySignature, lender), - Fee(env.current()->fees().base * 5)); - env.close(); - - auto const sleMPT3 = env.le(mptoken); - BEAST_EXPECT(sleMPT3 != nullptr); - }, - {}, - CaseArgs{.initialXRP = (acctReserve * 2) + (incReserve * 8) + 1}); - - testCase( - {}, - [&, this](Env& env, BrokerInfo const& broker) { - using namespace loan; - Number const principalRequest = broker.asset(1'000).value(); - - testcase( - "IOU authorized borrower, borrower submits, lender has no " - "reserve"); - // Remove trust line from lender to issuer - env.trust(broker.asset(0), lender); - env.close(); - - auto const trustline = keylet::trustLine(lender, broker.asset.raw().get()); - auto const sleLine1 = env.le(trustline); - BEAST_EXPECT(sleLine1 != nullptr); - - env(pay(lender, issuer, broker.asset(abs(sleLine1->at(sfBalance).value())))); - env.close(); - auto const sleLine2 = env.le(trustline); - BEAST_EXPECT(sleLine2 == nullptr); - - // Burn some XRP - env(noop(lender), Fee(XRP(incReserve))); - env.close(); - - // Cannot create loan, not enough reserve to create trust line - env(set(borrower, broker.brokerID, principalRequest), - kLoanOriginationFee(broker.asset(1).value()), - kCounterparty(lender), - Sig(sfCounterpartySignature, lender), - Fee(env.current()->fees().base * 5), - Ter{tecNO_LINE_INSUF_RESERVE}); - env.close(); - - // Can create loan now, will implicitly create trust line - env(pay(issuer, lender, XRP(incReserve))); - env.close(); - env(set(borrower, broker.brokerID, principalRequest), - kLoanOriginationFee(broker.asset(1).value()), - kCounterparty(lender), - Sig(sfCounterpartySignature, lender), - Fee(env.current()->fees().base * 5)); - env.close(); - - auto const sleLine3 = env.le(trustline); - BEAST_EXPECT(sleLine3 != nullptr); - }, - CaseArgs{.initialXRP = (acctReserve * 2) + (incReserve * 8) + 1}); - - testCase( - [&, this](Env& env, BrokerInfo const& broker, MPTTester& mptt) { - using namespace loan; - Number const principalRequest = broker.asset(1'000).value(); - - testcase("MPT authorized borrower, unauthorized lender"); - auto const mptoken = keylet::mptoken(mptt.issuanceID(), lender); - auto const sleMPT1 = env.le(mptoken); - BEAST_EXPECT(sleMPT1 != nullptr); - - env(pay(lender, issuer, broker.asset(sleMPT1->at(sfMPTAmount)))); - env.close(); - - mptt.authorize({.account = lender, .flags = tfMPTUnauthorize}); - env.close(); - - auto const sleMPT2 = env.le(mptoken); - BEAST_EXPECT(sleMPT2 == nullptr); - - // Cannot create loan, lender not authorized to receive fee - env(set(borrower, broker.brokerID, principalRequest), - kLoanOriginationFee(broker.asset(1).value()), - kCounterparty(lender), - Sig(sfCounterpartySignature, lender), - Fee(env.current()->fees().base * 5), - Ter{tecNO_AUTH}); - env.close(); - - // Cannot create loan, even without an origination fee - env(set(borrower, broker.brokerID, principalRequest), - kCounterparty(lender), - Sig(sfCounterpartySignature, lender), - Fee(env.current()->fees().base * 5), - Ter{tecNO_AUTH}); - env.close(); - - // No MPToken for lender - no authorization and no payment - auto const sleMPT3 = env.le(mptoken); - BEAST_EXPECT(sleMPT3 == nullptr); - }, - {}, - CaseArgs{.requireAuth = true, .authorizeBorrower = true}); - - testCase( - [&, this](Env& env, BrokerInfo const& broker, auto&) { - using namespace loan; - Number const principalRequest = broker.asset(1'000).value(); - - testcase("MPT authorized borrower, borrower submits"); - env(set(borrower, broker.brokerID, principalRequest), - kCounterparty(lender), - Sig(sfCounterpartySignature, lender), - Fee(env.current()->fees().base * 5)); - }, - [&, this](Env& env, BrokerInfo const& broker) { - using namespace loan; - Number const principalRequest = broker.asset(1'000).value(); - - testcase("IOU authorized borrower, borrower submits"); - env(set(borrower, broker.brokerID, principalRequest), - kCounterparty(lender), - Sig(sfCounterpartySignature, lender), - Fee(env.current()->fees().base * 5)); - }, - CaseArgs{.requireAuth = true, .authorizeBorrower = true}); - - testCase( - [&, this](Env& env, BrokerInfo const& broker, auto&) { - using namespace loan; - Number const principalRequest = broker.asset(1'000).value(); - - testcase("MPT authorized borrower, lender submits"); - env(set(lender, broker.brokerID, principalRequest), - kCounterparty(borrower), - Sig(sfCounterpartySignature, borrower), - Fee(env.current()->fees().base * 5)); - }, - [&, this](Env& env, BrokerInfo const& broker) { - using namespace loan; - Number const principalRequest = broker.asset(1'000).value(); - - testcase("IOU authorized borrower, lender submits"); - env(set(lender, broker.brokerID, principalRequest), - kCounterparty(borrower), - Sig(sfCounterpartySignature, borrower), - Fee(env.current()->fees().base * 5)); - }, - CaseArgs{.requireAuth = true, .authorizeBorrower = true}); - - jtx::Account const alice{"alice"}; - jtx::Account const bella{"bella"}; - auto const msigSetup = [&](Env& env, Account const& account) { - json::Value const tx1 = signers(account, 2, {{alice, 1}, {bella, 1}}); - env(tx1); - env.close(); - }; - - testCase( - [&, this](Env& env, BrokerInfo const& broker, auto&) { - using namespace loan; - msigSetup(env, lender); - Number const principalRequest = broker.asset(1'000).value(); - - testcase( - "MPT authorized borrower, borrower submits, lender " - "multisign"); - env(set(borrower, broker.brokerID, principalRequest), - kCounterparty(lender), - Msig(sfCounterpartySignature, alice, bella), - Fee(env.current()->fees().base * 5)); - }, - [&, this](Env& env, BrokerInfo const& broker) { - using namespace loan; - msigSetup(env, lender); - Number const principalRequest = broker.asset(1'000).value(); - - testcase( - "IOU authorized borrower, borrower submits, lender " - "multisign"); - env(set(borrower, broker.brokerID, principalRequest), - kCounterparty(lender), - Msig(sfCounterpartySignature, alice, bella), - Fee(env.current()->fees().base * 5)); - }, - CaseArgs{.requireAuth = true, .authorizeBorrower = true}); - - testCase( - [&, this](Env& env, BrokerInfo const& broker, auto&) { - using namespace loan; - msigSetup(env, borrower); - Number const principalRequest = broker.asset(1'000).value(); - - testcase( - "MPT authorized borrower, lender submits, borrower " - "multisign"); - env(set(lender, broker.brokerID, principalRequest), - kCounterparty(borrower), - Msig(sfCounterpartySignature, alice, bella), - Fee(env.current()->fees().base * 5)); - }, - [&, this](Env& env, BrokerInfo const& broker) { - using namespace loan; - msigSetup(env, borrower); - Number const principalRequest = broker.asset(1'000).value(); - - testcase( - "IOU authorized borrower, lender submits, borrower " - "multisign"); - env(set(lender, broker.brokerID, principalRequest), - kCounterparty(borrower), - Msig(sfCounterpartySignature, alice, bella), - Fee(env.current()->fees().base * 5)); - }, - CaseArgs{.requireAuth = true, .authorizeBorrower = true}); - - testCase( - [&, this](Env& env, BrokerInfo const& broker, auto&) { - using namespace loan; - Number const principalRequest = broker.asset(1'000).value(); - Vault const vault{env}; - auto tx = vault.set({.owner = lender, .id = broker.vaultID}); - tx[sfAssetsMaximum] = BrokerParameters::defaults().vaultDeposit; - env(tx); - env.close(); - - testcase("Vault at maximum value"); - env(set(issuer, broker.brokerID, principalRequest), - kCounterparty(lender), - kInterestRate(TenthBips32(10'000)), - Sig(sfCounterpartySignature, lender), - Fee(env.current()->fees().base * 5), - Ter(tecLIMIT_EXCEEDED)); - }, - nullptr); - - testCase( - [&, this](Env& env, BrokerInfo const& broker, auto&) { - using namespace loan; - Number const principalRequest = broker.asset(1'000).value(); - Vault const vault{env}; - auto tx = vault.set({.owner = lender, .id = broker.vaultID}); - tx[sfAssetsMaximum] = - BrokerParameters::defaults().vaultDeposit + broker.asset(1).number(); - env(tx); - env.close(); - - testcase("Vault maximum value exceeded"); - env(set(issuer, broker.brokerID, principalRequest), - kCounterparty(lender), - kInterestRate(TenthBips32(100'000)), - Sig(sfCounterpartySignature, lender), - Fee(env.current()->fees().base * 5), - kPaymentTotal(2), - kPaymentInterval(3600 * 24), - Ter(tecLIMIT_EXCEEDED)); - }, - nullptr); - } - - void - testLifecycle(FeatureBitset features) - { - testcase("Lifecycle"); - using namespace jtx; - - // Create 3 loan brokers: one for XRP, one for an IOU, and one for - // an MPT. That'll require three corresponding SAVs. - Env env(*this, features); - - Account const issuer{"issuer"}; - // For simplicity, lender will be the sole actor for the vault & - // brokers. - Account const lender{"lender"}; - // Borrower only wants to borrow - Account const borrower{"borrower"}; - // Evan will attempt to be naughty - Account const evan{"evan"}; - // Do not fund alice - Account const alice{"alice"}; - - // Fund the accounts and trust lines with the same amount so that - // tests can use the same values regardless of the asset. - env.fund(XRP(100'000'000), issuer, noripple(lender, borrower, evan)); - env.close(); - - // Create assets - PrettyAsset const xrpAsset{xrpIssue(), 1'000'000}; - PrettyAsset const iouAsset = issuer[iouCurrency_]; - env(trust(lender, iouAsset(10'000'000))); - env(trust(borrower, iouAsset(10'000'000))); - env(trust(evan, iouAsset(10'000'000))); - env(pay(issuer, evan, iouAsset(1'000'000))); - env(pay(issuer, lender, iouAsset(10'000'000))); - // Fund the borrower with enough to cover interest and fees - env(pay(issuer, borrower, iouAsset(10'000))); - env.close(); - - MPTTester mptt{env, issuer, kMptInitNoFund}; - mptt.create({.flags = tfMPTCanClawback | tfMPTCanTransfer | tfMPTCanLock}); - // Scale the MPT asset a little bit so we can get some interest - PrettyAsset const mptAsset{mptt.issuanceID(), 100}; - mptt.authorize({.account = lender}); - mptt.authorize({.account = borrower}); - mptt.authorize({.account = evan}); - env(pay(issuer, lender, mptAsset(10'000'000))); - env(pay(issuer, evan, mptAsset(1'000'000))); - // Fund the borrower with enough to cover interest and fees - env(pay(issuer, borrower, mptAsset(10'000))); - env.close(); - - std::array const assets{iouAsset, xrpAsset, mptAsset}; - - // Create vaults and loan brokers - std::vector brokers; - brokers.reserve(assets.size()); - for (auto const& asset : assets) - { - brokers.emplace_back(createVaultAndBroker( - env, asset, lender, BrokerParameters{.data = "spam spam spam spam"})); - } - - // Create and update Loans - for (auto const& broker : brokers) - { - for (int amountExponent = 3; amountExponent >= 3; --amountExponent) - { - Number const loanAmount{1, amountExponent}; - for (int interestExponent = 0; interestExponent >= 0; --interestExponent) - { - testCaseWrapper(env, mptt, assets, broker, loanAmount, interestExponent); - } - } - - if (auto brokerSle = env.le(keylet::loanBroker(broker.brokerID)); - BEAST_EXPECT(brokerSle)) - { - BEAST_EXPECT(brokerSle->at(sfOwnerCount) == 0); - BEAST_EXPECT(brokerSle->at(sfDebtTotal) == 0); - - auto const coverAvailable = brokerSle->at(sfCoverAvailable); - env(loanBroker::coverWithdraw( - lender, broker.brokerID, STAmount(broker.asset, coverAvailable))); - env.close(); - - brokerSle = env.le(keylet::loanBroker(broker.brokerID)); - BEAST_EXPECT(brokerSle && brokerSle->at(sfCoverAvailable) == 0); - } - // Verify we can delete the loan broker - env(loanBroker::del(lender, broker.brokerID)); - env.close(); - } - } - - void - testSelfLoan(FeatureBitset features) - { - testcase << "Self Loan"; - - using namespace jtx; - using namespace std::chrono_literals; - // Create 3 loan brokers: one for XRP, one for an IOU, and one for - // an MPT. That'll require three corresponding SAVs. - Env env(*this, features); - - Account const issuer{"issuer"}; - // For simplicity, lender will be the sole actor for the vault & - // brokers. - Account const lender{"lender"}; - - // Fund the accounts and trust lines with the same amount so that - // tests can use the same values regardless of the asset. - env.fund(XRP(100'000'000), issuer, noripple(lender)); - env.close(); - - // Use an XRP asset for simplicity - PrettyAsset const xrpAsset{xrpIssue(), 1'000'000}; - - // Create vaults and loan brokers - BrokerInfo broker{createVaultAndBroker(env, xrpAsset, lender)}; - - using namespace loan; - - auto const loanSetFee = Fee(env.current()->fees().base * 2); - Number const principalRequest{1, 3}; - - // The LoanSet json can be created without a counterparty signature, - // but it will not pass preflight - auto createJson = env.json( - set(lender, broker.brokerID, broker.asset(principalRequest).value()), Fee(loanSetFee)); - env(createJson, Ter(temBAD_SIGNER)); - - // Adding an empty counterparty signature object also fails, but - // at the RPC level. - createJson = env.json(createJson, Json(sfCounterpartySignature, json::ValueType::Object)); - env(createJson, Ter(telENV_RPC_FAILED)); - - if (auto const jt = env.jt(createJson); BEAST_EXPECT(jt.stx)) - { - Serializer s; - jt.stx->add(s); - auto const jr = env.rpc("submit", strHex(s.slice())); - - BEAST_EXPECT(jr.isMember(jss::result)); - auto const jResult = jr[jss::result]; - BEAST_EXPECT(jResult[jss::error] == "invalidTransaction"); - BEAST_EXPECT( - jResult[jss::error_exception] == - "fails local checks: Transaction has bad signature."); - } - - // Copy the transaction signature into the counterparty signature. - json::Value counterpartyJson{json::ValueType::Object}; - counterpartyJson[sfTxnSignature] = createJson[sfTxnSignature]; - counterpartyJson[sfSigningPubKey] = createJson[sfSigningPubKey]; - if (!BEAST_EXPECT(!createJson.isMember(jss::Signers))) - counterpartyJson[sfSigners] = createJson[sfSigners]; - - // The duplicated signature works - createJson = env.json(createJson, Json(sfCounterpartySignature, counterpartyJson)); - env(createJson); - - env.close(); - - auto const startDate = env.current()->header().parentCloseTime; - - // Loan is successfully created - { - auto const res = env.rpc("account_objects", lender.human()); - auto const objects = res[jss::result][jss::account_objects]; - - std::map types; - BEAST_EXPECT(objects.size() == 4); - for (auto const& object : objects) - { - ++types[object[sfLedgerEntryType].asString()]; - } - BEAST_EXPECT(types.size() == 4); - for (std::string const type : {"MPToken", "Vault", "LoanBroker", "Loan"}) - { - BEAST_EXPECT(types[type] == 1); - } - } - auto const loanID = [&]() { - json::Value params(json::ValueType::Object); - params[jss::account] = lender.human(); - params[jss::type] = "Loan"; - auto const res = env.rpc("json", "account_objects", to_string(params)); - auto const objects = res[jss::result][jss::account_objects]; - - BEAST_EXPECT(objects.size() == 1); - - auto const loan = objects[0u]; - BEAST_EXPECT(loan[sfBorrower] == lender.human()); - // soeDEFAULT fields are not returned if they're in the default - // state - BEAST_EXPECT(!loan.isMember(sfCloseInterestRate)); - BEAST_EXPECT(!loan.isMember(sfClosePaymentFee)); - BEAST_EXPECT(loan[sfFlags] == 0); - BEAST_EXPECT(loan[sfGracePeriod] == 60); - BEAST_EXPECT(!loan.isMember(sfInterestRate)); - BEAST_EXPECT(!loan.isMember(sfLateInterestRate)); - BEAST_EXPECT(!loan.isMember(sfLatePaymentFee)); - BEAST_EXPECT(loan[sfLoanBrokerID] == to_string(broker.brokerID)); - BEAST_EXPECT(!loan.isMember(sfLoanOriginationFee)); - BEAST_EXPECT(loan[sfLoanSequence] == 1); - BEAST_EXPECT(!loan.isMember(sfLoanServiceFee)); - BEAST_EXPECT(loan[sfNextPaymentDueDate] == loan[sfStartDate].asUInt() + 60); - BEAST_EXPECT(!loan.isMember(sfOverpaymentFee)); - BEAST_EXPECT(!loan.isMember(sfOverpaymentInterestRate)); - BEAST_EXPECT(loan[sfPaymentInterval] == 60); - BEAST_EXPECT(loan[sfPeriodicPayment] == "1000000000"); - BEAST_EXPECT(loan[sfPaymentRemaining] == 1); - BEAST_EXPECT(!loan.isMember(sfPreviousPaymentDueDate)); - BEAST_EXPECT(loan[sfPrincipalOutstanding] == "1000000000"); - BEAST_EXPECT(loan[sfTotalValueOutstanding] == "1000000000"); - BEAST_EXPECT(!loan.isMember(sfLoanScale)); - BEAST_EXPECT(loan[sfStartDate].asUInt() == startDate.time_since_epoch().count()); - - return loan["index"].asString(); - }(); - auto const loanKeylet{keylet::loan(uint256{std::string_view(loanID)})}; - - env.close(startDate); - - // Make a payment - env(pay(lender, loanKeylet.key, broker.asset(1000))); - } - - void - testBatchBypassCounterparty(FeatureBitset features) - { - // From FIND-001 - testcase << "Batch Bypass Counterparty"; - - bool const lendingBatchEnabled = !std::ranges::any_of( - Batch::kDisabledTxTypes, - [](auto const& disabled) { return disabled == ttLOAN_BROKER_SET; }); - - using namespace jtx; - using namespace std::chrono_literals; - Env env(*this, features); - - Account const lender{"lender"}; - Account const borrower{"borrower"}; - - BrokerParameters const brokerParams; - env.fund(XRP(brokerParams.vaultDeposit * 100), lender, borrower); - env.close(); - - PrettyAsset const xrpAsset{xrpIssue(), 1'000'000}; - - BrokerInfo const broker{createVaultAndBroker(env, xrpAsset, lender, brokerParams)}; - - using namespace loan; - - auto const loanSetFee = Fee(env.current()->fees().base * 2); - Number const principalRequest{1, 3}; - - auto forgedLoanSet = set(borrower, broker.brokerID, principalRequest, 0); - - json::Value randomData{json::ValueType::Object}; - randomData[jss::SigningPubKey] = json::StaticString{"2600"}; - json::Value sigObject{json::ValueType::Object}; - sigObject[jss::SigningPubKey] = strHex(lender.pk().slice()); - Serializer ss; - ss.add32(HashPrefix::TxSign); - parse(randomData).addWithoutSigningFields(ss); - auto const sig = xrpl::sign(borrower.pk(), borrower.sk(), ss.slice()); - sigObject[jss::TxnSignature] = strHex(Slice{sig.data(), sig.size()}); - - forgedLoanSet[json::StaticString{"CounterpartySignature"}] = sigObject; - - // ? Fails because the lender hasn't signed the tx - env(env.json(forgedLoanSet, Fee(loanSetFee)), Ter(telENV_RPC_FAILED)); - - auto const seq = env.seq(borrower); - auto const batchFee = batch::calcBatchFee(env, 1, 2); - // ! Should fail because the lender hasn't signed the tx - env(batch::outer(borrower, seq, batchFee, tfAllOrNothing), - batch::Inner(forgedLoanSet, seq + 1), - batch::Inner(pay(borrower, lender, XRP(1)), seq + 2), - Ter(lendingBatchEnabled ? temBAD_SIGNATURE : temINVALID_INNER_BATCH)); - env.close(); - - // ? Check that the loan was NOT created - { - json::Value params(json::ValueType::Object); - params[jss::account] = borrower.human(); - params[jss::type] = "Loan"; - auto const res = env.rpc("json", "account_objects", to_string(params)); - auto const objects = res[jss::result][jss::account_objects]; - BEAST_EXPECT(objects.size() == 0); - } - } - - void - testWrongMaxDebtBehavior(FeatureBitset features) - { - // From FIND-003 - testcase << "Wrong Max Debt Behavior"; - - using namespace jtx; - using namespace std::chrono_literals; - Env env(*this, features); - - Account const issuer{"issuer"}; - Account const lender{"lender"}; - - BrokerParameters const brokerParams{.debtMax = 0}; - env.fund(XRP(brokerParams.vaultDeposit * 100), issuer, noripple(lender)); - env.close(); - - PrettyAsset const xrpAsset{xrpIssue(), 1'000'000}; - - BrokerInfo const broker{createVaultAndBroker(env, xrpAsset, lender, brokerParams)}; - - if (auto const brokerSle = env.le(keylet::loanBroker(broker.brokerID)); - BEAST_EXPECT(brokerSle)) - { - BEAST_EXPECT(brokerSle->at(sfDebtMaximum) == 0); - } - - using namespace loan; - - auto const loanSetFee = Fee(env.current()->fees().base * 2); - Number const principalRequest{1, 3}; - - auto createJson = env.json(set(lender, broker.brokerID, principalRequest), Fee(loanSetFee)); - - json::Value counterpartyJson{json::ValueType::Object}; - counterpartyJson[sfTxnSignature] = createJson[sfTxnSignature]; - counterpartyJson[sfSigningPubKey] = createJson[sfSigningPubKey]; - if (!BEAST_EXPECT(!createJson.isMember(jss::Signers))) - counterpartyJson[sfSigners] = createJson[sfSigners]; - - createJson = env.json(createJson, Json(sfCounterpartySignature, counterpartyJson)); - env(createJson); - - env.close(); - } - - void - testLoanPayComputePeriodicPaymentValidRateInvariant(FeatureBitset features) - { - // From FIND-012 - testcase << "LoanPay xrpl::detail::computePeriodicPayment : " - "valid rate"; - - using namespace jtx; - using namespace std::chrono_literals; - Env env(*this, features); - - Account const issuer{"issuer"}; - Account const lender{"lender"}; - Account const borrower{"borrower"}; - - BrokerParameters const brokerParams; - env.fund(XRP(brokerParams.vaultDeposit * 100), issuer, lender, borrower); - env.close(); - - PrettyAsset const xrpAsset{xrpIssue(), 1'000'000}; - BrokerInfo const broker{createVaultAndBroker(env, xrpAsset, lender, brokerParams)}; - - using namespace loan; - - auto const loanSetFee = Fee(env.current()->fees().base * 2); - Number const principalRequest{640562, -5}; - - Number const serviceFee{2462611968}; - std::uint32_t const numPayments{4294967295 / 800}; - - auto createJson = env.json( - set(borrower, broker.brokerID, principalRequest), - Fee(loanSetFee), - kLoanServiceFee(serviceFee), - kPaymentTotal(numPayments), - Json(sfCounterpartySignature, json::ValueType::Object)); - - createJson["CloseInterestRate"] = 55374; - createJson["ClosePaymentFee"] = "3825205248"; - createJson["LatePaymentFee"] = "237"; - createJson["LoanOriginationFee"] = "0"; - createJson["OverpaymentFee"] = 35167; - createJson["OverpaymentInterestRate"] = 1360; - createJson["PaymentInterval"] = 727; - - auto const brokerStateBefore = env.le(keylet::loanBroker(broker.brokerID)); - auto const loanSequence = brokerStateBefore->at(sfLoanSequence); - auto const keylet = keylet::loan(broker.brokerID, loanSequence); - - createJson = env.json(createJson, Sig(sfCounterpartySignature, lender)); - // Fails in preclaim because principal requested can't be - // represented as XRP - env(createJson, Ter(tecPRECISION_LOSS)); - env.close(); - - BEAST_EXPECT(!env.le(keylet)); - - Number const actualPrincipal{6}; - - createJson[sfPrincipalRequested] = actualPrincipal; - createJson.removeMember(sfSequence.jsonName); - createJson = env.json(createJson, Sig(sfCounterpartySignature, lender)); - // Fails in doApply because the payment is too small to be - // represented as XRP. - env(createJson, Ter(tecPRECISION_LOSS)); - env.close(); - } - - void - testRPC(FeatureBitset features) - { - // This will expand as more test cases are added. Some functionality - // is tested in other test functions. - testcase("RPC"); - - using namespace jtx; - - Env env(*this, features); - - auto lowerFee = [&]() { - // Run the local fee back down. - while (env.app().getFeeTrack().lowerLocalFee()) - ; - }; - - auto const baseFee = env.current()->fees().base; - - Account const alice{"alice"}; - std::string const borrowerPass = "borrower"; - Account const borrower{borrowerPass, KeyType::Ed25519}; - auto const lenderPass = "lender"; - Account const lender{lenderPass, KeyType::Ed25519}; - - env.fund(XRP(1'000'000), alice, lender, borrower); - env.close(); - env(noop(lender)); - env(noop(lender)); - env(noop(lender)); - env(noop(lender)); - env(noop(lender)); - env.close(); - - { - testcase("RPC AccountSet"); - json::Value txJson{json::ValueType::Object}; - txJson[sfTransactionType] = "AccountSet"; - txJson[sfAccount] = borrower.human(); - - auto const signParams = [&]() { - json::Value signParams{json::ValueType::Object}; - signParams[jss::passphrase] = borrowerPass; - signParams[jss::key_type] = "ed25519"; - signParams[jss::tx_json] = txJson; - return signParams; - }(); - auto const jSign = env.rpc("json", "sign", to_string(signParams)); - BEAST_EXPECT(jSign.isMember(jss::result) && jSign[jss::result].isMember(jss::tx_json)); - auto txSignResult = jSign[jss::result][jss::tx_json]; - auto txSignBlob = jSign[jss::result][jss::tx_blob].asString(); - txSignResult.removeMember(jss::hash); - - auto const jtx = env.jt(txJson, Sig(borrower)); - BEAST_EXPECT(txSignResult == jtx.jv); - - lowerFee(); - auto const jSubmit = env.rpc("submit", txSignBlob); - BEAST_EXPECT( - jSubmit.isMember(jss::result) && - jSubmit[jss::result].isMember(jss::engine_result) && - jSubmit[jss::result][jss::engine_result].asString() == "tesSUCCESS"); - - lowerFee(); - env(jtx.jv, Sig(kNone), Seq(kNone), Fee(kNone), Ter(tefPAST_SEQ)); - } - - { - testcase("RPC LoanSet - illegal signature_target"); - - json::Value txJson{json::ValueType::Object}; - txJson[sfTransactionType] = "AccountSet"; - txJson[sfAccount] = borrower.human(); - - auto const borrowerSignParams = [&]() { - json::Value params{json::ValueType::Object}; - params[jss::passphrase] = borrowerPass; - params[jss::key_type] = "ed25519"; - params[jss::signature_target] = "Destination"; - params[jss::tx_json] = txJson; - return params; - }(); - auto const jSignBorrower = env.rpc("json", "sign", to_string(borrowerSignParams)); - BEAST_EXPECT( - jSignBorrower.isMember(jss::result) && - jSignBorrower[jss::result].isMember(jss::error) && - jSignBorrower[jss::result][jss::error] == "invalidParams" && - jSignBorrower[jss::result].isMember(jss::error_message) && - jSignBorrower[jss::result][jss::error_message] == "Destination"); - } - { - testcase("RPC LoanSet - sign and submit borrower initiated"); - // 1. Borrower creates the transaction - json::Value txJson{json::ValueType::Object}; - txJson[sfTransactionType] = "LoanSet"; - txJson[sfAccount] = borrower.human(); - txJson[sfCounterparty] = lender.human(); - txJson[sfLoanBrokerID] = - "FF924CD18A236C2B49CF8E80A351CEAC6A10171DC9F110025646894FEC" - "F83F" - "5C"; - txJson[sfPrincipalRequested] = "100000000"; - txJson[sfPaymentTotal] = 10000; - txJson[sfPaymentInterval] = 3600; - txJson[sfGracePeriod] = 300; - txJson[sfFlags] = 65536; // tfLoanOverpayment - txJson[sfFee] = to_string(24 * baseFee / 10); - - // 2. Borrower signs the transaction - auto const borrowerSignParams = [&]() { - json::Value params{json::ValueType::Object}; - params[jss::passphrase] = borrowerPass; - params[jss::key_type] = "ed25519"; - params[jss::tx_json] = txJson; - return params; - }(); - auto const jSignBorrower = env.rpc("json", "sign", to_string(borrowerSignParams)); - BEAST_EXPECTS( - jSignBorrower.isMember(jss::result) && - jSignBorrower[jss::result].isMember(jss::tx_json), - to_string(jSignBorrower)); - auto const txBorrowerSignResult = jSignBorrower[jss::result][jss::tx_json]; - auto const txBorrowerSignBlob = jSignBorrower[jss::result][jss::tx_blob].asString(); - - // 2a. Borrower attempts to submit the transaction. It doesn't - // work - { - lowerFee(); - auto const jSubmitBlob = env.rpc("submit", txBorrowerSignBlob); - BEAST_EXPECT(jSubmitBlob.isMember(jss::result)); - auto const jSubmitBlobResult = jSubmitBlob[jss::result]; - BEAST_EXPECT(jSubmitBlobResult.isMember(jss::tx_json)); - // Transaction fails because the CounterpartySignature is - // missing - BEAST_EXPECT( - jSubmitBlobResult.isMember(jss::engine_result) && - jSubmitBlobResult[jss::engine_result].asString() == "temBAD_SIGNER"); - } - - // 3. Borrower sends the signed transaction to the lender - // 4. Lender signs the transaction - auto const lenderSignParams = [&]() { - json::Value params{json::ValueType::Object}; - params[jss::passphrase] = lenderPass; - params[jss::key_type] = "ed25519"; - params[jss::signature_target] = "CounterpartySignature"; - params[jss::tx_json] = txBorrowerSignResult; - return params; - }(); - auto const jSignLender = env.rpc("json", "sign", to_string(lenderSignParams)); - BEAST_EXPECT( - jSignLender.isMember(jss::result) && - jSignLender[jss::result].isMember(jss::tx_json)); - auto const txLenderSignResult = jSignLender[jss::result][jss::tx_json]; - auto const txLenderSignBlob = jSignLender[jss::result][jss::tx_blob].asString(); - - // 5. Lender submits the signed transaction blob - lowerFee(); - auto const jSubmitBlob = env.rpc("submit", txLenderSignBlob); - BEAST_EXPECT(jSubmitBlob.isMember(jss::result)); - auto const jSubmitBlobResult = jSubmitBlob[jss::result]; - BEAST_EXPECT(jSubmitBlobResult.isMember(jss::tx_json)); - auto const jSubmitBlobTx = jSubmitBlobResult[jss::tx_json]; - // To get far enough to return tecNO_ENTRY means that the - // signatures all validated. Of course the transaction won't - // succeed because no Vault or Broker were created. - BEAST_EXPECTS( - jSubmitBlobResult.isMember(jss::engine_result) && - jSubmitBlobResult[jss::engine_result].asString() == "tecNO_ENTRY", - to_string(jSubmitBlobResult)); - - BEAST_EXPECT( - !jSubmitBlob.isMember(jss::error) && !jSubmitBlobResult.isMember(jss::error)); - - // 4-alt. Lender submits the transaction json originally - // received from the Borrower. It gets signed, but is now a - // duplicate, so fails. Borrower could done this instead of - // steps 4 and 5. - lowerFee(); - auto const jSubmitJson = env.rpc("json", "submit", to_string(lenderSignParams)); - BEAST_EXPECT(jSubmitJson.isMember(jss::result)); - auto const jSubmitJsonResult = jSubmitJson[jss::result]; - BEAST_EXPECT(jSubmitJsonResult.isMember(jss::tx_json)); - auto const jSubmitJsonTx = jSubmitJsonResult[jss::tx_json]; - // Since the previous tx claimed a fee, this duplicate is not - // going anywhere - BEAST_EXPECTS( - jSubmitJsonResult.isMember(jss::engine_result) && - jSubmitJsonResult[jss::engine_result].asString() == "tefPAST_SEQ", - to_string(jSubmitJsonResult)); - - BEAST_EXPECT( - !jSubmitJson.isMember(jss::error) && !jSubmitJsonResult.isMember(jss::error)); - - BEAST_EXPECT(jSubmitBlobTx == jSubmitJsonTx); - } - - { - testcase("RPC LoanSet - sign and submit lender initiated"); - // 1. Lender creates the transaction - json::Value txJson{json::ValueType::Object}; - txJson[sfTransactionType] = "LoanSet"; - txJson[sfAccount] = lender.human(); - txJson[sfCounterparty] = borrower.human(); - txJson[sfLoanBrokerID] = - "FF924CD18A236C2B49CF8E80A351CEAC6A10171DC9F110025646894FEC" - "F83F" - "5C"; - txJson[sfPrincipalRequested] = "100000000"; - txJson[sfPaymentTotal] = 10000; - txJson[sfPaymentInterval] = 3600; - txJson[sfGracePeriod] = 300; - txJson[sfFlags] = 65536; // tfLoanOverpayment - txJson[sfFee] = to_string(24 * baseFee / 10); - - // 2. Lender signs the transaction - auto const lenderSignParams = [&]() { - json::Value params{json::ValueType::Object}; - params[jss::passphrase] = lenderPass; - params[jss::key_type] = "ed25519"; - params[jss::tx_json] = txJson; - return params; - }(); - auto const jSignLender = env.rpc("json", "sign", to_string(lenderSignParams)); - BEAST_EXPECT( - jSignLender.isMember(jss::result) && - jSignLender[jss::result].isMember(jss::tx_json)); - auto const txLenderSignResult = jSignLender[jss::result][jss::tx_json]; - auto const txLenderSignBlob = jSignLender[jss::result][jss::tx_blob].asString(); - - // 2a. Lender attempts to submit the transaction. It doesn't - // work - { - lowerFee(); - auto const jSubmitBlob = env.rpc("submit", txLenderSignBlob); - BEAST_EXPECT(jSubmitBlob.isMember(jss::result)); - auto const jSubmitBlobResult = jSubmitBlob[jss::result]; - BEAST_EXPECT(jSubmitBlobResult.isMember(jss::tx_json)); - // Transaction fails because the CounterpartySignature is - // missing - BEAST_EXPECT( - jSubmitBlobResult.isMember(jss::engine_result) && - jSubmitBlobResult[jss::engine_result].asString() == "temBAD_SIGNER"); - } - - // 3. Lender sends the signed transaction to the Borrower - // 4. Borrower signs the transaction - auto const borrowerSignParams = [&]() { - json::Value params{json::ValueType::Object}; - params[jss::passphrase] = borrowerPass; - params[jss::key_type] = "ed25519"; - params[jss::signature_target] = "CounterpartySignature"; - params[jss::tx_json] = txLenderSignResult; - return params; - }(); - auto const jSignBorrower = env.rpc("json", "sign", to_string(borrowerSignParams)); - BEAST_EXPECT( - jSignBorrower.isMember(jss::result) && - jSignBorrower[jss::result].isMember(jss::tx_json)); - auto const txBorrowerSignResult = jSignBorrower[jss::result][jss::tx_json]; - auto const txBorrowerSignBlob = jSignBorrower[jss::result][jss::tx_blob].asString(); - - // 5. Borrower submits the signed transaction blob - lowerFee(); - auto const jSubmitBlob = env.rpc("submit", txBorrowerSignBlob); - BEAST_EXPECT(jSubmitBlob.isMember(jss::result)); - auto const jSubmitBlobResult = jSubmitBlob[jss::result]; - BEAST_EXPECT(jSubmitBlobResult.isMember(jss::tx_json)); - auto const jSubmitBlobTx = jSubmitBlobResult[jss::tx_json]; - // To get far enough to return tecNO_ENTRY means that the - // signatures all validated. Of course the transaction won't - // succeed because no Vault or Broker were created. - BEAST_EXPECTS( - jSubmitBlobResult.isMember(jss::engine_result) && - jSubmitBlobResult[jss::engine_result].asString() == "tecNO_ENTRY", - to_string(jSubmitBlobResult)); - - BEAST_EXPECT( - !jSubmitBlob.isMember(jss::error) && !jSubmitBlobResult.isMember(jss::error)); - - // 4-alt. Borrower submits the transaction json originally - // received from the Lender. It gets signed, but is now a - // duplicate, so fails. Lender could done this instead of steps - // 4 and 5. - lowerFee(); - auto const jSubmitJson = env.rpc("json", "submit", to_string(borrowerSignParams)); - BEAST_EXPECT(jSubmitJson.isMember(jss::result)); - auto const jSubmitJsonResult = jSubmitJson[jss::result]; - BEAST_EXPECT(jSubmitJsonResult.isMember(jss::tx_json)); - auto const jSubmitJsonTx = jSubmitJsonResult[jss::tx_json]; - // Since the previous tx claimed a fee, this duplicate is not - // going anywhere - BEAST_EXPECTS( - jSubmitJsonResult.isMember(jss::engine_result) && - jSubmitJsonResult[jss::engine_result].asString() == "tefPAST_SEQ", - to_string(jSubmitJsonResult)); - - BEAST_EXPECT( - !jSubmitJson.isMember(jss::error) && !jSubmitJsonResult.isMember(jss::error)); - - BEAST_EXPECT(jSubmitBlobTx == jSubmitJsonTx); - } - } - - void - testServiceFeeOnBrokerDeepFreeze() - { - testcase << "Service Fee On Broker Deep Freeze"; - using namespace jtx; - using namespace loan; - Account const issuer("issuer"); - Account const borrower("borrower"); - Account const broker("broker"); - auto const iou = issuer["IOU"]; - - for (bool const deepFreeze : {true, false}) - { - Env env(*this); - - auto getCoverBalance = [&](BrokerInfo const& brokerInfo, auto const& accountField) { - if (auto const le = env.le(keylet::loanBroker(brokerInfo.brokerID)); - BEAST_EXPECT(le)) - { - auto const account = le->at(accountField); - if (auto const sleLine = env.le(keylet::trustLine(account, iou)); - BEAST_EXPECT(sleLine)) - { - STAmount balance = sleLine->at(sfBalance); - if (account > issuer.id()) - balance.negate(); - return balance; - } - } - return STAmount{iou}; - }; - - env.fund(XRP(20'000), issuer, broker, borrower); - env.close(); - - env(trust(broker, iou(20'000'000))); - env(pay(issuer, broker, iou(10'000'000))); - env.close(); - - auto const brokerInfo = createVaultAndBroker(env, iou, broker); - - BEAST_EXPECT(getCoverBalance(brokerInfo, sfAccount) == iou(1'000)); - - auto const keylet = keylet::loan(brokerInfo.brokerID, 1); - - env(set(borrower, brokerInfo.brokerID, 10'000), - Sig(sfCounterpartySignature, broker), - kLoanServiceFee(iou(100).value()), - kPaymentInterval(100), - Fee(XRP(100))); - env.close(); - - env(trust(borrower, iou(20'000'000))); - // The borrower increases their limit and acquires some IOU so - // they can pay interest - env(pay(issuer, borrower, iou(500))); - env.close(); - - if (auto const le = env.le(keylet::loan(keylet.key)); BEAST_EXPECT(le)) - { - if (deepFreeze) - { - env(trust(issuer, broker["IOU"](0), tfSetFreeze | tfSetDeepFreeze)); - env.close(); - } - - env(pay(borrower, keylet.key, iou(10'100)), Fee(XRP(100))); - env.close(); - - if (deepFreeze) - { - // The fee goes to the broker pseudo-account - BEAST_EXPECT(getCoverBalance(brokerInfo, sfAccount) == iou(1'100)); - BEAST_EXPECT(getCoverBalance(brokerInfo, sfOwner) == iou(8'999'000)); - } - else - { - // The fee goes to the broker account - BEAST_EXPECT(getCoverBalance(brokerInfo, sfOwner) == iou(8'999'100)); - BEAST_EXPECT(getCoverBalance(brokerInfo, sfAccount) == iou(1'000)); - } - } - }; - } - - void - testIssuerLoan() - { - testcase << "Issuer Loan"; - - using namespace jtx; - using namespace loan; - Account const issuer("issuer"); - Account const borrower = issuer; - Account const lender("lender"); - Env env(*this); - - env.fund(XRP(1'000), issuer, lender); - - static constexpr std::int64_t kIssuerBalance = 10'000'000; - MPTTester const asset( - {.env = env, .issuer = issuer, .holders = {lender}, .pay = kIssuerBalance}); - - BrokerParameters const brokerParams{ - .debtMax = 200, - }; - auto const broker = createVaultAndBroker(env, asset, lender, brokerParams); - auto const loanSetFee = Fee(env.current()->fees().base * 2); - // Create Loan - env(set(borrower, broker.brokerID, 200), Sig(sfCounterpartySignature, lender), loanSetFee); - env.close(); - // Issuer should not create MPToken - BEAST_EXPECT(!env.le(keylet::mptoken(asset.issuanceID(), issuer))); - // Issuer "borrowed" 200, OutstandingAmount decreased by 200 - BEAST_EXPECT(env.balance(issuer, asset) == asset(-kIssuerBalance + 200)); - // Pay Loan - auto const loanKeylet = keylet::loan(broker.brokerID, 1); - env(pay(borrower, loanKeylet.key, asset(200))); - env.close(); - // Issuer "re-payed" 200, OutstandingAmount increased by 200 - BEAST_EXPECT(env.balance(issuer, asset) == asset(-kIssuerBalance)); - } - - void - testInvalidLoanDelete() - { - testcase("Invalid LoanDelete"); - using namespace jtx; - using namespace loan; - - // preflight: temINVALID, LoanID == zero - { - Account const alice{"alice"}; - Env env(*this); - env.fund(XRP(1'000), alice); - env.close(); - env(del(alice, beast::kZero), Ter(temINVALID)); - } - } - - void - testInvalidLoanManage() - { - testcase("Invalid LoanManage"); - using namespace jtx; - using namespace loan; - - // preflight: temINVALID, LoanID == zero - { - Account const alice{"alice"}; - Env env(*this); - env.fund(XRP(1'000), alice); - env.close(); - env(manage(alice, beast::kZero, tfLoanDefault), Ter(temINVALID)); - } - } - - void - testInvalidLoanPay() - { - testcase("Invalid LoanPay"); - using namespace jtx; - using namespace loan; - Account const lender{"lender"}; - Account const issuer{"issuer"}; - Account const borrower{"borrower"}; - auto const iou = issuer["IOU"]; - - // preclaim - Env env(*this); - env.fund(XRP(1'000), lender, issuer, borrower); - env(trust(lender, iou(10'000'000))); - env(pay(issuer, lender, iou(5'000'000))); - BrokerInfo brokerInfo{createVaultAndBroker(env, issuer["IOU"], lender)}; - - auto const loanSetFee = Fee(env.current()->fees().base * 2); - STAmount const debtMaximumRequest = brokerInfo.asset(1'000).value(); - - env(set(borrower, brokerInfo.brokerID, debtMaximumRequest), - Sig(sfCounterpartySignature, lender), - loanSetFee); - - env.close(); - - std::uint32_t const loanSequence = 1; - auto const loanKeylet = keylet::loan(brokerInfo.brokerID, loanSequence); - - env(fset(issuer, asfGlobalFreeze)); - env.close(); - - // preclaim: tecFROZEN - env(pay(borrower, loanKeylet.key, debtMaximumRequest), Ter(tecFROZEN)); - env.close(); - - env(fclear(issuer, asfGlobalFreeze)); - env.close(); - - auto const pseudoBroker = [&]() -> std::optional { - if (auto brokerSle = env.le(keylet::loanBroker(brokerInfo.brokerID)); - BEAST_EXPECT(brokerSle)) - { - return Account{"pseudo", brokerSle->at(sfAccount)}; - } - - return std::nullopt; - }(); - if (!pseudoBroker) - return; - - // Lender and pseudoaccount must both be frozen - env(trust(issuer, lender["IOU"](1'000), lender, tfSetFreeze | tfSetDeepFreeze)); - env(trust( - issuer, (*pseudoBroker)["IOU"](1'000), *pseudoBroker, tfSetFreeze | tfSetDeepFreeze)); - env.close(); - - // preclaim: tecFROZEN due to deep frozen - env(pay(borrower, loanKeylet.key, debtMaximumRequest), Ter(tecFROZEN)); - env.close(); - - // Only one needs to be unfrozen - env(trust(issuer, lender["IOU"](1'000), tfClearFreeze | tfClearDeepFreeze)); - env.close(); - - // The payment is late by this point - env(pay(borrower, loanKeylet.key, debtMaximumRequest), Ter(tecEXPIRED)); - env.close(); - env(pay(borrower, loanKeylet.key, debtMaximumRequest, tfLoanLatePayment)); - env.close(); - - // preclaim: tecKILLED - // note that tecKILLED in loanMakePayment() - // doesn't happen because of the preclaim check. - env(pay(borrower, loanKeylet.key, debtMaximumRequest), Ter(tecKILLED)); - } - - void - testInvalidLoanSet() - { - testcase("Invalid LoanSet"); - using namespace jtx; - using namespace loan; - Account const lender{"lender"}; - Account const issuer{"issuer"}; - Account const borrower{"borrower"}; - Account const sponsor{"sponsor"}; - auto const iou = issuer["IOU"]; - - auto testWrapper = [&](auto&& test) { - Env env(*this); - env.fund(XRP(1'000), lender, issuer, borrower, sponsor); - env(trust(lender, iou(10'000'000))); - env(pay(issuer, lender, iou(5'000'000))); - BrokerInfo const brokerInfo{createVaultAndBroker(env, issuer["IOU"], lender)}; - - auto const loanSetFee = Fee(env.current()->fees().base * 2); - Number const debtMaximumRequest = brokerInfo.asset(1'000).value(); - test(env, brokerInfo, loanSetFee, debtMaximumRequest); - }; - - // preflight: - testWrapper([&](Env& env, - BrokerInfo const& brokerInfo, - jtx::Fee const& loanSetFee, - Number const& debtMaximumRequest) { - for (auto const sponsorFlags : {spfSponsorReserve, spfSponsorReserve | spfSponsorFee}) - { - env(set(borrower, brokerInfo.brokerID, debtMaximumRequest), - sponsor::As(sponsor, sponsorFlags), - Sig(sfCounterpartySignature, lender), - loanSetFee, - Ter(temINVALID_FLAG)); - } - - // first temBAD_SIGNER: TODO - // invalid grace period - { - // zero grace period - env(set(borrower, brokerInfo.brokerID, debtMaximumRequest), - Sig(sfCounterpartySignature, lender), - kGracePeriod(0), - loanSetFee, - Ter(temINVALID)); - - // grace period less than default minimum - env(set(borrower, brokerInfo.brokerID, debtMaximumRequest), - Sig(sfCounterpartySignature, lender), - kGracePeriod(LoanSet::kDefaultGracePeriod - 1), - loanSetFee, - Ter(temINVALID)); - - // grace period greater than payment interval - env(set(borrower, brokerInfo.brokerID, debtMaximumRequest), - Sig(sfCounterpartySignature, lender), - kPaymentInterval(120), - kGracePeriod(121), - loanSetFee, - Ter(temINVALID)); - } - // empty/zero broker ID - { - auto jv = set(borrower, uint256{}, debtMaximumRequest); - - auto testZeroBrokerID = [&](std::string const& id, std::uint32_t flags = 0) { - // empty broker ID - jv[sfLoanBrokerID] = id; - env(jv, - Sig(sfCounterpartySignature, lender), - loanSetFee, - Txflags(flags), - Ter(temINVALID)); - }; - // empty broker ID - testZeroBrokerID(std::string("")); - // zero broker ID - // needs a flag to distinguish the parsed STTx from the prior - // test - testZeroBrokerID(to_string(uint256{}), tfFullyCanonicalSig); - } - - // preflightCheckSigningKey() failure: - // can it happen? the signature is checked before transactor - // executes - - JTx const tx = env.jt( - set(borrower, brokerInfo.brokerID, debtMaximumRequest), - Sig(sfCounterpartySignature, lender), - loanSetFee); - STTx local = *(tx.stx); - auto counterpartySig = local.getFieldObject(sfCounterpartySignature); - auto badPubKey = counterpartySig.getFieldVL(sfSigningPubKey); - badPubKey[20] ^= 0xAA; - counterpartySig.setFieldVL(sfSigningPubKey, badPubKey); - local.setFieldObject(sfCounterpartySignature, counterpartySig); - json::Value jvResult; - jvResult[jss::tx_blob] = strHex(local.getSerializer().slice()); - auto res = env.rpc("json", "submit", to_string(jvResult))["result"]; - BEAST_EXPECT( - res[jss::error] == "invalidTransaction" && - res[jss::error_exception] == - "fails local checks: Counterparty: Invalid signature."); - }); - - // preclaim: - testWrapper([&](Env& env, - BrokerInfo const& brokerInfo, - jtx::Fee const& loanSetFee, - Number const& debtMaximumRequest) { - // canAddHoldingFailure (IOU only, if MPT doesn't have - // MPTCanTransfer set, then can't create Vault/LoanBroker, - // and LoanSet will fail with different error - env(fclear(issuer, asfDefaultRipple)); - env.close(); - env(set(borrower, brokerInfo.brokerID, debtMaximumRequest), - Sig(sfCounterpartySignature, lender), - loanSetFee, - Ter(terNO_RIPPLE)); - }); - - // doApply: - testWrapper([&](Env& env, - BrokerInfo const& brokerInfo, - jtx::Fee const& loanSetFee, - Number const& debtMaximumRequest) { - auto const amt = - env.balance(borrower) - accountReserve(*env.current(), borrower.id(), env.journal); - env(pay(borrower, issuer, amt)); - - // tecINSUFFICIENT_RESERVE - env(set(borrower, brokerInfo.brokerID, debtMaximumRequest), - Sig(sfCounterpartySignature, lender), - loanSetFee, - Ter(tecINSUFFICIENT_RESERVE)); - - // addEmptyHolding failure - env(pay(issuer, borrower, amt)); - env(fset(issuer, asfGlobalFreeze)); - env.close(); - - env(set(borrower, brokerInfo.brokerID, debtMaximumRequest), - Sig(sfCounterpartySignature, lender), - loanSetFee, - Ter(tecFROZEN)); - }); - } - - void - testAccountSendMptMinAmountInvariant(FeatureBitset features) - { - // (From FIND-006) - testcase << "LoanSet trigger xrpl::accountSendMPT : minimum amount " - "and MPT"; - - using namespace jtx; - using namespace std::chrono_literals; - Env env(*this, features); - - Account const issuer{"issuer"}; - Account const lender{"lender"}; - Account const borrower{"borrower"}; - - env.fund(XRP(1'000'000), issuer, lender, borrower); - env.close(); - - MPTTester mptt{env, issuer, kMptInitNoFund}; - mptt.create({.flags = tfMPTCanClawback | tfMPTCanTransfer | tfMPTCanLock}); - PrettyAsset const mptAsset = mptt.issuanceID(); - mptt.authorize({.account = lender}); - mptt.authorize({.account = borrower}); - env(pay(issuer, lender, mptAsset(2'000'000))); - env(pay(issuer, borrower, mptAsset(1'000))); - env.close(); - - BrokerInfo const broker{createVaultAndBroker(env, mptAsset, lender)}; - - using namespace loan; - - auto const loanSetFee = Fee(env.current()->fees().base * 2); - Number const principalRequest{1, 3}; - - auto createJson = env.json( - set(borrower, broker.brokerID, principalRequest), - Fee(loanSetFee), - Json(sfCounterpartySignature, json::ValueType::Object)); - - createJson["CloseInterestRate"] = 76671; - createJson["ClosePaymentFee"] = "2061925410"; - createJson["GracePeriod"] = 434; - createJson["InterestRate"] = 50302; - createJson["LateInterestRate"] = 30322; - createJson["LatePaymentFee"] = "294427911"; - createJson["LoanOriginationFee"] = "3250635102"; - createJson["LoanServiceFee"] = "9557386"; - createJson["OverpaymentFee"] = 51249; - createJson["OverpaymentInterestRate"] = 14304; - createJson["PaymentInterval"] = 434; - createJson["PaymentTotal"] = "2891743748"; - createJson["PrincipalRequested"] = "8516.98"; - - auto const brokerStateBefore = env.le(keylet::loanBroker(broker.brokerID)); - - createJson = env.json(createJson, Sig(sfCounterpartySignature, lender)); - env(createJson, Ter(temINVALID)); - env.close(); - } - - void - testLoanPayDebtDecreaseInvariant(FeatureBitset features) - { - // From FIND-007 - testcase << "LoanPay xrpl::LoanPay::doApply : debtDecrease " - "rounding good"; - - using namespace jtx; - using namespace std::chrono_literals; - using namespace Lending; - Env env(*this, features); - - Account const issuer{"issuer"}; - Account const lender{"lender"}; - Account const borrower{"borrower"}; - - env.fund(XRP(1'000'000), issuer, lender, borrower); - env.close(); - - PrettyAsset const iouAsset = issuer[iouCurrency_]; - auto trustLenderTx = env.json(trust(lender, iouAsset(1'000'000'000))); - env(trustLenderTx); - auto trustBorrowerTx = env.json(trust(borrower, iouAsset(1'000'000'000))); - env(trustBorrowerTx); - auto payLenderTx = pay(issuer, lender, iouAsset(100'000'000)); - env(payLenderTx); - auto payIssuerTx = pay(issuer, borrower, iouAsset(1'000'000)); - env(payIssuerTx); - env.close(); - - BrokerInfo broker{createVaultAndBroker(env, iouAsset, lender)}; - - using namespace loan; - - auto const baseFee = env.current()->fees().base; - auto const loanSetFee = Fee(baseFee * 2); - Number const principalRequest{1, 3}; - - auto createJson = env.json( - set(borrower, broker.brokerID, principalRequest), - Fee(loanSetFee), - Json(sfCounterpartySignature, json::ValueType::Object)); - - createJson["ClosePaymentFee"] = "0"; - createJson["GracePeriod"] = 60; - createJson["InterestRate"] = 24346; - createJson["LateInterestRate"] = 65535; - createJson["LatePaymentFee"] = "0"; - createJson["LoanOriginationFee"] = "218"; - createJson["LoanServiceFee"] = "0"; - createJson["PaymentInterval"] = 60; - createJson["PaymentTotal"] = 5678; - createJson["PrincipalRequested"] = "9924.81"; - - auto const brokerStateBefore = env.le(keylet::loanBroker(broker.brokerID)); - auto const loanSequence = brokerStateBefore->at(sfLoanSequence); - auto const keylet = keylet::loan(broker.brokerID, loanSequence); - - createJson = env.json(createJson, Sig(sfCounterpartySignature, lender)); - env(createJson, Ter(tesSUCCESS)); - env.close(); - - auto const pseudoAcct = [&]() { - auto const brokerSle = env.le(keylet::loanBroker(broker.brokerID)); - if (!BEAST_EXPECT(brokerSle)) - return Account{lender}; - auto const brokerPseudo = brokerSle->at(sfAccount); - return Account("Broker pseudo-account", brokerPseudo); - }(); - - VerifyLoanStatus const verifyLoanStatus(env, broker, pseudoAcct, keylet); - auto const originalState = getCurrentState(env, broker, keylet); - verifyLoanStatus(originalState); - - Number const payment{3'269'349'176'470'588, -12}; - XRPAmount const payFee{ - baseFee * - ((payment / originalState.periodicPayment) / kLoanPaymentsPerFeeIncrement + 1)}; - auto loanPayTx = - env.json(pay(borrower, keylet.key, STAmount{broker.asset, payment}), Fee(payFee)); - BEAST_EXPECT(to_string(payment) == "3269.349176470588"); - env(loanPayTx, Ter(tesSUCCESS)); - env.close(); - - auto const newState = getCurrentState(env, broker, keylet); - BEAST_EXPECT( - isRounded(broker.asset, newState.managementFeeOutstanding, originalState.loanScale)); - BEAST_EXPECT(newState.managementFeeOutstanding < originalState.managementFeeOutstanding); - BEAST_EXPECT(isRounded(broker.asset, newState.totalValue, originalState.loanScale)); - BEAST_EXPECT( - isRounded(broker.asset, newState.principalOutstanding, originalState.loanScale)); - } - - void - testLoanPayComputePeriodicPaymentValidTotalInterestInvariant(FeatureBitset features) - { - // From FIND-010 - testcase << "xrpl::loanComputePaymentParts : valid total interest"; - - using namespace jtx; - using namespace std::chrono_literals; - Env env(*this, features); - - Account const issuer{"issuer"}; - Account const lender{"lender"}; - Account const borrower{"borrower"}; - - env.fund(XRP(1'000'000), issuer, lender, borrower); - env.close(); - - PrettyAsset const iouAsset = issuer[iouCurrency_]; - auto trustLenderTx = env.json(trust(lender, iouAsset(1'000'000'000))); - env(trustLenderTx); - auto trustBorrowerTx = env.json(trust(borrower, iouAsset(1'000'000'000))); - env(trustBorrowerTx); - auto payLenderTx = pay(issuer, lender, iouAsset(100'000'000)); - env(payLenderTx); - auto payIssuerTx = pay(issuer, borrower, iouAsset(1'000'000)); - env(payIssuerTx); - env.close(); - - BrokerInfo const broker{createVaultAndBroker(env, iouAsset, lender)}; - - using namespace loan; - - auto const loanSetFee = Fee(env.current()->fees().base * 2); - Number const principalRequest{1, 3}; - - auto createJson = env.json( - set(borrower, broker.brokerID, principalRequest), - Fee(loanSetFee), - Json(sfCounterpartySignature, json::ValueType::Object)); - - createJson["CloseInterestRate"] = 47299; - createJson["ClosePaymentFee"] = "3985819770"; - createJson["InterestRate"] = 92; - createJson["LatePaymentFee"] = "3866894865"; - createJson["LoanOriginationFee"] = "0"; - createJson["LoanServiceFee"] = "2348810240"; - createJson["OverpaymentFee"] = 58545; - createJson["PaymentInterval"] = 60; - createJson["PaymentTotal"] = 1; - createJson["PrincipalRequested"] = "0.000763058"; - - auto const brokerStateBefore = env.le(keylet::loanBroker(broker.brokerID)); - auto const loanSequence = brokerStateBefore->at(sfLoanSequence); - auto const keylet = keylet::loan(broker.brokerID, loanSequence); - - createJson = env.json(createJson, Sig(sfCounterpartySignature, lender)); - env(createJson); - env.close(); - - auto loanPayTx = env.json(pay(borrower, keylet.key, STAmount{broker.asset, Number{}})); - loanPayTx["Amount"]["value"] = "0.000281284125490196"; - env(loanPayTx, Ter(tecINSUFFICIENT_PAYMENT)); - env.close(); - } - - void - testDosLoanPay(FeatureBitset features) - { - bool const feeCapped = features[fixCleanup3_1_3]; - - // From FIND-005 - testcase << "DoS LoanPay: fee calculation " << (feeCapped ? "capped" : "uncapped"); - - using namespace jtx; - using namespace std::chrono_literals; - using namespace Lending; - Env env(*this, features); - - Account const issuer{"issuer"}; - Account const lender{"lender"}; - Account const borrower{"borrower"}; - - env.fund(XRP(1'000'000), issuer, lender, borrower); - env.close(); - - BEAST_EXPECT(feeCapped == env.current()->rules().enabled(fixCleanup3_1_3)); - - PrettyAsset const iouAsset = issuer[iouCurrency_]; - env(trust(lender, iouAsset(100'000'000))); - env(trust(borrower, iouAsset(100'000'000))); - env(pay(issuer, lender, iouAsset(10'000'000))); - env(pay(issuer, borrower, iouAsset(1'000))); - env.close(); - - BrokerInfo const broker{createVaultAndBroker(env, iouAsset, lender)}; - - using namespace loan; - - auto const loanSetFee = Fee(env.current()->fees().base * 2); - Number const principalRequest{3959'37, -2}; - auto const baseFee = env.current()->fees().base; - - auto const createJson = env.json( - set(borrower, broker.brokerID, principalRequest), - Fee(loanSetFee), - Json(sfCounterpartySignature, json::ValueType::Object), - kClosePaymentFee(0), - kGracePeriod(60), - kInterestRate(TenthBips32(20930)), - kLateInterestRate(TenthBips32(77049)), - kLatePaymentFee(0), - kLoanServiceFee(0), - kOverpaymentFee(TenthBips32(7)), - kOverpaymentInterestRate(TenthBips32(66653)), - kPaymentInterval(60), - kPaymentTotal(3239184)); - - // There are enough payments due on this loan that it only needs to be - // created once, and can be paid on multiple times. Just don't create a - // gazillion test cases. - auto const brokerStateBefore = env.le(keylet::loanBroker(broker.brokerID)); - auto const loanSequence = brokerStateBefore->at(sfLoanSequence); - auto const keylet = keylet::loan(broker.brokerID, loanSequence); - - env(createJson, Sig(sfCounterpartySignature, lender)); - env.close(); - - auto const roundedPayment = [&]() { - auto const stateBefore = getCurrentState(env, broker, keylet); - BEAST_EXPECT(stateBefore.paymentRemaining == 3239184); - BEAST_EXPECT(stateBefore.paymentRemaining > kLoanMaximumPaymentsPerTransaction); - - return roundToAsset( - iouAsset, - stateBefore.periodicPayment, - stateBefore.loanScale, - Number::RoundingMode::Upward); - }(); - - auto test = [&](int const payFactor, - int const feeFactor, - TER const expectedTer = tesSUCCESS) { - auto const stateBefore = getCurrentState(env, broker, keylet); - BEAST_EXPECT(stateBefore.paymentRemaining <= 3239184); - BEAST_EXPECT(stateBefore.paymentRemaining > kLoanMaximumPaymentsPerTransaction); - - Number const amount = roundedPayment * payFactor; - auto loanPayTx = env.json(pay(borrower, keylet.key, STAmount{broker.asset, amount})); - XRPAmount const payFee{baseFee * feeFactor}; - env(loanPayTx, Ter(expectedTer), Fee(payFee)); - env.close(); - auto const expectedChange = isTesSuccess(expectedTer) - ? std::min(kLoanMaximumPaymentsPerTransaction, payFactor) - : 0; - - auto const stateAfter = getCurrentState(env, broker, keylet); - BEAST_EXPECT( - stateAfter.paymentRemaining == stateBefore.paymentRemaining - expectedChange); - }; - - static constexpr std::int64_t kMaxFeeIncrements = - kLoanMaximumPaymentsPerTransaction / kLoanPaymentsPerFeeIncrement; - - TER const failWithoutFix = feeCapped ? (TER)tesSUCCESS : (TER)telINSUF_FEE_P; - - // * Amount well above threshold -> capped fee - // The original test case - way over the limit - more fee is always ok - test(1819878, 363976); - // The capped fee is only sufficient if the amendment is enabled. - test(1819878, kMaxFeeIncrements, failWithoutFix); - - // * Amount exactly at threshold -> capped fee - test(kLoanMaximumPaymentsPerTransaction, kMaxFeeIncrements); - // More fee is always ok - test(kLoanMaximumPaymentsPerTransaction, kMaxFeeIncrements + 10); - - // * Amount below threshold -> normal calculation - test(1, 1); - test(kLoanPaymentsPerFeeIncrement * 2, 2); - test(0, 0, temBAD_AMOUNT); - test(0, 1, temBAD_AMOUNT); - // Fee difference rounds evenly - test( - kLoanMaximumPaymentsPerTransaction - 10, - ((kLoanMaximumPaymentsPerTransaction - 10) / kLoanPaymentsPerFeeIncrement) - 1, - telINSUF_FEE_P); - test( - kLoanMaximumPaymentsPerTransaction - 10, - ((kLoanMaximumPaymentsPerTransaction - 10) / kLoanPaymentsPerFeeIncrement)); - // More fee is always ok - test( - kLoanMaximumPaymentsPerTransaction - 10, - ((kLoanMaximumPaymentsPerTransaction - 10) / kLoanPaymentsPerFeeIncrement) + 3); - // Fee rounds up - for (int under = 1; under < kLoanPaymentsPerFeeIncrement; ++under) - { - test(kLoanMaximumPaymentsPerTransaction - under, kMaxFeeIncrements - 1, telINSUF_FEE_P); - test(kLoanMaximumPaymentsPerTransaction - under, kMaxFeeIncrements); - } - // Only when you get one less fee increment can you pay less - test( - kLoanMaximumPaymentsPerTransaction - kLoanPaymentsPerFeeIncrement, - kMaxFeeIncrements - 1); - // And again, more fee is always ok. - test(kLoanMaximumPaymentsPerTransaction - kLoanPaymentsPerFeeIncrement, kMaxFeeIncrements); - } - - void - testLoanPayComputePeriodicPaymentValidTotalPrincipalPaidInvariant(FeatureBitset features) - { - // From FIND-009 - testcase << "xrpl::loanComputePaymentParts : totalPrincipalPaid " - "rounded"; - - using namespace jtx; - using namespace std::chrono_literals; - using namespace Lending; - Env env(*this, features); - - Account const issuer{"issuer"}; - Account const lender{"lender"}; - Account const borrower{"borrower"}; - - env.fund(XRP(1'000'000), issuer, lender, borrower); - env.close(); - - PrettyAsset const iouAsset = issuer[iouCurrency_]; - auto trustLenderTx = env.json(trust(lender, iouAsset(1'000'000'000))); - env(trustLenderTx); - auto trustBorrowerTx = env.json(trust(borrower, iouAsset(1'000'000'000))); - env(trustBorrowerTx); - auto payLenderTx = pay(issuer, lender, iouAsset(100'000'000)); - env(payLenderTx); - auto payIssuerTx = pay(issuer, borrower, iouAsset(1'000'000)); - env(payIssuerTx); - env.close(); - - BrokerInfo const broker{createVaultAndBroker(env, iouAsset, lender)}; - - using namespace loan; - - auto const loanSetFee = Fee(env.current()->fees().base * 2); - Number const principalRequest{1, 3}; - - auto createJson = env.json( - set(borrower, broker.brokerID, principalRequest), - Fee(loanSetFee), - Json(sfCounterpartySignature, json::ValueType::Object)); - - createJson["ClosePaymentFee"] = "0"; - createJson["InterestRate"] = 24346; - createJson["LateInterestRate"] = 65535; - createJson["LatePaymentFee"] = "0"; - createJson["LoanOriginationFee"] = "218"; - createJson["LoanServiceFee"] = "0"; - createJson["PaymentInterval"] = 60; - createJson["PaymentTotal"] = 5678; - createJson["PrincipalRequested"] = "9924.81"; - - auto const brokerStateBefore = env.le(keylet::loanBroker(broker.brokerID)); - auto const loanSequence = brokerStateBefore->at(sfLoanSequence); - auto const keylet = keylet::loan(broker.brokerID, loanSequence); - - createJson = env.json(createJson, Sig(sfCounterpartySignature, lender)); - env(createJson, Ter(tesSUCCESS)); - env.close(); - - auto const baseFee = env.current()->fees().base; - - auto const stateBefore = getCurrentState(env, broker, keylet); - - { - auto loanPayTx = env.json(pay(borrower, keylet.key, STAmount{broker.asset, Number{}})); - Number const amount{3074'745'058'823'529, -12}; - BEAST_EXPECT(to_string(amount) == "3074.745058823529"); - XRPAmount const payFee{ - baseFee * - (amount / stateBefore.periodicPayment / kLoanPaymentsPerFeeIncrement + 1)}; - loanPayTx["Amount"]["value"] = to_string(amount); - env(loanPayTx, Fee(payFee), Ter(tesSUCCESS)); - env.close(); - } - - { - auto loanPayTx = env.json(pay(borrower, keylet.key, STAmount{broker.asset, Number{}})); - Number const amount{6732'118'170'944'051, -12}; - BEAST_EXPECT(to_string(amount) == "6732.118170944051"); - XRPAmount const payFee{ - baseFee * - (amount / stateBefore.periodicPayment / kLoanPaymentsPerFeeIncrement + 1)}; - loanPayTx["Amount"]["value"] = to_string(amount); - env(loanPayTx, Fee(payFee), Ter(tesSUCCESS)); - env.close(); - } - - auto const stateAfter = getCurrentState(env, broker, keylet); - // Total interest outstanding is non-negative - BEAST_EXPECT(stateAfter.totalValue >= stateAfter.principalOutstanding); - // Principal paid is non-negative - BEAST_EXPECT(stateBefore.principalOutstanding >= stateAfter.principalOutstanding); - // Total value change is non-negative - BEAST_EXPECT(stateBefore.totalValue >= stateAfter.totalValue); - // Value delta is larger or same as principal delta (meaning - // non-negative interest paid) - BEAST_EXPECT( - (stateBefore.totalValue - stateAfter.totalValue) >= - (stateBefore.principalOutstanding - stateAfter.principalOutstanding)); - } - - void - testLoanPayComputePeriodicPaymentValidTotalInterestPaidInvariant(FeatureBitset features) - { - // From FIND-008 - testcase << "xrpl::loanComputePaymentParts : loanValueChange rounded"; - - using namespace jtx; - using namespace std::chrono_literals; - using namespace Lending; - Env env(*this, features); - - Account const issuer{"issuer"}; - Account const lender{"lender"}; - Account const borrower{"borrower"}; - - env.fund(XRP(1'000'000), issuer, lender, borrower); - env.close(); - - PrettyAsset const iouAsset = issuer[iouCurrency_]; - auto trustLenderTx = env.json(trust(lender, iouAsset(1'000'000'000))); - env(trustLenderTx); - auto trustBorrowerTx = env.json(trust(borrower, iouAsset(1'000'000'000))); - env(trustBorrowerTx); - auto payLenderTx = pay(issuer, lender, iouAsset(100'000'000)); - env(payLenderTx); - auto payIssuerTx = pay(issuer, borrower, iouAsset(10'000'000)); - env(payIssuerTx); - env.close(); - - BrokerInfo const broker{createVaultAndBroker(env, iouAsset, lender)}; - { - auto const coverDepositValue = broker.asset(broker.params.coverDeposit * 10).value(); - env(loanBroker::coverDeposit(lender, broker.brokerID, coverDepositValue)); - env.close(); - } - - using namespace loan; - - auto const loanSetFee = Fee(env.current()->fees().base * 2); - Number const principalRequest{1, 3}; - - auto createJson = env.json( - set(borrower, broker.brokerID, principalRequest), - Fee(loanSetFee), - Json(sfCounterpartySignature, json::ValueType::Object)); - - createJson["ClosePaymentFee"] = "0"; - createJson["InterestRate"] = 12833; - createJson["LateInterestRate"] = 77048; - createJson["LatePaymentFee"] = "0"; - createJson["LoanOriginationFee"] = "218"; - createJson["LoanServiceFee"] = "0"; - createJson["PaymentInterval"] = 752; - createJson["PaymentTotal"] = 5678; - createJson["PrincipalRequested"] = "9924.81"; - - auto const brokerStateBefore = env.le(keylet::loanBroker(broker.brokerID)); - auto const loanSequence = brokerStateBefore->at(sfLoanSequence); - auto const keylet = keylet::loan(broker.brokerID, loanSequence); - - createJson = env.json(createJson, Sig(sfCounterpartySignature, lender)); - env(createJson, Ter(tesSUCCESS)); - env.close(); - - auto const baseFee = env.current()->fees().base; - - auto const stateBefore = getCurrentState(env, broker, keylet); - BEAST_EXPECT(stateBefore.paymentRemaining == 5678); - BEAST_EXPECT(stateBefore.paymentRemaining > kLoanMaximumPaymentsPerTransaction); - - auto loanPayTx = env.json(pay(borrower, keylet.key, STAmount{broker.asset, Number{}})); - Number const amount{9924'81, -2}; - BEAST_EXPECT(to_string(amount) == "9924.81"); - XRPAmount const payFee{ - baseFee * (amount / stateBefore.periodicPayment / kLoanPaymentsPerFeeIncrement + 1)}; - loanPayTx["Amount"]["value"] = to_string(amount); - env(loanPayTx, Fee(payFee), Ter(tesSUCCESS)); - env.close(); - - auto const stateAfter = getCurrentState(env, broker, keylet); - BEAST_EXPECT( - stateAfter.paymentRemaining == - stateBefore.paymentRemaining - kLoanMaximumPaymentsPerTransaction); - } - - void - testLoanNextPaymentDueDateOverflow(FeatureBitset features) - { - // For FIND-013 - testcase << "Prevent nextPaymentDueDate overflow"; - - using namespace jtx; - using namespace std::chrono_literals; - using namespace Lending; - Env env{*this, features}; - - Account const issuer{"issuer"}; - Account const lender{"lender"}; - Account const borrower{"borrower"}; - - env.fund(XRP(1'000'000), issuer, lender, borrower); - env.close(); - - PrettyAsset const iouAsset = issuer[iouCurrency_]; - auto trustLenderTx = env.json(trust(lender, iouAsset(1'000'000'000))); - env(trustLenderTx); - auto trustBorrowerTx = env.json(trust(borrower, iouAsset(1'000'000'000))); - env(trustBorrowerTx); - auto payLenderTx = pay(issuer, lender, iouAsset(100'000'000)); - env(payLenderTx); - auto payIssuerTx = pay(issuer, borrower, iouAsset(10'000'000)); - env(payIssuerTx); - env.close(); - - BrokerParameters const brokerParams{.debtMax = Number{0}, .coverRateMin = TenthBips32{1}}; - BrokerInfo broker{createVaultAndBroker(env, iouAsset, lender, brokerParams)}; - - using namespace loan; - - auto const loanSetFee = Fee(env.current()->fees().base * 2); - - using timeType = decltype(sfNextPaymentDueDate)::type::value_type; - static_assert(std::is_same_v); - constexpr timeType kMaxTime = std::numeric_limits::max(); - static_assert(kMaxTime == 4'294'967'295); - - auto const baseJson = [&]() { - auto createJson = env.json( - set(borrower, broker.brokerID, Number{55524'81, -2}), - Fee(loanSetFee), - kClosePaymentFee(0), - kGracePeriod(LoanSet::kDefaultGracePeriod), - kInterestRate(TenthBips32(12833)), - kLateInterestRate(TenthBips32(77048)), - kLatePaymentFee(0), - kLoanOriginationFee(218), - Json(sfCounterpartySignature, json::ValueType::Object)); - - createJson.removeMember(sfSequence.getJsonName()); - - return createJson; - }(); - - auto const baseFee = env.current()->fees().base; - - auto parentCloseTime = [&]() { - return env.current()->parentCloseTime().time_since_epoch().count(); - }; - auto maxLoanTime = [&]() { - auto const startDate = parentCloseTime(); - - BEAST_EXPECT(startDate >= 50); - - return kMaxTime - startDate; - }; - - { - // straight-up overflow: interval - auto const interval = maxLoanTime() + 1; - auto const total = 1; - auto createJson = env.json(baseJson, kPaymentInterval(interval), kPaymentTotal(total)); - - env(createJson, Sig(sfCounterpartySignature, lender), Ter(tecKILLED)); - env.close(); - } - { - // straight-up overflow: total - // min interval is 60 - auto const interval = 60; - auto const total = maxLoanTime() + 1; - auto createJson = env.json(baseJson, kPaymentInterval(interval), kPaymentTotal(total)); - - env(createJson, Sig(sfCounterpartySignature, lender), Ter(tecKILLED)); - env.close(); - } - { - // straight-up overflow: grace period - // min interval is 60 - auto const interval = maxLoanTime() + 1; - auto const total = 1; - auto const grace = interval; - auto createJson = env.json( - baseJson, kPaymentInterval(interval), kPaymentTotal(total), kGracePeriod(grace)); - - // The grace period can't be larger than the interval. - env(createJson, Sig(sfCounterpartySignature, lender), Ter(tecKILLED)); - env.close(); - } - { - // Overflow with multiplication of a few large intervals - auto const interval = 1'000'000'000; - auto const total = 10; - auto createJson = env.json(baseJson, kPaymentInterval(interval), kPaymentTotal(total)); - - env(createJson, Sig(sfCounterpartySignature, lender), Ter(tecKILLED)); - env.close(); - } - { - // Overflow with multiplication of many small payments - // min interval is 60 - auto const interval = 60; - auto const total = 1'000'000'000; - auto createJson = env.json(baseJson, kPaymentInterval(interval), kPaymentTotal(total)); - - env(createJson, Sig(sfCounterpartySignature, lender), Ter(tecKILLED)); - env.close(); - } - { - // Overflow with an absurdly large grace period - // min interval is 60 - auto const total = 60; - auto const interval = (maxLoanTime() - total) / total; - auto const grace = interval; - auto createJson = env.json( - baseJson, kPaymentInterval(interval), kPaymentTotal(total), kGracePeriod(grace)); - - env(createJson, Sig(sfCounterpartySignature, lender), Ter(tecKILLED)); - env.close(); - } - { - // Start date when the ledger is closed will be larger - auto const brokerStateBefore = env.le(keylet::loanBroker(broker.brokerID)); - auto const loanSequence = brokerStateBefore->at(sfLoanSequence); - auto const keylet = keylet::loan(broker.brokerID, loanSequence); - - auto const grace = 100; - auto const interval = maxLoanTime() - grace; - auto const total = 1; - auto createJson = env.json( - baseJson, kPaymentInterval(interval), kPaymentTotal(total), kGracePeriod(grace)); - - env(createJson, Sig(sfCounterpartySignature, lender), Ter(tesSUCCESS)); - env.close(); - - // The transaction is killed in the closed ledger - auto const meta = env.meta(); - if (BEAST_EXPECT(meta)) - { - BEAST_EXPECT(meta->at(sfTransactionResult) == tecKILLED); - } - - // If the transaction had succeeded, the loan would exist - auto const loanSle = env.le(keylet); - // but it doesn't - BEAST_EXPECT(!loanSle); - } - { - // Start date when the ledger is closed will be larger - auto const brokerStateBefore = env.le(keylet::loanBroker(broker.brokerID)); - auto const loanSequence = brokerStateBefore->at(sfLoanSequence); - auto const keylet = keylet::loan(broker.brokerID, loanSequence); - - auto const closeStartDate = ((parentCloseTime() / 10) + 1) * 10; - auto const grace = 5'000; - auto const interval = kMaxTime - closeStartDate - grace; - auto const total = 1; - auto createJson = env.json( - baseJson, kPaymentInterval(interval), kPaymentTotal(total), kGracePeriod(grace)); - - env(createJson, Sig(sfCounterpartySignature, lender), Ter(tesSUCCESS)); - env.close(); - - // The transaction succeeds in the closed ledger - auto const meta = env.meta(); - if (BEAST_EXPECT(meta)) - { - BEAST_EXPECT(meta->at(sfTransactionResult) == tesSUCCESS); - } - - // This loan exists - auto const afterState = getCurrentState(env, broker, keylet); - BEAST_EXPECT(afterState.nextPaymentDate == kMaxTime - grace); - BEAST_EXPECT(afterState.previousPaymentDate == 0); - BEAST_EXPECT(afterState.paymentRemaining == 1); - } - - { - // Ensure the borrower has funds to pay back the loan - env(pay(issuer, borrower, iouAsset(Number{1'055'524'81, -2}))); - - // Start date when the ledger is closed will be larger - auto const closeStartDate = ((parentCloseTime() / 10) + 1) * 10; - auto const grace = 5'000; - auto const maxLoanTime = kMaxTime - closeStartDate - grace; - auto const total = [&]() { - if (maxLoanTime % 5 == 0) - return 5; - if (maxLoanTime % 3 == 0) - return 3; - if (maxLoanTime % 2 == 0) - return 2; - return 0; - }(); - if (!BEAST_EXPECT(total != 0)) - return; - - auto const brokerState = env.le(keylet::loanBroker(broker.brokerID)); - // Intentionally shadow the outer values - auto const loanSequence = brokerState->at(sfLoanSequence); - auto const keylet = keylet::loan(broker.brokerID, loanSequence); - - auto const interval = maxLoanTime / total; - auto createJson = env.json( - baseJson, kPaymentInterval(interval), kPaymentTotal(total), kGracePeriod(grace)); - - env(createJson, Sig(sfCounterpartySignature, lender), Ter(tesSUCCESS)); - env.close(); - - // This loan exists - auto const beforeState = getCurrentState(env, broker, keylet); - BEAST_EXPECT(beforeState.nextPaymentDate == closeStartDate + interval); - BEAST_EXPECT(beforeState.previousPaymentDate == 0); - BEAST_EXPECT(beforeState.paymentRemaining == total); - BEAST_EXPECT(beforeState.periodicPayment > 0); - - // pay all but the last payment - { - NumberRoundModeGuard const mg{Number::RoundingMode::Upward}; - Number const payment = beforeState.periodicPayment * (total - 1); - XRPAmount const payFee{baseFee * ((total - 1) / kLoanPaymentsPerFeeIncrement + 1)}; - STAmount const paymentAmount = - roundToScale(STAmount{broker.asset, payment}, beforeState.loanScale); - auto loanPayTx = env.json(pay(borrower, keylet.key, paymentAmount), Fee(payFee)); - env(loanPayTx, Ter(tesSUCCESS)); - env.close(); - } - - // The loan is on the last payment - auto const afterState = getCurrentState(env, broker, keylet); - BEAST_EXPECT(afterState.paymentRemaining == 1); - BEAST_EXPECT(afterState.nextPaymentDate == kMaxTime - grace); - BEAST_EXPECT(afterState.previousPaymentDate == kMaxTime - grace - interval); - } - } - - void - testRequireAuth() - { - testcase("Require Auth - Implicit Pseudo-account authorization"); - using namespace jtx; - using namespace loan; - Account const lender{"lender"}; - Account const issuer{"issuer"}; - Account const borrower{"borrower"}; - Env env(*this); - - env.fund(XRP(100'000), issuer, lender, borrower); - env.close(); - - auto asset = MPTTester({ - .env = env, - .issuer = issuer, - .holders = {lender, borrower}, - .flags = kMptDexFlags | tfMPTRequireAuth | tfMPTCanClawback | tfMPTCanLock, - .authHolder = true, - }); - - env(pay(issuer, lender, asset(5'000'000))); - BrokerInfo brokerInfo{createVaultAndBroker(env, asset, lender)}; - - auto const loanSetFee = Fee(env.current()->fees().base * 2); - STAmount const debtMaximumRequest = brokerInfo.asset(1'000).value(); - - auto forUnauthAuth = [&](auto&& doTx) { - for (auto const flag : {tfMPTUnauthorize, 0u}) - { - asset.authorize({.account = issuer, .holder = borrower, .flags = flag}); - env.close(); - doTx(flag == 0); - env.close(); - } - }; - - // Can't create a loan if the borrower is not authorized - forUnauthAuth([&](bool authorized) { - auto const err = !authorized ? Ter(tecNO_AUTH) : Ter(tesSUCCESS); - env(set(borrower, brokerInfo.brokerID, debtMaximumRequest), - Sig(sfCounterpartySignature, lender), - loanSetFee, - err); - }); - - static constexpr std::uint32_t kLoanSequence = 1; - auto const loanKeylet = keylet::loan(brokerInfo.brokerID, kLoanSequence); - - // Can't loan pay if the borrower is not authorized - forUnauthAuth([&](bool authorized) { - auto const err = !authorized ? Ter(tecNO_AUTH) : Ter(tesSUCCESS); - env(pay(borrower, loanKeylet.key, debtMaximumRequest), err); - }); - } - - void - testLendingCanTradeDisabledNoImpact() - { - testcase("Lending: CanTrade disabled has no impact"); - using namespace jtx; - using namespace loan; - using namespace loanBroker; - - Env env(*this, all_); - - Account const issuer{"issuer"}; - Account const lender{"lender"}; - Account const borrower{"borrower"}; - - env.fund(XRP(1'000'000), issuer, lender, borrower); - env.close(); - - MPTTester mpt( - {.env = env, - .issuer = issuer, - .holders = {lender, borrower}, - .flags = tfMPTCanTransfer | tfMPTCanLock, - .mutableFlags = tmfMPTCanEnableCanTrade}); - PrettyAsset const asset = mpt.issuanceID(); - env(pay(issuer, lender, asset(10'000'000))); - env(pay(issuer, borrower, asset(100'000))); - env.close(); - - auto const broker = createVaultAndBroker(env, asset, lender); - - // CanTrade is not set - env(offer(lender, XRP(1), asset(10)), Ter{tecNO_PERMISSION}); - env.close(); - - auto const loanSetFee = Fee(env.current()->fees().base * 2); - - // New cover deposits still work. - env(coverDeposit(lender, broker.brokerID, asset(100))); - env.close(); - - // New loan issuance still works. - env(loan::set(borrower, broker.brokerID, 1'000), - Sig(sfCounterpartySignature, lender), - loanSetFee); - env.close(); - auto const loanKeylet = keylet::loan(broker.brokerID, 1); - BEAST_EXPECT(env.le(loanKeylet)); - - // Repayment still works. - env(pay(borrower, loanKeylet.key, asset(1'000))); - env.close(); - - // Cover withdrawal still works. - env(coverWithdraw(lender, broker.brokerID, asset(100))); - env.close(); - - // Enable CanTrade and verify the DEX path is restored. - mpt.set({.mutableFlags = tmfMPTSetCanTrade}); - env.close(); - - env(offer(lender, XRP(1), asset(10))); - env.close(); - } - -#if LOAN_TODO - void - testLoanPayLateFullPaymentBypassesPenalties(FeatureBitset features) - { - testcase("LoanPay full payment skips late penalties"); - using namespace jtx; - using namespace loan; - using namespace std::chrono_literals; - - Env env(*this, features); - - Account const issuer{"issuer"}; - Account const lender{"lender"}; - Account const borrower{"borrower"}; - - env.fund(XRP(1'000'000), issuer, lender, borrower); - env.close(); - - PrettyAsset const asset = issuer[iouCurrency]; - env(trust(lender, asset(100'000'000))); - env(trust(borrower, asset(100'000'000))); - env(pay(issuer, lender, asset(50'000'000))); - env(pay(issuer, borrower, asset(5'000'000))); - env.close(); - - BrokerInfo broker{createVaultAndBroker(env, asset, lender)}; - - auto const loanSetFee = Fee(env.current()->fees().base * 2); - - auto const brokerPreLoan = env.le(keylet::loanBroker(broker.brokerID)); - if (BEAST_EXPECT(brokerPreLoan); !brokerPreLoan.has_value()) - return; - - auto const loanSequence = brokerPreLoan->at(sfLoanSequence); - auto const loanKeylet = keylet::loan(broker.brokerID, loanSequence); - - Number const principal = asset(1'000).value(); - Number const serviceFee = asset(2).value(); - Number const lateFee = asset(5).value(); - Number const closeFee = asset(4).value(); - - env(set(borrower, broker.brokerID, principal), - Sig(sfCounterpartySignature, lender), - kLoanServiceFee(serviceFee), - kLatePaymentFee(lateFee), - kClosePaymentFee(closeFee), - kInterestRate(percentageToTenthBips(12)), - kLateInterestRate(percentageToTenthBips(24) / 10), - kCloseInterestRate(percentageToTenthBips(5)), - kPaymentTotal(12), - kPaymentInterval(600), - kGracePeriod(0), - Fee(loanSetFee)); - env.close(); - - auto state1 = getCurrentState(env, broker, loanKeylet); - if (!BEAST_EXPECT(state1.paymentRemaining > 1)) - return; - - using d = NetClock::duration; - using tp = NetClock::time_point; - auto const overdueClose = tp{d{state1.nextPaymentDate + state1.paymentInterval}}; - env.close(overdueClose); - - auto const brokerSle = env.le(keylet::loanBroker(broker.brokerID)); - auto const loanSle = env.le(loanKeylet); - if (!BEAST_EXPECT(brokerSle && loanSle)) - return; - - auto state = getCurrentState(env, broker, loanKeylet); - - TenthBips16 const managementFeeRate{brokerSle->at(sfManagementFeeRate)}; - TenthBips32 const interestRateValue{loanSle->at(sfInterestRate)}; - TenthBips32 const lateInterestRateValue{loanSle->at(sfLateInterestRate)}; - TenthBips32 const closeInterestRateValue{loanSle->at(sfCloseInterestRate)}; - - Number const closePaymentFeeRounded = - roundToAsset(broker.asset, loanSle->at(sfClosePaymentFee), state.loanScale); - Number const latePaymentFeeRounded = - roundToAsset(broker.asset, loanSle->at(sfLatePaymentFee), state.loanScale); - - auto const roundedLoanState = constructLoanState( - state.totalValue, state.principalOutstanding, state.managementFeeOutstanding); - Number const totalInterestOutstanding = roundedLoanState.interestDue; - - auto const periodicRate = loanPeriodicRate(interestRateValue, state.paymentInterval); - auto const rawLoanState = computeTheoreticalLoanState( - env.current()->rules(), - state.periodicPayment, - periodicRate, - state.paymentRemaining, - managementFeeRate); - - auto const parentCloseTime = env.current()->parentCloseTime(); - auto const startDateSeconds = - static_cast(state.startDate.time_since_epoch().count()); - - Number const fullPaymentInterest = computeFullPaymentInterest( - rawLoanState.principalOutstanding, - periodicRate, - parentCloseTime, - state.paymentInterval, - state.previousPaymentDate, - startDateSeconds, - closeInterestRateValue); - - Number const roundedFullInterestAmount = - roundToAsset(broker.asset, fullPaymentInterest, state.loanScale); - Number const roundedFullManagementFee = computeManagementFee( - broker.asset, roundedFullInterestAmount, managementFeeRate, state.loanScale); - Number const roundedFullInterest = roundedFullInterestAmount - roundedFullManagementFee; - - Number const trackedValueDelta = - state.principalOutstanding + totalInterestOutstanding + state.managementFeeOutstanding; - Number const untrackedManagementFee = - closePaymentFeeRounded + roundedFullManagementFee - state.managementFeeOutstanding; - Number const untrackedInterest = roundedFullInterest - totalInterestOutstanding; - - Number const baseFullDue = trackedValueDelta + untrackedInterest + untrackedManagementFee; - BEAST_EXPECT(baseFullDue == roundToAsset(broker.asset, baseFullDue, state.loanScale)); - - auto const overdueSeconds = - parentCloseTime.time_since_epoch().count() - state.nextPaymentDate; - if (!BEAST_EXPECT(overdueSeconds > 0)) - return; - - Number const overdueRate = loanPeriodicRate(lateInterestRateValue, overdueSeconds); - Number const lateInterestRaw = state.principalOutstanding * overdueRate; - Number const lateInterestRounded = - roundToAsset(broker.asset, lateInterestRaw, state.loanScale); - Number const lateManagementFeeRounded = computeManagementFee( - broker.asset, lateInterestRounded, managementFeeRate, state.loanScale); - Number const penaltyDue = - lateInterestRounded + lateManagementFeeRounded + latePaymentFeeRounded; - BEAST_EXPECT(penaltyDue > Number{}); - - auto const balanceBefore = env.balance(borrower, broker.asset).number(); - - STAmount const paymentAmount{broker.asset.raw(), baseFullDue}; - env(pay(borrower, loanKeylet.key, paymentAmount, tfLoanFullPayment)); - env.close(); - - if (auto const meta = env.meta(); BEAST_EXPECT(meta)) - BEAST_EXPECT(meta->at(sfTransactionResult) == tesSUCCESS); - - auto const balanceAfter = env.balance(borrower, broker.asset).number(); - Number const actualPaid = balanceBefore - balanceAfter; - BEAST_EXPECT(actualPaid == baseFullDue); - - Number const expectedWithPenalty = baseFullDue + penaltyDue; - BEAST_EXPECT(expectedWithPenalty > actualPaid); - BEAST_EXPECT(expectedWithPenalty - actualPaid == penaltyDue); - } - - void - testLoanCoverMinimumRoundingExploit(FeatureBitset features) - { - auto testLoanCoverMinimumRoundingExploit = [&, this](Number const& principalRequest) { - testcase << "LoanBrokerCoverClawback drains cover via rounding" - << " principalRequested=" << to_string(principalRequest); - - using namespace jtx; - using namespace loan; - using namespace loanBroker; - - Env env(*this, features); - - Account const issuer{"issuer"}; - Account const lender{"lender"}; - Account const borrower{"borrower"}; - - env.fund(XRP(1'000'000'000), issuer, lender, borrower); - env.close(); - - env(fset(issuer, asfAllowTrustLineClawback)); - env.close(); - - PrettyAsset const asset = issuer[iouCurrency]; - env(trust(lender, asset(2'000'0000))); - env(trust(borrower, asset(2'000'0000))); - env.close(); - - env(pay(issuer, lender, asset(2'000'0000))); - env.close(); - - BrokerParameters brokerParams{.debtMax = 0, .coverRateMin = TenthBips32{10'000}}; - BrokerInfo broker{createVaultAndBroker(env, asset, lender, brokerParams)}; - - auto const loanSetFee = Fee(env.current()->fees().base * 2); - auto createTx = env.jt( - set(borrower, broker.brokerID, principalRequest), - Sig(sfCounterpartySignature, lender), - loanSetFee, - kPaymentInterval(600), - kPaymentTotal(1), - kGracePeriod(60)); - env(createTx); - env.close(); - - auto const brokerBefore = env.le(keylet::loanBroker(broker.brokerID)); - BEAST_EXPECT(brokerBefore); - if (!brokerBefore) - return; - - Number const debtOutstanding = brokerBefore->at(sfDebtTotal); - Number const coverAvailableBefore = brokerBefore->at(sfCoverAvailable); - - BEAST_EXPECT(debtOutstanding > Number{}); - BEAST_EXPECT(coverAvailableBefore > Number{}); - - log << "debt=" << to_string(debtOutstanding) - << " cover_available=" << to_string(coverAvailableBefore); - - env(coverClawback(issuer, 0), loanBrokerID(broker.brokerID)); - env.close(); - - auto const brokerAfter = env.le(keylet::loanBroker(broker.brokerID)); - BEAST_EXPECT(brokerAfter); - if (!brokerAfter) - return; - - Number const debtAfter = brokerAfter->at(sfDebtTotal); - // the debt has not changed - BEAST_EXPECT(debtAfter == debtOutstanding); - - Number const coverAvailableAfter = brokerAfter->at(sfCoverAvailable); - - // since the cover rate min != 0, the cover available should not - // be zero - BEAST_EXPECT(coverAvailableAfter != Number{}); - }; - - // Call the lambda with different principal values - testLoanCoverMinimumRoundingExploit(Number{1, -30}); // 1e-30 units - testLoanCoverMinimumRoundingExploit(Number{1, -20}); // 1e-20 units - testLoanCoverMinimumRoundingExploit(Number{1, -10}); // 1e-10 units - testLoanCoverMinimumRoundingExploit(Number{1, 1}); // 1e-10 units - } -#endif - - void - testPoCUnsignedUnderflowOnFullPayAfterEarlyPeriodic(FeatureBitset features) - { - // --- PoC Summary ---------------------------------------------------- - // Scenario: Borrower makes one periodic payment early (before next due) - // so doPayment sets sfPreviousPaymentDueDate to the (future) - // sfNextPaymentDueDate and advances sfNextPaymentDueDate by one - // interval. Borrower then immediately performs a full-payment - // (tfLoanFullPayment). Why it matters: Full-payment interest accrual - // uses - // delta = now - max(prevPaymentDate, startDate) - // with an unsigned clock representation (uint32). If prevPaymentDate is - // in the future, the subtraction underflows to a very large positive - // number. This inflates roundedFullInterest and total full-close due, - // and LoanPay applies the inflated valueChange to the vault - // (sfAssetsTotal), increasing NAV. - // -------------------------------------------------------------------- - testcase("PoC: Unsigned-underflow full-pay accrual after early periodic"); - - using namespace jtx; - using namespace loan; - using namespace std::chrono_literals; - - Env env{*this, features}; - - Account const lender{"poc_lender4"}; - Account const borrower{"poc_borrower4"}; - env.fund(XRP(3'000'000), lender, borrower); - env.close(); - - PrettyAsset const asset{xrpIssue(), 1'000'000}; - BrokerParameters const brokerParams{}; - auto const broker = createVaultAndBroker(env, asset, lender, brokerParams); - - // Create a 3-payment loan so full-payment path is enabled after 1 - // periodic payment. - auto const loanSetFee = Fee(env.current()->fees().base * 2); - Number const principalRequest = asset(1000).value(); - auto const originationFee = asset(0).value(); - auto const serviceFee = asset(1).value(); - auto const serviceFeePA = asset(1); - auto const lateFee = asset(0).value(); - auto const closeFee = asset(0).value(); - auto const interest = percentageToTenthBips(12); - auto const lateInterest = percentageToTenthBips(12) / 10; - auto const closeInterest = percentageToTenthBips(12) / 10; - auto const overpaymentInterest = percentageToTenthBips(12) / 10; - auto const total = 3u; - auto const interval = 600u; - auto const grace = 60u; - - auto createJtx = env.jt( - set(borrower, broker.brokerID, principalRequest, 0), - Sig(sfCounterpartySignature, lender), - kLoanOriginationFee(originationFee), - kLoanServiceFee(serviceFee), - kLatePaymentFee(lateFee), - kClosePaymentFee(closeFee), - kOverpaymentFee(percentageToTenthBips(5) / 10), - kInterestRate(interest), - kLateInterestRate(lateInterest), - kCloseInterestRate(closeInterest), - kOverpaymentInterestRate(overpaymentInterest), - kPaymentTotal(total), - kPaymentInterval(interval), - kGracePeriod(grace), - Fee(loanSetFee)); - - auto const brokerSle = env.le(keylet::loanBroker(broker.brokerID)); - BEAST_EXPECT(brokerSle); - auto const loanSequence = brokerSle ? brokerSle->at(sfLoanSequence) : 0; - auto const loanKeylet = keylet::loan(broker.brokerID, loanSequence); - - env(createJtx); - env.close(); - - // Compute a regular periodic due and pay it early (before next due). - auto state = getCurrentState(env, broker, loanKeylet); - Number const periodicRate = loanPeriodicRate(state.interestRate, state.paymentInterval); - auto const components = xrpl::detail::computePaymentComponents( - env.current()->rules(), - asset.raw(), - state.loanScale, - state.totalValue, - state.principalOutstanding, - state.managementFeeOutstanding, - state.periodicPayment, - periodicRate, - state.paymentRemaining, - brokerParams.managementFeeRate); - STAmount const regularDue{asset, components.trackedValueDelta + serviceFeePA.number()}; - // now < nextDue immediately after creation, so this is an early pay. - env(pay(borrower, loanKeylet.key, regularDue)); - env.close(); - - // Immediately attempt a full payoff. Compute the exact full-payment - // due to ensure the tx applies. - auto after = getCurrentState(env, broker, loanKeylet); - auto const loanSle = env.le(loanKeylet); - BEAST_EXPECT(loanSle); - auto const brokerSle2 = env.le(keylet::loanBroker(broker.brokerID)); - BEAST_EXPECT(brokerSle2); - - auto const closePaymentFee = loanSle ? loanSle->at(sfClosePaymentFee) : Number{}; - auto const closeInterestRate = - loanSle ? TenthBips32{loanSle->at(sfCloseInterestRate)} : TenthBips32{}; - auto const managementFeeRate = - brokerSle2 ? TenthBips16{brokerSle2->at(sfManagementFeeRate)} : TenthBips16{}; - - Number const periodicRate2 = loanPeriodicRate(after.interestRate, after.paymentInterval); - // Accrued + prepayment-penalty interest based on current periodic - // schedule - auto const fullPaymentInterest = computeFullPaymentInterest( - xrpl::detail::loanPrincipalFromPeriodicPayment( - env.current()->rules(), - after.periodicPayment, - periodicRate2, - after.paymentRemaining), - periodicRate2, - env.current()->parentCloseTime(), - after.paymentInterval, - after.previousPaymentDate, - static_cast(after.startDate.time_since_epoch().count()), - closeInterestRate); - - // Round to asset scale and split interest/fee parts - auto const roundedInterest = - roundToAsset(asset.raw(), fullPaymentInterest, after.loanScale); - Number const roundedFullMgmtFee = - computeManagementFee(asset.raw(), roundedInterest, managementFeeRate, after.loanScale); - Number const roundedFullInterest = roundedInterest - roundedFullMgmtFee; - - // Show both signed and unsigned deltas to highlight the underflow. - auto const nowSecs = - static_cast(env.current()->parentCloseTime().time_since_epoch().count()); - auto const startSecs = - static_cast(after.startDate.time_since_epoch().count()); - auto const lastPaymentDate = std::max(after.previousPaymentDate, startSecs); - auto const signedDelta = - static_cast(nowSecs) - static_cast(lastPaymentDate); - auto const unsignedDelta = static_cast(nowSecs - lastPaymentDate); - log << "PoC window: prev=" << after.previousPaymentDate << " start=" << startSecs - << " now=" << nowSecs << " signedDelta=" << signedDelta - << " unsignedDelta=" << unsignedDelta << std::endl; - - // Reference (clamped) computation: emulate a non-negative accrual - // window by clamping prevPaymentDate to 'now' for the full-pay path. - auto const prevClamped = std::min(after.previousPaymentDate, nowSecs); - auto const fullPaymentInterestClamped = computeFullPaymentInterest( - xrpl::detail::loanPrincipalFromPeriodicPayment( - env.current()->rules(), - after.periodicPayment, - periodicRate2, - after.paymentRemaining), - periodicRate2, - env.current()->parentCloseTime(), - after.paymentInterval, - prevClamped, - startSecs, - closeInterestRate); - auto const roundedInterestClamped = - roundToAsset(asset.raw(), fullPaymentInterestClamped, after.loanScale); - Number const roundedFullMgmtFeeClamped = computeManagementFee( - asset.raw(), roundedInterestClamped, managementFeeRate, after.loanScale); - Number const roundedFullInterestClamped = - roundedInterestClamped - roundedFullMgmtFeeClamped; - STAmount const fullDueClamped{ - asset, - after.principalOutstanding + roundedFullInterestClamped + roundedFullMgmtFeeClamped + - closePaymentFee}; - - // Collect vault NAV before closing payment - auto const vaultId2 = brokerSle2 ? brokerSle2->at(sfVaultID) : uint256{}; - auto const vaultKey2 = keylet::vault(vaultId2); - auto const vaultBefore = env.le(vaultKey2); - BEAST_EXPECT(vaultBefore); - Number const assetsTotalBefore = vaultBefore ? vaultBefore->at(sfAssetsTotal) : Number{}; - - STAmount const fullDue{ - asset, - after.principalOutstanding + roundedFullInterest + roundedFullMgmtFee + - closePaymentFee}; - - log << "PoC payoff: principalOutstanding=" << after.principalOutstanding - << " roundedFullInterest=" << roundedFullInterest - << " roundedFullMgmtFee=" << roundedFullMgmtFee << " closeFee=" << closePaymentFee - << " fullDue=" << to_string(fullDue.getJson()) << std::endl; - log << "PoC reference (clamped): roundedFullInterestClamped=" << roundedFullInterestClamped - << " roundedFullMgmtFeeClamped=" << roundedFullMgmtFeeClamped - << " fullDueClamped=" << to_string(fullDueClamped.getJson()) << std::endl; - - env(pay(borrower, loanKeylet.key, fullDue), Txflags(tfLoanFullPayment)); - env.close(); - - // Sanity: underflow present (unsigned delta very large relative to - // interval) - BEAST_EXPECT(unsignedDelta > after.paymentInterval); - - // Compare vault NAV before/after the full close - auto const vaultAfter = env.le(vaultKey2); - BEAST_EXPECT(vaultAfter); - if (vaultAfter) - { - auto const assetsTotalAfter = vaultAfter->at(sfAssetsTotal); - log << "PoC NAV: assetsTotalBefore=" << assetsTotalBefore - << " assetsTotalAfter=" << assetsTotalAfter - << " delta=" << (assetsTotalAfter - assetsTotalBefore) << std::endl; - - // Value-based proof: underflowed window yields a payoff larger than - // the clamped (non-underflow) reference. - BEAST_EXPECT(fullDue == fullDueClamped); - if (fullDue > fullDueClamped) - log << "PoC delta: overcharge (fullDue > clamped)" << std::endl; - } - - // Loan should be paid off - auto const finalLoan = env.le(loanKeylet); - BEAST_EXPECT(finalLoan); - if (finalLoan) - { - BEAST_EXPECT(finalLoan->at(sfPaymentRemaining) == 0); - BEAST_EXPECT(finalLoan->at(sfPrincipalOutstanding) == 0); - } - } - - void - testDustManipulation(FeatureBitset features) - { - testcase("Dust manipulation"); - - using namespace jtx; - using namespace std::chrono_literals; - Env env{*this, features}; - - // Setup: Create accounts - Account const issuer{"issuer"}; - Account const lender{"lender"}; - Account const borrower{"borrower"}; - Account const victim{"victim"}; - - env.fund(XRP(1'000'000'00), issuer, lender, borrower, victim); - env.close(); - - // Step 1: Create vault with IOU asset - auto asset = issuer["USD"]; - env(trust(lender, asset(100000))); - env(trust(borrower, asset(100000))); - env(trust(victim, asset(100000))); - env(pay(issuer, lender, asset(50000))); - env(pay(issuer, borrower, asset(50000))); - env(pay(issuer, victim, asset(50000))); - env.close(); - - BrokerParameters const brokerParams{ - .vaultDeposit = 10000, - .debtMax = Number{0}, - .coverRateMin = TenthBips32{1000}, - .coverRateLiquidation = TenthBips32{2500}}; - - auto broker = createVaultAndBroker(env, asset, lender, brokerParams); - - auto const loanKeyletOpt = [&]() -> std::optional { - auto const brokerSle = env.le(keylet::loanBroker(broker.brokerID)); - if (!BEAST_EXPECT(brokerSle)) - return std::nullopt; - - // Broker has no loans - BEAST_EXPECT(brokerSle->at(sfOwnerCount) == 0); - - // The loan keylet is based on the LoanSequence of the - // _LOAN_BROKER_ object. - auto const loanSequence = brokerSle->at(sfLoanSequence); - return keylet::loan(broker.brokerID, loanSequence); - }(); - if (!loanKeyletOpt) - return; - - auto const& vaultKeylet = broker.vaultKeylet(); - - { - auto const vaultSle = env.le(vaultKeylet); - Number const assetsTotal = vaultSle->at(sfAssetsTotal); - Number const assetsAvail = vaultSle->at(sfAssetsAvailable); - - log << "Before loan creation:" << std::endl; - log << " AssetsTotal: " << assetsTotal << std::endl; - log << " AssetsAvailable: " << assetsAvail << std::endl; - log << " Difference: " << (assetsTotal - assetsAvail) << std::endl; - - // before the loan the assets total and available should be equal - BEAST_EXPECT(assetsAvail == assetsTotal); - BEAST_EXPECT(assetsAvail == broker.asset(brokerParams.vaultDeposit).number()); - } - - Keylet const& loanKeylet = *loanKeyletOpt; - - LoanParameters const loanParams{ - .account = lender, - .counter = borrower, - .principalRequest = Number{100}, - .interest = TenthBips32{1922}, - .payTotal = 5816, - .payInterval = 86400 * 6, - .gracePd = 86400 * 5, - }; - - env(loanParams(env, broker)); - env.close(); - - // Wait for loan to be late enough to default - env.close(std::chrono::seconds(86400 * 40)); // 40 days - - { - auto const vaultSle = env.le(vaultKeylet); - Number const assetsTotal = vaultSle->at(sfAssetsTotal); - Number const assetsAvail = vaultSle->at(sfAssetsAvailable); - - log << "After loan creation:" << std::endl; - log << " AssetsTotal: " << assetsTotal << std::endl; - log << " AssetsAvailable: " << assetsAvail << std::endl; - log << " Difference: " << (assetsTotal - assetsAvail) << std::endl; - - auto const loanSle = env.le(loanKeylet); - if (!BEAST_EXPECT(loanSle)) - return; - auto const state = constructLoanState(loanSle); - - log << "Loan state:" << std::endl; - log << " ValueOutstanding: " << state.valueOutstanding << std::endl; - log << " PrincipalOutstanding: " << state.principalOutstanding << std::endl; - log << " InterestOutstanding: " << state.interestOutstanding() << std::endl; - log << " InterestDue: " << state.interestDue << std::endl; - log << " FeeDue: " << state.managementFeeDue << std::endl; - - // after loan creation the assets total and available should - // reflect the value of the loan - BEAST_EXPECT(assetsAvail < assetsTotal); - BEAST_EXPECT( - assetsAvail == - broker.asset(brokerParams.vaultDeposit - loanParams.principalRequest).number()); - BEAST_EXPECT( - assetsTotal == - broker.asset(brokerParams.vaultDeposit + state.interestDue).number()); - } - - // Step 7: Trigger default (dust adjustment will occur) - env(jtx::loan::manage(lender, loanKeylet.key, tfLoanDefault)); - env.close(); - - // Step 8: Verify phantom assets created - { - auto const vaultSle2 = env.le(vaultKeylet); - Number const assetsTotal2 = vaultSle2->at(sfAssetsTotal); - Number const assetsAvail2 = vaultSle2->at(sfAssetsAvailable); - - log << "After default:" << std::endl; - log << " AssetsTotal: " << assetsTotal2 << std::endl; - log << " AssetsAvailable: " << assetsAvail2 << std::endl; - log << " Difference: " << (assetsTotal2 - assetsAvail2) << std::endl; - - // after a default the assets total and available should be equal - BEAST_EXPECT(assetsAvail2 == assetsTotal2); - } - } - - void - testRIPD3831(FeatureBitset features) - { - using namespace jtx; - - testcase("RIPD-3831"); - - Account const issuer("issuer"); - Account const lender("lender"); - Account const borrower("borrower"); - - BrokerParameters const brokerParams{ - .vaultDeposit = 100000, - .debtMax = 0, - .coverRateMin = TenthBips32{0}, - // .managementFeeRate = TenthBips16{5919}, - .coverRateLiquidation = TenthBips32{0}}; - LoanParameters const loanParams{ - .account = lender, - .counter = borrower, - .principalRequest = Number{200'000, -6}, - .lateFee = Number{200, -6}, - .interest = TenthBips32{50'000}, - .payTotal = 10, - .payInterval = 150}; - - auto const assetType = AssetType::XRP; - - Env env{*this, features}; - - auto loanResult = - createLoan(env, assetType, brokerParams, loanParams, issuer, lender, borrower); - - if (BEAST_EXPECT(loanResult); !loanResult.has_value()) - return; - - auto broker = std::get(*loanResult); - auto loanKeylet = std::get(*loanResult); - - using tp = NetClock::time_point; - using d = NetClock::duration; - - auto state = getCurrentState(env, broker, loanKeylet); - if (auto loan = env.le(loanKeylet); BEAST_EXPECT(loan)) - { - env.close(tp{d{loan->at(sfNextPaymentDueDate) + loan->at(sfGracePeriod) + 1}}); - } - - topUpBorrower(env, broker, issuer, borrower, state, loanParams.serviceFee); - - using namespace jtx::loan; - - auto jv = pay(borrower, loanKeylet.key, drops(XRPAmount(state.totalValue))); - - { - auto const submitParam = to_string(jv); - auto const jr = env.rpc("submit", borrower.name(), submitParam); - - BEAST_EXPECT(jr.isMember(jss::result)); - auto const jResult = jr[jss::result]; - } - - env.close(); - - // Make sure the system keeps responding - env(noop(borrower)); - env.close(); - env(noop(issuer)); - env.close(); - env(noop(lender)); - env.close(); - } - - void - testRIPD3459(FeatureBitset features) - { - testcase("RIPD-3459 - LoanBroker incorrect debt total"); - - using namespace jtx; - - Account const issuer("issuer"); - Account const lender("lender"); - Account const borrower("borrower"); - - BrokerParameters const brokerParams{ - .vaultDeposit = 200'000, - .debtMax = 0, - .coverRateMin = TenthBips32{0}, - .managementFeeRate = TenthBips16{500}, - .coverRateLiquidation = TenthBips32{0}}; - LoanParameters const loanParams{ - .account = lender, - .counter = borrower, - .principalRequest = Number{100'000, -4}, - .interest = TenthBips32{100'000}, - .payTotal = 10}; - - auto const assetType = AssetType::MPT; - - Env env{*this, features}; - - auto loanResult = - createLoan(env, assetType, brokerParams, loanParams, issuer, lender, borrower); - - if (BEAST_EXPECT(loanResult); !loanResult.has_value()) - return; - - auto broker = std::get(*loanResult); - auto loanKeylet = std::get(*loanResult); - auto pseudoAcct = std::get(*loanResult); - - VerifyLoanStatus const verifyLoanStatus(env, broker, pseudoAcct, loanKeylet); - - if (auto const brokerSle = env.le(broker.brokerKeylet()); BEAST_EXPECT(brokerSle)) - { - if (auto const loanSle = env.le(loanKeylet); BEAST_EXPECT(loanSle)) - { - BEAST_EXPECT(brokerSle->at(sfDebtTotal) == loanSle->at(sfTotalValueOutstanding)); - } - } - - makeLoanPayments( - env, - broker, - loanParams, - loanKeylet, - verifyLoanStatus, - issuer, - lender, - borrower, - PaymentParameters{.showStepBalances = true}); - - if (auto const brokerSle = env.le(broker.brokerKeylet()); BEAST_EXPECT(brokerSle)) - { - if (auto const loanSle = env.le(loanKeylet); BEAST_EXPECT(loanSle)) - { - BEAST_EXPECT(brokerSle->at(sfDebtTotal) == loanSle->at(sfTotalValueOutstanding)); - BEAST_EXPECT(brokerSle->at(sfDebtTotal) == beast::kZero); - } - } - } - - void - testRIPD3901() - { - testcase("Crash with tfLoanOverpayment"); - using namespace jtx; - using namespace loan; - Account const lender{"lender"}; - Account const issuer{"issuer"}; - Account const borrower{"borrower"}; - Account const depositor{"depositor"}; - auto const txFee = Fee(XRP(100)); - - Env env(*this); - Vault const vault(env); - - env.fund(XRP(10'000), lender, issuer, borrower, depositor); - env.close(); - - auto [tx, vaultKeyLet] = vault.create({.owner = lender, .asset = xrpIssue()}); - env(tx, txFee); - env.close(); - - env(vault.deposit({.depositor = depositor, .id = vaultKeyLet.key, .amount = XRP(1'000)}), - txFee); - env.close(); - - auto const brokerKeyLet = keylet::loanBroker(lender.id(), env.seq(lender)); - - env(loanBroker::set(lender, vaultKeyLet.key), txFee); - env.close(); - - // BrokerInfo brokerInfo{xrpIssue(), keylet, vaultKeyLet, {}}; - - STAmount const debtMaximumRequest = XRPAmount(200'000); - - env(set(borrower, brokerKeyLet.key, debtMaximumRequest), - Sig(sfCounterpartySignature, lender), - kInterestRate(TenthBips32(50'000)), - kPaymentTotal(2), - kPaymentInterval(150), - Txflags(tfLoanOverpayment), - txFee); - env.close(); - - std::uint32_t const loanSequence = 1; - auto const loanKeylet = keylet::loan(brokerKeyLet.key, loanSequence); - - if (auto loan = env.le(loanKeylet); env.test.BEAST_EXPECT(loan)) - { - env(loan::pay(borrower, loanKeylet.key, XRPAmount(150'001)), - Txflags(tfLoanOverpayment), - txFee); - env.close(); - } - } - - void - testRoundingAllowsUndercoverage(FeatureBitset features) - { - testcase("Minimum cover rounding allows undercoverage (XRP)"); - - using namespace jtx; - using namespace loanBroker; - - Env env{*this, features}; - - Account const lender{"lender"}; - Account const borrower{"borrower"}; - - env.fund(XRP(200'000), lender, borrower); - env.close(); - - // Vault with XRP asset - Vault const vault{env}; - auto [vaultCreate, vaultKeylet] = vault.create({.owner = lender, .asset = xrpIssue()}); - env(vaultCreate); - env.close(); - BEAST_EXPECT(env.le(vaultKeylet)); - - // Seed the vault with XRP so it can fund the loan principal - PrettyAsset const xrpAsset{xrpIssue(), 1}; - - BrokerParameters const brokerParams{ - .vaultDeposit = 1'000, - .debtMax = Number{0}, - .coverRateMin = TenthBips32{10'000}, - .coverDeposit = 82, - }; - - auto const brokerInfo = createVaultAndBroker(env, xrpAsset, lender, brokerParams); - // Create a loan with principal 804 XRP and 0% interest (so - // DebtTotal increases by exactly 804) - env(loan::set(borrower, brokerInfo.brokerID, xrpAsset(804).value()), - loan::kInterestRate(TenthBips32(0)), - Sig(sfCounterpartySignature, lender), - Fee(env.current()->fees().base * 2)); - BEAST_EXPECT(env.ter() == tesSUCCESS); - env.close(); - - // Verify DebtTotal is exactly 804 - if (auto const brokerSle = env.le(keylet::loanBroker(brokerInfo.brokerID)); - BEAST_EXPECT(brokerSle)) - { - log << *brokerSle << std::endl; - BEAST_EXPECT(brokerSle->at(sfDebtTotal) == Number(804)); - } - - // Attempt to withdraw 2 XRP to self, leaving 80 XRP CoverAvailable. - // The minimum is 80.4 XRP, which rounds up to 81 XRP, so this fails. - env(coverWithdraw(lender, brokerInfo.brokerID, xrpAsset(2).value()), - Ter(tecINSUFFICIENT_FUNDS)); - BEAST_EXPECT(env.ter() == tecINSUFFICIENT_FUNDS); - env.close(); - - // Attempt to withdraw 1 XRP to self, leaving 81 XRP CoverAvailable. - // because that leaves sufficient cover, this succeeds - env(coverWithdraw(lender, brokerInfo.brokerID, xrpAsset(1).value())); - BEAST_EXPECT(env.ter() == tesSUCCESS); - env.close(); - - // Validate CoverAvailable == 80 XRP and DebtTotal remains 804 - if (auto const brokerSle = env.le(keylet::loanBroker(brokerInfo.brokerID)); - BEAST_EXPECT(brokerSle)) - { - log << *brokerSle << std::endl; - BEAST_EXPECT(brokerSle->at(sfCoverAvailable) == xrpAsset(81).value()); - BEAST_EXPECT(brokerSle->at(sfDebtTotal) == Number(804)); - - // Also demonstrate that the true minimum (804 * 10%) exceeds 80 - auto const theoreticalMin = tenthBipsOfValue(Number(804), TenthBips32(10'000)); - log << "Theoretical min cover: " << theoreticalMin << std::endl; - BEAST_EXPECT(Number(804, -1) == theoreticalMin); - } - } - - void - testRIPD3902(FeatureBitset features) - { - testcase("RIPD-3902 - 1 IOU loan payments"); - - using namespace jtx; - - Account const issuer("issuer"); - Account const lender("lender"); - Account const borrower("borrower"); - - BrokerParameters const brokerParams{ - .vaultDeposit = 10, - .debtMax = 0, - .coverRateMin = TenthBips32{0}, - .managementFeeRate = TenthBips16{0}, - .coverRateLiquidation = TenthBips32{0}}; - LoanParameters const loanParams{ - .account = lender, - .counter = borrower, - .principalRequest = Number{1, 0}, - .interest = TenthBips32{100'000}, - .payTotal = 5, - .payInterval = 150, - .gracePd = 60}; - - auto const assetType = AssetType::IOU; - - Env env{*this, features}; - - auto loanResult = - createLoan(env, assetType, brokerParams, loanParams, issuer, lender, borrower); - - if (BEAST_EXPECT(loanResult); !loanResult.has_value()) - return; - - auto broker = std::get(*loanResult); - auto loanKeylet = std::get(*loanResult); - auto pseudoAcct = std::get(*loanResult); - - VerifyLoanStatus const verifyLoanStatus(env, broker, pseudoAcct, loanKeylet); - - makeLoanPayments( - env, - broker, - loanParams, - loanKeylet, - verifyLoanStatus, - issuer, - lender, - borrower, - PaymentParameters{.showStepBalances = true}); - } - - void - testBorrowerIsBroker() - { - testcase("Test Borrower is Broker"); - using namespace jtx; - using namespace loan; - Account const broker{"broker"}; - Account const issuer{"issuer"}; - Account const borrower{"borrower"}; - Account const depositor{"depositor"}; - - auto testLoanAsset = [&](auto&& getMaxDebt, auto const& borrower) { - Env env(*this); - Vault const vault(env); - - if (borrower == broker) - { - env.fund(XRP(10'000), broker, issuer, depositor); - } - else - { - env.fund(XRP(10'000), broker, borrower, issuer, depositor); - } - env.close(); - - auto const xrpFee = XRP(100); - auto const txFee = Fee(xrpFee); - - STAmount const debtMaximumRequest = getMaxDebt(env); - - auto const& asset = debtMaximumRequest.asset(); - auto const initialVault = asset(debtMaximumRequest * 100); - - auto [tx, vaultKeylet] = vault.create({.owner = broker, .asset = asset}); - env(tx, txFee); - env.close(); - - env(vault.deposit( - {.depositor = depositor, .id = vaultKeylet.key, .amount = initialVault}), - txFee); - env.close(); - - auto const brokerKeylet = keylet::loanBroker(broker.id(), env.seq(broker)); - - env(loanBroker::set(broker, vaultKeylet.key), txFee); - env.close(); - - auto const serviceFee = 101; - - env(set(broker, brokerKeylet.key, debtMaximumRequest), - kCounterparty(borrower), - Sig(sfCounterpartySignature, borrower), - kLoanServiceFee(serviceFee), - kPaymentTotal(10), - txFee); - env.close(); - - std::uint32_t const loanSequence = 1; - auto const loanKeylet = keylet::loan(brokerKeylet.key, loanSequence); - - auto const brokerBalanceBefore = env.balance(broker, asset); - - if (auto const loanSle = env.le(loanKeylet); env.test.BEAST_EXPECT(loanSle)) - { - auto const payment = loanSle->at(sfPeriodicPayment); - auto const totalPayment = payment + serviceFee; - env(loan::pay(borrower, loanKeylet.key, asset(totalPayment)), txFee); - env.close(); - if (auto const vaultSle = env.le(vaultKeylet); BEAST_EXPECT(vaultSle)) - { - auto const expected = [&]() { - // The service fee is transferred to the broker if - // a borrower is not the broker - if (borrower != broker) - return brokerBalanceBefore.number() + serviceFee; - // Since a borrower is the broker, the payment is - // transferred to the Vault from the broker but not - // the service fee. - // If the asset is XRP then the broker pays the txFee. - if (asset.native()) - return brokerBalanceBefore.number() - payment - xrpFee.number(); - return brokerBalanceBefore.number() - payment; - }(); - BEAST_EXPECT(env.balance(broker, asset).value() == asset(expected).value()); - } - } - }; - // Test when a borrower is the broker and is not to verify correct - // service fee transfer in both cases. - for (auto const& borrowerAcct : {broker, borrower}) - { - testLoanAsset( - [&](Env&) -> STAmount { return STAmount{XRPAmount{200'000}}; }, borrowerAcct); - testLoanAsset( - [&](Env& env) -> STAmount { - auto const iou = issuer["USD"]; - env(trust(broker, iou(1'000'000'000))); - env(trust(depositor, iou(1'000'000'000))); - env(pay(issuer, broker, iou(100'000'000))); - env(pay(issuer, depositor, iou(100'000'000))); - env.close(); - return iou(200'000); - }, - borrowerAcct); - testLoanAsset( - [&](Env& env) -> STAmount { - MPTTester const mpt( - {.env = env, - .issuer = issuer, - .holders = {broker, depositor}, - .pay = 100'000'000}); - return mpt(200'000); - }, - borrowerAcct); - } - } - - void - testIssuerIsBorrower(FeatureBitset features) - { - testcase("RIPD-4096 - Issuer as borrower"); - - using namespace jtx; - - Account const issuer("issuer"); - Account const lender("lender"); - - BrokerParameters const brokerParams{ - .vaultDeposit = 100'000, - .debtMax = 0, - .coverRateMin = TenthBips32{0}, - .managementFeeRate = TenthBips16{0}, - .coverRateLiquidation = TenthBips32{0}}; - LoanParameters const loanParams{ - .account = lender, .counter = issuer, .principalRequest = Number{10000}}; - - auto const assetType = AssetType::IOU; - - Env env{*this, features}; - - auto loanResult = - createLoan(env, assetType, brokerParams, loanParams, issuer, lender, issuer); - - if (BEAST_EXPECT(loanResult); !loanResult.has_value()) - return; - - auto broker = std::get(*loanResult); - auto loanKeylet = std::get(*loanResult); - auto pseudoAcct = std::get(*loanResult); - - VerifyLoanStatus const verifyLoanStatus(env, broker, pseudoAcct, loanKeylet); - - makeLoanPayments( - env, - broker, - loanParams, - loanKeylet, - verifyLoanStatus, - issuer, - lender, - issuer, - PaymentParameters{.showStepBalances = true}); - } - - void - testLimitExceeded() - { - testcase("RIPD-4125 - overpayment"); - - using namespace jtx; - - Account const issuer("issuer"); - Account const lender("lender"); - Account const borrower("borrower"); - - BrokerParameters const brokerParams{ - .vaultDeposit = 100'000, - .debtMax = 0, - .coverRateMin = TenthBips32{0}, - .managementFeeRate = TenthBips16{0}, - .coverRateLiquidation = TenthBips32{0}}; - LoanParameters const loanParams{ - .account = lender, - .counter = borrower, - .principalRequest = Number{200000, -6}, - .interest = TenthBips32{50000}, - .payTotal = 3, - .payInterval = 200, - .gracePd = 60, - .flags = tfLoanOverpayment, - }; - - auto const assetType = AssetType::XRP; - - Env env(*this, makeConfig(), all_, nullptr, beast::Severity::Warning); - - auto loanResult = - createLoan(env, assetType, brokerParams, loanParams, issuer, lender, borrower); - - if (BEAST_EXPECT(loanResult); !loanResult.has_value()) - return; - - auto broker = std::get(*loanResult); - auto loanKeylet = std::get(*loanResult); - auto pseudoAcct = std::get(*loanResult); - - VerifyLoanStatus const verifyLoanStatus(env, broker, pseudoAcct, loanKeylet); - - auto const state = getCurrentState(env, broker, loanKeylet); - - env(loan::pay( - borrower, - loanKeylet.key, - STAmount{broker.asset, state.periodicPayment * 3 / 2 + 1}, - tfLoanOverpayment)); - env.close(); - - PaymentParameters const paymentParams{ - .showStepBalances = false, - .validateBalances = true, - }; - - makeLoanPayments( - env, - broker, - loanParams, - loanKeylet, - verifyLoanStatus, - issuer, - lender, - borrower, - paymentParams); - } - - void - testOverpaymentManagementFee(FeatureBitset features) - { - testcase("testOverpaymentManagementFee"); - - using namespace jtx; - using namespace loan; - - Env env{*this, features}; - - Account const lender{"lender"}, borrower{"borrower"}; - - env.fund(XRP(10'000'000), lender, borrower); - env.close(); - - PrettyAsset const asset{xrpIssue(), 1000}; - - auto const result = createVaultAndBroker( - env, - asset, - lender, - { - .vaultDeposit = asset(100'000).value(), - .managementFeeRate = TenthBips16(10'000), - }); - - auto const loanSetFee = Fee(env.current()->fees().base * 2); - - auto const loanKeylet = keylet::loan( - result.brokerKeylet().key, (env.le(result.brokerKeylet()))->at(sfLoanSequence)); - env(loan::set( - borrower, result.brokerKeylet().key, asset(10'000).value(), tfLoanOverpayment), - Sig(sfCounterpartySignature, lender), - loan::kPaymentInterval(86400 * 30), - loan::kPaymentTotal(3), - loan::kOverpaymentInterestRate(TenthBips32(percentageToTenthBips(20))), - loanSetFee); - - // From calculator - auto const expectedOverpaymentManagementFee = Number{33333, 0}; - auto const loanBrokerBalanceBefore = env.balance(lender); - - auto const loanPayFee = Fee(env.current()->fees().base * 2); - env(pay(borrower, loanKeylet.key, asset(5'000).value(), tfLoanOverpayment), loanPayFee); - env.close(); - - BEAST_EXPECTS( - env.balance(lender) - loanBrokerBalanceBefore == expectedOverpaymentManagementFee, - "overpayment management fee missmatch; expected:" + - to_string(expectedOverpaymentManagementFee) + - " got: " + to_string(env.balance(lender) - loanBrokerBalanceBefore)); - } - - void - testLoanPayBrokerOwnerMissingTrustline(FeatureBitset features) - { - testcase << "LoanPay Broker Owner Missing Trustline (PoC)"; - using namespace jtx; - using namespace loan; - Account const issuer("issuer"); - Account const borrower("borrower"); - Account const broker("broker"); - auto const iou = issuer["IOU"]; - Env env(*this, features); - env.fund(XRP(20'000), issuer, broker, borrower); - env.close(); - // Set up trustlines and fund accounts - env(trust(broker, iou(20'000'000))); - env(trust(borrower, iou(20'000'000))); - env(pay(issuer, broker, iou(10'000'000))); - env(pay(issuer, borrower, iou(1'000))); - env.close(); - // Create vault and broker - auto const brokerInfo = createVaultAndBroker(env, iou, broker); - // Create a loan first (this creates debt) - auto const keylet = keylet::loan(brokerInfo.brokerID, 1); - env(set(borrower, brokerInfo.brokerID, 10'000), - Sig(sfCounterpartySignature, broker), - kLoanServiceFee(iou(100).value()), - kPaymentInterval(100), - Fee(XRP(100))); - env.close(); - // Ensure broker has sufficient cover so brokerPayee == brokerOwner - // We need coverAvailable >= (debtTotal * coverRateMinimum) - // Deposit enough cover to ensure the fee goes to broker owner - // The default coverRateMinimum is 10%, so for a 10,000 loan we need - // at least 1,000 cover. Default cover is 1,000, so we add more to be - // safe. - auto const additionalCover = iou(50'000).value(); - env(loanBroker::coverDeposit(broker, brokerInfo.brokerID, STAmount{iou, additionalCover})); - env.close(); - // Verify broker owner has a trustline - auto const brokerTrustline = keylet::trustLine(broker, iou); - BEAST_EXPECT(env.le(brokerTrustline) != nullptr); - // Broker owner deletes their trustline - // First, pay any positive balance to issuer to zero it out - auto const brokerBalance = env.balance(broker, iou); - env(pay(broker, issuer, brokerBalance)); - env.close(); - // Remove the trustline by setting limit to 0 - env(trust(broker, iou(0))); - env.close(); - // Verify trustline is deleted - BEAST_EXPECT(env.le(brokerTrustline) == nullptr); - // Now borrower tries to make a payment - // We should get a tesSUCCESS instead of a tecNO_LINE. - env(pay(borrower, keylet.key, iou(10'100)), Fee(XRP(100)), Ter(tesSUCCESS)); - env.close(); - // Verify trustline is still deleted - BEAST_EXPECT(env.le(brokerTrustline) == nullptr); - // Verify the service fee went to the broker pseudo-account - if (auto const brokerSle = env.le(keylet::loanBroker(brokerInfo.brokerID)); - BEAST_EXPECT(brokerSle)) - { - Account const pseudo("pseudo-account", brokerSle->at(sfAccount)); - auto const balance = env.balance(pseudo, iou); - // 1,000 default + 50,000 extra + 100 service fee from LoanPay - BEAST_EXPECTS(balance == iou(51'100), to_string(json::Value(balance))); - } - } - - void - testLoanPayBrokerOwnerUnauthorizedMPT(FeatureBitset features) - { - testcase << "LoanPay Broker Owner MPT unauthorized"; - using namespace jtx; - using namespace loan; - - Account const issuer("issuer"); - Account const borrower("borrower"); - Account const broker("broker"); - - Env env{*this, features}; - env.fund(XRP(20'000), issuer, broker, borrower); - env.close(); - - MPTTester mptt{env, issuer, kMptInitNoFund}; - mptt.create({.flags = tfMPTCanClawback | tfMPTCanTransfer | tfMPTCanLock}); - - PrettyAsset const mpt{mptt.issuanceID()}; - - // Authorize broker and borrower - mptt.authorize({.account = broker}); - mptt.authorize({.account = borrower}); - - env.close(); - - // Fund accounts - env(pay(issuer, broker, mpt(10'000'000))); - env(pay(issuer, borrower, mpt(1'000))); - env.close(); - - // Create vault and broker - auto const brokerInfo = createVaultAndBroker(env, mpt, broker); - // Create a loan first (this creates debt) - auto const keylet = keylet::loan(brokerInfo.brokerID, 1); - env(set(borrower, brokerInfo.brokerID, 10'000), - Sig(sfCounterpartySignature, broker), - kLoanServiceFee(mpt(100).value()), - kPaymentInterval(100), - Fee(XRP(100))); - env.close(); - // Ensure broker has sufficient cover so brokerPayee == brokerOwner - // We need coverAvailable >= (debtTotal * coverRateMinimum) - // Deposit enough cover to ensure the fee goes to broker owner - // The default coverRateMinimum is 10%, so for a 10,000 loan we need - // at least 1,000 cover. Default cover is 1,000, so we add more to be - // safe. - auto const additionalCover = mpt(50'000).value(); - env(loanBroker::coverDeposit(broker, brokerInfo.brokerID, STAmount{mpt, additionalCover})); - env.close(); - // Verify broker owner is authorized - auto const brokerMpt = keylet::mptoken(mptt.issuanceID(), broker); - BEAST_EXPECT(env.le(brokerMpt) != nullptr); - // Broker owner unauthorizes. - // First, pay any positive balance to issuer to zero it out - auto const brokerBalance = env.balance(broker, mpt); - env(pay(broker, issuer, brokerBalance)); - env.close(); - // Then, unauthorize the MPT. - mptt.authorize({.account = broker, .flags = tfMPTUnauthorize}); - env.close(); - // Verify the MPT is unauthorized. - BEAST_EXPECT(env.le(brokerMpt) == nullptr); - // Now borrower tries to make a payment - // We should get a tesSUCCESS instead of a tecNO_AUTH. - auto const borrowerBalance = env.balance(borrower, mpt); - env(pay(borrower, keylet.key, mpt(10'100)), Fee(XRP(100)), Ter(tesSUCCESS)); - env.close(); - // Verify the MPT is still unauthorized. - BEAST_EXPECT(env.le(brokerMpt) == nullptr); - // Verify the service fee went to the broker pseudo-account - if (auto const brokerSle = env.le(keylet::loanBroker(brokerInfo.brokerID)); - BEAST_EXPECT(brokerSle)) - { - Account const pseudo("pseudo-account", brokerSle->at(sfAccount)); - auto const balance = env.balance(pseudo, mpt); - // 1,000 default + 50,000 extra + 100 service fee from LoanPay - BEAST_EXPECTS(balance == mpt(51'100), to_string(json::Value(balance))); - } - } - - void - testLoanPayBrokerOwnerNoPermissionedDomainMPT(FeatureBitset features) - { - testcase << "LoanPay Broker Owner without permissioned domain of the MPT"; - using namespace jtx; - using namespace loan; - - Account const issuer("issuer"); - Account const borrower("borrower"); - Account const broker("broker"); - - Env env{*this, features}; - env.fund(XRP(20'000), issuer, broker, borrower); - env.close(); - - auto credType = "credential1"; - - pdomain::Credentials const credentials1 = {{.issuer = issuer, .credType = credType}}; - env(pdomain::setTx(issuer, credentials1)); - env.close(); - - auto domainID = pdomain::getNewDomain(env.meta()); - - env(credentials::create(broker, issuer, credType)); - env(credentials::accept(broker, issuer, credType)); - env.close(); - - env(credentials::create(borrower, issuer, credType)); - env(credentials::accept(borrower, issuer, credType)); - env.close(); - - MPTTester mptt{env, issuer, kMptInitNoFund}; - mptt.create({ - .flags = tfMPTCanClawback | tfMPTRequireAuth | tfMPTCanTransfer | tfMPTCanLock, - .domainID = domainID, - }); - - PrettyAsset const mpt{mptt.issuanceID()}; - - // Authorize broker and borrower - mptt.authorize({.account = broker}); - mptt.authorize({.account = borrower}); - - env.close(); - - // Fund accounts - env(pay(issuer, broker, mpt(10'000'000))); - env(pay(issuer, borrower, mpt(1'000))); - env.close(); - - // Create vault and broker - auto const brokerInfo = createVaultAndBroker(env, mpt, broker); - // Create a loan first (this creates debt) - auto const keylet = keylet::loan(brokerInfo.brokerID, 1); - env(set(borrower, brokerInfo.brokerID, 10'000), - Sig(sfCounterpartySignature, broker), - kLoanServiceFee(mpt(100).value()), - kPaymentInterval(100), - Fee(XRP(100))); - env.close(); - // Ensure broker has sufficient cover so brokerPayee == brokerOwner - // We need coverAvailable >= (debtTotal * coverRateMinimum) - // Deposit enough cover to ensure the fee goes to broker owner - // The default coverRateMinimum is 10%, so for a 10,000 loan we need - // at least 1,000 cover. Default cover is 1,000, so we add more to be - // safe. - auto const additionalCover = mpt(50'000).value(); - env(loanBroker::coverDeposit(broker, brokerInfo.brokerID, STAmount{mpt, additionalCover})); - env.close(); - // Verify broker owner is authorized - auto const brokerMpt = keylet::mptoken(mptt.issuanceID(), broker); - BEAST_EXPECT(env.le(brokerMpt) != nullptr); - // Remove the credentials for the Broker owner. - // First, pay any positive balance to issuer to zero it out - auto const brokerBalance = env.balance(broker, mpt); - env(pay(broker, issuer, brokerBalance)); - env.close(); - - env(credentials::deleteCred(broker, broker, issuer, credType)); - env.close(); - - // Make sure the broker is not authorized to hold the MPT after we - // deleted the credentials - env(pay(issuer, broker, mpt(1'000)), Ter(tecNO_AUTH)); - - // Now borrower tries to make a payment - // We should get a tesSUCCESS instead of a tecNO_AUTH. - auto const borrowerBalance = env.balance(borrower, mpt); - env(pay(borrower, keylet.key, mpt(10'100)), Fee(XRP(100)), Ter(tesSUCCESS)); - env.close(); - // Verify broker is still not authorized - env(pay(issuer, broker, mpt(1'000)), Ter(tecNO_AUTH)); - // Verify the service fee went to the broker pseudo-account - if (auto const brokerSle = env.le(keylet::loanBroker(brokerInfo.brokerID)); - BEAST_EXPECT(brokerSle)) - { - Account const pseudo("pseudo-account", brokerSle->at(sfAccount)); - auto const balance = env.balance(pseudo, mpt); - // 1,000 default + 50,000 extra + 100 service fee from LoanPay - BEAST_EXPECTS(balance == mpt(51'100), to_string(json::Value(balance))); - } - } - - void - testLoanSetBrokerOwnerNoPermissionedDomainMPT(FeatureBitset features) - { - testcase << "LoanSet Broker Owner without permissioned domain of the MPT"; - using namespace jtx; - using namespace loan; - - Account const issuer("issuer"); - Account const borrower("borrower"); - Account const broker("broker"); - - Env env{*this, features}; - env.fund(XRP(20'000), issuer, broker, borrower); - env.close(); - - auto credType = "credential1"; - - pdomain::Credentials const credentials1{{.issuer = issuer, .credType = credType}}; - env(pdomain::setTx(issuer, credentials1)); - env.close(); - - auto domainID = pdomain::getNewDomain(env.meta()); - - // Add credentials for the broker and borrower - env(credentials::create(broker, issuer, credType)); - env(credentials::accept(broker, issuer, credType)); - env.close(); - - env(credentials::create(borrower, issuer, credType)); - env(credentials::accept(borrower, issuer, credType)); - env.close(); - - MPTTester mptt{env, issuer, kMptInitNoFund}; - mptt.create({ - .flags = tfMPTCanClawback | tfMPTRequireAuth | tfMPTCanTransfer | tfMPTCanLock, - .domainID = domainID, - }); - - PrettyAsset const mpt{mptt.issuanceID()}; - - // Authorize broker and borrower - mptt.authorize({.account = broker}); - mptt.authorize({.account = borrower}); - env.close(); - - // Fund accounts - env(pay(issuer, broker, mpt(10'000'000))); - env(pay(issuer, borrower, mpt(1'000))); - env.close(); - - // Create vault and broker - auto const brokerInfo = createVaultAndBroker(env, mpt, broker); - - // Remove the credentials for the Broker owner. - // Clear the balance first. - auto const brokerBalance = env.balance(broker, mpt); - env(pay(broker, issuer, brokerBalance)); - env.close(); - // Delete the credentials - env(credentials::deleteCred(broker, broker, issuer, credType)); - env.close(); - - // Create a loan, this should fail for tecNO_AUTH - env(set(borrower, brokerInfo.brokerID, 10'000), - Sig(sfCounterpartySignature, broker), - kLoanServiceFee(mpt(100).value()), - kPaymentInterval(100), - Fee(XRP(100)), - Ter(tecNO_AUTH)); - env.close(); - } - - void - testSequentialFLCDepletion(FeatureBitset features) - { - testcase << "First-Loss Capital Depletion on Sequential Defaults"; - - using namespace jtx; - using namespace loan; - using namespace loanBroker; - - Env env{*this, features}; - - Account const issuer{"issuer"}; - Account const lender{"lender"}; - Account const borrowerA{"borrowerA"}; - Account const borrowerB{"borrowerB"}; - - env.fund(XRP(1'000'000), issuer, lender, borrowerA, borrowerB); - env.close(); - - PrettyAsset const asset = xrpIssue(); - auto const vaultDepositAmount = - asset(200'000); // Enough for 2 x 50k loans plus interest/fees - - auto const brokerInfo = createVaultAndBroker( - env, - asset, - lender, - { - .vaultDeposit = vaultDepositAmount.value(), - .debtMax = 0, - .coverRateMin = TenthBips32(20000), // 20% - .coverDeposit = 21'000, - .managementFeeRate = TenthBips16(100), // 0.1% - .coverRateLiquidation = TenthBips32(100000), - }); - auto const brokerKeylet = brokerInfo.brokerKeylet(); - - // Create two identical loans: each 50,000 XRP principal (scaled down to - // avoid funding issues) Total DebtTotal will be ~100,000 XRP (principal - // + interest) Formula will calculate cover as: 100% × (20% × 100,000) = - // 20,000 XRP So we need FLC = 20,000 XRP to be fully consumed by first - // default - auto const principalAmount = Number(50'000); - auto const loanPaymentInterval = 2592000; // 30 days - auto const loanGracePeriod = 604800; // 7 days - - // Create Loan A - auto loanATx = env.jt( - set(borrowerA, brokerKeylet.key, principalAmount), - Sig(sfCounterpartySignature, lender), - kInterestRate(TenthBips32(500)), // 5% - kPaymentTotal(12), - loan::kPaymentInterval(loanPaymentInterval), - loan::kGracePeriod(loanGracePeriod), - Fee(XRP(10))); // Sufficient fee for multi-sig transaction - env(loanATx); - env.close(); - - auto const loanAKeylet = keylet::loan(brokerKeylet.key, 1); - - // Create Loan B - auto loanBTx = env.jt( - set(borrowerB, brokerKeylet.key, principalAmount), - Sig(sfCounterpartySignature, lender), - kInterestRate(TenthBips32(500)), // 5% - kPaymentTotal(12), - loan::kPaymentInterval(loanPaymentInterval), - loan::kGracePeriod(loanGracePeriod), - Fee(XRP(10))); // Sufficient fee for multi-sig transaction - env(loanBTx); - env.close(); - - auto const loanBKeylet = keylet::loan(brokerKeylet.key, 2); - - auto loanASle = env.le(loanAKeylet); - if (!BEAST_EXPECT(loanASle)) - return; - - // Advance time past grace period for both loans to be defaultable - auto const loanANextDue = loanASle->at(sfNextPaymentDueDate); - auto const loanAGrace = loanASle->at(sfGracePeriod); - env.close(std::chrono::seconds{loanANextDue + loanAGrace + 60}); - - env(manage(lender, loanAKeylet.key, tfLoanDefault), Ter(tesSUCCESS)); - env.close(); - - // Verify Loan A is defaulted - loanASle = env.le(loanAKeylet); - if (!BEAST_EXPECT(loanASle)) - return; - BEAST_EXPECT(loanASle->isFlag(lsfLoanDefault)); - BEAST_EXPECT(loanASle->at(sfPaymentRemaining) == 0); - - // Check broker state after first default (from committed ledger) - auto brokerSle = env.le(brokerKeylet); - if (!BEAST_EXPECT(brokerSle)) - return; - auto const afterFirstDebtTotal = brokerSle->at(sfDebtTotal); - auto const afterFirstCoverAvailable = brokerSle->at(sfCoverAvailable); - - // DebtTotal should have decreased by Loan A's debt - BEAST_EXPECT(afterFirstDebtTotal == 50'134); - - // CoverAvailable should have decreased significantly - BEAST_EXPECT(afterFirstCoverAvailable == 946); - - env(manage(lender, loanBKeylet.key, tfLoanDefault), Ter(tesSUCCESS)); - - brokerSle = env.le(brokerKeylet); - if (!BEAST_EXPECT(brokerSle)) - return; - auto const afterSecondDebtTotal = brokerSle->at(sfDebtTotal); - auto const afterSecondCoverAvailable = brokerSle->at(sfCoverAvailable); - - BEAST_EXPECT(afterSecondDebtTotal == 0); - - BEAST_EXPECT(afterSecondCoverAvailable == 0); - } - - void - testYieldTheftRounding(std::uint32_t flags) - { - testcase("Rounding manipulation does not permit yield theft"); - using namespace jtx; - using namespace loan; - - // 1. Setup Environment - Env env(*this, all_); - Account const issuer{"issuer"}; - Account const lender{"lender"}; - Account const borrower{"borrower"}; - - env.fund(XRP(1000), issuer, lender, borrower); - env.close(); - - // 2. Asset Selection - PrettyAsset const iou = issuer["USD"]; - env(trust(lender, iou(100'000'000))); - env(trust(borrower, iou(100'000'000))); - env(pay(issuer, lender, iou(100'000'000))); - env(pay(issuer, borrower, iou(100'000'000))); - env.close(); - - // 3. Create Vault and Broker with High Debt Limit (100M) - auto const brokerInfo = createVaultAndBroker( - env, - iou, - lender, - { - .vaultDeposit = 5'000'000, - .debtMax = Number{100'000'000}, - .coverDeposit = 500'000, - }); - auto const [currentSeq, vaultKeylet] = [&]() { - auto const brokerSle = env.le(keylet::loanBroker(brokerInfo.brokerID)); - if (!BEAST_EXPECT(brokerSle)) - return std::make_tuple(0u, keylet::unchecked(beast::kZero)); - auto const currentSeq = brokerSle->at(sfLoanSequence); - auto const vaultKeylet = keylet::vault(brokerSle->at(sfVaultID)); - return std::make_tuple(currentSeq, vaultKeylet); - }(); - - // 4. Loan Parameters (Attack Vector) - Number const principal = 1'000'000; - TenthBips32 const interestRate = TenthBips32{1}; // 0.001% - std::uint32_t const paymentInterval = 86400; - std::uint32_t const paymentTotal = 3650; - - auto const loanSetFee = Fee(env.current()->fees().base * 2); - env(set(borrower, brokerInfo.brokerID, iou(principal).value(), flags), - Sig(sfCounterpartySignature, lender), - loan::kInterestRate(interestRate), - loan::kPaymentInterval(paymentInterval), - loan::kPaymentTotal(paymentTotal), - Fee(loanSetFee)); - env.close(); - - // --- RETRIEVE OBJECTS & SETUP ATTACK --- - - auto borrowerBalance = [&]() { return env.balance(borrower, iou); }; - auto const borrowerScale = static_cast(borrowerBalance()).exponent(); - - auto const loanKeylet = keylet::loan(brokerInfo.brokerID, currentSeq); - auto const maybePeriodicPayment = [&]() -> std::optional { - auto const loanSle = env.le(loanKeylet); - if (!BEAST_EXPECT(loanSle)) - return std::nullopt; - // Construct Payment - return STAmount{iou, loanSle->at(sfPeriodicPayment)}; - }(); - if (!maybePeriodicPayment) - return; - auto const periodicPayment = *maybePeriodicPayment; - auto const roundedPayment = - roundToScale(periodicPayment, borrowerScale, Number::RoundingMode::Upward); - - // ATTACK: Add dust buffer (1e-9) to force 'excess' logic execution - STAmount const paymentBuffer{iou, Number(1, -9)}; - STAmount const attackPayment = periodicPayment + paymentBuffer; - - auto const maybeInitialVaultAssets = [&]() -> std::optional { - auto const vault = env.le(vaultKeylet); - if (!BEAST_EXPECT(vault)) - return std::nullopt; - return vault->at(sfAssetsTotal); - }(); - if (!maybeInitialVaultAssets) - return; - auto const initialVaultAssets = *maybeInitialVaultAssets; - - // 5. Execution Loop - int yieldTheftCount = 0; - auto previousAssetsTotal = initialVaultAssets; - - for (int i = 0; i < 100; ++i) - { - auto const balanceBefore = borrowerBalance(); - env(pay(borrower, loanKeylet.key, attackPayment, flags)); - env.close(); - auto const borrowerDelta = balanceBefore - borrowerBalance(); - BEAST_EXPECT(borrowerDelta.signum() == roundedPayment.signum()); - - auto const loanSle = env.le(loanKeylet); - if (!BEAST_EXPECT(loanSle)) - break; - auto const updatedPayment = STAmount{iou, loanSle->at(sfPeriodicPayment)}; - BEAST_EXPECT( - (roundToScale(updatedPayment, borrowerScale, Number::RoundingMode::Upward) == - roundedPayment)); - BEAST_EXPECT( - (updatedPayment == periodicPayment) || - (flags == tfLoanOverpayment && i >= 2 && updatedPayment < periodicPayment)); - - auto const currentVaultSle = env.le(vaultKeylet); - if (!BEAST_EXPECT(currentVaultSle)) - break; - - auto const currentAssetsTotal = currentVaultSle->at(sfAssetsTotal); - auto const delta = currentAssetsTotal - previousAssetsTotal; - - BEAST_EXPECT( - (delta == beast::kZero && borrowerDelta <= roundedPayment) || - (delta > beast::kZero && borrowerDelta > roundedPayment)); - - // If tx succeeded but Assets Total didn't change, interest was - // stolen. - if (delta == beast::kZero && borrowerDelta > roundedPayment) - { - yieldTheftCount++; - } - - previousAssetsTotal = currentAssetsTotal; - } - - BEAST_EXPECTS(yieldTheftCount == 0, std::to_string(yieldTheftCount)); - } - - // Tests that vault withdrawals work correctly when the vault has unrealized - // loss from an impaired loan, ensuring the invariant check properly - // accounts for the loss. - void - testWithdrawReflectsUnrealizedLoss(FeatureBitset features) - { - using namespace jtx; - using namespace loan; - using namespace std::chrono_literals; - - testcase("Vault withdraw reflects sfLossUnrealized"); - - // Test constants - static constexpr std::int64_t kInitialFunding = 1'000'000; - static constexpr std::int64_t kLenderInitialIou = 5'000'000; - static constexpr std::int64_t kDepositorInitialIou = 1'000'000; - static constexpr std::int64_t kBorrowerInitialIou = 100'000; - static constexpr std::int64_t kDepositAmount = 5'000; - static constexpr std::int64_t kPrincipalAmount = 99; - static constexpr std::uint64_t kExpectedSharesPerDepositor = 5'000'000'000; - static constexpr std::uint32_t kLocalPaymentInterval = 600; - static constexpr std::uint32_t kLocalPaymentTotal = 2; - - Env env{*this, features}; - - // Setup accounts - Account const issuer{"issuer"}; - Account const lender{"lender"}; - Account const depositorA{"lpA"}; - Account const depositorB{"lpB"}; - Account const borrower{"borrowerA"}; - - env.fund(XRP(kInitialFunding), issuer, lender, depositorA, depositorB, borrower); - env.close(); - - // Setup trust lines - PrettyAsset const iouAsset = issuer[iouCurrency_]; - env(trust(lender, iouAsset(10'000'000))); - env(trust(depositorA, iouAsset(10'000'000))); - env(trust(depositorB, iouAsset(10'000'000))); - env(trust(borrower, iouAsset(10'000'000))); - env.close(); - - // Fund accounts with IOUs - env(pay(issuer, lender, iouAsset(kLenderInitialIou))); - env(pay(issuer, depositorA, iouAsset(kDepositorInitialIou))); - env(pay(issuer, depositorB, iouAsset(kDepositorInitialIou))); - env(pay(issuer, borrower, iouAsset(kBorrowerInitialIou))); - env.close(); - - // Create vault and broker, then add deposits from two depositors - auto const broker = createVaultAndBroker(env, iouAsset, lender); - Vault v{env}; - - env(v.deposit({ - .depositor = depositorA, - .id = broker.vaultKeylet().key, - .amount = iouAsset(kDepositAmount), - }), - Ter(tesSUCCESS)); - env(v.deposit({ - .depositor = depositorB, - .id = broker.vaultKeylet().key, - .amount = iouAsset(kDepositAmount), - }), - Ter(tesSUCCESS)); - env.close(); - - // Create a loan - auto const sleBroker = env.le(keylet::loanBroker(broker.brokerID)); - if (!BEAST_EXPECT(sleBroker)) - return; - - auto const loanKeylet = keylet::loan(broker.brokerID, sleBroker->at(sfLoanSequence)); - - env(set(borrower, broker.brokerID, kPrincipalAmount), - Sig(sfCounterpartySignature, lender), - kPaymentTotal(kLocalPaymentTotal), - kPaymentInterval(kLocalPaymentInterval), - Fee(env.current()->fees().base * 2), - Ter(tesSUCCESS)); - env.close(); - - // Impair the loan to create unrealized loss - env(manage(lender, loanKeylet.key, tfLoanImpair), Ter(tesSUCCESS)); - env.close(); - - // Verify unrealized loss is recorded in the vault - auto const vaultAfterImpair = env.le(broker.vaultKeylet()); - if (!BEAST_EXPECT(vaultAfterImpair)) - return; - - BEAST_EXPECT( - vaultAfterImpair->at(sfLossUnrealized) == broker.asset(kPrincipalAmount).value()); - - // Helper to get share balance for a depositor - auto const shareAsset = vaultAfterImpair->at(sfShareMPTID); - auto const getShareBalance = [&](Account const& depositor) -> std::uint64_t { - auto const token = env.le(keylet::mptoken(shareAsset, depositor.id())); - return token ? token->getFieldU64(sfMPTAmount) : 0; - }; - - // Verify both depositors have equal shares - auto const sharesLpA = getShareBalance(depositorA); - auto const sharesLpB = getShareBalance(depositorB); - BEAST_EXPECT(sharesLpA == kExpectedSharesPerDepositor); - BEAST_EXPECT(sharesLpB == kExpectedSharesPerDepositor); - BEAST_EXPECT(sharesLpA == sharesLpB); - - // Helper to attempt withdrawal - auto const attemptWithdrawShares = [&](Account const& depositor, - std::uint64_t shareAmount, - TER expected) { - STAmount const shareAmt{MPTIssue{shareAsset}, Number(shareAmount)}; - env(v.withdraw( - {.depositor = depositor, .id = broker.vaultKeylet().key, .amount = shareAmt}), - Ter(expected)); - env.close(); - }; - - // Regression test: Both depositors should successfully withdraw despite - // unrealized loss. Previously failed with invariant violation: - // "withdrawal must change vault and destination balance by equal - // amount". This was caused by sharesToAssetsWithdraw rounding down, - // creating a mismatch where vaultDeltaAssets * -1 != destinationDelta - // when unrealized loss exists. - attemptWithdrawShares(depositorA, sharesLpA, tesSUCCESS); - attemptWithdrawShares(depositorB, sharesLpB, tesSUCCESS); - } - - // A residual overpayment can reduce the stored principal by one scale-unit - // *less* than computeOverpaymentComponents predicts, firing the - // "principal change agrees" XRPL_ASSERT_PARTS in doOverpayment: - // - // trackedPrincipalDelta == principalOutstanding - newPrincipalOutstanding - // - // tryOverpayment re-amortizes the loan at the reduced principal, then - // re-derives the theoretical principal from the new periodic payment via - // (P * paymentFactor) / paymentFactor. That round-trip is not exact in - // Number's 19-digit arithmetic; a positive residual pushes the recomputed - // principal a hair above the exact grid point `oldPrincipal - delta`, and - // the Upward rounding in tryOverpayment then bumps it a full scale-unit - // higher. The principal therefore drops by `delta - 1 unit`, not `delta`. - // - // Concrete case (isolated, at the tryOverpayment level): - // A 100 USD loan at the minimum non-zero rate, 3 payments, loanScale -10. - // After one regular payment (principalOutstanding 66.6666666674) a residual overpayment of - // 0.049999998 yields trackedPrincipalDelta 0.048999998 but only reduces the principal by - // 0.0489999979 (newPrincipal 66.6176666695) — short by 1e-10. - // - // With fixCleanup3_2_0, tryOverpayment pins the new principal to the exact, - // on-grid reduction (oldPrincipal - trackedPrincipalDelta) instead of the - // lossy (P*factor)/factor round-trip, so the assertion holds and the - // overpayment applies cleanly. The three "principal change agrees" / - // "interest paid agrees" / "principal payment matches" assertions are - // gated behind the same amendment, so without it they are disabled (the - // server does not abort) and the loan keeps the pre-amendment computation. - // - // The test runs the same scenario under both amendment settings and checks - // the stored principal against a ground-truth value derived independently of - // the loan-state computation under test. - void - testBugOverpaymentPrincipalChange() - { - testcase("bug: doOverpayment asserts 'principal change agrees'"); - - using namespace jtx; - using namespace loan; - using namespace xrpl::detail; - - struct Params - { - TenthBips32 interestRate; - TenthBips16 managementFeeRate; - std::uint32_t paymentTotal; - std::uint32_t paymentInterval; - std::int64_t principal; - Number overpayment; - TenthBips32 overpaymentInterestRate; - TenthBips32 overpaymentFeeRate; - std::optional vaultScale; - }; - - struct Result - { - Number principalOutstanding; // stored principal after the LoanPay - Number expectedNewPrincipal; // ground truth, independent of the fix - Number managementFeeChange; // managementFeeOutstanding after - before - Number unit; // one scale-unit at the loan scale - }; - - auto runScenario = [this](FeatureBitset features, Params const& p) -> Result { - Env env(*this, features); - - Account const issuer{"issuer"}; - Account const lender{"vaultOwner"}; - Account const borrower{"borrower"}; - - env.fund(XRP(1'000'000), issuer, lender, borrower); - env(fset(issuer, asfDefaultRipple)); - env.close(); - - PrettyAsset const iouAsset = issuer["USD"]; - Asset const asset = iouAsset.raw(); - STAmount const iouLimit{asset, Number{9'999'999'999'999'999LL}}; - env(trust(lender, iouLimit)); - env(trust(borrower, iouLimit)); - env(pay(issuer, lender, iouAsset(1'000'000))); - env(pay(issuer, borrower, iouAsset(1'000'000))); - env.close(); - - auto const broker = createVaultAndBroker( - env, - iouAsset, - lender, - {.vaultDeposit = 900'000, - .debtMax = 0, - .managementFeeRate = p.managementFeeRate, - .vaultScale = p.vaultScale}); - - auto const brokerSle = env.le(broker.brokerKeylet()); - BEAST_EXPECT(brokerSle); - auto const loanSequence = brokerSle ? brokerSle->at(sfLoanSequence) : 0; - auto const loanKeylet = keylet::loan(broker.brokerID, loanSequence); - - env(set(borrower, broker.brokerID, Number{p.principal}, tfLoanOverpayment), - Sig(sfCounterpartySignature, lender), - kInterestRate(p.interestRate), - kPaymentTotal(p.paymentTotal), - kPaymentInterval(p.paymentInterval), - kGracePeriod(p.paymentInterval), - kOverpaymentFee(p.overpaymentFeeRate), - kOverpaymentInterestRate(p.overpaymentInterestRate), - Fee(env.current()->fees().base * 2), - Ter(tesSUCCESS)); - env.close(); - - // The single LoanPay below makes one regular payment (the overpayment - // is smaller than one period) and leaves the residual as an - // overpayment. - auto const s = getCurrentState(env, broker, loanKeylet); - auto const periodicRate = loanPeriodicRate(s.interestRate, s.paymentInterval); - auto const onePeriod = computePaymentComponents( - env.current()->rules(), - asset, - s.loanScale, - s.totalValue, - s.principalOutstanding, - s.managementFeeOutstanding, - s.periodicPayment, - periodicRate, - s.paymentRemaining, - p.managementFeeRate); - - // Ground truth: the stored principal must drop by exactly the regular - // payment's principal portion plus the overpayment's principal - // portion. computeOverpaymentComponents depends only on the - // overpayment amount and rates (not on the loan-state computation - // under test), so it is an independent oracle. Both components are - // computed under the same rules as the env so the payment factor - // matches. - auto const overpaymentComponents = computeOverpaymentComponents( - env.current()->rules(), - asset, - s.loanScale, - p.overpayment, - p.overpaymentInterestRate, - p.overpaymentFeeRate, - p.managementFeeRate); - Number const expectedNewPrincipal = s.principalOutstanding - - onePeriod.trackedPrincipalDelta - overpaymentComponents.trackedPrincipalDelta; - - Number const managementFeeBefore = s.managementFeeOutstanding; - - STAmount const payAmount{asset, onePeriod.trackedValueDelta + p.overpayment}; - env(pay(borrower, loanKeylet.key, payAmount), - Txflags(tfLoanOverpayment), - Ter(tesSUCCESS)); - env.close(); - - auto const loanSle = env.le(loanKeylet); - BEAST_EXPECT(loanSle); - - return Result{ - .principalOutstanding = loanSle ? Number{loanSle->at(sfPrincipalOutstanding)} : 0, - .expectedNewPrincipal = expectedNewPrincipal, - .managementFeeChange = - (loanSle ? Number{loanSle->at(sfManagementFeeOutstanding)} : Number{0}) - - managementFeeBefore, - .unit = Number{1, s.loanScale}}; - }; - - // Scenario 1: the original near-zero-rate principal reproduction - // (loanScale -10, no management fee). 0.049999998 is smaller than one - // period, so it stays a residual overpayment. - Params const principalCase{ - .interestRate = TenthBips32{1}, - .managementFeeRate = TenthBips16{0}, - .paymentTotal = 3, - .paymentInterval = 60, - .principal = 100, - .overpayment = Number{49999998, -9}, - .overpaymentInterestRate = TenthBips32{1000}, - .overpaymentFeeRate = TenthBips32{1000}, - .vaultScale = 1}; - - // With fixCleanup3_2_0 the stored principal lands exactly on the - // ground-truth grid point: it is reduced by exactly the overpayment's - // principal portion. This is the key correctness check: if the principal - // pin were removed (even with the assertions still gated off), the lossy - // (P * factor) / factor round-trip would leave the principal one - // scale-unit high and this would fail. - Result const fixed = runScenario(all_, principalCase); - BEAST_EXPECTS( - fixed.principalOutstanding == fixed.expectedNewPrincipal, - "fixed principal " + to_string(fixed.principalOutstanding) + " != expected " + - to_string(fixed.expectedNewPrincipal)); - - // Without the amendment the loan amortizes with the catastrophically - // cancelling near-zero payment factor, so its schedule (and ground truth) - // differ from the fixed case; the gated assertions keep the server from - // aborting and the overpayment still lands exactly on that schedule. - Result const legacy = runScenario(all_ - fixCleanup3_2_0, principalCase); - BEAST_EXPECTS( - legacy.principalOutstanding == legacy.expectedNewPrincipal, - "legacy principal " + to_string(legacy.principalOutstanding) + " != expected " + - to_string(legacy.expectedNewPrincipal)); - - // Scenario 2: a normal-rate loan with a 10% management fee. At a normal - // rate the payment factor is identical across the amendment, so toggling - // fixCleanup3_2_0 isolates the fix. This overpayment (found by search) - // lands on a state where both the principal and the management fee differ - // by one scale-unit between the fixed and legacy paths. - Params const feeCase{ - .interestRate = TenthBips32{10000}, - .managementFeeRate = TenthBips16{10000}, - .paymentTotal = 6, - .paymentInterval = 30u * 24 * 60 * 60, - .principal = 1000, - .overpayment = Number{214367363, -10}, - .overpaymentInterestRate = TenthBips32{0}, - .overpaymentFeeRate = TenthBips32{0}, - .vaultScale = std::nullopt}; - - Result const feeFixed = runScenario(all_, feeCase); - Result const feeLegacy = runScenario(all_ - fixCleanup3_2_0, feeCase); - - // With the fix the principal is the exact reduction; without it the lossy - // (P * factor) / factor round-trip leaves it one scale-unit high. - BEAST_EXPECTS( - feeFixed.principalOutstanding == feeFixed.expectedNewPrincipal, - "fee-case fixed principal " + to_string(feeFixed.principalOutstanding) + - " != expected " + to_string(feeFixed.expectedNewPrincipal)); - BEAST_EXPECTS( - feeLegacy.principalOutstanding == feeLegacy.expectedNewPrincipal + feeLegacy.unit, - "fee-case legacy principal " + to_string(feeLegacy.principalOutstanding) + - " != expected " + to_string(feeLegacy.expectedNewPrincipal + feeLegacy.unit)); - - // Management fee: the overpayment re-amortizes a fee-bearing loan, so the management fee - // outstanding drops. - // - // Unlike the principal that is already at the correct precision, the re-amortized - // management fee is tenthBipsOfValue of the new schedule's gross interest, which depends - // on the recomputed periodic payment. So the expected change below is a pinned constant - // captured from a passing run a magic value only because there is nothing simpler to - // compare against. - // - // At the integration level, toggling the amendment also changes the regular payment's - // rounding so a fixed-vs-legacy comparison cannot isolate the overpayment management-fee - // fix. - BEAST_EXPECT(feeFixed.managementFeeChange == feeLegacy.managementFeeChange); - BEAST_EXPECTS( - (feeFixed.managementFeeChange == Number{-8219709543, -10}), - "fee-case mgmt fee change " + to_string(feeFixed.managementFeeChange)); - } - - // A LoanSet with InterestRate = 1 (0.001% annualized, the minimum non-zero - // rate). At such a near-zero rate the closed-form payment factor - // (1 + r)^n - 1 cancels catastrophically. - // - // Without fixCleanup3_2_0 the resulting amortization is degenerate and the - // LoanSet is rejected with tecPRECISION_LOSS (no loan created). With the - // amendment, computePowerMinusOneHybrid uses a numerically-stable series - // expansion, so the loan is created and the scheduled payments - // (2 * periodicPayment) cover the principal — no economic underpayment - // (yield theft). - // - // The test runs the same LoanSet under both amendment settings and pins the - // exact outcome for each. - void - testLoanSetNearZeroInterestRateSucceeds() - { - testcase("LoanSet near-zero interest rate covers principal"); - - using namespace jtx; - using namespace loan; - - Number const principalRequested{1000}; - - struct Result - { - TER ter = tesSUCCESS; - bool created = false; - std::int32_t loanScale = 0; - Number principal; - Number totalValue; - Number managementFee; - Number periodicPayment; - }; - - auto runScenario = [&](FeatureBitset features, TER expectedTer) -> Result { - Env env(*this, features); - - Account const issuer{"issuer"}; - Account const lender{"vaultOwner"}; - Account const borrower{"borrower"}; - - env.fund(XRP(1'000'000), issuer, lender, borrower); - env(fset(issuer, asfDefaultRipple)); - env.close(); - - PrettyAsset const iouAsset = issuer["USD"]; - STAmount const iouLimit{iouAsset.raw(), Number{9'999'999'999'999'999LL}}; - env(trust(lender, iouLimit)); - env(trust(borrower, iouLimit)); - env(pay(issuer, lender, iouAsset(1'000'000))); - env(pay(issuer, borrower, iouAsset(1'000'000))); - env.close(); - - auto const broker = createVaultAndBroker( - env, - iouAsset, - lender, - {.vaultDeposit = 100'000, .debtMax = 0, .managementFeeRate = TenthBips16{0}}); - - auto const brokerSle = env.le(broker.brokerKeylet()); - BEAST_EXPECT(brokerSle); - auto const loanSequence = brokerSle ? brokerSle->at(sfLoanSequence) : 0; - auto const loanKeylet = keylet::loan(broker.brokerID, loanSequence); - - env(set(borrower, broker.brokerID, principalRequested), - Sig(sfCounterpartySignature, lender), - kInterestRate(TenthBips32{1}), - kPaymentTotal(2), - kPaymentInterval(400), - Fee(env.current()->fees().base * 2), - Ter(expectedTer)); - env.close(); - - Result r; - r.ter = env.ter(); - if (auto const loanSle = env.le(loanKeylet)) - { - r.created = true; - r.loanScale = loanSle->at(sfLoanScale); - r.principal = loanSle->at(sfPrincipalOutstanding); - r.totalValue = loanSle->at(sfTotalValueOutstanding); - r.managementFee = loanSle->at(sfManagementFeeOutstanding); - r.periodicPayment = loanSle->at(sfPeriodicPayment); - } - return r; - }; - - Result const fixed = runScenario(all_, tesSUCCESS); - Result const legacy = runScenario(all_ - fixCleanup3_2_0, tecPRECISION_LOSS); - - // Without the amendment, the catastrophically-cancelling closed-form - // payment factor produces a degenerate amortization that fails - // checkLoanGuards: the LoanSet is rejected with tecPRECISION_LOSS and no - // loan is created. - BEAST_EXPECT(legacy.ter == tecPRECISION_LOSS); - BEAST_EXPECT(!legacy.created); - - // With the amendment the stable series expansion produces a valid loan - // at loanScale -10. - BEAST_EXPECT(fixed.ter == tesSUCCESS); - BEAST_EXPECT(fixed.created); - BEAST_EXPECT(fixed.loanScale == -10); - BEAST_EXPECT(fixed.principal == principalRequested); - BEAST_EXPECT((fixed.totalValue == Number{10000000001903, -10})); - BEAST_EXPECT(fixed.managementFee == beast::kZero); - - // Periodic payment from the numerically-stable series expansion, and the - // scheduled total (2 * periodicPayment) which exceeds the 1000 principal - // — no economic underpayment / yield theft. - BEAST_EXPECT((fixed.periodicPayment == Number{5000000000951293762, -16})); - BEAST_EXPECT((fixed.periodicPayment * 2 == Number{1000000000190258752, -15})); - BEAST_EXPECT(fixed.periodicPayment * 2 > principalRequested); - } - - // An overpayment whose residual amount has more precision than loanScale - // fires the isRounded(asset, overpayment, loanScale) assertion in - // computeOverpaymentComponents (and a downstream "interest paid agrees" - // assertion in doOverpayment). fixCleanup3_2_0 rounds the residual down - // to loanScale before passing it in. The pre-amendment path can't be - // tested here because the assertion fires in Debug builds and aborts - // the test process — see the PR description for context. - void - testBugOverpayUnroundedAmount() - { - testcase("bug: computeOverpaymentComponents isRounded assertion"); - - using namespace jtx; - using namespace loan; - Env env(*this, all_); - - Account const issuer{"issuer"}; - Account const lender{"vaultOwner"}; - Account const borrower{"borrower"}; - - env.fund(XRP(1'000'000), issuer, lender, borrower); - env(fset(issuer, asfDefaultRipple)); - env.close(); - - PrettyAsset const iouAsset = issuer["USD"]; - STAmount const iouLimit{iouAsset.raw(), Number{9'999'999'999'999'999LL}}; - env(trust(lender, iouLimit)); - env(trust(borrower, iouLimit)); - env(pay(issuer, lender, iouAsset(1'000'000))); - env(pay(issuer, borrower, iouAsset(1'000'000))); - env.close(); - - auto const broker = createVaultAndBroker( - env, - iouAsset, - lender, - {.vaultDeposit = 100'000, - .debtMax = 5000, - .managementFeeRate = TenthBips16{1000}, - .vaultScale = 1}); - - auto const sleBroker = env.le(broker.brokerKeylet()); - if (!BEAST_EXPECT(sleBroker)) - return; - auto const loanSequence = sleBroker->at(sfLoanSequence); - auto const loanKeylet = keylet::loan(broker.brokerID, loanSequence); - - using namespace loan; - env(set(borrower, broker.brokerID, Number{1000}, tfLoanOverpayment), - Sig(sfCounterpartySignature, lender), - kInterestRate(TenthBips32{10000}), - kPaymentTotal(12), - kPaymentInterval(60), - kGracePeriod(60), - kOverpaymentFee(TenthBips32{1000}), - kOverpaymentInterestRate(TenthBips32{1000}), - Fee(env.current()->fees().base * 2), - Ter(tesSUCCESS)); - env.close(); - - // periodic * 1.5 at 15-sig-digit precision: 125.000154585042. This - // has too many digits to round cleanly to loanScale=-10, so the - // overpayment residual fails the isRounded check. - STAmount const payAmount{iouAsset.raw(), Number{125'000'154'585'042LL, -12}}; - env(pay(borrower, loanKeylet.key, payAmount), Txflags(tfLoanOverpayment), Ter(tesSUCCESS)); - env.close(); - } - - // Regression for the dual-rounding fix at coarse (integer-MPT) scale. - // - // Loan: P=1, r=50% (50000 tenth-bips), n=3, yearly interval. The - // amortization schedule produces a fractional principal - // (~0.47) which under round-to-nearest collapses to 0 in a single - // step, causing `doPayment`'s strict `>` assertion on principal to - // fire mid-loan. With fixCleanup3_2_0 enabled, principal is rounded - // upward (sticks at 1 across the first two periods) and only clears - // in the final payment. - // - // The test pays one period at a time across three LoanPay - // transactions and verifies the loan completes (paymentRemaining=0) - // with totals matching the loan's economics (1 principal + 2 interest). - void - testIntegerScalePrincipalSticks(FeatureBitset features) - { - // Without fixCleanup3_2_0, this behavior will abort the server, so - // don't run without it. - if (!features[fixCleanup3_2_0]) - return; - - testcase("edge: integer MPT principal stuck mid-loan completes via final"); - - using namespace jtx; - Env env(*this, features); - - Account const issuer{"issuer"}; - Account const lender{"lender"}; - Account const borrower{"borrower"}; - - env.fund(XRP(100'000), issuer, lender, borrower); - env.close(); - - MPTTester mptt{env, issuer, kMptInitNoFund}; - mptt.create({.maxAmt = 100'000, .flags = tfMPTCanTransfer}); - PrettyAsset const asset{mptt.issuanceID()}; - - mptt.authorize({.account = lender}); - mptt.authorize({.account = borrower}); - - env(pay(issuer, lender, asset(10'000))); - env(pay(issuer, borrower, asset(10'000))); - env.close(); - - Vault const vault{env}; - auto [vaultTx, vaultKeylet] = vault.create({.owner = lender, .asset = asset}); - env(vaultTx); - env.close(); - - env(vault.deposit({.depositor = lender, .id = vaultKeylet.key, .amount = asset(5'000)})); - env.close(); - - auto const brokerKeylet = keylet::loanBroker(lender.id(), env.seq(lender)); - env(loanBroker::set(lender, vaultKeylet.key), - loanBroker::kDebtMaximum(Number{100}), - Fee(env.current()->fees().base * 2)); - env.close(); - - auto const brokerStateBefore = env.le(brokerKeylet); - if (!BEAST_EXPECT(brokerStateBefore)) - return; - auto const loanSequence = brokerStateBefore->at(sfLoanSequence); - auto const loanKeylet = keylet::loan(brokerKeylet.key, loanSequence); - - env(loan::set(borrower, brokerKeylet.key, Number{1}), - Sig(sfCounterpartySignature, lender), - loan::kInterestRate(TenthBips32{50'000}), - loan::kPaymentTotal(3), - loan::kPaymentInterval(31'536'000), - Fee(env.current()->fees().base * 2)); - env.close(); - - auto const borrowerStart = env.balance(borrower, asset).value(); - - // Three separate periodic payments of 1 each. Expected per-period - // evolution at integer MPT scale (TVO = PO + interestDue + - // managementFeeDue): - // start: PO=1, TVO=3, paymentRemaining=3 - // after pay #1: PO=1, TVO=2, paymentRemaining=2 (principal sticks) - // after pay #2: PO=1, TVO=1, paymentRemaining=1 (principal sticks) - // after pay #3: PO=0, TVO=0, paymentRemaining=0 (final clears) - std::array const expectedPO{Number{1}, Number{1}, Number{0}}; - std::array const expectedTVO{Number{2}, Number{1}, Number{0}}; - std::array const expectedRemaining{2, 1, 0}; - - for (int i = 0; i < 3; ++i) - { - env(loan::pay(borrower, loanKeylet.key, asset(1)), Ter(tesSUCCESS)); - env.close(); - - auto const sle = env.le(loanKeylet); - if (!BEAST_EXPECT(sle)) - return; - BEAST_EXPECT(sle->at(sfPrincipalOutstanding) == expectedPO[i]); - BEAST_EXPECT(sle->at(sfTotalValueOutstanding) == expectedTVO[i]); - BEAST_EXPECT(sle->at(sfPaymentRemaining) == expectedRemaining[i]); - } - - // Borrower paid 3 total regardless of fee split (1 principal + 2 - // interest+fee, matching loan economics). - auto const borrowerEnd = env.balance(borrower, asset).value(); - BEAST_EXPECT(borrowerStart - borrowerEnd == asset(3).value()); - } - - // A near-zero interest rate on a 100 USD loan - // produces total interest of ~6 units at loanScale -9. Numerical error - // in the amortization formula pushes the theoretical principal above - // the theoretical value, producing a negative theoretical interest. - // The payment delta then exceeds the actual outstanding interest, - // violating XRPL_ASSERT_PARTS in computePaymentComponents. - void - testBugInterestDueDeltaCrash() - { - testcase("bug: LoanPay asserts 'interest due delta' on near-zero rate"); - - using namespace jtx; - using namespace std::chrono_literals; - Env env(*this, all_); - - Account const issuer{"issuer"}; - Account const lender{"lender"}; - Account const borrower{"borrower"}; - - env.fund(XRP(1'000'000), issuer, lender, borrower); - env.close(); - env(fset(issuer, asfDefaultRipple)); - env.close(); - - PrettyAsset const iouAsset = issuer["USD"]; - env(trust(lender, iouAsset(1'000'000'000))); - env(trust(borrower, iouAsset(1'000'000'000))); - env(pay(issuer, lender, iouAsset(5'000'000))); - env(pay(issuer, borrower, iouAsset(5'000'000))); - env.close(); - - BrokerParameters const brokerParams{ - .vaultDeposit = 1'000'000, - .debtMax = 1'000'000, - .coverRateMin = TenthBips32{0}, - .coverDeposit = 0, - .managementFeeRate = TenthBips16{0}, - .coverRateLiquidation = TenthBips32{0}}; - - BrokerInfo const broker{createVaultAndBroker(env, iouAsset, lender, brokerParams)}; - - using namespace loan; - - auto const loanSetFee = Fee(env.current()->fees().base * 2); - Number const principalRequest{100}; - - auto createJson = env.json( - set(borrower, broker.brokerID, principalRequest), - Fee(loanSetFee), - Json(sfCounterpartySignature, json::ValueType::Object)); - - createJson["InterestRate"] = 1; // minimum non-zero rate - createJson["PaymentTotal"] = 3; - createJson["PaymentInterval"] = 600; - - auto const 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(); - - // For principal=100, n=3 the amortization schedule produces a - // periodic payment ≈ 33.33 USD. We pay 35 USD, which is more than - // one period's worth — enough for the LoanPay path to enter - // computePaymentComponents and reach the assertion that fires - // when the bug is present. With the fix, the tx applies cleanly. - env(pay(borrower, keylet.key, iouAsset(35)), Ter(tesSUCCESS)); - env.close(); - } - - // Integration test: full lifecycle of a $1B loan in the bug regime. - // Verifies that the vault collects the economically-correct interest - // income and that conservation holds at the trust-line level. - // - // Pre-fix (closed-form `power(1+r, n) - 1`): vault collected only - // ~$0.058 per $1B due to cancellation of `(1+r)^n - 1` at r*n ~ 5.7e-10. - // Post-fix (hybrid binomial path): vault collects ~$0.38 per $1B, - // matching the value computed independently with arbitrary-precision - // Decimal arithmetic. - void - testFullLifecycleVaultPnLNearZeroRate() - { - testcase("integration: full loan lifecycle, vault interest at near-zero rate"); - - using namespace jtx; - using namespace jtx::loan; - using namespace std::chrono_literals; - Env env(*this, all_); - - Account const issuer{"issuer"}; - Account const lender{"lender"}; - Account const borrower{"borrower"}; - - env.fund(XRP(1'000'000), issuer, lender, borrower); - env.close(); - env(fset(issuer, asfDefaultRipple)); - env.close(); - - PrettyAsset const iouAsset = issuer["USD"]; - STAmount const trustLimit{iouAsset.raw(), Number{1, 17}}; - env(trust(lender, trustLimit)); - env(trust(borrower, trustLimit)); - env.close(); - env(pay(issuer, lender, iouAsset(5'000'000'000LL))); - env(pay(issuer, borrower, iouAsset(5'000'000'000LL))); - env.close(); - - auto usdBalance = [&](Account const& a) { - return env.balance(a, iouAsset.raw().get()).value(); - }; - STAmount const borrowerStartBal = usdBalance(borrower); - - BrokerParameters const brokerParams{ - .vaultDeposit = Number{2, 9}, - .debtMax = Number{0}, - .coverRateMin = TenthBips32{0}, - .coverDeposit = 0, - .managementFeeRate = TenthBips16{0}, - .coverRateLiquidation = TenthBips32{0}}; - BrokerInfo const broker{createVaultAndBroker(env, iouAsset, lender, brokerParams)}; - - auto const vaultBefore = env.le(broker.vaultKeylet()); - BEAST_EXPECT(vaultBefore); - Number const vaultAvailableBefore = vaultBefore->at(sfAssetsAvailable); - - // Loan: $1B principal, 3 payments, 600s interval, rate=1 TenthBips32. - auto const loanSetFee = Fee(env.current()->fees().base * 2); - Number const principalRequest{1, 9}; - auto createJson = env.json( - set(borrower, broker.brokerID, principalRequest), - Fee(loanSetFee), - Json(sfCounterpartySignature, json::ValueType::Object)); - createJson["InterestRate"] = 1; - createJson["PaymentTotal"] = 3; - createJson["PaymentInterval"] = 600; - - auto const brokerStateBefore = env.le(keylet::loanBroker(broker.brokerID)); - auto const loanSequence = brokerStateBefore->at(sfLoanSequence); - auto const loanKeylet = keylet::loan(broker.brokerID, loanSequence); - createJson = env.json(createJson, Sig(sfCounterpartySignature, lender)); - env(createJson, Ter(tesSUCCESS)); - env.close(); - - auto const loanSle = env.le(loanKeylet); - BEAST_EXPECT(loanSle); - Number const expectedTotalInterest = - loanSle->at(sfTotalValueOutstanding) - loanSle->at(sfPrincipalOutstanding); - - env(pay(borrower, loanKeylet.key, iouAsset(1'500'000'000LL)), Ter(tesSUCCESS)); - env.close(); - - auto const vaultAfter = env.le(broker.vaultKeylet()); - Number const vaultAvailableAfter = vaultAfter->at(sfAssetsAvailable); - Number const vaultGain = vaultAvailableAfter - vaultAvailableBefore; - - STAmount const borrowerEndBal = usdBalance(borrower); - STAmount const borrowerNetOut = borrowerStartBal - borrowerEndBal; - - // Self-consistency: vault gained exactly the expected interest - // computed at LoanSet, and the borrower's outflow matches. - BEAST_EXPECT(vaultGain == expectedTotalInterest); - BEAST_EXPECT(Number(borrowerNetOut) == expectedTotalInterest); - - // Mathematical correctness: the total interest for this loan - // configuration is 0.38051750382930729983, calculated - // independently using 50-digit Decimal arithmetic (no - // cancellation possible at that precision). At Number's 19-digit - // mantissa this rounds to 0.38051750382930729 — the literal - // below. The vault's actual gain must agree to within - // sub-microcent precision. - Number const decimalReference{38051750382930729LL, -17}; - Number const tolerance{1, -6}; // 1e-6 USD = sub-microcent - Number const error = abs(vaultGain - decimalReference); - BEAST_EXPECTS( - error < tolerance, - "vault gain " + to_string(vaultGain) + " differs from Decimal reference " + - to_string(decimalReference) + " by " + to_string(error) + " — exceeds tolerance " + - to_string(tolerance)); - } - - // Verify that LoanPay, LoanBrokerCoverWithdraw, and LoanSet all use the - // same vault-scale minimum cover when fixCleanup3_2_0 is enabled. - // Before the amendment, each transactor computed its minimum cover at a - // different precision (loanScale, debtScale, or the raw unrounded - // tenthBipsOfValue), which could lead to inconsistent decisions for the - // same broker state. After the amendment all three use - // minimumBrokerCover at vaultScale. - void - testMinimumBrokerCoverConsistency(FeatureBitset features) - { - using namespace jtx; - using namespace loan; - using namespace loanBroker; - - bool const withAmendment = features[fixCleanup3_2_0]; - - struct Ctx - { - jtx::Account issuer; - jtx::Account lender; - jtx::Account borrower; - jtx::PrettyAsset iou; - BrokerInfo broker; - BrokerParameters brokerParams; - }; - - // Shared setup, parametrized by vaultDeposit (the only varying setup - // field across the three scenarios). Each call runs in its own Env - // so multiple invocations within one scenario cannot interfere. - // The caller is responsible for invoking testcase(...) before the - // first runTest call of each scenario. - auto runTest = [&](Number vaultDeposit, auto&& body) { - Env env(*this, features); - - Account const issuer{"issuer"}; - Account const lender{"lender"}; - Account const borrower{"borrower"}; - - env.fund(XRP(1'000'000'000), issuer, lender, borrower); - env.close(); - - // Enable clawback on the issuer *before* any trust lines exist - // (asfAllowTrustLineClawback requires an empty owner directory). - env(fset(issuer, asfAllowTrustLineClawback)); - env.close(); - - PrettyAsset const iou = issuer[iouCurrency_]; - env(trust(lender, iou(1'000'000'000))); - env(trust(borrower, iou(1'000'000'000))); - env.close(); - env(pay(issuer, lender, iou(100'000'000))); - env(pay(issuer, borrower, iou(100'000'000))); - env.close(); - - // 13.37% — non-round rate produces a messier minimum. - BrokerParameters const brokerParams{ - .vaultDeposit = vaultDeposit, - .debtMax = 0, - .coverRateMin = TenthBips32{13'370}, - .coverDeposit = 5'000, - .managementFeeRate = TenthBips16{500}}; - - BrokerInfo const broker = createVaultAndBroker(env, iou, lender, brokerParams); - - body( - env, - Ctx{.issuer = issuer, - .lender = lender, - .borrower = borrower, - .iou = iou, - .broker = broker, - .brokerParams = brokerParams}); - }; - - // Scenario 1 — LoanPay - // - // Verify that LoanPay's minimum cover check uses vault scale (not - // loan scale). Before the amendment, different loans could produce - // different fee routing decisions for the same broker-level state. - // Small vault deposit => vaultScale = -12. - testcase("LoanPay minimum cover scale consistency"); - { - struct LoanKeylets - { - Keylet tiny; - Keylet big; - }; - - // Create the tiny + big loans and reduce cover via clawback so - // that subsequent LoanPay calls hit the minimum-cover boundary. - // Used by the two pay-and-check sub-tests below so each can run - // in its own Env. - auto setupLoansAndClawback = [&](Env& env, Ctx const& c) -> std::optional { - Asset const asset{c.iou}; - - // Create the TINY loan first (while vaultScale is still - // small). principal 0.01, 0% interest, 1 payment => - // loanScale = vaultScale. - auto const brokerSle1 = env.le(keylet::loanBroker(c.broker.brokerID)); - if (!BEAST_EXPECT(brokerSle1)) - return std::nullopt; - auto const tinyLoanSeq = brokerSle1->at(sfLoanSequence); - auto const tinyLoanKeylet = keylet::loan(c.broker.brokerID, tinyLoanSeq); - - env(set(c.borrower, c.broker.brokerID, Number{1, -2}), - Sig(sfCounterpartySignature, c.lender), - kInterestRate(TenthBips32{0}), - kPaymentTotal(1), - kPaymentInterval(86400 * 365), - Fee(XRP(10))); - env.close(); - - // Create the BIG loan second. 100% annual interest over 20 - // payments pushes totalValueOutstanding high enough that - // loanScale > vaultScale. - auto const brokerSle2 = env.le(keylet::loanBroker(c.broker.brokerID)); - if (!BEAST_EXPECT(brokerSle2)) - return std::nullopt; - auto const bigLoanSeq = brokerSle2->at(sfLoanSequence); - auto const bigLoanKeylet = keylet::loan(c.broker.brokerID, bigLoanSeq); - - env(set(c.borrower, c.broker.brokerID, Number{500}), - Sig(sfCounterpartySignature, c.lender), - kInterestRate(TenthBips32{100'000}), - kPaymentTotal(20), - kPaymentInterval(86400 * 365), - Fee(XRP(10))); - env.close(); - - // The tiny loan's scale is frozen at the vault's pre-big-loan - // scale, so it is strictly smaller than the big loan's. - // After the big loan is created the vault absorbs its value, - // pushing vaultScale up to match bigLoanScale. - auto const tinyLoanSle = env.le(tinyLoanKeylet); - auto const bigLoanSle = env.le(bigLoanKeylet); - auto const vaultSle = env.le(keylet::vault(c.broker.vaultID)); - if (!BEAST_EXPECT(tinyLoanSle) || !BEAST_EXPECT(bigLoanSle) || - !BEAST_EXPECT(vaultSle)) - return std::nullopt; - if (!BEAST_EXPECT(tinyLoanSle->at(sfLoanScale) == -12) || - !BEAST_EXPECT(bigLoanSle->at(sfLoanScale) == -11) || - !BEAST_EXPECT(getAssetsTotalScale(vaultSle) == -11)) - return std::nullopt; - - // Use issuer clawback to reduce cover to the minimum the - // clawback transactor allows. Compute the amount as - // initialCover - expectedCoverAfter so we exercise the exact - // clawback rather than relying on the transactor to clip - // down. - // - // Before the amendment the clawback minimum is the - // *unrounded* tenthBipsOfValue — strictly less than the - // rounded-at-vaultScale minimum LoanPay uses for the big - // loan. After the amendment both clawback and LoanPay use - // the same rounded minimum (via minimumBrokerCover), so - // cover lands exactly at that threshold. - Number const expectedCoverAfter = withAmendment ? Number{1330651855688460000, -15} - : Number{1330651855688458000, -15}; - Number const clawbackAmount = - Number{c.brokerParams.coverDeposit} - expectedCoverAfter; - - env(coverClawback(c.issuer), - kLoanBrokerId(c.broker.brokerID), - kAmount(STAmount{asset, clawbackAmount})); - env.close(); - - auto const brokerSle = env.le(keylet::loanBroker(c.broker.brokerID)); - if (!BEAST_EXPECT(brokerSle) || - !BEAST_EXPECT(brokerSle->at(sfCoverAvailable) == expectedCoverAfter)) - return std::nullopt; - - return LoanKeylets{.tiny = tinyLoanKeylet, .big = bigLoanKeylet}; - }; - - // Pay one loan and report whether the fee went to the broker's - // pseudo account (the fallback when cover < minimum) rather - // than to the owner. - auto feeGoesToPseudo = [&](Env& env, Ctx const& c, Keylet const& loanKeylet) -> bool { - Asset const asset{c.iou}; - auto const brokerSle = env.le(keylet::loanBroker(c.broker.brokerID)); - if (!BEAST_EXPECT(brokerSle)) - return false; - auto const pseudoAcct = Account("pseudo", brokerSle->at(sfAccount)); - auto const pseudoBefore = env.balance(pseudoAcct, c.iou); - - auto const payLoan = env.le(loanKeylet); - if (!BEAST_EXPECT(payLoan)) - return false; - auto const periodicPayment = payLoan->at(sfPeriodicPayment); - auto const serviceFee = payLoan->at(sfLoanServiceFee); - std::int32_t const loanScale = payLoan->at(sfLoanScale); - - auto const payment = roundPeriodicPayment(asset, periodicPayment, loanScale); - auto const payAmt = STAmount{asset, payment + serviceFee}; - - env(loan::pay(c.borrower, loanKeylet.key, payAmt), Fee(XRP(10))); - env.close(); - - auto const pseudoAfter = env.balance(pseudoAcct, c.iou); - return pseudoAfter.number() > pseudoBefore.number(); - }; - - // Pay the BIG loan in its own Env so its outcome cannot affect - // the TINY-loan check. With the fix, LoanPay and clawback use - // the same vaultScale minimum (cover == minAtVaultScale => - // fee to owner). Without the fix, LoanPay uses bigLoanScale=-11, - // rounds up to a larger minimum than what clawback used => - // cover < min => fee to pseudo. - runTest(/*vaultDeposit=*/1'000, [&](Env& env, Ctx const& c) { - auto const loans = setupLoansAndClawback(env, c); - if (!loans) - return; - BEAST_EXPECT(feeGoesToPseudo(env, c, loans->big) == !withAmendment); - }); - - // Pay the TINY loan in its own Env. Fee goes to the owner - // either way: - // - With the fix: LoanPay uses vaultScale=-11 (same as - // clawback) => owner. - // - Without the fix: LoanPay uses tinyLoanScale=-12, rounds - // up at -12 (a no-op) => min == cover => owner. - runTest(/*vaultDeposit=*/1'000, [&](Env& env, Ctx const& c) { - auto const loans = setupLoansAndClawback(env, c); - if (!loans) - return; - BEAST_EXPECT(!feeGoesToPseudo(env, c, loans->tiny)); - }); - } - - // Scenario 2 — LoanBrokerCoverWithdraw - // - // Verify that CoverWithdraw's minimum cover check uses vault scale - // (not scale(debtTotal, asset)). Before the amendment, CoverWithdraw - // used: - // roundToAsset(asset, tenthBipsOfValue(debt, rate), scale(debt, asset)) - // which could disagree with LoanPay's minimum (which used loanScale). - // - // Use a large vault deposit so that vaultScale (from AssetsTotal) is - // strictly larger than debtScale (from DebtTotal). With - // vaultDeposit = 100,000: after the big loan - // AssetsTotal ≈ 109,500 → vaultScale = -10 - // DebtTotal ≈ 10,000 → debtScale = -11 - // The one-order-of-magnitude gap makes roundToAsset at -10 truncate - // more aggressively than at -11, exposing the bug. - testcase("CoverWithdraw minimum cover scale consistency"); - runTest( - /*vaultDeposit=*/100'000, [&](Env& env, Ctx const& c) { - Asset const asset{c.iou}; - - // Create only the big loan to push DebtTotal up to ~10,000 - // while AssetsTotal stays around 109,500 (dominated by the - // large vault deposit). - env(set(c.borrower, c.broker.brokerID, Number{500}), - Sig(sfCounterpartySignature, c.lender), - kInterestRate(TenthBips32{100'000}), - kPaymentTotal(20), - kPaymentInterval(86400 * 365), - Fee(XRP(10))); - env.close(); - - // Read broker state and compute both old and new minimums. - auto const brokerSle = env.le(keylet::loanBroker(c.broker.brokerID)); - auto const vaultSle = env.le(keylet::vault(c.broker.vaultID)); - if (!BEAST_EXPECT(brokerSle) || !BEAST_EXPECT(vaultSle)) - return; - - auto const coverAvail = brokerSle->at(sfCoverAvailable); - auto const debtTotal = brokerSle->at(sfDebtTotal); - auto const vaultScale = getAssetsTotalScale(vaultSle); - auto const debtScale = scale(debtTotal, asset); - - // Sanity: debt scale differs from vault scale for this setup. - BEAST_EXPECT(debtScale < vaultScale); - - auto const oldMin = [&]() { - NumberRoundModeGuard const mg(Number::RoundingMode::Upward); - return roundToAsset( - asset, - tenthBipsOfValue(debtTotal, TenthBips32{c.brokerParams.coverRateMin}), - debtScale); - }(); - auto const newMin = minimumBrokerCover( - debtTotal, TenthBips32{c.brokerParams.coverRateMin}, vaultSle); - - // The new (vaultScale) minimum must be strictly larger than - // the old (debtScale) minimum — that is the gap the amendment - // closes. - Number const expectedNewMin{1330650518688500000, -15}; - Number const expectedOldMin{1330650518688472000, -15}; - BEAST_EXPECT(newMin == expectedNewMin); - BEAST_EXPECT(oldMin == expectedOldMin); - - // Try to withdraw so that remaining cover lands between the - // two minimums: oldMin < target < newMin. - auto const target = oldMin + (newMin - oldMin) / 2; - auto const withdrawAmount = STAmount{asset, coverAvail - target}; - - if (withAmendment) - { - // CoverWithdraw now uses vaultScale: target < newMin - // => FAILS. - env(coverWithdraw(c.lender, c.broker.brokerID, withdrawAmount), - Ter(tecINSUFFICIENT_FUNDS)); - } - else - { - // Old CoverWithdraw uses debtScale: target > oldMin - // => SUCCEEDS. - env(coverWithdraw(c.lender, c.broker.brokerID, withdrawAmount)); - } - env.close(); - }); - - // Scenario 3 — LoanSet - // - // Verify that LoanSet's minimum cover check uses vault scale (not the - // raw unrounded tenthBipsOfValue). Before the amendment, LoanSet - // used tenthBipsOfValue(newDebtTotal, coverRateMinimum) (no - // roundToAsset), while clawback/withdraw used different formulas. - // After the amendment all use minimumBrokerCover at vaultScale, and - // rounding at a coarser scale can absorb a tiny debt increase — - // allowing a loan that would otherwise be rejected. - testcase("LoanSet minimum cover scale consistency"); - runTest( - /*vaultDeposit=*/1'000, [&](Env& env, Ctx const& c) { - // Create the tiny loan (scale -12) AND the big loan (scale - // -11). Both loans are needed so that DebtTotal has a full - // 16-digit mantissa — a "messy" value where roundToAsset at - // vaultScale actually truncates digits and produces a - // different result from the raw tenthBipsOfValue. With only - // the big loan, DebtTotal has ~4 significant digits and - // rounding at scale -11 is a no-op, masking the amendment's - // effect. - env(set(c.borrower, c.broker.brokerID, Number{1, -2}), - Sig(sfCounterpartySignature, c.lender), - kInterestRate(TenthBips32{0}), - kPaymentTotal(1), - kPaymentInterval(86400 * 365), - Fee(XRP(10))); - env.close(); - - env(set(c.borrower, c.broker.brokerID, Number{500}), - Sig(sfCounterpartySignature, c.lender), - kInterestRate(TenthBips32{100'000}), - kPaymentTotal(20), - kPaymentInterval(86400 * 365), - Fee(XRP(10))); - env.close(); - - // Clawback to reduce cover to the clawback transactor's - // minimum. Pass the exact amount rather than relying on the - // transactor to clip down; the setup matches Scenario 1 so - // the same residual-cover values apply. - Number const expectedCoverAfter = withAmendment ? Number{1330651855688460000, -15} - : Number{1330651855688458000, -15}; - Number const clawbackAmount = - Number{c.brokerParams.coverDeposit} - expectedCoverAfter; - env(coverClawback(c.issuer), - kLoanBrokerId(c.broker.brokerID), - kAmount(c.iou(clawbackAmount))); - env.close(); - - // Verify scales. - auto const vaultSle = env.le(keylet::vault(c.broker.vaultID)); - if (!BEAST_EXPECT(vaultSle)) - return; - auto const vaultScale = getAssetsTotalScale(vaultSle); - BEAST_EXPECT(vaultScale == -11); - - // Now try to create a tiny additional loan. Principal is - // 1e-11 (the smallest value that survives the precision - // check at loanScale = vaultScale = -11), with 0% interest - // and 1 payment. - // - // The tiny debt increase adds ~1.337e-12 to the unrounded - // minimum. - // - Without the amendment: the old LoanSet formula rounds - // up during tenthBipsOfValue (16-digit Number - // normalisation), pushing the minimum past the cover left - // by clawback => tecINSUFFICIENT_FUNDS. - // - With the amendment: minimumBrokerCover rounds at - // vaultScale=-11, which absorbs the tiny increase — the - // rounded minimum stays the same => tesSUCCESS. - auto const tinyPrincipal = Number{1, -11}; - - if (withAmendment) - { - env(set(c.borrower, c.broker.brokerID, tinyPrincipal), - Sig(sfCounterpartySignature, c.lender), - kInterestRate(TenthBips32{0}), - kPaymentTotal(1), - kPaymentInterval(86400 * 365), - Fee(XRP(10))); - } - else - { - env(set(c.borrower, c.broker.brokerID, tinyPrincipal), - Sig(sfCounterpartySignature, c.lender), - kInterestRate(TenthBips32{0}), - kPaymentTotal(1), - kPaymentInterval(86400 * 365), - Fee(XRP(10)), - Ter(tecINSUFFICIENT_FUNDS)); - } - env.close(); - }); - } - - // LendingProtocolV1_1 ("cash-basis" accounting) dedicated coverage. - // - // Existing tests never enable featureLendingProtocolV1_1 (see `all_` - // above), so these are the only tests in this file that exercise the - // amendment. They are called once, directly, from - // runAmendmentIndependent() -- not looped through - // runAmendmentSensitive()/amendmentCombinations(), since doing so would - // require re-deriving whole-life-specific expected values for ~15 - // unrelated regression tests. - - // 1. LoanSet origination: Vault.AssetsTotal/LoanBroker.DebtTotal deltas, - // and the AssetsMaximum/DebtMaximum guards (which always check against - // principal + interestDue, regardless of the amendment). - void - testCashBasisLoanSetOrigination() - { - testcase("cash-basis: LoanSet origination"); - - using namespace jtx; - using namespace loan; - - PrettyAsset const xrpAsset{xrpIssue(), 1'000'000}; - BrokerParameters const brokerParams{ - .vaultDeposit = 100'000, - .debtMax = 0, - .coverRateMin = TenthBips32{0}, - .coverDeposit = 0, - .managementFeeRate = TenthBips16{0}, - .coverRateLiquidation = TenthBips32{0}}; - - Number const principalRequest{10'000}; - TenthBips32 const interestRate{percentageToTenthBips(10)}; - std::uint32_t const paymentTotal = 2; - std::uint32_t const paymentInterval = 86400; - - // Creates a broker/vault, submits a single LoanSet with a nonzero - // interest rate, and returns the observed Vault.AssetsTotal / - // LoanBroker.DebtTotal deltas plus the loan's own computed - // interestDue and principalOutstanding. - auto runOrigination = [&](FeatureBitset features) { - Env env(*this, features); - - Account const lender{"lender"}; - Account const borrower{"borrower"}; - env.fund(XRP(1'000'000), lender, borrower); - env.close(); - - BrokerInfo const broker{createVaultAndBroker(env, xrpAsset, lender, brokerParams)}; - - auto const vaultBefore = env.le(broker.vaultKeylet()); - auto const brokerBefore = env.le(broker.brokerKeylet()); - BEAST_EXPECT(vaultBefore && brokerBefore); - Number const assetsTotalBefore = vaultBefore->at(sfAssetsTotal); - Number const debtTotalBefore = brokerBefore->at(sfDebtTotal); - - auto const loanSequence = brokerBefore->at(sfLoanSequence); - auto const loanKeylet = keylet::loan(broker.brokerID, loanSequence); - - env(set(borrower, broker.brokerID, xrpAsset(principalRequest).value()), - kCounterparty(lender), - kInterestRate(interestRate), - kPaymentTotal(paymentTotal), - kPaymentInterval(paymentInterval), - Sig(sfCounterpartySignature, lender), - Fee(env.current()->fees().base * 2), - Ter(tesSUCCESS)); - env.close(); - - auto const loanSle = env.le(loanKeylet); - BEAST_EXPECT(loanSle); - Number const principalOutstanding = loanSle->at(sfPrincipalOutstanding); - Number const totalValueOutstanding = loanSle->at(sfTotalValueOutstanding); - Number const interestDue = totalValueOutstanding - principalOutstanding; - BEAST_EXPECT(interestDue > beast::kZero); - BEAST_EXPECT(principalOutstanding == xrpAsset(principalRequest).value()); - - auto const vaultAfter = env.le(broker.vaultKeylet()); - auto const brokerAfter = env.le(broker.brokerKeylet()); - BEAST_EXPECT(vaultAfter && brokerAfter); - Number const assetsTotalDelta = - Number(vaultAfter->at(sfAssetsTotal)) - assetsTotalBefore; - Number const debtTotalDelta = Number(brokerAfter->at(sfDebtTotal)) - debtTotalBefore; - - return std::make_tuple( - assetsTotalDelta, debtTotalDelta, interestDue, principalOutstanding); - }; - - Number interestDueCash{}; - Number principalOutstandingCash{}; - { - auto const [assetsTotalDelta, debtTotalDelta, interestDue, principalOutstanding] = - runOrigination(all_ | featureLendingProtocolV1_1); - interestDueCash = interestDue; - principalOutstandingCash = principalOutstanding; - - BEAST_EXPECTS( - assetsTotalDelta == beast::kZero, - "cash-basis origination must not change AssetsTotal; delta=" + - to_string(assetsTotalDelta)); - BEAST_EXPECTS( - debtTotalDelta == principalOutstanding, - "cash-basis origination must add principal-only to DebtTotal; delta=" + - to_string(debtTotalDelta) + " principal=" + to_string(principalOutstanding)); - } - - { - auto const [assetsTotalDelta, debtTotalDelta, interestDue, principalOutstanding] = - runOrigination(all_); - - BEAST_EXPECTS( - assetsTotalDelta == interestDue, - "whole-life origination must add interestDue to AssetsTotal; delta=" + - to_string(assetsTotalDelta) + " interestDue=" + to_string(interestDue)); - BEAST_EXPECTS( - debtTotalDelta == principalOutstanding + interestDue, - "whole-life origination must add principal+interest to DebtTotal; delta=" + - to_string(debtTotalDelta)); - } - - // AssetsMaximum guard checks interestDue headroom only under - // whole-life accounting; DebtMaximum guard also varies by model. - auto runVaultGuard = [&](FeatureBitset features, Number const& slack, TER expected) { - Env env(*this, features); - - Account const lender{"lender"}; - Account const borrower{"borrower"}; - env.fund(XRP(1'000'000), lender, borrower); - env.close(); - - BrokerInfo const broker{createVaultAndBroker(env, xrpAsset, lender, brokerParams)}; - - auto const vaultSle = env.le(broker.vaultKeylet()); - BEAST_EXPECT(vaultSle); - Number const assetsTotalBefore = vaultSle->at(sfAssetsTotal); - - Vault const vault{env}; - auto tx = vault.set({.owner = lender, .id = broker.vaultID}); - tx[sfAssetsMaximum] = assetsTotalBefore + slack; - env(tx); - env.close(); - - env(set(borrower, broker.brokerID, xrpAsset(principalRequest).value()), - kCounterparty(lender), - kInterestRate(interestRate), - kPaymentTotal(paymentTotal), - kPaymentInterval(paymentInterval), - Sig(sfCounterpartySignature, lender), - Fee(env.current()->fees().base * 2), - Ter(expected)); - env.close(); - }; - - auto runBrokerGuard = [&](FeatureBitset features, Number const& debtMaximum, TER expected) { - Env env(*this, features); - - Account const lender{"lender"}; - Account const borrower{"borrower"}; - env.fund(XRP(1'000'000), lender, borrower); - env.close(); - - BrokerInfo const broker{createVaultAndBroker(env, xrpAsset, lender, brokerParams)}; - - env(loanBroker::set(lender, broker.vaultID), - loanBroker::kLoanBrokerId(broker.brokerID), - loanBroker::kDebtMaximum(debtMaximum), - Fee(env.current()->fees().base * 2)); - env.close(); - - env(set(borrower, broker.brokerID, xrpAsset(principalRequest).value()), - kCounterparty(lender), - kInterestRate(interestRate), - kPaymentTotal(paymentTotal), - kPaymentInterval(paymentInterval), - Sig(sfCounterpartySignature, lender), - Fee(env.current()->fees().base * 2), - Ter(expected)); - env.close(); - }; - - Number const oneDrop = xrpAsset(1).value(); - { - testcase("whole-life: LoanSet AssetsMaximum guard checks interestDue headroom"); - // Guard rejects when there's not quite enough headroom for the - // interest. - runVaultGuard(all_, interestDueCash - oneDrop, tecLIMIT_EXCEEDED); - // Guard accepts at the exact boundary. - runVaultGuard(all_, interestDueCash, tesSUCCESS); - } - - { - testcase("cash-basis: LoanSet AssetsMaximum guard ignores interestDue headroom"); - // Even far less headroom than interestDue still succeeds, since - // cash-basis origination never adds interest to AssetsTotal. - runVaultGuard(all_ | featureLendingProtocolV1_1, oneDrop, tesSUCCESS); - } - - // DebtMaximum guard: cash-basis projects principal-only DebtTotal; - // whole-life projects principal + interestDue. - for (auto const cashBasis : {true, false}) - { - testcase( - std::string("LoanSet DebtMaximum guard (") + - (cashBasis ? "cash-basis)" : "whole-life)")); - auto const features = cashBasis ? all_ | featureLendingProtocolV1_1 : all_; - Number const newDebtTotal = - principalOutstandingCash + (cashBasis ? Number{} : interestDueCash); - runBrokerGuard(features, newDebtTotal - oneDrop, tecLIMIT_EXCEEDED); - runBrokerGuard(features, newDebtTotal, tesSUCCESS); - } - } - - // 2. LoanPay: regular, late, overpayment, and full-payment types. - // Assert Vault.AssetsTotal/LoanBroker.DebtTotal deltas match - // interestPaid/principalPaid under cash-basis, and cross-check the - // amendment-disabled run's deltas against the documented whole-life - // formula (AssetsTotal += valueChange; DebtTotal mirrors the loan's own - // TotalValueOutstanding delta exactly, since whole-life debt recognition - // tracks total loan value). - void - testCashBasisLoanPay() - { - using namespace jtx; - using namespace loan; - using namespace std::chrono_literals; - using tp = NetClock::time_point; - - PrettyAsset const xrpAsset{xrpIssue(), 1'000'000}; - BrokerParameters const brokerParams{ - .vaultDeposit = 1'000'000, - .debtMax = 0, - .coverRateMin = TenthBips32{0}, - .coverDeposit = 0, - .managementFeeRate = TenthBips16{0}, - .coverRateLiquidation = TenthBips32{0}}; - - Number const principalRequest{12'000}; - TenthBips32 const interestRate{percentageToTenthBips(12)}; - std::uint32_t const paymentTotal = 4; - std::uint32_t const paymentInterval = 600; - std::uint32_t const gracePeriod = 300; - - struct PaymentDeltas - { - Number principalPaid; - Number assetsTotalDelta; - Number debtTotalDelta; - Number totalValueDelta; - }; - - // Sets up a fresh broker + loan, advances time, submits a single - // payment of the given type/amount, and returns the observed deltas. - auto runPayment = [&](FeatureBitset features, - std::uint32_t loanSetFlags, - std::uint32_t payFlags, - std::function const& advanceTime, - std::function const& paymentAmount) { - Env env(*this, features); - - Account const lender{"lender"}; - Account const borrower{"borrower"}; - env.fund(XRP(10'000'000), lender, borrower); - env.close(); - - BrokerInfo const broker{createVaultAndBroker(env, xrpAsset, lender, brokerParams)}; - - LoanParameters const loanParams{ - .account = borrower, - .counter = lender, - .principalRequest = principalRequest, - .interest = interestRate, - .payTotal = paymentTotal, - .payInterval = paymentInterval, - .gracePd = gracePeriod, - .flags = loanSetFlags, - }; - - auto const brokerBeforeLoan = env.le(broker.brokerKeylet()); - BEAST_EXPECT(brokerBeforeLoan); - auto const loanSequence = brokerBeforeLoan->at(sfLoanSequence); - auto const loanKeylet = keylet::loan(broker.brokerID, loanSequence); - - env(loanParams(env, broker)); - env.close(); - - LoanState const state = getCurrentState(env, broker, loanKeylet); - - advanceTime(env, state.startDate); - - auto const vaultBefore = env.le(broker.vaultKeylet()); - auto const brokerBefore = env.le(broker.brokerKeylet()); - auto const loanBefore = env.le(loanKeylet); - BEAST_EXPECT(vaultBefore && brokerBefore && loanBefore); - - Number const principalBefore = loanBefore->at(sfPrincipalOutstanding); - Number const totalValueBefore = loanBefore->at(sfTotalValueOutstanding); - Number const assetsTotalBefore = vaultBefore->at(sfAssetsTotal); - Number const debtTotalBefore = brokerBefore->at(sfDebtTotal); - - STAmount const amount = paymentAmount(state); - env(pay(borrower, loanKeylet.key, amount, payFlags), Ter(tesSUCCESS)); - env.close(); - - auto const vaultAfter = env.le(broker.vaultKeylet()); - auto const brokerAfter = env.le(broker.brokerKeylet()); - auto const loanAfter = env.le(loanKeylet); - BEAST_EXPECT(vaultAfter && brokerAfter && loanAfter); - - Number const principalAfter = loanAfter->at(sfPrincipalOutstanding); - Number const totalValueAfter = loanAfter->at(sfTotalValueOutstanding); - Number const assetsTotalAfter = vaultAfter->at(sfAssetsTotal); - Number const debtTotalAfter = brokerAfter->at(sfDebtTotal); - - return PaymentDeltas{ - .principalPaid = principalBefore - principalAfter, - .assetsTotalDelta = assetsTotalAfter - assetsTotalBefore, - .debtTotalDelta = debtTotalAfter - debtTotalBefore, - .totalValueDelta = totalValueAfter - totalValueBefore}; - }; - - // Compares the disabled (whole-life) and enabled (cash-basis) runs - // of the same payment scenario, and asserts the documented - // relationships between them. - auto checkScenario = [&](std::string const& label, - PaymentDeltas const& off, - PaymentDeltas const& on) { - testcase("cash-basis: LoanPay " + label); - - // The loan's own PrincipalOutstanding field is untouched by - // the amendment. - BEAST_EXPECTS( - off.principalPaid == on.principalPaid, - "principalPaid must be amendment-independent; off=" + to_string(off.principalPaid) + - " on=" + to_string(on.principalPaid)); - - // Whole-life structural invariant: DebtTotal (which - // recognizes a loan's full remaining value as debt) must - // change exactly as the loan's own TotalValueOutstanding - // does. - BEAST_EXPECTS( - off.debtTotalDelta == off.totalValueDelta, - "whole-life DebtTotal delta must mirror TotalValueOutstanding delta; " - "debtTotalDelta=" + - to_string(off.debtTotalDelta) + - " totalValueDelta=" + to_string(off.totalValueDelta)); - - // Derive interestPaid from the whole-life run's independent - // ledger deltas: - // assetsTotalDelta_off == valueChange - // debtTotalDelta_off == valueChange - (principalPaid + interestPaid) - // => interestPaid == assetsTotalDelta_off - debtTotalDelta_off - principalPaid - Number const interestPaid = - off.assetsTotalDelta - off.debtTotalDelta - off.principalPaid; - BEAST_EXPECTS( - interestPaid >= beast::kZero, - "derived interestPaid must be non-negative: " + to_string(interestPaid)); - - BEAST_EXPECTS( - on.assetsTotalDelta == interestPaid, - "cash-basis AssetsTotal delta must equal interestPaid; delta=" + - to_string(on.assetsTotalDelta) + " interestPaid=" + to_string(interestPaid)); - BEAST_EXPECTS( - on.debtTotalDelta == -on.principalPaid, - "cash-basis DebtTotal delta must equal -principalPaid; delta=" + - to_string(on.debtTotalDelta) + " principalPaid=" + to_string(on.principalPaid)); - }; - - // ---- Regular, on-time payment ---- - { - auto const noAdvance = [](Env& env, tp const&) { env.close(); }; - auto const regularAmount = [&](LoanState const& state) { - return STAmount{ - xrpAsset, - roundPeriodicPayment(xrpAsset, state.periodicPayment, state.loanScale) * - Number{3, -1} * 5}; // 1.5x, so only a single period is paid - }; - - auto const off = runPayment(all_, 0, 0, noAdvance, regularAmount); - auto const on = - runPayment(all_ | featureLendingProtocolV1_1, 0, 0, noAdvance, regularAmount); - - // Regular, on-time payments never change the loan's value beyond - // normal amortization (production asserts valueChange == 0), so - // AssetsTotal must be unaffected in the whole-life run. - BEAST_EXPECTS( - off.assetsTotalDelta == beast::kZero, - "regular on-time payment must not change AssetsTotal under whole-life; delta=" + - to_string(off.assetsTotalDelta)); - - checkScenario("regular payment", off, on); - } - - // ---- Late payment ---- - { - auto const advancePastDue = [&](Env& env, tp const& startDate) { - env.close(startDate + std::chrono::seconds(paymentInterval + 1)); - }; - auto const lateAmount = [&](LoanState const& state) { - return STAmount{ - xrpAsset, - roundPeriodicPayment(xrpAsset, state.periodicPayment, state.loanScale) * - Number{3}}; // generous; excess is not withdrawn - }; - - auto const off = runPayment(all_, 0, tfLoanLatePayment, advancePastDue, lateAmount); - auto const on = runPayment( - all_ | featureLendingProtocolV1_1, - 0, - tfLoanLatePayment, - advancePastDue, - lateAmount); - - checkScenario("late payment", off, on); - } - - // ---- Overpayment ---- - { - auto const noAdvance = [](Env& env, tp const&) { env.close(); }; - auto const overpayAmount = [&](LoanState const& state) { - // One regular period, plus a generous extra principal - // paydown. - return STAmount{ - xrpAsset, - roundPeriodicPayment(xrpAsset, state.periodicPayment, state.loanScale) + - xrpAsset(2'000).value()}; - }; - - auto const off = - runPayment(all_, tfLoanOverpayment, tfLoanOverpayment, noAdvance, overpayAmount); - auto const on = runPayment( - all_ | featureLendingProtocolV1_1, - tfLoanOverpayment, - tfLoanOverpayment, - noAdvance, - overpayAmount); - - checkScenario("overpayment", off, on); - } - - // ---- Full payment ---- - { - auto const noAdvance = [](Env& env, tp const&) { env.close(); }; - auto const fullAmount = [&](LoanState const&) { - // Generously large: full payment only ever consumes exactly - // what's due (principal + accrued interest; close fee/ - // prepayment penalty are 0 here), excess is not withdrawn. - return STAmount{xrpAsset, xrpAsset(principalRequest).value() * Number{2}}; - }; - - auto const off = runPayment(all_, 0, tfLoanFullPayment, noAdvance, fullAmount); - auto const on = runPayment( - all_ | featureLendingProtocolV1_1, 0, tfLoanFullPayment, noAdvance, fullAmount); - - checkScenario("full payment", off, on); - } - } - - // 3. LoanManage: impair, unimpair, and default. - void - testCashBasisLoanManage() - { - using namespace jtx; - using namespace loan; - using namespace std::chrono_literals; - - PrettyAsset const xrpAsset{xrpIssue(), 1'000'000}; - BrokerParameters const brokerParams{ - .vaultDeposit = 1'000'000, - .debtMax = 0, - .coverRateMin = TenthBips32{percentageToTenthBips(10)}, - .coverDeposit = 5'000, - .managementFeeRate = TenthBips16{0}, - .coverRateLiquidation = TenthBips32{percentageToTenthBips(25)}}; - - Number const principalRequest{10'000}; - TenthBips32 const interestRate{percentageToTenthBips(12)}; - std::uint32_t const paymentTotal = 4; - std::uint32_t const paymentInterval = 600; - std::uint32_t const gracePeriod = 60; - - auto setupLoan = [&](Env& env) { - Account const lender{"lender"}; - Account const borrower{"borrower"}; - env.fund(XRP(10'000'000), lender, borrower); - env.close(); - - BrokerInfo const broker{createVaultAndBroker(env, xrpAsset, lender, brokerParams)}; - - LoanParameters const loanParams{ - .account = borrower, - .counter = lender, - .principalRequest = principalRequest, - .interest = interestRate, - .payTotal = paymentTotal, - .payInterval = paymentInterval, - .gracePd = gracePeriod, - }; - - auto const brokerBeforeLoan = env.le(broker.brokerKeylet()); - BEAST_EXPECT(brokerBeforeLoan); - auto const loanSequence = brokerBeforeLoan->at(sfLoanSequence); - auto const loanKeylet = keylet::loan(broker.brokerID, loanSequence); - - env(loanParams(env, broker)); - env.close(); - - return std::make_tuple(broker, loanKeylet, lender, borrower); - }; - - // ---- impair / unimpair ---- - auto runImpairUnimpair = [&](FeatureBitset features) { - Env env(*this, features); - auto const [broker, loanKeylet, lender, borrower] = setupLoan(env); - - auto const loanBefore = env.le(loanKeylet); - BEAST_EXPECT(loanBefore); - Number const principalOutstanding = loanBefore->at(sfPrincipalOutstanding); - Number const totalValueOutstanding = loanBefore->at(sfTotalValueOutstanding); - Number const managementFeeOutstanding = loanBefore->at(sfManagementFeeOutstanding); - - Number const expectedExposure = - env.current()->rules().enabled(featureLendingProtocolV1_1) - ? principalOutstanding - : totalValueOutstanding - managementFeeOutstanding; - - auto const vaultBeforeImpair = env.le(broker.vaultKeylet()); - BEAST_EXPECT(vaultBeforeImpair); - Number const lossBefore = vaultBeforeImpair->at(sfLossUnrealized); - - env(manage(lender, loanKeylet.key, tfLoanImpair), Ter(tesSUCCESS)); - env.close(); - - auto const vaultAfterImpair = env.le(broker.vaultKeylet()); - BEAST_EXPECT(vaultAfterImpair); - Number const impairDelta = Number(vaultAfterImpair->at(sfLossUnrealized)) - lossBefore; - - env(manage(lender, loanKeylet.key, tfLoanUnimpair), Ter(tesSUCCESS)); - env.close(); - - auto const vaultAfterUnimpair = env.le(broker.vaultKeylet()); - BEAST_EXPECT(vaultAfterUnimpair); - Number const netDelta = Number(vaultAfterUnimpair->at(sfLossUnrealized)) - lossBefore; - - return std::make_tuple(expectedExposure, impairDelta, netDelta); - }; - - for (auto const features : {all_ | featureLendingProtocolV1_1, all_}) - { - testcase( - std::string("cash-basis: LoanManage impair/unimpair (") + - (features[featureLendingProtocolV1_1] ? "enabled)" : "disabled)")); - auto const [expectedExposure, impairDelta, netDelta] = runImpairUnimpair(features); - - BEAST_EXPECTS( - impairDelta == expectedExposure, - "impair must add loanVaultExposure to LossUnrealized; delta=" + - to_string(impairDelta) + " expected=" + to_string(expectedExposure)); - BEAST_EXPECTS( - netDelta == beast::kZero, - "unimpair must be an exact reversal of impair; net=" + to_string(netDelta)); - } - - // ---- impair, then default ---- - auto runDefault = [&](FeatureBitset features) { - Env env(*this, features); - auto const [broker, loanKeylet, lender, borrower] = setupLoan(env); - - auto const loanBeforeImpair = env.le(loanKeylet); - BEAST_EXPECT(loanBeforeImpair); - Number const principalOutstanding = loanBeforeImpair->at(sfPrincipalOutstanding); - Number const totalValueOutstanding = loanBeforeImpair->at(sfTotalValueOutstanding); - Number const managementFeeOutstanding = - loanBeforeImpair->at(sfManagementFeeOutstanding); - - Number const expectedExposure = - env.current()->rules().enabled(featureLendingProtocolV1_1) - ? principalOutstanding - : totalValueOutstanding - managementFeeOutstanding; - - env(manage(lender, loanKeylet.key, tfLoanImpair), Ter(tesSUCCESS)); - env.close(); - - LoanState const state = getCurrentState(env, broker, loanKeylet); - env.close( - state.startDate + std::chrono::seconds(paymentInterval) + - std::chrono::seconds(gracePeriod) + 60s); - - auto const vaultBefore = env.le(broker.vaultKeylet()); - auto const brokerBefore = env.le(broker.brokerKeylet()); - BEAST_EXPECT(vaultBefore && brokerBefore); - Number const assetsTotalBefore = vaultBefore->at(sfAssetsTotal); - Number const debtTotalBefore = brokerBefore->at(sfDebtTotal); - Number const lossBefore = vaultBefore->at(sfLossUnrealized); - Number const coverAvailableBefore = brokerBefore->at(sfCoverAvailable); - - env(manage(lender, loanKeylet.key, tfLoanDefault), Ter(tesSUCCESS)); - env.close(); - - auto const vaultAfter = env.le(broker.vaultKeylet()); - auto const brokerAfter = env.le(broker.brokerKeylet()); - BEAST_EXPECT(vaultAfter && brokerAfter); - Number const assetsTotalDelta = - Number(vaultAfter->at(sfAssetsTotal)) - assetsTotalBefore; - Number const debtTotalDelta = Number(brokerAfter->at(sfDebtTotal)) - debtTotalBefore; - Number const lossDelta = Number(vaultAfter->at(sfLossUnrealized)) - lossBefore; - Number const coverAvailableDelta = - Number(brokerAfter->at(sfCoverAvailable)) - coverAvailableBefore; - - Number const defaultCovered = -coverAvailableDelta; - Number const vaultDefaultAmount = expectedExposure - defaultCovered; - - return std::make_tuple( - expectedExposure, assetsTotalDelta, debtTotalDelta, lossDelta, vaultDefaultAmount); - }; - - for (auto const features : {all_ | featureLendingProtocolV1_1, all_}) - { - testcase( - std::string("cash-basis: LoanManage default (") + - (features[featureLendingProtocolV1_1] ? "enabled)" : "disabled)")); - auto const - [expectedExposure, - assetsTotalDelta, - debtTotalDelta, - lossDelta, - vaultDefaultAmount] = runDefault(features); - - BEAST_EXPECTS( - debtTotalDelta == -expectedExposure, - "default must reduce DebtTotal by the unified default amount; delta=" + - to_string(debtTotalDelta) + " expected=" + to_string(expectedExposure)); - BEAST_EXPECTS( - lossDelta == -expectedExposure, - "default must reverse the earlier impair's LossUnrealized exactly; delta=" + - to_string(lossDelta) + " expected=" + to_string(expectedExposure)); - BEAST_EXPECTS( - assetsTotalDelta == -vaultDefaultAmount, - "default must reduce AssetsTotal by (defaultAmount - defaultCovered); delta=" + - to_string(assetsTotalDelta) + " expected=" + to_string(-vaultDefaultAmount)); - } - } - - // 3b. LEVersion regression: a Vault created before featureLendingProtocolV1_1 - // activates (LEVersion absent) must keep whole-life (accrual) accounting - // forever, even after the amendment is later enabled -- the switch is - // per-Vault (LEVersion == VaultVersion::CashBasis), not a single global amendment - // flag. - void - testLegacyVaultKeepsAccrualAfterAmendmentEnabled() - { - testcase("LEVersion: legacy vault keeps accrual after amendment enabled"); - - using namespace jtx; - using namespace loan; - using namespace std::chrono_literals; - - PrettyAsset const xrpAsset{xrpIssue(), 1'000'000}; - BrokerParameters const brokerParams{ - .vaultDeposit = 1'000'000, - .debtMax = 0, - .coverRateMin = TenthBips32{percentageToTenthBips(10)}, - .coverDeposit = 5'000, - .managementFeeRate = TenthBips16{0}, - .coverRateLiquidation = TenthBips32{percentageToTenthBips(25)}}; - - Number const principalRequest{10'000}; - TenthBips32 const interestRate{percentageToTenthBips(12)}; - std::uint32_t const paymentTotal = 4; - std::uint32_t const paymentInterval = 600; - std::uint32_t const gracePeriod = 60; - - // Amendment disabled at Vault creation time: LEVersion stays absent. - Env env(*this, all_); - - Account const lender{"lender"}; - Account const borrower{"borrower"}; - env.fund(XRP(10'000'000), lender, borrower); - env.close(); - - BrokerInfo const broker{createVaultAndBroker(env, xrpAsset, lender, brokerParams)}; - - { - auto const vaultSle = env.le(broker.vaultKeylet()); - BEAST_EXPECT(vaultSle); - BEAST_EXPECT(!vaultSle->isFieldPresent(sfLEVersion)); - } - - // Now enable the amendment -- production dispatch must still treat - // this specific Vault as accrual-basis, since its LEVersion is - // (and remains) absent. - env.enableFeature(featureLendingProtocolV1_1); - env.close(); - - LoanParameters const loanParams{ - .account = borrower, - .counter = lender, - .principalRequest = principalRequest, - .interest = interestRate, - .payTotal = paymentTotal, - .payInterval = paymentInterval, - .gracePd = gracePeriod, - }; - - auto const brokerBeforeLoan = env.le(broker.brokerKeylet()); - BEAST_EXPECT(brokerBeforeLoan); - auto const loanSequence = brokerBeforeLoan->at(sfLoanSequence); - auto const loanKeylet = keylet::loan(broker.brokerID, loanSequence); - - // ---- LoanSet origination: whole-life formulas expected ---- - auto const vaultBeforeSet = env.le(broker.vaultKeylet()); - auto const brokerBeforeSet = env.le(broker.brokerKeylet()); - BEAST_EXPECT(vaultBeforeSet && brokerBeforeSet); - Number const assetsTotalBeforeSet = vaultBeforeSet->at(sfAssetsTotal); - Number const debtTotalBeforeSet = brokerBeforeSet->at(sfDebtTotal); - - env(loanParams(env, broker)); - env.close(); - - auto const loanAfterSet = env.le(loanKeylet); - BEAST_EXPECT(loanAfterSet); - Number const principalOutstanding = loanAfterSet->at(sfPrincipalOutstanding); - Number const totalValueOutstanding = loanAfterSet->at(sfTotalValueOutstanding); - Number const interestDue = totalValueOutstanding - principalOutstanding; - BEAST_EXPECT(interestDue > beast::kZero); - - auto const vaultAfterSet = env.le(broker.vaultKeylet()); - auto const brokerAfterSet = env.le(broker.brokerKeylet()); - BEAST_EXPECT(vaultAfterSet && brokerAfterSet); - Number const assetsTotalDeltaSet = - Number(vaultAfterSet->at(sfAssetsTotal)) - assetsTotalBeforeSet; - Number const debtTotalDeltaSet = - Number(brokerAfterSet->at(sfDebtTotal)) - debtTotalBeforeSet; - - BEAST_EXPECTS( - assetsTotalDeltaSet == interestDue, - "legacy vault origination must still add interestDue to AssetsTotal; delta=" + - to_string(assetsTotalDeltaSet) + " interestDue=" + to_string(interestDue)); - BEAST_EXPECTS( - debtTotalDeltaSet == principalOutstanding + interestDue, - "legacy vault origination must still add principal+interest to DebtTotal; delta=" + - to_string(debtTotalDeltaSet)); - - LoanState const state = getCurrentState(env, broker, loanKeylet); - env.close(); - - // ---- LoanPay: whole-life formulas expected ---- - auto const vaultBeforePay = env.le(broker.vaultKeylet()); - auto const brokerBeforePay = env.le(broker.brokerKeylet()); - auto const loanBeforePay = env.le(loanKeylet); - BEAST_EXPECT(vaultBeforePay && brokerBeforePay && loanBeforePay); - Number const totalValueBeforePay = loanBeforePay->at(sfTotalValueOutstanding); - Number const assetsTotalBeforePay = vaultBeforePay->at(sfAssetsTotal); - Number const debtTotalBeforePay = brokerBeforePay->at(sfDebtTotal); - - STAmount const paymentAmount{ - xrpAsset, roundPeriodicPayment(xrpAsset, state.periodicPayment, state.loanScale)}; - env(pay(borrower, loanKeylet.key, paymentAmount), Ter(tesSUCCESS)); - env.close(); - - auto const vaultAfterPay = env.le(broker.vaultKeylet()); - auto const brokerAfterPay = env.le(broker.brokerKeylet()); - auto const loanAfterPay = env.le(loanKeylet); - BEAST_EXPECT(vaultAfterPay && brokerAfterPay && loanAfterPay); - Number const totalValueAfterPay = loanAfterPay->at(sfTotalValueOutstanding); - Number const assetsTotalDeltaPay = - Number(vaultAfterPay->at(sfAssetsTotal)) - assetsTotalBeforePay; - Number const debtTotalDeltaPay = - Number(brokerAfterPay->at(sfDebtTotal)) - debtTotalBeforePay; - Number const totalValueDeltaPay = totalValueAfterPay - totalValueBeforePay; - - // A regular, on-time payment has valueChange == 0, so whole-life - // AssetsTotal is untouched and DebtTotal mirrors TotalValueOutstanding. - BEAST_EXPECTS( - assetsTotalDeltaPay == beast::kZero, - "legacy vault regular payment must not change AssetsTotal; delta=" + - to_string(assetsTotalDeltaPay)); - BEAST_EXPECTS( - debtTotalDeltaPay == totalValueDeltaPay, - "legacy vault DebtTotal delta must mirror TotalValueOutstanding delta; " - "debtTotalDelta=" + - to_string(debtTotalDeltaPay) + " totalValueDelta=" + to_string(totalValueDeltaPay)); - - // ---- LoanManage: impair, then default -- whole-life exposure expected ---- - auto const loanBeforeImpair = env.le(loanKeylet); - BEAST_EXPECT(loanBeforeImpair); - Number const totalValueBeforeImpair = loanBeforeImpair->at(sfTotalValueOutstanding); - Number const managementFeeBeforeImpair = loanBeforeImpair->at(sfManagementFeeOutstanding); - Number const expectedExposure = totalValueBeforeImpair - managementFeeBeforeImpair; - - env(manage(lender, loanKeylet.key, tfLoanImpair), Ter(tesSUCCESS)); - env.close(); - - LoanState const stateAtImpair = getCurrentState(env, broker, loanKeylet); - env.close( - stateAtImpair.startDate + std::chrono::seconds(paymentInterval) + - std::chrono::seconds(gracePeriod) + 60s); - - auto const vaultBeforeDefault = env.le(broker.vaultKeylet()); - auto const brokerBeforeDefault = env.le(broker.brokerKeylet()); - BEAST_EXPECT(vaultBeforeDefault && brokerBeforeDefault); - Number const debtTotalBeforeDefault = brokerBeforeDefault->at(sfDebtTotal); - Number const lossBeforeDefault = vaultBeforeDefault->at(sfLossUnrealized); - - env(manage(lender, loanKeylet.key, tfLoanDefault), Ter(tesSUCCESS)); - env.close(); - - auto const vaultAfterDefault = env.le(broker.vaultKeylet()); - auto const brokerAfterDefault = env.le(broker.brokerKeylet()); - BEAST_EXPECT(vaultAfterDefault && brokerAfterDefault); - Number const debtTotalDeltaDefault = - Number(brokerAfterDefault->at(sfDebtTotal)) - debtTotalBeforeDefault; - Number const lossDeltaDefault = - Number(vaultAfterDefault->at(sfLossUnrealized)) - lossBeforeDefault; - - BEAST_EXPECTS( - debtTotalDeltaDefault == -expectedExposure, - "legacy vault default must reduce DebtTotal by whole-life exposure; delta=" + - to_string(debtTotalDeltaDefault) + " expected=" + to_string(expectedExposure)); - BEAST_EXPECTS( - lossDeltaDefault == -expectedExposure, - "legacy vault default must reverse the earlier impair's LossUnrealized exactly; " - "delta=" + - to_string(lossDeltaDefault) + " expected=" + to_string(expectedExposure)); - - // Confirm the Vault's LEVersion truly never got set, throughout. - { - auto const vaultSle = env.le(broker.vaultKeylet()); - BEAST_EXPECT(vaultSle); - BEAST_EXPECT(!vaultSle->isFieldPresent(sfLEVersion)); - BEAST_EXPECT(getVaultVersion(vaultSle) == VaultVersion::Legacy); - } - } - - // 4. End-to-end trajectory: LoanSet -> 2 LoanPays -> LoanManage(default), - // entirely under the amendment, with independently hand-computed - // expected AssetsTotal/DebtTotal/LossUnrealized/CoverAvailable values at - // each step. 0% interest keeps the arithmetic exact and tractable; the - // divergence from whole-life accounting is already covered directly by - // testCashBasisLoanSetOrigination/LoanPay/LoanManage above, so this test - // focuses purely on an independent, from-scratch trajectory check. - void - testCashBasisEndToEndTrajectory() - { - testcase("cash-basis: end-to-end trajectory"); - - using namespace jtx; - using namespace loan; - using namespace std::chrono_literals; - - PrettyAsset const xrpAsset{xrpIssue(), 1'000'000}; - BrokerParameters const brokerParams{ - .vaultDeposit = 100'000, .managementFeeRate = TenthBips16{0}}; - - Env env(*this, all_ | featureLendingProtocolV1_1); - - Account const lender{"lender"}; - Account const borrower{"borrower"}; - env.fund(XRP(10'000'000), lender, borrower); - env.close(); - - BrokerInfo const broker{createVaultAndBroker(env, xrpAsset, lender, brokerParams)}; - - // Hand computation (all values in XRP, drops == 1e-6 XRP): - // Vault: AssetsTotal starts at 100'000 (the deposit). - // Broker: DebtTotal starts at 0, CoverAvailable starts at 1'000 - // (BrokerParameters::defaults().coverDeposit). - auto const vaultKeylet = broker.vaultKeylet(); - auto const brokerKeylet = broker.brokerKeylet(); - - // All the "human XRP unit" constants below (e.g. `100'000`) are - // converted to raw native (drops) values via xrpAsset(...), since - // that's how the ledger fields are actually denominated. - auto const checkVaultBroker = [&](Number const& assetsTotalUnits, - Number const& debtTotalUnits, - Number const& lossUnrealizedUnits, - Number const& coverAvailableUnits, - char const* step) { - Number const assetsTotal = xrpAsset(assetsTotalUnits).value(); - Number const debtTotal = xrpAsset(debtTotalUnits).value(); - Number const lossUnrealized = xrpAsset(lossUnrealizedUnits).value(); - Number const coverAvailable = xrpAsset(coverAvailableUnits).value(); - - auto const vaultSle = env.le(vaultKeylet); - auto const brokerSle = env.le(brokerKeylet); - BEAST_EXPECT(vaultSle && brokerSle); - BEAST_EXPECTS( - vaultSle->at(sfAssetsTotal) == assetsTotal, - std::string(step) + ": AssetsTotal expected " + to_string(assetsTotal) + " got " + - to_string(Number(vaultSle->at(sfAssetsTotal)))); - BEAST_EXPECTS( - brokerSle->at(sfDebtTotal) == debtTotal, - std::string(step) + ": DebtTotal expected " + to_string(debtTotal) + " got " + - to_string(Number(brokerSle->at(sfDebtTotal)))); - BEAST_EXPECTS( - vaultSle->at(sfLossUnrealized) == lossUnrealized, - std::string(step) + ": LossUnrealized expected " + to_string(lossUnrealized) + - " got " + to_string(Number(vaultSle->at(sfLossUnrealized)))); - BEAST_EXPECTS( - brokerSle->at(sfCoverAvailable) == coverAvailable, - std::string(step) + ": CoverAvailable expected " + to_string(coverAvailable) + - " got " + to_string(Number(brokerSle->at(sfCoverAvailable)))); - }; - - checkVaultBroker(100'000, 0, 0, 1'000, "before LoanSet"); - - // Loan: principal=1200, 0% interest, 12 payments of 100 each, no fees. - Number const principalRequest{1'200}; - std::uint32_t const paymentTotal = 12; - std::uint32_t const paymentInterval = 600; - std::uint32_t const gracePeriod = 60; - - auto const brokerBeforeLoan = env.le(brokerKeylet); - BEAST_EXPECT(brokerBeforeLoan); - auto const loanSequence = brokerBeforeLoan->at(sfLoanSequence); - auto const loanKeylet = keylet::loan(broker.brokerID, loanSequence); - - LoanParameters const loanParams{ - .account = borrower, - .counter = lender, - .principalRequest = principalRequest, - .interest = TenthBips32{0}, - .payTotal = paymentTotal, - .payInterval = paymentInterval, - .gracePd = gracePeriod, - }; - env(loanParams(env, broker)); - env.close(); - - // Origination (cash-basis): AssetsTotal += 0, DebtTotal += principal. - checkVaultBroker(100'000, 1'200, 0, 1'000, "after LoanSet"); - - LoanState const state = getCurrentState(env, broker, loanKeylet); - BEAST_EXPECT(state.periodicPayment == xrpAsset(100).value()); - - // Payment 1: principalPaid=100, interestPaid=0. - // AssetsTotal += 0; DebtTotal -= 100. - env(pay(borrower, loanKeylet.key, xrpAsset(100).value()), Ter(tesSUCCESS)); - env.close(); - checkVaultBroker(100'000, 1'100, 0, 1'000, "after payment 1"); - - // Payment 2: same as above. - env(pay(borrower, loanKeylet.key, xrpAsset(100).value()), Ter(tesSUCCESS)); - env.close(); - checkVaultBroker(100'000, 1'000, 0, 1'000, "after payment 2"); - - // Default (no impair): principalOutstanding remaining is 1'000. - // totalDefaultAmount (cash-basis) = PrincipalOutstanding = 1'000. - // minimumCover = DebtTotal(1'000) * coverRateMin(10%) = 100. - // covered = min(minimumCover * coverRateLiquidation(25%), totalDefaultAmount) - // = min(25, 1'000) = 25. - // defaultCovered = min(covered, CoverAvailable(1'000)) = 25. - // vaultDefaultAmount = 1'000 - 25 = 975. - // DebtTotal -= 1'000 -> 0. CoverAvailable -= 25 -> 975. - // AssetsTotal -= 975 -> 99'025. LossUnrealized unaffected (never impaired). - auto const loanBeforeDefault = env.le(loanKeylet); - BEAST_EXPECT(loanBeforeDefault); - BEAST_EXPECT( - Number(loanBeforeDefault->at(sfPrincipalOutstanding)) == xrpAsset(1'000).value()); - - env.close(state.startDate + std::chrono::seconds((3 * paymentInterval) + gracePeriod) + 1s); - - env(manage(lender, loanKeylet.key, tfLoanDefault), Ter(tesSUCCESS)); - env.close(); - - checkVaultBroker(99'025, 0, 0, 975, "after LoanManage(default)"); - } - - void - runAmendmentIndependent() - { - testDisabled(); - testInvalidLoanSet(); - testInvalidLoanDelete(); - testInvalidLoanManage(); - testInvalidLoanPay(); - testIssuerLoan(); - testServiceFeeOnBrokerDeepFreeze(); - testRequireAuth(); - testRIPD3901(); - testBorrowerIsBroker(); - testLimitExceeded(); - testLendingCanTradeDisabledNoImpact(); - testBugOverpaymentPrincipalChange(); - testBugOverpayUnroundedAmount(); - - for (auto const flags : {0u, tfLoanOverpayment}) - testYieldTheftRounding(flags); - testBugInterestDueDeltaCrash(); - testFullLifecycleVaultPnLNearZeroRate(); - testLoanSetNearZeroInterestRateSucceeds(); - - testCashBasisLoanSetOrigination(); - testCashBasisLoanPay(); - testCashBasisLoanManage(); - testLegacyVaultKeepsAccrualAfterAmendmentEnabled(); - testCashBasisEndToEndTrajectory(); - } - - // Tests run under each entry in amendmentCombinations(). - void - runAmendmentSensitive(FeatureBitset features) - { -#if LOAN_TODO - testLoanPayLateFullPaymentBypassesPenalties(features); - testLoanCoverMinimumRoundingExploit(features); -#endif - // Lifecycle - testLifecycle(features); - testLoanSet(features); - testDosLoanPay(features); - testSelfLoan(features); - - // Payment paths - testWithdrawReflectsUnrealizedLoss(features); - testPoCUnsignedUnderflowOnFullPayAfterEarlyPeriodic(features); - testBatchBypassCounterparty(features); - testLoanNextPaymentDueDateOverflow(features); - testSequentialFLCDepletion(features); - - // Invariants - testLoanPayComputePeriodicPaymentValidRateInvariant(features); - testAccountSendMptMinAmountInvariant(features); - testLoanPayDebtDecreaseInvariant(features); - testWrongMaxDebtBehavior(features); - testLoanPayComputePeriodicPaymentValidTotalInterestInvariant(features); - testLoanPayComputePeriodicPaymentValidTotalPrincipalPaidInvariant(features); - testLoanPayComputePeriodicPaymentValidTotalInterestPaidInvariant(features); - - // RPC - testRPC(features); - - // Edge / rounding - testDustManipulation(features); - testRoundingAllowsUndercoverage(features); - testOverpaymentManagementFee(features); - testIssuerIsBorrower(features); - testIntegerScalePrincipalSticks(features); - testMinimumBrokerCoverConsistency(features); - - // RIPD regressions - testRIPD3831(features); - testRIPD3459(features); - testRIPD3902(features); - - // Broker-owner permissions - testLoanPayBrokerOwnerMissingTrustline(features); - testLoanPayBrokerOwnerUnauthorizedMPT(features); - testLoanPayBrokerOwnerNoPermissionedDomainMPT(features); - testLoanSetBrokerOwnerNoPermissionedDomainMPT(features); - } - -public: - void - run() override - { - runAmendmentIndependent(); - for (auto const& features : jtx::amendmentCombinations( - {fixCleanup3_1_3, fixCleanup3_2_0, featureMPTokensV2}, all_)) - runAmendmentSensitive(features); - } -}; - -class LoanBatch_test : public 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 - auto const assetType = static_cast(assetDist_(engine_)); - auto const principalRequest = principalDist_(engine_); - TenthBips16 const managementFeeRate{managementFeeRateDist_(engine_)}; - auto const serviceFee = serviceFeeDist_(engine_); - TenthBips32 interest{interestRateDist_(engine_)}; - auto const payTotal = paymentTotalDist_(engine_); - auto const payInterval = paymentIntervalDist_(engine_); - - BrokerParameters const brokerParams{ - .vaultDeposit = principalRequest * 10, - .debtMax = 0, - .coverRateMin = TenthBips32{0}, - .managementFeeRate = managementFeeRate}; - LoanParameters const loanParams{ - .account = lender, - .counter = borrower, - .principalRequest = principalRequest, - .serviceFee = serviceFee, - .interest = interest, - .payTotal = payTotal, - .payInterval = payInterval, - }; - - runLoan(assetType, brokerParams, loanParams, all_); - } - -public: - void - run() override - { - auto const numIterations = [s = arg()]() -> int { - int const defaultNum = 5; - if (s.empty()) - return defaultNum; - try - { - std::size_t pos = 0; - auto const r = stoi(s, &pos); - if (pos != s.size()) - return defaultNum; - return r; - } - catch (...) - { - return defaultNum; - } - }(); - - using namespace jtx; - - auto const updateInterval = std::min(numIterations / 5, 100); - - for (int i = 0; i < numIterations; ++i) - { - if (i % updateInterval == 0) - testcase << "Random Loan Test iteration " << (i + 1) << "/" << numIterations; - testRandomLoan(); - } - } -}; - -class LoanArbitrary_test : public LoanBatch_test -{ - void - run() override - { - using namespace jtx; - - BrokerParameters const brokerParams{ - .vaultDeposit = 10000, - .debtMax = 0, - .coverRateMin = TenthBips32{0}, - .managementFeeRate = TenthBips16{0}, - .coverRateLiquidation = TenthBips32{0}}; - LoanParameters const loanParams{ - .account = Account("lender"), - .counter = Account("borrower"), - .principalRequest = Number{200000, -6}, - .interest = TenthBips32{50000}, - .payTotal = 2, - .payInterval = 200}; - - runLoan(AssetType::XRP, brokerParams, loanParams, all_); - } -}; - -BEAST_DEFINE_TESTSUITE(Loan, tx, xrpl); -BEAST_DEFINE_TESTSUITE_MANUAL(LoanBatch, tx, xrpl); -BEAST_DEFINE_TESTSUITE_MANUAL(LoanArbitrary, tx, xrpl); - -} // namespace xrpl::test diff --git a/src/test/app/LendingHelpers_test.cpp b/src/test/app/lending/LendingHelpers_test.cpp similarity index 100% rename from src/test/app/LendingHelpers_test.cpp rename to src/test/app/lending/LendingHelpers_test.cpp diff --git a/src/test/app/LoanBroker_test.cpp b/src/test/app/lending/LoanBroker_test.cpp similarity index 100% rename from src/test/app/LoanBroker_test.cpp rename to src/test/app/lending/LoanBroker_test.cpp diff --git a/src/test/app/lending/LoanCashBasis_test.cpp b/src/test/app/lending/LoanCashBasis_test.cpp new file mode 100644 index 00000000000..7af75c86bdf --- /dev/null +++ b/src/test/app/lending/LoanCashBasis_test.cpp @@ -0,0 +1,1013 @@ +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include + +namespace xrpl::test { + +// LendingProtocolV1_1 ("cash-basis" accounting) dedicated coverage. +// +// Existing tests never enable featureLendingProtocolV1_1 (see `all_` +// above), so these are the only tests in this file that exercise the +// amendment. They are called once, directly, from +// runAmendmentIndependent() -- not looped through +// runAmendmentSensitive()/amendmentCombinations(), since doing so would +// require re-deriving whole-life-specific expected values for ~15 +// unrelated regression tests. +class LoanCashBasis_test : public LoanTestBase +{ +private: + // 1. LoanSet origination: Vault.AssetsTotal/LoanBroker.DebtTotal deltas, + // and the AssetsMaximum/DebtMaximum guards (which always check against + // principal + interestDue, regardless of the amendment). + void + testCashBasisLoanSetOrigination() + { + testcase("cash-basis: LoanSet origination"); + + using namespace jtx; + using namespace loan; + + PrettyAsset const xrpAsset{xrpIssue(), 1'000'000}; + BrokerParameters const brokerParams{ + .vaultDeposit = 100'000, + .debtMax = 0, + .coverRateMin = TenthBips32{0}, + .coverDeposit = 0, + .managementFeeRate = TenthBips16{0}, + .coverRateLiquidation = TenthBips32{0}}; + + Number const principalRequest{10'000}; + TenthBips32 const interestRate{percentageToTenthBips(10)}; + std::uint32_t const paymentTotal = 2; + std::uint32_t const paymentInterval = 86400; + + // Creates a broker/vault, submits a single LoanSet with a nonzero + // interest rate, and returns the observed Vault.AssetsTotal / + // LoanBroker.DebtTotal deltas plus the loan's own computed + // interestDue and principalOutstanding. + auto runOrigination = [&](FeatureBitset features) { + Env env(*this, features); + + Account const lender{"lender"}; + Account const borrower{"borrower"}; + env.fund(XRP(1'000'000), lender, borrower); + env.close(); + + BrokerInfo const broker{createVaultAndBroker(env, xrpAsset, lender, brokerParams)}; + + auto const vaultBefore = env.le(broker.vaultKeylet()); + auto const brokerBefore = env.le(broker.brokerKeylet()); + BEAST_EXPECT(vaultBefore && brokerBefore); + Number const assetsTotalBefore = vaultBefore->at(sfAssetsTotal); + Number const debtTotalBefore = brokerBefore->at(sfDebtTotal); + + auto const loanSequence = brokerBefore->at(sfLoanSequence); + auto const loanKeylet = keylet::loan(broker.brokerID, loanSequence); + + env(set(borrower, broker.brokerID, xrpAsset(principalRequest).value()), + kCounterparty(lender), + kInterestRate(interestRate), + kPaymentTotal(paymentTotal), + kPaymentInterval(paymentInterval), + Sig(sfCounterpartySignature, lender), + Fee(env.current()->fees().base * 2), + Ter(tesSUCCESS)); + env.close(); + + auto const loanSle = env.le(loanKeylet); + BEAST_EXPECT(loanSle); + Number const principalOutstanding = loanSle->at(sfPrincipalOutstanding); + Number const totalValueOutstanding = loanSle->at(sfTotalValueOutstanding); + Number const interestDue = totalValueOutstanding - principalOutstanding; + BEAST_EXPECT(interestDue > beast::kZero); + BEAST_EXPECT(principalOutstanding == xrpAsset(principalRequest).value()); + + auto const vaultAfter = env.le(broker.vaultKeylet()); + auto const brokerAfter = env.le(broker.brokerKeylet()); + BEAST_EXPECT(vaultAfter && brokerAfter); + Number const assetsTotalDelta = + Number(vaultAfter->at(sfAssetsTotal)) - assetsTotalBefore; + Number const debtTotalDelta = Number(brokerAfter->at(sfDebtTotal)) - debtTotalBefore; + + return std::make_tuple( + assetsTotalDelta, debtTotalDelta, interestDue, principalOutstanding); + }; + + Number interestDueCash{}; + Number principalOutstandingCash{}; + { + auto const [assetsTotalDelta, debtTotalDelta, interestDue, principalOutstanding] = + runOrigination(all_ | featureLendingProtocolV1_1); + interestDueCash = interestDue; + principalOutstandingCash = principalOutstanding; + + BEAST_EXPECTS( + assetsTotalDelta == beast::kZero, + "cash-basis origination must not change AssetsTotal; delta=" + + to_string(assetsTotalDelta)); + BEAST_EXPECTS( + debtTotalDelta == principalOutstanding, + "cash-basis origination must add principal-only to DebtTotal; delta=" + + to_string(debtTotalDelta) + " principal=" + to_string(principalOutstanding)); + } + + { + auto const [assetsTotalDelta, debtTotalDelta, interestDue, principalOutstanding] = + runOrigination(all_); + + BEAST_EXPECTS( + assetsTotalDelta == interestDue, + "whole-life origination must add interestDue to AssetsTotal; delta=" + + to_string(assetsTotalDelta) + " interestDue=" + to_string(interestDue)); + BEAST_EXPECTS( + debtTotalDelta == principalOutstanding + interestDue, + "whole-life origination must add principal+interest to DebtTotal; delta=" + + to_string(debtTotalDelta)); + } + + // AssetsMaximum guard checks interestDue headroom only under + // whole-life accounting; DebtMaximum guard also varies by model. + auto runVaultGuard = [&](FeatureBitset features, Number const& slack, TER expected) { + Env env(*this, features); + + Account const lender{"lender"}; + Account const borrower{"borrower"}; + env.fund(XRP(1'000'000), lender, borrower); + env.close(); + + BrokerInfo const broker{createVaultAndBroker(env, xrpAsset, lender, brokerParams)}; + + auto const vaultSle = env.le(broker.vaultKeylet()); + BEAST_EXPECT(vaultSle); + Number const assetsTotalBefore = vaultSle->at(sfAssetsTotal); + + Vault const vault{env}; + auto tx = vault.set({.owner = lender, .id = broker.vaultID}); + tx[sfAssetsMaximum] = assetsTotalBefore + slack; + env(tx); + env.close(); + + env(set(borrower, broker.brokerID, xrpAsset(principalRequest).value()), + kCounterparty(lender), + kInterestRate(interestRate), + kPaymentTotal(paymentTotal), + kPaymentInterval(paymentInterval), + Sig(sfCounterpartySignature, lender), + Fee(env.current()->fees().base * 2), + Ter(expected)); + env.close(); + }; + + auto runBrokerGuard = [&](FeatureBitset features, Number const& debtMaximum, TER expected) { + Env env(*this, features); + + Account const lender{"lender"}; + Account const borrower{"borrower"}; + env.fund(XRP(1'000'000), lender, borrower); + env.close(); + + BrokerInfo const broker{createVaultAndBroker(env, xrpAsset, lender, brokerParams)}; + + env(loanBroker::set(lender, broker.vaultID), + loanBroker::kLoanBrokerId(broker.brokerID), + loanBroker::kDebtMaximum(debtMaximum), + Fee(env.current()->fees().base * 2)); + env.close(); + + env(set(borrower, broker.brokerID, xrpAsset(principalRequest).value()), + kCounterparty(lender), + kInterestRate(interestRate), + kPaymentTotal(paymentTotal), + kPaymentInterval(paymentInterval), + Sig(sfCounterpartySignature, lender), + Fee(env.current()->fees().base * 2), + Ter(expected)); + env.close(); + }; + + Number const oneDrop = xrpAsset(1).value(); + { + testcase("whole-life: LoanSet AssetsMaximum guard checks interestDue headroom"); + // Guard rejects when there's not quite enough headroom for the + // interest. + runVaultGuard(all_, interestDueCash - oneDrop, tecLIMIT_EXCEEDED); + // Guard accepts at the exact boundary. + runVaultGuard(all_, interestDueCash, tesSUCCESS); + } + + { + testcase("cash-basis: LoanSet AssetsMaximum guard ignores interestDue headroom"); + // Even far less headroom than interestDue still succeeds, since + // cash-basis origination never adds interest to AssetsTotal. + runVaultGuard(all_ | featureLendingProtocolV1_1, oneDrop, tesSUCCESS); + } + + // DebtMaximum guard: cash-basis projects principal-only DebtTotal; + // whole-life projects principal + interestDue. + for (auto const cashBasis : {true, false}) + { + testcase( + std::string("LoanSet DebtMaximum guard (") + + (cashBasis ? "cash-basis)" : "whole-life)")); + auto const features = cashBasis ? all_ | featureLendingProtocolV1_1 : all_; + Number const newDebtTotal = + principalOutstandingCash + (cashBasis ? Number{} : interestDueCash); + runBrokerGuard(features, newDebtTotal - oneDrop, tecLIMIT_EXCEEDED); + runBrokerGuard(features, newDebtTotal, tesSUCCESS); + } + } + + // 2. LoanPay: regular, late, overpayment, and full-payment types. + // Assert Vault.AssetsTotal/LoanBroker.DebtTotal deltas match + // interestPaid/principalPaid under cash-basis, and cross-check the + // amendment-disabled run's deltas against the documented whole-life + // formula (AssetsTotal += valueChange; DebtTotal mirrors the loan's own + // TotalValueOutstanding delta exactly, since whole-life debt recognition + // tracks total loan value). + void + testCashBasisLoanPay() + { + using namespace jtx; + using namespace loan; + using namespace std::chrono_literals; + using tp = NetClock::time_point; + + PrettyAsset const xrpAsset{xrpIssue(), 1'000'000}; + BrokerParameters const brokerParams{ + .vaultDeposit = 1'000'000, + .debtMax = 0, + .coverRateMin = TenthBips32{0}, + .coverDeposit = 0, + .managementFeeRate = TenthBips16{0}, + .coverRateLiquidation = TenthBips32{0}}; + + Number const principalRequest{12'000}; + TenthBips32 const interestRate{percentageToTenthBips(12)}; + std::uint32_t const paymentTotal = 4; + std::uint32_t const paymentInterval = 600; + std::uint32_t const gracePeriod = 300; + + struct PaymentDeltas + { + Number principalPaid; + Number assetsTotalDelta; + Number debtTotalDelta; + Number totalValueDelta; + }; + + // Sets up a fresh broker + loan, advances time, submits a single + // payment of the given type/amount, and returns the observed deltas. + auto runPayment = [&](FeatureBitset features, + std::uint32_t loanSetFlags, + std::uint32_t payFlags, + std::function const& advanceTime, + std::function const& paymentAmount) { + Env env(*this, features); + + Account const lender{"lender"}; + Account const borrower{"borrower"}; + env.fund(XRP(10'000'000), lender, borrower); + env.close(); + + BrokerInfo const broker{createVaultAndBroker(env, xrpAsset, lender, brokerParams)}; + + LoanParameters const loanParams{ + .account = borrower, + .counter = lender, + .principalRequest = principalRequest, + .interest = interestRate, + .payTotal = paymentTotal, + .payInterval = paymentInterval, + .gracePd = gracePeriod, + .flags = loanSetFlags, + }; + + auto const brokerBeforeLoan = env.le(broker.brokerKeylet()); + BEAST_EXPECT(brokerBeforeLoan); + auto const loanSequence = brokerBeforeLoan->at(sfLoanSequence); + auto const loanKeylet = keylet::loan(broker.brokerID, loanSequence); + + env(loanParams(env, broker)); + env.close(); + + LoanState const state = getCurrentState(env, broker, loanKeylet); + + advanceTime(env, state.startDate); + + auto const vaultBefore = env.le(broker.vaultKeylet()); + auto const brokerBefore = env.le(broker.brokerKeylet()); + auto const loanBefore = env.le(loanKeylet); + BEAST_EXPECT(vaultBefore && brokerBefore && loanBefore); + + Number const principalBefore = loanBefore->at(sfPrincipalOutstanding); + Number const totalValueBefore = loanBefore->at(sfTotalValueOutstanding); + Number const assetsTotalBefore = vaultBefore->at(sfAssetsTotal); + Number const debtTotalBefore = brokerBefore->at(sfDebtTotal); + + STAmount const amount = paymentAmount(state); + env(pay(borrower, loanKeylet.key, amount, payFlags), Ter(tesSUCCESS)); + env.close(); + + auto const vaultAfter = env.le(broker.vaultKeylet()); + auto const brokerAfter = env.le(broker.brokerKeylet()); + auto const loanAfter = env.le(loanKeylet); + BEAST_EXPECT(vaultAfter && brokerAfter && loanAfter); + + Number const principalAfter = loanAfter->at(sfPrincipalOutstanding); + Number const totalValueAfter = loanAfter->at(sfTotalValueOutstanding); + Number const assetsTotalAfter = vaultAfter->at(sfAssetsTotal); + Number const debtTotalAfter = brokerAfter->at(sfDebtTotal); + + return PaymentDeltas{ + .principalPaid = principalBefore - principalAfter, + .assetsTotalDelta = assetsTotalAfter - assetsTotalBefore, + .debtTotalDelta = debtTotalAfter - debtTotalBefore, + .totalValueDelta = totalValueAfter - totalValueBefore}; + }; + + // Compares the disabled (whole-life) and enabled (cash-basis) runs + // of the same payment scenario, and asserts the documented + // relationships between them. + auto checkScenario = [&](std::string const& label, + PaymentDeltas const& off, + PaymentDeltas const& on) { + testcase("cash-basis: LoanPay " + label); + + // The loan's own PrincipalOutstanding field is untouched by + // the amendment. + BEAST_EXPECTS( + off.principalPaid == on.principalPaid, + "principalPaid must be amendment-independent; off=" + to_string(off.principalPaid) + + " on=" + to_string(on.principalPaid)); + + // Whole-life structural invariant: DebtTotal (which + // recognizes a loan's full remaining value as debt) must + // change exactly as the loan's own TotalValueOutstanding + // does. + BEAST_EXPECTS( + off.debtTotalDelta == off.totalValueDelta, + "whole-life DebtTotal delta must mirror TotalValueOutstanding delta; " + "debtTotalDelta=" + + to_string(off.debtTotalDelta) + + " totalValueDelta=" + to_string(off.totalValueDelta)); + + // Derive interestPaid from the whole-life run's independent + // ledger deltas: + // assetsTotalDelta_off == valueChange + // debtTotalDelta_off == valueChange - (principalPaid + interestPaid) + // => interestPaid == assetsTotalDelta_off - debtTotalDelta_off - principalPaid + Number const interestPaid = + off.assetsTotalDelta - off.debtTotalDelta - off.principalPaid; + BEAST_EXPECTS( + interestPaid >= beast::kZero, + "derived interestPaid must be non-negative: " + to_string(interestPaid)); + + BEAST_EXPECTS( + on.assetsTotalDelta == interestPaid, + "cash-basis AssetsTotal delta must equal interestPaid; delta=" + + to_string(on.assetsTotalDelta) + " interestPaid=" + to_string(interestPaid)); + BEAST_EXPECTS( + on.debtTotalDelta == -on.principalPaid, + "cash-basis DebtTotal delta must equal -principalPaid; delta=" + + to_string(on.debtTotalDelta) + " principalPaid=" + to_string(on.principalPaid)); + }; + + // ---- Regular, on-time payment ---- + { + auto const noAdvance = [](Env& env, tp const&) { env.close(); }; + auto const regularAmount = [&](LoanState const& state) { + return STAmount{ + xrpAsset, + roundPeriodicPayment(xrpAsset, state.periodicPayment, state.loanScale) * + Number{3, -1} * 5}; // 1.5x, so only a single period is paid + }; + + auto const off = runPayment(all_, 0, 0, noAdvance, regularAmount); + auto const on = + runPayment(all_ | featureLendingProtocolV1_1, 0, 0, noAdvance, regularAmount); + + // Regular, on-time payments never change the loan's value beyond + // normal amortization (production asserts valueChange == 0), so + // AssetsTotal must be unaffected in the whole-life run. + BEAST_EXPECTS( + off.assetsTotalDelta == beast::kZero, + "regular on-time payment must not change AssetsTotal under whole-life; delta=" + + to_string(off.assetsTotalDelta)); + + checkScenario("regular payment", off, on); + } + + // ---- Late payment ---- + { + auto const advancePastDue = [&](Env& env, tp const& startDate) { + env.close(startDate + std::chrono::seconds(paymentInterval + 1)); + }; + auto const lateAmount = [&](LoanState const& state) { + return STAmount{ + xrpAsset, + roundPeriodicPayment(xrpAsset, state.periodicPayment, state.loanScale) * + Number{3}}; // generous; excess is not withdrawn + }; + + auto const off = runPayment(all_, 0, tfLoanLatePayment, advancePastDue, lateAmount); + auto const on = runPayment( + all_ | featureLendingProtocolV1_1, + 0, + tfLoanLatePayment, + advancePastDue, + lateAmount); + + checkScenario("late payment", off, on); + } + + // ---- Overpayment ---- + { + auto const noAdvance = [](Env& env, tp const&) { env.close(); }; + auto const overpayAmount = [&](LoanState const& state) { + // One regular period, plus a generous extra principal + // paydown. + return STAmount{ + xrpAsset, + roundPeriodicPayment(xrpAsset, state.periodicPayment, state.loanScale) + + xrpAsset(2'000).value()}; + }; + + auto const off = + runPayment(all_, tfLoanOverpayment, tfLoanOverpayment, noAdvance, overpayAmount); + auto const on = runPayment( + all_ | featureLendingProtocolV1_1, + tfLoanOverpayment, + tfLoanOverpayment, + noAdvance, + overpayAmount); + + checkScenario("overpayment", off, on); + } + + // ---- Full payment ---- + { + auto const noAdvance = [](Env& env, tp const&) { env.close(); }; + auto const fullAmount = [&](LoanState const&) { + // Generously large: full payment only ever consumes exactly + // what's due (principal + accrued interest; close fee/ + // prepayment penalty are 0 here), excess is not withdrawn. + return STAmount{xrpAsset, xrpAsset(principalRequest).value() * Number{2}}; + }; + + auto const off = runPayment(all_, 0, tfLoanFullPayment, noAdvance, fullAmount); + auto const on = runPayment( + all_ | featureLendingProtocolV1_1, 0, tfLoanFullPayment, noAdvance, fullAmount); + + checkScenario("full payment", off, on); + } + } + + // 3. LoanManage: impair, unimpair, and default. + void + testCashBasisLoanManage() + { + using namespace jtx; + using namespace loan; + using namespace std::chrono_literals; + + PrettyAsset const xrpAsset{xrpIssue(), 1'000'000}; + BrokerParameters const brokerParams{ + .vaultDeposit = 1'000'000, + .debtMax = 0, + .coverRateMin = TenthBips32{percentageToTenthBips(10)}, + .coverDeposit = 5'000, + .managementFeeRate = TenthBips16{0}, + .coverRateLiquidation = TenthBips32{percentageToTenthBips(25)}}; + + Number const principalRequest{10'000}; + TenthBips32 const interestRate{percentageToTenthBips(12)}; + std::uint32_t const paymentTotal = 4; + std::uint32_t const paymentInterval = 600; + std::uint32_t const gracePeriod = 60; + + auto setupLoan = [&](Env& env) { + Account const lender{"lender"}; + Account const borrower{"borrower"}; + env.fund(XRP(10'000'000), lender, borrower); + env.close(); + + BrokerInfo const broker{createVaultAndBroker(env, xrpAsset, lender, brokerParams)}; + + LoanParameters const loanParams{ + .account = borrower, + .counter = lender, + .principalRequest = principalRequest, + .interest = interestRate, + .payTotal = paymentTotal, + .payInterval = paymentInterval, + .gracePd = gracePeriod, + }; + + auto const brokerBeforeLoan = env.le(broker.brokerKeylet()); + BEAST_EXPECT(brokerBeforeLoan); + auto const loanSequence = brokerBeforeLoan->at(sfLoanSequence); + auto const loanKeylet = keylet::loan(broker.brokerID, loanSequence); + + env(loanParams(env, broker)); + env.close(); + + return std::make_tuple(broker, loanKeylet, lender, borrower); + }; + + // ---- impair / unimpair ---- + auto runImpairUnimpair = [&](FeatureBitset features) { + Env env(*this, features); + auto const [broker, loanKeylet, lender, borrower] = setupLoan(env); + + auto const loanBefore = env.le(loanKeylet); + BEAST_EXPECT(loanBefore); + Number const principalOutstanding = loanBefore->at(sfPrincipalOutstanding); + Number const totalValueOutstanding = loanBefore->at(sfTotalValueOutstanding); + Number const managementFeeOutstanding = loanBefore->at(sfManagementFeeOutstanding); + + Number const expectedExposure = + env.current()->rules().enabled(featureLendingProtocolV1_1) + ? principalOutstanding + : totalValueOutstanding - managementFeeOutstanding; + + auto const vaultBeforeImpair = env.le(broker.vaultKeylet()); + BEAST_EXPECT(vaultBeforeImpair); + Number const lossBefore = vaultBeforeImpair->at(sfLossUnrealized); + + env(manage(lender, loanKeylet.key, tfLoanImpair), Ter(tesSUCCESS)); + env.close(); + + auto const vaultAfterImpair = env.le(broker.vaultKeylet()); + BEAST_EXPECT(vaultAfterImpair); + Number const impairDelta = Number(vaultAfterImpair->at(sfLossUnrealized)) - lossBefore; + + env(manage(lender, loanKeylet.key, tfLoanUnimpair), Ter(tesSUCCESS)); + env.close(); + + auto const vaultAfterUnimpair = env.le(broker.vaultKeylet()); + BEAST_EXPECT(vaultAfterUnimpair); + Number const netDelta = Number(vaultAfterUnimpair->at(sfLossUnrealized)) - lossBefore; + + return std::make_tuple(expectedExposure, impairDelta, netDelta); + }; + + for (auto const features : {all_ | featureLendingProtocolV1_1, all_}) + { + testcase( + std::string("cash-basis: LoanManage impair/unimpair (") + + (features[featureLendingProtocolV1_1] ? "enabled)" : "disabled)")); + auto const [expectedExposure, impairDelta, netDelta] = runImpairUnimpair(features); + + BEAST_EXPECTS( + impairDelta == expectedExposure, + "impair must add loanVaultExposure to LossUnrealized; delta=" + + to_string(impairDelta) + " expected=" + to_string(expectedExposure)); + BEAST_EXPECTS( + netDelta == beast::kZero, + "unimpair must be an exact reversal of impair; net=" + to_string(netDelta)); + } + + // ---- impair, then default ---- + auto runDefault = [&](FeatureBitset features) { + Env env(*this, features); + auto const [broker, loanKeylet, lender, borrower] = setupLoan(env); + + auto const loanBeforeImpair = env.le(loanKeylet); + BEAST_EXPECT(loanBeforeImpair); + Number const principalOutstanding = loanBeforeImpair->at(sfPrincipalOutstanding); + Number const totalValueOutstanding = loanBeforeImpair->at(sfTotalValueOutstanding); + Number const managementFeeOutstanding = + loanBeforeImpair->at(sfManagementFeeOutstanding); + + Number const expectedExposure = + env.current()->rules().enabled(featureLendingProtocolV1_1) + ? principalOutstanding + : totalValueOutstanding - managementFeeOutstanding; + + env(manage(lender, loanKeylet.key, tfLoanImpair), Ter(tesSUCCESS)); + env.close(); + + LoanState const state = getCurrentState(env, broker, loanKeylet); + env.close( + state.startDate + std::chrono::seconds(paymentInterval) + + std::chrono::seconds(gracePeriod) + 60s); + + auto const vaultBefore = env.le(broker.vaultKeylet()); + auto const brokerBefore = env.le(broker.brokerKeylet()); + BEAST_EXPECT(vaultBefore && brokerBefore); + Number const assetsTotalBefore = vaultBefore->at(sfAssetsTotal); + Number const debtTotalBefore = brokerBefore->at(sfDebtTotal); + Number const lossBefore = vaultBefore->at(sfLossUnrealized); + Number const coverAvailableBefore = brokerBefore->at(sfCoverAvailable); + + env(manage(lender, loanKeylet.key, tfLoanDefault), Ter(tesSUCCESS)); + env.close(); + + auto const vaultAfter = env.le(broker.vaultKeylet()); + auto const brokerAfter = env.le(broker.brokerKeylet()); + BEAST_EXPECT(vaultAfter && brokerAfter); + Number const assetsTotalDelta = + Number(vaultAfter->at(sfAssetsTotal)) - assetsTotalBefore; + Number const debtTotalDelta = Number(brokerAfter->at(sfDebtTotal)) - debtTotalBefore; + Number const lossDelta = Number(vaultAfter->at(sfLossUnrealized)) - lossBefore; + Number const coverAvailableDelta = + Number(brokerAfter->at(sfCoverAvailable)) - coverAvailableBefore; + + Number const defaultCovered = -coverAvailableDelta; + Number const vaultDefaultAmount = expectedExposure - defaultCovered; + + return std::make_tuple( + expectedExposure, assetsTotalDelta, debtTotalDelta, lossDelta, vaultDefaultAmount); + }; + + for (auto const features : {all_ | featureLendingProtocolV1_1, all_}) + { + testcase( + std::string("cash-basis: LoanManage default (") + + (features[featureLendingProtocolV1_1] ? "enabled)" : "disabled)")); + auto const + [expectedExposure, + assetsTotalDelta, + debtTotalDelta, + lossDelta, + vaultDefaultAmount] = runDefault(features); + + BEAST_EXPECTS( + debtTotalDelta == -expectedExposure, + "default must reduce DebtTotal by the unified default amount; delta=" + + to_string(debtTotalDelta) + " expected=" + to_string(expectedExposure)); + BEAST_EXPECTS( + lossDelta == -expectedExposure, + "default must reverse the earlier impair's LossUnrealized exactly; delta=" + + to_string(lossDelta) + " expected=" + to_string(expectedExposure)); + BEAST_EXPECTS( + assetsTotalDelta == -vaultDefaultAmount, + "default must reduce AssetsTotal by (defaultAmount - defaultCovered); delta=" + + to_string(assetsTotalDelta) + " expected=" + to_string(-vaultDefaultAmount)); + } + } + + // 3b. LEVersion regression: a Vault created before featureLendingProtocolV1_1 + // activates (LEVersion absent) must keep whole-life (accrual) accounting + // forever, even after the amendment is later enabled -- the switch is + // per-Vault (LEVersion == VaultVersion::CashBasis), not a single global amendment + // flag. + void + testLegacyVaultKeepsAccrualAfterAmendmentEnabled() + { + testcase("LEVersion: legacy vault keeps accrual after amendment enabled"); + + using namespace jtx; + using namespace loan; + using namespace std::chrono_literals; + + PrettyAsset const xrpAsset{xrpIssue(), 1'000'000}; + BrokerParameters const brokerParams{ + .vaultDeposit = 1'000'000, + .debtMax = 0, + .coverRateMin = TenthBips32{percentageToTenthBips(10)}, + .coverDeposit = 5'000, + .managementFeeRate = TenthBips16{0}, + .coverRateLiquidation = TenthBips32{percentageToTenthBips(25)}}; + + Number const principalRequest{10'000}; + TenthBips32 const interestRate{percentageToTenthBips(12)}; + std::uint32_t const paymentTotal = 4; + std::uint32_t const paymentInterval = 600; + std::uint32_t const gracePeriod = 60; + + // Amendment disabled at Vault creation time: LEVersion stays absent. + Env env(*this, all_); + + Account const lender{"lender"}; + Account const borrower{"borrower"}; + env.fund(XRP(10'000'000), lender, borrower); + env.close(); + + BrokerInfo const broker{createVaultAndBroker(env, xrpAsset, lender, brokerParams)}; + + { + auto const vaultSle = env.le(broker.vaultKeylet()); + BEAST_EXPECT(vaultSle); + BEAST_EXPECT(!vaultSle->isFieldPresent(sfLEVersion)); + } + + // Now enable the amendment -- production dispatch must still treat + // this specific Vault as accrual-basis, since its LEVersion is + // (and remains) absent. + env.enableFeature(featureLendingProtocolV1_1); + env.close(); + + LoanParameters const loanParams{ + .account = borrower, + .counter = lender, + .principalRequest = principalRequest, + .interest = interestRate, + .payTotal = paymentTotal, + .payInterval = paymentInterval, + .gracePd = gracePeriod, + }; + + auto const brokerBeforeLoan = env.le(broker.brokerKeylet()); + BEAST_EXPECT(brokerBeforeLoan); + auto const loanSequence = brokerBeforeLoan->at(sfLoanSequence); + auto const loanKeylet = keylet::loan(broker.brokerID, loanSequence); + + // ---- LoanSet origination: whole-life formulas expected ---- + auto const vaultBeforeSet = env.le(broker.vaultKeylet()); + auto const brokerBeforeSet = env.le(broker.brokerKeylet()); + BEAST_EXPECT(vaultBeforeSet && brokerBeforeSet); + Number const assetsTotalBeforeSet = vaultBeforeSet->at(sfAssetsTotal); + Number const debtTotalBeforeSet = brokerBeforeSet->at(sfDebtTotal); + + env(loanParams(env, broker)); + env.close(); + + auto const loanAfterSet = env.le(loanKeylet); + BEAST_EXPECT(loanAfterSet); + Number const principalOutstanding = loanAfterSet->at(sfPrincipalOutstanding); + Number const totalValueOutstanding = loanAfterSet->at(sfTotalValueOutstanding); + Number const interestDue = totalValueOutstanding - principalOutstanding; + BEAST_EXPECT(interestDue > beast::kZero); + + auto const vaultAfterSet = env.le(broker.vaultKeylet()); + auto const brokerAfterSet = env.le(broker.brokerKeylet()); + BEAST_EXPECT(vaultAfterSet && brokerAfterSet); + Number const assetsTotalDeltaSet = + Number(vaultAfterSet->at(sfAssetsTotal)) - assetsTotalBeforeSet; + Number const debtTotalDeltaSet = + Number(brokerAfterSet->at(sfDebtTotal)) - debtTotalBeforeSet; + + BEAST_EXPECTS( + assetsTotalDeltaSet == interestDue, + "legacy vault origination must still add interestDue to AssetsTotal; delta=" + + to_string(assetsTotalDeltaSet) + " interestDue=" + to_string(interestDue)); + BEAST_EXPECTS( + debtTotalDeltaSet == principalOutstanding + interestDue, + "legacy vault origination must still add principal+interest to DebtTotal; delta=" + + to_string(debtTotalDeltaSet)); + + LoanState const state = getCurrentState(env, broker, loanKeylet); + env.close(); + + // ---- LoanPay: whole-life formulas expected ---- + auto const vaultBeforePay = env.le(broker.vaultKeylet()); + auto const brokerBeforePay = env.le(broker.brokerKeylet()); + auto const loanBeforePay = env.le(loanKeylet); + BEAST_EXPECT(vaultBeforePay && brokerBeforePay && loanBeforePay); + Number const totalValueBeforePay = loanBeforePay->at(sfTotalValueOutstanding); + Number const assetsTotalBeforePay = vaultBeforePay->at(sfAssetsTotal); + Number const debtTotalBeforePay = brokerBeforePay->at(sfDebtTotal); + + STAmount const paymentAmount{ + xrpAsset, roundPeriodicPayment(xrpAsset, state.periodicPayment, state.loanScale)}; + env(pay(borrower, loanKeylet.key, paymentAmount), Ter(tesSUCCESS)); + env.close(); + + auto const vaultAfterPay = env.le(broker.vaultKeylet()); + auto const brokerAfterPay = env.le(broker.brokerKeylet()); + auto const loanAfterPay = env.le(loanKeylet); + BEAST_EXPECT(vaultAfterPay && brokerAfterPay && loanAfterPay); + Number const totalValueAfterPay = loanAfterPay->at(sfTotalValueOutstanding); + Number const assetsTotalDeltaPay = + Number(vaultAfterPay->at(sfAssetsTotal)) - assetsTotalBeforePay; + Number const debtTotalDeltaPay = + Number(brokerAfterPay->at(sfDebtTotal)) - debtTotalBeforePay; + Number const totalValueDeltaPay = totalValueAfterPay - totalValueBeforePay; + + // A regular, on-time payment has valueChange == 0, so whole-life + // AssetsTotal is untouched and DebtTotal mirrors TotalValueOutstanding. + BEAST_EXPECTS( + assetsTotalDeltaPay == beast::kZero, + "legacy vault regular payment must not change AssetsTotal; delta=" + + to_string(assetsTotalDeltaPay)); + BEAST_EXPECTS( + debtTotalDeltaPay == totalValueDeltaPay, + "legacy vault DebtTotal delta must mirror TotalValueOutstanding delta; " + "debtTotalDelta=" + + to_string(debtTotalDeltaPay) + " totalValueDelta=" + to_string(totalValueDeltaPay)); + + // ---- LoanManage: impair, then default -- whole-life exposure expected ---- + auto const loanBeforeImpair = env.le(loanKeylet); + BEAST_EXPECT(loanBeforeImpair); + Number const totalValueBeforeImpair = loanBeforeImpair->at(sfTotalValueOutstanding); + Number const managementFeeBeforeImpair = loanBeforeImpair->at(sfManagementFeeOutstanding); + Number const expectedExposure = totalValueBeforeImpair - managementFeeBeforeImpair; + + env(manage(lender, loanKeylet.key, tfLoanImpair), Ter(tesSUCCESS)); + env.close(); + + LoanState const stateAtImpair = getCurrentState(env, broker, loanKeylet); + env.close( + stateAtImpair.startDate + std::chrono::seconds(paymentInterval) + + std::chrono::seconds(gracePeriod) + 60s); + + auto const vaultBeforeDefault = env.le(broker.vaultKeylet()); + auto const brokerBeforeDefault = env.le(broker.brokerKeylet()); + BEAST_EXPECT(vaultBeforeDefault && brokerBeforeDefault); + Number const debtTotalBeforeDefault = brokerBeforeDefault->at(sfDebtTotal); + Number const lossBeforeDefault = vaultBeforeDefault->at(sfLossUnrealized); + + env(manage(lender, loanKeylet.key, tfLoanDefault), Ter(tesSUCCESS)); + env.close(); + + auto const vaultAfterDefault = env.le(broker.vaultKeylet()); + auto const brokerAfterDefault = env.le(broker.brokerKeylet()); + BEAST_EXPECT(vaultAfterDefault && brokerAfterDefault); + Number const debtTotalDeltaDefault = + Number(brokerAfterDefault->at(sfDebtTotal)) - debtTotalBeforeDefault; + Number const lossDeltaDefault = + Number(vaultAfterDefault->at(sfLossUnrealized)) - lossBeforeDefault; + + BEAST_EXPECTS( + debtTotalDeltaDefault == -expectedExposure, + "legacy vault default must reduce DebtTotal by whole-life exposure; delta=" + + to_string(debtTotalDeltaDefault) + " expected=" + to_string(expectedExposure)); + BEAST_EXPECTS( + lossDeltaDefault == -expectedExposure, + "legacy vault default must reverse the earlier impair's LossUnrealized exactly; " + "delta=" + + to_string(lossDeltaDefault) + " expected=" + to_string(expectedExposure)); + + // Confirm the Vault's LEVersion truly never got set, throughout. + { + auto const vaultSle = env.le(broker.vaultKeylet()); + BEAST_EXPECT(vaultSle); + BEAST_EXPECT(!vaultSle->isFieldPresent(sfLEVersion)); + BEAST_EXPECT(getVaultVersion(vaultSle) == VaultVersion::Legacy); + } + } + + // 4. End-to-end trajectory: LoanSet -> 2 LoanPays -> LoanManage(default), + // entirely under the amendment, with independently hand-computed + // expected AssetsTotal/DebtTotal/LossUnrealized/CoverAvailable values at + // each step. 0% interest keeps the arithmetic exact and tractable; the + // divergence from whole-life accounting is already covered directly by + // testCashBasisLoanSetOrigination/LoanPay/LoanManage above, so this test + // focuses purely on an independent, from-scratch trajectory check. + void + testCashBasisEndToEndTrajectory() + { + testcase("cash-basis: end-to-end trajectory"); + + using namespace jtx; + using namespace loan; + using namespace std::chrono_literals; + + PrettyAsset const xrpAsset{xrpIssue(), 1'000'000}; + BrokerParameters const brokerParams{ + .vaultDeposit = 100'000, .managementFeeRate = TenthBips16{0}}; + + Env env(*this, all_ | featureLendingProtocolV1_1); + + Account const lender{"lender"}; + Account const borrower{"borrower"}; + env.fund(XRP(10'000'000), lender, borrower); + env.close(); + + BrokerInfo const broker{createVaultAndBroker(env, xrpAsset, lender, brokerParams)}; + + // Hand computation (all values in XRP, drops == 1e-6 XRP): + // Vault: AssetsTotal starts at 100'000 (the deposit). + // Broker: DebtTotal starts at 0, CoverAvailable starts at 1'000 + // (BrokerParameters::defaults().coverDeposit). + auto const vaultKeylet = broker.vaultKeylet(); + auto const brokerKeylet = broker.brokerKeylet(); + + // All the "human XRP unit" constants below (e.g. `100'000`) are + // converted to raw native (drops) values via xrpAsset(...), since + // that's how the ledger fields are actually denominated. + auto const checkVaultBroker = [&](Number const& assetsTotalUnits, + Number const& debtTotalUnits, + Number const& lossUnrealizedUnits, + Number const& coverAvailableUnits, + char const* step) { + Number const assetsTotal = xrpAsset(assetsTotalUnits).value(); + Number const debtTotal = xrpAsset(debtTotalUnits).value(); + Number const lossUnrealized = xrpAsset(lossUnrealizedUnits).value(); + Number const coverAvailable = xrpAsset(coverAvailableUnits).value(); + + auto const vaultSle = env.le(vaultKeylet); + auto const brokerSle = env.le(brokerKeylet); + BEAST_EXPECT(vaultSle && brokerSle); + BEAST_EXPECTS( + vaultSle->at(sfAssetsTotal) == assetsTotal, + std::string(step) + ": AssetsTotal expected " + to_string(assetsTotal) + " got " + + to_string(Number(vaultSle->at(sfAssetsTotal)))); + BEAST_EXPECTS( + brokerSle->at(sfDebtTotal) == debtTotal, + std::string(step) + ": DebtTotal expected " + to_string(debtTotal) + " got " + + to_string(Number(brokerSle->at(sfDebtTotal)))); + BEAST_EXPECTS( + vaultSle->at(sfLossUnrealized) == lossUnrealized, + std::string(step) + ": LossUnrealized expected " + to_string(lossUnrealized) + + " got " + to_string(Number(vaultSle->at(sfLossUnrealized)))); + BEAST_EXPECTS( + brokerSle->at(sfCoverAvailable) == coverAvailable, + std::string(step) + ": CoverAvailable expected " + to_string(coverAvailable) + + " got " + to_string(Number(brokerSle->at(sfCoverAvailable)))); + }; + + checkVaultBroker(100'000, 0, 0, 1'000, "before LoanSet"); + + // Loan: principal=1200, 0% interest, 12 payments of 100 each, no fees. + Number const principalRequest{1'200}; + std::uint32_t const paymentTotal = 12; + std::uint32_t const paymentInterval = 600; + std::uint32_t const gracePeriod = 60; + + auto const brokerBeforeLoan = env.le(brokerKeylet); + BEAST_EXPECT(brokerBeforeLoan); + auto const loanSequence = brokerBeforeLoan->at(sfLoanSequence); + auto const loanKeylet = keylet::loan(broker.brokerID, loanSequence); + + LoanParameters const loanParams{ + .account = borrower, + .counter = lender, + .principalRequest = principalRequest, + .interest = TenthBips32{0}, + .payTotal = paymentTotal, + .payInterval = paymentInterval, + .gracePd = gracePeriod, + }; + env(loanParams(env, broker)); + env.close(); + + // Origination (cash-basis): AssetsTotal += 0, DebtTotal += principal. + checkVaultBroker(100'000, 1'200, 0, 1'000, "after LoanSet"); + + LoanState const state = getCurrentState(env, broker, loanKeylet); + BEAST_EXPECT(state.periodicPayment == xrpAsset(100).value()); + + // Payment 1: principalPaid=100, interestPaid=0. + // AssetsTotal += 0; DebtTotal -= 100. + env(pay(borrower, loanKeylet.key, xrpAsset(100).value()), Ter(tesSUCCESS)); + env.close(); + checkVaultBroker(100'000, 1'100, 0, 1'000, "after payment 1"); + + // Payment 2: same as above. + env(pay(borrower, loanKeylet.key, xrpAsset(100).value()), Ter(tesSUCCESS)); + env.close(); + checkVaultBroker(100'000, 1'000, 0, 1'000, "after payment 2"); + + // Default (no impair): principalOutstanding remaining is 1'000. + // totalDefaultAmount (cash-basis) = PrincipalOutstanding = 1'000. + // minimumCover = DebtTotal(1'000) * coverRateMin(10%) = 100. + // covered = min(minimumCover * coverRateLiquidation(25%), totalDefaultAmount) + // = min(25, 1'000) = 25. + // defaultCovered = min(covered, CoverAvailable(1'000)) = 25. + // vaultDefaultAmount = 1'000 - 25 = 975. + // DebtTotal -= 1'000 -> 0. CoverAvailable -= 25 -> 975. + // AssetsTotal -= 975 -> 99'025. LossUnrealized unaffected (never impaired). + auto const loanBeforeDefault = env.le(loanKeylet); + BEAST_EXPECT(loanBeforeDefault); + BEAST_EXPECT( + Number(loanBeforeDefault->at(sfPrincipalOutstanding)) == xrpAsset(1'000).value()); + + env.close(state.startDate + std::chrono::seconds((3 * paymentInterval) + gracePeriod) + 1s); + + env(manage(lender, loanKeylet.key, tfLoanDefault), Ter(tesSUCCESS)); + env.close(); + + checkVaultBroker(99'025, 0, 0, 975, "after LoanManage(default)"); + } + +public: + void + run() override + { + testCashBasisLoanSetOrigination(); + testCashBasisLoanPay(); + testCashBasisLoanManage(); + testLegacyVaultKeepsAccrualAfterAmendmentEnabled(); + testCashBasisEndToEndTrajectory(); + } +}; + +BEAST_DEFINE_TESTSUITE(LoanCashBasis, tx, xrpl); + +} // namespace xrpl::test diff --git a/src/test/app/lending/LoanCoverFreezeAuth_test.cpp b/src/test/app/lending/LoanCoverFreezeAuth_test.cpp new file mode 100644 index 00000000000..4bff8c4c9ae --- /dev/null +++ b/src/test/app/lending/LoanCoverFreezeAuth_test.cpp @@ -0,0 +1,724 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +namespace xrpl::test { + +class LoanCoverFreezeAuth_test : public LoanTestBase +{ +private: + void + testSequentialFLCDepletion(FeatureBitset features) + { + testcase << "First-Loss Capital Depletion on Sequential Defaults"; + + using namespace jtx; + using namespace loan; + using namespace loanBroker; + + Env env{*this, features}; + + Account const issuer{"issuer"}; + Account const lender{"lender"}; + Account const borrowerA{"borrowerA"}; + Account const borrowerB{"borrowerB"}; + + env.fund(XRP(1'000'000), issuer, lender, borrowerA, borrowerB); + env.close(); + + PrettyAsset const asset = xrpIssue(); + auto const vaultDepositAmount = + asset(200'000); // Enough for 2 x 50k loans plus interest/fees + + auto const brokerInfo = createVaultAndBroker( + env, + asset, + lender, + { + .vaultDeposit = vaultDepositAmount.value(), + .debtMax = 0, + .coverRateMin = TenthBips32(20000), // 20% + .coverDeposit = 21'000, + .managementFeeRate = TenthBips16(100), // 0.1% + .coverRateLiquidation = TenthBips32(100000), + }); + auto const brokerKeylet = brokerInfo.brokerKeylet(); + + // Create two identical loans: each 50,000 XRP principal (scaled down to + // avoid funding issues) Total DebtTotal will be ~100,000 XRP (principal + // + interest) Formula will calculate cover as: 100% × (20% × 100,000) = + // 20,000 XRP So we need FLC = 20,000 XRP to be fully consumed by first + // default + auto const principalAmount = Number(50'000); + auto const loanPaymentInterval = 2592000; // 30 days + auto const loanGracePeriod = 604800; // 7 days + + // Create Loan A + auto loanATx = env.jt( + set(borrowerA, brokerKeylet.key, principalAmount), + Sig(sfCounterpartySignature, lender), + kInterestRate(TenthBips32(500)), // 5% + kPaymentTotal(12), + loan::kPaymentInterval(loanPaymentInterval), + loan::kGracePeriod(loanGracePeriod), + Fee(XRP(10))); // Sufficient fee for multi-sig transaction + env(loanATx); + env.close(); + + auto const loanAKeylet = keylet::loan(brokerKeylet.key, 1); + + // Create Loan B + auto loanBTx = env.jt( + set(borrowerB, brokerKeylet.key, principalAmount), + Sig(sfCounterpartySignature, lender), + kInterestRate(TenthBips32(500)), // 5% + kPaymentTotal(12), + loan::kPaymentInterval(loanPaymentInterval), + loan::kGracePeriod(loanGracePeriod), + Fee(XRP(10))); // Sufficient fee for multi-sig transaction + env(loanBTx); + env.close(); + + auto const loanBKeylet = keylet::loan(brokerKeylet.key, 2); + + auto loanASle = env.le(loanAKeylet); + if (!BEAST_EXPECT(loanASle)) + return; + + // Advance time past grace period for both loans to be defaultable + auto const loanANextDue = loanASle->at(sfNextPaymentDueDate); + auto const loanAGrace = loanASle->at(sfGracePeriod); + env.close(std::chrono::seconds{loanANextDue + loanAGrace + 60}); + + env(manage(lender, loanAKeylet.key, tfLoanDefault), Ter(tesSUCCESS)); + env.close(); + + // Verify Loan A is defaulted + loanASle = env.le(loanAKeylet); + if (!BEAST_EXPECT(loanASle)) + return; + BEAST_EXPECT(loanASle->isFlag(lsfLoanDefault)); + BEAST_EXPECT(loanASle->at(sfPaymentRemaining) == 0); + + // Check broker state after first default (from committed ledger) + auto brokerSle = env.le(brokerKeylet); + if (!BEAST_EXPECT(brokerSle)) + return; + auto const afterFirstDebtTotal = brokerSle->at(sfDebtTotal); + auto const afterFirstCoverAvailable = brokerSle->at(sfCoverAvailable); + + // DebtTotal should have decreased by Loan A's debt + BEAST_EXPECT(afterFirstDebtTotal == 50'134); + + // CoverAvailable should have decreased significantly + BEAST_EXPECT(afterFirstCoverAvailable == 946); + + env(manage(lender, loanBKeylet.key, tfLoanDefault), Ter(tesSUCCESS)); + + brokerSle = env.le(brokerKeylet); + if (!BEAST_EXPECT(brokerSle)) + return; + auto const afterSecondDebtTotal = brokerSle->at(sfDebtTotal); + auto const afterSecondCoverAvailable = brokerSle->at(sfCoverAvailable); + + BEAST_EXPECT(afterSecondDebtTotal == 0); + + BEAST_EXPECT(afterSecondCoverAvailable == 0); + } + + // Tests that vault withdrawals work correctly when the vault has unrealized + // loss from an impaired loan, ensuring the invariant check properly + // accounts for the loss. + void + testWithdrawReflectsUnrealizedLoss(FeatureBitset features) + { + using namespace jtx; + using namespace loan; + using namespace std::chrono_literals; + + testcase("Vault withdraw reflects sfLossUnrealized"); + + // Test constants + static constexpr std::int64_t kInitialFunding = 1'000'000; + static constexpr std::int64_t kLenderInitialIou = 5'000'000; + static constexpr std::int64_t kDepositorInitialIou = 1'000'000; + static constexpr std::int64_t kBorrowerInitialIou = 100'000; + static constexpr std::int64_t kDepositAmount = 5'000; + static constexpr std::int64_t kPrincipalAmount = 99; + static constexpr std::uint64_t kExpectedSharesPerDepositor = 5'000'000'000; + static constexpr std::uint32_t kLocalPaymentInterval = 600; + static constexpr std::uint32_t kLocalPaymentTotal = 2; + + Env env{*this, features}; + + // Setup accounts + Account const issuer{"issuer"}; + Account const lender{"lender"}; + Account const depositorA{"lpA"}; + Account const depositorB{"lpB"}; + Account const borrower{"borrowerA"}; + + env.fund(XRP(kInitialFunding), issuer, lender, depositorA, depositorB, borrower); + env.close(); + + // Setup trust lines + PrettyAsset const iouAsset = issuer[iouCurrency_]; + env(trust(lender, iouAsset(10'000'000))); + env(trust(depositorA, iouAsset(10'000'000))); + env(trust(depositorB, iouAsset(10'000'000))); + env(trust(borrower, iouAsset(10'000'000))); + env.close(); + + // Fund accounts with IOUs + env(pay(issuer, lender, iouAsset(kLenderInitialIou))); + env(pay(issuer, depositorA, iouAsset(kDepositorInitialIou))); + env(pay(issuer, depositorB, iouAsset(kDepositorInitialIou))); + env(pay(issuer, borrower, iouAsset(kBorrowerInitialIou))); + env.close(); + + // Create vault and broker, then add deposits from two depositors + auto const broker = createVaultAndBroker(env, iouAsset, lender); + Vault v{env}; + + env(v.deposit({ + .depositor = depositorA, + .id = broker.vaultKeylet().key, + .amount = iouAsset(kDepositAmount), + }), + Ter(tesSUCCESS)); + env(v.deposit({ + .depositor = depositorB, + .id = broker.vaultKeylet().key, + .amount = iouAsset(kDepositAmount), + }), + Ter(tesSUCCESS)); + env.close(); + + // Create a loan + auto const sleBroker = env.le(keylet::loanBroker(broker.brokerID)); + if (!BEAST_EXPECT(sleBroker)) + return; + + auto const loanKeylet = keylet::loan(broker.brokerID, sleBroker->at(sfLoanSequence)); + + env(set(borrower, broker.brokerID, kPrincipalAmount), + Sig(sfCounterpartySignature, lender), + kPaymentTotal(kLocalPaymentTotal), + kPaymentInterval(kLocalPaymentInterval), + Fee(env.current()->fees().base * 2), + Ter(tesSUCCESS)); + env.close(); + + // Impair the loan to create unrealized loss + env(manage(lender, loanKeylet.key, tfLoanImpair), Ter(tesSUCCESS)); + env.close(); + + // Verify unrealized loss is recorded in the vault + auto const vaultAfterImpair = env.le(broker.vaultKeylet()); + if (!BEAST_EXPECT(vaultAfterImpair)) + return; + + BEAST_EXPECT( + vaultAfterImpair->at(sfLossUnrealized) == broker.asset(kPrincipalAmount).value()); + + // Helper to get share balance for a depositor + auto const shareAsset = vaultAfterImpair->at(sfShareMPTID); + auto const getShareBalance = [&](Account const& depositor) -> std::uint64_t { + auto const token = env.le(keylet::mptoken(shareAsset, depositor.id())); + return token ? token->getFieldU64(sfMPTAmount) : 0; + }; + + // Verify both depositors have equal shares + auto const sharesLpA = getShareBalance(depositorA); + auto const sharesLpB = getShareBalance(depositorB); + BEAST_EXPECT(sharesLpA == kExpectedSharesPerDepositor); + BEAST_EXPECT(sharesLpB == kExpectedSharesPerDepositor); + BEAST_EXPECT(sharesLpA == sharesLpB); + + // Helper to attempt withdrawal + auto const attemptWithdrawShares = [&](Account const& depositor, + std::uint64_t shareAmount, + TER expected) { + STAmount const shareAmt{MPTIssue{shareAsset}, Number(shareAmount)}; + env(v.withdraw( + {.depositor = depositor, .id = broker.vaultKeylet().key, .amount = shareAmt}), + Ter(expected)); + env.close(); + }; + + // Regression test: Both depositors should successfully withdraw despite + // unrealized loss. Previously failed with invariant violation: + // "withdrawal must change vault and destination balance by equal + // amount". This was caused by sharesToAssetsWithdraw rounding down, + // creating a mismatch where vaultDeltaAssets * -1 != destinationDelta + // when unrealized loss exists. + attemptWithdrawShares(depositorA, sharesLpA, tesSUCCESS); + attemptWithdrawShares(depositorB, sharesLpB, tesSUCCESS); + } + + void + testServiceFeeOnBrokerDeepFreeze() + { + testcase << "Service Fee On Broker Deep Freeze"; + using namespace jtx; + using namespace loan; + Account const issuer("issuer"); + Account const borrower("borrower"); + Account const broker("broker"); + auto const iou = issuer["IOU"]; + + for (bool const deepFreeze : {true, false}) + { + Env env(*this); + + auto getCoverBalance = [&](BrokerInfo const& brokerInfo, auto const& accountField) { + if (auto const le = env.le(keylet::loanBroker(brokerInfo.brokerID)); + BEAST_EXPECT(le)) + { + auto const account = le->at(accountField); + if (auto const sleLine = env.le(keylet::trustLine(account, iou)); + BEAST_EXPECT(sleLine)) + { + STAmount balance = sleLine->at(sfBalance); + if (account > issuer.id()) + balance.negate(); + return balance; + } + } + return STAmount{iou}; + }; + + env.fund(XRP(20'000), issuer, broker, borrower); + env.close(); + + env(trust(broker, iou(20'000'000))); + env(pay(issuer, broker, iou(10'000'000))); + env.close(); + + auto const brokerInfo = createVaultAndBroker(env, iou, broker); + + BEAST_EXPECT(getCoverBalance(brokerInfo, sfAccount) == iou(1'000)); + + auto const keylet = keylet::loan(brokerInfo.brokerID, 1); + + env(set(borrower, brokerInfo.brokerID, 10'000), + Sig(sfCounterpartySignature, broker), + kLoanServiceFee(iou(100).value()), + kPaymentInterval(100), + Fee(XRP(100))); + env.close(); + + env(trust(borrower, iou(20'000'000))); + // The borrower increases their limit and acquires some IOU so + // they can pay interest + env(pay(issuer, borrower, iou(500))); + env.close(); + + if (auto const le = env.le(keylet::loan(keylet.key)); BEAST_EXPECT(le)) + { + if (deepFreeze) + { + env(trust(issuer, broker["IOU"](0), tfSetFreeze | tfSetDeepFreeze)); + env.close(); + } + + env(pay(borrower, keylet.key, iou(10'100)), Fee(XRP(100))); + env.close(); + + if (deepFreeze) + { + // The fee goes to the broker pseudo-account + BEAST_EXPECT(getCoverBalance(brokerInfo, sfAccount) == iou(1'100)); + BEAST_EXPECT(getCoverBalance(brokerInfo, sfOwner) == iou(8'999'000)); + } + else + { + // The fee goes to the broker account + BEAST_EXPECT(getCoverBalance(brokerInfo, sfOwner) == iou(8'999'100)); + BEAST_EXPECT(getCoverBalance(brokerInfo, sfAccount) == iou(1'000)); + } + } + }; + } + + void + testLoanPayBrokerOwnerMissingTrustline(FeatureBitset features) + { + testcase << "LoanPay Broker Owner Missing Trustline (PoC)"; + using namespace jtx; + using namespace loan; + Account const issuer("issuer"); + Account const borrower("borrower"); + Account const broker("broker"); + auto const iou = issuer["IOU"]; + Env env(*this, features); + env.fund(XRP(20'000), issuer, broker, borrower); + env.close(); + // Set up trustlines and fund accounts + env(trust(broker, iou(20'000'000))); + env(trust(borrower, iou(20'000'000))); + env(pay(issuer, broker, iou(10'000'000))); + env(pay(issuer, borrower, iou(1'000))); + env.close(); + // Create vault and broker + auto const brokerInfo = createVaultAndBroker(env, iou, broker); + // Create a loan first (this creates debt) + auto const keylet = keylet::loan(brokerInfo.brokerID, 1); + env(set(borrower, brokerInfo.brokerID, 10'000), + Sig(sfCounterpartySignature, broker), + kLoanServiceFee(iou(100).value()), + kPaymentInterval(100), + Fee(XRP(100))); + env.close(); + // Ensure broker has sufficient cover so brokerPayee == brokerOwner + // We need coverAvailable >= (debtTotal * coverRateMinimum) + // Deposit enough cover to ensure the fee goes to broker owner + // The default coverRateMinimum is 10%, so for a 10,000 loan we need + // at least 1,000 cover. Default cover is 1,000, so we add more to be + // safe. + auto const additionalCover = iou(50'000).value(); + env(loanBroker::coverDeposit(broker, brokerInfo.brokerID, STAmount{iou, additionalCover})); + env.close(); + // Verify broker owner has a trustline + auto const brokerTrustline = keylet::trustLine(broker, iou); + BEAST_EXPECT(env.le(brokerTrustline) != nullptr); + // Broker owner deletes their trustline + // First, pay any positive balance to issuer to zero it out + auto const brokerBalance = env.balance(broker, iou); + env(pay(broker, issuer, brokerBalance)); + env.close(); + // Remove the trustline by setting limit to 0 + env(trust(broker, iou(0))); + env.close(); + // Verify trustline is deleted + BEAST_EXPECT(env.le(brokerTrustline) == nullptr); + // Now borrower tries to make a payment + // We should get a tesSUCCESS instead of a tecNO_LINE. + env(pay(borrower, keylet.key, iou(10'100)), Fee(XRP(100)), Ter(tesSUCCESS)); + env.close(); + // Verify trustline is still deleted + BEAST_EXPECT(env.le(brokerTrustline) == nullptr); + // Verify the service fee went to the broker pseudo-account + if (auto const brokerSle = env.le(keylet::loanBroker(brokerInfo.brokerID)); + BEAST_EXPECT(brokerSle)) + { + Account const pseudo("pseudo-account", brokerSle->at(sfAccount)); + auto const balance = env.balance(pseudo, iou); + // 1,000 default + 50,000 extra + 100 service fee from LoanPay + BEAST_EXPECTS(balance == iou(51'100), to_string(json::Value(balance))); + } + } + + void + testLoanPayBrokerOwnerUnauthorizedMPT(FeatureBitset features) + { + testcase << "LoanPay Broker Owner MPT unauthorized"; + using namespace jtx; + using namespace loan; + + Account const issuer("issuer"); + Account const borrower("borrower"); + Account const broker("broker"); + + Env env{*this, features}; + env.fund(XRP(20'000), issuer, broker, borrower); + env.close(); + + MPTTester mptt{env, issuer, kMptInitNoFund}; + mptt.create({.flags = tfMPTCanClawback | tfMPTCanTransfer | tfMPTCanLock}); + + PrettyAsset const mpt{mptt.issuanceID()}; + + // Authorize broker and borrower + mptt.authorize({.account = broker}); + mptt.authorize({.account = borrower}); + + env.close(); + + // Fund accounts + env(pay(issuer, broker, mpt(10'000'000))); + env(pay(issuer, borrower, mpt(1'000))); + env.close(); + + // Create vault and broker + auto const brokerInfo = createVaultAndBroker(env, mpt, broker); + // Create a loan first (this creates debt) + auto const keylet = keylet::loan(brokerInfo.brokerID, 1); + env(set(borrower, brokerInfo.brokerID, 10'000), + Sig(sfCounterpartySignature, broker), + kLoanServiceFee(mpt(100).value()), + kPaymentInterval(100), + Fee(XRP(100))); + env.close(); + // Ensure broker has sufficient cover so brokerPayee == brokerOwner + // We need coverAvailable >= (debtTotal * coverRateMinimum) + // Deposit enough cover to ensure the fee goes to broker owner + // The default coverRateMinimum is 10%, so for a 10,000 loan we need + // at least 1,000 cover. Default cover is 1,000, so we add more to be + // safe. + auto const additionalCover = mpt(50'000).value(); + env(loanBroker::coverDeposit(broker, brokerInfo.brokerID, STAmount{mpt, additionalCover})); + env.close(); + // Verify broker owner is authorized + auto const brokerMpt = keylet::mptoken(mptt.issuanceID(), broker); + BEAST_EXPECT(env.le(brokerMpt) != nullptr); + // Broker owner unauthorizes. + // First, pay any positive balance to issuer to zero it out + auto const brokerBalance = env.balance(broker, mpt); + env(pay(broker, issuer, brokerBalance)); + env.close(); + // Then, unauthorize the MPT. + mptt.authorize({.account = broker, .flags = tfMPTUnauthorize}); + env.close(); + // Verify the MPT is unauthorized. + BEAST_EXPECT(env.le(brokerMpt) == nullptr); + // Now borrower tries to make a payment + // We should get a tesSUCCESS instead of a tecNO_AUTH. + auto const borrowerBalance = env.balance(borrower, mpt); + env(pay(borrower, keylet.key, mpt(10'100)), Fee(XRP(100)), Ter(tesSUCCESS)); + env.close(); + // Verify the MPT is still unauthorized. + BEAST_EXPECT(env.le(brokerMpt) == nullptr); + // Verify the service fee went to the broker pseudo-account + if (auto const brokerSle = env.le(keylet::loanBroker(brokerInfo.brokerID)); + BEAST_EXPECT(brokerSle)) + { + Account const pseudo("pseudo-account", brokerSle->at(sfAccount)); + auto const balance = env.balance(pseudo, mpt); + // 1,000 default + 50,000 extra + 100 service fee from LoanPay + BEAST_EXPECTS(balance == mpt(51'100), to_string(json::Value(balance))); + } + } + + void + testLoanPayBrokerOwnerNoPermissionedDomainMPT(FeatureBitset features) + { + testcase << "LoanPay Broker Owner without permissioned domain of the MPT"; + using namespace jtx; + using namespace loan; + + Account const issuer("issuer"); + Account const borrower("borrower"); + Account const broker("broker"); + + Env env{*this, features}; + env.fund(XRP(20'000), issuer, broker, borrower); + env.close(); + + auto credType = "credential1"; + + pdomain::Credentials const credentials1 = {{.issuer = issuer, .credType = credType}}; + env(pdomain::setTx(issuer, credentials1)); + env.close(); + + auto domainID = pdomain::getNewDomain(env.meta()); + + env(credentials::create(broker, issuer, credType)); + env(credentials::accept(broker, issuer, credType)); + env.close(); + + env(credentials::create(borrower, issuer, credType)); + env(credentials::accept(borrower, issuer, credType)); + env.close(); + + MPTTester mptt{env, issuer, kMptInitNoFund}; + mptt.create({ + .flags = tfMPTCanClawback | tfMPTRequireAuth | tfMPTCanTransfer | tfMPTCanLock, + .domainID = domainID, + }); + + PrettyAsset const mpt{mptt.issuanceID()}; + + // Authorize broker and borrower + mptt.authorize({.account = broker}); + mptt.authorize({.account = borrower}); + + env.close(); + + // Fund accounts + env(pay(issuer, broker, mpt(10'000'000))); + env(pay(issuer, borrower, mpt(1'000))); + env.close(); + + // Create vault and broker + auto const brokerInfo = createVaultAndBroker(env, mpt, broker); + // Create a loan first (this creates debt) + auto const keylet = keylet::loan(brokerInfo.brokerID, 1); + env(set(borrower, brokerInfo.brokerID, 10'000), + Sig(sfCounterpartySignature, broker), + kLoanServiceFee(mpt(100).value()), + kPaymentInterval(100), + Fee(XRP(100))); + env.close(); + // Ensure broker has sufficient cover so brokerPayee == brokerOwner + // We need coverAvailable >= (debtTotal * coverRateMinimum) + // Deposit enough cover to ensure the fee goes to broker owner + // The default coverRateMinimum is 10%, so for a 10,000 loan we need + // at least 1,000 cover. Default cover is 1,000, so we add more to be + // safe. + auto const additionalCover = mpt(50'000).value(); + env(loanBroker::coverDeposit(broker, brokerInfo.brokerID, STAmount{mpt, additionalCover})); + env.close(); + // Verify broker owner is authorized + auto const brokerMpt = keylet::mptoken(mptt.issuanceID(), broker); + BEAST_EXPECT(env.le(brokerMpt) != nullptr); + // Remove the credentials for the Broker owner. + // First, pay any positive balance to issuer to zero it out + auto const brokerBalance = env.balance(broker, mpt); + env(pay(broker, issuer, brokerBalance)); + env.close(); + + env(credentials::deleteCred(broker, broker, issuer, credType)); + env.close(); + + // Make sure the broker is not authorized to hold the MPT after we + // deleted the credentials + env(pay(issuer, broker, mpt(1'000)), Ter(tecNO_AUTH)); + + // Now borrower tries to make a payment + // We should get a tesSUCCESS instead of a tecNO_AUTH. + auto const borrowerBalance = env.balance(borrower, mpt); + env(pay(borrower, keylet.key, mpt(10'100)), Fee(XRP(100)), Ter(tesSUCCESS)); + env.close(); + // Verify broker is still not authorized + env(pay(issuer, broker, mpt(1'000)), Ter(tecNO_AUTH)); + // Verify the service fee went to the broker pseudo-account + if (auto const brokerSle = env.le(keylet::loanBroker(brokerInfo.brokerID)); + BEAST_EXPECT(brokerSle)) + { + Account const pseudo("pseudo-account", brokerSle->at(sfAccount)); + auto const balance = env.balance(pseudo, mpt); + // 1,000 default + 50,000 extra + 100 service fee from LoanPay + BEAST_EXPECTS(balance == mpt(51'100), to_string(json::Value(balance))); + } + } + + void + testLoanSetBrokerOwnerNoPermissionedDomainMPT(FeatureBitset features) + { + testcase << "LoanSet Broker Owner without permissioned domain of the MPT"; + using namespace jtx; + using namespace loan; + + Account const issuer("issuer"); + Account const borrower("borrower"); + Account const broker("broker"); + + Env env{*this, features}; + env.fund(XRP(20'000), issuer, broker, borrower); + env.close(); + + auto credType = "credential1"; + + pdomain::Credentials const credentials1{{.issuer = issuer, .credType = credType}}; + env(pdomain::setTx(issuer, credentials1)); + env.close(); + + auto domainID = pdomain::getNewDomain(env.meta()); + + // Add credentials for the broker and borrower + env(credentials::create(broker, issuer, credType)); + env(credentials::accept(broker, issuer, credType)); + env.close(); + + env(credentials::create(borrower, issuer, credType)); + env(credentials::accept(borrower, issuer, credType)); + env.close(); + + MPTTester mptt{env, issuer, kMptInitNoFund}; + mptt.create({ + .flags = tfMPTCanClawback | tfMPTRequireAuth | tfMPTCanTransfer | tfMPTCanLock, + .domainID = domainID, + }); + + PrettyAsset const mpt{mptt.issuanceID()}; + + // Authorize broker and borrower + mptt.authorize({.account = broker}); + mptt.authorize({.account = borrower}); + env.close(); + + // Fund accounts + env(pay(issuer, broker, mpt(10'000'000))); + env(pay(issuer, borrower, mpt(1'000))); + env.close(); + + // Create vault and broker + auto const brokerInfo = createVaultAndBroker(env, mpt, broker); + + // Remove the credentials for the Broker owner. + // Clear the balance first. + auto const brokerBalance = env.balance(broker, mpt); + env(pay(broker, issuer, brokerBalance)); + env.close(); + // Delete the credentials + env(credentials::deleteCred(broker, broker, issuer, credType)); + env.close(); + + // Create a loan, this should fail for tecNO_AUTH + env(set(borrower, brokerInfo.brokerID, 10'000), + Sig(sfCounterpartySignature, broker), + kLoanServiceFee(mpt(100).value()), + kPaymentInterval(100), + Fee(XRP(100)), + Ter(tecNO_AUTH)); + env.close(); + } + + void + runAmendmentIndependent() + { + testServiceFeeOnBrokerDeepFreeze(); + } + + // Tests run under each entry in amendmentCombinations(). + void + runAmendmentSensitive(FeatureBitset features) + { + testSequentialFLCDepletion(features); + testWithdrawReflectsUnrealizedLoss(features); + testLoanPayBrokerOwnerMissingTrustline(features); + testLoanPayBrokerOwnerUnauthorizedMPT(features); + testLoanPayBrokerOwnerNoPermissionedDomainMPT(features); + testLoanSetBrokerOwnerNoPermissionedDomainMPT(features); + } + +public: + void + run() override + { + runAmendmentIndependent(); + for (auto const& features : jtx::amendmentCombinations( + {fixCleanup3_1_3, fixCleanup3_2_0, featureMPTokensV2}, all_)) + runAmendmentSensitive(features); + } +}; + +BEAST_DEFINE_TESTSUITE(LoanCoverFreezeAuth, tx, xrpl); + +} // namespace xrpl::test diff --git a/src/test/app/lending/LoanInvariants_test.cpp b/src/test/app/lending/LoanInvariants_test.cpp new file mode 100644 index 00000000000..59eb51fd54d --- /dev/null +++ b/src/test/app/lending/LoanInvariants_test.cpp @@ -0,0 +1,875 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +namespace xrpl::test { + +class LoanInvariants_test : public LoanTestBase +{ +private: + // Each of these regression tests reproduces a single fuzzer-found (FIND-*) + // scenario against xrpl::detail::computePeriodicPayment / + // loanComputePaymentParts. They're merged into one function, one block + // per finding, because each is a narrow, self-contained repro that + // shares little beyond the surrounding scaffold. + void + testLoanPayComputePeriodicPaymentInvariants(FeatureBitset features) + { + using namespace jtx; + using namespace std::chrono_literals; + using namespace Lending; + + // From FIND-012 + { + testcase << "LoanPay xrpl::detail::computePeriodicPayment : " + "valid rate"; + + Env env(*this, features); + + Account const issuer{"issuer"}; + Account const lender{"lender"}; + Account const borrower{"borrower"}; + + BrokerParameters const brokerParams; + env.fund(XRP(brokerParams.vaultDeposit * 100), issuer, lender, borrower); + env.close(); + + PrettyAsset const xrpAsset{xrpIssue(), 1'000'000}; + BrokerInfo const broker{createVaultAndBroker(env, xrpAsset, lender, brokerParams)}; + + using namespace loan; + + auto const loanSetFee = Fee(env.current()->fees().base * 2); + Number const principalRequest{640562, -5}; + + Number const serviceFee{2462611968}; + std::uint32_t const numPayments{4294967295 / 800}; + + auto createJson = env.json( + set(borrower, broker.brokerID, principalRequest), + Fee(loanSetFee), + kLoanServiceFee(serviceFee), + kPaymentTotal(numPayments), + Json(sfCounterpartySignature, json::ValueType::Object)); + + createJson["CloseInterestRate"] = 55374; + createJson["ClosePaymentFee"] = "3825205248"; + createJson["LatePaymentFee"] = "237"; + createJson["LoanOriginationFee"] = "0"; + createJson["OverpaymentFee"] = 35167; + createJson["OverpaymentInterestRate"] = 1360; + createJson["PaymentInterval"] = 727; + + auto const keylet = nextLoanKeylet(env, broker); + + createJson = env.json(createJson, Sig(sfCounterpartySignature, lender)); + // Fails in preclaim because principal requested can't be + // represented as XRP + env(createJson, Ter(tecPRECISION_LOSS)); + env.close(); + + BEAST_EXPECT(!env.le(keylet)); + + Number const actualPrincipal{6}; + + createJson[sfPrincipalRequested] = actualPrincipal; + createJson.removeMember(sfSequence.jsonName); + createJson = env.json(createJson, Sig(sfCounterpartySignature, lender)); + // Fails in doApply because the payment is too small to be + // represented as XRP. + env(createJson, Ter(tecPRECISION_LOSS)); + env.close(); + } + + // From FIND-010 + { + testcase << "xrpl::loanComputePaymentParts : valid total interest"; + + Env env(*this, features); + + Account const issuer{"issuer"}; + Account const lender{"lender"}; + Account const borrower{"borrower"}; + + PrettyAsset const iouAsset = createFundedIouAsset(env, issuer, lender, borrower); + + BrokerInfo const broker{createVaultAndBroker(env, iouAsset, lender)}; + + using namespace loan; + + auto const loanSetFee = Fee(env.current()->fees().base * 2); + Number const principalRequest{1, 3}; + + auto createJson = env.json( + set(borrower, broker.brokerID, principalRequest), + Fee(loanSetFee), + Json(sfCounterpartySignature, json::ValueType::Object)); + + createJson["CloseInterestRate"] = 47299; + createJson["ClosePaymentFee"] = "3985819770"; + createJson["InterestRate"] = 92; + createJson["LatePaymentFee"] = "3866894865"; + createJson["LoanOriginationFee"] = "0"; + createJson["LoanServiceFee"] = "2348810240"; + createJson["OverpaymentFee"] = 58545; + createJson["PaymentInterval"] = 60; + createJson["PaymentTotal"] = 1; + createJson["PrincipalRequested"] = "0.000763058"; + + auto const keylet = nextLoanKeylet(env, broker); + + createJson = env.json(createJson, Sig(sfCounterpartySignature, lender)); + env(createJson); + env.close(); + + auto loanPayTx = env.json(pay(borrower, keylet.key, STAmount{broker.asset, Number{}})); + loanPayTx["Amount"]["value"] = "0.000281284125490196"; + env(loanPayTx, Ter(tecINSUFFICIENT_PAYMENT)); + env.close(); + } + + // From FIND-009 + { + testcase << "xrpl::loanComputePaymentParts : totalPrincipalPaid " + "rounded"; + + Env env(*this, features); + + Account const issuer{"issuer"}; + Account const lender{"lender"}; + Account const borrower{"borrower"}; + + PrettyAsset const iouAsset = createFundedIouAsset(env, issuer, lender, borrower); + + BrokerInfo const broker{createVaultAndBroker(env, iouAsset, lender)}; + + using namespace loan; + + auto const loanSetFee = Fee(env.current()->fees().base * 2); + Number const principalRequest{1, 3}; + + auto createJson = env.json( + set(borrower, broker.brokerID, principalRequest), + Fee(loanSetFee), + Json(sfCounterpartySignature, json::ValueType::Object)); + + createJson["ClosePaymentFee"] = "0"; + createJson["InterestRate"] = 24346; + createJson["LateInterestRate"] = 65535; + createJson["LatePaymentFee"] = "0"; + createJson["LoanOriginationFee"] = "218"; + createJson["LoanServiceFee"] = "0"; + createJson["PaymentInterval"] = 60; + createJson["PaymentTotal"] = 5678; + createJson["PrincipalRequested"] = "9924.81"; + + auto const keylet = nextLoanKeylet(env, broker); + + createJson = env.json(createJson, Sig(sfCounterpartySignature, lender)); + env(createJson, Ter(tesSUCCESS)); + env.close(); + + auto const baseFee = env.current()->fees().base; + + auto const stateBefore = getCurrentState(env, broker, keylet); + + { + auto loanPayTx = + env.json(pay(borrower, keylet.key, STAmount{broker.asset, Number{}})); + Number const amount{3074'745'058'823'529, -12}; + BEAST_EXPECT(to_string(amount) == "3074.745058823529"); + XRPAmount const payFee{ + baseFee * + (amount / stateBefore.periodicPayment / kLoanPaymentsPerFeeIncrement + 1)}; + loanPayTx["Amount"]["value"] = to_string(amount); + env(loanPayTx, Fee(payFee), Ter(tesSUCCESS)); + env.close(); + } + + { + auto loanPayTx = + env.json(pay(borrower, keylet.key, STAmount{broker.asset, Number{}})); + Number const amount{6732'118'170'944'051, -12}; + BEAST_EXPECT(to_string(amount) == "6732.118170944051"); + XRPAmount const payFee{ + baseFee * + (amount / stateBefore.periodicPayment / kLoanPaymentsPerFeeIncrement + 1)}; + loanPayTx["Amount"]["value"] = to_string(amount); + env(loanPayTx, Fee(payFee), Ter(tesSUCCESS)); + env.close(); + } + + auto const stateAfter = getCurrentState(env, broker, keylet); + // Total interest outstanding is non-negative + BEAST_EXPECT(stateAfter.totalValue >= stateAfter.principalOutstanding); + // Principal paid is non-negative + BEAST_EXPECT(stateBefore.principalOutstanding >= stateAfter.principalOutstanding); + // Total value change is non-negative + BEAST_EXPECT(stateBefore.totalValue >= stateAfter.totalValue); + // Value delta is larger or same as principal delta (meaning + // non-negative interest paid) + BEAST_EXPECT( + (stateBefore.totalValue - stateAfter.totalValue) >= + (stateBefore.principalOutstanding - stateAfter.principalOutstanding)); + } + + // From FIND-008 + { + testcase << "xrpl::loanComputePaymentParts : loanValueChange rounded"; + + Env env(*this, features); + + Account const issuer{"issuer"}; + Account const lender{"lender"}; + Account const borrower{"borrower"}; + + PrettyAsset const iouAsset = + createFundedIouAsset(env, issuer, lender, borrower, 100'000'000, 10'000'000); + + BrokerInfo const broker{createVaultAndBroker(env, iouAsset, lender)}; + { + auto const coverDepositValue = + broker.asset(broker.params.coverDeposit * 10).value(); + env(loanBroker::coverDeposit(lender, broker.brokerID, coverDepositValue)); + env.close(); + } + + using namespace loan; + + auto const loanSetFee = Fee(env.current()->fees().base * 2); + Number const principalRequest{1, 3}; + + auto createJson = env.json( + set(borrower, broker.brokerID, principalRequest), + Fee(loanSetFee), + Json(sfCounterpartySignature, json::ValueType::Object)); + + createJson["ClosePaymentFee"] = "0"; + createJson["InterestRate"] = 12833; + createJson["LateInterestRate"] = 77048; + createJson["LatePaymentFee"] = "0"; + createJson["LoanOriginationFee"] = "218"; + createJson["LoanServiceFee"] = "0"; + createJson["PaymentInterval"] = 752; + createJson["PaymentTotal"] = 5678; + createJson["PrincipalRequested"] = "9924.81"; + + auto const keylet = nextLoanKeylet(env, broker); + + createJson = env.json(createJson, Sig(sfCounterpartySignature, lender)); + env(createJson, Ter(tesSUCCESS)); + env.close(); + + auto const baseFee = env.current()->fees().base; + + auto const stateBefore = getCurrentState(env, broker, keylet); + BEAST_EXPECT(stateBefore.paymentRemaining == 5678); + BEAST_EXPECT(stateBefore.paymentRemaining > kLoanMaximumPaymentsPerTransaction); + + auto loanPayTx = env.json(pay(borrower, keylet.key, STAmount{broker.asset, Number{}})); + Number const amount{9924'81, -2}; + BEAST_EXPECT(to_string(amount) == "9924.81"); + XRPAmount const payFee{ + baseFee * + (amount / stateBefore.periodicPayment / kLoanPaymentsPerFeeIncrement + 1)}; + loanPayTx["Amount"]["value"] = to_string(amount); + env(loanPayTx, Fee(payFee), Ter(tesSUCCESS)); + env.close(); + + auto const stateAfter = getCurrentState(env, broker, keylet); + BEAST_EXPECT( + stateAfter.paymentRemaining == + stateBefore.paymentRemaining - kLoanMaximumPaymentsPerTransaction); + } + } + + void + testLoanPayDebtDecreaseInvariant(FeatureBitset features) + { + // From FIND-007 + testcase << "LoanPay xrpl::LoanPay::doApply : debtDecrease " + "rounding good"; + + using namespace jtx; + using namespace std::chrono_literals; + using namespace Lending; + Env env(*this, features); + + Account const issuer{"issuer"}; + Account const lender{"lender"}; + Account const borrower{"borrower"}; + + PrettyAsset const iouAsset = createFundedIouAsset(env, issuer, lender, borrower); + + BrokerInfo const broker{createVaultAndBroker(env, iouAsset, lender)}; + + using namespace loan; + + auto const baseFee = env.current()->fees().base; + auto const loanSetFee = Fee(baseFee * 2); + Number const principalRequest{1, 3}; + + auto createJson = env.json( + set(borrower, broker.brokerID, principalRequest), + Fee(loanSetFee), + Json(sfCounterpartySignature, json::ValueType::Object)); + + createJson["ClosePaymentFee"] = "0"; + createJson["GracePeriod"] = 60; + createJson["InterestRate"] = 24346; + createJson["LateInterestRate"] = 65535; + createJson["LatePaymentFee"] = "0"; + createJson["LoanOriginationFee"] = "218"; + createJson["LoanServiceFee"] = "0"; + createJson["PaymentInterval"] = 60; + createJson["PaymentTotal"] = 5678; + createJson["PrincipalRequested"] = "9924.81"; + + auto const keylet = nextLoanKeylet(env, broker); + + createJson = env.json(createJson, Sig(sfCounterpartySignature, lender)); + env(createJson, Ter(tesSUCCESS)); + env.close(); + + auto const pseudoAcct = brokerPseudoAccount(env, broker, lender); + + VerifyLoanStatus const verifyLoanStatus(env, broker, pseudoAcct, keylet); + auto const originalState = getCurrentState(env, broker, keylet); + verifyLoanStatus(originalState); + + Number const payment{3'269'349'176'470'588, -12}; + XRPAmount const payFee{ + baseFee * + ((payment / originalState.periodicPayment) / kLoanPaymentsPerFeeIncrement + 1)}; + auto loanPayTx = + env.json(pay(borrower, keylet.key, STAmount{broker.asset, payment}), Fee(payFee)); + BEAST_EXPECT(to_string(payment) == "3269.349176470588"); + env(loanPayTx, Ter(tesSUCCESS)); + env.close(); + + auto const newState = getCurrentState(env, broker, keylet); + BEAST_EXPECT( + isRounded(broker.asset, newState.managementFeeOutstanding, originalState.loanScale)); + BEAST_EXPECT(newState.managementFeeOutstanding < originalState.managementFeeOutstanding); + BEAST_EXPECT(isRounded(broker.asset, newState.totalValue, originalState.loanScale)); + BEAST_EXPECT( + isRounded(broker.asset, newState.principalOutstanding, originalState.loanScale)); + } + + void + testAccountSendMptMinAmountInvariant(FeatureBitset features) + { + // (From FIND-006) + testcase << "LoanSet trigger xrpl::accountSendMPT : minimum amount " + "and MPT"; + + using namespace jtx; + using namespace std::chrono_literals; + Env env(*this, features); + + Account const issuer{"issuer"}; + Account const lender{"lender"}; + Account const borrower{"borrower"}; + + env.fund(XRP(1'000'000), issuer, lender, borrower); + env.close(); + + MPTTester mptt{env, issuer, kMptInitNoFund}; + mptt.create({.flags = tfMPTCanClawback | tfMPTCanTransfer | tfMPTCanLock}); + PrettyAsset const mptAsset = mptt.issuanceID(); + mptt.authorize({.account = lender}); + mptt.authorize({.account = borrower}); + env(pay(issuer, lender, mptAsset(2'000'000))); + env(pay(issuer, borrower, mptAsset(1'000))); + env.close(); + + BrokerInfo const broker{createVaultAndBroker(env, mptAsset, lender)}; + + using namespace loan; + + auto const loanSetFee = Fee(env.current()->fees().base * 2); + Number const principalRequest{1, 3}; + + auto createJson = env.json( + set(borrower, broker.brokerID, principalRequest), + Fee(loanSetFee), + Json(sfCounterpartySignature, json::ValueType::Object)); + + createJson["CloseInterestRate"] = 76671; + createJson["ClosePaymentFee"] = "2061925410"; + createJson["GracePeriod"] = 434; + createJson["InterestRate"] = 50302; + createJson["LateInterestRate"] = 30322; + createJson["LatePaymentFee"] = "294427911"; + createJson["LoanOriginationFee"] = "3250635102"; + createJson["LoanServiceFee"] = "9557386"; + createJson["OverpaymentFee"] = 51249; + createJson["OverpaymentInterestRate"] = 14304; + createJson["PaymentInterval"] = 434; + createJson["PaymentTotal"] = "2891743748"; + createJson["PrincipalRequested"] = "8516.98"; + + auto const brokerStateBefore = env.le(keylet::loanBroker(broker.brokerID)); + + createJson = env.json(createJson, Sig(sfCounterpartySignature, lender)); + env(createJson, Ter(temINVALID)); + env.close(); + } + + // Verify that LoanPay, LoanBrokerCoverWithdraw, and LoanSet all use the + // same vault-scale minimum cover when fixCleanup3_2_0 is enabled. + // Before the amendment, each transactor computed its minimum cover at a + // different precision (loanScale, debtScale, or the raw unrounded + // tenthBipsOfValue), which could lead to inconsistent decisions for the + // same broker state. After the amendment all three use + // minimumBrokerCover at vaultScale. + void + testMinimumBrokerCoverConsistency(FeatureBitset features) + { + using namespace jtx; + using namespace loan; + using namespace loanBroker; + + bool const withAmendment = features[fixCleanup3_2_0]; + + struct Ctx + { + jtx::Account issuer; + jtx::Account lender; + jtx::Account borrower; + jtx::PrettyAsset iou; + BrokerInfo broker; + BrokerParameters brokerParams; + }; + + // Shared setup, parametrized by vaultDeposit (the only varying setup + // field across the three scenarios). Each call runs in its own Env + // so multiple invocations within one scenario cannot interfere. + // The caller is responsible for invoking testcase(...) before the + // first runTest call of each scenario. + auto runTest = [&](Number vaultDeposit, auto&& body) { + Env env(*this, features); + + Account const issuer{"issuer"}; + Account const lender{"lender"}; + Account const borrower{"borrower"}; + + env.fund(XRP(1'000'000'000), issuer, lender, borrower); + env.close(); + + // Enable clawback on the issuer *before* any trust lines exist + // (asfAllowTrustLineClawback requires an empty owner directory). + env(fset(issuer, asfAllowTrustLineClawback)); + env.close(); + + PrettyAsset const iou = issuer[iouCurrency_]; + env(trust(lender, iou(1'000'000'000))); + env(trust(borrower, iou(1'000'000'000))); + env.close(); + env(pay(issuer, lender, iou(100'000'000))); + env(pay(issuer, borrower, iou(100'000'000))); + env.close(); + + // 13.37% — non-round rate produces a messier minimum. + BrokerParameters const brokerParams{ + .vaultDeposit = vaultDeposit, + .debtMax = 0, + .coverRateMin = TenthBips32{13'370}, + .coverDeposit = 5'000, + .managementFeeRate = TenthBips16{500}}; + + BrokerInfo const broker = createVaultAndBroker(env, iou, lender, brokerParams); + + body( + env, + Ctx{.issuer = issuer, + .lender = lender, + .borrower = borrower, + .iou = iou, + .broker = broker, + .brokerParams = brokerParams}); + }; + + // Scenario 1 — LoanPay + // + // Verify that LoanPay's minimum cover check uses vault scale (not + // loan scale). Before the amendment, different loans could produce + // different fee routing decisions for the same broker-level state. + // Small vault deposit => vaultScale = -12. + testcase("LoanPay minimum cover scale consistency"); + { + struct LoanKeylets + { + Keylet tiny; + Keylet big; + }; + + // Create the tiny + big loans and reduce cover via clawback so + // that subsequent LoanPay calls hit the minimum-cover boundary. + // Used by the two pay-and-check sub-tests below so each can run + // in its own Env. + auto setupLoansAndClawback = [&](Env& env, Ctx const& c) -> std::optional { + Asset const asset{c.iou}; + + // Create the TINY loan first (while vaultScale is still + // small). principal 0.01, 0% interest, 1 payment => + // loanScale = vaultScale. + auto const brokerSle1 = env.le(keylet::loanBroker(c.broker.brokerID)); + if (!BEAST_EXPECT(brokerSle1)) + return std::nullopt; + auto const tinyLoanSeq = brokerSle1->at(sfLoanSequence); + auto const tinyLoanKeylet = keylet::loan(c.broker.brokerID, tinyLoanSeq); + + env(set(c.borrower, c.broker.brokerID, Number{1, -2}), + Sig(sfCounterpartySignature, c.lender), + kInterestRate(TenthBips32{0}), + kPaymentTotal(1), + kPaymentInterval(86400 * 365), + Fee(XRP(10))); + env.close(); + + // Create the BIG loan second. 100% annual interest over 20 + // payments pushes totalValueOutstanding high enough that + // loanScale > vaultScale. + auto const brokerSle2 = env.le(keylet::loanBroker(c.broker.brokerID)); + if (!BEAST_EXPECT(brokerSle2)) + return std::nullopt; + auto const bigLoanSeq = brokerSle2->at(sfLoanSequence); + auto const bigLoanKeylet = keylet::loan(c.broker.brokerID, bigLoanSeq); + + env(set(c.borrower, c.broker.brokerID, Number{500}), + Sig(sfCounterpartySignature, c.lender), + kInterestRate(TenthBips32{100'000}), + kPaymentTotal(20), + kPaymentInterval(86400 * 365), + Fee(XRP(10))); + env.close(); + + // The tiny loan's scale is frozen at the vault's pre-big-loan + // scale, so it is strictly smaller than the big loan's. + // After the big loan is created the vault absorbs its value, + // pushing vaultScale up to match bigLoanScale. + auto const tinyLoanSle = env.le(tinyLoanKeylet); + auto const bigLoanSle = env.le(bigLoanKeylet); + auto const vaultSle = env.le(keylet::vault(c.broker.vaultID)); + if (!BEAST_EXPECT(tinyLoanSle) || !BEAST_EXPECT(bigLoanSle) || + !BEAST_EXPECT(vaultSle)) + return std::nullopt; + if (!BEAST_EXPECT(tinyLoanSle->at(sfLoanScale) == -12) || + !BEAST_EXPECT(bigLoanSle->at(sfLoanScale) == -11) || + !BEAST_EXPECT(getAssetsTotalScale(vaultSle) == -11)) + return std::nullopt; + + // Use issuer clawback to reduce cover to the minimum the + // clawback transactor allows. Compute the amount as + // initialCover - expectedCoverAfter so we exercise the exact + // clawback rather than relying on the transactor to clip + // down. + // + // Before the amendment the clawback minimum is the + // *unrounded* tenthBipsOfValue — strictly less than the + // rounded-at-vaultScale minimum LoanPay uses for the big + // loan. After the amendment both clawback and LoanPay use + // the same rounded minimum (via minimumBrokerCover), so + // cover lands exactly at that threshold. + Number const expectedCoverAfter = withAmendment ? Number{1330651855688460000, -15} + : Number{1330651855688458000, -15}; + Number const clawbackAmount = + Number{c.brokerParams.coverDeposit} - expectedCoverAfter; + + env(coverClawback(c.issuer), + kLoanBrokerId(c.broker.brokerID), + kAmount(STAmount{asset, clawbackAmount})); + env.close(); + + auto const brokerSle = env.le(keylet::loanBroker(c.broker.brokerID)); + if (!BEAST_EXPECT(brokerSle) || + !BEAST_EXPECT(brokerSle->at(sfCoverAvailable) == expectedCoverAfter)) + return std::nullopt; + + return LoanKeylets{.tiny = tinyLoanKeylet, .big = bigLoanKeylet}; + }; + + // Pay one loan and report whether the fee went to the broker's + // pseudo account (the fallback when cover < minimum) rather + // than to the owner. + auto feeGoesToPseudo = [&](Env& env, Ctx const& c, Keylet const& loanKeylet) -> bool { + Asset const asset{c.iou}; + auto const brokerSle = env.le(keylet::loanBroker(c.broker.brokerID)); + if (!BEAST_EXPECT(brokerSle)) + return false; + auto const pseudoAcct = Account("pseudo", brokerSle->at(sfAccount)); + auto const pseudoBefore = env.balance(pseudoAcct, c.iou); + + auto const payLoan = env.le(loanKeylet); + if (!BEAST_EXPECT(payLoan)) + return false; + auto const periodicPayment = payLoan->at(sfPeriodicPayment); + auto const serviceFee = payLoan->at(sfLoanServiceFee); + std::int32_t const loanScale = payLoan->at(sfLoanScale); + + auto const payment = roundPeriodicPayment(asset, periodicPayment, loanScale); + auto const payAmt = STAmount{asset, payment + serviceFee}; + + env(loan::pay(c.borrower, loanKeylet.key, payAmt), Fee(XRP(10))); + env.close(); + + auto const pseudoAfter = env.balance(pseudoAcct, c.iou); + return pseudoAfter.number() > pseudoBefore.number(); + }; + + // Pay the BIG loan in its own Env so its outcome cannot affect + // the TINY-loan check. With the fix, LoanPay and clawback use + // the same vaultScale minimum (cover == minAtVaultScale => + // fee to owner). Without the fix, LoanPay uses bigLoanScale=-11, + // rounds up to a larger minimum than what clawback used => + // cover < min => fee to pseudo. + runTest(/*vaultDeposit=*/1'000, [&](Env& env, Ctx const& c) { + auto const loans = setupLoansAndClawback(env, c); + if (!loans) + return; + BEAST_EXPECT(feeGoesToPseudo(env, c, loans->big) == !withAmendment); + }); + + // Pay the TINY loan in its own Env. Fee goes to the owner + // either way: + // - With the fix: LoanPay uses vaultScale=-11 (same as + // clawback) => owner. + // - Without the fix: LoanPay uses tinyLoanScale=-12, rounds + // up at -12 (a no-op) => min == cover => owner. + runTest(/*vaultDeposit=*/1'000, [&](Env& env, Ctx const& c) { + auto const loans = setupLoansAndClawback(env, c); + if (!loans) + return; + BEAST_EXPECT(!feeGoesToPseudo(env, c, loans->tiny)); + }); + } + + // Scenario 2 — LoanBrokerCoverWithdraw + // + // Verify that CoverWithdraw's minimum cover check uses vault scale + // (not scale(debtTotal, asset)). Before the amendment, CoverWithdraw + // used: + // roundToAsset(asset, tenthBipsOfValue(debt, rate), scale(debt, asset)) + // which could disagree with LoanPay's minimum (which used loanScale). + // + // Use a large vault deposit so that vaultScale (from AssetsTotal) is + // strictly larger than debtScale (from DebtTotal). With + // vaultDeposit = 100,000: after the big loan + // AssetsTotal ≈ 109,500 → vaultScale = -10 + // DebtTotal ≈ 10,000 → debtScale = -11 + // The one-order-of-magnitude gap makes roundToAsset at -10 truncate + // more aggressively than at -11, exposing the bug. + testcase("CoverWithdraw minimum cover scale consistency"); + runTest( + /*vaultDeposit=*/100'000, [&](Env& env, Ctx const& c) { + Asset const asset{c.iou}; + + // Create only the big loan to push DebtTotal up to ~10,000 + // while AssetsTotal stays around 109,500 (dominated by the + // large vault deposit). + env(set(c.borrower, c.broker.brokerID, Number{500}), + Sig(sfCounterpartySignature, c.lender), + kInterestRate(TenthBips32{100'000}), + kPaymentTotal(20), + kPaymentInterval(86400 * 365), + Fee(XRP(10))); + env.close(); + + // Read broker state and compute both old and new minimums. + auto const brokerSle = env.le(keylet::loanBroker(c.broker.brokerID)); + auto const vaultSle = env.le(keylet::vault(c.broker.vaultID)); + if (!BEAST_EXPECT(brokerSle) || !BEAST_EXPECT(vaultSle)) + return; + + auto const coverAvail = brokerSle->at(sfCoverAvailable); + auto const debtTotal = brokerSle->at(sfDebtTotal); + auto const vaultScale = getAssetsTotalScale(vaultSle); + auto const debtScale = scale(debtTotal, asset); + + // Sanity: debt scale differs from vault scale for this setup. + BEAST_EXPECT(debtScale < vaultScale); + + auto const oldMin = [&]() { + NumberRoundModeGuard const mg(Number::RoundingMode::Upward); + return roundToAsset( + asset, + tenthBipsOfValue(debtTotal, TenthBips32{c.brokerParams.coverRateMin}), + debtScale); + }(); + auto const newMin = minimumBrokerCover( + debtTotal, TenthBips32{c.brokerParams.coverRateMin}, vaultSle); + + // The new (vaultScale) minimum must be strictly larger than + // the old (debtScale) minimum — that is the gap the amendment + // closes. + Number const expectedNewMin{1330650518688500000, -15}; + Number const expectedOldMin{1330650518688472000, -15}; + BEAST_EXPECT(newMin == expectedNewMin); + BEAST_EXPECT(oldMin == expectedOldMin); + + // Try to withdraw so that remaining cover lands between the + // two minimums: oldMin < target < newMin. + auto const target = oldMin + (newMin - oldMin) / 2; + auto const withdrawAmount = STAmount{asset, coverAvail - target}; + + if (withAmendment) + { + // CoverWithdraw now uses vaultScale: target < newMin + // => FAILS. + env(coverWithdraw(c.lender, c.broker.brokerID, withdrawAmount), + Ter(tecINSUFFICIENT_FUNDS)); + } + else + { + // Old CoverWithdraw uses debtScale: target > oldMin + // => SUCCEEDS. + env(coverWithdraw(c.lender, c.broker.brokerID, withdrawAmount)); + } + env.close(); + }); + + // Scenario 3 — LoanSet + // + // Verify that LoanSet's minimum cover check uses vault scale (not the + // raw unrounded tenthBipsOfValue). Before the amendment, LoanSet + // used tenthBipsOfValue(newDebtTotal, coverRateMinimum) (no + // roundToAsset), while clawback/withdraw used different formulas. + // After the amendment all use minimumBrokerCover at vaultScale, and + // rounding at a coarser scale can absorb a tiny debt increase — + // allowing a loan that would otherwise be rejected. + testcase("LoanSet minimum cover scale consistency"); + runTest( + /*vaultDeposit=*/1'000, [&](Env& env, Ctx const& c) { + // Create the tiny loan (scale -12) AND the big loan (scale + // -11). Both loans are needed so that DebtTotal has a full + // 16-digit mantissa — a "messy" value where roundToAsset at + // vaultScale actually truncates digits and produces a + // different result from the raw tenthBipsOfValue. With only + // the big loan, DebtTotal has ~4 significant digits and + // rounding at scale -11 is a no-op, masking the amendment's + // effect. + env(set(c.borrower, c.broker.brokerID, Number{1, -2}), + Sig(sfCounterpartySignature, c.lender), + kInterestRate(TenthBips32{0}), + kPaymentTotal(1), + kPaymentInterval(86400 * 365), + Fee(XRP(10))); + env.close(); + + env(set(c.borrower, c.broker.brokerID, Number{500}), + Sig(sfCounterpartySignature, c.lender), + kInterestRate(TenthBips32{100'000}), + kPaymentTotal(20), + kPaymentInterval(86400 * 365), + Fee(XRP(10))); + env.close(); + + // Clawback to reduce cover to the clawback transactor's + // minimum. Pass the exact amount rather than relying on the + // transactor to clip down; the setup matches Scenario 1 so + // the same residual-cover values apply. + Number const expectedCoverAfter = withAmendment ? Number{1330651855688460000, -15} + : Number{1330651855688458000, -15}; + Number const clawbackAmount = + Number{c.brokerParams.coverDeposit} - expectedCoverAfter; + env(coverClawback(c.issuer), + kLoanBrokerId(c.broker.brokerID), + kAmount(c.iou(clawbackAmount))); + env.close(); + + // Verify scales. + auto const vaultSle = env.le(keylet::vault(c.broker.vaultID)); + if (!BEAST_EXPECT(vaultSle)) + return; + auto const vaultScale = getAssetsTotalScale(vaultSle); + BEAST_EXPECT(vaultScale == -11); + + // Now try to create a tiny additional loan. Principal is + // 1e-11 (the smallest value that survives the precision + // check at loanScale = vaultScale = -11), with 0% interest + // and 1 payment. + // + // The tiny debt increase adds ~1.337e-12 to the unrounded + // minimum. + // - Without the amendment: the old LoanSet formula rounds + // up during tenthBipsOfValue (16-digit Number + // normalisation), pushing the minimum past the cover left + // by clawback => tecINSUFFICIENT_FUNDS. + // - With the amendment: minimumBrokerCover rounds at + // vaultScale=-11, which absorbs the tiny increase — the + // rounded minimum stays the same => tesSUCCESS. + auto const tinyPrincipal = Number{1, -11}; + + if (withAmendment) + { + env(set(c.borrower, c.broker.brokerID, tinyPrincipal), + Sig(sfCounterpartySignature, c.lender), + kInterestRate(TenthBips32{0}), + kPaymentTotal(1), + kPaymentInterval(86400 * 365), + Fee(XRP(10))); + } + else + { + env(set(c.borrower, c.broker.brokerID, tinyPrincipal), + Sig(sfCounterpartySignature, c.lender), + kInterestRate(TenthBips32{0}), + kPaymentTotal(1), + kPaymentInterval(86400 * 365), + Fee(XRP(10)), + Ter(tecINSUFFICIENT_FUNDS)); + } + env.close(); + }); + } + + // Tests run under each entry in amendmentCombinations(). + void + runAmendmentSensitive(FeatureBitset features) + { + testLoanPayComputePeriodicPaymentInvariants(features); + testLoanPayDebtDecreaseInvariant(features); + testAccountSendMptMinAmountInvariant(features); + testMinimumBrokerCoverConsistency(features); + } + +public: + void + run() override + { + for (auto const& features : jtx::amendmentCombinations( + {fixCleanup3_1_3, fixCleanup3_2_0, featureMPTokensV2}, all_)) + runAmendmentSensitive(features); + } +}; + +BEAST_DEFINE_TESTSUITE(LoanInvariants, tx, xrpl); + +} // namespace xrpl::test diff --git a/src/test/app/lending/LoanLifecycle_test.cpp b/src/test/app/lending/LoanLifecycle_test.cpp new file mode 100644 index 00000000000..f2da88bfa70 --- /dev/null +++ b/src/test/app/lending/LoanLifecycle_test.cpp @@ -0,0 +1,686 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +namespace xrpl::test { + +class LoanLifecycle_test : public LoanTestBase +{ +private: + void + testLifecycle(FeatureBitset features) + { + testcase("Lifecycle"); + using namespace jtx; + + // Create 3 loan brokers: one for XRP, one for an IOU, and one for + // an MPT. That'll require three corresponding SAVs. + Env env(*this, features); + + Account const issuer{"issuer"}; + // For simplicity, lender will be the sole actor for the vault & + // brokers. + Account const lender{"lender"}; + // Borrower only wants to borrow + Account const borrower{"borrower"}; + // Evan will attempt to be naughty + Account const evan{"evan"}; + // Do not fund alice + Account const alice{"alice"}; + + // Fund the accounts and trust lines with the same amount so that + // tests can use the same values regardless of the asset. + env.fund(XRP(100'000'000), issuer, noripple(lender, borrower, evan)); + env.close(); + + // Create assets + PrettyAsset const xrpAsset{xrpIssue(), 1'000'000}; + PrettyAsset const iouAsset = issuer[iouCurrency_]; + env(trust(lender, iouAsset(10'000'000))); + env(trust(borrower, iouAsset(10'000'000))); + env(trust(evan, iouAsset(10'000'000))); + env(pay(issuer, evan, iouAsset(1'000'000))); + env(pay(issuer, lender, iouAsset(10'000'000))); + // Fund the borrower with enough to cover interest and fees + env(pay(issuer, borrower, iouAsset(10'000))); + env.close(); + + MPTTester mptt{env, issuer, kMptInitNoFund}; + mptt.create({.flags = tfMPTCanClawback | tfMPTCanTransfer | tfMPTCanLock}); + // Scale the MPT asset a little bit so we can get some interest + PrettyAsset const mptAsset{mptt.issuanceID(), 100}; + mptt.authorize({.account = lender}); + mptt.authorize({.account = borrower}); + mptt.authorize({.account = evan}); + env(pay(issuer, lender, mptAsset(10'000'000))); + env(pay(issuer, evan, mptAsset(1'000'000))); + // Fund the borrower with enough to cover interest and fees + env(pay(issuer, borrower, mptAsset(10'000))); + env.close(); + + std::array const assets{iouAsset, xrpAsset, mptAsset}; + + // Create vaults and loan brokers + std::vector brokers; + brokers.reserve(assets.size()); + for (auto const& asset : assets) + { + brokers.emplace_back(createVaultAndBroker( + env, asset, lender, BrokerParameters{.data = "spam spam spam spam"})); + } + + // Create and update Loans + for (auto const& broker : brokers) + { + for (int amountExponent = 3; amountExponent >= 3; --amountExponent) + { + Number const loanAmount{1, amountExponent}; + for (int interestExponent = 0; interestExponent >= 0; --interestExponent) + { + testCaseWrapper(env, mptt, assets, broker, loanAmount, interestExponent); + } + } + + if (auto brokerSle = env.le(keylet::loanBroker(broker.brokerID)); + BEAST_EXPECT(brokerSle)) + { + BEAST_EXPECT(brokerSle->at(sfOwnerCount) == 0); + BEAST_EXPECT(brokerSle->at(sfDebtTotal) == 0); + + auto const coverAvailable = brokerSle->at(sfCoverAvailable); + env(loanBroker::coverWithdraw( + lender, broker.brokerID, STAmount(broker.asset, coverAvailable))); + env.close(); + + brokerSle = env.le(keylet::loanBroker(broker.brokerID)); + BEAST_EXPECT(brokerSle && brokerSle->at(sfCoverAvailable) == 0); + } + // Verify we can delete the loan broker + env(loanBroker::del(lender, broker.brokerID)); + env.close(); + } + } + + void + testSelfLoan(FeatureBitset features) + { + testcase << "Self Loan"; + + using namespace jtx; + using namespace std::chrono_literals; + // Create 3 loan brokers: one for XRP, one for an IOU, and one for + // an MPT. That'll require three corresponding SAVs. + Env env(*this, features); + + Account const issuer{"issuer"}; + // For simplicity, lender will be the sole actor for the vault & + // brokers. + Account const lender{"lender"}; + + // Fund the accounts and trust lines with the same amount so that + // tests can use the same values regardless of the asset. + env.fund(XRP(100'000'000), issuer, noripple(lender)); + env.close(); + + // Use an XRP asset for simplicity + PrettyAsset const xrpAsset{xrpIssue(), 1'000'000}; + + // Create vaults and loan brokers + BrokerInfo broker{createVaultAndBroker(env, xrpAsset, lender)}; + + using namespace loan; + + auto const loanSetFee = Fee(env.current()->fees().base * 2); + Number const principalRequest{1, 3}; + + // The LoanSet json can be created without a counterparty signature, + // but it will not pass preflight + auto createJson = env.json( + set(lender, broker.brokerID, broker.asset(principalRequest).value()), Fee(loanSetFee)); + env(createJson, Ter(temBAD_SIGNER)); + + // Adding an empty counterparty signature object also fails, but + // at the RPC level. + createJson = env.json(createJson, Json(sfCounterpartySignature, json::ValueType::Object)); + env(createJson, Ter(telENV_RPC_FAILED)); + + if (auto const jt = env.jt(createJson); BEAST_EXPECT(jt.stx)) + { + Serializer s; + jt.stx->add(s); + auto const jr = env.rpc("submit", strHex(s.slice())); + + BEAST_EXPECT(jr.isMember(jss::result)); + auto const jResult = jr[jss::result]; + BEAST_EXPECT(jResult[jss::error] == "invalidTransaction"); + BEAST_EXPECT( + jResult[jss::error_exception] == + "fails local checks: Transaction has bad signature."); + } + + // Copy the transaction signature into the counterparty signature. + json::Value counterpartyJson{json::ValueType::Object}; + counterpartyJson[sfTxnSignature] = createJson[sfTxnSignature]; + counterpartyJson[sfSigningPubKey] = createJson[sfSigningPubKey]; + if (!BEAST_EXPECT(!createJson.isMember(jss::Signers))) + counterpartyJson[sfSigners] = createJson[sfSigners]; + + // The duplicated signature works + createJson = env.json(createJson, Json(sfCounterpartySignature, counterpartyJson)); + env(createJson); + + env.close(); + + auto const startDate = env.current()->header().parentCloseTime; + + // Loan is successfully created + { + auto const res = env.rpc("account_objects", lender.human()); + auto const objects = res[jss::result][jss::account_objects]; + + std::map types; + BEAST_EXPECT(objects.size() == 4); + for (auto const& object : objects) + { + ++types[object[sfLedgerEntryType].asString()]; + } + BEAST_EXPECT(types.size() == 4); + for (std::string const type : {"MPToken", "Vault", "LoanBroker", "Loan"}) + { + BEAST_EXPECT(types[type] == 1); + } + } + auto const loanID = [&]() { + json::Value params(json::ValueType::Object); + params[jss::account] = lender.human(); + params[jss::type] = "Loan"; + auto const res = env.rpc("json", "account_objects", to_string(params)); + auto const objects = res[jss::result][jss::account_objects]; + + BEAST_EXPECT(objects.size() == 1); + + auto const loan = objects[0u]; + BEAST_EXPECT(loan[sfBorrower] == lender.human()); + // soeDEFAULT fields are not returned if they're in the default + // state + BEAST_EXPECT(!loan.isMember(sfCloseInterestRate)); + BEAST_EXPECT(!loan.isMember(sfClosePaymentFee)); + BEAST_EXPECT(loan[sfFlags] == 0); + BEAST_EXPECT(loan[sfGracePeriod] == 60); + BEAST_EXPECT(!loan.isMember(sfInterestRate)); + BEAST_EXPECT(!loan.isMember(sfLateInterestRate)); + BEAST_EXPECT(!loan.isMember(sfLatePaymentFee)); + BEAST_EXPECT(loan[sfLoanBrokerID] == to_string(broker.brokerID)); + BEAST_EXPECT(!loan.isMember(sfLoanOriginationFee)); + BEAST_EXPECT(loan[sfLoanSequence] == 1); + BEAST_EXPECT(!loan.isMember(sfLoanServiceFee)); + BEAST_EXPECT(loan[sfNextPaymentDueDate] == loan[sfStartDate].asUInt() + 60); + BEAST_EXPECT(!loan.isMember(sfOverpaymentFee)); + BEAST_EXPECT(!loan.isMember(sfOverpaymentInterestRate)); + BEAST_EXPECT(loan[sfPaymentInterval] == 60); + BEAST_EXPECT(loan[sfPeriodicPayment] == "1000000000"); + BEAST_EXPECT(loan[sfPaymentRemaining] == 1); + BEAST_EXPECT(!loan.isMember(sfPreviousPaymentDueDate)); + BEAST_EXPECT(loan[sfPrincipalOutstanding] == "1000000000"); + BEAST_EXPECT(loan[sfTotalValueOutstanding] == "1000000000"); + BEAST_EXPECT(!loan.isMember(sfLoanScale)); + BEAST_EXPECT(loan[sfStartDate].asUInt() == startDate.time_since_epoch().count()); + + return loan["index"].asString(); + }(); + auto const loanKeylet{keylet::loan(uint256{std::string_view(loanID)})}; + + env.close(startDate); + + // Make a payment + env(pay(lender, loanKeylet.key, broker.asset(1000))); + } + + void + testIssuerLoan() + { + testcase << "Issuer Loan"; + + using namespace jtx; + using namespace loan; + Account const issuer("issuer"); + Account const borrower = issuer; + Account const lender("lender"); + Env env(*this); + + env.fund(XRP(1'000), issuer, lender); + + static constexpr std::int64_t kIssuerBalance = 10'000'000; + MPTTester const asset( + {.env = env, .issuer = issuer, .holders = {lender}, .pay = kIssuerBalance}); + + BrokerParameters const brokerParams{ + .debtMax = 200, + }; + auto const broker = createVaultAndBroker(env, asset, lender, brokerParams); + auto const loanSetFee = Fee(env.current()->fees().base * 2); + // Create Loan + env(set(borrower, broker.brokerID, 200), Sig(sfCounterpartySignature, lender), loanSetFee); + env.close(); + // Issuer should not create MPToken + BEAST_EXPECT(!env.le(keylet::mptoken(asset.issuanceID(), issuer))); + // Issuer "borrowed" 200, OutstandingAmount decreased by 200 + BEAST_EXPECT(env.balance(issuer, asset) == asset(-kIssuerBalance + 200)); + // Pay Loan + auto const loanKeylet = keylet::loan(broker.brokerID, 1); + env(pay(borrower, loanKeylet.key, asset(200))); + env.close(); + // Issuer "re-payed" 200, OutstandingAmount increased by 200 + BEAST_EXPECT(env.balance(issuer, asset) == asset(-kIssuerBalance)); + } + + void + testBorrowerIsBroker() + { + testcase("Test Borrower is Broker"); + using namespace jtx; + using namespace loan; + Account const broker{"broker"}; + Account const issuer{"issuer"}; + Account const borrower{"borrower"}; + Account const depositor{"depositor"}; + + auto testLoanAsset = [&](auto&& getMaxDebt, auto const& borrower) { + Env env(*this); + Vault const vault(env); + + if (borrower == broker) + { + env.fund(XRP(10'000), broker, issuer, depositor); + } + else + { + env.fund(XRP(10'000), broker, borrower, issuer, depositor); + } + env.close(); + + auto const xrpFee = XRP(100); + auto const txFee = Fee(xrpFee); + + STAmount const debtMaximumRequest = getMaxDebt(env); + + auto const& asset = debtMaximumRequest.asset(); + auto const initialVault = asset(debtMaximumRequest * 100); + + auto [tx, vaultKeylet] = vault.create({.owner = broker, .asset = asset}); + env(tx, txFee); + env.close(); + + env(vault.deposit( + {.depositor = depositor, .id = vaultKeylet.key, .amount = initialVault}), + txFee); + env.close(); + + auto const brokerKeylet = keylet::loanBroker(broker.id(), env.seq(broker)); + + env(loanBroker::set(broker, vaultKeylet.key), txFee); + env.close(); + + auto const serviceFee = 101; + + env(set(broker, brokerKeylet.key, debtMaximumRequest), + kCounterparty(borrower), + Sig(sfCounterpartySignature, borrower), + kLoanServiceFee(serviceFee), + kPaymentTotal(10), + txFee); + env.close(); + + std::uint32_t const loanSequence = 1; + auto const loanKeylet = keylet::loan(brokerKeylet.key, loanSequence); + + auto const brokerBalanceBefore = env.balance(broker, asset); + + if (auto const loanSle = env.le(loanKeylet); env.test.BEAST_EXPECT(loanSle)) + { + auto const payment = loanSle->at(sfPeriodicPayment); + auto const totalPayment = payment + serviceFee; + env(loan::pay(borrower, loanKeylet.key, asset(totalPayment)), txFee); + env.close(); + if (auto const vaultSle = env.le(vaultKeylet); BEAST_EXPECT(vaultSle)) + { + auto const expected = [&]() { + // The service fee is transferred to the broker if + // a borrower is not the broker + if (borrower != broker) + return brokerBalanceBefore.number() + serviceFee; + // Since a borrower is the broker, the payment is + // transferred to the Vault from the broker but not + // the service fee. + // If the asset is XRP then the broker pays the txFee. + if (asset.native()) + return brokerBalanceBefore.number() - payment - xrpFee.number(); + return brokerBalanceBefore.number() - payment; + }(); + BEAST_EXPECT(env.balance(broker, asset).value() == asset(expected).value()); + } + } + }; + // Test when a borrower is the broker and is not to verify correct + // service fee transfer in both cases. + for (auto const& borrowerAcct : {broker, borrower}) + { + testLoanAsset( + [&](Env&) -> STAmount { return STAmount{XRPAmount{200'000}}; }, borrowerAcct); + testLoanAsset( + [&](Env& env) -> STAmount { + auto const iou = issuer["USD"]; + env(trust(broker, iou(1'000'000'000))); + env(trust(depositor, iou(1'000'000'000))); + env(pay(issuer, broker, iou(100'000'000))); + env(pay(issuer, depositor, iou(100'000'000))); + env.close(); + return iou(200'000); + }, + borrowerAcct); + testLoanAsset( + [&](Env& env) -> STAmount { + MPTTester const mpt( + {.env = env, + .issuer = issuer, + .holders = {broker, depositor}, + .pay = 100'000'000}); + return mpt(200'000); + }, + borrowerAcct); + } + } + + void + testIssuerIsBorrower(FeatureBitset features) + { + testcase("RIPD-4096 - Issuer as borrower"); + + using namespace jtx; + + Account const issuer("issuer"); + Account const lender("lender"); + + BrokerParameters const brokerParams{ + .vaultDeposit = 100'000, + .debtMax = 0, + .coverRateMin = TenthBips32{0}, + .managementFeeRate = TenthBips16{0}, + .coverRateLiquidation = TenthBips32{0}}; + LoanParameters const loanParams{ + .account = lender, .counter = issuer, .principalRequest = Number{10000}}; + + auto const assetType = AssetType::IOU; + + Env env{*this, features}; + + auto loanResult = + createLoan(env, assetType, brokerParams, loanParams, issuer, lender, issuer); + + if (BEAST_EXPECT(loanResult); !loanResult.has_value()) + return; + + auto broker = std::get(*loanResult); + auto loanKeylet = std::get(*loanResult); + auto pseudoAcct = std::get(*loanResult); + + VerifyLoanStatus const verifyLoanStatus(env, broker, pseudoAcct, loanKeylet); + + makeLoanPayments( + env, + broker, + loanParams, + loanKeylet, + verifyLoanStatus, + issuer, + lender, + issuer, + PaymentParameters{.showStepBalances = true}); + } + + void + testBatchBypassCounterparty(FeatureBitset features) + { + // From FIND-001 + testcase << "Batch Bypass Counterparty"; + + bool const lendingBatchEnabled = !std::ranges::any_of( + Batch::kDisabledTxTypes, + [](auto const& disabled) { return disabled == ttLOAN_BROKER_SET; }); + + using namespace jtx; + using namespace std::chrono_literals; + Env env(*this, features); + + Account const lender{"lender"}; + Account const borrower{"borrower"}; + + BrokerParameters const brokerParams; + env.fund(XRP(brokerParams.vaultDeposit * 100), lender, borrower); + env.close(); + + PrettyAsset const xrpAsset{xrpIssue(), 1'000'000}; + + BrokerInfo const broker{createVaultAndBroker(env, xrpAsset, lender, brokerParams)}; + + using namespace loan; + + auto const loanSetFee = Fee(env.current()->fees().base * 2); + Number const principalRequest{1, 3}; + + auto forgedLoanSet = set(borrower, broker.brokerID, principalRequest, 0); + + json::Value randomData{json::ValueType::Object}; + randomData[jss::SigningPubKey] = json::StaticString{"2600"}; + json::Value sigObject{json::ValueType::Object}; + sigObject[jss::SigningPubKey] = strHex(lender.pk().slice()); + Serializer ss; + ss.add32(HashPrefix::TxSign); + parse(randomData).addWithoutSigningFields(ss); + auto const sig = xrpl::sign(borrower.pk(), borrower.sk(), ss.slice()); + sigObject[jss::TxnSignature] = strHex(Slice{sig.data(), sig.size()}); + + forgedLoanSet[json::StaticString{"CounterpartySignature"}] = sigObject; + + // ? Fails because the lender hasn't signed the tx + env(env.json(forgedLoanSet, Fee(loanSetFee)), Ter(telENV_RPC_FAILED)); + + auto const seq = env.seq(borrower); + auto const batchFee = batch::calcBatchFee(env, 1, 2); + // ! Should fail because the lender hasn't signed the tx + env(batch::outer(borrower, seq, batchFee, tfAllOrNothing), + batch::Inner(forgedLoanSet, seq + 1), + batch::Inner(pay(borrower, lender, XRP(1)), seq + 2), + Ter(lendingBatchEnabled ? temBAD_SIGNATURE : temINVALID_INNER_BATCH)); + env.close(); + + // ? Check that the loan was NOT created + { + json::Value params(json::ValueType::Object); + params[jss::account] = borrower.human(); + params[jss::type] = "Loan"; + auto const res = env.rpc("json", "account_objects", to_string(params)); + auto const objects = res[jss::result][jss::account_objects]; + BEAST_EXPECT(objects.size() == 0); + } + } + + // Integration test: full lifecycle of a $1B loan in the bug regime. + // Verifies that the vault collects the economically-correct interest + // income and that conservation holds at the trust-line level. + // + // Pre-fix (closed-form `power(1+r, n) - 1`): vault collected only + // ~$0.058 per $1B due to cancellation of `(1+r)^n - 1` at r*n ~ 5.7e-10. + // Post-fix (hybrid binomial path): vault collects ~$0.38 per $1B, + // matching the value computed independently with arbitrary-precision + // Decimal arithmetic. + void + testFullLifecycleVaultPnLNearZeroRate() + { + testcase("integration: full loan lifecycle, vault interest at near-zero rate"); + + using namespace jtx; + using namespace jtx::loan; + using namespace std::chrono_literals; + Env env(*this, all_); + + Account const issuer{"issuer"}; + Account const lender{"lender"}; + Account const borrower{"borrower"}; + + env.fund(XRP(1'000'000), issuer, lender, borrower); + env.close(); + env(fset(issuer, asfDefaultRipple)); + env.close(); + + PrettyAsset const iouAsset = issuer["USD"]; + STAmount const trustLimit{iouAsset.raw(), Number{1, 17}}; + env(trust(lender, trustLimit)); + env(trust(borrower, trustLimit)); + env.close(); + env(pay(issuer, lender, iouAsset(5'000'000'000LL))); + env(pay(issuer, borrower, iouAsset(5'000'000'000LL))); + env.close(); + + auto usdBalance = [&](Account const& a) { + return env.balance(a, iouAsset.raw().get()).value(); + }; + STAmount const borrowerStartBal = usdBalance(borrower); + + BrokerParameters const brokerParams{ + .vaultDeposit = Number{2, 9}, + .debtMax = Number{0}, + .coverRateMin = TenthBips32{0}, + .coverDeposit = 0, + .managementFeeRate = TenthBips16{0}, + .coverRateLiquidation = TenthBips32{0}}; + BrokerInfo const broker{createVaultAndBroker(env, iouAsset, lender, brokerParams)}; + + auto const vaultBefore = env.le(broker.vaultKeylet()); + BEAST_EXPECT(vaultBefore); + Number const vaultAvailableBefore = vaultBefore->at(sfAssetsAvailable); + + // Loan: $1B principal, 3 payments, 600s interval, rate=1 TenthBips32. + auto const loanSetFee = Fee(env.current()->fees().base * 2); + Number const principalRequest{1, 9}; + auto createJson = env.json( + set(borrower, broker.brokerID, principalRequest), + Fee(loanSetFee), + Json(sfCounterpartySignature, json::ValueType::Object)); + createJson["InterestRate"] = 1; + createJson["PaymentTotal"] = 3; + createJson["PaymentInterval"] = 600; + + auto const loanKeylet = nextLoanKeylet(env, broker); + createJson = env.json(createJson, Sig(sfCounterpartySignature, lender)); + env(createJson, Ter(tesSUCCESS)); + env.close(); + + auto const loanSle = env.le(loanKeylet); + BEAST_EXPECT(loanSle); + Number const expectedTotalInterest = + loanSle->at(sfTotalValueOutstanding) - loanSle->at(sfPrincipalOutstanding); + + env(pay(borrower, loanKeylet.key, iouAsset(1'500'000'000LL)), Ter(tesSUCCESS)); + env.close(); + + auto const vaultAfter = env.le(broker.vaultKeylet()); + Number const vaultAvailableAfter = vaultAfter->at(sfAssetsAvailable); + Number const vaultGain = vaultAvailableAfter - vaultAvailableBefore; + + STAmount const borrowerEndBal = usdBalance(borrower); + STAmount const borrowerNetOut = borrowerStartBal - borrowerEndBal; + + // Self-consistency: vault gained exactly the expected interest + // computed at LoanSet, and the borrower's outflow matches. + BEAST_EXPECT(vaultGain == expectedTotalInterest); + BEAST_EXPECT(Number(borrowerNetOut) == expectedTotalInterest); + + // Mathematical correctness: the total interest for this loan + // configuration is 0.38051750382930729983, calculated + // independently using 50-digit Decimal arithmetic (no + // cancellation possible at that precision). At Number's 19-digit + // mantissa this rounds to 0.38051750382930729 — the literal + // below. The vault's actual gain must agree to within + // sub-microcent precision. + Number const decimalReference{38051750382930729LL, -17}; + Number const tolerance{1, -6}; // 1e-6 USD = sub-microcent + Number const error = abs(vaultGain - decimalReference); + BEAST_EXPECTS( + error < tolerance, + "vault gain " + to_string(vaultGain) + " differs from Decimal reference " + + to_string(decimalReference) + " by " + to_string(error) + " — exceeds tolerance " + + to_string(tolerance)); + } + + void + runAmendmentIndependent() + { + testIssuerLoan(); + testBorrowerIsBroker(); + testFullLifecycleVaultPnLNearZeroRate(); + } + + // Tests run under each entry in amendmentCombinations(). + void + runAmendmentSensitive(FeatureBitset features) + { + testLifecycle(features); + testSelfLoan(features); + testIssuerIsBorrower(features); + testBatchBypassCounterparty(features); + } + +public: + void + run() override + { + runAmendmentIndependent(); + for (auto const& features : jtx::amendmentCombinations( + {fixCleanup3_1_3, fixCleanup3_2_0, featureMPTokensV2}, all_)) + runAmendmentSensitive(features); + } +}; + +BEAST_DEFINE_TESTSUITE(LoanLifecycle, tx, xrpl); + +} // namespace xrpl::test diff --git a/src/test/app/lending/LoanMisc_test.cpp b/src/test/app/lending/LoanMisc_test.cpp new file mode 100644 index 00000000000..38a93bec96c --- /dev/null +++ b/src/test/app/lending/LoanMisc_test.cpp @@ -0,0 +1,561 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include + +namespace xrpl::test { + +class LoanMisc_test : public LoanTestBase +{ +private: + void + testRPC(FeatureBitset features) + { + // This will expand as more test cases are added. Some functionality + // is tested in other test functions. + testcase("RPC"); + + using namespace jtx; + + Env env(*this, features); + + auto lowerFee = [&]() { + // Run the local fee back down. + while (env.app().getFeeTrack().lowerLocalFee()) + ; + }; + + auto const baseFee = env.current()->fees().base; + + Account const alice{"alice"}; + std::string const borrowerPass = "borrower"; + Account const borrower{borrowerPass, KeyType::Ed25519}; + auto const lenderPass = "lender"; + Account const lender{lenderPass, KeyType::Ed25519}; + + env.fund(XRP(1'000'000), alice, lender, borrower); + env.close(); + env(noop(lender)); + env(noop(lender)); + env(noop(lender)); + env(noop(lender)); + env(noop(lender)); + env.close(); + + { + testcase("RPC AccountSet"); + json::Value txJson{json::ValueType::Object}; + txJson[sfTransactionType] = "AccountSet"; + txJson[sfAccount] = borrower.human(); + + auto const signParams = [&]() { + json::Value signParams{json::ValueType::Object}; + signParams[jss::passphrase] = borrowerPass; + signParams[jss::key_type] = "ed25519"; + signParams[jss::tx_json] = txJson; + return signParams; + }(); + auto const jSign = env.rpc("json", "sign", to_string(signParams)); + BEAST_EXPECT(jSign.isMember(jss::result) && jSign[jss::result].isMember(jss::tx_json)); + auto txSignResult = jSign[jss::result][jss::tx_json]; + auto txSignBlob = jSign[jss::result][jss::tx_blob].asString(); + txSignResult.removeMember(jss::hash); + + auto const jtx = env.jt(txJson, Sig(borrower)); + BEAST_EXPECT(txSignResult == jtx.jv); + + lowerFee(); + auto const jSubmit = env.rpc("submit", txSignBlob); + BEAST_EXPECT( + jSubmit.isMember(jss::result) && + jSubmit[jss::result].isMember(jss::engine_result) && + jSubmit[jss::result][jss::engine_result].asString() == "tesSUCCESS"); + + lowerFee(); + env(jtx.jv, Sig(kNone), Seq(kNone), Fee(kNone), Ter(tefPAST_SEQ)); + } + + { + testcase("RPC LoanSet - illegal signature_target"); + + json::Value txJson{json::ValueType::Object}; + txJson[sfTransactionType] = "AccountSet"; + txJson[sfAccount] = borrower.human(); + + auto const borrowerSignParams = [&]() { + json::Value params{json::ValueType::Object}; + params[jss::passphrase] = borrowerPass; + params[jss::key_type] = "ed25519"; + params[jss::signature_target] = "Destination"; + params[jss::tx_json] = txJson; + return params; + }(); + auto const jSignBorrower = env.rpc("json", "sign", to_string(borrowerSignParams)); + BEAST_EXPECT( + jSignBorrower.isMember(jss::result) && + jSignBorrower[jss::result].isMember(jss::error) && + jSignBorrower[jss::result][jss::error] == "invalidParams" && + jSignBorrower[jss::result].isMember(jss::error_message) && + jSignBorrower[jss::result][jss::error_message] == "Destination"); + } + { + testcase("RPC LoanSet - sign and submit borrower initiated"); + // 1. Borrower creates the transaction + json::Value txJson{json::ValueType::Object}; + txJson[sfTransactionType] = "LoanSet"; + txJson[sfAccount] = borrower.human(); + txJson[sfCounterparty] = lender.human(); + txJson[sfLoanBrokerID] = + "FF924CD18A236C2B49CF8E80A351CEAC6A10171DC9F110025646894FEC" + "F83F" + "5C"; + txJson[sfPrincipalRequested] = "100000000"; + txJson[sfPaymentTotal] = 10000; + txJson[sfPaymentInterval] = 3600; + txJson[sfGracePeriod] = 300; + txJson[sfFlags] = 65536; // tfLoanOverpayment + txJson[sfFee] = to_string(24 * baseFee / 10); + + // 2. Borrower signs the transaction + auto const borrowerSignParams = [&]() { + json::Value params{json::ValueType::Object}; + params[jss::passphrase] = borrowerPass; + params[jss::key_type] = "ed25519"; + params[jss::tx_json] = txJson; + return params; + }(); + auto const jSignBorrower = env.rpc("json", "sign", to_string(borrowerSignParams)); + BEAST_EXPECTS( + jSignBorrower.isMember(jss::result) && + jSignBorrower[jss::result].isMember(jss::tx_json), + to_string(jSignBorrower)); + auto const txBorrowerSignResult = jSignBorrower[jss::result][jss::tx_json]; + auto const txBorrowerSignBlob = jSignBorrower[jss::result][jss::tx_blob].asString(); + + // 2a. Borrower attempts to submit the transaction. It doesn't + // work + { + lowerFee(); + auto const jSubmitBlob = env.rpc("submit", txBorrowerSignBlob); + BEAST_EXPECT(jSubmitBlob.isMember(jss::result)); + auto const jSubmitBlobResult = jSubmitBlob[jss::result]; + BEAST_EXPECT(jSubmitBlobResult.isMember(jss::tx_json)); + // Transaction fails because the CounterpartySignature is + // missing + BEAST_EXPECT( + jSubmitBlobResult.isMember(jss::engine_result) && + jSubmitBlobResult[jss::engine_result].asString() == "temBAD_SIGNER"); + } + + // 3. Borrower sends the signed transaction to the lender + // 4. Lender signs the transaction + auto const lenderSignParams = [&]() { + json::Value params{json::ValueType::Object}; + params[jss::passphrase] = lenderPass; + params[jss::key_type] = "ed25519"; + params[jss::signature_target] = "CounterpartySignature"; + params[jss::tx_json] = txBorrowerSignResult; + return params; + }(); + auto const jSignLender = env.rpc("json", "sign", to_string(lenderSignParams)); + BEAST_EXPECT( + jSignLender.isMember(jss::result) && + jSignLender[jss::result].isMember(jss::tx_json)); + auto const txLenderSignResult = jSignLender[jss::result][jss::tx_json]; + auto const txLenderSignBlob = jSignLender[jss::result][jss::tx_blob].asString(); + + // 5. Lender submits the signed transaction blob + lowerFee(); + auto const jSubmitBlob = env.rpc("submit", txLenderSignBlob); + BEAST_EXPECT(jSubmitBlob.isMember(jss::result)); + auto const jSubmitBlobResult = jSubmitBlob[jss::result]; + BEAST_EXPECT(jSubmitBlobResult.isMember(jss::tx_json)); + auto const jSubmitBlobTx = jSubmitBlobResult[jss::tx_json]; + // To get far enough to return tecNO_ENTRY means that the + // signatures all validated. Of course the transaction won't + // succeed because no Vault or Broker were created. + BEAST_EXPECTS( + jSubmitBlobResult.isMember(jss::engine_result) && + jSubmitBlobResult[jss::engine_result].asString() == "tecNO_ENTRY", + to_string(jSubmitBlobResult)); + + BEAST_EXPECT( + !jSubmitBlob.isMember(jss::error) && !jSubmitBlobResult.isMember(jss::error)); + + // 4-alt. Lender submits the transaction json originally + // received from the Borrower. It gets signed, but is now a + // duplicate, so fails. Borrower could done this instead of + // steps 4 and 5. + lowerFee(); + auto const jSubmitJson = env.rpc("json", "submit", to_string(lenderSignParams)); + BEAST_EXPECT(jSubmitJson.isMember(jss::result)); + auto const jSubmitJsonResult = jSubmitJson[jss::result]; + BEAST_EXPECT(jSubmitJsonResult.isMember(jss::tx_json)); + auto const jSubmitJsonTx = jSubmitJsonResult[jss::tx_json]; + // Since the previous tx claimed a fee, this duplicate is not + // going anywhere + BEAST_EXPECTS( + jSubmitJsonResult.isMember(jss::engine_result) && + jSubmitJsonResult[jss::engine_result].asString() == "tefPAST_SEQ", + to_string(jSubmitJsonResult)); + + BEAST_EXPECT( + !jSubmitJson.isMember(jss::error) && !jSubmitJsonResult.isMember(jss::error)); + + BEAST_EXPECT(jSubmitBlobTx == jSubmitJsonTx); + } + + { + testcase("RPC LoanSet - sign and submit lender initiated"); + // 1. Lender creates the transaction + json::Value txJson{json::ValueType::Object}; + txJson[sfTransactionType] = "LoanSet"; + txJson[sfAccount] = lender.human(); + txJson[sfCounterparty] = borrower.human(); + txJson[sfLoanBrokerID] = + "FF924CD18A236C2B49CF8E80A351CEAC6A10171DC9F110025646894FEC" + "F83F" + "5C"; + txJson[sfPrincipalRequested] = "100000000"; + txJson[sfPaymentTotal] = 10000; + txJson[sfPaymentInterval] = 3600; + txJson[sfGracePeriod] = 300; + txJson[sfFlags] = 65536; // tfLoanOverpayment + txJson[sfFee] = to_string(24 * baseFee / 10); + + // 2. Lender signs the transaction + auto const lenderSignParams = [&]() { + json::Value params{json::ValueType::Object}; + params[jss::passphrase] = lenderPass; + params[jss::key_type] = "ed25519"; + params[jss::tx_json] = txJson; + return params; + }(); + auto const jSignLender = env.rpc("json", "sign", to_string(lenderSignParams)); + BEAST_EXPECT( + jSignLender.isMember(jss::result) && + jSignLender[jss::result].isMember(jss::tx_json)); + auto const txLenderSignResult = jSignLender[jss::result][jss::tx_json]; + auto const txLenderSignBlob = jSignLender[jss::result][jss::tx_blob].asString(); + + // 2a. Lender attempts to submit the transaction. It doesn't + // work + { + lowerFee(); + auto const jSubmitBlob = env.rpc("submit", txLenderSignBlob); + BEAST_EXPECT(jSubmitBlob.isMember(jss::result)); + auto const jSubmitBlobResult = jSubmitBlob[jss::result]; + BEAST_EXPECT(jSubmitBlobResult.isMember(jss::tx_json)); + // Transaction fails because the CounterpartySignature is + // missing + BEAST_EXPECT( + jSubmitBlobResult.isMember(jss::engine_result) && + jSubmitBlobResult[jss::engine_result].asString() == "temBAD_SIGNER"); + } + + // 3. Lender sends the signed transaction to the Borrower + // 4. Borrower signs the transaction + auto const borrowerSignParams = [&]() { + json::Value params{json::ValueType::Object}; + params[jss::passphrase] = borrowerPass; + params[jss::key_type] = "ed25519"; + params[jss::signature_target] = "CounterpartySignature"; + params[jss::tx_json] = txLenderSignResult; + return params; + }(); + auto const jSignBorrower = env.rpc("json", "sign", to_string(borrowerSignParams)); + BEAST_EXPECT( + jSignBorrower.isMember(jss::result) && + jSignBorrower[jss::result].isMember(jss::tx_json)); + auto const txBorrowerSignResult = jSignBorrower[jss::result][jss::tx_json]; + auto const txBorrowerSignBlob = jSignBorrower[jss::result][jss::tx_blob].asString(); + + // 5. Borrower submits the signed transaction blob + lowerFee(); + auto const jSubmitBlob = env.rpc("submit", txBorrowerSignBlob); + BEAST_EXPECT(jSubmitBlob.isMember(jss::result)); + auto const jSubmitBlobResult = jSubmitBlob[jss::result]; + BEAST_EXPECT(jSubmitBlobResult.isMember(jss::tx_json)); + auto const jSubmitBlobTx = jSubmitBlobResult[jss::tx_json]; + // To get far enough to return tecNO_ENTRY means that the + // signatures all validated. Of course the transaction won't + // succeed because no Vault or Broker were created. + BEAST_EXPECTS( + jSubmitBlobResult.isMember(jss::engine_result) && + jSubmitBlobResult[jss::engine_result].asString() == "tecNO_ENTRY", + to_string(jSubmitBlobResult)); + + BEAST_EXPECT( + !jSubmitBlob.isMember(jss::error) && !jSubmitBlobResult.isMember(jss::error)); + + // 4-alt. Borrower submits the transaction json originally + // received from the Lender. It gets signed, but is now a + // duplicate, so fails. Lender could done this instead of steps + // 4 and 5. + lowerFee(); + auto const jSubmitJson = env.rpc("json", "submit", to_string(borrowerSignParams)); + BEAST_EXPECT(jSubmitJson.isMember(jss::result)); + auto const jSubmitJsonResult = jSubmitJson[jss::result]; + BEAST_EXPECT(jSubmitJsonResult.isMember(jss::tx_json)); + auto const jSubmitJsonTx = jSubmitJsonResult[jss::tx_json]; + // Since the previous tx claimed a fee, this duplicate is not + // going anywhere + BEAST_EXPECTS( + jSubmitJsonResult.isMember(jss::engine_result) && + jSubmitJsonResult[jss::engine_result].asString() == "tefPAST_SEQ", + to_string(jSubmitJsonResult)); + + BEAST_EXPECT( + !jSubmitJson.isMember(jss::error) && !jSubmitJsonResult.isMember(jss::error)); + + BEAST_EXPECT(jSubmitBlobTx == jSubmitJsonTx); + } + } + + void + testLendingCanTradeDisabledNoImpact() + { + testcase("Lending: CanTrade disabled has no impact"); + using namespace jtx; + using namespace loan; + using namespace loanBroker; + + Env env(*this, all_); + + Account const issuer{"issuer"}; + Account const lender{"lender"}; + Account const borrower{"borrower"}; + + env.fund(XRP(1'000'000), issuer, lender, borrower); + env.close(); + + MPTTester mpt( + {.env = env, + .issuer = issuer, + .holders = {lender, borrower}, + .flags = tfMPTCanTransfer | tfMPTCanLock, + .mutableFlags = tmfMPTCanEnableCanTrade}); + PrettyAsset const asset = mpt.issuanceID(); + env(pay(issuer, lender, asset(10'000'000))); + env(pay(issuer, borrower, asset(100'000))); + env.close(); + + auto const broker = createVaultAndBroker(env, asset, lender); + + // CanTrade is not set + env(offer(lender, XRP(1), asset(10)), Ter{tecNO_PERMISSION}); + env.close(); + + auto const loanSetFee = Fee(env.current()->fees().base * 2); + + // New cover deposits still work. + env(coverDeposit(lender, broker.brokerID, asset(100))); + env.close(); + + // New loan issuance still works. + env(loan::set(borrower, broker.brokerID, 1'000), + Sig(sfCounterpartySignature, lender), + loanSetFee); + env.close(); + auto const loanKeylet = keylet::loan(broker.brokerID, 1); + BEAST_EXPECT(env.le(loanKeylet)); + + // Repayment still works. + env(pay(borrower, loanKeylet.key, asset(1'000))); + env.close(); + + // Cover withdrawal still works. + env(coverWithdraw(lender, broker.brokerID, asset(100))); + env.close(); + + // Enable CanTrade and verify the DEX path is restored. + mpt.set({.mutableFlags = tmfMPTSetCanTrade}); + env.close(); + + env(offer(lender, XRP(1), asset(10))); + env.close(); + } + + void + runAmendmentIndependent() + { + testLendingCanTradeDisabledNoImpact(); + } + + // Tests run under each entry in amendmentCombinations(). + void + runAmendmentSensitive(FeatureBitset features) + { + testRPC(features); + } + +public: + void + run() override + { + runAmendmentIndependent(); + for (auto const& features : jtx::amendmentCombinations( + {fixCleanup3_1_3, fixCleanup3_2_0, featureMPTokensV2}, all_)) + runAmendmentSensitive(features); + } +}; + +class LoanBatch_test : public LoanTestBase +{ +protected: + beast::xor_shift_engine engine_; + + std::uniform_int_distribution<> assetDist_{0, 2}; + std::uniform_int_distribution principalDist_{100'000, 1'000'000'000}; + std::uniform_int_distribution interestRateDist_{0, 10000}; + std::uniform_int_distribution<> paymentTotalDist_{12, 10000}; + std::uniform_int_distribution<> paymentIntervalDist_{60, 3600 * 24 * 30}; + std::uniform_int_distribution managementFeeRateDist_{0, 10'000}; + std::uniform_int_distribution<> serviceFeeDist_{0, 20}; + /* + # Generate parameters that are more likely to be valid + principal = Decimal(str(rand.randint(100000, + 100'000'000))).quantize(ROUND_TARGET) + + interest_rate = Decimal(rand.randint(1, 10000)) / + Decimal(100000) + + payment_total = rand.randint(12, 10000) + + payment_interval = Decimal(str(rand.randint(60, 2629746))) + + interest_fee = Decimal(rand.randint(0, 100000)) / + Decimal(100000) +*/ + + void + testRandomLoan() + { + using namespace jtx; + + Account const issuer("issuer"); + Account const lender("lender"); + Account const borrower("borrower"); + + // Determine all the random parameters at once + auto const assetType = static_cast(assetDist_(engine_)); + auto const principalRequest = principalDist_(engine_); + TenthBips16 const managementFeeRate{managementFeeRateDist_(engine_)}; + auto const serviceFee = serviceFeeDist_(engine_); + TenthBips32 interest{interestRateDist_(engine_)}; + auto const payTotal = paymentTotalDist_(engine_); + auto const payInterval = paymentIntervalDist_(engine_); + + BrokerParameters const brokerParams{ + .vaultDeposit = principalRequest * 10, + .debtMax = 0, + .coverRateMin = TenthBips32{0}, + .managementFeeRate = managementFeeRate}; + LoanParameters const loanParams{ + .account = lender, + .counter = borrower, + .principalRequest = principalRequest, + .serviceFee = serviceFee, + .interest = interest, + .payTotal = payTotal, + .payInterval = payInterval, + }; + + runLoan(assetType, brokerParams, loanParams, all_); + } + +public: + void + run() override + { + auto const numIterations = [s = arg()]() -> int { + int const defaultNum = 5; + if (s.empty()) + return defaultNum; + try + { + std::size_t pos = 0; + auto const r = stoi(s, &pos); + if (pos != s.size()) + return defaultNum; + return r; + } + catch (...) + { + return defaultNum; + } + }(); + + using namespace jtx; + + auto const updateInterval = std::min(numIterations / 5, 100); + + for (int i = 0; i < numIterations; ++i) + { + if (i % updateInterval == 0) + testcase << "Random Loan Test iteration " << (i + 1) << "/" << numIterations; + testRandomLoan(); + } + } +}; + +class LoanArbitrary_test : public LoanBatch_test +{ + void + run() override + { + using namespace jtx; + + BrokerParameters const brokerParams{ + .vaultDeposit = 10000, + .debtMax = 0, + .coverRateMin = TenthBips32{0}, + .managementFeeRate = TenthBips16{0}, + .coverRateLiquidation = TenthBips32{0}}; + LoanParameters const loanParams{ + .account = Account("lender"), + .counter = Account("borrower"), + .principalRequest = Number{200000, -6}, + .interest = TenthBips32{50000}, + .payTotal = 2, + .payInterval = 200}; + + runLoan(AssetType::XRP, brokerParams, loanParams, all_); + } +}; + +BEAST_DEFINE_TESTSUITE(LoanMisc, tx, xrpl); +BEAST_DEFINE_TESTSUITE_MANUAL(LoanBatch, tx, xrpl); +BEAST_DEFINE_TESTSUITE_MANUAL(LoanArbitrary, tx, xrpl); + +} // namespace xrpl::test diff --git a/src/test/app/lending/LoanPay_test.cpp b/src/test/app/lending/LoanPay_test.cpp new file mode 100644 index 00000000000..448f5dd81d1 --- /dev/null +++ b/src/test/app/lending/LoanPay_test.cpp @@ -0,0 +1,755 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include + +namespace xrpl::test { + +class LoanPay_test : public LoanTestBase +{ +private: +#if LOAN_TODO + void + testLoanPayLateFullPaymentBypassesPenalties(FeatureBitset features) + { + testcase("LoanPay full payment skips late penalties"); + using namespace jtx; + using namespace loan; + using namespace std::chrono_literals; + + Env env(*this, features); + + Account const issuer{"issuer"}; + Account const lender{"lender"}; + Account const borrower{"borrower"}; + + env.fund(XRP(1'000'000), issuer, lender, borrower); + env.close(); + + PrettyAsset const asset = issuer[iouCurrency]; + env(trust(lender, asset(100'000'000))); + env(trust(borrower, asset(100'000'000))); + env(pay(issuer, lender, asset(50'000'000))); + env(pay(issuer, borrower, asset(5'000'000))); + env.close(); + + BrokerInfo broker{createVaultAndBroker(env, asset, lender)}; + + auto const loanSetFee = Fee(env.current()->fees().base * 2); + + auto const brokerPreLoan = env.le(keylet::loanBroker(broker.brokerID)); + if (BEAST_EXPECT(brokerPreLoan); !brokerPreLoan.has_value()) + return; + + auto const loanSequence = brokerPreLoan->at(sfLoanSequence); + auto const loanKeylet = keylet::loan(broker.brokerID, loanSequence); + + Number const principal = asset(1'000).value(); + Number const serviceFee = asset(2).value(); + Number const lateFee = asset(5).value(); + Number const closeFee = asset(4).value(); + + env(set(borrower, broker.brokerID, principal), + Sig(sfCounterpartySignature, lender), + kLoanServiceFee(serviceFee), + kLatePaymentFee(lateFee), + kClosePaymentFee(closeFee), + kInterestRate(percentageToTenthBips(12)), + kLateInterestRate(percentageToTenthBips(24) / 10), + kCloseInterestRate(percentageToTenthBips(5)), + kPaymentTotal(12), + kPaymentInterval(600), + kGracePeriod(0), + Fee(loanSetFee)); + env.close(); + + auto state1 = getCurrentState(env, broker, loanKeylet); + if (!BEAST_EXPECT(state1.paymentRemaining > 1)) + return; + + using d = NetClock::duration; + using tp = NetClock::time_point; + auto const overdueClose = tp{d{state1.nextPaymentDate + state1.paymentInterval}}; + env.close(overdueClose); + + auto const brokerSle = env.le(keylet::loanBroker(broker.brokerID)); + auto const loanSle = env.le(loanKeylet); + if (!BEAST_EXPECT(brokerSle && loanSle)) + return; + + auto state = getCurrentState(env, broker, loanKeylet); + + TenthBips16 const managementFeeRate{brokerSle->at(sfManagementFeeRate)}; + TenthBips32 const interestRateValue{loanSle->at(sfInterestRate)}; + TenthBips32 const lateInterestRateValue{loanSle->at(sfLateInterestRate)}; + TenthBips32 const closeInterestRateValue{loanSle->at(sfCloseInterestRate)}; + + Number const closePaymentFeeRounded = + roundToAsset(broker.asset, loanSle->at(sfClosePaymentFee), state.loanScale); + Number const latePaymentFeeRounded = + roundToAsset(broker.asset, loanSle->at(sfLatePaymentFee), state.loanScale); + + auto const roundedLoanState = constructLoanState( + state.totalValue, state.principalOutstanding, state.managementFeeOutstanding); + Number const totalInterestOutstanding = roundedLoanState.interestDue; + + auto const periodicRate = loanPeriodicRate(interestRateValue, state.paymentInterval); + auto const rawLoanState = computeTheoreticalLoanState( + env.current()->rules(), + state.periodicPayment, + periodicRate, + state.paymentRemaining, + managementFeeRate); + + auto const parentCloseTime = env.current()->parentCloseTime(); + auto const startDateSeconds = + static_cast(state.startDate.time_since_epoch().count()); + + Number const fullPaymentInterest = computeFullPaymentInterest( + rawLoanState.principalOutstanding, + periodicRate, + parentCloseTime, + state.paymentInterval, + state.previousPaymentDate, + startDateSeconds, + closeInterestRateValue); + + Number const roundedFullInterestAmount = + roundToAsset(broker.asset, fullPaymentInterest, state.loanScale); + Number const roundedFullManagementFee = computeManagementFee( + broker.asset, roundedFullInterestAmount, managementFeeRate, state.loanScale); + Number const roundedFullInterest = roundedFullInterestAmount - roundedFullManagementFee; + + Number const trackedValueDelta = + state.principalOutstanding + totalInterestOutstanding + state.managementFeeOutstanding; + Number const untrackedManagementFee = + closePaymentFeeRounded + roundedFullManagementFee - state.managementFeeOutstanding; + Number const untrackedInterest = roundedFullInterest - totalInterestOutstanding; + + Number const baseFullDue = trackedValueDelta + untrackedInterest + untrackedManagementFee; + BEAST_EXPECT(baseFullDue == roundToAsset(broker.asset, baseFullDue, state.loanScale)); + + auto const overdueSeconds = + parentCloseTime.time_since_epoch().count() - state.nextPaymentDate; + if (!BEAST_EXPECT(overdueSeconds > 0)) + return; + + Number const overdueRate = loanPeriodicRate(lateInterestRateValue, overdueSeconds); + Number const lateInterestRaw = state.principalOutstanding * overdueRate; + Number const lateInterestRounded = + roundToAsset(broker.asset, lateInterestRaw, state.loanScale); + Number const lateManagementFeeRounded = computeManagementFee( + broker.asset, lateInterestRounded, managementFeeRate, state.loanScale); + Number const penaltyDue = + lateInterestRounded + lateManagementFeeRounded + latePaymentFeeRounded; + BEAST_EXPECT(penaltyDue > Number{}); + + auto const balanceBefore = env.balance(borrower, broker.asset).number(); + + STAmount const paymentAmount{broker.asset.raw(), baseFullDue}; + env(pay(borrower, loanKeylet.key, paymentAmount, tfLoanFullPayment)); + env.close(); + + if (auto const meta = env.meta(); BEAST_EXPECT(meta)) + BEAST_EXPECT(meta->at(sfTransactionResult) == tesSUCCESS); + + auto const balanceAfter = env.balance(borrower, broker.asset).number(); + Number const actualPaid = balanceBefore - balanceAfter; + BEAST_EXPECT(actualPaid == baseFullDue); + + Number const expectedWithPenalty = baseFullDue + penaltyDue; + BEAST_EXPECT(expectedWithPenalty > actualPaid); + BEAST_EXPECT(expectedWithPenalty - actualPaid == penaltyDue); + } +#endif + + void + testOverpaymentManagementFee(FeatureBitset features) + { + testcase("testOverpaymentManagementFee"); + + using namespace jtx; + using namespace loan; + + Env env{*this, features}; + + Account const lender{"lender"}, borrower{"borrower"}; + + env.fund(XRP(10'000'000), lender, borrower); + env.close(); + + PrettyAsset const asset{xrpIssue(), 1000}; + + auto const result = createVaultAndBroker( + env, + asset, + lender, + { + .vaultDeposit = asset(100'000).value(), + .managementFeeRate = TenthBips16(10'000), + }); + + auto const loanSetFee = Fee(env.current()->fees().base * 2); + + auto const loanKeylet = keylet::loan( + result.brokerKeylet().key, (env.le(result.brokerKeylet()))->at(sfLoanSequence)); + env(loan::set( + borrower, result.brokerKeylet().key, asset(10'000).value(), tfLoanOverpayment), + Sig(sfCounterpartySignature, lender), + loan::kPaymentInterval(86400 * 30), + loan::kPaymentTotal(3), + loan::kOverpaymentInterestRate(TenthBips32(percentageToTenthBips(20))), + loanSetFee); + + // From calculator + auto const expectedOverpaymentManagementFee = Number{33333, 0}; + auto const loanBrokerBalanceBefore = env.balance(lender); + + auto const loanPayFee = Fee(env.current()->fees().base * 2); + env(pay(borrower, loanKeylet.key, asset(5'000).value(), tfLoanOverpayment), loanPayFee); + env.close(); + + BEAST_EXPECTS( + env.balance(lender) - loanBrokerBalanceBefore == expectedOverpaymentManagementFee, + "overpayment management fee missmatch; expected:" + + to_string(expectedOverpaymentManagementFee) + + " got: " + to_string(env.balance(lender) - loanBrokerBalanceBefore)); + } + + void + testDosLoanPay(FeatureBitset features) + { + bool const feeCapped = features[fixCleanup3_1_3]; + + // From FIND-005 + testcase << "DoS LoanPay: fee calculation " << (feeCapped ? "capped" : "uncapped"); + + using namespace jtx; + using namespace std::chrono_literals; + using namespace Lending; + Env env(*this, features); + + Account const issuer{"issuer"}; + Account const lender{"lender"}; + Account const borrower{"borrower"}; + + env.fund(XRP(1'000'000), issuer, lender, borrower); + env.close(); + + BEAST_EXPECT(feeCapped == env.current()->rules().enabled(fixCleanup3_1_3)); + + PrettyAsset const iouAsset = issuer[iouCurrency_]; + env(trust(lender, iouAsset(100'000'000))); + env(trust(borrower, iouAsset(100'000'000))); + env(pay(issuer, lender, iouAsset(10'000'000))); + env(pay(issuer, borrower, iouAsset(1'000))); + env.close(); + + BrokerInfo const broker{createVaultAndBroker(env, iouAsset, lender)}; + + using namespace loan; + + auto const loanSetFee = Fee(env.current()->fees().base * 2); + Number const principalRequest{3959'37, -2}; + auto const baseFee = env.current()->fees().base; + + auto const createJson = env.json( + set(borrower, broker.brokerID, principalRequest), + Fee(loanSetFee), + Json(sfCounterpartySignature, json::ValueType::Object), + kClosePaymentFee(0), + kGracePeriod(60), + kInterestRate(TenthBips32(20930)), + kLateInterestRate(TenthBips32(77049)), + kLatePaymentFee(0), + kLoanServiceFee(0), + kOverpaymentFee(TenthBips32(7)), + kOverpaymentInterestRate(TenthBips32(66653)), + kPaymentInterval(60), + kPaymentTotal(3239184)); + + // There are enough payments due on this loan that it only needs to be + // created once, and can be paid on multiple times. Just don't create a + // gazillion test cases. + auto const keylet = nextLoanKeylet(env, broker); + + env(createJson, Sig(sfCounterpartySignature, lender)); + env.close(); + + auto const roundedPayment = [&]() { + auto const stateBefore = getCurrentState(env, broker, keylet); + BEAST_EXPECT(stateBefore.paymentRemaining == 3239184); + BEAST_EXPECT(stateBefore.paymentRemaining > kLoanMaximumPaymentsPerTransaction); + + return roundToAsset( + iouAsset, + stateBefore.periodicPayment, + stateBefore.loanScale, + Number::RoundingMode::Upward); + }(); + + auto test = [&](int const payFactor, + int const feeFactor, + TER const expectedTer = tesSUCCESS) { + auto const stateBefore = getCurrentState(env, broker, keylet); + BEAST_EXPECT(stateBefore.paymentRemaining <= 3239184); + BEAST_EXPECT(stateBefore.paymentRemaining > kLoanMaximumPaymentsPerTransaction); + + Number const amount = roundedPayment * payFactor; + auto loanPayTx = env.json(pay(borrower, keylet.key, STAmount{broker.asset, amount})); + XRPAmount const payFee{baseFee * feeFactor}; + env(loanPayTx, Ter(expectedTer), Fee(payFee)); + env.close(); + auto const expectedChange = isTesSuccess(expectedTer) + ? std::min(kLoanMaximumPaymentsPerTransaction, payFactor) + : 0; + + auto const stateAfter = getCurrentState(env, broker, keylet); + BEAST_EXPECT( + stateAfter.paymentRemaining == stateBefore.paymentRemaining - expectedChange); + }; + + static constexpr std::int64_t kMaxFeeIncrements = + kLoanMaximumPaymentsPerTransaction / kLoanPaymentsPerFeeIncrement; + + TER const failWithoutFix = feeCapped ? (TER)tesSUCCESS : (TER)telINSUF_FEE_P; + + // * Amount well above threshold -> capped fee + // The original test case - way over the limit - more fee is always ok + test(1819878, 363976); + // The capped fee is only sufficient if the amendment is enabled. + test(1819878, kMaxFeeIncrements, failWithoutFix); + + // * Amount exactly at threshold -> capped fee + test(kLoanMaximumPaymentsPerTransaction, kMaxFeeIncrements); + // More fee is always ok + test(kLoanMaximumPaymentsPerTransaction, kMaxFeeIncrements + 10); + + // * Amount below threshold -> normal calculation + test(1, 1); + test(kLoanPaymentsPerFeeIncrement * 2, 2); + test(0, 0, temBAD_AMOUNT); + test(0, 1, temBAD_AMOUNT); + // Fee difference rounds evenly + test( + kLoanMaximumPaymentsPerTransaction - 10, + ((kLoanMaximumPaymentsPerTransaction - 10) / kLoanPaymentsPerFeeIncrement) - 1, + telINSUF_FEE_P); + test( + kLoanMaximumPaymentsPerTransaction - 10, + ((kLoanMaximumPaymentsPerTransaction - 10) / kLoanPaymentsPerFeeIncrement)); + // More fee is always ok + test( + kLoanMaximumPaymentsPerTransaction - 10, + ((kLoanMaximumPaymentsPerTransaction - 10) / kLoanPaymentsPerFeeIncrement) + 3); + // Fee rounds up + for (int under = 1; under < kLoanPaymentsPerFeeIncrement; ++under) + { + test(kLoanMaximumPaymentsPerTransaction - under, kMaxFeeIncrements - 1, telINSUF_FEE_P); + test(kLoanMaximumPaymentsPerTransaction - under, kMaxFeeIncrements); + } + // Only when you get one less fee increment can you pay less + test( + kLoanMaximumPaymentsPerTransaction - kLoanPaymentsPerFeeIncrement, + kMaxFeeIncrements - 1); + // And again, more fee is always ok. + test(kLoanMaximumPaymentsPerTransaction - kLoanPaymentsPerFeeIncrement, kMaxFeeIncrements); + } + + // A LoanSet with InterestRate = 1 (0.001% annualized, the minimum non-zero + // rate). At such a near-zero rate the closed-form payment factor + // (1 + r)^n - 1 cancels catastrophically. + // + // Without fixCleanup3_2_0 the resulting amortization is degenerate and the + // LoanSet is rejected with tecPRECISION_LOSS (no loan created). With the + // amendment, computePowerMinusOneHybrid uses a numerically-stable series + // expansion, so the loan is created and the scheduled payments + // (2 * periodicPayment) cover the principal — no economic underpayment + // (yield theft). + // + // The test runs the same LoanSet under both amendment settings and pins the + // exact outcome for each. + void + testLoanSetNearZeroInterestRateSucceeds() + { + testcase("LoanSet near-zero interest rate covers principal"); + + using namespace jtx; + using namespace loan; + + Number const principalRequested{1000}; + + struct Result + { + TER ter = tesSUCCESS; + bool created = false; + std::int32_t loanScale = 0; + Number principal; + Number totalValue; + Number managementFee; + Number periodicPayment; + }; + + auto runScenario = [&](FeatureBitset features, TER expectedTer) -> Result { + Env env(*this, features); + + Account const issuer{"issuer"}; + Account const lender{"vaultOwner"}; + Account const borrower{"borrower"}; + + PrettyAsset const iouAsset = createFundedRippleIouAsset(env, issuer, lender, borrower); + + auto const broker = createVaultAndBroker( + env, + iouAsset, + lender, + {.vaultDeposit = 100'000, .debtMax = 0, .managementFeeRate = TenthBips16{0}}); + + auto const brokerSle = env.le(broker.brokerKeylet()); + BEAST_EXPECT(brokerSle); + auto const loanSequence = brokerSle ? brokerSle->at(sfLoanSequence) : 0; + auto const loanKeylet = keylet::loan(broker.brokerID, loanSequence); + + env(set(borrower, broker.brokerID, principalRequested), + Sig(sfCounterpartySignature, lender), + kInterestRate(TenthBips32{1}), + kPaymentTotal(2), + kPaymentInterval(400), + Fee(env.current()->fees().base * 2), + Ter(expectedTer)); + env.close(); + + Result r; + r.ter = env.ter(); + if (auto const loanSle = env.le(loanKeylet)) + { + r.created = true; + r.loanScale = loanSle->at(sfLoanScale); + r.principal = loanSle->at(sfPrincipalOutstanding); + r.totalValue = loanSle->at(sfTotalValueOutstanding); + r.managementFee = loanSle->at(sfManagementFeeOutstanding); + r.periodicPayment = loanSle->at(sfPeriodicPayment); + } + return r; + }; + + Result const fixed = runScenario(all_, tesSUCCESS); + Result const legacy = runScenario(all_ - fixCleanup3_2_0, tecPRECISION_LOSS); + + // Without the amendment, the catastrophically-cancelling closed-form + // payment factor produces a degenerate amortization that fails + // checkLoanGuards: the LoanSet is rejected with tecPRECISION_LOSS and no + // loan is created. + BEAST_EXPECT(legacy.ter == tecPRECISION_LOSS); + BEAST_EXPECT(!legacy.created); + + // With the amendment the stable series expansion produces a valid loan + // at loanScale -10. + BEAST_EXPECT(fixed.ter == tesSUCCESS); + BEAST_EXPECT(fixed.created); + BEAST_EXPECT(fixed.loanScale == -10); + BEAST_EXPECT(fixed.principal == principalRequested); + BEAST_EXPECT((fixed.totalValue == Number{10000000001903, -10})); + BEAST_EXPECT(fixed.managementFee == beast::kZero); + + // Periodic payment from the numerically-stable series expansion, and the + // scheduled total (2 * periodicPayment) which exceeds the 1000 principal + // — no economic underpayment / yield theft. + BEAST_EXPECT((fixed.periodicPayment == Number{5000000000951293762, -16})); + BEAST_EXPECT((fixed.periodicPayment * 2 == Number{1000000000190258752, -15})); + BEAST_EXPECT(fixed.periodicPayment * 2 > principalRequested); + } + + void + testLoanNextPaymentDueDateOverflow(FeatureBitset features) + { + // For FIND-013 + testcase << "Prevent nextPaymentDueDate overflow"; + + using namespace jtx; + using namespace std::chrono_literals; + using namespace Lending; + Env env{*this, features}; + + Account const issuer{"issuer"}; + Account const lender{"lender"}; + Account const borrower{"borrower"}; + + PrettyAsset const iouAsset = + createFundedIouAsset(env, issuer, lender, borrower, 100'000'000, 10'000'000); + + BrokerParameters const brokerParams{.debtMax = Number{0}, .coverRateMin = TenthBips32{1}}; + BrokerInfo broker{createVaultAndBroker(env, iouAsset, lender, brokerParams)}; + + using namespace loan; + + auto const loanSetFee = Fee(env.current()->fees().base * 2); + + using timeType = decltype(sfNextPaymentDueDate)::type::value_type; + static_assert(std::is_same_v); + constexpr timeType kMaxTime = std::numeric_limits::max(); + static_assert(kMaxTime == 4'294'967'295); + + auto const baseJson = [&]() { + auto createJson = env.json( + set(borrower, broker.brokerID, Number{55524'81, -2}), + Fee(loanSetFee), + kClosePaymentFee(0), + kGracePeriod(LoanSet::kDefaultGracePeriod), + kInterestRate(TenthBips32(12833)), + kLateInterestRate(TenthBips32(77048)), + kLatePaymentFee(0), + kLoanOriginationFee(218), + Json(sfCounterpartySignature, json::ValueType::Object)); + + createJson.removeMember(sfSequence.getJsonName()); + + return createJson; + }(); + + auto const baseFee = env.current()->fees().base; + + auto parentCloseTime = [&]() { + return env.current()->parentCloseTime().time_since_epoch().count(); + }; + auto maxLoanTime = [&]() { + auto const startDate = parentCloseTime(); + + BEAST_EXPECT(startDate >= 50); + + return kMaxTime - startDate; + }; + + { + // straight-up overflow: interval + auto const interval = maxLoanTime() + 1; + auto const total = 1; + auto createJson = env.json(baseJson, kPaymentInterval(interval), kPaymentTotal(total)); + + env(createJson, Sig(sfCounterpartySignature, lender), Ter(tecKILLED)); + env.close(); + } + { + // straight-up overflow: total + // min interval is 60 + auto const interval = 60; + auto const total = maxLoanTime() + 1; + auto createJson = env.json(baseJson, kPaymentInterval(interval), kPaymentTotal(total)); + + env(createJson, Sig(sfCounterpartySignature, lender), Ter(tecKILLED)); + env.close(); + } + { + // straight-up overflow: grace period + // min interval is 60 + auto const interval = maxLoanTime() + 1; + auto const total = 1; + auto const grace = interval; + auto createJson = env.json( + baseJson, kPaymentInterval(interval), kPaymentTotal(total), kGracePeriod(grace)); + + // The grace period can't be larger than the interval. + env(createJson, Sig(sfCounterpartySignature, lender), Ter(tecKILLED)); + env.close(); + } + { + // Overflow with multiplication of a few large intervals + auto const interval = 1'000'000'000; + auto const total = 10; + auto createJson = env.json(baseJson, kPaymentInterval(interval), kPaymentTotal(total)); + + env(createJson, Sig(sfCounterpartySignature, lender), Ter(tecKILLED)); + env.close(); + } + { + // Overflow with multiplication of many small payments + // min interval is 60 + auto const interval = 60; + auto const total = 1'000'000'000; + auto createJson = env.json(baseJson, kPaymentInterval(interval), kPaymentTotal(total)); + + env(createJson, Sig(sfCounterpartySignature, lender), Ter(tecKILLED)); + env.close(); + } + { + // Overflow with an absurdly large grace period + // min interval is 60 + auto const total = 60; + auto const interval = (maxLoanTime() - total) / total; + auto const grace = interval; + auto createJson = env.json( + baseJson, kPaymentInterval(interval), kPaymentTotal(total), kGracePeriod(grace)); + + env(createJson, Sig(sfCounterpartySignature, lender), Ter(tecKILLED)); + env.close(); + } + { + // Start date when the ledger is closed will be larger + auto const keylet = nextLoanKeylet(env, broker); + + auto const grace = 100; + auto const interval = maxLoanTime() - grace; + auto const total = 1; + auto createJson = env.json( + baseJson, kPaymentInterval(interval), kPaymentTotal(total), kGracePeriod(grace)); + + env(createJson, Sig(sfCounterpartySignature, lender), Ter(tesSUCCESS)); + env.close(); + + // The transaction is killed in the closed ledger + auto const meta = env.meta(); + if (BEAST_EXPECT(meta)) + { + BEAST_EXPECT(meta->at(sfTransactionResult) == tecKILLED); + } + + // If the transaction had succeeded, the loan would exist + auto const loanSle = env.le(keylet); + // but it doesn't + BEAST_EXPECT(!loanSle); + } + { + // Start date when the ledger is closed will be larger + auto const keylet = nextLoanKeylet(env, broker); + + auto const closeStartDate = ((parentCloseTime() / 10) + 1) * 10; + auto const grace = 5'000; + auto const interval = kMaxTime - closeStartDate - grace; + auto const total = 1; + auto createJson = env.json( + baseJson, kPaymentInterval(interval), kPaymentTotal(total), kGracePeriod(grace)); + + env(createJson, Sig(sfCounterpartySignature, lender), Ter(tesSUCCESS)); + env.close(); + + // The transaction succeeds in the closed ledger + auto const meta = env.meta(); + if (BEAST_EXPECT(meta)) + { + BEAST_EXPECT(meta->at(sfTransactionResult) == tesSUCCESS); + } + + // This loan exists + auto const afterState = getCurrentState(env, broker, keylet); + BEAST_EXPECT(afterState.nextPaymentDate == kMaxTime - grace); + BEAST_EXPECT(afterState.previousPaymentDate == 0); + BEAST_EXPECT(afterState.paymentRemaining == 1); + } + + { + // Ensure the borrower has funds to pay back the loan + env(pay(issuer, borrower, iouAsset(Number{1'055'524'81, -2}))); + + // Start date when the ledger is closed will be larger + auto const closeStartDate = ((parentCloseTime() / 10) + 1) * 10; + auto const grace = 5'000; + auto const maxLoanTime = kMaxTime - closeStartDate - grace; + auto const total = [&]() { + if (maxLoanTime % 5 == 0) + return 5; + if (maxLoanTime % 3 == 0) + return 3; + if (maxLoanTime % 2 == 0) + return 2; + return 0; + }(); + if (!BEAST_EXPECT(total != 0)) + return; + + auto const brokerState = env.le(keylet::loanBroker(broker.brokerID)); + // Intentionally shadow the outer values + auto const loanSequence = brokerState->at(sfLoanSequence); + auto const keylet = keylet::loan(broker.brokerID, loanSequence); + + auto const interval = maxLoanTime / total; + auto createJson = env.json( + baseJson, kPaymentInterval(interval), kPaymentTotal(total), kGracePeriod(grace)); + + env(createJson, Sig(sfCounterpartySignature, lender), Ter(tesSUCCESS)); + env.close(); + + // This loan exists + auto const beforeState = getCurrentState(env, broker, keylet); + BEAST_EXPECT(beforeState.nextPaymentDate == closeStartDate + interval); + BEAST_EXPECT(beforeState.previousPaymentDate == 0); + BEAST_EXPECT(beforeState.paymentRemaining == total); + BEAST_EXPECT(beforeState.periodicPayment > 0); + + // pay all but the last payment + { + NumberRoundModeGuard const mg{Number::RoundingMode::Upward}; + Number const payment = beforeState.periodicPayment * (total - 1); + XRPAmount const payFee{baseFee * ((total - 1) / kLoanPaymentsPerFeeIncrement + 1)}; + STAmount const paymentAmount = + roundToScale(STAmount{broker.asset, payment}, beforeState.loanScale); + auto loanPayTx = env.json(pay(borrower, keylet.key, paymentAmount), Fee(payFee)); + env(loanPayTx, Ter(tesSUCCESS)); + env.close(); + } + + // The loan is on the last payment + auto const afterState = getCurrentState(env, broker, keylet); + BEAST_EXPECT(afterState.paymentRemaining == 1); + BEAST_EXPECT(afterState.nextPaymentDate == kMaxTime - grace); + BEAST_EXPECT(afterState.previousPaymentDate == kMaxTime - grace - interval); + } + } + + void + runAmendmentIndependent() + { + testLoanSetNearZeroInterestRateSucceeds(); + } + + // Tests run under each entry in amendmentCombinations(). + void + runAmendmentSensitive(FeatureBitset features) + { +#if LOAN_TODO + testLoanPayLateFullPaymentBypassesPenalties(features); +#endif + testOverpaymentManagementFee(features); + testDosLoanPay(features); + testLoanNextPaymentDueDateOverflow(features); + } + +public: + void + run() override + { + runAmendmentIndependent(); + for (auto const& features : jtx::amendmentCombinations( + {fixCleanup3_1_3, fixCleanup3_2_0, featureMPTokensV2}, all_)) + runAmendmentSensitive(features); + } +}; + +BEAST_DEFINE_TESTSUITE(LoanPay, tx, xrpl); + +} // namespace xrpl::test diff --git a/src/test/app/lending/LoanRounding_test.cpp b/src/test/app/lending/LoanRounding_test.cpp new file mode 100644 index 00000000000..b9338528f13 --- /dev/null +++ b/src/test/app/lending/LoanRounding_test.cpp @@ -0,0 +1,993 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +namespace xrpl::test { + +class LoanRounding_test : public LoanTestBase +{ +private: + void + testDustManipulation(FeatureBitset features) + { + testcase("Dust manipulation"); + + using namespace jtx; + using namespace std::chrono_literals; + Env env{*this, features}; + + // Setup: Create accounts + Account const issuer{"issuer"}; + Account const lender{"lender"}; + Account const borrower{"borrower"}; + Account const victim{"victim"}; + + env.fund(XRP(1'000'000'00), issuer, lender, borrower, victim); + env.close(); + + // Step 1: Create vault with IOU asset + auto asset = issuer["USD"]; + env(trust(lender, asset(100000))); + env(trust(borrower, asset(100000))); + env(trust(victim, asset(100000))); + env(pay(issuer, lender, asset(50000))); + env(pay(issuer, borrower, asset(50000))); + env(pay(issuer, victim, asset(50000))); + env.close(); + + BrokerParameters const brokerParams{ + .vaultDeposit = 10000, + .debtMax = Number{0}, + .coverRateMin = TenthBips32{1000}, + .coverRateLiquidation = TenthBips32{2500}}; + + auto broker = createVaultAndBroker(env, asset, lender, brokerParams); + + auto const loanKeyletOpt = [&]() -> std::optional { + auto const brokerSle = env.le(keylet::loanBroker(broker.brokerID)); + if (!BEAST_EXPECT(brokerSle)) + return std::nullopt; + + // Broker has no loans + BEAST_EXPECT(brokerSle->at(sfOwnerCount) == 0); + + // The loan keylet is based on the LoanSequence of the + // _LOAN_BROKER_ object. + auto const loanSequence = brokerSle->at(sfLoanSequence); + return keylet::loan(broker.brokerID, loanSequence); + }(); + if (!loanKeyletOpt) + return; + + auto const& vaultKeylet = broker.vaultKeylet(); + + { + auto const vaultSle = env.le(vaultKeylet); + Number const assetsTotal = vaultSle->at(sfAssetsTotal); + Number const assetsAvail = vaultSle->at(sfAssetsAvailable); + + log << "Before loan creation:" << std::endl; + log << " AssetsTotal: " << assetsTotal << std::endl; + log << " AssetsAvailable: " << assetsAvail << std::endl; + log << " Difference: " << (assetsTotal - assetsAvail) << std::endl; + + // before the loan the assets total and available should be equal + BEAST_EXPECT(assetsAvail == assetsTotal); + BEAST_EXPECT(assetsAvail == broker.asset(brokerParams.vaultDeposit).number()); + } + + Keylet const& loanKeylet = *loanKeyletOpt; + + LoanParameters const loanParams{ + .account = lender, + .counter = borrower, + .principalRequest = Number{100}, + .interest = TenthBips32{1922}, + .payTotal = 5816, + .payInterval = 86400 * 6, + .gracePd = 86400 * 5, + }; + + env(loanParams(env, broker)); + env.close(); + + // Wait for loan to be late enough to default + env.close(std::chrono::seconds(86400 * 40)); // 40 days + + { + auto const vaultSle = env.le(vaultKeylet); + Number const assetsTotal = vaultSle->at(sfAssetsTotal); + Number const assetsAvail = vaultSle->at(sfAssetsAvailable); + + log << "After loan creation:" << std::endl; + log << " AssetsTotal: " << assetsTotal << std::endl; + log << " AssetsAvailable: " << assetsAvail << std::endl; + log << " Difference: " << (assetsTotal - assetsAvail) << std::endl; + + auto const loanSle = env.le(loanKeylet); + if (!BEAST_EXPECT(loanSle)) + return; + auto const state = constructLoanState(loanSle); + + log << "Loan state:" << std::endl; + log << " ValueOutstanding: " << state.valueOutstanding << std::endl; + log << " PrincipalOutstanding: " << state.principalOutstanding << std::endl; + log << " InterestOutstanding: " << state.interestOutstanding() << std::endl; + log << " InterestDue: " << state.interestDue << std::endl; + log << " FeeDue: " << state.managementFeeDue << std::endl; + + // after loan creation the assets total and available should + // reflect the value of the loan + BEAST_EXPECT(assetsAvail < assetsTotal); + BEAST_EXPECT( + assetsAvail == + broker.asset(brokerParams.vaultDeposit - loanParams.principalRequest).number()); + BEAST_EXPECT( + assetsTotal == + broker.asset(brokerParams.vaultDeposit + state.interestDue).number()); + } + + // Step 7: Trigger default (dust adjustment will occur) + env(jtx::loan::manage(lender, loanKeylet.key, tfLoanDefault)); + env.close(); + + // Step 8: Verify phantom assets created + { + auto const vaultSle2 = env.le(vaultKeylet); + Number const assetsTotal2 = vaultSle2->at(sfAssetsTotal); + Number const assetsAvail2 = vaultSle2->at(sfAssetsAvailable); + + log << "After default:" << std::endl; + log << " AssetsTotal: " << assetsTotal2 << std::endl; + log << " AssetsAvailable: " << assetsAvail2 << std::endl; + log << " Difference: " << (assetsTotal2 - assetsAvail2) << std::endl; + + // after a default the assets total and available should be equal + BEAST_EXPECT(assetsAvail2 == assetsTotal2); + } + } + + void + testRoundingAllowsUndercoverage(FeatureBitset features) + { + testcase("Minimum cover rounding allows undercoverage (XRP)"); + + using namespace jtx; + using namespace loanBroker; + + Env env{*this, features}; + + Account const lender{"lender"}; + Account const borrower{"borrower"}; + + env.fund(XRP(200'000), lender, borrower); + env.close(); + + // Vault with XRP asset + Vault const vault{env}; + auto [vaultCreate, vaultKeylet] = vault.create({.owner = lender, .asset = xrpIssue()}); + env(vaultCreate); + env.close(); + BEAST_EXPECT(env.le(vaultKeylet)); + + // Seed the vault with XRP so it can fund the loan principal + PrettyAsset const xrpAsset{xrpIssue(), 1}; + + BrokerParameters const brokerParams{ + .vaultDeposit = 1'000, + .debtMax = Number{0}, + .coverRateMin = TenthBips32{10'000}, + .coverDeposit = 82, + }; + + auto const brokerInfo = createVaultAndBroker(env, xrpAsset, lender, brokerParams); + // Create a loan with principal 804 XRP and 0% interest (so + // DebtTotal increases by exactly 804) + env(loan::set(borrower, brokerInfo.brokerID, xrpAsset(804).value()), + loan::kInterestRate(TenthBips32(0)), + Sig(sfCounterpartySignature, lender), + Fee(env.current()->fees().base * 2)); + BEAST_EXPECT(env.ter() == tesSUCCESS); + env.close(); + + // Verify DebtTotal is exactly 804 + if (auto const brokerSle = env.le(keylet::loanBroker(brokerInfo.brokerID)); + BEAST_EXPECT(brokerSle)) + { + log << *brokerSle << std::endl; + BEAST_EXPECT(brokerSle->at(sfDebtTotal) == Number(804)); + } + + // Attempt to withdraw 2 XRP to self, leaving 80 XRP CoverAvailable. + // The minimum is 80.4 XRP, which rounds up to 81 XRP, so this fails. + env(coverWithdraw(lender, brokerInfo.brokerID, xrpAsset(2).value()), + Ter(tecINSUFFICIENT_FUNDS)); + BEAST_EXPECT(env.ter() == tecINSUFFICIENT_FUNDS); + env.close(); + + // Attempt to withdraw 1 XRP to self, leaving 81 XRP CoverAvailable. + // because that leaves sufficient cover, this succeeds + env(coverWithdraw(lender, brokerInfo.brokerID, xrpAsset(1).value())); + BEAST_EXPECT(env.ter() == tesSUCCESS); + env.close(); + + // Validate CoverAvailable == 80 XRP and DebtTotal remains 804 + if (auto const brokerSle = env.le(keylet::loanBroker(brokerInfo.brokerID)); + BEAST_EXPECT(brokerSle)) + { + log << *brokerSle << std::endl; + BEAST_EXPECT(brokerSle->at(sfCoverAvailable) == xrpAsset(81).value()); + BEAST_EXPECT(brokerSle->at(sfDebtTotal) == Number(804)); + + // Also demonstrate that the true minimum (804 * 10%) exceeds 80 + auto const theoreticalMin = tenthBipsOfValue(Number(804), TenthBips32(10'000)); + log << "Theoretical min cover: " << theoreticalMin << std::endl; + BEAST_EXPECT(Number(804, -1) == theoreticalMin); + } + } + + void + testYieldTheftRounding(std::uint32_t flags) + { + testcase("Rounding manipulation does not permit yield theft"); + using namespace jtx; + using namespace loan; + + // 1. Setup Environment + Env env(*this, all_); + Account const issuer{"issuer"}; + Account const lender{"lender"}; + Account const borrower{"borrower"}; + + env.fund(XRP(1000), issuer, lender, borrower); + env.close(); + + // 2. Asset Selection + PrettyAsset const iou = issuer["USD"]; + env(trust(lender, iou(100'000'000))); + env(trust(borrower, iou(100'000'000))); + env(pay(issuer, lender, iou(100'000'000))); + env(pay(issuer, borrower, iou(100'000'000))); + env.close(); + + // 3. Create Vault and Broker with High Debt Limit (100M) + auto const brokerInfo = createVaultAndBroker( + env, + iou, + lender, + { + .vaultDeposit = 5'000'000, + .debtMax = Number{100'000'000}, + .coverDeposit = 500'000, + }); + auto const [currentSeq, vaultKeylet] = [&]() { + auto const brokerSle = env.le(keylet::loanBroker(brokerInfo.brokerID)); + if (!BEAST_EXPECT(brokerSle)) + return std::make_tuple(0u, keylet::unchecked(beast::kZero)); + auto const currentSeq = brokerSle->at(sfLoanSequence); + auto const vaultKeylet = keylet::vault(brokerSle->at(sfVaultID)); + return std::make_tuple(currentSeq, vaultKeylet); + }(); + + // 4. Loan Parameters (Attack Vector) + Number const principal = 1'000'000; + TenthBips32 const interestRate = TenthBips32{1}; // 0.001% + std::uint32_t const paymentInterval = 86400; + std::uint32_t const paymentTotal = 3650; + + auto const loanSetFee = Fee(env.current()->fees().base * 2); + env(set(borrower, brokerInfo.brokerID, iou(principal).value(), flags), + Sig(sfCounterpartySignature, lender), + loan::kInterestRate(interestRate), + loan::kPaymentInterval(paymentInterval), + loan::kPaymentTotal(paymentTotal), + Fee(loanSetFee)); + env.close(); + + // --- RETRIEVE OBJECTS & SETUP ATTACK --- + + auto borrowerBalance = [&]() { return env.balance(borrower, iou); }; + auto const borrowerScale = static_cast(borrowerBalance()).exponent(); + + auto const loanKeylet = keylet::loan(brokerInfo.brokerID, currentSeq); + auto const maybePeriodicPayment = [&]() -> std::optional { + auto const loanSle = env.le(loanKeylet); + if (!BEAST_EXPECT(loanSle)) + return std::nullopt; + // Construct Payment + return STAmount{iou, loanSle->at(sfPeriodicPayment)}; + }(); + if (!maybePeriodicPayment) + return; + auto const periodicPayment = *maybePeriodicPayment; + auto const roundedPayment = + roundToScale(periodicPayment, borrowerScale, Number::RoundingMode::Upward); + + // ATTACK: Add dust buffer (1e-9) to force 'excess' logic execution + STAmount const paymentBuffer{iou, Number(1, -9)}; + STAmount const attackPayment = periodicPayment + paymentBuffer; + + auto const maybeInitialVaultAssets = [&]() -> std::optional { + auto const vault = env.le(vaultKeylet); + if (!BEAST_EXPECT(vault)) + return std::nullopt; + return vault->at(sfAssetsTotal); + }(); + if (!maybeInitialVaultAssets) + return; + auto const initialVaultAssets = *maybeInitialVaultAssets; + + // 5. Execution Loop + int yieldTheftCount = 0; + auto previousAssetsTotal = initialVaultAssets; + + for (int i = 0; i < 100; ++i) + { + auto const balanceBefore = borrowerBalance(); + env(pay(borrower, loanKeylet.key, attackPayment, flags)); + env.close(); + auto const borrowerDelta = balanceBefore - borrowerBalance(); + BEAST_EXPECT(borrowerDelta.signum() == roundedPayment.signum()); + + auto const loanSle = env.le(loanKeylet); + if (!BEAST_EXPECT(loanSle)) + break; + auto const updatedPayment = STAmount{iou, loanSle->at(sfPeriodicPayment)}; + BEAST_EXPECT( + (roundToScale(updatedPayment, borrowerScale, Number::RoundingMode::Upward) == + roundedPayment)); + BEAST_EXPECT( + (updatedPayment == periodicPayment) || + (flags == tfLoanOverpayment && i >= 2 && updatedPayment < periodicPayment)); + + auto const currentVaultSle = env.le(vaultKeylet); + if (!BEAST_EXPECT(currentVaultSle)) + break; + + auto const currentAssetsTotal = currentVaultSle->at(sfAssetsTotal); + auto const delta = currentAssetsTotal - previousAssetsTotal; + + BEAST_EXPECT( + (delta == beast::kZero && borrowerDelta <= roundedPayment) || + (delta > beast::kZero && borrowerDelta > roundedPayment)); + + // If tx succeeded but Assets Total didn't change, interest was + // stolen. + if (delta == beast::kZero && borrowerDelta > roundedPayment) + { + yieldTheftCount++; + } + + previousAssetsTotal = currentAssetsTotal; + } + + BEAST_EXPECTS(yieldTheftCount == 0, std::to_string(yieldTheftCount)); + } + + // Regression for the dual-rounding fix at coarse (integer-MPT) scale. + // + // Loan: P=1, r=50% (50000 tenth-bips), n=3, yearly interval. The + // amortization schedule produces a fractional principal + // (~0.47) which under round-to-nearest collapses to 0 in a single + // step, causing `doPayment`'s strict `>` assertion on principal to + // fire mid-loan. With fixCleanup3_2_0 enabled, principal is rounded + // upward (sticks at 1 across the first two periods) and only clears + // in the final payment. + // + // The test pays one period at a time across three LoanPay + // transactions and verifies the loan completes (paymentRemaining=0) + // with totals matching the loan's economics (1 principal + 2 interest). + void + testIntegerScalePrincipalSticks(FeatureBitset features) + { + // Without fixCleanup3_2_0, this behavior will abort the server, so + // don't run without it. + if (!features[fixCleanup3_2_0]) + return; + + testcase("edge: integer MPT principal stuck mid-loan completes via final"); + + using namespace jtx; + Env env(*this, features); + + Account const issuer{"issuer"}; + Account const lender{"lender"}; + Account const borrower{"borrower"}; + + env.fund(XRP(100'000), issuer, lender, borrower); + env.close(); + + MPTTester mptt{env, issuer, kMptInitNoFund}; + mptt.create({.maxAmt = 100'000, .flags = tfMPTCanTransfer}); + PrettyAsset const asset{mptt.issuanceID()}; + + mptt.authorize({.account = lender}); + mptt.authorize({.account = borrower}); + + env(pay(issuer, lender, asset(10'000))); + env(pay(issuer, borrower, asset(10'000))); + env.close(); + + Vault const vault{env}; + auto [vaultTx, vaultKeylet] = vault.create({.owner = lender, .asset = asset}); + env(vaultTx); + env.close(); + + env(vault.deposit({.depositor = lender, .id = vaultKeylet.key, .amount = asset(5'000)})); + env.close(); + + auto const brokerKeylet = keylet::loanBroker(lender.id(), env.seq(lender)); + env(loanBroker::set(lender, vaultKeylet.key), + loanBroker::kDebtMaximum(Number{100}), + Fee(env.current()->fees().base * 2)); + env.close(); + + auto const brokerStateBefore = env.le(brokerKeylet); + if (!BEAST_EXPECT(brokerStateBefore)) + return; + auto const loanSequence = brokerStateBefore->at(sfLoanSequence); + auto const loanKeylet = keylet::loan(brokerKeylet.key, loanSequence); + + env(loan::set(borrower, brokerKeylet.key, Number{1}), + Sig(sfCounterpartySignature, lender), + loan::kInterestRate(TenthBips32{50'000}), + loan::kPaymentTotal(3), + loan::kPaymentInterval(31'536'000), + Fee(env.current()->fees().base * 2)); + env.close(); + + auto const borrowerStart = env.balance(borrower, asset).value(); + + // Three separate periodic payments of 1 each. Expected per-period + // evolution at integer MPT scale (TVO = PO + interestDue + + // managementFeeDue): + // start: PO=1, TVO=3, paymentRemaining=3 + // after pay #1: PO=1, TVO=2, paymentRemaining=2 (principal sticks) + // after pay #2: PO=1, TVO=1, paymentRemaining=1 (principal sticks) + // after pay #3: PO=0, TVO=0, paymentRemaining=0 (final clears) + std::array const expectedPO{Number{1}, Number{1}, Number{0}}; + std::array const expectedTVO{Number{2}, Number{1}, Number{0}}; + std::array const expectedRemaining{2, 1, 0}; + + for (int i = 0; i < 3; ++i) + { + env(loan::pay(borrower, loanKeylet.key, asset(1)), Ter(tesSUCCESS)); + env.close(); + + auto const sle = env.le(loanKeylet); + if (!BEAST_EXPECT(sle)) + return; + BEAST_EXPECT(sle->at(sfPrincipalOutstanding) == expectedPO[i]); + BEAST_EXPECT(sle->at(sfTotalValueOutstanding) == expectedTVO[i]); + BEAST_EXPECT(sle->at(sfPaymentRemaining) == expectedRemaining[i]); + } + + // Borrower paid 3 total regardless of fee split (1 principal + 2 + // interest+fee, matching loan economics). + auto const borrowerEnd = env.balance(borrower, asset).value(); + BEAST_EXPECT(borrowerStart - borrowerEnd == asset(3).value()); + } + +#if LOAN_TODO + void + testLoanCoverMinimumRoundingExploit(FeatureBitset features) + { + auto testLoanCoverMinimumRoundingExploit = [&, this](Number const& principalRequest) { + testcase << "LoanBrokerCoverClawback drains cover via rounding" + << " principalRequested=" << to_string(principalRequest); + + using namespace jtx; + using namespace loan; + using namespace loanBroker; + + Env env(*this, features); + + Account const issuer{"issuer"}; + Account const lender{"lender"}; + Account const borrower{"borrower"}; + + env.fund(XRP(1'000'000'000), issuer, lender, borrower); + env.close(); + + env(fset(issuer, asfAllowTrustLineClawback)); + env.close(); + + PrettyAsset const asset = issuer[iouCurrency]; + env(trust(lender, asset(2'000'0000))); + env(trust(borrower, asset(2'000'0000))); + env.close(); + + env(pay(issuer, lender, asset(2'000'0000))); + env.close(); + + BrokerParameters brokerParams{.debtMax = 0, .coverRateMin = TenthBips32{10'000}}; + BrokerInfo broker{createVaultAndBroker(env, asset, lender, brokerParams)}; + + auto const loanSetFee = Fee(env.current()->fees().base * 2); + auto createTx = env.jt( + set(borrower, broker.brokerID, principalRequest), + Sig(sfCounterpartySignature, lender), + loanSetFee, + kPaymentInterval(600), + kPaymentTotal(1), + kGracePeriod(60)); + env(createTx); + env.close(); + + auto const brokerBefore = env.le(keylet::loanBroker(broker.brokerID)); + BEAST_EXPECT(brokerBefore); + if (!brokerBefore) + return; + + Number const debtOutstanding = brokerBefore->at(sfDebtTotal); + Number const coverAvailableBefore = brokerBefore->at(sfCoverAvailable); + + BEAST_EXPECT(debtOutstanding > Number{}); + BEAST_EXPECT(coverAvailableBefore > Number{}); + + log << "debt=" << to_string(debtOutstanding) + << " cover_available=" << to_string(coverAvailableBefore); + + env(coverClawback(issuer, 0), loanBrokerID(broker.brokerID)); + env.close(); + + auto const brokerAfter = env.le(keylet::loanBroker(broker.brokerID)); + BEAST_EXPECT(brokerAfter); + if (!brokerAfter) + return; + + Number const debtAfter = brokerAfter->at(sfDebtTotal); + // the debt has not changed + BEAST_EXPECT(debtAfter == debtOutstanding); + + Number const coverAvailableAfter = brokerAfter->at(sfCoverAvailable); + + // since the cover rate min != 0, the cover available should not + // be zero + BEAST_EXPECT(coverAvailableAfter != Number{}); + }; + + // Call the lambda with different principal values + testLoanCoverMinimumRoundingExploit(Number{1, -30}); // 1e-30 units + testLoanCoverMinimumRoundingExploit(Number{1, -20}); // 1e-20 units + testLoanCoverMinimumRoundingExploit(Number{1, -10}); // 1e-10 units + testLoanCoverMinimumRoundingExploit(Number{1, 1}); // 1e-10 units + } +#endif + + // A residual overpayment can reduce the stored principal by one scale-unit + // *less* than computeOverpaymentComponents predicts, firing the + // "principal change agrees" XRPL_ASSERT_PARTS in doOverpayment: + // + // trackedPrincipalDelta == principalOutstanding - newPrincipalOutstanding + // + // tryOverpayment re-amortizes the loan at the reduced principal, then + // re-derives the theoretical principal from the new periodic payment via + // (P * paymentFactor) / paymentFactor. That round-trip is not exact in + // Number's 19-digit arithmetic; a positive residual pushes the recomputed + // principal a hair above the exact grid point `oldPrincipal - delta`, and + // the Upward rounding in tryOverpayment then bumps it a full scale-unit + // higher. The principal therefore drops by `delta - 1 unit`, not `delta`. + // + // Concrete case (isolated, at the tryOverpayment level): + // A 100 USD loan at the minimum non-zero rate, 3 payments, loanScale -10. + // After one regular payment (principalOutstanding 66.6666666674) a residual overpayment of + // 0.049999998 yields trackedPrincipalDelta 0.048999998 but only reduces the principal by + // 0.0489999979 (newPrincipal 66.6176666695) — short by 1e-10. + // + // With fixCleanup3_2_0, tryOverpayment pins the new principal to the exact, + // on-grid reduction (oldPrincipal - trackedPrincipalDelta) instead of the + // lossy (P*factor)/factor round-trip, so the assertion holds and the + // overpayment applies cleanly. The three "principal change agrees" / + // "interest paid agrees" / "principal payment matches" assertions are + // gated behind the same amendment, so without it they are disabled (the + // server does not abort) and the loan keeps the pre-amendment computation. + // + // The test runs the same scenario under both amendment settings and checks + // the stored principal against a ground-truth value derived independently of + // the loan-state computation under test. + void + testBugOverpaymentPrincipalChange() + { + testcase("bug: doOverpayment asserts 'principal change agrees'"); + + using namespace jtx; + using namespace loan; + using namespace xrpl::detail; + + struct Params + { + TenthBips32 interestRate; + TenthBips16 managementFeeRate; + std::uint32_t paymentTotal; + std::uint32_t paymentInterval; + std::int64_t principal; + Number overpayment; + TenthBips32 overpaymentInterestRate; + TenthBips32 overpaymentFeeRate; + std::optional vaultScale; + }; + + struct Result + { + Number principalOutstanding; // stored principal after the LoanPay + Number expectedNewPrincipal; // ground truth, independent of the fix + Number managementFeeChange; // managementFeeOutstanding after - before + Number unit; // one scale-unit at the loan scale + }; + + auto runScenario = [this](FeatureBitset features, Params const& p) -> Result { + Env env(*this, features); + + Account const issuer{"issuer"}; + Account const lender{"vaultOwner"}; + Account const borrower{"borrower"}; + + PrettyAsset const iouAsset = createFundedRippleIouAsset(env, issuer, lender, borrower); + Asset const asset = iouAsset.raw(); + + auto const broker = createVaultAndBroker( + env, + iouAsset, + lender, + {.vaultDeposit = 900'000, + .debtMax = 0, + .managementFeeRate = p.managementFeeRate, + .vaultScale = p.vaultScale}); + + auto const brokerSle = env.le(broker.brokerKeylet()); + BEAST_EXPECT(brokerSle); + auto const loanSequence = brokerSle ? brokerSle->at(sfLoanSequence) : 0; + auto const loanKeylet = keylet::loan(broker.brokerID, loanSequence); + + env(set(borrower, broker.brokerID, Number{p.principal}, tfLoanOverpayment), + Sig(sfCounterpartySignature, lender), + kInterestRate(p.interestRate), + kPaymentTotal(p.paymentTotal), + kPaymentInterval(p.paymentInterval), + kGracePeriod(p.paymentInterval), + kOverpaymentFee(p.overpaymentFeeRate), + kOverpaymentInterestRate(p.overpaymentInterestRate), + Fee(env.current()->fees().base * 2), + Ter(tesSUCCESS)); + env.close(); + + // The single LoanPay below makes one regular payment (the overpayment + // is smaller than one period) and leaves the residual as an + // overpayment. + auto const s = getCurrentState(env, broker, loanKeylet); + auto const periodicRate = loanPeriodicRate(s.interestRate, s.paymentInterval); + auto const onePeriod = computePaymentComponents( + env.current()->rules(), + asset, + s.loanScale, + s.totalValue, + s.principalOutstanding, + s.managementFeeOutstanding, + s.periodicPayment, + periodicRate, + s.paymentRemaining, + p.managementFeeRate); + + // Ground truth: the stored principal must drop by exactly the regular + // payment's principal portion plus the overpayment's principal + // portion. computeOverpaymentComponents depends only on the + // overpayment amount and rates (not on the loan-state computation + // under test), so it is an independent oracle. Both components are + // computed under the same rules as the env so the payment factor + // matches. + auto const overpaymentComponents = computeOverpaymentComponents( + env.current()->rules(), + asset, + s.loanScale, + p.overpayment, + p.overpaymentInterestRate, + p.overpaymentFeeRate, + p.managementFeeRate); + Number const expectedNewPrincipal = s.principalOutstanding - + onePeriod.trackedPrincipalDelta - overpaymentComponents.trackedPrincipalDelta; + + Number const managementFeeBefore = s.managementFeeOutstanding; + + STAmount const payAmount{asset, onePeriod.trackedValueDelta + p.overpayment}; + env(pay(borrower, loanKeylet.key, payAmount), + Txflags(tfLoanOverpayment), + Ter(tesSUCCESS)); + env.close(); + + auto const loanSle = env.le(loanKeylet); + BEAST_EXPECT(loanSle); + + return Result{ + .principalOutstanding = loanSle ? Number{loanSle->at(sfPrincipalOutstanding)} : 0, + .expectedNewPrincipal = expectedNewPrincipal, + .managementFeeChange = + (loanSle ? Number{loanSle->at(sfManagementFeeOutstanding)} : Number{0}) - + managementFeeBefore, + .unit = Number{1, s.loanScale}}; + }; + + // Scenario 1: the original near-zero-rate principal reproduction + // (loanScale -10, no management fee). 0.049999998 is smaller than one + // period, so it stays a residual overpayment. + Params const principalCase{ + .interestRate = TenthBips32{1}, + .managementFeeRate = TenthBips16{0}, + .paymentTotal = 3, + .paymentInterval = 60, + .principal = 100, + .overpayment = Number{49999998, -9}, + .overpaymentInterestRate = TenthBips32{1000}, + .overpaymentFeeRate = TenthBips32{1000}, + .vaultScale = 1}; + + // With fixCleanup3_2_0 the stored principal lands exactly on the + // ground-truth grid point: it is reduced by exactly the overpayment's + // principal portion. This is the key correctness check: if the principal + // pin were removed (even with the assertions still gated off), the lossy + // (P * factor) / factor round-trip would leave the principal one + // scale-unit high and this would fail. + Result const fixed = runScenario(all_, principalCase); + BEAST_EXPECTS( + fixed.principalOutstanding == fixed.expectedNewPrincipal, + "fixed principal " + to_string(fixed.principalOutstanding) + " != expected " + + to_string(fixed.expectedNewPrincipal)); + + // Without the amendment the loan amortizes with the catastrophically + // cancelling near-zero payment factor, so its schedule (and ground truth) + // differ from the fixed case; the gated assertions keep the server from + // aborting and the overpayment still lands exactly on that schedule. + Result const legacy = runScenario(all_ - fixCleanup3_2_0, principalCase); + BEAST_EXPECTS( + legacy.principalOutstanding == legacy.expectedNewPrincipal, + "legacy principal " + to_string(legacy.principalOutstanding) + " != expected " + + to_string(legacy.expectedNewPrincipal)); + + // Scenario 2: a normal-rate loan with a 10% management fee. At a normal + // rate the payment factor is identical across the amendment, so toggling + // fixCleanup3_2_0 isolates the fix. This overpayment (found by search) + // lands on a state where both the principal and the management fee differ + // by one scale-unit between the fixed and legacy paths. + Params const feeCase{ + .interestRate = TenthBips32{10000}, + .managementFeeRate = TenthBips16{10000}, + .paymentTotal = 6, + .paymentInterval = 30u * 24 * 60 * 60, + .principal = 1000, + .overpayment = Number{214367363, -10}, + .overpaymentInterestRate = TenthBips32{0}, + .overpaymentFeeRate = TenthBips32{0}, + .vaultScale = std::nullopt}; + + Result const feeFixed = runScenario(all_, feeCase); + Result const feeLegacy = runScenario(all_ - fixCleanup3_2_0, feeCase); + + // With the fix the principal is the exact reduction; without it the lossy + // (P * factor) / factor round-trip leaves it one scale-unit high. + BEAST_EXPECTS( + feeFixed.principalOutstanding == feeFixed.expectedNewPrincipal, + "fee-case fixed principal " + to_string(feeFixed.principalOutstanding) + + " != expected " + to_string(feeFixed.expectedNewPrincipal)); + BEAST_EXPECTS( + feeLegacy.principalOutstanding == feeLegacy.expectedNewPrincipal + feeLegacy.unit, + "fee-case legacy principal " + to_string(feeLegacy.principalOutstanding) + + " != expected " + to_string(feeLegacy.expectedNewPrincipal + feeLegacy.unit)); + + // Management fee: the overpayment re-amortizes a fee-bearing loan, so the management fee + // outstanding drops. + // + // Unlike the principal that is already at the correct precision, the re-amortized + // management fee is tenthBipsOfValue of the new schedule's gross interest, which depends + // on the recomputed periodic payment. So the expected change below is a pinned constant + // captured from a passing run a magic value only because there is nothing simpler to + // compare against. + // + // At the integration level, toggling the amendment also changes the regular payment's + // rounding so a fixed-vs-legacy comparison cannot isolate the overpayment management-fee + // fix. + BEAST_EXPECT(feeFixed.managementFeeChange == feeLegacy.managementFeeChange); + BEAST_EXPECTS( + (feeFixed.managementFeeChange == Number{-8219709543, -10}), + "fee-case mgmt fee change " + to_string(feeFixed.managementFeeChange)); + } + + // An overpayment whose residual amount has more precision than loanScale + // fires the isRounded(asset, overpayment, loanScale) assertion in + // computeOverpaymentComponents (and a downstream "interest paid agrees" + // assertion in doOverpayment). fixCleanup3_2_0 rounds the residual down + // to loanScale before passing it in. The pre-amendment path can't be + // tested here because the assertion fires in Debug builds and aborts + // the test process — see the PR description for context. + void + testBugOverpayUnroundedAmount() + { + testcase("bug: computeOverpaymentComponents isRounded assertion"); + + using namespace jtx; + using namespace loan; + Env env(*this, all_); + + Account const issuer{"issuer"}; + Account const lender{"vaultOwner"}; + Account const borrower{"borrower"}; + + PrettyAsset const iouAsset = createFundedRippleIouAsset(env, issuer, lender, borrower); + + auto const broker = createVaultAndBroker( + env, + iouAsset, + lender, + {.vaultDeposit = 100'000, + .debtMax = 5000, + .managementFeeRate = TenthBips16{1000}, + .vaultScale = 1}); + + auto const sleBroker = env.le(broker.brokerKeylet()); + if (!BEAST_EXPECT(sleBroker)) + return; + auto const loanSequence = sleBroker->at(sfLoanSequence); + auto const loanKeylet = keylet::loan(broker.brokerID, loanSequence); + + using namespace loan; + env(set(borrower, broker.brokerID, Number{1000}, tfLoanOverpayment), + Sig(sfCounterpartySignature, lender), + kInterestRate(TenthBips32{10000}), + kPaymentTotal(12), + kPaymentInterval(60), + kGracePeriod(60), + kOverpaymentFee(TenthBips32{1000}), + kOverpaymentInterestRate(TenthBips32{1000}), + Fee(env.current()->fees().base * 2), + Ter(tesSUCCESS)); + env.close(); + + // periodic * 1.5 at 15-sig-digit precision: 125.000154585042. This + // has too many digits to round cleanly to loanScale=-10, so the + // overpayment residual fails the isRounded check. + STAmount const payAmount{iouAsset.raw(), Number{125'000'154'585'042LL, -12}}; + env(pay(borrower, loanKeylet.key, payAmount), Txflags(tfLoanOverpayment), Ter(tesSUCCESS)); + env.close(); + } + + // A near-zero interest rate on a 100 USD loan + // produces total interest of ~6 units at loanScale -9. Numerical error + // in the amortization formula pushes the theoretical principal above + // the theoretical value, producing a negative theoretical interest. + // The payment delta then exceeds the actual outstanding interest, + // violating XRPL_ASSERT_PARTS in computePaymentComponents. + void + testBugInterestDueDeltaCrash() + { + testcase("bug: LoanPay asserts 'interest due delta' on near-zero rate"); + + using namespace jtx; + using namespace std::chrono_literals; + Env env(*this, all_); + + Account const issuer{"issuer"}; + Account const lender{"lender"}; + Account const borrower{"borrower"}; + + env.fund(XRP(1'000'000), issuer, lender, borrower); + env.close(); + env(fset(issuer, asfDefaultRipple)); + env.close(); + + PrettyAsset const iouAsset = issuer["USD"]; + env(trust(lender, iouAsset(1'000'000'000))); + env(trust(borrower, iouAsset(1'000'000'000))); + env(pay(issuer, lender, iouAsset(5'000'000))); + env(pay(issuer, borrower, iouAsset(5'000'000))); + env.close(); + + BrokerParameters const brokerParams{ + .vaultDeposit = 1'000'000, + .debtMax = 1'000'000, + .coverRateMin = TenthBips32{0}, + .coverDeposit = 0, + .managementFeeRate = TenthBips16{0}, + .coverRateLiquidation = TenthBips32{0}}; + + BrokerInfo const broker{createVaultAndBroker(env, iouAsset, lender, brokerParams)}; + + using namespace loan; + + auto const loanSetFee = Fee(env.current()->fees().base * 2); + Number const principalRequest{100}; + + auto createJson = env.json( + set(borrower, broker.brokerID, principalRequest), + Fee(loanSetFee), + Json(sfCounterpartySignature, json::ValueType::Object)); + + createJson["InterestRate"] = 1; // minimum non-zero rate + createJson["PaymentTotal"] = 3; + createJson["PaymentInterval"] = 600; + + auto const keylet = nextLoanKeylet(env, broker); + + createJson = env.json(createJson, Sig(sfCounterpartySignature, lender)); + env(createJson, Ter(tesSUCCESS)); + env.close(); + + // For principal=100, n=3 the amortization schedule produces a + // periodic payment ≈ 33.33 USD. We pay 35 USD, which is more than + // one period's worth — enough for the LoanPay path to enter + // computePaymentComponents and reach the assertion that fires + // when the bug is present. With the fix, the tx applies cleanly. + env(pay(borrower, keylet.key, iouAsset(35)), Ter(tesSUCCESS)); + env.close(); + } + + void + runAmendmentIndependent() + { + for (auto const flags : {0u, tfLoanOverpayment}) + testYieldTheftRounding(flags); + testBugOverpaymentPrincipalChange(); + testBugOverpayUnroundedAmount(); + testBugInterestDueDeltaCrash(); + } + + // Tests run under each entry in amendmentCombinations(). + void + runAmendmentSensitive(FeatureBitset features) + { + testDustManipulation(features); + testRoundingAllowsUndercoverage(features); + testIntegerScalePrincipalSticks(features); +#if LOAN_TODO + testLoanCoverMinimumRoundingExploit(features); +#endif + } + +public: + void + run() override + { + runAmendmentIndependent(); + for (auto const& features : jtx::amendmentCombinations( + {fixCleanup3_1_3, fixCleanup3_2_0, featureMPTokensV2}, all_)) + runAmendmentSensitive(features); + } +}; + +BEAST_DEFINE_TESTSUITE(LoanRounding, tx, xrpl); + +} // namespace xrpl::test diff --git a/src/test/app/lending/LoanSecurity_test.cpp b/src/test/app/lending/LoanSecurity_test.cpp new file mode 100644 index 00000000000..9aac0722ea7 --- /dev/null +++ b/src/test/app/lending/LoanSecurity_test.cpp @@ -0,0 +1,541 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include + +namespace xrpl::test { + +class LoanSecurity_test : public LoanTestBase +{ +private: + void + testPoCUnsignedUnderflowOnFullPayAfterEarlyPeriodic(FeatureBitset features) + { + // --- PoC Summary ---------------------------------------------------- + // Scenario: Borrower makes one periodic payment early (before next due) + // so doPayment sets sfPreviousPaymentDueDate to the (future) + // sfNextPaymentDueDate and advances sfNextPaymentDueDate by one + // interval. Borrower then immediately performs a full-payment + // (tfLoanFullPayment). Why it matters: Full-payment interest accrual + // uses + // delta = now - max(prevPaymentDate, startDate) + // with an unsigned clock representation (uint32). If prevPaymentDate is + // in the future, the subtraction underflows to a very large positive + // number. This inflates roundedFullInterest and total full-close due, + // and LoanPay applies the inflated valueChange to the vault + // (sfAssetsTotal), increasing NAV. + // -------------------------------------------------------------------- + testcase("PoC: Unsigned-underflow full-pay accrual after early periodic"); + + using namespace jtx; + using namespace loan; + using namespace std::chrono_literals; + + Env env{*this, features}; + + Account const lender{"poc_lender4"}; + Account const borrower{"poc_borrower4"}; + env.fund(XRP(3'000'000), lender, borrower); + env.close(); + + PrettyAsset const asset{xrpIssue(), 1'000'000}; + BrokerParameters const brokerParams{}; + auto const broker = createVaultAndBroker(env, asset, lender, brokerParams); + + // Create a 3-payment loan so full-payment path is enabled after 1 + // periodic payment. + auto const loanSetFee = Fee(env.current()->fees().base * 2); + Number const principalRequest = asset(1000).value(); + auto const originationFee = asset(0).value(); + auto const serviceFee = asset(1).value(); + auto const serviceFeePA = asset(1); + auto const lateFee = asset(0).value(); + auto const closeFee = asset(0).value(); + auto const interest = percentageToTenthBips(12); + auto const lateInterest = percentageToTenthBips(12) / 10; + auto const closeInterest = percentageToTenthBips(12) / 10; + auto const overpaymentInterest = percentageToTenthBips(12) / 10; + auto const total = 3u; + auto const interval = 600u; + auto const grace = 60u; + + auto createJtx = env.jt( + set(borrower, broker.brokerID, principalRequest, 0), + Sig(sfCounterpartySignature, lender), + kLoanOriginationFee(originationFee), + kLoanServiceFee(serviceFee), + kLatePaymentFee(lateFee), + kClosePaymentFee(closeFee), + kOverpaymentFee(percentageToTenthBips(5) / 10), + kInterestRate(interest), + kLateInterestRate(lateInterest), + kCloseInterestRate(closeInterest), + kOverpaymentInterestRate(overpaymentInterest), + kPaymentTotal(total), + kPaymentInterval(interval), + kGracePeriod(grace), + Fee(loanSetFee)); + + auto const brokerSle = env.le(keylet::loanBroker(broker.brokerID)); + BEAST_EXPECT(brokerSle); + auto const loanSequence = brokerSle ? brokerSle->at(sfLoanSequence) : 0; + auto const loanKeylet = keylet::loan(broker.brokerID, loanSequence); + + env(createJtx); + env.close(); + + // Compute a regular periodic due and pay it early (before next due). + auto state = getCurrentState(env, broker, loanKeylet); + Number const periodicRate = loanPeriodicRate(state.interestRate, state.paymentInterval); + auto const components = xrpl::detail::computePaymentComponents( + env.current()->rules(), + asset.raw(), + state.loanScale, + state.totalValue, + state.principalOutstanding, + state.managementFeeOutstanding, + state.periodicPayment, + periodicRate, + state.paymentRemaining, + brokerParams.managementFeeRate); + STAmount const regularDue{asset, components.trackedValueDelta + serviceFeePA.number()}; + // now < nextDue immediately after creation, so this is an early pay. + env(pay(borrower, loanKeylet.key, regularDue)); + env.close(); + + // Immediately attempt a full payoff. Compute the exact full-payment + // due to ensure the tx applies. + auto after = getCurrentState(env, broker, loanKeylet); + auto const loanSle = env.le(loanKeylet); + BEAST_EXPECT(loanSle); + auto const brokerSle2 = env.le(keylet::loanBroker(broker.brokerID)); + BEAST_EXPECT(brokerSle2); + + auto const closePaymentFee = loanSle ? loanSle->at(sfClosePaymentFee) : Number{}; + auto const closeInterestRate = + loanSle ? TenthBips32{loanSle->at(sfCloseInterestRate)} : TenthBips32{}; + auto const managementFeeRate = + brokerSle2 ? TenthBips16{brokerSle2->at(sfManagementFeeRate)} : TenthBips16{}; + + Number const periodicRate2 = loanPeriodicRate(after.interestRate, after.paymentInterval); + // Accrued + prepayment-penalty interest based on current periodic + // schedule + auto const fullPaymentInterest = computeFullPaymentInterest( + xrpl::detail::loanPrincipalFromPeriodicPayment( + env.current()->rules(), + after.periodicPayment, + periodicRate2, + after.paymentRemaining), + periodicRate2, + env.current()->parentCloseTime(), + after.paymentInterval, + after.previousPaymentDate, + static_cast(after.startDate.time_since_epoch().count()), + closeInterestRate); + + // Round to asset scale and split interest/fee parts + auto const roundedInterest = + roundToAsset(asset.raw(), fullPaymentInterest, after.loanScale); + Number const roundedFullMgmtFee = + computeManagementFee(asset.raw(), roundedInterest, managementFeeRate, after.loanScale); + Number const roundedFullInterest = roundedInterest - roundedFullMgmtFee; + + // Show both signed and unsigned deltas to highlight the underflow. + auto const nowSecs = + static_cast(env.current()->parentCloseTime().time_since_epoch().count()); + auto const startSecs = + static_cast(after.startDate.time_since_epoch().count()); + auto const lastPaymentDate = std::max(after.previousPaymentDate, startSecs); + auto const signedDelta = + static_cast(nowSecs) - static_cast(lastPaymentDate); + auto const unsignedDelta = static_cast(nowSecs - lastPaymentDate); + log << "PoC window: prev=" << after.previousPaymentDate << " start=" << startSecs + << " now=" << nowSecs << " signedDelta=" << signedDelta + << " unsignedDelta=" << unsignedDelta << std::endl; + + // Reference (clamped) computation: emulate a non-negative accrual + // window by clamping prevPaymentDate to 'now' for the full-pay path. + auto const prevClamped = std::min(after.previousPaymentDate, nowSecs); + auto const fullPaymentInterestClamped = computeFullPaymentInterest( + xrpl::detail::loanPrincipalFromPeriodicPayment( + env.current()->rules(), + after.periodicPayment, + periodicRate2, + after.paymentRemaining), + periodicRate2, + env.current()->parentCloseTime(), + after.paymentInterval, + prevClamped, + startSecs, + closeInterestRate); + auto const roundedInterestClamped = + roundToAsset(asset.raw(), fullPaymentInterestClamped, after.loanScale); + Number const roundedFullMgmtFeeClamped = computeManagementFee( + asset.raw(), roundedInterestClamped, managementFeeRate, after.loanScale); + Number const roundedFullInterestClamped = + roundedInterestClamped - roundedFullMgmtFeeClamped; + STAmount const fullDueClamped{ + asset, + after.principalOutstanding + roundedFullInterestClamped + roundedFullMgmtFeeClamped + + closePaymentFee}; + + // Collect vault NAV before closing payment + auto const vaultId2 = brokerSle2 ? brokerSle2->at(sfVaultID) : uint256{}; + auto const vaultKey2 = keylet::vault(vaultId2); + auto const vaultBefore = env.le(vaultKey2); + BEAST_EXPECT(vaultBefore); + Number const assetsTotalBefore = vaultBefore ? vaultBefore->at(sfAssetsTotal) : Number{}; + + STAmount const fullDue{ + asset, + after.principalOutstanding + roundedFullInterest + roundedFullMgmtFee + + closePaymentFee}; + + log << "PoC payoff: principalOutstanding=" << after.principalOutstanding + << " roundedFullInterest=" << roundedFullInterest + << " roundedFullMgmtFee=" << roundedFullMgmtFee << " closeFee=" << closePaymentFee + << " fullDue=" << to_string(fullDue.getJson()) << std::endl; + log << "PoC reference (clamped): roundedFullInterestClamped=" << roundedFullInterestClamped + << " roundedFullMgmtFeeClamped=" << roundedFullMgmtFeeClamped + << " fullDueClamped=" << to_string(fullDueClamped.getJson()) << std::endl; + + env(pay(borrower, loanKeylet.key, fullDue), Txflags(tfLoanFullPayment)); + env.close(); + + // Sanity: underflow present (unsigned delta very large relative to + // interval) + BEAST_EXPECT(unsignedDelta > after.paymentInterval); + + // Compare vault NAV before/after the full close + auto const vaultAfter = env.le(vaultKey2); + BEAST_EXPECT(vaultAfter); + if (vaultAfter) + { + auto const assetsTotalAfter = vaultAfter->at(sfAssetsTotal); + log << "PoC NAV: assetsTotalBefore=" << assetsTotalBefore + << " assetsTotalAfter=" << assetsTotalAfter + << " delta=" << (assetsTotalAfter - assetsTotalBefore) << std::endl; + + // Value-based proof: underflowed window yields a payoff larger than + // the clamped (non-underflow) reference. + BEAST_EXPECT(fullDue == fullDueClamped); + if (fullDue > fullDueClamped) + log << "PoC delta: overcharge (fullDue > clamped)" << std::endl; + } + + // Loan should be paid off + auto const finalLoan = env.le(loanKeylet); + BEAST_EXPECT(finalLoan); + if (finalLoan) + { + BEAST_EXPECT(finalLoan->at(sfPaymentRemaining) == 0); + BEAST_EXPECT(finalLoan->at(sfPrincipalOutstanding) == 0); + } + } + + void + testRIPD3831(FeatureBitset features) + { + using namespace jtx; + + testcase("RIPD-3831"); + + Account const issuer("issuer"); + Account const lender("lender"); + Account const borrower("borrower"); + + BrokerParameters const brokerParams{ + .vaultDeposit = 100000, + .debtMax = 0, + .coverRateMin = TenthBips32{0}, + // .managementFeeRate = TenthBips16{5919}, + .coverRateLiquidation = TenthBips32{0}}; + LoanParameters const loanParams{ + .account = lender, + .counter = borrower, + .principalRequest = Number{200'000, -6}, + .lateFee = Number{200, -6}, + .interest = TenthBips32{50'000}, + .payTotal = 10, + .payInterval = 150}; + + auto const assetType = AssetType::XRP; + + Env env{*this, features}; + + auto loanResult = + createLoan(env, assetType, brokerParams, loanParams, issuer, lender, borrower); + + if (BEAST_EXPECT(loanResult); !loanResult.has_value()) + return; + + auto broker = std::get(*loanResult); + auto loanKeylet = std::get(*loanResult); + + using tp = NetClock::time_point; + using d = NetClock::duration; + + auto state = getCurrentState(env, broker, loanKeylet); + if (auto loan = env.le(loanKeylet); BEAST_EXPECT(loan)) + { + env.close(tp{d{loan->at(sfNextPaymentDueDate) + loan->at(sfGracePeriod) + 1}}); + } + + topUpBorrower(env, broker, issuer, borrower, state, loanParams.serviceFee); + + using namespace jtx::loan; + + auto jv = pay(borrower, loanKeylet.key, drops(XRPAmount(state.totalValue))); + + { + auto const submitParam = to_string(jv); + auto const jr = env.rpc("submit", borrower.name(), submitParam); + + BEAST_EXPECT(jr.isMember(jss::result)); + auto const jResult = jr[jss::result]; + } + + env.close(); + + // Make sure the system keeps responding + env(noop(borrower)); + env.close(); + env(noop(issuer)); + env.close(); + env(noop(lender)); + env.close(); + } + + void + testRIPD3459(FeatureBitset features) + { + testcase("RIPD-3459 - LoanBroker incorrect debt total"); + + using namespace jtx; + + Account const issuer("issuer"); + Account const lender("lender"); + Account const borrower("borrower"); + + BrokerParameters const brokerParams{ + .vaultDeposit = 200'000, + .debtMax = 0, + .coverRateMin = TenthBips32{0}, + .managementFeeRate = TenthBips16{500}, + .coverRateLiquidation = TenthBips32{0}}; + LoanParameters const loanParams{ + .account = lender, + .counter = borrower, + .principalRequest = Number{100'000, -4}, + .interest = TenthBips32{100'000}, + .payTotal = 10}; + + auto const assetType = AssetType::MPT; + + Env env{*this, features}; + + auto loanResult = + createLoan(env, assetType, brokerParams, loanParams, issuer, lender, borrower); + + if (BEAST_EXPECT(loanResult); !loanResult.has_value()) + return; + + auto broker = std::get(*loanResult); + auto loanKeylet = std::get(*loanResult); + auto pseudoAcct = std::get(*loanResult); + + VerifyLoanStatus const verifyLoanStatus(env, broker, pseudoAcct, loanKeylet); + + if (auto const brokerSle = env.le(broker.brokerKeylet()); BEAST_EXPECT(brokerSle)) + { + if (auto const loanSle = env.le(loanKeylet); BEAST_EXPECT(loanSle)) + { + BEAST_EXPECT(brokerSle->at(sfDebtTotal) == loanSle->at(sfTotalValueOutstanding)); + } + } + + makeLoanPayments( + env, + broker, + loanParams, + loanKeylet, + verifyLoanStatus, + issuer, + lender, + borrower, + PaymentParameters{.showStepBalances = true}); + + if (auto const brokerSle = env.le(broker.brokerKeylet()); BEAST_EXPECT(brokerSle)) + { + if (auto const loanSle = env.le(loanKeylet); BEAST_EXPECT(loanSle)) + { + BEAST_EXPECT(brokerSle->at(sfDebtTotal) == loanSle->at(sfTotalValueOutstanding)); + BEAST_EXPECT(brokerSle->at(sfDebtTotal) == beast::kZero); + } + } + } + + void + testRIPD3901() + { + testcase("Crash with tfLoanOverpayment"); + using namespace jtx; + using namespace loan; + Account const lender{"lender"}; + Account const issuer{"issuer"}; + Account const borrower{"borrower"}; + Account const depositor{"depositor"}; + auto const txFee = Fee(XRP(100)); + + Env env(*this); + Vault const vault(env); + + env.fund(XRP(10'000), lender, issuer, borrower, depositor); + env.close(); + + auto [tx, vaultKeyLet] = vault.create({.owner = lender, .asset = xrpIssue()}); + env(tx, txFee); + env.close(); + + env(vault.deposit({.depositor = depositor, .id = vaultKeyLet.key, .amount = XRP(1'000)}), + txFee); + env.close(); + + auto const brokerKeyLet = keylet::loanBroker(lender.id(), env.seq(lender)); + + env(loanBroker::set(lender, vaultKeyLet.key), txFee); + env.close(); + + // BrokerInfo brokerInfo{xrpIssue(), keylet, vaultKeyLet, {}}; + + STAmount const debtMaximumRequest = XRPAmount(200'000); + + env(set(borrower, brokerKeyLet.key, debtMaximumRequest), + Sig(sfCounterpartySignature, lender), + kInterestRate(TenthBips32(50'000)), + kPaymentTotal(2), + kPaymentInterval(150), + Txflags(tfLoanOverpayment), + txFee); + env.close(); + + std::uint32_t const loanSequence = 1; + auto const loanKeylet = keylet::loan(brokerKeyLet.key, loanSequence); + + if (auto loan = env.le(loanKeylet); env.test.BEAST_EXPECT(loan)) + { + env(loan::pay(borrower, loanKeylet.key, XRPAmount(150'001)), + Txflags(tfLoanOverpayment), + txFee); + env.close(); + } + } + + void + testRIPD3902(FeatureBitset features) + { + testcase("RIPD-3902 - 1 IOU loan payments"); + + using namespace jtx; + + Account const issuer("issuer"); + Account const lender("lender"); + Account const borrower("borrower"); + + BrokerParameters const brokerParams{ + .vaultDeposit = 10, + .debtMax = 0, + .coverRateMin = TenthBips32{0}, + .managementFeeRate = TenthBips16{0}, + .coverRateLiquidation = TenthBips32{0}}; + LoanParameters const loanParams{ + .account = lender, + .counter = borrower, + .principalRequest = Number{1, 0}, + .interest = TenthBips32{100'000}, + .payTotal = 5, + .payInterval = 150, + .gracePd = 60}; + + auto const assetType = AssetType::IOU; + + Env env{*this, features}; + + auto loanResult = + createLoan(env, assetType, brokerParams, loanParams, issuer, lender, borrower); + + if (BEAST_EXPECT(loanResult); !loanResult.has_value()) + return; + + auto broker = std::get(*loanResult); + auto loanKeylet = std::get(*loanResult); + auto pseudoAcct = std::get(*loanResult); + + VerifyLoanStatus const verifyLoanStatus(env, broker, pseudoAcct, loanKeylet); + + makeLoanPayments( + env, + broker, + loanParams, + loanKeylet, + verifyLoanStatus, + issuer, + lender, + borrower, + PaymentParameters{.showStepBalances = true}); + } + + void + runAmendmentIndependent() + { + testRIPD3901(); + } + + // Tests run under each entry in amendmentCombinations(). + void + runAmendmentSensitive(FeatureBitset features) + { + testPoCUnsignedUnderflowOnFullPayAfterEarlyPeriodic(features); + testRIPD3831(features); + testRIPD3459(features); + testRIPD3902(features); + } + +public: + void + run() override + { + runAmendmentIndependent(); + for (auto const& features : jtx::amendmentCombinations( + {fixCleanup3_1_3, fixCleanup3_2_0, featureMPTokensV2}, all_)) + runAmendmentSensitive(features); + } +}; + +BEAST_DEFINE_TESTSUITE(LoanSecurity, tx, xrpl); + +} // namespace xrpl::test diff --git a/src/test/app/lending/LoanSet_test.cpp b/src/test/app/lending/LoanSet_test.cpp new file mode 100644 index 00000000000..85528ee9a03 --- /dev/null +++ b/src/test/app/lending/LoanSet_test.cpp @@ -0,0 +1,607 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include + +namespace xrpl::test { + +class LoanSet_test : public LoanTestBase +{ +private: + void + testLoanSet(FeatureBitset features) + { + using namespace jtx; + + Account const issuer{"issuer"}; + Account const lender{"lender"}; + Account const borrower{"borrower"}; + + struct CaseArgs + { + bool requireAuth = false; + bool authorizeBorrower = false; + int initialXRP = 1'000'000; + }; + + auto const testCase = [&, this]( + std::function mptTest, + std::function iouTest, + CaseArgs args = {}) { + Env env(*this, features); + env.fund(XRP(args.initialXRP), issuer, lender, borrower); + env.close(); + if (args.requireAuth) + { + env(fset(issuer, asfRequireAuth)); + env.close(); + } + + // We need two different asset types, MPT and IOU. Prepare MPT + // first + MPTTester mptt{env, issuer, kMptInitNoFund}; + + auto const kNone = LedgerSpecificFlags(0); + mptt.create( + {.flags = tfMPTCanTransfer | tfMPTCanLock | + (args.requireAuth ? tfMPTRequireAuth : kNone)}); + env.close(); + PrettyAsset const mptAsset = mptt.issuanceID(); + mptt.authorize({.account = lender}); + mptt.authorize({.account = borrower}); + env.close(); + if (args.requireAuth) + { + mptt.authorize({.account = issuer, .holder = lender}); + if (args.authorizeBorrower) + mptt.authorize({.account = issuer, .holder = borrower}); + env.close(); + } + + env(pay(issuer, lender, mptAsset(10'000'000))); + env.close(); + + // Prepare IOU + PrettyAsset const iouAsset = issuer[iouCurrency_]; + env(trust(lender, iouAsset(10'000'000))); + env(trust(borrower, iouAsset(10'000'000))); + env.close(); + if (args.requireAuth) + { + env(trust(issuer, iouAsset(0), lender, tfSetfAuth)); + env(pay(issuer, lender, iouAsset(10'000'000))); + if (args.authorizeBorrower) + { + env(trust(issuer, iouAsset(0), borrower, tfSetfAuth)); + env(pay(issuer, borrower, iouAsset(10'000))); + } + } + else + { + env(pay(issuer, lender, iouAsset(10'000'000))); + env(pay(issuer, borrower, iouAsset(10'000))); + } + env.close(); + + // Create vaults and loan brokers + std::array const assets{mptAsset, iouAsset}; + std::vector brokers; + brokers.reserve(assets.size()); + for (auto const& asset : assets) + { + brokers.emplace_back(createVaultAndBroker(env, asset, lender)); + } + + if (mptTest) + mptTest(env, brokers[0], mptt); + if (iouTest) + iouTest(env, brokers[1]); + }; + + testCase( + [&, this](Env& env, BrokerInfo const& broker, auto&) { + using namespace loan; + Number const principalRequest = broker.asset(1'000).value(); + + testcase("MPT issuer is borrower, issuer submits"); + env(set(issuer, broker.brokerID, principalRequest), + kCounterparty(lender), + Sig(sfCounterpartySignature, lender), + Fee(env.current()->fees().base * 5)); + + testcase("MPT issuer is borrower, lender submits"); + env(set(lender, broker.brokerID, principalRequest), + kCounterparty(issuer), + Sig(sfCounterpartySignature, issuer), + Fee(env.current()->fees().base * 5)); + }, + [&, this](Env& env, BrokerInfo const& broker) { + using namespace loan; + Number const principalRequest = broker.asset(1'000).value(); + + testcase("IOU issuer is borrower, issuer submits"); + env(set(issuer, broker.brokerID, principalRequest), + kCounterparty(lender), + Sig(sfCounterpartySignature, lender), + Fee(env.current()->fees().base * 5)); + + testcase("IOU issuer is borrower, lender submits"); + env(set(lender, broker.brokerID, principalRequest), + kCounterparty(issuer), + Sig(sfCounterpartySignature, issuer), + Fee(env.current()->fees().base * 5)); + }, + CaseArgs{.requireAuth = true}); + + testCase( + [&, this](Env& env, BrokerInfo const& broker, auto&) { + using namespace loan; + Number const principalRequest = broker.asset(1'000).value(); + + testcase("MPT unauthorized borrower, borrower submits"); + env(set(borrower, broker.brokerID, principalRequest), + kCounterparty(lender), + Sig(sfCounterpartySignature, lender), + Fee(env.current()->fees().base * 5), + Ter{tecNO_AUTH}); + + testcase("MPT unauthorized borrower, lender submits"); + env(set(lender, broker.brokerID, principalRequest), + kCounterparty(borrower), + Sig(sfCounterpartySignature, borrower), + Fee(env.current()->fees().base * 5), + Ter{tecNO_AUTH}); + }, + [&, this](Env& env, BrokerInfo const& broker) { + using namespace loan; + Number const principalRequest = broker.asset(1'000).value(); + + testcase("IOU unauthorized borrower, borrower submits"); + env(set(borrower, broker.brokerID, principalRequest), + kCounterparty(lender), + Sig(sfCounterpartySignature, lender), + Fee(env.current()->fees().base * 5), + Ter{tecNO_AUTH}); + + testcase("IOU unauthorized borrower, lender submits"); + env(set(lender, broker.brokerID, principalRequest), + kCounterparty(borrower), + Sig(sfCounterpartySignature, borrower), + Fee(env.current()->fees().base * 5), + Ter{tecNO_AUTH}); + }, + CaseArgs{.requireAuth = true}); + + auto const [acctReserve, incReserve] = [this]() -> std::pair { + Env const env{*this, testableAmendments()}; + return { + env.current()->fees().accountReserve(0, 1).drops() / kDropsPerXrp.drops(), + env.current()->fees().increment.drops() / kDropsPerXrp.drops()}; + }(); + + testCase( + [&, this](Env& env, BrokerInfo const& broker, MPTTester& mptt) { + using namespace loan; + Number const principalRequest = broker.asset(1'000).value(); + + testcase( + "MPT authorized borrower, borrower submits, borrower has " + "no reserve"); + mptt.authorize({.account = borrower, .flags = tfMPTUnauthorize}); + env.close(); + + auto const mptoken = keylet::mptoken(mptt.issuanceID(), borrower); + auto const sleMPT1 = env.le(mptoken); + BEAST_EXPECT(sleMPT1 == nullptr); + + // Burn some XRP + env(noop(borrower), Fee(XRP((acctReserve * 2) + (incReserve * 2)))); + env.close(); + + // Cannot create loan, not enough reserve to create MPToken + env(set(borrower, broker.brokerID, principalRequest), + kCounterparty(lender), + Sig(sfCounterpartySignature, lender), + Fee(env.current()->fees().base * 5), + Ter{tecINSUFFICIENT_RESERVE}); + env.close(); + + // Can create loan now, will implicitly create MPToken + env(pay(issuer, borrower, XRP(incReserve))); + env.close(); + env(set(borrower, broker.brokerID, principalRequest), + kCounterparty(lender), + Sig(sfCounterpartySignature, lender), + Fee(env.current()->fees().base * 5)); + env.close(); + + auto const sleMPT2 = env.le(mptoken); + BEAST_EXPECT(sleMPT2 != nullptr); + }, + {}, + CaseArgs{.initialXRP = (acctReserve * 2) + (incReserve * 8) + 1}); + + testCase( + {}, + [&, this](Env& env, BrokerInfo const& broker) { + using namespace loan; + Number const principalRequest = broker.asset(1'000).value(); + + testcase( + "IOU authorized borrower, borrower submits, borrower has " + "no reserve"); + // Remove trust line from borrower to issuer + env.trust(broker.asset(0), borrower); + env.close(); + + env(pay(borrower, issuer, broker.asset(10'000))); + env.close(); + auto const trustline = keylet::trustLine(borrower, broker.asset.raw().get()); + auto const sleLine1 = env.le(trustline); + BEAST_EXPECT(sleLine1 == nullptr); + + // Burn some XRP + env(noop(borrower), Fee(XRP((acctReserve * 2) + (incReserve * 2)))); + env.close(); + + // Cannot create loan, not enough reserve to create trust line + env(set(borrower, broker.brokerID, principalRequest), + kCounterparty(lender), + Sig(sfCounterpartySignature, lender), + Fee(env.current()->fees().base * 5), + Ter{tecNO_LINE_INSUF_RESERVE}); + env.close(); + + // Can create loan now, will implicitly create trust line + env(pay(issuer, borrower, XRP(incReserve))); + env.close(); + env(set(borrower, broker.brokerID, principalRequest), + kCounterparty(lender), + Sig(sfCounterpartySignature, lender), + Fee(env.current()->fees().base * 5)); + env.close(); + + auto const sleLine2 = env.le(trustline); + BEAST_EXPECT(sleLine2 != nullptr); + }, + CaseArgs{.initialXRP = (acctReserve * 2) + (incReserve * 8) + 1}); + + testCase( + [&, this](Env& env, BrokerInfo const& broker, MPTTester& mptt) { + using namespace loan; + Number const principalRequest = broker.asset(1'000).value(); + + testcase( + "MPT authorized borrower, borrower submits, lender has " + "no reserve"); + auto const mptoken = keylet::mptoken(mptt.issuanceID(), lender); + auto const sleMPT1 = env.le(mptoken); + BEAST_EXPECT(sleMPT1 != nullptr); + + env(pay(lender, issuer, broker.asset(sleMPT1->at(sfMPTAmount)))); + env.close(); + + mptt.authorize({.account = lender, .flags = tfMPTUnauthorize}); + env.close(); + + auto const sleMPT2 = env.le(mptoken); + BEAST_EXPECT(sleMPT2 == nullptr); + + // Burn some XRP + env(noop(lender), Fee(XRP(incReserve))); + env.close(); + + // Cannot create loan, not enough reserve to create MPToken + env(set(borrower, broker.brokerID, principalRequest), + kLoanOriginationFee(broker.asset(1).value()), + kCounterparty(lender), + Sig(sfCounterpartySignature, lender), + Fee(env.current()->fees().base * 5), + Ter{tecINSUFFICIENT_RESERVE}); + env.close(); + + // Can create loan now, will implicitly create MPToken + env(pay(issuer, lender, XRP(incReserve))); + env.close(); + env(set(borrower, broker.brokerID, principalRequest), + kLoanOriginationFee(broker.asset(1).value()), + kCounterparty(lender), + Sig(sfCounterpartySignature, lender), + Fee(env.current()->fees().base * 5)); + env.close(); + + auto const sleMPT3 = env.le(mptoken); + BEAST_EXPECT(sleMPT3 != nullptr); + }, + {}, + CaseArgs{.initialXRP = (acctReserve * 2) + (incReserve * 8) + 1}); + + testCase( + {}, + [&, this](Env& env, BrokerInfo const& broker) { + using namespace loan; + Number const principalRequest = broker.asset(1'000).value(); + + testcase( + "IOU authorized borrower, borrower submits, lender has no " + "reserve"); + // Remove trust line from lender to issuer + env.trust(broker.asset(0), lender); + env.close(); + + auto const trustline = keylet::trustLine(lender, broker.asset.raw().get()); + auto const sleLine1 = env.le(trustline); + BEAST_EXPECT(sleLine1 != nullptr); + + env(pay(lender, issuer, broker.asset(abs(sleLine1->at(sfBalance).value())))); + env.close(); + auto const sleLine2 = env.le(trustline); + BEAST_EXPECT(sleLine2 == nullptr); + + // Burn some XRP + env(noop(lender), Fee(XRP(incReserve))); + env.close(); + + // Cannot create loan, not enough reserve to create trust line + env(set(borrower, broker.brokerID, principalRequest), + kLoanOriginationFee(broker.asset(1).value()), + kCounterparty(lender), + Sig(sfCounterpartySignature, lender), + Fee(env.current()->fees().base * 5), + Ter{tecNO_LINE_INSUF_RESERVE}); + env.close(); + + // Can create loan now, will implicitly create trust line + env(pay(issuer, lender, XRP(incReserve))); + env.close(); + env(set(borrower, broker.brokerID, principalRequest), + kLoanOriginationFee(broker.asset(1).value()), + kCounterparty(lender), + Sig(sfCounterpartySignature, lender), + Fee(env.current()->fees().base * 5)); + env.close(); + + auto const sleLine3 = env.le(trustline); + BEAST_EXPECT(sleLine3 != nullptr); + }, + CaseArgs{.initialXRP = (acctReserve * 2) + (incReserve * 8) + 1}); + + testCase( + [&, this](Env& env, BrokerInfo const& broker, MPTTester& mptt) { + using namespace loan; + Number const principalRequest = broker.asset(1'000).value(); + + testcase("MPT authorized borrower, unauthorized lender"); + auto const mptoken = keylet::mptoken(mptt.issuanceID(), lender); + auto const sleMPT1 = env.le(mptoken); + BEAST_EXPECT(sleMPT1 != nullptr); + + env(pay(lender, issuer, broker.asset(sleMPT1->at(sfMPTAmount)))); + env.close(); + + mptt.authorize({.account = lender, .flags = tfMPTUnauthorize}); + env.close(); + + auto const sleMPT2 = env.le(mptoken); + BEAST_EXPECT(sleMPT2 == nullptr); + + // Cannot create loan, lender not authorized to receive fee + env(set(borrower, broker.brokerID, principalRequest), + kLoanOriginationFee(broker.asset(1).value()), + kCounterparty(lender), + Sig(sfCounterpartySignature, lender), + Fee(env.current()->fees().base * 5), + Ter{tecNO_AUTH}); + env.close(); + + // Cannot create loan, even without an origination fee + env(set(borrower, broker.brokerID, principalRequest), + kCounterparty(lender), + Sig(sfCounterpartySignature, lender), + Fee(env.current()->fees().base * 5), + Ter{tecNO_AUTH}); + env.close(); + + // No MPToken for lender - no authorization and no payment + auto const sleMPT3 = env.le(mptoken); + BEAST_EXPECT(sleMPT3 == nullptr); + }, + {}, + CaseArgs{.requireAuth = true, .authorizeBorrower = true}); + + testCase( + [&, this](Env& env, BrokerInfo const& broker, auto&) { + using namespace loan; + Number const principalRequest = broker.asset(1'000).value(); + + testcase("MPT authorized borrower, borrower submits"); + env(set(borrower, broker.brokerID, principalRequest), + kCounterparty(lender), + Sig(sfCounterpartySignature, lender), + Fee(env.current()->fees().base * 5)); + }, + [&, this](Env& env, BrokerInfo const& broker) { + using namespace loan; + Number const principalRequest = broker.asset(1'000).value(); + + testcase("IOU authorized borrower, borrower submits"); + env(set(borrower, broker.brokerID, principalRequest), + kCounterparty(lender), + Sig(sfCounterpartySignature, lender), + Fee(env.current()->fees().base * 5)); + }, + CaseArgs{.requireAuth = true, .authorizeBorrower = true}); + + testCase( + [&, this](Env& env, BrokerInfo const& broker, auto&) { + using namespace loan; + Number const principalRequest = broker.asset(1'000).value(); + + testcase("MPT authorized borrower, lender submits"); + env(set(lender, broker.brokerID, principalRequest), + kCounterparty(borrower), + Sig(sfCounterpartySignature, borrower), + Fee(env.current()->fees().base * 5)); + }, + [&, this](Env& env, BrokerInfo const& broker) { + using namespace loan; + Number const principalRequest = broker.asset(1'000).value(); + + testcase("IOU authorized borrower, lender submits"); + env(set(lender, broker.brokerID, principalRequest), + kCounterparty(borrower), + Sig(sfCounterpartySignature, borrower), + Fee(env.current()->fees().base * 5)); + }, + CaseArgs{.requireAuth = true, .authorizeBorrower = true}); + + jtx::Account const alice{"alice"}; + jtx::Account const bella{"bella"}; + auto const msigSetup = [&](Env& env, Account const& account) { + json::Value const tx1 = signers(account, 2, {{alice, 1}, {bella, 1}}); + env(tx1); + env.close(); + }; + + testCase( + [&, this](Env& env, BrokerInfo const& broker, auto&) { + using namespace loan; + msigSetup(env, lender); + Number const principalRequest = broker.asset(1'000).value(); + + testcase( + "MPT authorized borrower, borrower submits, lender " + "multisign"); + env(set(borrower, broker.brokerID, principalRequest), + kCounterparty(lender), + Msig(sfCounterpartySignature, alice, bella), + Fee(env.current()->fees().base * 5)); + }, + [&, this](Env& env, BrokerInfo const& broker) { + using namespace loan; + msigSetup(env, lender); + Number const principalRequest = broker.asset(1'000).value(); + + testcase( + "IOU authorized borrower, borrower submits, lender " + "multisign"); + env(set(borrower, broker.brokerID, principalRequest), + kCounterparty(lender), + Msig(sfCounterpartySignature, alice, bella), + Fee(env.current()->fees().base * 5)); + }, + CaseArgs{.requireAuth = true, .authorizeBorrower = true}); + + testCase( + [&, this](Env& env, BrokerInfo const& broker, auto&) { + using namespace loan; + msigSetup(env, borrower); + Number const principalRequest = broker.asset(1'000).value(); + + testcase( + "MPT authorized borrower, lender submits, borrower " + "multisign"); + env(set(lender, broker.brokerID, principalRequest), + kCounterparty(borrower), + Msig(sfCounterpartySignature, alice, bella), + Fee(env.current()->fees().base * 5)); + }, + [&, this](Env& env, BrokerInfo const& broker) { + using namespace loan; + msigSetup(env, borrower); + Number const principalRequest = broker.asset(1'000).value(); + + testcase( + "IOU authorized borrower, lender submits, borrower " + "multisign"); + env(set(lender, broker.brokerID, principalRequest), + kCounterparty(borrower), + Msig(sfCounterpartySignature, alice, bella), + Fee(env.current()->fees().base * 5)); + }, + CaseArgs{.requireAuth = true, .authorizeBorrower = true}); + + testCase( + [&, this](Env& env, BrokerInfo const& broker, auto&) { + using namespace loan; + Number const principalRequest = broker.asset(1'000).value(); + Vault const vault{env}; + auto tx = vault.set({.owner = lender, .id = broker.vaultID}); + tx[sfAssetsMaximum] = BrokerParameters::defaults().vaultDeposit; + env(tx); + env.close(); + + testcase("Vault at maximum value"); + env(set(issuer, broker.brokerID, principalRequest), + kCounterparty(lender), + kInterestRate(TenthBips32(10'000)), + Sig(sfCounterpartySignature, lender), + Fee(env.current()->fees().base * 5), + Ter(tecLIMIT_EXCEEDED)); + }, + nullptr); + + testCase( + [&, this](Env& env, BrokerInfo const& broker, auto&) { + using namespace loan; + Number const principalRequest = broker.asset(1'000).value(); + Vault const vault{env}; + auto tx = vault.set({.owner = lender, .id = broker.vaultID}); + tx[sfAssetsMaximum] = + BrokerParameters::defaults().vaultDeposit + broker.asset(1).number(); + env(tx); + env.close(); + + testcase("Vault maximum value exceeded"); + env(set(issuer, broker.brokerID, principalRequest), + kCounterparty(lender), + kInterestRate(TenthBips32(100'000)), + Sig(sfCounterpartySignature, lender), + Fee(env.current()->fees().base * 5), + kPaymentTotal(2), + kPaymentInterval(3600 * 24), + Ter(tecLIMIT_EXCEEDED)); + }, + nullptr); + } + +public: + void + run() override + { + for (auto const& features : jtx::amendmentCombinations( + {fixCleanup3_1_3, fixCleanup3_2_0, featureMPTokensV2}, all_)) + testLoanSet(features); + } +}; + +BEAST_DEFINE_TESTSUITE(LoanSet, tx, xrpl); + +} // namespace xrpl::test diff --git a/src/test/app/lending/LoanTestBase.h b/src/test/app/lending/LoanTestBase.h new file mode 100644 index 00000000000..e38e93d3e57 --- /dev/null +++ b/src/test/app/lending/LoanTestBase.h @@ -0,0 +1,2947 @@ +#pragma once + +#include +// +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace xrpl::test { + +class LoanTestBase : public beast::unit_test::Suite +{ +protected: + // Ensure that all the features needed for Lending Protocol are included, + // even if they are set to unsupported. + // + // featureLendingProtocolV1_1 is excluded from the default set: it changes + // Vault/LoanBroker accounting (AssetsTotal/DebtTotal/LossUnrealized), and + // most of this file's tests assert whole-life-specific expected values + // for those fields. Tests that specifically exercise the amendment opt + // it back in explicitly (e.g. `all_ | featureLendingProtocolV1_1`). + FeatureBitset const all_{jtx::testableAmendments() - featureLendingProtocolV1_1}; + std::string const iouCurrency_{"IOU"}; + + struct BrokerParameters + { + Number vaultDeposit = 1'000'000; + Number debtMax = 25'000; + TenthBips32 coverRateMin = percentageToTenthBips(10); + int coverDeposit = 1000; + TenthBips16 managementFeeRate{100}; + TenthBips32 coverRateLiquidation = percentageToTenthBips(25); + std::string data = {}; // NOLINT(readability-redundant-member-init) + std::uint32_t flags = 0; + // If set, the vault is created with this sfScale value. Useful for + // tests that need finer loanScale to exercise rounding edge cases. + std::optional vaultScale = + std::nullopt; // NOLINT(readability-redundant-member-init) + + [[nodiscard]] Number + maxCoveredLoanValue(Number const& currentDebt) const + { + NumberRoundModeGuard const mg(Number::RoundingMode::Downward); + auto debtLimit = coverDeposit * kTenthBipsPerUnity.value() / coverRateMin.value(); + + return debtLimit - currentDebt; + } + + static BrokerParameters const& + defaults() + { + static BrokerParameters const kResult{}; + return kResult; + } + + // TODO: create an operator() which returns a transaction similar to + // LoanParameters + }; + + struct BrokerInfo + { + jtx::PrettyAsset asset; + uint256 brokerID; + uint256 vaultID; + BrokerParameters params; + BrokerInfo( + jtx::PrettyAsset const& asset, + Keylet const& brokerKeylet, + Keylet const& vaultKeylet, + BrokerParameters p) + : asset(asset) + , brokerID(brokerKeylet.key) + , vaultID(vaultKeylet.key) + , params(std::move(p)) + { + } + + [[nodiscard]] Keylet + brokerKeylet() const + { + return keylet::loanBroker(brokerID); + } + [[nodiscard]] Keylet + vaultKeylet() const + { + return keylet::vault(vaultID); + } + + [[nodiscard]] int + vaultScale(jtx::Env const& env) const + { + using namespace jtx; + + auto const vaultSle = env.le(keylet::vault(vaultID)); + return getAssetsTotalScale(vaultSle); + } + }; + + struct LoanParameters + { + // The account submitting the transaction. May be borrower or broker. + jtx::Account account; + // The counterparty. Should be the other of borrower or broker. + jtx::Account counter; + // Whether the counterparty is specified in the `counterparty` field, or + // only signs. + bool counterpartyExplicit = true; + Number principalRequest; + // NOLINTBEGIN(readability-redundant-member-init) + std::optional setFee = std::nullopt; + std::optional originationFee = std::nullopt; + std::optional serviceFee = std::nullopt; + std::optional lateFee = std::nullopt; + std::optional closeFee = std::nullopt; + std::optional overFee = std::nullopt; + std::optional interest = std::nullopt; + std::optional lateInterest = std::nullopt; + std::optional closeInterest = std::nullopt; + std::optional overpaymentInterest = std::nullopt; + std::optional payTotal = std::nullopt; + std::optional payInterval = std::nullopt; + std::optional gracePd = std::nullopt; + std::optional flags = std::nullopt; + // NOLINTEND(readability-redundant-member-init) + + template + jtx::JTx + operator()(jtx::Env& env, BrokerInfo const& broker, FN const&... fN) const + { + using namespace jtx; + using namespace jtx::loan; + + JTx jt{loan::set( + account, + broker.brokerID, + broker.asset(principalRequest).number(), + flags.value_or(0))}; + + Sig(sfCounterpartySignature, counter)(env, jt); + + Fee{setFee.value_or(env.current()->fees().base * 2)}(env, jt); + + if (counterpartyExplicit) + kCounterparty(counter)(env, jt); + if (originationFee) + kLoanOriginationFee(broker.asset(*originationFee).number())(env, jt); + if (serviceFee) + kLoanServiceFee(broker.asset(*serviceFee).number())(env, jt); + if (lateFee) + kLatePaymentFee(broker.asset(*lateFee).number())(env, jt); + if (closeFee) + kClosePaymentFee(broker.asset(*closeFee).number())(env, jt); + if (overFee) + kOverpaymentFee (*overFee)(env, jt); + if (interest) + kInterestRate (*interest)(env, jt); + if (lateInterest) + kLateInterestRate (*lateInterest)(env, jt); + if (closeInterest) + kCloseInterestRate (*closeInterest)(env, jt); + if (overpaymentInterest) + kOverpaymentInterestRate (*overpaymentInterest)(env, jt); + if (payTotal) + kPaymentTotal (*payTotal)(env, jt); + if (payInterval) + kPaymentInterval (*payInterval)(env, jt); + if (gracePd) + kGracePeriod (*gracePd)(env, jt); + + return env.jt(jt, fN...); + } + }; + + struct PaymentParameters + { + Number overpaymentFactor = Number{1}; + std::optional overpaymentExtra = std::nullopt; + std::uint32_t flags = 0; + bool showStepBalances = false; + bool validateBalances = true; + + static PaymentParameters const& + defaults() + { + static PaymentParameters const kResult{}; + return kResult; + } + }; + + struct LoanState + { + std::uint32_t previousPaymentDate = 0; + NetClock::time_point startDate; + std::uint32_t nextPaymentDate = 0; + std::uint32_t paymentRemaining = 0; + std::int32_t const loanScale = 0; + Number totalValue = 0; + Number principalOutstanding = 0; + Number managementFeeOutstanding = 0; + Number periodicPayment = 0; + std::uint32_t flags = 0; + std::uint32_t const paymentInterval = 0; + TenthBips32 const interestRate{}; + }; + + /** + * Helper class to compare the expected state of a loan and loan broker + * against the data in the ledger. + */ + struct VerifyLoanStatus + { + public: + jtx::Env const& env; + BrokerInfo const& broker; + jtx::Account const& pseudoAccount; + Keylet const& loanKeylet; + + VerifyLoanStatus( + jtx::Env const& env, + BrokerInfo const& broker, + jtx::Account const& pseudo, + Keylet const& keylet) + : env(env), broker(broker), pseudoAccount(pseudo), loanKeylet(keylet) + { + } + + /** + * Checks the expected broker state against the ledger + */ + void + checkBroker( + Number const& principalOutstanding, + Number const& interestOwed, + TenthBips32 interestRate, + std::uint32_t paymentInterval, + std::uint32_t paymentsRemaining, + std::uint32_t ownerCount) const + { + using namespace jtx; + if (auto brokerSle = env.le(keylet::loanBroker(broker.brokerID)); + env.test.BEAST_EXPECT(brokerSle)) + { + TenthBips16 const managementFeeRate{brokerSle->at(sfManagementFeeRate)}; + auto const brokerDebt = brokerSle->at(sfDebtTotal); + + if (auto vaultSle = env.le(keylet::vault(brokerSle->at(sfVaultID))); + env.test.BEAST_EXPECT(vaultSle)) + { + auto const expectedDebt = + env.current()->rules().enabled(featureLendingProtocolV1_1) && + getVaultVersion(vaultSle) == VaultVersion::CashBasis + ? principalOutstanding + : principalOutstanding + interestOwed; + env.test.BEAST_EXPECT(brokerDebt == expectedDebt); + env.test.BEAST_EXPECT( + env.balance(pseudoAccount, broker.asset).number() == + brokerSle->at(sfCoverAvailable)); + env.test.BEAST_EXPECT(brokerSle->at(sfOwnerCount) == ownerCount); + + Account const vaultPseudo{"vaultPseudoAccount", vaultSle->at(sfAccount)}; + env.test.BEAST_EXPECT( + vaultSle->at(sfAssetsAvailable) == + env.balance(vaultPseudo, broker.asset).number()); + if (ownerCount == 0) + { + // The Vault must be perfectly balanced if there + // are no loans outstanding + auto const total = vaultSle->at(sfAssetsTotal); + auto const available = vaultSle->at(sfAssetsAvailable); + env.test.BEAST_EXPECT(total == available); + env.test.BEAST_EXPECT(vaultSle->at(sfLossUnrealized) == 0); + } + } + } + } + + void + checkPayment( + std::int32_t loanScale, + jtx::Account const& account, + jtx::PrettyAmount const& balanceBefore, + STAmount const& expectedPayment, + jtx::PrettyAmount const& adjustment) const + { + auto const borrowerScale = std::max(loanScale, balanceBefore.number().exponent()); + + STAmount const balanceChangeAmount{ + broker.asset, + roundToAsset(broker.asset, expectedPayment + adjustment, borrowerScale)}; + { + auto const difference = roundToScale( + env.balance(account, broker.asset) - (balanceBefore - balanceChangeAmount), + borrowerScale); + env.test.expect( + roundToScale(difference, loanScale) >= beast::kZero, + "Balance before: " + to_string(balanceBefore.value()) + + ", expected change: " + to_string(balanceChangeAmount) + + ", difference (balance after - expected): " + to_string(difference), + __FILE__, + __LINE__); + } + } + + /** + * Checks both the loan and broker expect states against the ledger + */ + void + operator()( + std::uint32_t previousPaymentDate, + std::uint32_t nextPaymentDate, + std::uint32_t paymentRemaining, + Number const& loanScale, + Number const& totalValue, + Number const& principalOutstanding, + Number const& managementFeeOutstanding, + Number const& periodicPayment, + std::uint32_t flags) const + { + using namespace jtx; + if (auto loan = env.le(loanKeylet); env.test.BEAST_EXPECT(loan)) + { + env.test.BEAST_EXPECT(loan->at(sfPreviousPaymentDueDate) == previousPaymentDate); + env.test.BEAST_EXPECT(loan->at(sfPaymentRemaining) == paymentRemaining); + env.test.BEAST_EXPECT(loan->at(sfNextPaymentDueDate) == nextPaymentDate); + env.test.BEAST_EXPECT(loan->at(sfLoanScale) == loanScale); + env.test.BEAST_EXPECT(loan->at(sfTotalValueOutstanding) == totalValue); + env.test.BEAST_EXPECT(loan->at(sfPrincipalOutstanding) == principalOutstanding); + env.test.BEAST_EXPECT( + loan->at(sfManagementFeeOutstanding) == managementFeeOutstanding); + env.test.BEAST_EXPECT(loan->at(sfPeriodicPayment) == periodicPayment); + env.test.BEAST_EXPECT(loan->at(sfFlags) == flags); + + auto const ls = constructLoanState(loan); + + auto const interestRate = TenthBips32{loan->at(sfInterestRate)}; + auto const paymentInterval = loan->at(sfPaymentInterval); + checkBroker( + principalOutstanding, + ls.interestDue, + interestRate, + paymentInterval, + paymentRemaining, + 1); + + if (auto brokerSle = env.le(keylet::loanBroker(broker.brokerID)); + env.test.BEAST_EXPECT(brokerSle)) + { + if (auto vaultSle = env.le(keylet::vault(brokerSle->at(sfVaultID))); + env.test.BEAST_EXPECT(vaultSle)) + { + if (((flags & lsfLoanImpaired) != 0u) && ((flags & lsfLoanDefault) == 0u)) + { + env.test.BEAST_EXPECT( + vaultSle->at(sfLossUnrealized) == + (env.current()->rules().enabled(featureLendingProtocolV1_1) && + getVaultVersion(vaultSle) == VaultVersion::CashBasis + ? principalOutstanding + : totalValue - managementFeeOutstanding)); + } + else + { + env.test.BEAST_EXPECT(vaultSle->at(sfLossUnrealized) == 0); + } + } + } + } + } + + /** + * Checks both the loan and broker expect states against the ledger + */ + void + operator()(LoanState const& state) const + { + operator()( + state.previousPaymentDate, + state.nextPaymentDate, + state.paymentRemaining, + state.loanScale, + state.totalValue, + state.principalOutstanding, + state.managementFeeOutstanding, + state.periodicPayment, + state.flags); + }; + }; + + BrokerInfo + createVaultAndBroker( + jtx::Env& env, + jtx::PrettyAsset const& asset, + jtx::Account const& lender, + BrokerParameters const& params = BrokerParameters::defaults()) + { + using namespace jtx; + + Vault const vault{env}; + + auto const deposit = asset(params.vaultDeposit); + auto const debtMaximumValue = asset(params.debtMax).value(); + auto const coverDepositValue = asset(params.coverDeposit).value(); + + auto const coverRateMinValue = params.coverRateMin; + + auto [tx, vaultKeylet] = vault.create({.owner = lender, .asset = asset}); + if (params.vaultScale) + tx[sfScale] = *params.vaultScale; + env(tx); + env.close(); + BEAST_EXPECT(env.le(vaultKeylet)); + + env(vault.deposit({.depositor = lender, .id = vaultKeylet.key, .amount = deposit})); + env.close(); + if (auto const vault = env.le(keylet::vault(vaultKeylet.key)); BEAST_EXPECT(vault)) + { + BEAST_EXPECT(vault->at(sfAssetsAvailable) == deposit.value()); + } + + auto const keylet = keylet::loanBroker(lender.id(), env.seq(lender)); + + using namespace loanBroker; + env(set(lender, vaultKeylet.key, params.flags), + kData(params.data), + kManagementFeeRate(params.managementFeeRate), + kDebtMaximum(debtMaximumValue), + kCoverRateMinimum(coverRateMinValue), + kCoverRateLiquidation(TenthBips32(params.coverRateLiquidation))); + + if (coverDepositValue != beast::kZero) + env(coverDeposit(lender, keylet.key, coverDepositValue)); + + env.close(); + + return {asset, keylet, vaultKeylet, params}; + } + + /** + * Get the state without checking anything + */ + LoanState + getCurrentState(jtx::Env const& env, BrokerInfo const& broker, Keylet const& loanKeylet) + { + using d = NetClock::duration; + using tp = NetClock::time_point; + + // Lookup the current loan state + if (auto loan = env.le(loanKeylet); BEAST_EXPECT(loan)) + { + return LoanState{ + .previousPaymentDate = loan->at(sfPreviousPaymentDueDate), + .startDate = tp{d{loan->at(sfStartDate)}}, + .nextPaymentDate = loan->at(sfNextPaymentDueDate), + .paymentRemaining = loan->at(sfPaymentRemaining), + .loanScale = loan->at(sfLoanScale), + .totalValue = loan->at(sfTotalValueOutstanding), + .principalOutstanding = loan->at(sfPrincipalOutstanding), + .managementFeeOutstanding = loan->at(sfManagementFeeOutstanding), + .periodicPayment = loan->at(sfPeriodicPayment), + .flags = loan->at(sfFlags), + .paymentInterval = loan->at(sfPaymentInterval), + .interestRate = TenthBips32{loan->at(sfInterestRate)}, + }; + } + return LoanState{}; + } + + /** + * Get the state and check the values against the parameters used in + * `lifecycle` + */ + LoanState + getCurrentState( + jtx::Env const& env, + BrokerInfo const& broker, + Keylet const& loanKeylet, + VerifyLoanStatus const& verifyLoanStatus) + { + using namespace std::chrono_literals; + using d = NetClock::duration; + using tp = NetClock::time_point; + + auto const state = getCurrentState(env, broker, loanKeylet); + BEAST_EXPECT(state.previousPaymentDate == 0); + BEAST_EXPECT(tp{d{state.nextPaymentDate}} == state.startDate + 600s); + BEAST_EXPECT(state.paymentRemaining == 12); + BEAST_EXPECT(state.principalOutstanding == broker.asset(1000).value()); + BEAST_EXPECT( + state.loanScale >= + (broker.asset.integral() + ? 0 + : std::max(broker.vaultScale(env), state.principalOutstanding.exponent()))); + BEAST_EXPECT(state.paymentInterval == 600); + { + NumberRoundModeGuard const mg(Number::RoundingMode::Upward); + BEAST_EXPECT( + state.totalValue == + roundToAsset( + broker.asset, state.periodicPayment * state.paymentRemaining, state.loanScale)); + } + BEAST_EXPECT( + state.managementFeeOutstanding == + computeManagementFee( + broker.asset, + state.totalValue - state.principalOutstanding, + broker.params.managementFeeRate, + state.loanScale)); + + verifyLoanStatus(state); + + return state; + } + + bool + canImpairLoan(jtx::Env const& env, BrokerInfo const& broker, LoanState const& state) + { + if (auto const brokerSle = env.le(keylet::loanBroker(broker.brokerID)); + BEAST_EXPECT(brokerSle)) + { + if (auto const vaultSle = env.le(keylet::vault(brokerSle->at(sfVaultID))); + BEAST_EXPECT(vaultSle)) + { + // log << vaultSle->getJson() << std::endl; + auto const assetsUnavailable = + vaultSle->at(sfAssetsTotal) - vaultSle->at(sfAssetsAvailable); + auto const unrealizedLoss = vaultSle->at(sfLossUnrealized) + + (env.current()->rules().enabled(featureLendingProtocolV1_1) && + getVaultVersion(vaultSle) == VaultVersion::CashBasis + ? state.principalOutstanding + : state.totalValue - state.managementFeeOutstanding); + + if (!BEAST_EXPECT(unrealizedLoss <= assetsUnavailable)) + { + return false; + } + } + } + return true; + } + + enum class AssetType { XRP = 0, IOU = 1, MPT = 2 }; + + // Specify the accounts as params to allow other accounts to be used + jtx::PrettyAsset + createAsset( + jtx::Env& env, + AssetType assetType, + BrokerParameters const& brokerParams, + jtx::Account const& issuer, + jtx::Account const& lender, + jtx::Account const& borrower) + { + using namespace jtx; + + switch (assetType) + { + case AssetType::XRP: + // TODO: remove the factor, and set up loans in drops + return PrettyAsset{xrpIssue(), 1'000'000}; + + case AssetType::IOU: { + PrettyAsset const asset{issuer[iouCurrency_]}; + + auto const limit = + asset(100 * (brokerParams.vaultDeposit + brokerParams.coverDeposit)); + if (lender != issuer) + env(trust(lender, limit)); + if (borrower != issuer) + env(trust(borrower, limit)); + + return asset; + } + + case AssetType::MPT: { + // Enough to cover initial fees + if (!env.le(keylet::account(issuer))) + env.fund(env.current()->fees().accountReserve(10, 1) * 10, issuer); + if (!env.le(keylet::account(lender))) + env.fund(env.current()->fees().accountReserve(10, 1) * 10, noripple(lender)); + if (!env.le(keylet::account(borrower))) + env.fund(env.current()->fees().accountReserve(10, 1) * 10, noripple(borrower)); + + MPTTester mptt{env, issuer, kMptInitNoFund}; + mptt.create({.flags = tfMPTCanClawback | tfMPTCanTransfer | tfMPTCanLock}); + // Scale the MPT asset so interest is interesting + PrettyAsset const asset{mptt.issuanceID(), 10'000}; + // Need to do the authorization here because mptt isn't + // accessible outside + if (lender != issuer) + mptt.authorize({.account = lender}); + if (borrower != issuer) + mptt.authorize({.account = borrower}); + + env.close(); + + return asset; + } + + default: + throw std::runtime_error("Unknown asset type"); + } + } + + // Predicts the keylet of the next loan `broker` will originate, before + // that loan exists, by reading the broker's current LoanSequence. + static Keylet + nextLoanKeylet(jtx::Env const& env, BrokerInfo const& broker) + { + auto const brokerStateBefore = env.le(keylet::loanBroker(broker.brokerID)); + auto const loanSequence = brokerStateBefore->at(sfLoanSequence); + return keylet::loan(broker.brokerID, loanSequence); + } + + // Funds issuer/lender/borrower with XRP, creates an IOU asset issued by + // `issuer`, establishes trustlines for lender and borrower, and pays + // them starting balances. This is the exact setup shared by several of + // the fuzzer-derived regression tests below. + jtx::PrettyAsset + createFundedIouAsset( + jtx::Env& env, + jtx::Account const& issuer, + jtx::Account const& lender, + jtx::Account const& borrower, + Number const& lenderPay = 100'000'000, + Number const& borrowerPay = 1'000'000) + { + using namespace jtx; + + env.fund(XRP(1'000'000), issuer, lender, borrower); + env.close(); + + PrettyAsset const iouAsset = issuer[iouCurrency_]; + auto trustLenderTx = env.json(trust(lender, iouAsset(1'000'000'000))); + env(trustLenderTx); + auto trustBorrowerTx = env.json(trust(borrower, iouAsset(1'000'000'000))); + env(trustBorrowerTx); + auto payLenderTx = pay(issuer, lender, iouAsset(lenderPay)); + env(payLenderTx); + auto payIssuerTx = pay(issuer, borrower, iouAsset(borrowerPay)); + env(payIssuerTx); + env.close(); + + return iouAsset; + } + + // Funds issuer/lender/borrower with XRP, sets DefaultRipple on the + // issuer, creates a "USD" IOU asset with a large trust limit, and pays + // lender/borrower starting balances. Shared setup for several + // overpayment/rounding regression tests below. + static jtx::PrettyAsset + createFundedRippleIouAsset( + jtx::Env& env, + jtx::Account const& issuer, + jtx::Account const& lender, + jtx::Account const& borrower, + Number const& lenderPay = 1'000'000, + Number const& borrowerPay = 1'000'000) + { + using namespace jtx; + + env.fund(XRP(1'000'000), issuer, lender, borrower); + env(fset(issuer, asfDefaultRipple)); + env.close(); + + PrettyAsset const iouAsset = issuer["USD"]; + STAmount const iouLimit{iouAsset.raw(), Number{9'999'999'999'999'999LL}}; + env(trust(lender, iouLimit)); + env(trust(borrower, iouLimit)); + env(pay(issuer, lender, iouAsset(lenderPay))); + env(pay(issuer, borrower, iouAsset(borrowerPay))); + env.close(); + + return iouAsset; + } + + // Returns the broker's pseudo-account, or `fallback` if the broker's + // ledger entry cannot be read. + jtx::Account + brokerPseudoAccount(jtx::Env const& env, BrokerInfo const& broker, jtx::Account const& fallback) + { + auto const brokerSle = env.le(keylet::loanBroker(broker.brokerID)); + if (!BEAST_EXPECT(brokerSle)) + return fallback; + auto const brokerPseudo = brokerSle->at(sfAccount); + return jtx::Account("Broker pseudo-account", brokerPseudo); + } + + void + describeLoan( + jtx::Env& env, + BrokerParameters const& brokerParams, + LoanParameters const& loanParams, + AssetType assetType, + jtx::Account const& issuer, + jtx::Account const& lender, + jtx::Account const& borrower) + { + using namespace jtx; + + auto const asset = createAsset(env, assetType, brokerParams, issuer, lender, borrower); + auto const principal = asset(loanParams.principalRequest).number(); + auto const interest = loanParams.interest.value_or(TenthBips32{}); + auto const interval = loanParams.payInterval.value_or(LoanSet::kDefaultPaymentInterval); + auto const total = loanParams.payTotal.value_or(LoanSet::kDefaultPaymentTotal); + auto const feeRate = brokerParams.managementFeeRate; + auto const props = computeLoanProperties( + env.current()->rules(), + asset, + principal, + interest, + interval, + total, + feeRate, + asset(brokerParams.vaultDeposit).number().exponent()); + log << "Loan properties:\n" + << "\tPrincipal: " << principal << std::endl + << "\tInterest rate: " << interest << std::endl + << "\tPayment interval: " << interval << std::endl + << "\tManagement Fee Rate: " << feeRate << std::endl + << "\tTotal Payments: " << total << std::endl + << "\tPeriodic Payment: " << props.periodicPayment << std::endl + << "\tTotal Value: " << props.loanState.valueOutstanding << std::endl + << "\tManagement Fee: " << props.loanState.managementFeeDue << std::endl + << "\tLoan Scale: " << props.loanScale << std::endl + << "\tFirst payment principal: " << props.firstPaymentPrincipal << std::endl; + + // checkGuards returns a TER, so success is 0 + BEAST_EXPECT(!checkLoanGuards( + asset, + asset(loanParams.principalRequest).number(), + loanParams.interest.value_or(TenthBips32{}) != beast::kZero, + loanParams.payTotal.value_or(LoanSet::kDefaultPaymentTotal), + props, + env.journal)); + } + + std::optional> + createLoan( + jtx::Env& env, + AssetType assetType, + BrokerParameters const& brokerParams, + LoanParameters const& loanParams, + jtx::Account const& issuer, + jtx::Account const& lender, + jtx::Account const& borrower) + { + using namespace jtx; + + // Enough to cover initial fees + env.fund(env.current()->fees().accountReserve(10, 1) * 10, issuer); + if (lender != issuer) + env.fund(env.current()->fees().accountReserve(10, 1) * 10, noripple(lender)); + if (borrower != issuer && borrower != lender) + env.fund(env.current()->fees().accountReserve(10, 1) * 10, noripple(borrower)); + + describeLoan(env, brokerParams, loanParams, assetType, issuer, lender, borrower); + + // Make the asset + auto const asset = createAsset(env, assetType, brokerParams, issuer, lender, borrower); + + env.close(); + if (asset.native() || lender != issuer) + { + env( + pay((asset.native() ? env.master : issuer), + lender, + asset(brokerParams.vaultDeposit + brokerParams.coverDeposit))); + } + // Fund the borrower later once we know the total loan + // size + + BrokerInfo const broker = createVaultAndBroker(env, asset, lender, brokerParams); + + auto const pseudoAcctOpt = [&]() -> std::optional { + auto const brokerSle = env.le(keylet::loanBroker(broker.brokerID)); + if (!BEAST_EXPECT(brokerSle)) + return std::nullopt; + auto const brokerPseudo = brokerSle->at(sfAccount); + return Account("Broker pseudo-account", brokerPseudo); + }(); + if (!pseudoAcctOpt) + return std::nullopt; + Account const& pseudoAcct = *pseudoAcctOpt; + + auto const loanKeyletOpt = [&]() -> std::optional { + auto const brokerSle = env.le(keylet::loanBroker(broker.brokerID)); + if (!BEAST_EXPECT(brokerSle)) + return std::nullopt; + + // Broker has no loans + BEAST_EXPECT(brokerSle->at(sfOwnerCount) == 0); + + // The loan keylet is based on the LoanSequence of the + // _LOAN_BROKER_ object. + auto const loanSequence = brokerSle->at(sfLoanSequence); + return keylet::loan(broker.brokerID, loanSequence); + }(); + if (!loanKeyletOpt) + return std::nullopt; + Keylet const& loanKeylet = *loanKeyletOpt; + + env(loanParams(env, broker)); + + env.close(); + + return std::make_tuple(broker, loanKeylet, pseudoAcct); + } + + static void + topUpBorrower( + jtx::Env& env, + BrokerInfo const& broker, + jtx::Account const& issuer, + jtx::Account const& borrower, + LoanState const& state, + std::optional const& servFee) + { + using namespace jtx; + + STAmount const serviceFee = broker.asset(servFee.value_or(0)); + + // Ensure the borrower has enough funds to make the payments + // (including tx fees, if necessary) + auto const borrowerBalance = env.balance(borrower, broker.asset); + + auto const baseFee = env.current()->fees().base; + + // Add extra for transaction fees and reserves, if appropriate, or a + // tiny amount for the extra paid in each transaction + auto const totalNeeded = state.totalValue + (serviceFee * state.paymentRemaining) + + (broker.asset.native() ? Number( + baseFee * state.paymentRemaining + + accountReserve(*env.current(), borrower.id(), env.journal)) + : broker.asset(15).number()); + + auto const shortage = totalNeeded - borrowerBalance.number(); + + if (shortage > beast::kZero && (broker.asset.native() || issuer != borrower)) + { + env( + pay((broker.asset.native() ? env.master : issuer), + borrower, + STAmount{broker.asset, shortage})); + } + } + + void + makeLoanPayments( + jtx::Env& env, + BrokerInfo const& broker, + LoanParameters const& loanParams, + Keylet const& loanKeylet, + VerifyLoanStatus const& verifyLoanStatus, + jtx::Account const& issuer, + jtx::Account const& lender, + jtx::Account const& borrower, + PaymentParameters const& paymentParams = PaymentParameters::defaults()) + { + // Make all the individual payments + using namespace jtx; + using namespace jtx::loan; + using namespace std::chrono_literals; + using d = NetClock::duration; + + bool const showStepBalances = paymentParams.showStepBalances; + + auto const currencyLabel = getCurrencyLabel(broker.asset); + + auto const baseFee = env.current()->fees().base; + + env.close(); + auto state = getCurrentState(env, broker, loanKeylet); + + verifyLoanStatus(state); + + STAmount const serviceFee = broker.asset(loanParams.serviceFee.value_or(0)); + + topUpBorrower(env, broker, issuer, borrower, state, loanParams.serviceFee); + + // Periodic payment amount will consist of + // 1. principal outstanding (1000) + // 2. interest interest rate (at 12%) + // 3. payment interval (600s) + // 4. loan service fee (2) + // Calculate these values without the helper functions + // to verify they're working correctly The numbers in + // the below BEAST_EXPECTs may not hold across assets. + auto const periodicRate = loanPeriodicRate(state.interestRate, state.paymentInterval); + STAmount const roundedPeriodicPayment{ + broker.asset, + roundPeriodicPayment(broker.asset, state.periodicPayment, state.loanScale)}; + + if (!showStepBalances) + { + log << currencyLabel << " Payment components: " + << "Payments remaining, " + << "rawInterest, rawPrincipal, " + "rawMFee, " + << "trackedValueDelta, trackedPrincipalDelta, " + "trackedInterestDelta, trackedMgmtFeeDelta, special" + << std::endl; + } + + // Include the service fee + STAmount const totalDue = roundToScale( + roundedPeriodicPayment + serviceFee, state.loanScale, Number::RoundingMode::Upward); + + auto currentRoundedState = constructLoanState( + state.totalValue, state.principalOutstanding, state.managementFeeOutstanding); + { + auto const raw = computeTheoreticalLoanState( + env.current()->rules(), + state.periodicPayment, + periodicRate, + state.paymentRemaining, + broker.params.managementFeeRate); + + if (showStepBalances) + { + log << currencyLabel << " Starting loan balances: " + << "\n\tTotal value: " << currentRoundedState.valueOutstanding + << "\n\tPrincipal: " << currentRoundedState.principalOutstanding + << "\n\tInterest: " << currentRoundedState.interestDue + << "\n\tMgmt fee: " << currentRoundedState.managementFeeDue + << "\n\tPayments remaining " << state.paymentRemaining << std::endl; + } + else + { + log << currencyLabel << " Loan starting state: " << state.paymentRemaining << ", " + << raw.interestDue << ", " << raw.principalOutstanding << ", " + << raw.managementFeeDue << ", " << currentRoundedState.valueOutstanding << ", " + << currentRoundedState.principalOutstanding << ", " + << currentRoundedState.interestDue << ", " + << currentRoundedState.managementFeeDue << std::endl; + } + } + + // Try to pay a little extra to show that it's _not_ + // taken + auto const extraAmount = paymentParams.overpaymentExtra + ? broker.asset(*paymentParams.overpaymentExtra).value() + : std::min(broker.asset(10).value(), STAmount{broker.asset, totalDue / 20}); + + STAmount const transactionAmount = + STAmount{broker.asset, totalDue * paymentParams.overpaymentFactor} + extraAmount; + + auto const borrowerInitialBalance = env.balance(borrower, broker.asset).number(); + auto const initialState = state; + xrpl::detail::PaymentComponents totalPaid{ + .trackedValueDelta = 0, .trackedPrincipalDelta = 0, .trackedManagementFeeDelta = 0}; + Number totalInterestPaid = 0; + Number totalFeesPaid = 0; + std::size_t totalPaymentsMade = 0; + + xrpl::LoanState currentTrueState = computeTheoreticalLoanState( + env.current()->rules(), + state.periodicPayment, + periodicRate, + state.paymentRemaining, + broker.params.managementFeeRate); + + auto validateBorrowerBalance = [&]() { + if (borrower == issuer || !paymentParams.validateBalances) + return; + auto const totalSpent = + (totalPaid.trackedValueDelta + totalFeesPaid + + (broker.asset.native() ? Number(baseFee) * totalPaymentsMade : kNumZero)); + BEAST_EXPECT( + env.balance(borrower, broker.asset).number() == + borrowerInitialBalance - totalSpent); + }; + + auto const defaultRound = broker.asset.integral() ? 3 : 0; + auto truncate = [defaultRound](Number const& n, std::optional places = std::nullopt) { + auto const p = places.value_or(defaultRound); + if (p == 0) + return n; + auto const factor = Number{1, p}; + return (n * factor).truncate() / factor; + }; + while (state.paymentRemaining > 0) + { + validateBorrowerBalance(); + // Compute the expected principal amount + auto const paymentComponents = xrpl::detail::computePaymentComponents( + env.current()->rules(), + broker.asset.raw(), + state.loanScale, + state.totalValue, + state.principalOutstanding, + state.managementFeeOutstanding, + state.periodicPayment, + periodicRate, + state.paymentRemaining, + broker.params.managementFeeRate); + + BEAST_EXPECT( + paymentComponents.trackedValueDelta <= roundedPeriodicPayment || + (paymentComponents.specialCase == xrpl::detail::PaymentSpecialCase::Final && + paymentComponents.trackedValueDelta >= roundedPeriodicPayment)); + BEAST_EXPECT( + paymentComponents.trackedValueDelta == + paymentComponents.trackedPrincipalDelta + paymentComponents.trackedInterestPart() + + paymentComponents.trackedManagementFeeDelta); + + xrpl::LoanState const nextTrueState = computeTheoreticalLoanState( + env.current()->rules(), + state.periodicPayment, + periodicRate, + state.paymentRemaining - 1, + broker.params.managementFeeRate); + xrpl::detail::LoanStateDeltas const deltas = currentTrueState - nextTrueState; + BEAST_EXPECT( + deltas.total() == deltas.principal + deltas.interest + deltas.managementFee); + BEAST_EXPECT( + paymentComponents.specialCase == xrpl::detail::PaymentSpecialCase::Final || + deltas.total() == state.periodicPayment || + (state.loanScale - (deltas.total() - state.periodicPayment).exponent()) > 14); + + if (!showStepBalances) + { + log << currencyLabel << " Payment components: " << state.paymentRemaining << ", " + + << deltas.interest << ", " << deltas.principal << ", " << deltas.managementFee + << ", " << paymentComponents.trackedValueDelta << ", " + << paymentComponents.trackedPrincipalDelta << ", " + << paymentComponents.trackedInterestPart() << ", " + << paymentComponents.trackedManagementFeeDelta << ", " << [&]() -> char const* { + if (paymentComponents.specialCase == ::xrpl::detail::PaymentSpecialCase::Final) + return "final"; + if (paymentComponents.specialCase == ::xrpl::detail::PaymentSpecialCase::Extra) + return "extra"; + return "none"; + }() << std::endl; + } + + auto const totalDueAmount = + STAmount{broker.asset, paymentComponents.trackedValueDelta + serviceFee}; + + if (paymentParams.validateBalances) + { + // Due to the rounding algorithms to keep the interest and + // principal in sync with "true" values, the computed amount + // may be a little less than the rounded fixed payment + // amount. For integral types, the difference should be < 3 + // (1 unit for each of the interest and management fee). For + // IOUs, the difference should be dust. + Number const diff = totalDue - totalDueAmount; + BEAST_EXPECT( + paymentComponents.specialCase == xrpl::detail::PaymentSpecialCase::Final || + diff == beast::kZero || + (diff > beast::kZero && + ((broker.asset.integral() && (static_cast(diff) < 3)) || + (state.loanScale - diff.exponent() > 13)))); + + BEAST_EXPECT( + paymentComponents.trackedPrincipalDelta >= beast::kZero && + paymentComponents.trackedPrincipalDelta <= state.principalOutstanding); + BEAST_EXPECT( + paymentComponents.specialCase != xrpl::detail::PaymentSpecialCase::Final || + paymentComponents.trackedPrincipalDelta == state.principalOutstanding); + } + + auto const borrowerBalanceBeforePayment = env.balance(borrower, broker.asset); + + // Make the payment + env(pay(borrower, loanKeylet.key, transactionAmount, paymentParams.flags)); + + env.close(d{state.paymentInterval / 2}); + + if (paymentParams.validateBalances) + { + // Need to account for fees if the loan is in XRP + PrettyAmount adjustment = broker.asset(0); + if (broker.asset.native()) + { + adjustment = env.current()->fees().base; + } + + // Check the result + verifyLoanStatus.checkPayment( + state.loanScale, + borrower, + borrowerBalanceBeforePayment, + totalDueAmount, + adjustment); + } + + if (showStepBalances) + { + auto const loanSle = env.le(loanKeylet); + if (!BEAST_EXPECT(loanSle)) + { + // No reason for this not to exist + return; + } + auto const current = constructLoanState(loanSle); + auto const errors = nextTrueState - current; + log << currencyLabel << " Loan balances: " + << "\n\tAmount taken: " << paymentComponents.trackedValueDelta + << "\n\tTotal value: " << current.valueOutstanding + << " (true: " << truncate(nextTrueState.valueOutstanding) + << ", error: " << truncate(errors.total()) + << ")\n\tPrincipal: " << current.principalOutstanding + << " (true: " << truncate(nextTrueState.principalOutstanding) + << ", error: " << truncate(errors.principal) + << ")\n\tInterest: " << current.interestDue + << " (true: " << truncate(nextTrueState.interestDue) + << ", error: " << truncate(errors.interest) + << ")\n\tMgmt fee: " << current.managementFeeDue + << " (true: " << truncate(nextTrueState.managementFeeDue) + << ", error: " << truncate(errors.managementFee) << ")\n\tPayments remaining " + << loanSle->at(sfPaymentRemaining) << std::endl; + + currentRoundedState = current; + } + + --state.paymentRemaining; + state.previousPaymentDate = state.nextPaymentDate; + if (paymentComponents.specialCase == xrpl::detail::PaymentSpecialCase::Final) + { + state.paymentRemaining = 0; + state.nextPaymentDate = 0; + } + else + { + state.nextPaymentDate += state.paymentInterval; + } + state.principalOutstanding -= paymentComponents.trackedPrincipalDelta; + state.managementFeeOutstanding -= paymentComponents.trackedManagementFeeDelta; + state.totalValue -= paymentComponents.trackedValueDelta; + + if (paymentParams.validateBalances) + verifyLoanStatus(state); + + totalPaid.trackedValueDelta += paymentComponents.trackedValueDelta; + totalPaid.trackedPrincipalDelta += paymentComponents.trackedPrincipalDelta; + totalPaid.trackedManagementFeeDelta += paymentComponents.trackedManagementFeeDelta; + totalInterestPaid += paymentComponents.trackedInterestPart(); + totalFeesPaid += serviceFee; + ++totalPaymentsMade; + + currentTrueState = nextTrueState; + } + validateBorrowerBalance(); + + // Loan is paid off + BEAST_EXPECT(state.paymentRemaining == 0); + BEAST_EXPECT(state.principalOutstanding == 0); + + auto const initialInterestDue = initialState.totalValue - + (initialState.principalOutstanding + initialState.managementFeeOutstanding); + if (paymentParams.validateBalances) + { + // Make sure all the payments add up + BEAST_EXPECT(totalPaid.trackedValueDelta == initialState.totalValue); + BEAST_EXPECT(totalPaid.trackedPrincipalDelta == initialState.principalOutstanding); + BEAST_EXPECT( + totalPaid.trackedManagementFeeDelta == initialState.managementFeeOutstanding); + // This is almost a tautology given the previous checks, but + // check it anyway for completeness. + BEAST_EXPECT(totalInterestPaid == initialInterestDue); + BEAST_EXPECT(totalPaymentsMade == initialState.paymentRemaining); + } + + if (showStepBalances) + { + auto const loanSle = env.le(loanKeylet); + if (!BEAST_EXPECT(loanSle)) + { + // No reason for this not to exist + return; + } + log << currencyLabel << " Total amounts paid: " + << "\n\tTotal value: " << totalPaid.trackedValueDelta + << " (initial: " << truncate(initialState.totalValue) + << ", error: " << truncate(initialState.totalValue - totalPaid.trackedValueDelta) + << ")\n\tPrincipal: " << totalPaid.trackedPrincipalDelta + << " (initial: " << truncate(initialState.principalOutstanding) << ", error: " + << truncate(initialState.principalOutstanding - totalPaid.trackedPrincipalDelta) + << ")\n\tInterest: " << totalInterestPaid + << " (initial: " << truncate(initialInterestDue) + << ", error: " << truncate(initialInterestDue - totalInterestPaid) + << ")\n\tMgmt fee: " << totalPaid.trackedManagementFeeDelta + << " (initial: " << truncate(initialState.managementFeeOutstanding) << ", error: " + << truncate( + initialState.managementFeeOutstanding - totalPaid.trackedManagementFeeDelta) + << ")\n\tTotal payments made: " << totalPaymentsMade << std::endl; + } + } + + void + runLoan( + AssetType assetType, + BrokerParameters const& brokerParams, + LoanParameters const& loanParams, + FeatureBitset features) + { + using namespace jtx; + + Account const issuer("issuer"); + Account const lender("lender"); + Account const borrower("borrower"); + + Env env(*this, features); + + auto loanResult = + createLoan(env, assetType, brokerParams, loanParams, issuer, lender, borrower); + if (BEAST_EXPECT(loanResult); !loanResult.has_value()) + return; + + auto broker = std::get(*loanResult); + auto loanKeylet = std::get(*loanResult); + auto pseudoAcct = std::get(*loanResult); + + VerifyLoanStatus const verifyLoanStatus(env, broker, pseudoAcct, loanKeylet); + + makeLoanPayments( + env, + broker, + loanParams, + loanKeylet, + verifyLoanStatus, + issuer, + lender, + borrower, + PaymentParameters{.showStepBalances = true}); + } + + /** + * Runs through the complete lifecycle of a loan + * + * 1. Create a loan. + * 2. Test a bunch of transaction failure conditions. + * 3. Use the `toEndOfLife` callback to take the loan to 0. How that is done + * depends on the callback. e.g. Default, Early payoff, make all the + * normal payments, etc. + * 4. Delete the loan. The loan will alternate between being deleted by the + * lender and the borrower. + */ + void + lifecycle( + std::string const& caseLabel, + char const* label, + jtx::Env& env, + Number const& loanAmount, + int interestExponent, + jtx::Account const& lender, + jtx::Account const& borrower, + jtx::Account const& evan, + BrokerInfo const& broker, + jtx::Account const& pseudoAcct, + std::uint32_t flags, + // The end of life callback is expected to take the loan to 0 payments + // remaining, one way or another + std::function + toEndOfLife) + { + auto const [keylet, loanSequence] = [&]() { + auto const brokerSle = env.le(keylet::loanBroker(broker.brokerID)); + if (!BEAST_EXPECT(brokerSle)) + { + // will be invalid + return std::make_pair(keylet::loan(broker.brokerID), std::uint32_t(0)); + } + + // Broker has no loans + BEAST_EXPECT(brokerSle->at(sfOwnerCount) == 0); + + // The loan keylet is based on the LoanSequence of the _LOAN_BROKER_ + // object. + auto const loanSequence = brokerSle->at(sfLoanSequence); + return std::make_pair(keylet::loan(broker.brokerID, loanSequence), loanSequence); + }(); + + VerifyLoanStatus const verifyLoanStatus(env, broker, pseudoAcct, keylet); + + // No loans yet + verifyLoanStatus.checkBroker(0, 0, TenthBips32{0}, 1, 0, 0); + + if (!BEAST_EXPECT(loanSequence != 0)) + return; + + testcase << caseLabel << " " << label; + + using namespace jtx; + using namespace loan; + using namespace std::chrono_literals; + + auto applyExponent = [interestExponent, this](TenthBips32 value) mutable { + BEAST_EXPECT(value > TenthBips32(0)); + while (interestExponent > 0) + { + auto const oldValue = value; + value *= 10; + --interestExponent; + BEAST_EXPECT(value / 10 == oldValue); + } + while (interestExponent < 0) + { + auto const oldValue = value; + value /= 10; + ++interestExponent; + BEAST_EXPECT(value * 10 == oldValue); + } + return value; + }; + + auto const borrowerOwnerCount = env.ownerCount(borrower); + + auto const loanSetFee = env.current()->fees().base * 2; + LoanParameters const loanParams{ + .account = borrower, + .counter = lender, + .counterpartyExplicit = false, + .principalRequest = loanAmount, + .setFee = loanSetFee, + .originationFee = 1, + .serviceFee = 2, + .lateFee = 3, + .closeFee = 4, + .overFee = applyExponent(percentageToTenthBips(5) / 10), + .interest = applyExponent(percentageToTenthBips(12)), + // 2.4% + .lateInterest = applyExponent(percentageToTenthBips(24) / 10), + .closeInterest = applyExponent(percentageToTenthBips(36) / 10), + .overpaymentInterest = applyExponent(percentageToTenthBips(48) / 10), + .payTotal = 12, + .payInterval = 600, + .gracePd = 60, + .flags = flags, + }; + Number const principalRequestAmount = broker.asset(loanParams.principalRequest).value(); + auto const originationFeeAmount = broker.asset(*loanParams.originationFee).value(); + auto const serviceFeeAmount = broker.asset(*loanParams.serviceFee).value(); + auto const lateFeeAmount = broker.asset(*loanParams.lateFee).value(); + auto const closeFeeAmount = broker.asset(*loanParams.closeFee).value(); + + auto const borrowerStartbalance = env.balance(borrower, broker.asset); + + auto createJtx = loanParams(env, broker); + // Successfully create a Loan + env(createJtx); + + env.close(); + + auto const startDate = env.current()->header().parentCloseTime.time_since_epoch().count(); + + if (auto const brokerSle = env.le(keylet::loanBroker(broker.brokerID)); + BEAST_EXPECT(brokerSle)) + { + BEAST_EXPECT(brokerSle->at(sfOwnerCount) == 1); + } + + { + // Need to account for fees if the loan is in XRP + PrettyAmount adjustment = broker.asset(0); + if (broker.asset.native()) + { + adjustment = 2 * env.current()->fees().base; + } + + BEAST_EXPECT( + env.balance(borrower, broker.asset).value() == + borrowerStartbalance.value() + principalRequestAmount - originationFeeAmount - + adjustment.value()); + } + + auto const loanFlags = + createJtx.stx->isFlag(tfLoanOverpayment) ? lsfLoanOverpayment : LedgerSpecificFlags(0); + + if (auto loan = env.le(keylet); BEAST_EXPECT(loan)) + { + // log << "loan after create: " << to_string(loan->getJson()) + // << std::endl; + BEAST_EXPECT( + loan->isFlag(lsfLoanOverpayment) == createJtx.stx->isFlag(tfLoanOverpayment)); + BEAST_EXPECT(loan->at(sfLoanSequence) == loanSequence); + BEAST_EXPECT(loan->at(sfBorrower) == borrower.id()); + BEAST_EXPECT(loan->at(sfLoanBrokerID) == broker.brokerID); + BEAST_EXPECT(loan->at(sfLoanOriginationFee) == originationFeeAmount); + BEAST_EXPECT(loan->at(sfLoanServiceFee) == serviceFeeAmount); + BEAST_EXPECT(loan->at(sfLatePaymentFee) == lateFeeAmount); + BEAST_EXPECT(loan->at(sfClosePaymentFee) == closeFeeAmount); + BEAST_EXPECT(loan->at(sfOverpaymentFee) == *loanParams.overFee); + BEAST_EXPECT(loan->at(sfInterestRate) == *loanParams.interest); + BEAST_EXPECT(loan->at(sfLateInterestRate) == *loanParams.lateInterest); + BEAST_EXPECT(loan->at(sfCloseInterestRate) == *loanParams.closeInterest); + BEAST_EXPECT(loan->at(sfOverpaymentInterestRate) == *loanParams.overpaymentInterest); + BEAST_EXPECT(loan->at(sfStartDate) == startDate); + BEAST_EXPECT(loan->at(sfPaymentInterval) == *loanParams.payInterval); + BEAST_EXPECT(loan->at(sfGracePeriod) == *loanParams.gracePd); + BEAST_EXPECT(loan->at(sfPreviousPaymentDueDate) == 0); + BEAST_EXPECT(loan->at(sfNextPaymentDueDate) == startDate + *loanParams.payInterval); + BEAST_EXPECT(loan->at(sfPaymentRemaining) == *loanParams.payTotal); + BEAST_EXPECT( + loan->at(sfLoanScale) >= + (broker.asset.integral() + ? 0 + : std::max(broker.vaultScale(env), principalRequestAmount.exponent()))); + BEAST_EXPECT(loan->at(sfPrincipalOutstanding) == principalRequestAmount); + } + + auto state = getCurrentState(env, broker, keylet, verifyLoanStatus); + + auto const loanProperties = computeLoanProperties( + env.current()->rules(), + broker.asset.raw(), + state.principalOutstanding, + state.interestRate, + state.paymentInterval, + state.paymentRemaining, + broker.params.managementFeeRate, + state.loanScale); + + verifyLoanStatus( + 0, + startDate + *loanParams.payInterval, + *loanParams.payTotal, + state.loanScale, + loanProperties.loanState.valueOutstanding, + principalRequestAmount, + loanProperties.loanState.managementFeeDue, + loanProperties.periodicPayment, + loanFlags | 0); + + // Manage the loan + // no-op + env(manage(lender, keylet.key, 0)); + { + // no flags + auto jt = manage(lender, keylet.key, 0); + jt.removeMember(sfFlags.getName()); + env(jt); + } + // Only the lender can manage + env(manage(evan, keylet.key, 0), Ter(tecNO_PERMISSION)); + // unknown flags + env(manage(lender, keylet.key, tfLoanManageMask), Ter(temINVALID_FLAG)); + // combinations of flags are not allowed + env(manage(lender, keylet.key, tfLoanUnimpair | tfLoanImpair), Ter(temINVALID_FLAG)); + env(manage(lender, keylet.key, tfLoanImpair | tfLoanDefault), Ter(temINVALID_FLAG)); + env(manage(lender, keylet.key, tfLoanUnimpair | tfLoanDefault), Ter(temINVALID_FLAG)); + env(manage(lender, keylet.key, tfLoanUnimpair | tfLoanImpair | tfLoanDefault), + Ter(temINVALID_FLAG)); + // invalid loan ID + env(manage(lender, broker.brokerID, tfLoanImpair), Ter(tecNO_ENTRY)); + // Loan is unimpaired, can't unimpair it again + env(manage(lender, keylet.key, tfLoanUnimpair), Ter(tecNO_PERMISSION)); + // Loan is unimpaired, it can go into default, but only after it's past + // due + env(manage(lender, keylet.key, tfLoanDefault), Ter(tecTOO_SOON)); + + // Check the vault + bool const canImpair = canImpairLoan(env, broker, state); + // Impair the loan, if possible + env(manage(lender, keylet.key, tfLoanImpair), + canImpair ? Ter(tesSUCCESS) : Ter(tecLIMIT_EXCEEDED)); + // Unimpair the loan + env(manage(lender, keylet.key, tfLoanUnimpair), + canImpair ? Ter(tesSUCCESS) : Ter(tecNO_PERMISSION)); + + auto const nextDueDate = startDate + *loanParams.payInterval; + + env.close(); + + verifyLoanStatus( + 0, + nextDueDate, + *loanParams.payTotal, + loanProperties.loanScale, + loanProperties.loanState.valueOutstanding, + principalRequestAmount, + loanProperties.loanState.managementFeeDue, + loanProperties.periodicPayment, + loanFlags | 0); + + // Can't delete the loan yet. It has payments remaining. + env(del(lender, keylet.key), Ter(tecHAS_OBLIGATIONS)); + + if (BEAST_EXPECT(toEndOfLife)) + toEndOfLife(keylet, verifyLoanStatus); + env.close(); + + // Verify the loan is at EOL + if (auto loan = env.le(keylet); BEAST_EXPECT(loan)) + { + BEAST_EXPECT(loan->at(sfPaymentRemaining) == 0); + BEAST_EXPECT(loan->at(sfPrincipalOutstanding) == 0); + } + auto const borrowerStartingBalance = env.balance(borrower, broker.asset); + + // Try to delete the loan broker with an active loan + env(loanBroker::del(lender, broker.brokerID), Ter(tecHAS_OBLIGATIONS)); + // Ensure the above tx doesn't get ordered after the LoanDelete and + // delete our broker! + env.close(); + + // Test failure cases + env(del(lender, keylet.key, tfLoanOverpayment), Ter(temINVALID_FLAG)); + env(del(evan, keylet.key), Ter(tecNO_PERMISSION)); + env(del(lender, broker.brokerID), Ter(tecNO_ENTRY)); + + // Delete the loan + // Either the borrower or the lender can delete the loan. Alternate + // between who does it across tests. + static unsigned kDeleteCounter = 0; + auto const deleter = ((++kDeleteCounter % 2) != 0u) ? lender : borrower; + env(del(deleter, keylet.key)); + env.close(); + + PrettyAmount adjustment = broker.asset(0); + if (deleter == borrower) + { + // Need to account for fees if the loan is in XRP + if (broker.asset.native()) + { + adjustment = env.current()->fees().base; + } + } + + // No loans left + verifyLoanStatus.checkBroker(0, 0, *loanParams.interest, 1, 0, 0); + + BEAST_EXPECT( + env.balance(borrower, broker.asset).value() == + borrowerStartingBalance.value() - adjustment); + BEAST_EXPECT(env.ownerCount(borrower) == borrowerOwnerCount); + + if (auto const brokerSle = env.le(keylet::loanBroker(broker.brokerID)); + BEAST_EXPECT(brokerSle)) + { + BEAST_EXPECT(brokerSle->at(sfOwnerCount) == 0); + } + } + + static std::string + getCurrencyLabel(Asset const& asset) + { + if (asset.native()) + return "XRP"; + if (asset.holds()) + return "IOU"; + if (asset.holds()) + return "MPT"; + return "Unknown"; + } + + /** + * Wrapper to run a series of lifecycle tests for a given asset and loan + * amount + * + * Will be used in the future to vary the loan parameters. For now, it is + * only called once. + * + * Tests a bunch of LoanSet failure conditions before lifecycle. + */ + template + void + testCaseWrapper( + jtx::Env& env, + jtx::MPTTester& mptt, + std::array const& assets, + BrokerInfo const& broker, + Number const& loanAmount, + int interestExponent) + { + using namespace jtx; + using namespace Lending; + + auto const& asset = broker.asset.raw(); + auto const currencyLabel = getCurrencyLabel(asset); + auto const caseLabel = [&]() { + std::stringstream ss; + ss << "Lifecycle: " << loanAmount << " " << currencyLabel + << " Scale interest to: " << interestExponent << " "; + return ss.str(); + }(); + testcase << caseLabel; + + using namespace loan; + using namespace std::chrono_literals; + using d = NetClock::duration; + using tp = NetClock::time_point; + + Account const issuer{"issuer"}; + // For simplicity, lender will be the sole actor for the vault & + // brokers. + Account const lender{"lender"}; + // Borrower only wants to borrow + Account const borrower{"borrower"}; + // Evan will attempt to be naughty + Account const evan{"evan"}; + // Do not fund alice + Account const alice{"alice"}; + + Number const principalRequest = broker.asset(loanAmount).value(); + Number const maxCoveredLoanValue = broker.params.maxCoveredLoanValue(0); + BEAST_EXPECT(maxCoveredLoanValue == 1000 * 100 / 10); + Number const maxCoveredLoanRequest = broker.asset(maxCoveredLoanValue).value(); + Number const totalVaultRequest = broker.asset(broker.params.vaultDeposit).value(); + Number const debtMaximumRequest = broker.asset(broker.params.debtMax).value(); + + auto const loanSetFee = Fee(env.current()->fees().base * 2); + + auto const pseudoAcct = brokerPseudoAccount(env, broker, lender); + + auto const baseFee = env.current()->fees().base; + + auto badKeylet = keylet::vault(lender.id(), env.seq(lender)); + // Try some failure cases + // flags are checked first + env(set(evan, broker.brokerID, principalRequest, tfLoanSetMask), + Sig(sfCounterpartySignature, lender), + loanSetFee, + Ter(temINVALID_FLAG)); + + // field length validation + // sfData: good length, bad account + env(set(evan, broker.brokerID, principalRequest), + Sig(sfCounterpartySignature, borrower), + kData(std::string(kMaxDataPayloadLength, 'X')), + loanSetFee, + Ter(tefBAD_AUTH)); + // sfData: too long + env(set(evan, broker.brokerID, principalRequest), + Sig(sfCounterpartySignature, lender), + kData(std::string(kMaxDataPayloadLength + 1, 'Y')), + loanSetFee, + Ter(temINVALID)); + + // field range validation + // sfOverpaymentFee: good value, bad account + env(set(evan, broker.brokerID, principalRequest), + Sig(sfCounterpartySignature, borrower), + kOverpaymentFee(kMaxOverpaymentFee), + loanSetFee, + Ter(tefBAD_AUTH)); + // sfOverpaymentFee: too big + env(set(evan, broker.brokerID, principalRequest), + Sig(sfCounterpartySignature, lender), + kOverpaymentFee(kMaxOverpaymentFee + 1), + loanSetFee, + Ter(temINVALID)); + + // sfInterestRate: good value, bad account + env(set(evan, broker.brokerID, principalRequest), + Sig(sfCounterpartySignature, borrower), + kInterestRate(kMaxInterestRate), + loanSetFee, + Ter(tefBAD_AUTH)); + env(set(evan, broker.brokerID, principalRequest), + Sig(sfCounterpartySignature, borrower), + kInterestRate(TenthBips32(0)), + loanSetFee, + Ter(tefBAD_AUTH)); + // sfInterestRate: too big + env(set(evan, broker.brokerID, principalRequest), + Sig(sfCounterpartySignature, lender), + kInterestRate(kMaxInterestRate + 1), + loanSetFee, + Ter(temINVALID)); + // sfInterestRate: too small + env(set(evan, broker.brokerID, principalRequest), + Sig(sfCounterpartySignature, lender), + kInterestRate(TenthBips32(-1)), + loanSetFee, + Ter(temINVALID)); + + // sfLateInterestRate: good value, bad account + env(set(evan, broker.brokerID, principalRequest), + Sig(sfCounterpartySignature, borrower), + kLateInterestRate(kMaxLateInterestRate), + loanSetFee, + Ter(tefBAD_AUTH)); + env(set(evan, broker.brokerID, principalRequest), + Sig(sfCounterpartySignature, borrower), + kLateInterestRate(TenthBips32(0)), + loanSetFee, + Ter(tefBAD_AUTH)); + // sfLateInterestRate: too big + env(set(evan, broker.brokerID, principalRequest), + Sig(sfCounterpartySignature, lender), + kLateInterestRate(kMaxLateInterestRate + 1), + loanSetFee, + Ter(temINVALID)); + // sfLateInterestRate: too small + env(set(evan, broker.brokerID, principalRequest), + Sig(sfCounterpartySignature, lender), + kLateInterestRate(TenthBips32(-1)), + loanSetFee, + Ter(temINVALID)); + + // sfCloseInterestRate: good value, bad account + env(set(evan, broker.brokerID, principalRequest), + Sig(sfCounterpartySignature, borrower), + kCloseInterestRate(kMaxCloseInterestRate), + loanSetFee, + Ter(tefBAD_AUTH)); + env(set(evan, broker.brokerID, principalRequest), + Sig(sfCounterpartySignature, borrower), + kCloseInterestRate(TenthBips32(0)), + loanSetFee, + Ter(tefBAD_AUTH)); + // sfCloseInterestRate: too big + env(set(evan, broker.brokerID, principalRequest), + Sig(sfCounterpartySignature, lender), + kCloseInterestRate(kMaxCloseInterestRate + 1), + loanSetFee, + Ter(temINVALID)); + env(set(evan, broker.brokerID, principalRequest), + Sig(sfCounterpartySignature, lender), + kCloseInterestRate(TenthBips32(-1)), + loanSetFee, + Ter(temINVALID)); + + // sfOverpaymentInterestRate: good value, bad account + env(set(evan, broker.brokerID, principalRequest), + Sig(sfCounterpartySignature, borrower), + kOverpaymentInterestRate(kMaxOverpaymentInterestRate), + loanSetFee, + Ter(tefBAD_AUTH)); + env(set(evan, broker.brokerID, principalRequest), + Sig(sfCounterpartySignature, borrower), + kOverpaymentInterestRate(TenthBips32(0)), + loanSetFee, + Ter(tefBAD_AUTH)); + // sfOverpaymentInterestRate: too big + env(set(evan, broker.brokerID, principalRequest), + Sig(sfCounterpartySignature, lender), + kOverpaymentInterestRate(kMaxOverpaymentInterestRate + 1), + loanSetFee, + Ter(temINVALID)); + env(set(evan, broker.brokerID, principalRequest), + Sig(sfCounterpartySignature, lender), + kOverpaymentInterestRate(TenthBips32(-1)), + loanSetFee, + Ter(temINVALID)); + + // sfPaymentTotal: good value, bad account + env(set(evan, broker.brokerID, principalRequest), + Sig(sfCounterpartySignature, borrower), + kPaymentTotal(LoanSet::kMinPaymentTotal), + loanSetFee, + Ter(tefBAD_AUTH)); + // sfPaymentTotal: too small (there is no max) + env(set(evan, broker.brokerID, principalRequest), + Sig(sfCounterpartySignature, lender), + kPaymentTotal(LoanSet::kMinPaymentTotal - 1), + loanSetFee, + Ter(temINVALID)); + + // sfPaymentInterval: good value, bad account + env(set(evan, broker.brokerID, principalRequest), + Sig(sfCounterpartySignature, borrower), + kPaymentInterval(LoanSet::kMinPaymentInterval), + loanSetFee, + Ter(tefBAD_AUTH)); + // sfPaymentInterval: too small (there is no max) + env(set(evan, broker.brokerID, principalRequest), + Sig(sfCounterpartySignature, lender), + kPaymentInterval(LoanSet::kMinPaymentInterval - 1), + loanSetFee, + Ter(temINVALID)); + + // sfGracePeriod: good value, bad account + env(set(evan, broker.brokerID, principalRequest), + Sig(sfCounterpartySignature, borrower), + kPaymentInterval(LoanSet::kMinPaymentInterval * 2), + kGracePeriod(LoanSet::kMinPaymentInterval * 2), + loanSetFee, + Ter(tefBAD_AUTH)); + // sfGracePeriod: larger than paymentInterval + env(set(evan, broker.brokerID, principalRequest), + Sig(sfCounterpartySignature, lender), + kPaymentInterval(LoanSet::kMinPaymentInterval * 2), + kGracePeriod(LoanSet::kMinPaymentInterval * 3), + loanSetFee, + Ter(temINVALID)); + + // insufficient fee - single sign + env(set(borrower, broker.brokerID, principalRequest), + Sig(sfCounterpartySignature, lender), + Ter(telINSUF_FEE_P)); + // insufficient fee - multisign + env(signers(lender, 2, {{evan, 1}, {borrower, 1}})); + env(signers(borrower, 2, {{evan, 1}, {lender, 1}})); + env(set(borrower, broker.brokerID, principalRequest), + kCounterparty(lender), + Msig(evan, lender), + Msig(sfCounterpartySignature, evan, borrower), + Fee(env.current()->fees().base * 5 - 1), + Ter(telINSUF_FEE_P)); + // Bad multisign signatures for borrower (Account) + env(set(borrower, broker.brokerID, principalRequest), + kCounterparty(lender), + Msig(alice, issuer), + Msig(sfCounterpartySignature, evan, borrower), + Fee(env.current()->fees().base * 5), + Ter(tefBAD_SIGNATURE)); + // Bad multisign signatures for issuer (Counterparty) + env(set(borrower, broker.brokerID, principalRequest), + kCounterparty(lender), + Msig(evan, lender), + Msig(sfCounterpartySignature, alice, issuer), + Fee(env.current()->fees().base * 5 - 1), + Ter(tefBAD_SIGNATURE)); + env(signers(lender, kNone)); + env(signers(borrower, kNone)); + // multisign sufficient fee, but no signers set up + env(set(borrower, broker.brokerID, principalRequest), + kCounterparty(lender), + Msig(evan, lender), + Msig(sfCounterpartySignature, evan, borrower), + Fee(env.current()->fees().base * 5), + Ter(tefNOT_MULTI_SIGNING)); + // not the broker owner, no counterparty, not signed by broker + // owner + env(set(borrower, broker.brokerID, principalRequest), + Sig(sfCounterpartySignature, evan), + loanSetFee, + Ter(tefBAD_AUTH)); + // not the broker owner, counterparty is borrower + env(set(evan, broker.brokerID, principalRequest), + kCounterparty(borrower), + Sig(sfCounterpartySignature, borrower), + loanSetFee, + Ter(tecNO_PERMISSION)); + // not a LoanBroker object, no counterparty + env(set(lender, badKeylet.key, principalRequest), + Sig(sfCounterpartySignature, evan), + loanSetFee, + Ter(temBAD_SIGNER)); + // not a LoanBroker object, counterparty is valid + env(set(lender, badKeylet.key, principalRequest), + kCounterparty(borrower), + Sig(sfCounterpartySignature, borrower), + loanSetFee, + Ter(tecNO_ENTRY)); + // borrower doesn't exist + env(set(lender, broker.brokerID, principalRequest), + kCounterparty(alice), + Sig(sfCounterpartySignature, alice), + loanSetFee, + Ter(terNO_ACCOUNT)); + + // Request more funds than the vault has available + env(set(evan, broker.brokerID, totalVaultRequest + 1), + Sig(sfCounterpartySignature, lender), + loanSetFee, + Ter(tecINSUFFICIENT_FUNDS)); + + // Request more funds than the broker's first-loss capital can + // cover. + env(set(evan, broker.brokerID, maxCoveredLoanRequest + 1), + Sig(sfCounterpartySignature, lender), + loanSetFee, + Ter(tecINSUFFICIENT_FUNDS)); + + // Frozen trust line / locked MPT issuance + // XRP can not be frozen, but run through the loop anyway to test + // the tecLIMIT_EXCEEDED case + { + auto const brokerSle = env.le(keylet::loanBroker(broker.brokerID)); + if (!BEAST_EXPECT(brokerSle)) + return; + + auto const vaultPseudo = [&]() { + auto const vaultSle = env.le(keylet::vault(brokerSle->at(sfVaultID))); + if (!BEAST_EXPECT(vaultSle)) + { + // This will be wrong, but the test has failed anyway. + return Account{lender}; + } + auto vaultPseudo = Account("Vault pseudo-account", vaultSle->at(sfAccount)); + return vaultPseudo; + }(); + + auto const [freeze, deepfreeze, unfreeze, expectedResult] = + [&]() -> std::tuple< + std::function, + std::function, + std::function, + TER> { + // Freeze / lock the asset + std::function const empty; + if (broker.asset.native()) + { + // XRP can't be frozen + return std::make_tuple(empty, empty, empty, tesSUCCESS); + } + if (broker.asset.holds()) + { + auto freeze = [&](Account const& holder) { + env(trust(issuer, holder[iouCurrency_](0), tfSetFreeze)); + }; + auto deepfreeze = [&](Account const& holder) { + env(trust(issuer, holder[iouCurrency_](0), tfSetFreeze | tfSetDeepFreeze)); + }; + auto unfreeze = [&](Account const& holder) { + env(trust( + issuer, holder[iouCurrency_](0), tfClearFreeze | tfClearDeepFreeze)); + }; + return std::make_tuple(freeze, deepfreeze, unfreeze, tecFROZEN); + } + + auto freeze = [&](Account const& holder) { + mptt.set({.account = issuer, .holder = holder, .flags = tfMPTLock}); + }; + auto unfreeze = [&](Account const& holder) { + mptt.set({.account = issuer, .holder = holder, .flags = tfMPTUnlock}); + }; + return std::make_tuple(freeze, empty, unfreeze, tecLOCKED); + }(); + + // Try freezing the accounts that can't be frozen + if (freeze) + { + for (auto const& account : {vaultPseudo, evan}) + { + // Freeze the account + freeze(account); + + // Try to create a loan with a frozen line + env(set(evan, broker.brokerID, debtMaximumRequest), + Sig(sfCounterpartySignature, lender), + loanSetFee, + Ter(expectedResult)); + + // Unfreeze the account + BEAST_EXPECT(unfreeze); + unfreeze(account); + + // Ensure the line is unfrozen with a request that is fine + // except too it requests more principal than the broker can + // carry + env(set(evan, broker.brokerID, debtMaximumRequest + 1), + Sig(sfCounterpartySignature, lender), + loanSetFee, + Ter(tecLIMIT_EXCEEDED)); + } + } + + // Deep freeze the borrower, which prevents them from receiving + // funds + if (deepfreeze) + { + // Make sure evan has a trust line that so the issuer can + // freeze it. (Don't need to do this for the borrower, + // because LoanSet will create a line to the borrower + // automatically.) + env(trust(evan, issuer[iouCurrency_](100'000))); + + for (auto const& account : {// these accounts can't be frozen, which deep freeze + // implies + vaultPseudo, + evan, + // these accounts can't be deep frozen + lender}) + { + // Freeze evan + deepfreeze(account); + + // Try to create a loan with a deep frozen line + env(set(evan, broker.brokerID, debtMaximumRequest), + Sig(sfCounterpartySignature, lender), + loanSetFee, + Ter(expectedResult)); + + // Unfreeze evan + BEAST_EXPECT(unfreeze); + unfreeze(account); + + // Ensure the line is unfrozen with a request that is fine + // except too it requests more principal than the broker can + // carry + env(set(evan, broker.brokerID, debtMaximumRequest + 1), + Sig(sfCounterpartySignature, lender), + loanSetFee, + Ter(tecLIMIT_EXCEEDED)); + } + } + } + + // Finally! Create a loan + + auto coverAvailable = [&env, this](uint256 const& brokerID, Number const& expected) { + if (auto const brokerSle = env.le(keylet::loanBroker(brokerID)); + BEAST_EXPECT(brokerSle)) + { + auto const available = brokerSle->at(sfCoverAvailable); + BEAST_EXPECT(available == expected); + return available; + } + return Number{}; + }; + auto getDefaultInfo = [&env, this](LoanState const& state, BrokerInfo const& broker) { + if (auto const brokerSle = env.le(keylet::loanBroker(broker.brokerID)); + BEAST_EXPECT(brokerSle)) + { + BEAST_EXPECT( + state.loanScale >= + (broker.asset.integral() + ? 0 + : std::max( + broker.vaultScale(env), state.principalOutstanding.exponent()))); + NumberRoundModeGuard const mg(Number::RoundingMode::Upward); + auto const defaultAmount = roundToAsset( + broker.asset, + std::min( + tenthBipsOfValue( + tenthBipsOfValue( + brokerSle->at(sfDebtTotal), broker.params.coverRateMin), + broker.params.coverRateLiquidation), + state.totalValue - state.managementFeeOutstanding), + state.loanScale); + return std::make_pair(defaultAmount, brokerSle->at(sfOwner)); + } + return std::make_pair(Number{}, AccountID{}); + }; + auto replenishCover = [&env, &coverAvailable]( + BrokerInfo const& broker, + AccountID const& brokerAcct, + Number const& startingCoverAvailable, + Number const& amountToBeCovered) { + coverAvailable(broker.brokerID, startingCoverAvailable - amountToBeCovered); + env(loanBroker::coverDeposit( + brokerAcct, broker.brokerID, STAmount{broker.asset, amountToBeCovered})); + coverAvailable(broker.brokerID, startingCoverAvailable); + env.close(); + }; + + auto defaultImmediately = [&](std::uint32_t baseFlag, bool impair = true) { + return [&, impair, baseFlag]( + Keylet const& loanKeylet, VerifyLoanStatus const& verifyLoanStatus) { + // toEndOfLife + // + // Default the loan + + // Initialize values with the current state + auto state = getCurrentState(env, broker, loanKeylet, verifyLoanStatus); + BEAST_EXPECT(state.flags == baseFlag); + + auto const& broker = verifyLoanStatus.broker; + auto const startingCoverAvailable = coverAvailable( + broker.brokerID, broker.asset(broker.params.coverDeposit).number()); + + if (impair) + { + // Check the vault + bool const canImpair = canImpairLoan(env, broker, state); + // Impair the loan, if possible + env(manage(lender, loanKeylet.key, tfLoanImpair), + canImpair ? Ter(tesSUCCESS) : Ter(tecLIMIT_EXCEEDED)); + + if (canImpair) + { + state.flags |= tfLoanImpair; + state.nextPaymentDate = env.now().time_since_epoch().count(); + + // Once the loan is impaired, it can't be impaired again + env(manage(lender, loanKeylet.key, tfLoanImpair), Ter(tecNO_PERMISSION)); + } + verifyLoanStatus(state); + } + + auto const nextDueDate = tp{d{state.nextPaymentDate}}; + + // Can't default the loan yet. The grace period hasn't + // expired + env(manage(lender, loanKeylet.key, tfLoanDefault), Ter(tecTOO_SOON)); + + // Let some time pass so that the loan can be + // defaulted + env.close(nextDueDate + 60s); + + auto const [amountToBeCovered, brokerAcct] = getDefaultInfo(state, broker); + + // Default the loan + env(manage(lender, loanKeylet.key, tfLoanDefault)); + env.close(); + + // The LoanBroker just lost some of it's first-loss capital. + // Replenish it. + replenishCover(broker, brokerAcct, startingCoverAvailable, amountToBeCovered); + + state.flags |= tfLoanDefault; + state.paymentRemaining = 0; + state.totalValue = 0; + state.principalOutstanding = 0; + state.managementFeeOutstanding = 0; + state.nextPaymentDate = 0; + verifyLoanStatus(state); + + // Once a loan is defaulted, it can't be managed + env(manage(lender, loanKeylet.key, tfLoanUnimpair), Ter(tecNO_PERMISSION)); + env(manage(lender, loanKeylet.key, tfLoanImpair), Ter(tecNO_PERMISSION)); + // Can't make a payment on it either + env(pay(borrower, loanKeylet.key, broker.asset(300)), Ter(tecKILLED)); + }; + }; + + auto singlePayment = [&](Keylet const& loanKeylet, + VerifyLoanStatus const& verifyLoanStatus, + LoanState& state, + STAmount const& payoffAmount, + std::uint32_t numPayments, + std::uint32_t baseFlag, + std::uint32_t txFlags) { + // toEndOfLife + // + verifyLoanStatus(state); + + // Send some bogus pay transactions + env(pay(borrower, keylet::loan(uint256(0)).key, broker.asset(10), txFlags), + Ter(temINVALID)); + // broker.asset(80) is less than a single payment, but all these + // checks fail before that matters + env(pay(borrower, loanKeylet.key, broker.asset(-80), txFlags), Ter(temBAD_AMOUNT)); + env(pay(borrower, broker.brokerID, broker.asset(80), txFlags), Ter(tecNO_ENTRY)); + env(pay(evan, loanKeylet.key, broker.asset(80), txFlags), Ter(tecNO_PERMISSION)); + + // TODO: Write a general "isFlag" function? See STObject::isFlag. + // Maybe add a static overloaded member? + if (!(state.flags & lsfLoanOverpayment)) + { + // If the loan does not allow overpayments, send a payment that + // tries to make an overpayment. Do not include `txFlags`, so we + // don't end up duplicating the next test transaction. + // + // fixCleanup3_1_3 gates tfLoanOverpayment as a valid flag: + // with fix on → preflight passes, apply returns tecNO_PERMISSION; + // with fix off → preflight rejects the flag, returns temINVALID_FLAG. + bool const hasFix313 = env.current()->rules().enabled(fixCleanup3_1_3); + STAmount const overpayAmount{broker.asset, state.periodicPayment * Number{15, -1}}; + XRPAmount const overpayFee{ + baseFee * (Number{15, -1} / kLoanPaymentsPerFeeIncrement + 1)}; + env(pay(borrower, loanKeylet.key, overpayAmount, tfLoanOverpayment), + Fee(overpayFee), + Ter(hasFix313 ? TER{tecNO_PERMISSION} : TER{temINVALID_FLAG})); + + if (hasFix313) + { + env.disableFeature(fixCleanup3_1_3); + env(pay(borrower, loanKeylet.key, overpayAmount, tfLoanOverpayment), + Fee(overpayFee), + Ter(temINVALID_FLAG)); + env.enableFeature(fixCleanup3_1_3); + } + } + // Try to send a payment marked as multiple mutually exclusive + // payment types. Do not include `txFlags`, so we don't duplicate + // the prior test transaction. + env(pay(borrower, + loanKeylet.key, + broker.asset(state.periodicPayment * 2), + tfLoanLatePayment | tfLoanFullPayment), + Ter(temINVALID_FLAG)); + env(pay(borrower, + loanKeylet.key, + broker.asset(state.periodicPayment * 2), + tfLoanLatePayment | tfLoanOverpayment), + Ter(temINVALID_FLAG)); + env(pay(borrower, + loanKeylet.key, + broker.asset(state.periodicPayment * 2), + tfLoanOverpayment | tfLoanFullPayment), + Ter(temINVALID_FLAG)); + env(pay(borrower, + loanKeylet.key, + broker.asset(state.periodicPayment * 2), + tfLoanLatePayment | tfLoanOverpayment | tfLoanFullPayment), + Ter(temINVALID_FLAG)); + + { + auto const otherAsset = + broker.asset.raw() == assets[0].raw() ? assets[1] : assets[0]; + env(pay(borrower, loanKeylet.key, otherAsset(100), txFlags), Ter(tecWRONG_ASSET)); + } + + // Amount doesn't cover a single payment + env(pay(borrower, loanKeylet.key, STAmount{broker.asset, 1}, txFlags), + Ter(tecINSUFFICIENT_PAYMENT)); + + // Get the balance after these failed transactions take + // fees + auto const borrowerBalanceBeforePayment = env.balance(borrower, broker.asset); + + BEAST_EXPECT(payoffAmount > state.principalOutstanding); + // Try to pay a little extra to show that it's _not_ + // taken + auto const transactionAmount = payoffAmount + broker.asset(10); + + // Send a transaction that tries to pay more than the borrowers's + // balance + XRPAmount const badFee{ + baseFee * + (borrowerBalanceBeforePayment.number() * 2 / state.periodicPayment / + kLoanPaymentsPerFeeIncrement + + 1)}; + env(pay(borrower, + loanKeylet.key, + STAmount{broker.asset, borrowerBalanceBeforePayment.number() * 2}, + txFlags), + Fee(badFee), + Ter(tecINSUFFICIENT_FUNDS)); + + XRPAmount const goodFee{baseFee * (numPayments / kLoanPaymentsPerFeeIncrement + 1)}; + env(pay(borrower, loanKeylet.key, transactionAmount, txFlags), Fee(goodFee)); + + env.close(); + + // log << env.meta()->getJson() << std::endl; + + // Need to account for fees if the loan is in XRP + PrettyAmount adjustment = broker.asset(0); + if (broker.asset.native()) + { + adjustment = badFee + goodFee; + } + + state.paymentRemaining = 0; + state.principalOutstanding = 0; + state.totalValue = 0; + state.managementFeeOutstanding = 0; + state.previousPaymentDate = + state.nextPaymentDate + (state.paymentInterval * (numPayments - 1)); + state.nextPaymentDate = 0; + verifyLoanStatus(state); + + verifyLoanStatus.checkPayment( + state.loanScale, borrower, borrowerBalanceBeforePayment, payoffAmount, adjustment); + + // Can't impair or default a paid off loan + env(manage(lender, loanKeylet.key, tfLoanImpair), Ter(tecNO_PERMISSION)); + env(manage(lender, loanKeylet.key, tfLoanDefault), Ter(tecNO_PERMISSION)); + }; + + auto fullPayment = [&](std::uint32_t baseFlag) { + return [&, baseFlag]( + Keylet const& loanKeylet, VerifyLoanStatus const& verifyLoanStatus) { + // toEndOfLife + // + auto state = getCurrentState(env, broker, loanKeylet, verifyLoanStatus); + env.close(state.startDate + 20s); + auto const loanAge = (env.now() - state.startDate).count(); + BEAST_EXPECT(loanAge == 30); + + // Full payoff amount will consist of + // 1. principal outstanding (1000) + // 2. accrued interest (at 12%) + // 3. prepayment penalty (closeInterest at 3.6%) + // 4. close payment fee (4) + // Calculate these values without the helper functions + // to verify they're working correctly The numbers in + // the below BEAST_EXPECTs may not hold across assets. + Number const interval = state.paymentInterval; + auto const periodicRate = interval * Number(12, -2) / kSecondsInYear; + BEAST_EXPECT( + periodicRate == Number(2283105022831050228ULL, -24, Number::Normalized{})); + STAmount const principalOutstanding{broker.asset, state.principalOutstanding}; + STAmount const accruedInterest{ + broker.asset, state.principalOutstanding * periodicRate * loanAge / interval}; + BEAST_EXPECT(accruedInterest == broker.asset(Number(1141552511415525, -19))); + STAmount const prepaymentPenalty{ + broker.asset, state.principalOutstanding * Number(36, -3)}; + BEAST_EXPECT(prepaymentPenalty == broker.asset(36)); + STAmount const closePaymentFee = broker.asset(4); + auto const payoffAmount = roundToScale( + principalOutstanding + accruedInterest + prepaymentPenalty + closePaymentFee, + state.loanScale); + BEAST_EXPECT( + payoffAmount == + roundToAsset( + broker.asset, + broker.asset(Number(1040000114155251, -12)).number(), + state.loanScale)); + + // The terms of this loan actually make the early payoff + // more expensive than just making payments + BEAST_EXPECT( + payoffAmount > + state.paymentRemaining * (state.periodicPayment + broker.asset(2).value())); + + singlePayment( + loanKeylet, + verifyLoanStatus, + state, + payoffAmount, + 1, + baseFlag, + tfLoanFullPayment); + }; + }; + + auto combineAllPayments = [&](std::uint32_t baseFlag) { + return + [&, baseFlag](Keylet const& loanKeylet, VerifyLoanStatus const& verifyLoanStatus) { + // toEndOfLife + // + + auto state = getCurrentState(env, broker, loanKeylet, verifyLoanStatus); + env.close(); + + BEAST_EXPECT( + STAmount(broker.asset, state.periodicPayment) == + broker.asset(Number(8333457002039338267, -17))); + + // Make all the payments in one transaction + // service fee is 2 + auto const startingPayments = state.paymentRemaining; + STAmount const payoffAmount = [&]() { + NumberRoundModeGuard const mg(Number::RoundingMode::Upward); + auto const rawPayoff = + startingPayments * (state.periodicPayment + broker.asset(2).value()); + STAmount payoffAmount{broker.asset, rawPayoff}; + BEAST_EXPECTS( + payoffAmount == broker.asset(Number(1024014840244721, -12)), + to_string(payoffAmount)); + BEAST_EXPECT(payoffAmount > state.principalOutstanding); + + payoffAmount = roundToScale(payoffAmount, state.loanScale); + + return payoffAmount; + }(); + + auto const totalPayoffValue = + state.totalValue + startingPayments * broker.asset(2).value(); + STAmount const totalPayoffAmount{broker.asset, totalPayoffValue}; + + BEAST_EXPECTS( + totalPayoffAmount == payoffAmount, + "Payoff amount: " + to_string(payoffAmount) + + ". Total Value: " + to_string(totalPayoffAmount)); + + singlePayment( + loanKeylet, + verifyLoanStatus, + state, + payoffAmount, + state.paymentRemaining, + baseFlag, + 0); + }; + }; + + // There are a lot of fields that can be set on a loan, but most + // of them only affect the "math" when a payment is made. The + // only one that really affects behavior is the + // `tfLoanOverpayment` flag. + lifecycle( + caseLabel, + "Loan overpayment allowed - Impair and Default", + env, + loanAmount, + interestExponent, + lender, + borrower, + evan, + broker, + pseudoAcct, + tfLoanOverpayment, + defaultImmediately(lsfLoanOverpayment)); + + lifecycle( + caseLabel, + "Loan overpayment prohibited - Impair and Default", + env, + loanAmount, + interestExponent, + lender, + borrower, + evan, + broker, + pseudoAcct, + 0, + defaultImmediately(0)); + + lifecycle( + caseLabel, + "Loan overpayment allowed - Default without Impair", + env, + loanAmount, + interestExponent, + lender, + borrower, + evan, + broker, + pseudoAcct, + tfLoanOverpayment, + defaultImmediately(lsfLoanOverpayment, false)); + + lifecycle( + caseLabel, + "Loan overpayment prohibited - Default without Impair", + env, + loanAmount, + interestExponent, + lender, + borrower, + evan, + broker, + pseudoAcct, + 0, + defaultImmediately(0, false)); + + lifecycle( + caseLabel, + "Loan overpayment prohibited - Pay off immediately", + env, + loanAmount, + interestExponent, + lender, + borrower, + evan, + broker, + pseudoAcct, + 0, + fullPayment(0)); + + lifecycle( + caseLabel, + "Loan overpayment allowed - Pay off immediately", + env, + loanAmount, + interestExponent, + lender, + borrower, + evan, + broker, + pseudoAcct, + tfLoanOverpayment, + fullPayment(lsfLoanOverpayment)); + + lifecycle( + caseLabel, + "Loan overpayment prohibited - Combine all payments", + env, + loanAmount, + interestExponent, + lender, + borrower, + evan, + broker, + pseudoAcct, + 0, + combineAllPayments(0)); + + lifecycle( + caseLabel, + "Loan overpayment allowed - Combine all payments", + env, + loanAmount, + interestExponent, + lender, + borrower, + evan, + broker, + pseudoAcct, + tfLoanOverpayment, + combineAllPayments(lsfLoanOverpayment)); + + lifecycle( + caseLabel, + "Loan overpayment prohibited - Make payments", + env, + loanAmount, + interestExponent, + lender, + borrower, + evan, + broker, + pseudoAcct, + 0, + [&](Keylet const& loanKeylet, VerifyLoanStatus const& verifyLoanStatus) { + // toEndOfLife + // + // Draw and make multiple payments + auto state = getCurrentState(env, broker, loanKeylet, verifyLoanStatus); + BEAST_EXPECT(state.flags == 0); + env.close(); + + verifyLoanStatus(state); + + env.close(state.startDate + 20s); + auto const loanAge = (env.now() - state.startDate).count(); + BEAST_EXPECT(loanAge == 30); + + // Periodic payment amount will consist of + // 1. principal outstanding (1000) + // 2. interest interest rate (at 12%) + // 3. payment interval (600s) + // 4. loan service fee (2) + // Calculate these values without the helper functions + // to verify they're working correctly The numbers in + // the below BEAST_EXPECTs may not hold across assets. + Number const interval = state.paymentInterval; + auto const periodicRate = interval * Number(12, -2) / kSecondsInYear; + BEAST_EXPECT( + periodicRate == Number(2283105022831050228, -24, Number::Normalized{})); + STAmount const roundedPeriodicPayment{ + broker.asset, + roundPeriodicPayment(broker.asset, state.periodicPayment, state.loanScale)}; + + testcase << currencyLabel << " Payment components: " + << "Payments remaining, rawInterest, rawPrincipal, " + "rawMFee, trackedValueDelta, trackedPrincipalDelta, " + "trackedInterestDelta, trackedMgmtFeeDelta, special"; + + auto const serviceFee = broker.asset(2); + + BEAST_EXPECT( + roundedPeriodicPayment == + roundToScale( + broker.asset( + Number(8333457002039338267, -17), Number::RoundingMode::Upward), + state.loanScale, + Number::RoundingMode::Upward)); + // 83334570.01162141 + // Include the service fee + STAmount const totalDue = roundToScale( + roundedPeriodicPayment + serviceFee, + state.loanScale, + Number::RoundingMode::Upward); + // Only check the first payment since the rounding + // may drift as payments are made + BEAST_EXPECT( + totalDue == + roundToScale( + broker.asset( + Number(8533457002039338267, -17), Number::RoundingMode::Upward), + state.loanScale, + Number::RoundingMode::Upward)); + + { + auto const raw = computeTheoreticalLoanState( + env.current()->rules(), + state.periodicPayment, + periodicRate, + state.paymentRemaining, + broker.params.managementFeeRate); + auto const rounded = constructLoanState( + state.totalValue, + state.principalOutstanding, + state.managementFeeOutstanding); + testcase << currencyLabel << " Loan starting state: " << state.paymentRemaining + << ", " << raw.interestDue << ", " << raw.principalOutstanding << ", " + << raw.managementFeeDue << ", " << rounded.valueOutstanding << ", " + << rounded.principalOutstanding << ", " << rounded.interestDue << ", " + << rounded.managementFeeDue; + } + + // Try to pay a little extra to show that it's _not_ + // taken + STAmount const transactionAmount = + STAmount{broker.asset, totalDue} + broker.asset(10); + // Only check the first payment since the rounding + // may drift as payments are made + BEAST_EXPECT( + transactionAmount == + roundToScale( + broker.asset(Number(9533457002039400, -14), Number::RoundingMode::Upward), + state.loanScale, + Number::RoundingMode::Upward)); + + auto const initialState = state; + xrpl::detail::PaymentComponents totalPaid{ + .trackedValueDelta = 0, + .trackedPrincipalDelta = 0, + .trackedManagementFeeDelta = 0}; + Number totalInterestPaid = 0; + std::size_t totalPaymentsMade = 0; + + xrpl::LoanState currentTrueState = computeTheoreticalLoanState( + env.current()->rules(), + state.periodicPayment, + periodicRate, + state.paymentRemaining, + broker.params.managementFeeRate); + + while (state.paymentRemaining > 0) + { + // Compute the expected principal amount + auto const paymentComponents = xrpl::detail::computePaymentComponents( + env.current()->rules(), + broker.asset.raw(), + state.loanScale, + state.totalValue, + state.principalOutstanding, + state.managementFeeOutstanding, + state.periodicPayment, + periodicRate, + state.paymentRemaining, + broker.params.managementFeeRate); + + BEAST_EXPECTS( + paymentComponents.specialCase == xrpl::detail::PaymentSpecialCase::Final || + paymentComponents.trackedValueDelta <= roundedPeriodicPayment, + "Delta: " + to_string(paymentComponents.trackedValueDelta) + + ", periodic payment: " + to_string(roundedPeriodicPayment)); + + xrpl::LoanState const nextTrueState = computeTheoreticalLoanState( + env.current()->rules(), + state.periodicPayment, + periodicRate, + state.paymentRemaining - 1, + broker.params.managementFeeRate); + xrpl::detail::LoanStateDeltas const deltas = currentTrueState - nextTrueState; + + testcase << currencyLabel << " Payment components: " << state.paymentRemaining + << ", " << deltas.interest << ", " << deltas.principal << ", " + << deltas.managementFee << ", " << paymentComponents.trackedValueDelta + << ", " << paymentComponents.trackedPrincipalDelta << ", " + << paymentComponents.trackedInterestPart() << ", " + << paymentComponents.trackedManagementFeeDelta << ", " + << [&]() -> char const* { + if (paymentComponents.specialCase == + ::xrpl::detail::PaymentSpecialCase::Final) + return "final"; + if (paymentComponents.specialCase == + ::xrpl::detail::PaymentSpecialCase::Extra) + return "extra"; + return "none"; + }(); + + auto const totalDueAmount = STAmount{ + broker.asset, paymentComponents.trackedValueDelta + serviceFee.number()}; + + // Due to the rounding algorithms to keep the interest and + // principal in sync with "true" values, the computed amount + // may be a little less than the rounded fixed payment + // amount. For integral types, the difference should be < 3 + // (1 unit for each of the interest and management fee). For + // IOUs, the difference should be after the 8th digit. + Number const diff = totalDue - totalDueAmount; + BEAST_EXPECT( + paymentComponents.specialCase == xrpl::detail::PaymentSpecialCase::Final || + diff == beast::kZero || + (diff > beast::kZero && + ((broker.asset.integral() && (static_cast(diff) < 3)) || + (state.loanScale - diff.exponent() > 13)))); + + BEAST_EXPECT( + paymentComponents.trackedValueDelta == + paymentComponents.trackedPrincipalDelta + + paymentComponents.trackedInterestPart() + + paymentComponents.trackedManagementFeeDelta); + BEAST_EXPECT( + paymentComponents.specialCase == xrpl::detail::PaymentSpecialCase::Final || + paymentComponents.trackedValueDelta <= roundedPeriodicPayment); + + BEAST_EXPECT( + state.paymentRemaining < 12 || + roundToAsset( + broker.asset, + deltas.principal, + state.loanScale, + Number::RoundingMode::Upward) == + roundToScale( + broker.asset( + Number(8333228691531218890, -17), Number::RoundingMode::Upward), + state.loanScale, + Number::RoundingMode::Upward)); + BEAST_EXPECT( + paymentComponents.trackedPrincipalDelta >= beast::kZero && + paymentComponents.trackedPrincipalDelta <= state.principalOutstanding); + BEAST_EXPECT( + paymentComponents.specialCase != xrpl::detail::PaymentSpecialCase::Final || + paymentComponents.trackedPrincipalDelta == state.principalOutstanding); + BEAST_EXPECT( + paymentComponents.specialCase == xrpl::detail::PaymentSpecialCase::Final || + (state.periodicPayment.exponent() - + (deltas.principal + deltas.interest + deltas.managementFee - + state.periodicPayment) + .exponent()) > 14); + + auto const borrowerBalanceBeforePayment = env.balance(borrower, broker.asset); + + if (canImpairLoan(env, broker, state)) + { + // Making a payment will unimpair the loan + env(manage(lender, loanKeylet.key, tfLoanImpair)); + } + + env.close(); + + // Make the payment + env(pay(borrower, loanKeylet.key, transactionAmount)); + + env.close(); + + // Need to account for fees if the loan is in XRP + PrettyAmount adjustment = broker.asset(0); + if (broker.asset.native()) + { + adjustment = env.current()->fees().base; + } + + // Check the result + verifyLoanStatus.checkPayment( + state.loanScale, + borrower, + borrowerBalanceBeforePayment, + totalDueAmount, + adjustment); + + --state.paymentRemaining; + state.previousPaymentDate = state.nextPaymentDate; + if (paymentComponents.specialCase == xrpl::detail::PaymentSpecialCase::Final) + { + state.paymentRemaining = 0; + state.nextPaymentDate = 0; + } + else + { + state.nextPaymentDate += state.paymentInterval; + } + state.principalOutstanding -= paymentComponents.trackedPrincipalDelta; + state.managementFeeOutstanding -= paymentComponents.trackedManagementFeeDelta; + state.totalValue -= paymentComponents.trackedValueDelta; + + verifyLoanStatus(state); + + totalPaid.trackedValueDelta += paymentComponents.trackedValueDelta; + totalPaid.trackedPrincipalDelta += paymentComponents.trackedPrincipalDelta; + totalPaid.trackedManagementFeeDelta += + paymentComponents.trackedManagementFeeDelta; + totalInterestPaid += paymentComponents.trackedInterestPart(); + ++totalPaymentsMade; + + currentTrueState = nextTrueState; + } + + // Loan is paid off + BEAST_EXPECT(state.paymentRemaining == 0); + BEAST_EXPECT(state.principalOutstanding == 0); + + // Make sure all the payments add up + BEAST_EXPECT(totalPaid.trackedValueDelta == initialState.totalValue); + BEAST_EXPECT(totalPaid.trackedPrincipalDelta == initialState.principalOutstanding); + BEAST_EXPECT( + totalPaid.trackedManagementFeeDelta == initialState.managementFeeOutstanding); + // This is almost a tautology given the previous checks, but + // check it anyway for completeness. + BEAST_EXPECT( + totalInterestPaid == + initialState.totalValue - + (initialState.principalOutstanding + + initialState.managementFeeOutstanding)); + BEAST_EXPECT(totalPaymentsMade == initialState.paymentRemaining); + + // Can't impair or default a paid off loan + env(manage(lender, loanKeylet.key, tfLoanImpair), Ter(tecNO_PERMISSION)); + env(manage(lender, loanKeylet.key, tfLoanDefault), Ter(tecNO_PERMISSION)); + }); + +#if LOAN_TODO + // TODO + + /* + LoanPay fails with tecINVARIANT_FAILED error when loan_broker(also + borrower) tries to do the payment. Here's the scenario: Create a XRP + loan with loan broker as borrower, loan origination fee and loan service + fee. Loan broker makes the first payment with periodic payment and loan + service fee. + */ + + auto time = [&](std::string label, std::function timed) { + if (!BEAST_EXPECT(timed)) + return; + + using clock_type = std::chrono::steady_clock; + using duration_type = std::chrono::milliseconds; + + auto const start = clock_type::now(); + timed(); + auto const duration = + std::chrono::duration_cast(clock_type::now() - start); + + log << label << " took " << duration.count() << "ms" << std::endl; + + return duration; + }; + + lifecycle( + caseLabel, + "timing", + env, + loanAmount, + interestExponent, + lender, + borrower, + evan, + broker, + pseudoAcct, + tfLoanOverpayment, + [&](Keylet const& loanKeylet, VerifyLoanStatus const& verifyLoanStatus) { + // Estimate optimal values for kLoanPaymentsPerFeeIncrement and + // kLoanMaximumPaymentsPerTransaction. + using namespace loan; + + auto const state = getCurrentState(env, broker, verifyLoanStatus.keylet); + auto const serviceFee = broker.asset(2).value(); + + STAmount const totalDue{ + broker.asset, + roundPeriodicPayment( + broker.asset, state.periodicPayment + serviceFee, state.loanScale)}; + + // Make a single payment + time("single payment", [&]() { env(pay(borrower, loanKeylet.key, totalDue)); }); + env.close(); + + // Make all but the final payment + auto const numPayments = (state.paymentRemaining - 2); + STAmount const bigPayment{broker.asset, totalDue * numPayments}; + XRPAmount const bigFee{baseFee * (numPayments / kLoanPaymentsPerFeeIncrement + 1)}; + time("ten payments", [&]() { + env(pay(borrower, loanKeylet.key, bigPayment), Fee(bigFee)); + }); + env.close(); + + time("final payment", [&]() { + // Make the final payment + env(pay(borrower, loanKeylet.key, totalDue + STAmount{broker.asset, 1})); + }); + env.close(); + }); + + lifecycle( + caseLabel, + "Loan overpayment allowed - Explicit overpayment", + env, + loanAmount, + interestExponent, + lender, + borrower, + evan, + broker, + pseudoAcct, + tfLoanOverpayment, + [&](Keylet const& loanKeylet, VerifyLoanStatus const& verifyLoanStatus) { throw 0; }); + + lifecycle( + caseLabel, + "Loan overpayment prohibited - Late payment", + env, + loanAmount, + interestExponent, + lender, + borrower, + evan, + broker, + pseudoAcct, + tfLoanOverpayment, + [&](Keylet const& loanKeylet, VerifyLoanStatus const& verifyLoanStatus) { throw 0; }); + + lifecycle( + caseLabel, + "Loan overpayment allowed - Late payment", + env, + loanAmount, + interestExponent, + lender, + borrower, + evan, + broker, + pseudoAcct, + tfLoanOverpayment, + [&](Keylet const& loanKeylet, VerifyLoanStatus const& verifyLoanStatus) { throw 0; }); + + lifecycle( + caseLabel, + "Loan overpayment allowed - Late payment and overpayment", + env, + loanAmount, + interestExponent, + lender, + borrower, + evan, + broker, + pseudoAcct, + tfLoanOverpayment, + [&](Keylet const& loanKeylet, VerifyLoanStatus const& verifyLoanStatus) { throw 0; }); + +#endif + } +}; + +} // namespace xrpl::test diff --git a/src/test/app/lending/LoanValidation_test.cpp b/src/test/app/lending/LoanValidation_test.cpp new file mode 100644 index 00000000000..d2985c4c30e --- /dev/null +++ b/src/test/app/lending/LoanValidation_test.cpp @@ -0,0 +1,558 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +namespace xrpl::test { + +class LoanValidation_test : public LoanTestBase +{ +private: + void + testDisabled() + { + testcase("Disabled"); + // Lending Protocol depends on Single Asset Vault (SAV). Test + // combinations of the two amendments. + // Single Asset Vault depends on MPTokensV1, but don't test every combo + // of that. + using namespace jtx; + auto failAll = [this](FeatureBitset features) { + Env env(*this, features); + + Account const alice{"alice"}; + Account const bob{"bob"}; + env.fund(XRP(10000), alice, bob); + + auto const keylet = keylet::loanBroker(alice, env.seq(alice)); + + using namespace std::chrono_literals; + using namespace loan; + + // counter party signature is optional on LoanSet. Confirm that by + // sending transaction without one. + auto setTx = env.jt(set(alice, keylet.key, Number(10000)), Ter(temDISABLED)); + env(setTx); + + // All loan transactions are disabled. + // 1. LoanSet + setTx = env.jt(setTx, Sig(sfCounterpartySignature, bob), Ter(temDISABLED)); + env(setTx); + // Actual sequence will be based off the loan broker, but we + // obviously don't have one of those if the amendment is disabled + auto const loanKeylet = keylet::loan(keylet.key, env.seq(alice)); + // Other Loan transactions are disabled, too. + // 2. LoanDelete + env(del(alice, loanKeylet.key), Ter(temDISABLED)); + // 3. LoanManage + env(manage(alice, loanKeylet.key, tfLoanImpair), Ter(temDISABLED)); + // 4. LoanPay + env(pay(alice, loanKeylet.key, XRP(500)), Ter(temDISABLED)); + }; + failAll(all_ - featureMPTokensV1); + failAll(all_ - featureSingleAssetVault - featureLendingProtocol); + failAll(all_ - featureSingleAssetVault); + failAll(all_ - featureLendingProtocol); + } + + void + testInvalidLoanSet() + { + testcase("Invalid LoanSet"); + using namespace jtx; + using namespace loan; + Account const lender{"lender"}; + Account const issuer{"issuer"}; + Account const borrower{"borrower"}; + Account const sponsor{"sponsor"}; + auto const iou = issuer["IOU"]; + + auto testWrapper = [&](auto&& test) { + Env env(*this); + env.fund(XRP(1'000), lender, issuer, borrower, sponsor); + env(trust(lender, iou(10'000'000))); + env(pay(issuer, lender, iou(5'000'000))); + BrokerInfo const brokerInfo{createVaultAndBroker(env, issuer["IOU"], lender)}; + + auto const loanSetFee = Fee(env.current()->fees().base * 2); + Number const debtMaximumRequest = brokerInfo.asset(1'000).value(); + test(env, brokerInfo, loanSetFee, debtMaximumRequest); + }; + + // preflight: + testWrapper([&](Env& env, + BrokerInfo const& brokerInfo, + jtx::Fee const& loanSetFee, + Number const& debtMaximumRequest) { + for (auto const sponsorFlags : {spfSponsorReserve, spfSponsorReserve | spfSponsorFee}) + { + env(set(borrower, brokerInfo.brokerID, debtMaximumRequest), + sponsor::As(sponsor, sponsorFlags), + Sig(sfCounterpartySignature, lender), + loanSetFee, + Ter(temINVALID_FLAG)); + } + + // first temBAD_SIGNER: TODO + // invalid grace period + { + // zero grace period + env(set(borrower, brokerInfo.brokerID, debtMaximumRequest), + Sig(sfCounterpartySignature, lender), + kGracePeriod(0), + loanSetFee, + Ter(temINVALID)); + + // grace period less than default minimum + env(set(borrower, brokerInfo.brokerID, debtMaximumRequest), + Sig(sfCounterpartySignature, lender), + kGracePeriod(LoanSet::kDefaultGracePeriod - 1), + loanSetFee, + Ter(temINVALID)); + + // grace period greater than payment interval + env(set(borrower, brokerInfo.brokerID, debtMaximumRequest), + Sig(sfCounterpartySignature, lender), + kPaymentInterval(120), + kGracePeriod(121), + loanSetFee, + Ter(temINVALID)); + } + // empty/zero broker ID + { + auto jv = set(borrower, uint256{}, debtMaximumRequest); + + auto testZeroBrokerID = [&](std::string const& id, std::uint32_t flags = 0) { + // empty broker ID + jv[sfLoanBrokerID] = id; + env(jv, + Sig(sfCounterpartySignature, lender), + loanSetFee, + Txflags(flags), + Ter(temINVALID)); + }; + // empty broker ID + testZeroBrokerID(std::string("")); + // zero broker ID + // needs a flag to distinguish the parsed STTx from the prior + // test + testZeroBrokerID(to_string(uint256{}), tfFullyCanonicalSig); + } + + // preflightCheckSigningKey() failure: + // can it happen? the signature is checked before transactor + // executes + + JTx const tx = env.jt( + set(borrower, brokerInfo.brokerID, debtMaximumRequest), + Sig(sfCounterpartySignature, lender), + loanSetFee); + STTx local = *(tx.stx); + auto counterpartySig = local.getFieldObject(sfCounterpartySignature); + auto badPubKey = counterpartySig.getFieldVL(sfSigningPubKey); + badPubKey[20] ^= 0xAA; + counterpartySig.setFieldVL(sfSigningPubKey, badPubKey); + local.setFieldObject(sfCounterpartySignature, counterpartySig); + json::Value jvResult; + jvResult[jss::tx_blob] = strHex(local.getSerializer().slice()); + auto res = env.rpc("json", "submit", to_string(jvResult))["result"]; + BEAST_EXPECT( + res[jss::error] == "invalidTransaction" && + res[jss::error_exception] == + "fails local checks: Counterparty: Invalid signature."); + }); + + // preclaim: + testWrapper([&](Env& env, + BrokerInfo const& brokerInfo, + jtx::Fee const& loanSetFee, + Number const& debtMaximumRequest) { + // canAddHoldingFailure (IOU only, if MPT doesn't have + // MPTCanTransfer set, then can't create Vault/LoanBroker, + // and LoanSet will fail with different error + env(fclear(issuer, asfDefaultRipple)); + env.close(); + env(set(borrower, brokerInfo.brokerID, debtMaximumRequest), + Sig(sfCounterpartySignature, lender), + loanSetFee, + Ter(terNO_RIPPLE)); + }); + + // doApply: + testWrapper([&](Env& env, + BrokerInfo const& brokerInfo, + jtx::Fee const& loanSetFee, + Number const& debtMaximumRequest) { + auto const amt = + env.balance(borrower) - accountReserve(*env.current(), borrower.id(), env.journal); + env(pay(borrower, issuer, amt)); + + // tecINSUFFICIENT_RESERVE + env(set(borrower, brokerInfo.brokerID, debtMaximumRequest), + Sig(sfCounterpartySignature, lender), + loanSetFee, + Ter(tecINSUFFICIENT_RESERVE)); + + // addEmptyHolding failure + env(pay(issuer, borrower, amt)); + env(fset(issuer, asfGlobalFreeze)); + env.close(); + + env(set(borrower, brokerInfo.brokerID, debtMaximumRequest), + Sig(sfCounterpartySignature, lender), + loanSetFee, + Ter(tecFROZEN)); + }); + } + + void + testInvalidLoanDelete() + { + testcase("Invalid LoanDelete"); + using namespace jtx; + using namespace loan; + + // preflight: temINVALID, LoanID == zero + { + Account const alice{"alice"}; + Env env(*this); + env.fund(XRP(1'000), alice); + env.close(); + env(del(alice, beast::kZero), Ter(temINVALID)); + } + } + + void + testInvalidLoanManage() + { + testcase("Invalid LoanManage"); + using namespace jtx; + using namespace loan; + + // preflight: temINVALID, LoanID == zero + { + Account const alice{"alice"}; + Env env(*this); + env.fund(XRP(1'000), alice); + env.close(); + env(manage(alice, beast::kZero, tfLoanDefault), Ter(temINVALID)); + } + } + + void + testInvalidLoanPay() + { + testcase("Invalid LoanPay"); + using namespace jtx; + using namespace loan; + Account const lender{"lender"}; + Account const issuer{"issuer"}; + Account const borrower{"borrower"}; + auto const iou = issuer["IOU"]; + + // preclaim + Env env(*this); + env.fund(XRP(1'000), lender, issuer, borrower); + env(trust(lender, iou(10'000'000))); + env(pay(issuer, lender, iou(5'000'000))); + BrokerInfo brokerInfo{createVaultAndBroker(env, issuer["IOU"], lender)}; + + auto const loanSetFee = Fee(env.current()->fees().base * 2); + STAmount const debtMaximumRequest = brokerInfo.asset(1'000).value(); + + env(set(borrower, brokerInfo.brokerID, debtMaximumRequest), + Sig(sfCounterpartySignature, lender), + loanSetFee); + + env.close(); + + std::uint32_t const loanSequence = 1; + auto const loanKeylet = keylet::loan(brokerInfo.brokerID, loanSequence); + + env(fset(issuer, asfGlobalFreeze)); + env.close(); + + // preclaim: tecFROZEN + env(pay(borrower, loanKeylet.key, debtMaximumRequest), Ter(tecFROZEN)); + env.close(); + + env(fclear(issuer, asfGlobalFreeze)); + env.close(); + + auto const pseudoBroker = [&]() -> std::optional { + if (auto brokerSle = env.le(keylet::loanBroker(brokerInfo.brokerID)); + BEAST_EXPECT(brokerSle)) + { + return Account{"pseudo", brokerSle->at(sfAccount)}; + } + + return std::nullopt; + }(); + if (!pseudoBroker) + return; + + // Lender and pseudoaccount must both be frozen + env(trust(issuer, lender["IOU"](1'000), lender, tfSetFreeze | tfSetDeepFreeze)); + env(trust( + issuer, (*pseudoBroker)["IOU"](1'000), *pseudoBroker, tfSetFreeze | tfSetDeepFreeze)); + env.close(); + + // preclaim: tecFROZEN due to deep frozen + env(pay(borrower, loanKeylet.key, debtMaximumRequest), Ter(tecFROZEN)); + env.close(); + + // Only one needs to be unfrozen + env(trust(issuer, lender["IOU"](1'000), tfClearFreeze | tfClearDeepFreeze)); + env.close(); + + // The payment is late by this point + env(pay(borrower, loanKeylet.key, debtMaximumRequest), Ter(tecEXPIRED)); + env.close(); + env(pay(borrower, loanKeylet.key, debtMaximumRequest, tfLoanLatePayment)); + env.close(); + + // preclaim: tecKILLED + // note that tecKILLED in loanMakePayment() + // doesn't happen because of the preclaim check. + env(pay(borrower, loanKeylet.key, debtMaximumRequest), Ter(tecKILLED)); + } + + void + testRequireAuth() + { + testcase("Require Auth - Implicit Pseudo-account authorization"); + using namespace jtx; + using namespace loan; + Account const lender{"lender"}; + Account const issuer{"issuer"}; + Account const borrower{"borrower"}; + Env env(*this); + + env.fund(XRP(100'000), issuer, lender, borrower); + env.close(); + + auto asset = MPTTester({ + .env = env, + .issuer = issuer, + .holders = {lender, borrower}, + .flags = kMptDexFlags | tfMPTRequireAuth | tfMPTCanClawback | tfMPTCanLock, + .authHolder = true, + }); + + env(pay(issuer, lender, asset(5'000'000))); + BrokerInfo brokerInfo{createVaultAndBroker(env, asset, lender)}; + + auto const loanSetFee = Fee(env.current()->fees().base * 2); + STAmount const debtMaximumRequest = brokerInfo.asset(1'000).value(); + + auto forUnauthAuth = [&](auto&& doTx) { + for (auto const flag : {tfMPTUnauthorize, 0u}) + { + asset.authorize({.account = issuer, .holder = borrower, .flags = flag}); + env.close(); + doTx(flag == 0); + env.close(); + } + }; + + // Can't create a loan if the borrower is not authorized + forUnauthAuth([&](bool authorized) { + auto const err = !authorized ? Ter(tecNO_AUTH) : Ter(tesSUCCESS); + env(set(borrower, brokerInfo.brokerID, debtMaximumRequest), + Sig(sfCounterpartySignature, lender), + loanSetFee, + err); + }); + + static constexpr std::uint32_t kLoanSequence = 1; + auto const loanKeylet = keylet::loan(brokerInfo.brokerID, kLoanSequence); + + // Can't loan pay if the borrower is not authorized + forUnauthAuth([&](bool authorized) { + auto const err = !authorized ? Ter(tecNO_AUTH) : Ter(tesSUCCESS); + env(pay(borrower, loanKeylet.key, debtMaximumRequest), err); + }); + } + + void + testLimitExceeded() + { + testcase("RIPD-4125 - overpayment"); + + using namespace jtx; + + Account const issuer("issuer"); + Account const lender("lender"); + Account const borrower("borrower"); + + BrokerParameters const brokerParams{ + .vaultDeposit = 100'000, + .debtMax = 0, + .coverRateMin = TenthBips32{0}, + .managementFeeRate = TenthBips16{0}, + .coverRateLiquidation = TenthBips32{0}}; + LoanParameters const loanParams{ + .account = lender, + .counter = borrower, + .principalRequest = Number{200000, -6}, + .interest = TenthBips32{50000}, + .payTotal = 3, + .payInterval = 200, + .gracePd = 60, + .flags = tfLoanOverpayment, + }; + + auto const assetType = AssetType::XRP; + + Env env(*this, makeConfig(), all_, nullptr, beast::Severity::Warning); + + auto loanResult = + createLoan(env, assetType, brokerParams, loanParams, issuer, lender, borrower); + + if (BEAST_EXPECT(loanResult); !loanResult.has_value()) + return; + + auto broker = std::get(*loanResult); + auto loanKeylet = std::get(*loanResult); + auto pseudoAcct = std::get(*loanResult); + + VerifyLoanStatus const verifyLoanStatus(env, broker, pseudoAcct, loanKeylet); + + auto const state = getCurrentState(env, broker, loanKeylet); + + env(loan::pay( + borrower, + loanKeylet.key, + STAmount{broker.asset, state.periodicPayment * 3 / 2 + 1}, + tfLoanOverpayment)); + env.close(); + + PaymentParameters const paymentParams{ + .showStepBalances = false, + .validateBalances = true, + }; + + makeLoanPayments( + env, + broker, + loanParams, + loanKeylet, + verifyLoanStatus, + issuer, + lender, + borrower, + paymentParams); + } + + void + testWrongMaxDebtBehavior(FeatureBitset features) + { + // From FIND-003 + testcase << "Wrong Max Debt Behavior"; + + using namespace jtx; + using namespace std::chrono_literals; + Env env(*this, features); + + Account const issuer{"issuer"}; + Account const lender{"lender"}; + + BrokerParameters const brokerParams{.debtMax = 0}; + env.fund(XRP(brokerParams.vaultDeposit * 100), issuer, noripple(lender)); + env.close(); + + PrettyAsset const xrpAsset{xrpIssue(), 1'000'000}; + + BrokerInfo const broker{createVaultAndBroker(env, xrpAsset, lender, brokerParams)}; + + if (auto const brokerSle = env.le(keylet::loanBroker(broker.brokerID)); + BEAST_EXPECT(brokerSle)) + { + BEAST_EXPECT(brokerSle->at(sfDebtMaximum) == 0); + } + + using namespace loan; + + auto const loanSetFee = Fee(env.current()->fees().base * 2); + Number const principalRequest{1, 3}; + + auto createJson = env.json(set(lender, broker.brokerID, principalRequest), Fee(loanSetFee)); + + json::Value counterpartyJson{json::ValueType::Object}; + counterpartyJson[sfTxnSignature] = createJson[sfTxnSignature]; + counterpartyJson[sfSigningPubKey] = createJson[sfSigningPubKey]; + if (!BEAST_EXPECT(!createJson.isMember(jss::Signers))) + counterpartyJson[sfSigners] = createJson[sfSigners]; + + createJson = env.json(createJson, Json(sfCounterpartySignature, counterpartyJson)); + env(createJson); + + env.close(); + } + + void + runAmendmentIndependent() + { + testDisabled(); + testInvalidLoanSet(); + testInvalidLoanDelete(); + testInvalidLoanManage(); + testInvalidLoanPay(); + testRequireAuth(); + testLimitExceeded(); + } + + // Tests run under each entry in amendmentCombinations(). + void + runAmendmentSensitive(FeatureBitset features) + { + testWrongMaxDebtBehavior(features); + } + +public: + void + run() override + { + runAmendmentIndependent(); + for (auto const& features : jtx::amendmentCombinations( + {fixCleanup3_1_3, fixCleanup3_2_0, featureMPTokensV2}, all_)) + runAmendmentSensitive(features); + } +}; + +BEAST_DEFINE_TESTSUITE(LoanValidation, tx, xrpl); + +} // namespace xrpl::test diff --git a/src/test/app/lending/Loan_test.cpp b/src/test/app/lending/Loan_test.cpp new file mode 100644 index 00000000000..717387665e3 --- /dev/null +++ b/src/test/app/lending/Loan_test.cpp @@ -0,0 +1,46 @@ +#include +#include + +#include +#include +#include + +namespace xrpl::test { + +/** + * Aggregator: running this suite ("Loan") reruns every topical Loan/Lending + * suite in one invocation. Each member suite below remains independently + * runnable under its own name. Declared manual so an unfiltered full test + * run doesn't execute every case twice. + */ +class Loan_test : public beast::unit_test::Suite +{ + void + run() override + { + static constexpr std::array kMembers{ + "LendingHelpers", + "LoanBroker", + "LoanCashBasis", + "LoanCoverFreezeAuth", + "LoanInvariants", + "LoanLifecycle", + "LoanMisc", + "LoanPay", + "LoanRounding", + "LoanSecurity", + "LoanSet", + "LoanValidation", + }; + + for (auto const& info : beast::unit_test::globalSuites()) + { + if (std::ranges::find(kMembers, info.name()) != kMembers.end()) + info.run(runner()); + } + } +}; + +BEAST_DEFINE_TESTSUITE_MANUAL(Loan, tx, xrpl); + +} // namespace xrpl::test