test: Cover YieldUnrealized net of management fee on LoanSet

Check YieldUnrealized against the loans' TotalValueOutstanding -
PrincipalOutstanding - ManagementFeeOutstanding in the shared
FixedPrecision Vault helper, and add LoanSet cases with a nonzero
management fee on IOU, XRP and MPT FixedPrecision Vaults.
This commit is contained in:
Vito
2026-10-12 11:10:53 +02:00
parent b0dcfb765c
commit a198934412
3 changed files with 113 additions and 2 deletions

View File

@@ -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<std::uint8_t> 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 {

View File

@@ -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();

View File

@@ -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())
{