From 6d84c1c422373ea0b77501f29e5e7b4edcd34d54 Mon Sep 17 00:00:00 2001 From: JCW Date: Tue, 7 Jul 2026 17:54:31 +0100 Subject: [PATCH] Add loan and vault invariant checks and invariant tests --- include/xrpl/tx/invariants/LoanInvariant.h | 8 +++++ include/xrpl/tx/invariants/VaultInvariant.h | 4 ++- src/libxrpl/tx/invariants/LoanInvariant.cpp | 32 +++++++++++++++++++- src/libxrpl/tx/invariants/VaultInvariant.cpp | 10 ++++++ src/test/app/Invariants_test.cpp | 28 +++++++++++++++++ 5 files changed, 80 insertions(+), 2 deletions(-) diff --git a/include/xrpl/tx/invariants/LoanInvariant.h b/include/xrpl/tx/invariants/LoanInvariant.h index 0648881423..5ac511a84a 100644 --- a/include/xrpl/tx/invariants/LoanInvariant.h +++ b/include/xrpl/tx/invariants/LoanInvariant.h @@ -17,12 +17,20 @@ namespace xrpl { * * 1. If `Loan.PaymentRemaining = 0` then `Loan.PrincipalOutstanding = 0` * + * A loan may only be deleted once it is fully paid off (no payments + * remaining): + * + * 2. A loan may only be deleted by a `LoanDelete` transaction. + * 3. A loan that is not fully paid off must not be deleted. + * */ class ValidLoan { // Pair is . After is used for most of the checks, except // those that check changed values. std::vector> loans_; + // Loans removed from the ledger (final state captured at deletion). + std::vector deletedLoans_; public: void diff --git a/include/xrpl/tx/invariants/VaultInvariant.h b/include/xrpl/tx/invariants/VaultInvariant.h index 135583385c..60a8aee825 100644 --- a/include/xrpl/tx/invariants/VaultInvariant.h +++ b/include/xrpl/tx/invariants/VaultInvariant.h @@ -43,7 +43,9 @@ namespace xrpl { * - loan manage never removes assets from the vault: assets available may only * grow (and the vault balance grows with it, by the returned first-loss * capital on a default), assets outstanding may only shrink (realized loss), - * loss unrealized stays non-negative, and shares do not change + * loss unrealized stays non-negative, and shares do not change; a loan + * manage with none of the sub-operation flags (impair, unimpair, default) + * is a no-op and must not modify the vault * - loan pay adds the paid principal and interest to the vault: assets * available (and the vault balance) increase by the same amount, which is at * most the amount paid, loss unrealized stays non-negative, and shares do not diff --git a/src/libxrpl/tx/invariants/LoanInvariant.cpp b/src/libxrpl/tx/invariants/LoanInvariant.cpp index ce9a7c6e03..7ae5e4b407 100644 --- a/src/libxrpl/tx/invariants/LoanInvariant.cpp +++ b/src/libxrpl/tx/invariants/LoanInvariant.cpp @@ -10,6 +10,7 @@ #include // IWYU pragma: keep #include #include +#include #include namespace xrpl { @@ -17,7 +18,13 @@ namespace xrpl { void ValidLoan::visitEntry(bool isDelete, SLE::const_ref before, SLE::const_ref after) { - if (after && after->getType() == ltLOAN) + if (isDelete) + { + // On deletion `after` holds the loan's final state. + if (after && after->getType() == ltLOAN) + deletedLoans_.emplace_back(after); + } + else if (after && after->getType() == ltLOAN) { loans_.emplace_back(before, after); } @@ -34,6 +41,8 @@ ValidLoan::finalize( // Loans will not exist on ledger if the Lending Protocol amendment // is not enabled, so there's no need to check it. + auto const txType = tx.getTxnType(); + for (auto const& [before, after] : loans_) { // https://github.com/Tapanito/XRPL-Standards/blob/xls-66-lending-protocol/XLS-0066d-lending-protocol/README.md#3223-invariants @@ -90,6 +99,27 @@ ValidLoan::finalize( } } } + + // A loan may only be deleted by a LoanDelete transaction, and only once it + // is fully paid off (no payments remaining). Deleting a loan with + // outstanding obligations is a violation. + for (auto const& loan : deletedLoans_) + { + if (txType != ttLOAN_DELETE) + { + JLOG(j.fatal()) << "Invariant failed: Loan deleted by a transaction " + "other than LoanDelete"; + return false; + } + + if (loan->at(sfPaymentRemaining) != 0) + { + JLOG(j.fatal()) << "Invariant failed: Loan deleted while not fully " + "paid off"; + return false; + } + } + return true; } diff --git a/src/libxrpl/tx/invariants/VaultInvariant.cpp b/src/libxrpl/tx/invariants/VaultInvariant.cpp index a81d444cf2..da656611d2 100644 --- a/src/libxrpl/tx/invariants/VaultInvariant.cpp +++ b/src/libxrpl/tx/invariants/VaultInvariant.cpp @@ -1277,6 +1277,16 @@ ValidVault::finalize( result = false; } } + else + { + // A loan manage with none of the sub-operation flags + // (impair, unimpair, default) is a no-op and must not + // modify the vault. + JLOG(j.fatal()) << // + "Invariant failed: loan manage without a sub-operation " + "must not modify the vault"; + result = false; + } return result; } diff --git a/src/test/app/Invariants_test.cpp b/src/test/app/Invariants_test.cpp index 5d6a4b6687..4d45c53867 100644 --- a/src/test/app/Invariants_test.cpp +++ b/src/test/app/Invariants_test.cpp @@ -3396,6 +3396,34 @@ class Invariants_test : public beast::unit_test::Suite testcase << "Vault loan operations"; + // ttLOAN_MANAGE (impair): assets outstanding must not change. Only + // assets outstanding is bumped, so the common checks (vault balance, + // assets available, shares) all pass and only the impair/unimpair + // sub-check fires. + doInvariantCheck( + {"loan impair/unimpair must not change assets outstanding"}, + [&](Account const& a1, Account const& a2, ApplyContext& ac) { + auto const keylet = keylet::vault(a1.id(), ac.view().seq()); + return kAdjust(ac.view(), keylet, Adjustments{.assetsTotal = 100}); + }, + XRPAmount{}, + STTx{ttLOAN_MANAGE, [](STObject& tx) { tx.setFieldU32(sfFlags, tfLoanImpair); }}, + {tecINVARIANT_FAILED, tecINVARIANT_FAILED}, + precloseXrp); + + // ttLOAN_MANAGE with none of the sub-operation flags (impair, + // unimpair, default) is a no-op and must not modify the vault. + doInvariantCheck( + {"loan manage without a sub-operation must not modify the vault"}, + [&](Account const& a1, Account const& a2, ApplyContext& ac) { + auto const keylet = keylet::vault(a1.id(), ac.view().seq()); + return kAdjust(ac.view(), keylet, Adjustments{.assetsTotal = 100}); + }, + XRPAmount{}, + STTx{ttLOAN_MANAGE, [](STObject&) {}}, + {tecINVARIANT_FAILED, tecINVARIANT_FAILED}, + precloseXrp); + // ttLOAN_SET: the vault (pseudo-account) balance must change doInvariantCheck( {"loan set must change vault balance"},