refactor: add vault interest unrealized invariants

This commit is contained in:
Vito
2026-03-24 10:20:51 +01:00
parent a8914da275
commit c0fc689113
3 changed files with 179 additions and 3 deletions

View File

@@ -48,6 +48,7 @@ class ValidVault
Number assetsAvailable = 0;
Number assetsMaximum = 0;
Number lossUnrealized = 0;
Number interestUnrealized = 0;
Vault static make(SLE const&);
};

View File

@@ -30,6 +30,7 @@ ValidVault::Vault::make(SLE const& from)
self.assetsAvailable = from.at(sfAssetsAvailable);
self.assetsMaximum = from.at(sfAssetsMaximum);
self.lossUnrealized = from.at(sfLossUnrealized);
self.interestUnrealized = from.at(sfInterestUnrealized);
return self;
}
@@ -386,6 +387,48 @@ ValidVault::finalize(
result = false;
}
if (view.rules().enabled(featureLendingProtocolV1_1))
{
if (afterVault.interestUnrealized < zero)
{
JLOG(j.fatal()) << "Invariant failed: interest unrealized must be positive";
result = false;
}
// InterestUnrealized <= AssetsTotal - AssetsAvailable (the lent portion)
if (afterVault.interestUnrealized > afterVault.assetsTotal - afterVault.assetsAvailable)
{
JLOG(j.fatal()) << "Invariant failed: interest unrealized exceeds lent assets";
result = false;
}
// Deposit net asset value = AssetsTotal - InterestUnrealized must be > 0 when vault
// has assets.
if (afterVault.assetsTotal > zero &&
afterVault.assetsTotal - afterVault.interestUnrealized <= zero)
{
JLOG(j.fatal()) << "Invariant failed: deposit NAV must be positive";
result = false;
}
// Withdrawal net asset value = AssetsTotal - InterestUnrealized - LossUnrealized
// must not go negative.
if (afterVault.assetsTotal - afterVault.interestUnrealized - afterVault.lossUnrealized <
zero)
{
JLOG(j.fatal()) << "Invariant failed: withdrawal NAV must not be negative";
result = false;
}
// If there's unrealized interest, shares must be outstanding.
if (updatedShares && afterVault.interestUnrealized > zero &&
updatedShares->sharesTotal == 0)
{
JLOG(j.fatal()) << "Invariant failed: interest unrealized with no outstanding shares";
result = false;
}
}
// Thanks to this check we can simply do `assert(!beforeVault_.empty()` when
// enforcing invariants on transaction types other than ttVAULT_CREATE
if (beforeVault_.empty() && txnType != ttVAULT_CREATE)
@@ -400,8 +443,17 @@ ValidVault::finalize(
txnType != ttLOAN_MANAGE && txnType != ttLOAN_PAY)
{
JLOG(j.fatal()) << //
"Invariant failed: vault transaction must not change loss "
"unrealized";
"Invariant failed: vault transaction must not change loss unrealized";
result = false;
}
// Interest unrealized must only change via loan transactions.
if (view.rules().enabled(featureLendingProtocolV1_1) && !beforeVault_.empty() &&
afterVault.interestUnrealized != beforeVault_[0].interestUnrealized &&
txnType != ttLOAN_MANAGE && txnType != ttLOAN_PAY && txnType != ttLOAN_SET)
{
JLOG(j.fatal()) << //
"Invariant failed: vault transaction must not change interest unrealized";
result = false;
}

View File

@@ -2273,6 +2273,7 @@ class Invariants_test : public beast::unit_test::suite
std::optional<int> assetsMaximum = std::nullopt;
std::optional<int> sharesTotal = std::nullopt;
std::optional<int> vaultAssets = std::nullopt;
std::optional<int> interestUnrealized = std::nullopt;
std::optional<AccountAmount> accountAssets = std::nullopt;
std::optional<AccountAmount> accountShares = std::nullopt;
// NOLINTEND(readability-redundant-member-init)
@@ -2287,11 +2288,13 @@ class Invariants_test : public beast::unit_test::suite
if (!sleShares)
return false;
// These two fields are adjusted in absolute terms
// These three fields are adjusted in absolute terms
if (args.lossUnrealized)
(*sleVault)[sfLossUnrealized] = *args.lossUnrealized;
if (args.assetsMaximum)
(*sleVault)[sfAssetsMaximum] = *args.assetsMaximum;
if (args.interestUnrealized)
(*sleVault)[sfInterestUnrealized] = *args.interestUnrealized;
// Remaining fields are adjusted in terms of difference
if (args.assetsTotal)
@@ -2952,6 +2955,126 @@ class Invariants_test : public beast::unit_test::suite
precloseXrp,
TxAccount::A2);
doInvariantCheck(
{"interest unrealized must be positive"},
[&](Account const& A1, Account const& A2, ApplyContext& ac) {
auto const keylet = keylet::vault(A1.id(), ac.view().seq());
adjust(ac.view(), keylet, args(A2.id(), 10, [](Adjustments& adj) {
adj.interestUnrealized = -1;
}));
return true;
},
XRPAmount{},
STTx{ttVAULT_DEPOSIT, [](STObject&) {}},
{tecINVARIANT_FAILED, tecINVARIANT_FAILED},
precloseXrp,
TxAccount::A2);
// InterestUnrealized exceeds the lent portion (assetsTotal -
// assetsAvailable). Vault has 30 XRP total, reduce available by 10 XRP
// so lent = 10 XRP, then set interest to 20 XRP which exceeds lent.
doInvariantCheck(
{"interest unrealized exceeds lent assets"},
[&](Account const& A1, Account const& A2, ApplyContext& ac) {
auto const keylet = keylet::vault(A1.id(), ac.view().seq());
adjust(ac.view(), keylet, args(A2.id(), 10, [](Adjustments& adj) {
adj.assetsAvailable = (DROPS_PER_XRP * -10).value();
adj.interestUnrealized = (DROPS_PER_XRP * 20).value();
}));
return true;
},
XRPAmount{},
STTx{ttVAULT_DEPOSIT, [](STObject&) {}},
{tecINVARIANT_FAILED, tecINVARIANT_FAILED},
precloseXrp,
TxAccount::A2);
// Deposit net asset value = assetsTotal - interestUnrealized must be > 0 when
// vault has assets. Set interestUnrealized = assetsTotal so net asset value = 0.
doInvariantCheck(
{"deposit net asset value must be positive"},
[&](Account const& A1, Account const& A2, ApplyContext& ac) {
auto const keylet = keylet::vault(A1.id(), ac.view().seq());
adjust(ac.view(), keylet, args(A2.id(), 10, [](Adjustments& adj) {
adj.assetsAvailable = (DROPS_PER_XRP * -20).value();
adj.interestUnrealized = (DROPS_PER_XRP * 30 + 10).value();
}));
return true;
},
XRPAmount{},
STTx{ttVAULT_DEPOSIT, [](STObject&) {}},
{tecINVARIANT_FAILED, tecINVARIANT_FAILED},
precloseXrp,
TxAccount::A2);
// Withdrawal net asset value = assetsTotal - interestUnrealized - lossUnrealized
// must not go negative.
doInvariantCheck(
{"withdrawal net asset value must not be negative"},
[&](Account const& A1, Account const& A2, ApplyContext& ac) {
auto const keylet = keylet::vault(A1.id(), ac.view().seq());
adjust(ac.view(), keylet, args(A2.id(), 10, [](Adjustments& adj) {
adj.assetsAvailable = (DROPS_PER_XRP * -20).value();
adj.interestUnrealized = (DROPS_PER_XRP * 15).value();
adj.lossUnrealized = (DROPS_PER_XRP * 20).value();
}));
return true;
},
XRPAmount{},
STTx{ttVAULT_DEPOSIT, [](STObject&) {}},
{tecINVARIANT_FAILED, tecINVARIANT_FAILED},
precloseXrp,
TxAccount::A2);
// Interest unrealized with no outstanding shares.
doInvariantCheck(
{"interest unrealized with no outstanding shares"},
[&](Account const& A1, Account const& A2, ApplyContext& ac) {
auto const keylet = keylet::vault(A1.id(), ac.view().seq());
adjust(ac.view(), keylet, args(A2.id(), 10, [](Adjustments& adj) {
adj.assetsAvailable = (DROPS_PER_XRP * -10).value();
adj.interestUnrealized = (DROPS_PER_XRP * 5).value();
}));
// Zero out shares
auto sleVault = ac.view().peek(keylet);
if (!sleVault)
return false;
auto sleShares = ac.view().peek(keylet::mptIssuance((*sleVault)[sfShareMPTID]));
if (!sleShares)
return false;
(*sleShares)[sfOutstandingAmount] = 0;
ac.view().update(sleShares);
return true;
},
XRPAmount{},
STTx{ttVAULT_DEPOSIT, [](STObject&) {}},
{tecINVARIANT_FAILED, tecINVARIANT_FAILED},
precloseXrp,
TxAccount::A2);
// Interest unrealized must not change on non-loan transactions.
doInvariantCheck(
{"vault transaction must not change interest unrealized"},
[&](Account const& A1, Account const& A2, ApplyContext& ac) {
auto const keylet = keylet::vault(A1.id(), ac.view().seq());
adjust(ac.view(), keylet, args(A2.id(), 10, [](Adjustments& adj) {
adj.assetsAvailable = (DROPS_PER_XRP * -10).value();
adj.interestUnrealized = (DROPS_PER_XRP * 5).value();
}));
return true;
},
XRPAmount{},
STTx{ttVAULT_DEPOSIT, [](STObject&) {}},
{tecINVARIANT_FAILED, tecINVARIANT_FAILED},
precloseXrp,
TxAccount::A2);
testcase << "Vault create";
doInvariantCheck(
{