diff --git a/src/test/app/lending/LoanFixedPrecisionBase.h b/src/test/app/lending/LoanFixedPrecisionBase.h index d314cbe5ca..793bbc4cee 100644 --- a/src/test/app/lending/LoanFixedPrecisionBase.h +++ b/src/test/app/lending/LoanFixedPrecisionBase.h @@ -79,6 +79,7 @@ protected: // Cover rates default to 0%, so no cover deposit is needed; pass non-zero // values to exercise First-Loss Capital. scale overrides the default sfScale. + // managementFeeRate defaults to 0%, so interest due equals gross interest. static LendingFixture setupLendingVault( test::jtx::Env& env, @@ -89,7 +90,8 @@ protected: std::chrono::seconds investmentWindow = std::chrono::seconds{1'000'000}, std::optional scale = std::nullopt, TenthBips32 coverRateMinimum = TenthBips32(0), - TenthBips32 coverRateLiquidation = TenthBips32(0)) + TenthBips32 coverRateLiquidation = TenthBips32(0), + TenthBips16 managementFeeRate = TenthBips16(0)) { using namespace test::jtx; using namespace loan_broker; @@ -115,7 +117,8 @@ protected: keylet::loanBroker(owner.id(), SeqProxy::rawSequence(env.seq(owner))); env(set(owner, vaultKeylet.key), kCoverRateMinimum(coverRateMinimum), - kCoverRateLiquidation(coverRateLiquidation)); + kCoverRateLiquidation(coverRateLiquidation), + kManagementFeeRate(managementFeeRate)); env.close(); return { diff --git a/src/test/app/lending/LoanSetFixedPrecision_test.cpp b/src/test/app/lending/LoanSetFixedPrecision_test.cpp index 7c09377d5a..d4786631aa 100644 --- a/src/test/app/lending/LoanSetFixedPrecision_test.cpp +++ b/src/test/app/lending/LoanSetFixedPrecision_test.cpp @@ -95,6 +95,102 @@ class LoanSetFixedPrecision_test : public LoanFixedPrecisionBase } } + // YieldUnrealized books only the Vault's share of the interest: gross + // interest less the broker's management fee. Covers IOU, XRP and MPT + // Vaults, since XRP and MPT use base scale 0. + void + testOriginationBooksInterestDueNetOfManagementFee() + { + using namespace test::jtx; + + enum class AssetKind { Iou, Xrp, Mpt }; + struct Row + { + char const* name = nullptr; + AssetKind kind = AssetKind::Iou; + Number deposit; + Number principal; + }; + Row const rows[] = { + {.name = + "LoanSet on an IOU FixedPrecision vault books interest net of the management fee", + .kind = AssetKind::Iou, + .deposit = Number{1'000}, + .principal = Number{300}}, + {.name = + "LoanSet on an XRP FixedPrecision vault books interest net of the management fee", + .kind = AssetKind::Xrp, + .deposit = Number{10'000'000}, + .principal = Number{3'000'000}}, + {.name = + "LoanSet on an MPT FixedPrecision vault books interest net of the management fee", + .kind = AssetKind::Mpt, + .deposit = Number{10'000'000}, + .principal = Number{3'000'000}}, + }; + for (auto const& row : rows) + { + testcase(row.name); + + constexpr std::uint32_t paymentTotal = 2; + constexpr std::uint32_t paymentInterval = 30 * 24 * 60 * 60; + TenthBips32 const interestRate{12'000}; + TenthBips16 const managementFeeRate{10'000}; + + Env env(*this, features()); + AssetAccounts accounts; + switch (row.kind) + { + case AssetKind::Iou: + accounts = setupIou(env); + break; + case AssetKind::Xrp: + env.fund(XRP(1'000'000), accounts.owner, accounts.depositor); + env.close(); + accounts.asset = xrpIssue(); + break; + case AssetKind::Mpt: + accounts = setupMpt( + env, + {.maxAmt = 100'000'000, .withDepositor = true, .holderFunds = 10'000'000}); + break; + } + + auto const fixture = setupLendingVault( + env, + accounts.owner, + accounts.depositor, + accounts.asset, + row.deposit, + std::chrono::seconds{10'000'000}, + std::nullopt, + TenthBips32(0), + TenthBips32(0), + managementFeeRate); + + auto const loanKeylet = openLoan( + env, fixture, row.principal, paymentTotal, interestRate, 0, paymentInterval); + + auto const loanSle = env.le(loanKeylet); + auto const vaultSle = env.le(fixture.vaultKeylet); + if (!BEAST_EXPECT(loanSle && vaultSle)) + return; + + Number const principal = loanSle->at(sfPrincipalOutstanding); + Number const grossInterest = loanSle->at(sfTotalValueOutstanding) - principal; + Number const managementFee = loanSle->at(sfManagementFeeOutstanding); + BEAST_EXPECT(principal == row.principal); + BEAST_EXPECT(managementFee > beast::kZero); + BEAST_EXPECT(grossInterest > managementFee); + + BEAST_EXPECT(vaultSle->at(sfAssetsDeployed) == row.principal); + BEAST_EXPECT(Number(vaultSle->at(sfYieldUnrealized)) == grossInterest - managementFee); + // Origination books principal as debt only; AssetsTotal is unchanged. + BEAST_EXPECT(vaultSle->at(sfAssetsTotal) == row.deposit); + checkVaultLoanSums(env, fixture, {loanKeylet}, row.name); + } + } + void testLendingClawbackAndWithdrawWhileLoanOpen() { @@ -649,6 +745,7 @@ public: run() override { testAssetsDeployedOrigination(); + testOriginationBooksInterestDueNetOfManagementFee(); testLendingClawbackAndWithdrawWhileLoanOpen(); testLendingFinalWithdrawalWhileLoanOpen(); testLendingFullClawbackClampsInsteadOfHasObligations(); diff --git a/src/test/app/lending/LoanTestBase.h b/src/test/app/lending/LoanTestBase.h index a07e188e36..1e6476ab28 100644 --- a/src/test/app/lending/LoanTestBase.h +++ b/src/test/app/lending/LoanTestBase.h @@ -72,6 +72,8 @@ namespace xrpl::test { // - AssetsDeployed exactly equals the sum of the given loans' // PrincipalOutstanding, and (when brokerKeylets is non-empty) also // exactly equals the sum of the given brokers' DebtTotal; +// - YieldUnrealized exactly equals the sum of the given loans' +// TotalValueOutstanding - PrincipalOutstanding - ManagementFeeOutstanding; // - stored AssetsTotal <= derived getAssetsTotal, with the gap under one // live unit (equal below coarsening), since the stored field is a // Downward-rounded cache; @@ -106,15 +108,24 @@ checkFixedPrecisionVaultAssetsDeployed( Number const vaultAssetsDeployed = vaultSle->at(sfAssetsDeployed); Number principalSum{0}; + Number scheduledInterestSum{0}; for (auto const& loanKeylet : loanKeylets) { if (auto const loanSle = env.le(loanKeylet)) + { principalSum += loanSle->at(sfPrincipalOutstanding); + scheduledInterestSum += loanSle->at(sfTotalValueOutstanding) - + loanSle->at(sfPrincipalOutstanding) - loanSle->at(sfManagementFeeOutstanding); + } } suite.expect( vaultAssetsDeployed == principalSum, (label.empty() ? "" : label + ": ") + std::string{"AssetsDeployed vs PrincipalOutstanding sum mismatch"}); + suite.expect( + Number(vaultSle->at(sfYieldUnrealized)) == scheduledInterestSum, + (label.empty() ? "" : label + ": ") + + std::string{"YieldUnrealized vs scheduled interest sum mismatch"}); if (!brokerKeylets.empty()) {