diff --git a/include/xrpl/ledger/helpers/VaultHelpers.h b/include/xrpl/ledger/helpers/VaultHelpers.h index f3d1d02061..3fc72e8675 100644 --- a/include/xrpl/ledger/helpers/VaultHelpers.h +++ b/include/xrpl/ledger/helpers/VaultHelpers.h @@ -59,6 +59,15 @@ roundToPosteriorVaultScale( STAmount const& amount, Number::RoundingMode roundingMode); +/** + * Round an amount at the posterior live exponent of AssetsAvailable. + */ +[[nodiscard]] STAmount +roundToPosteriorAvailableScale( + SLE::const_ref vault, + STAmount const& amount, + Number::RoundingMode roundingMode); + /** * Open-zone capacity ceiling: 9 * 10^(15 + baseScale). * diff --git a/src/libxrpl/ledger/helpers/VaultHelpers.cpp b/src/libxrpl/ledger/helpers/VaultHelpers.cpp index 6a2838b93f..8e231385b5 100644 --- a/src/libxrpl/ledger/helpers/VaultHelpers.cpp +++ b/src/libxrpl/ledger/helpers/VaultHelpers.cpp @@ -45,6 +45,28 @@ liveScale(Number const& reference, Asset const& asset, int baseScale) return std::max(baseScale, scale(reference, asset)); } +[[nodiscard]] int +posteriorScale(SLE::const_ref vault, Number const& reference, STAmount const& delta) +{ + Number const posterior = [&] { + NumberRoundModeGuard const rg(Number::RoundingMode::ToNearest); + return reference + delta; + }(); + + switch (getVaultVersion(vault)) + { + case VaultVersion::Legacy: + case VaultVersion::CashBasis: + return scale(posterior, vault->at(sfAsset)); + case VaultVersion::FixedPrecision: + return liveScale(posterior, vault->at(sfAsset), fixedBaseScale(vault)); + } + // LCOV_EXCL_START + UNREACHABLE("xrpl::posteriorScale : valid VaultVersion"); + return Number::kMinExponent - 1; + // LCOV_EXCL_STOP +} + [[nodiscard]] VaultKind decodeVaultKind(std::optional vaultKind) { @@ -101,24 +123,7 @@ getPosteriorVaultScale(SLE::const_ref vault, STAmount const& delta) XRPL_ASSERT( delta.asset() == vault->at(sfAsset), "xrpl::getPosteriorVaultScale : delta and Vault asset match"); - - Number const posterior = [&] { - NumberRoundModeGuard const rg(Number::RoundingMode::ToNearest); - return vault->at(sfAssetsTotal) + delta; - }(); - - switch (getVaultVersion(vault)) - { - case VaultVersion::Legacy: - case VaultVersion::CashBasis: - return scale(posterior, vault->at(sfAsset)); - case VaultVersion::FixedPrecision: - return liveScale(posterior, vault->at(sfAsset), fixedBaseScale(vault)); - } - // LCOV_EXCL_START - UNREACHABLE("xrpl::getPosteriorVaultScale : valid VaultVersion"); - return Number::kMinExponent - 1; - // LCOV_EXCL_STOP + return posteriorScale(vault, vault->at(sfAssetsTotal), delta); } [[nodiscard]] STAmount @@ -149,6 +154,24 @@ roundToPosteriorVaultScale( return roundToScale(amount, getPosteriorVaultScale(vault, amount), roundingMode); } +[[nodiscard]] STAmount +roundToPosteriorAvailableScale( + SLE::const_ref vault, + STAmount const& amount, + Number::RoundingMode roundingMode) +{ + XRPL_ASSERT( + vault && vault->getType() == ltVAULT, + "xrpl::roundToPosteriorAvailableScale : valid Vault sle"); + XRPL_ASSERT( + amount.asset() == vault->at(sfAsset), + "xrpl::roundToPosteriorAvailableScale : amount and Vault asset match"); + if (amount.integral()) + return amount; + return roundToScale( + amount, posteriorScale(vault, vault->at(sfAssetsAvailable), amount), roundingMode); +} + [[nodiscard]] Number getVaultOpenLimit(SLE::const_ref vault) { diff --git a/src/libxrpl/tx/transactors/lending/LoanPay.cpp b/src/libxrpl/tx/transactors/lending/LoanPay.cpp index 1c77c86cef..ee3da77fe2 100644 --- a/src/libxrpl/tx/transactors/lending/LoanPay.cpp +++ b/src/libxrpl/tx/transactors/lending/LoanPay.cpp @@ -361,6 +361,7 @@ LoanPay::doApply() TenthBips32 const coverRateMinimum{brokerSle->at(sfCoverRateMinimum)}; auto debtTotalProxy = brokerSle->at(sfDebtTotal); + bool const fixedPrecision = getVaultVersion(vaultSle) == VaultVersion::FixedPrecision; auto const vaultScale = getAssetsTotalScale(vaultSle); // Send the broker fee to the owner if they have sufficient cover available, @@ -376,8 +377,7 @@ LoanPay::doApply() // DebtTotal) use vaultScale. The legacy path below intentionally retains // its pre-amendment loanScale behavior. auto const minCover = [&]() { - if (view.rules().enabled(fixCleanup3_2_0) || - getVaultVersion(vaultSle) == VaultVersion::FixedPrecision) + if (view.rules().enabled(fixCleanup3_2_0) || fixedPrecision) { return minimumBrokerCover(debtTotalProxy.value(), coverRateMinimum, vaultSle); } @@ -421,6 +421,12 @@ LoanPay::doApply() } } + auto const scheduledInterest = [&loanSle] { + return loanSle->at(sfTotalValueOutstanding) - loanSle->at(sfPrincipalOutstanding) - + loanSle->at(sfManagementFeeOutstanding); + }; + Number const scheduledInterestBefore = fixedPrecision ? scheduledInterest() : kNumZero; + LoanPaymentType const paymentType = [&tx]() { // preflight already checked that at most one flag is set. if (tx.isFlag(tfLoanLatePayment)) @@ -446,6 +452,9 @@ LoanPay::doApply() // has been modified. view.update(loanSle); + Number const scheduledInterestDelta = + fixedPrecision ? scheduledInterest() - scheduledInterestBefore : kNumZero; + XRPL_ASSERT_PARTS( // It is possible to pay 0 principal paymentParts->principalPaid >= 0, @@ -472,32 +481,62 @@ LoanPay::doApply() // LCOV_EXCL_STOP } - auto const [assetsTotalDelta, debtTotalDelta] = loanPaymentDeltas(vaultSle, *paymentParts); - - JLOG(j_.debug()) << "Loan Pay: principal paid: " << paymentParts->principalPaid - << ", interest paid: " << paymentParts->interestPaid - << ", fee paid: " << paymentParts->feePaid - << ", assets total delta: " << assetsTotalDelta - << ", debt total delta: " << debtTotalDelta; - //------------------------------------------------------ // LoanBroker object state changes view.update(brokerSle); auto assetsAvailableProxy = vaultSle->at(sfAssetsAvailable); auto assetsTotalProxy = vaultSle->at(sfAssetsTotal); + Number const assetsAvailableBefore = *assetsAvailableProxy; + Number const assetsTotalBefore = *assetsTotalProxy; auto const totalPaidToVaultRaw = paymentParts->principalPaid + paymentParts->interestPaid; - auto const totalPaidToVaultRounded = - roundToAsset(asset, totalPaidToVaultRaw, vaultScale, Number::RoundingMode::Downward); + auto const [assetsTotalDelta, debtTotalDelta] = [&] { + if (!fixedPrecision) + return loanPaymentDeltas(vaultSle, *paymentParts); + + Number const assetsTotalAfter = [&] { + NumberRoundModeGuard const rg(Number::RoundingMode::Downward); + // The STAmount conversion also clamps integral assets. + return Number{STAmount{asset, assetsTotalBefore + paymentParts->interestPaid}}; + }(); + return AccountingDeltas{ + .assetsTotalDelta = assetsTotalAfter - assetsTotalBefore, + .debtTotalDelta = paymentParts->principalPaid}; + }(); + Number const totalPaidToVaultRounded = [&] { + if (!fixedPrecision) + { + return roundToAsset( + asset, totalPaidToVaultRaw, vaultScale, Number::RoundingMode::Downward); + } + + Number const creditRaw = paymentParts->principalPaid + assetsTotalDelta; + return Number{roundToPosteriorAvailableScale( + vaultSle, STAmount{asset, creditRaw}, Number::RoundingMode::Downward)}; + }(); XRPL_ASSERT_PARTS( !asset.integral() || totalPaidToVaultRaw == totalPaidToVaultRounded, "xrpl::LoanPay::doApply", "rounding does nothing for integral asset"); - auto const totalPaidToBroker = paymentParts->feePaid; + Number const totalPaidToBrokerRaw = paymentParts->feePaid; + Number const totalPaidToBroker = fixedPrecision && !sendBrokerFeeToOwner + ? Number{roundToPosteriorBrokerCoverScale( + vaultSle, + brokerSle, + STAmount{asset, totalPaidToBrokerRaw}, + Number::RoundingMode::TowardsZero)} + : totalPaidToBrokerRaw; + + JLOG(j_.debug()) << "Loan Pay: principal paid: " << paymentParts->principalPaid + << ", interest paid: " << paymentParts->interestPaid + << ", fee paid: " << paymentParts->feePaid + << ", assets total delta: " << assetsTotalDelta + << ", debt total delta: " << debtTotalDelta + << ", scheduled interest delta: " << scheduledInterestDelta; XRPL_ASSERT_PARTS( - (totalPaidToVaultRaw + totalPaidToBroker) == + (totalPaidToVaultRaw + totalPaidToBrokerRaw) == (paymentParts->principalPaid + paymentParts->interestPaid + paymentParts->feePaid), "xrpl::LoanPay::doApply", "payments add up"); @@ -509,17 +548,22 @@ LoanPay::doApply() isRounded(asset, debtTotalDelta, loanScale), "xrpl::LoanPay::doApply", "debtTotalDelta rounding good"); - // Despite our best efforts, it's possible for rounding errors to accumulate - // in the loan broker's debt total. This is because the broker may have more - // than one loan with significantly different scales. - adjustImpreciseNumber(debtTotalProxy, -debtTotalDelta, asset, vaultScale); + if (fixedPrecision) + { + debtTotalProxy -= debtTotalDelta; + } + else + { + // Despite our best efforts, it's possible for rounding errors to accumulate + // in the loan broker's debt total. This is because the broker may have more + // than one loan with significantly different scales. + adjustImpreciseNumber(debtTotalProxy, -debtTotalDelta, asset, vaultScale); + } //------------------------------------------------------ // Vault object state changes view.update(vaultSle); - Number const assetsAvailableBefore = *assetsAvailableProxy; - Number const assetsTotalBefore = *assetsTotalProxy; #if !NDEBUG { Number const pseudoAccountBalanceBefore = accountHolds( @@ -539,6 +583,13 @@ LoanPay::doApply() assetsAvailableProxy += totalPaidToVaultRounded; assetsTotalProxy += assetsTotalDelta; + if (fixedPrecision) + { + auto yieldUnrealizedProxy = vaultSle->at(sfYieldUnrealized); + yieldUnrealizedProxy += scheduledInterestDelta; + if (*yieldUnrealizedProxy < beast::kZero) + yieldUnrealizedProxy = kNumZero; + } XRPL_ASSERT_PARTS( *assetsAvailableProxy <= *assetsTotalProxy, @@ -559,9 +610,10 @@ LoanPay::doApply() if (!sendBrokerFeeToOwner) { // If there is not enough first-loss capital, add the fee to First Loss - // Cover Pool. Note that this moves the entire fee - it does not attempt - // to split it. The broker can Withdraw it later if they want, or leave - // it for future needs. + // Cover Pool. FixedPrecision rounds the redirected fee at the + // posterior cover scale; any sub-unit remainder is forgiven. The + // broker can Withdraw the credited amount later or leave it for future + // needs. coverAvailableProxy += totalPaidToBroker; } diff --git a/src/test/app/lending/LoanPay_test.cpp b/src/test/app/lending/LoanPay_test.cpp index 7cd31b7988..c3660e523a 100644 --- a/src/test/app/lending/LoanPay_test.cpp +++ b/src/test/app/lending/LoanPay_test.cpp @@ -12,6 +12,7 @@ #include #include +#include #include #include #include @@ -19,6 +20,7 @@ #include #include #include +#include #include #include #include @@ -34,11 +36,14 @@ #include #include +#include #include #include #include +#include #include #include +#include namespace xrpl::test { @@ -1483,6 +1488,761 @@ private: run(all_, tesSUCCESS); } + void + testFixedPrecisionScheduledPayment() + { + testcase("FixedPrecision scheduled LoanPay accounting"); + + using namespace jtx; + using namespace loan; + + FeatureBitset const features{ + all_ | featureLendingProtocolV1_1 | featureLendingProtocolV1_2}; + 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(); + + PrettyAsset const asset = issuer["USD"]; + env(trust(lender, asset(10'000'000))); + env(trust(borrower, asset(10'000'000))); + env(pay(issuer, lender, asset(2'000'000))); + env.close(); + + BrokerParameters brokerParams; + brokerParams.vaultScale = 6; + brokerParams.managementFeeRate = TenthBips16{0}; + auto const broker = createVaultAndBroker(env, asset, lender, brokerParams); + auto const loanKeylet = nextLoanKeylet(env, broker); + + env(set(borrower, broker.brokerID, asset(1'000).value()), + Sig(sfCounterpartySignature, lender), + kInterestRate(percentageToTenthBips(12)), + kPaymentTotal(2), + kPaymentInterval(24 * 60 * 60), + Fee(env.current()->fees().base * 2)); + env.close(); + + auto const state = getCurrentState(env, broker, loanKeylet); + STAmount const payment{ + asset, roundPeriodicPayment(asset, state.periodicPayment, state.loanScale)}; + + auto const vaultBefore = env.le(broker.vaultKeylet()); + auto const brokerBefore = env.le(broker.brokerKeylet()); + auto const loanBefore = env.le(loanKeylet); + if (!BEAST_EXPECT(vaultBefore && brokerBefore && loanBefore)) + return; + + Number const assetsAvailableBefore = vaultBefore->at(sfAssetsAvailable); + Number const assetsTotalBefore = vaultBefore->at(sfAssetsTotal); + Number const yieldBefore = vaultBefore->at(sfYieldUnrealized); + Number const debtBefore = brokerBefore->at(sfDebtTotal); + Number const principalBefore = loanBefore->at(sfPrincipalOutstanding); + Number const scheduledInterestBefore = loanBefore->at(sfTotalValueOutstanding) - + principalBefore - loanBefore->at(sfManagementFeeOutstanding); + + env(pay(borrower, loanKeylet.key, payment)); + env.close(); + + auto const vaultAfter = env.le(broker.vaultKeylet()); + auto const brokerAfter = env.le(broker.brokerKeylet()); + auto const loanAfter = env.le(loanKeylet); + if (!BEAST_EXPECT(vaultAfter && brokerAfter && loanAfter)) + return; + + Number const principalAfter = loanAfter->at(sfPrincipalOutstanding); + Number const principalPaid = principalBefore - principalAfter; + Number const credit = vaultAfter->at(sfAssetsAvailable) - assetsAvailableBefore; + Number const scheduledInterestAfter = loanAfter->at(sfTotalValueOutstanding) - + principalAfter - loanAfter->at(sfManagementFeeOutstanding); + Number const interestPaid = scheduledInterestBefore - scheduledInterestAfter; + Number const expectedAssetsTotal = [&] { + NumberRoundModeGuard const rg(Number::RoundingMode::Downward); + return Number{STAmount{asset, assetsTotalBefore + interestPaid}}; + }(); + Number const interestActual = expectedAssetsTotal - assetsTotalBefore; + + BEAST_EXPECT(brokerAfter->at(sfDebtTotal) == debtBefore - principalPaid); + BEAST_EXPECTS( + vaultAfter->at(sfAssetsTotal) == expectedAssetsTotal, + "AssetsTotal expected " + to_string(expectedAssetsTotal) + ", got " + + to_string(vaultAfter->at(sfAssetsTotal))); + BEAST_EXPECT(credit == principalPaid + interestActual); + BEAST_EXPECT( + vaultAfter->at(sfYieldUnrealized) == + yieldBefore + scheduledInterestAfter - scheduledInterestBefore); + BEAST_EXPECTS( + vaultAfter->at(sfAssetsTotal) + vaultAfter->at(sfYieldUnrealized) == + assetsTotalBefore + yieldBefore, + "capacity before " + to_string(assetsTotalBefore + yieldBefore) + ", after " + + to_string(vaultAfter->at(sfAssetsTotal) + vaultAfter->at(sfYieldUnrealized))); + } + + void + testFixedPrecisionRedirectedFeeRounding() + { + testcase("FixedPrecision redirected fee rounds at posterior cover scale"); + + using namespace jtx; + using namespace loan; + + FeatureBitset const features{ + all_ | featureLendingProtocolV1_1 | featureLendingProtocolV1_2}; + 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(); + + PrettyAsset const asset = issuer["USD"]; + Number const trustLimit{2, 10}; + env(trust(lender, asset(trustLimit))); + env(trust(borrower, asset(trustLimit))); + env(pay(issuer, lender, asset(Number{1, 10}))); + env(pay(issuer, borrower, asset(Number{2, 9}))); + env.close(); + + BrokerParameters brokerParams; + brokerParams.vaultScale = 6; + brokerParams.coverDeposit = 0; + brokerParams.managementFeeRate = TenthBips16{0}; + auto const broker = createVaultAndBroker(env, asset, lender, brokerParams); + + // Optional cover can fill the Open zone at P=6. The first redirected + // fee below is mandatory growth and takes CoverAvailable into the + // coarsened state. + env(loan_broker::coverDeposit(lender, broker.brokerID, asset(Number{9, 9}))); + env.close(); + + auto const makeLoan = [&](Number const& serviceFee) { + auto const loanKeylet = nextLoanKeylet(env, broker); + env(set(borrower, broker.brokerID, asset(1'000).value()), + Sig(sfCounterpartySignature, lender), + kLoanServiceFee(serviceFee), + kPaymentTotal(2), + kPaymentInterval(24 * 60 * 60), + Fee(env.current()->fees().base * 2)); + env.close(); + return loanKeylet; + }; + auto const payOnce = [&](Keylet const& loanKeylet, Number const& serviceFee) { + auto const state = getCurrentState(env, broker, loanKeylet); + STAmount const payment{ + asset, + roundPeriodicPayment(asset, state.periodicPayment + serviceFee, state.loanScale)}; + env(pay(borrower, loanKeylet.key, payment)); + env.close(); + }; + + Number const coarseningFee{1, 9}; + auto const firstLoan = makeLoan(coarseningFee); + Number const roundedFeeRaw{17, -6}; + auto const roundedFeeLoan = makeLoan(roundedFeeRaw); + Number const dustFee{1, -6}; + auto const secondLoan = makeLoan(dustFee); + + // A deep-frozen owner cannot receive broker fees, so LoanPay redirects + // them to the broker pseudo-account and CoverAvailable. Originate both + // loans first because the freeze also blocks LoanSet. + env(trust(issuer, asset(0), lender, tfSetFreeze | tfSetDeepFreeze)); + env.close(); + + payOnce(firstLoan, coarseningFee); + auto const brokerCoarsened = env.le(broker.brokerKeylet()); + if (!BEAST_EXPECT(brokerCoarsened)) + return; + Number const coverBeforeDust = brokerCoarsened->at(sfCoverAvailable); + Number const expectedCoarsenedCover{1, 10}; + BEAST_EXPECT(coverBeforeDust == expectedCoarsenedCover); + + payOnce(roundedFeeLoan, roundedFeeRaw); + auto const brokerAfterRoundedFee = env.le(broker.brokerKeylet()); + if (!BEAST_EXPECT(brokerAfterRoundedFee)) + return; + Number const roundedFee = brokerAfterRoundedFee->at(sfCoverAvailable) - coverBeforeDust; + BEAST_EXPECT(roundedFee > beast::kZero); + BEAST_EXPECT(roundedFee < roundedFeeRaw); + + auto const vaultBeforeDust = env.le(broker.vaultKeylet()); + if (!BEAST_EXPECT(vaultBeforeDust)) + return; + Number const availableBeforeDust = vaultBeforeDust->at(sfAssetsAvailable); + Number const borrowerBeforeDust = env.balance(borrower, asset).number(); + + payOnce(secondLoan, dustFee); + + auto const brokerAfterDust = env.le(broker.brokerKeylet()); + auto const vaultAfterDust = env.le(broker.vaultKeylet()); + if (!BEAST_EXPECT(brokerAfterDust && vaultAfterDust)) + return; + BEAST_EXPECT( + brokerAfterDust->at(sfCoverAvailable) == brokerAfterRoundedFee->at(sfCoverAvailable)); + Number const vaultCredit = vaultAfterDust->at(sfAssetsAvailable) - availableBeforeDust; + BEAST_EXPECT(borrowerBeforeDust - env.balance(borrower, asset).number() == vaultCredit); + } + + void + testFixedPrecisionCoarsenedPayments() + { + testcase("FixedPrecision coarsened LoanPay transfers principal in full"); + + using namespace jtx; + using namespace loan; + + FeatureBitset const features{ + all_ | featureLendingProtocolV1_1 | featureLendingProtocolV1_2}; + 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(); + + PrettyAsset const asset = issuer["USD"]; + Number const trustLimit{3, 10}; + env(trust(lender, asset(trustLimit))); + env(trust(borrower, asset(trustLimit))); + env(pay(issuer, lender, asset(Number{11, 9}))); + env(pay(issuer, borrower, asset(Number{15, 9}))); + env.close(); + + BrokerParameters brokerParams; + brokerParams.vaultDeposit = Number{9, 9}; + brokerParams.debtMax = Number{9, 9}; + brokerParams.coverDeposit = 1'000'000'000; + brokerParams.vaultScale = 6; + brokerParams.managementFeeRate = TenthBips16{0}; + auto const broker = createVaultAndBroker(env, asset, lender, brokerParams); + + auto const makeLoan = [&](Number const& principal, + std::uint32_t paymentTotal, + TenthBips32 closeInterestRate = TenthBips32{0}) { + auto const loanKeylet = nextLoanKeylet(env, broker); + env(set(borrower, broker.brokerID, principal), + Sig(sfCounterpartySignature, lender), + kCloseInterestRate(closeInterestRate), + kPaymentTotal(paymentTotal), + kPaymentInterval(2 * 365 * 24 * 60 * 60), + Fee(env.current()->fees().base * 2)); + env.close(); + return loanKeylet; + }; + + Number const terminalRemainder{7, -6}; + auto const terminalLoan = makeLoan(terminalRemainder, 1); + makeLoan(Number{7, 9}, 2); + auto const growthLoan = makeLoan(Number{1, 9}, 2, lending::kMaxCloseInterestRate); + + auto const vaultBeforeGrowth = env.le(broker.vaultKeylet()); + if (!BEAST_EXPECT(vaultBeforeGrowth)) + return; + Number const assetsAvailableBeforeGrowth = vaultBeforeGrowth->at(sfAssetsAvailable); + Number const assetsTotalBeforeGrowth = vaultBeforeGrowth->at(sfAssetsTotal); + Number const yieldBeforeGrowth = vaultBeforeGrowth->at(sfYieldUnrealized); + + auto const growthState = getCurrentState(env, broker, growthLoan); + using d = NetClock::duration; + env.close(growthState.startDate + d{366 * 24 * 60 * 60}); + env(pay(borrower, growthLoan.key, asset(Number{1, 10}), tfLoanFullPayment)); + env.close(); + + auto const coarsenedVault = env.le(broker.vaultKeylet()); + if (!BEAST_EXPECT(coarsenedVault)) + return; + int const coarsenedScale = getVaultScale(coarsenedVault); + BEAST_EXPECTS( + coarsenedScale > getVaultBaseScale(coarsenedVault), + "expected coarsened scale; base " + std::to_string(getVaultBaseScale(coarsenedVault)) + + ", live " + std::to_string(coarsenedScale) + ", total " + + to_string(coarsenedVault->at(sfAssetsTotal))); + BEAST_EXPECTS( + coarsenedVault->at(sfAssetsTotal) > assetsTotalBeforeGrowth, + "AssetsTotal before " + to_string(assetsTotalBeforeGrowth) + ", after " + + to_string(coarsenedVault->at(sfAssetsTotal))); + BEAST_EXPECT(coarsenedVault->at(sfYieldUnrealized) == yieldBeforeGrowth); + Number const vaultCredit = + coarsenedVault->at(sfAssetsAvailable) - assetsAvailableBeforeGrowth; + BEAST_EXPECT(isRounded(asset, vaultCredit, coarsenedScale)); + BEAST_EXPECT(isRounded(asset, coarsenedVault->at(sfAssetsTotal), coarsenedScale)); + + auto const vaultBeforeTerminal = env.le(broker.vaultKeylet()); + auto const brokerBeforeTerminal = env.le(broker.brokerKeylet()); + if (!BEAST_EXPECT(vaultBeforeTerminal && brokerBeforeTerminal)) + return; + Number const availableBeforeTerminal = vaultBeforeTerminal->at(sfAssetsAvailable); + Number const assetsTotalBeforeTerminal = vaultBeforeTerminal->at(sfAssetsTotal); + Number const yieldBeforeTerminal = vaultBeforeTerminal->at(sfYieldUnrealized); + Number const debtBeforeTerminal = brokerBeforeTerminal->at(sfDebtTotal); + Number const borrowerBeforeTerminal = env.balance(borrower, asset).number(); + + env(pay(borrower, terminalLoan.key, asset(terminalRemainder))); + env.close(); + + auto const terminalAfter = env.le(terminalLoan); + auto const vaultAfterTerminal = env.le(broker.vaultKeylet()); + auto const brokerAfterTerminal = env.le(broker.brokerKeylet()); + if (!BEAST_EXPECT(terminalAfter && vaultAfterTerminal && brokerAfterTerminal)) + return; + BEAST_EXPECT(terminalAfter->at(sfPaymentRemaining) == 0); + BEAST_EXPECT(terminalAfter->at(sfPrincipalOutstanding) == beast::kZero); + BEAST_EXPECT( + vaultAfterTerminal->at(sfAssetsAvailable) == + availableBeforeTerminal + terminalRemainder); + BEAST_EXPECT(borrowerBeforeTerminal > env.balance(borrower, asset).number()); + BEAST_EXPECT(vaultAfterTerminal->at(sfAssetsTotal) == assetsTotalBeforeTerminal); + BEAST_EXPECT(vaultAfterTerminal->at(sfYieldUnrealized) == yieldBeforeTerminal); + BEAST_EXPECT( + brokerAfterTerminal->at(sfDebtTotal) == debtBeforeTerminal - terminalRemainder); + } + + void + testFixedPrecisionCoarsenedSingleLoanTerminal() + { + testcase("FixedPrecision coarsened LoanPay single-loan terminal remainder"); + + using namespace jtx; + using namespace loan; + + FeatureBitset const features{ + all_ | featureLendingProtocolV1_1 | featureLendingProtocolV1_2}; + 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(); + + PrettyAsset const asset = issuer["USD"]; + Number const trustLimit{3, 10}; + env(trust(lender, asset(trustLimit))); + env(trust(borrower, asset(trustLimit))); + env(pay(issuer, lender, asset(Number{11, 9}))); + env(pay(issuer, borrower, asset(Number{5, 9}))); + env.close(); + + BrokerParameters brokerParams; + brokerParams.vaultDeposit = Number{9, 9}; + brokerParams.debtMax = Number{2, 9}; + brokerParams.coverDeposit = 200'000'000; + brokerParams.vaultScale = 6; + brokerParams.managementFeeRate = TenthBips16{0}; + auto const broker = createVaultAndBroker(env, asset, lender, brokerParams); + + auto const makeLoan = [&](Number const& principal, TenthBips32 closeInterestRate) { + auto const loanKeylet = nextLoanKeylet(env, broker); + env(set(borrower, broker.brokerID, principal), + Sig(sfCounterpartySignature, lender), + kCloseInterestRate(closeInterestRate), + kPaymentTotal(2), + kPaymentInterval(2 * 365 * 24 * 60 * 60), + Fee(env.current()->fees().base * 2)); + env.close(); + return loanKeylet; + }; + + Number const terminalPrincipal{1'000'000'000'007, -6}; + auto const terminalLoan = makeLoan(terminalPrincipal, TenthBips32{0}); + auto const growthLoan = makeLoan(Number{1, 9}, lending::kMaxCloseInterestRate); + + auto const growthState = getCurrentState(env, broker, growthLoan); + using d = NetClock::duration; + env.close(growthState.startDate + d{366 * 24 * 60 * 60}); + env(pay(borrower, growthLoan.key, asset(Number{4, 9}), tfLoanFullPayment)); + env.close(); + + auto const vaultBefore = env.le(broker.vaultKeylet()); + auto const brokerBefore = env.le(broker.brokerKeylet()); + if (!BEAST_EXPECT(vaultBefore && brokerBefore)) + return; + BEAST_EXPECT(getVaultScale(vaultBefore) > getVaultBaseScale(vaultBefore)); + BEAST_EXPECT( + vaultBefore->at(sfAssetsTotal) - vaultBefore->at(sfAssetsAvailable) == + terminalPrincipal); + + Number const assetsTotalBefore = vaultBefore->at(sfAssetsTotal); + Number const assetsAvailableBefore = vaultBefore->at(sfAssetsAvailable); + Number const borrowerBefore = env.balance(borrower, asset).number(); + env(pay(borrower, terminalLoan.key, asset(terminalPrincipal))); + env.close(); + + auto const loanAfter = env.le(terminalLoan); + auto const vaultAfter = env.le(broker.vaultKeylet()); + if (!BEAST_EXPECT(loanAfter && vaultAfter)) + return; + BEAST_EXPECT(loanAfter->at(sfPaymentRemaining) == 0); + BEAST_EXPECT(loanAfter->at(sfPrincipalOutstanding) == beast::kZero); + BEAST_EXPECT(vaultAfter->at(sfAssetsTotal) == assetsTotalBefore); + + Number const credit = vaultAfter->at(sfAssetsAvailable) - assetsAvailableBefore; + BEAST_EXPECT(borrowerBefore - env.balance(borrower, asset).number() == credit); + Number const residual = vaultAfter->at(sfAssetsTotal) - vaultAfter->at(sfAssetsAvailable); + BEAST_EXPECT(residual == terminalPrincipal - credit); + BEAST_EXPECT(residual > beast::kZero); + Number const availableCandidate = [&] { + NumberRoundModeGuard const rg(Number::RoundingMode::ToNearest); + return assetsAvailableBefore + terminalPrincipal; + }(); + int const availableScale = + std::max(getVaultBaseScale(vaultAfter), scale(availableCandidate, asset)); + Number const availableUnit{1, availableScale}; + BEAST_EXPECT(residual < availableUnit); + } + + void + testFixedPrecisionCoarsenedInterestPayment() + { + testcase("FixedPrecision coarsened LoanPay records interest before credit"); + + using namespace jtx; + using namespace loan; + + FeatureBitset const features{ + all_ | featureLendingProtocolV1_1 | featureLendingProtocolV1_2}; + 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(); + + PrettyAsset const asset = issuer["USD"]; + Number const trustLimit{3, 10}; + env(trust(lender, asset(trustLimit))); + env(trust(borrower, asset(trustLimit))); + env(pay(issuer, lender, asset(Number{1, 10}))); + env(pay(issuer, borrower, asset(Number{5, 9}))); + env.close(); + + BrokerParameters brokerParams; + brokerParams.vaultDeposit = Number{85, 8}; + brokerParams.debtMax = Number{2, 9}; + brokerParams.coverDeposit = 200'000'000; + brokerParams.vaultScale = 6; + brokerParams.managementFeeRate = TenthBips16{0}; + auto const broker = createVaultAndBroker(env, asset, lender, brokerParams); + + auto const targetLoan = nextLoanKeylet(env, broker); + env(set(borrower, broker.brokerID, asset(1'000'000).value()), + Sig(sfCounterpartySignature, lender), + kInterestRate(percentageToTenthBips(12)), + kPaymentTotal(2), + kPaymentInterval(2 * 365 * 24 * 60 * 60), + Fee(env.current()->fees().base * 2)); + env.close(); + + auto const growthLoan = nextLoanKeylet(env, broker); + env(set(borrower, broker.brokerID, asset(Number{15, 8}).value()), + Sig(sfCounterpartySignature, lender), + kCloseInterestRate(lending::kMaxCloseInterestRate), + kPaymentTotal(2), + kPaymentInterval(2 * 365 * 24 * 60 * 60), + Fee(env.current()->fees().base * 2)); + env.close(); + + auto const growthState = getCurrentState(env, broker, growthLoan); + using d = NetClock::duration; + env.close(growthState.startDate + d{366 * 24 * 60 * 60}); + env(pay(borrower, growthLoan.key, asset(Number{4, 9}), tfLoanFullPayment)); + env.close(); + + auto const targetBefore = env.le(targetLoan); + auto const vaultBefore = env.le(broker.vaultKeylet()); + if (!BEAST_EXPECT(targetBefore && vaultBefore)) + return; + BEAST_EXPECT(getVaultScale(vaultBefore) > getVaultBaseScale(vaultBefore)); + + Number const principalBefore = targetBefore->at(sfPrincipalOutstanding); + Number const scheduledBefore = targetBefore->at(sfTotalValueOutstanding) - principalBefore - + targetBefore->at(sfManagementFeeOutstanding); + Number const yieldBefore = vaultBefore->at(sfYieldUnrealized); + Number const assetsTotalBefore = vaultBefore->at(sfAssetsTotal); + Number const assetsAvailableBefore = vaultBefore->at(sfAssetsAvailable); + Number const borrowerBefore = env.balance(borrower, asset).number(); + + auto const targetState = getCurrentState(env, broker, targetLoan); + Number const payment = + roundPeriodicPayment(asset, targetState.periodicPayment, targetState.loanScale); + env(pay(borrower, targetLoan.key, asset(payment))); + env.close(); + + auto const targetAfter = env.le(targetLoan); + auto const vaultAfter = env.le(broker.vaultKeylet()); + if (!BEAST_EXPECT(targetAfter && vaultAfter)) + return; + Number const principalAfter = targetAfter->at(sfPrincipalOutstanding); + Number const scheduledAfter = targetAfter->at(sfTotalValueOutstanding) - principalAfter - + targetAfter->at(sfManagementFeeOutstanding); + Number const interestActual = vaultAfter->at(sfAssetsTotal) - assetsTotalBefore; + Number const interestPaid = scheduledBefore - scheduledAfter; + Number const credit = vaultAfter->at(sfAssetsAvailable) - assetsAvailableBefore; + + BEAST_EXPECT(interestActual < interestPaid); + BEAST_EXPECT(isRounded(asset, interestActual, getVaultScale(vaultAfter))); + BEAST_EXPECT(credit == principalBefore - principalAfter + interestActual); + BEAST_EXPECT(borrowerBefore - env.balance(borrower, asset).number() == credit); + BEAST_EXPECT(scheduledAfter < scheduledBefore); + BEAST_EXPECT( + vaultAfter->at(sfYieldUnrealized) == yieldBefore + scheduledAfter - scheduledBefore); + BEAST_EXPECT( + vaultAfter->at(sfAssetsTotal) - vaultAfter->at(sfAssetsAvailable) == principalAfter); + } + + void + testFixedPrecisionSpecialPayments() + { + using namespace jtx; + using namespace loan; + + FeatureBitset const features{ + all_ | featureLendingProtocolV1_1 | featureLendingProtocolV1_2}; + + { + testcase("FixedPrecision base-scale overpayment"); + + 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(); + + PrettyAsset const asset = issuer["USD"]; + env(trust(lender, asset(5'000'000))); + env(trust(borrower, asset(5'000'000))); + env(pay(issuer, lender, asset(2'000'000))); + env(pay(issuer, borrower, asset(500'000))); + env.close(); + + BrokerParameters brokerParams; + brokerParams.debtMax = 500'000; + brokerParams.coverDeposit = 50'000; + brokerParams.vaultScale = 6; + brokerParams.managementFeeRate = TenthBips16{0}; + auto const broker = createVaultAndBroker(env, asset, lender, brokerParams); + auto const loanKeylet = nextLoanKeylet(env, broker); + env(set(borrower, broker.brokerID, asset(100'000).value(), tfLoanOverpayment), + Sig(sfCounterpartySignature, lender), + kInterestRate(percentageToTenthBips(12)), + kOverpaymentInterestRate(percentageToTenthBips(20)), + kPaymentTotal(3), + kPaymentInterval(24 * 60 * 60), + Fee(env.current()->fees().base * 2)); + env.close(); + + env(pay(borrower, loanKeylet.key, asset(50'000), tfLoanOverpayment)); + env.close(); + + auto const loanAfter = env.le(loanKeylet); + auto const vaultAfter = env.le(broker.vaultKeylet()); + if (!BEAST_EXPECT(loanAfter && vaultAfter)) + return; + Number const scheduledAfter = loanAfter->at(sfTotalValueOutstanding) - + loanAfter->at(sfPrincipalOutstanding) - loanAfter->at(sfManagementFeeOutstanding); + BEAST_EXPECT(getVaultVersion(vaultAfter) == VaultVersion::FixedPrecision); + BEAST_EXPECT(vaultAfter->at(sfYieldUnrealized) == scheduledAfter); + BEAST_EXPECT( + vaultAfter->at(sfAssetsTotal) - vaultAfter->at(sfAssetsAvailable) == + loanAfter->at(sfPrincipalOutstanding)); + } + + { + testcase("FixedPrecision base-scale late payment"); + + 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(); + + PrettyAsset const asset = issuer["USD"]; + env(trust(lender, asset(5'000'000))); + env(trust(borrower, asset(5'000'000))); + env(pay(issuer, lender, asset(2'000'000))); + env(pay(issuer, borrower, asset(500'000))); + env.close(); + + BrokerParameters brokerParams; + brokerParams.debtMax = 500'000; + brokerParams.coverDeposit = 50'000; + brokerParams.vaultScale = 6; + brokerParams.managementFeeRate = TenthBips16{0}; + auto const broker = createVaultAndBroker(env, asset, lender, brokerParams); + auto const loanKeylet = nextLoanKeylet(env, broker); + env(set(borrower, broker.brokerID, asset(100'000).value()), + Sig(sfCounterpartySignature, lender), + kLateInterestRate(percentageToTenthBips(24)), + kLatePaymentFee(asset(1).value()), + kPaymentTotal(12), + kPaymentInterval(600), + Fee(env.current()->fees().base * 2)); + env.close(); + + auto const state = getCurrentState(env, broker, loanKeylet); + using d = NetClock::duration; + env.close(NetClock::time_point{d{state.nextPaymentDate + 1}}); + Number const generousAmount = + roundPeriodicPayment(asset, state.periodicPayment, state.loanScale) * 3 + + asset(100).value(); + env(pay(borrower, loanKeylet.key, asset(generousAmount), tfLoanLatePayment)); + env.close(); + + auto const loanAfter = env.le(loanKeylet); + auto const vaultAfter = env.le(broker.vaultKeylet()); + if (!BEAST_EXPECT(loanAfter && vaultAfter)) + return; + Number const scheduledAfter = loanAfter->at(sfTotalValueOutstanding) - + loanAfter->at(sfPrincipalOutstanding) - loanAfter->at(sfManagementFeeOutstanding); + BEAST_EXPECT(getVaultVersion(vaultAfter) == VaultVersion::FixedPrecision); + BEAST_EXPECT(vaultAfter->at(sfYieldUnrealized) == scheduledAfter); + BEAST_EXPECT( + vaultAfter->at(sfAssetsTotal) - vaultAfter->at(sfAssetsAvailable) == + loanAfter->at(sfPrincipalOutstanding)); + } + } + + void + testFixedPrecisionIntegralPayments() + { + using namespace jtx; + + FeatureBitset const features{ + all_ | featureLendingProtocolV1_1 | featureLendingProtocolV1_2}; + for (auto const assetType : {AssetType::XRP, AssetType::MPT}) + { + testcase << "FixedPrecision regular LoanPay " + << (assetType == AssetType::XRP ? "XRP" : "MPT"); + + Env env{*this, features}; + Account const issuer{"issuer"}; + Account const lender{"lender"}; + Account const borrower{"borrower"}; + + BrokerParameters brokerParams; + brokerParams.vaultDeposit = 100'000; + brokerParams.debtMax = 25'000; + brokerParams.coverDeposit = 1'000; + brokerParams.managementFeeRate = TenthBips16{0}; + LoanParameters const loanParams{ + .account = borrower, + .counter = lender, + .principalRequest = 1'000, + .payTotal = 2, + .payInterval = 24 * 60 * 60}; + + auto const loanOpt = + createLoan(env, assetType, brokerParams, loanParams, issuer, lender, borrower); + if (BEAST_EXPECT(loanOpt); !loanOpt) + continue; + auto const& [broker, loanKeylet, unusedBrokerPseudo] = *loanOpt; + + auto const state = getCurrentState(env, broker, loanKeylet); + topUpBorrower(env, broker, issuer, borrower, state, std::nullopt); + Number const payment = + roundPeriodicPayment(broker.asset, state.periodicPayment, state.loanScale); + env(loan::pay(borrower, loanKeylet.key, STAmount{broker.asset, payment})); + env.close(); + + auto const loanAfter = env.le(loanKeylet); + auto const vaultAfter = env.le(broker.vaultKeylet()); + if (!BEAST_EXPECT(loanAfter && vaultAfter)) + continue; + BEAST_EXPECT(getVaultVersion(vaultAfter) == VaultVersion::FixedPrecision); + BEAST_EXPECT(loanAfter->at(sfPaymentRemaining) == state.paymentRemaining - 1); + BEAST_EXPECT( + vaultAfter->at(sfAssetsTotal) - vaultAfter->at(sfAssetsAvailable) == + loanAfter->at(sfPrincipalOutstanding)); + } + } + + void + testFixedPrecisionYieldAcrossLoans() + { + testcase("FixedPrecision YieldUnrealized equals three-loan sum"); + + using namespace jtx; + using namespace loan; + + FeatureBitset const features{ + all_ | featureLendingProtocolV1_1 | featureLendingProtocolV1_2}; + 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(); + + PrettyAsset const asset = issuer["USD"]; + env(trust(lender, asset(10'000'000))); + env(trust(borrower, asset(10'000'000))); + env(pay(issuer, lender, asset(3'000'000))); + env(pay(issuer, borrower, asset(1'000'000))); + env.close(); + + BrokerParameters brokerParams; + brokerParams.vaultDeposit = 2'000'000; + brokerParams.debtMax = 1'000'000; + brokerParams.coverDeposit = 100'000; + brokerParams.vaultScale = 6; + brokerParams.managementFeeRate = TenthBips16{0}; + auto const broker = createVaultAndBroker(env, asset, lender, brokerParams); + + std::vector loans; + loans.reserve(3); + for (std::size_t i = 0; i < 3; ++i) + { + loans.push_back(nextLoanKeylet(env, broker)); + env(set(borrower, broker.brokerID, asset(100'000).value()), + Sig(sfCounterpartySignature, lender), + kInterestRate(percentageToTenthBips(12)), + kPaymentTotal(2), + kPaymentInterval(24 * 60 * 60), + Fee(env.current()->fees().base * 2)); + env.close(); + } + + auto const loanYield = [&](Keylet const& keylet) { + auto const loan = env.le(keylet); + if (!BEAST_EXPECT(loan)) + return kNumZero; + return loan->at(sfTotalValueOutstanding) - loan->at(sfPrincipalOutstanding) - + loan->at(sfManagementFeeOutstanding); + }; + auto expectedYield = [&] { + Number result; + for (auto const& loan : loans) + result += loanYield(loan); + return result; + }; + + auto vault = env.le(broker.vaultKeylet()); + if (!BEAST_EXPECT(vault)) + return; + BEAST_EXPECT(vault->at(sfYieldUnrealized) == expectedYield()); + + for (std::size_t i = 0; i < 2; ++i) + { + auto const state = getCurrentState(env, broker, loans[i]); + Number const payment = + roundPeriodicPayment(asset, state.periodicPayment, state.loanScale); + env(pay(borrower, loans[i].key, asset(payment))); + env.close(); + } + + vault = env.le(broker.vaultKeylet()); + if (!BEAST_EXPECT(vault)) + return; + BEAST_EXPECT(vault->at(sfYieldUnrealized) == expectedYield()); + } + void runAmendmentIndependent() { @@ -1494,6 +2254,14 @@ private: testLoanPayCatchUpFeeAtExactDueDatePreAmendment(); testRepayIntoUnauthorizedVault(); testLoanPaySelfBrokerExistingLineDefaultRipple(); + testFixedPrecisionScheduledPayment(); + testFixedPrecisionRedirectedFeeRounding(); + testFixedPrecisionCoarsenedPayments(); + testFixedPrecisionCoarsenedSingleLoanTerminal(); + testFixedPrecisionCoarsenedInterestPayment(); + testFixedPrecisionSpecialPayments(); + testFixedPrecisionIntegralPayments(); + testFixedPrecisionYieldAcrossLoans(); } // Tests run under each entry in amendmentCombinations().