From c0fc689113875265589f2a5ef85dd8aa815645cc Mon Sep 17 00:00:00 2001 From: Vito <5780819+Tapanito@users.noreply.github.com> Date: Tue, 24 Mar 2026 10:20:51 +0100 Subject: [PATCH] refactor: add vault interest unrealized invariants --- include/xrpl/tx/invariants/VaultInvariant.h | 1 + src/libxrpl/tx/invariants/VaultInvariant.cpp | 56 ++++++++- src/test/app/Invariants_test.cpp | 125 ++++++++++++++++++- 3 files changed, 179 insertions(+), 3 deletions(-) diff --git a/include/xrpl/tx/invariants/VaultInvariant.h b/include/xrpl/tx/invariants/VaultInvariant.h index 1e1ded6fa1..dc6cfed21e 100644 --- a/include/xrpl/tx/invariants/VaultInvariant.h +++ b/include/xrpl/tx/invariants/VaultInvariant.h @@ -48,6 +48,7 @@ class ValidVault Number assetsAvailable = 0; Number assetsMaximum = 0; Number lossUnrealized = 0; + Number interestUnrealized = 0; Vault static make(SLE const&); }; diff --git a/src/libxrpl/tx/invariants/VaultInvariant.cpp b/src/libxrpl/tx/invariants/VaultInvariant.cpp index 1f6e772dfe..e7b0218393 100644 --- a/src/libxrpl/tx/invariants/VaultInvariant.cpp +++ b/src/libxrpl/tx/invariants/VaultInvariant.cpp @@ -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; } diff --git a/src/test/app/Invariants_test.cpp b/src/test/app/Invariants_test.cpp index 608f82f890..1d06f247e7 100644 --- a/src/test/app/Invariants_test.cpp +++ b/src/test/app/Invariants_test.cpp @@ -2273,6 +2273,7 @@ class Invariants_test : public beast::unit_test::suite std::optional assetsMaximum = std::nullopt; std::optional sharesTotal = std::nullopt; std::optional vaultAssets = std::nullopt; + std::optional interestUnrealized = std::nullopt; std::optional accountAssets = std::nullopt; std::optional 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( {