Add loan and vault invariant checks and invariant tests

This commit is contained in:
JCW
2026-07-07 17:54:31 +01:00
parent c509ad3474
commit 6d84c1c422
5 changed files with 80 additions and 2 deletions

View File

@@ -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 <before, after>. After is used for most of the checks, except
// those that check changed values.
std::vector<std::pair<SLE::const_pointer, SLE::const_pointer>> loans_;
// Loans removed from the ledger (final state captured at deletion).
std::vector<SLE::const_pointer> deletedLoans_;
public:
void

View File

@@ -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

View File

@@ -10,6 +10,7 @@
#include <xrpl/protocol/STNumber.h> // IWYU pragma: keep
#include <xrpl/protocol/STTx.h>
#include <xrpl/protocol/TER.h>
#include <xrpl/protocol/TxFormats.h>
#include <xrpl/protocol/XRPAmount.h>
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;
}

View File

@@ -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;
}

View File

@@ -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"},