From e538aeb59af57287e14369d774ffe6f5b1374068 Mon Sep 17 00:00:00 2001 From: JCW Date: Mon, 6 Jul 2026 15:35:51 +0100 Subject: [PATCH] Add vault invariants --- .cspell.config.yaml | 1 + include/xrpl/tx/invariants/VaultInvariant.h | 38 ++ src/libxrpl/tx/invariants/VaultInvariant.cpp | 355 ++++++++++++++- src/test/app/Invariants_test.cpp | 438 +++++++++++++++++++ 4 files changed, 827 insertions(+), 5 deletions(-) diff --git a/.cspell.config.yaml b/.cspell.config.yaml index c558fc0984..1cb48fdc4b 100644 --- a/.cspell.config.yaml +++ b/.cspell.config.yaml @@ -356,3 +356,4 @@ words: - xxhash - xxhasher - CGNAT + - unimpairs diff --git a/include/xrpl/tx/invariants/VaultInvariant.h b/include/xrpl/tx/invariants/VaultInvariant.h index bc8246b234..135583385c 100644 --- a/include/xrpl/tx/invariants/VaultInvariant.h +++ b/include/xrpl/tx/invariants/VaultInvariant.h @@ -36,6 +36,20 @@ namespace xrpl { * - vault withdrawal and clawback reduce assets and share issuance, and * subtracts from: total assets, assets available, shares outstanding * - vault set must not alter the vault assets or shares balance + * - loan set moves the requested principal out of the vault: it must create + * exactly one loan, decreases assets available (and the vault balance) by the + * principal, grows assets outstanding by exactly the interest due booked on + * the created loan, and does not change shares + * - 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 + * - 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 + * change; and assets outstanding move in lock-step: their change equals the + * cash received plus the change in the paid loan's claim on the vault, which + * verifies the payment was split correctly between principal and interest * - no vault transaction can change loss unrealized (it's updated by loan * transactions) * @@ -55,6 +69,8 @@ class ValidVault Number assetsAvailable = 0; Number assetsMaximum = 0; Number lossUnrealized = 0; + std::uint8_t withdrawalPolicy = 0; + std::uint8_t scale = 0; Vault static make(SLE const&); }; @@ -68,6 +84,26 @@ class ValidVault Shares static make(SLE const&); }; + struct Loan final + { + uint256 key = beast::kZero; + Number principalOutstanding = 0; + Number totalValueOutstanding = 0; + Number managementFeeOutstanding = 0; + + // Interest booked to the vault at loan creation: the portion of the + // total value owed that is neither principal nor broker management fee. + [[nodiscard]] Number + interestDue() const; + + // The vault's claim on the loan: the total value owed less the broker's + // management fee (which belongs to the broker, not the vault). + [[nodiscard]] Number + claim() const; + + Loan static make(SLE const&); + }; + public: struct DeltaInfo final { @@ -82,8 +118,10 @@ public: private: std::vector afterVault_; std::vector afterMPTs_; + std::vector afterLoan_; std::vector beforeVault_; std::vector beforeMPTs_; + std::vector beforeLoan_; std::unordered_map deltas_; /** diff --git a/src/libxrpl/tx/invariants/VaultInvariant.cpp b/src/libxrpl/tx/invariants/VaultInvariant.cpp index 84814977db..4c6f664d65 100644 --- a/src/libxrpl/tx/invariants/VaultInvariant.cpp +++ b/src/libxrpl/tx/invariants/VaultInvariant.cpp @@ -17,6 +17,7 @@ #include // IWYU pragma: keep #include #include +#include #include #include #include @@ -44,6 +45,8 @@ ValidVault::Vault::make(SLE const& from) self.assetsAvailable = from.at(sfAssetsAvailable); self.assetsMaximum = from.at(sfAssetsMaximum); self.lossUnrealized = from.at(sfLossUnrealized); + self.withdrawalPolicy = from.at(sfWithdrawalPolicy); + self.scale = from.at(sfScale); return self; } @@ -61,6 +64,31 @@ ValidVault::Shares::make(SLE const& from) return self; } +Number +ValidVault::Loan::interestDue() const +{ + return totalValueOutstanding - principalOutstanding - managementFeeOutstanding; +} + +Number +ValidVault::Loan::claim() const +{ + return totalValueOutstanding - managementFeeOutstanding; +} + +ValidVault::Loan +ValidVault::Loan::make(SLE const& from) +{ + XRPL_ASSERT(from.getType() == ltLOAN, "ValidVault::Loan::make : from Loan object"); + + ValidVault::Loan self; + self.key = from.key(); + self.principalOutstanding = from.at(sfPrincipalOutstanding); + self.totalValueOutstanding = from.at(sfTotalValueOutstanding); + self.managementFeeOutstanding = from.at(sfManagementFeeOutstanding); + return self; +} + void ValidVault::visitEntry(bool isDelete, SLE::const_ref before, SLE::const_ref after) { @@ -119,6 +147,12 @@ ValidVault::visitEntry(bool isDelete, SLE::const_ref before, SLE::const_ref afte sign = -1; break; } + case ltLOAN: + // A loan carries no vault balance of its own; capture its prior + // state so the loan pay invariant can verify the change in the + // vault's claim on the loan. + beforeLoan_.push_back(Loan::make(*before)); + break; default:; } } @@ -164,6 +198,11 @@ ValidVault::visitEntry(bool isDelete, SLE::const_ref before, SLE::const_ref afte sign = -1; break; } + case ltLOAN: + // A loan carries no vault balance of its own; capture it so the + // loan set invariant can verify the interest booked to the vault. + afterLoan_.push_back(Loan::make(*after)); + break; default:; } } @@ -423,7 +462,10 @@ ValidVault::finalize( { auto const& beforeVault = beforeVault_[0]; if (afterVault.asset != beforeVault.asset || afterVault.pseudoId != beforeVault.pseudoId || - afterVault.shareMPTID != beforeVault.shareMPTID) + afterVault.shareMPTID != beforeVault.shareMPTID || + afterVault.owner != beforeVault.owner || + afterVault.withdrawalPolicy != beforeVault.withdrawalPolicy || + afterVault.scale != beforeVault.scale) { JLOG(j.fatal()) << "Invariant failed: violation of vault immutable data"; result = false; @@ -1042,11 +1084,314 @@ ValidVault::finalize( return result; } - case ttLOAN_SET: - case ttLOAN_MANAGE: + case ttLOAN_SET: { + bool result = true; + + XRPL_ASSERT( + !beforeVault_.empty(), "xrpl::ValidVault::finalize : loan set updated a vault"); + auto const& beforeVault = beforeVault_[0]; + + // Funding a loan moves the requested principal out of the vault + // pseudo-account to the borrower (and, if any, the origination + // fee to the broker owner). + auto const maybeVaultDeltaAssets = deltaAssets(afterVault.pseudoId); + if (!maybeVaultDeltaAssets) + { + JLOG(j.fatal()) << // + "Invariant failed: loan set must change vault balance"; + return false; // That's all we can do + } + + // Get the posterior scale to round calculations to + auto const minScale = computeVaultMinScale(*maybeVaultDeltaAssets, view.rules()); + + // The vault only releases the requested principal, which must + // match the reduction of both the vault (pseudo-account) balance + // and the assets available. + auto const principalDelta = + roundToAsset(vaultAsset, -tx[sfPrincipalRequested], minScale); + + auto const vaultDeltaAssets = + roundToAsset(vaultAsset, maybeVaultDeltaAssets->delta, minScale); + if (vaultDeltaAssets != principalDelta) + { + JLOG(j.fatal()) << // + "Invariant failed: loan set must decrease vault balance " + "by the principal requested"; + result = false; + } + + auto const assetAvailableDelta = roundToAsset( + vaultAsset, afterVault.assetsAvailable - beforeVault.assetsAvailable, minScale); + if (assetAvailableDelta != principalDelta) + { + JLOG(j.fatal()) << // + "Invariant failed: loan set must decrease assets " + "available by the principal requested"; + result = false; + } + + // A loan set must create exactly one loan object; the interest + // it books is the only permitted change to assets outstanding. + if (afterLoan_.size() != 1) + { + JLOG(j.fatal()) << // + "Invariant failed: loan set must create exactly one loan"; + return false; // That's all we can do + } + auto const& loan = afterLoan_[0]; + + // Interest accrues to the vault: assets outstanding must grow by + // exactly the interest due booked on the newly created loan. + auto const assetsTotalDelta = roundToAsset( + vaultAsset, afterVault.assetsTotal - beforeVault.assetsTotal, minScale); + auto const interestDue = roundToAsset(vaultAsset, loan.interestDue(), minScale); + if (assetsTotalDelta != interestDue) + { + JLOG(j.fatal()) << // + "Invariant failed: loan set must increase assets " + "outstanding by the interest due"; + result = false; + } + + if (afterVault.assetsMaximum > kZero && + afterVault.assetsTotal > afterVault.assetsMaximum) + { + JLOG(j.fatal()) << // + "Invariant failed: loan set assets outstanding must not " + "exceed assets maximum"; + result = false; + } + + // A loan set neither mints nor burns vault shares. + if (beforeShares && updatedShares && + beforeShares->sharesTotal != updatedShares->sharesTotal) + { + JLOG(j.fatal()) << // + "Invariant failed: loan set must not change shares outstanding"; + result = false; + } + + return result; + } + + case ttLOAN_MANAGE: { + bool result = true; + + XRPL_ASSERT( + !beforeVault_.empty(), + "xrpl::ValidVault::finalize : loan manage updated a vault"); + auto const& beforeVault = beforeVault_[0]; + + // Loan management (impair / unimpair / default) never removes + // assets from the vault. Only a default returns first-loss + // capital from the broker to the vault pseudo-account; impair + // and unimpair merely adjust the paper (unrealized) loss and + // touch no balances. + auto const maybeVaultDeltaAssets = deltaAssets(afterVault.pseudoId); + auto const vaultDelta = maybeVaultDeltaAssets.value_or( + DeltaInfo{.delta = kZero, .scale = scale(afterVault.assetsTotal, vaultAsset)}); + + // Get the posterior scale to round calculations to + auto const minScale = computeVaultMinScale(vaultDelta, view.rules()); + + auto const vaultDeltaAssets = roundToAsset(vaultAsset, vaultDelta.delta, minScale); + auto const assetAvailableDelta = roundToAsset( + vaultAsset, afterVault.assetsAvailable - beforeVault.assetsAvailable, minScale); + auto const assetTotalDelta = roundToAsset( + vaultAsset, afterVault.assetsTotal - beforeVault.assetsTotal, minScale); + + // --- Checks common to every loan manage sub-operation --- + + // Assets available always tracks the real vault balance: any + // change to one is matched by the other. + if (assetAvailableDelta != vaultDeltaAssets) + { + JLOG(j.fatal()) << // + "Invariant failed: loan manage vault balance and assets " + "available must add up"; + result = false; + } + + // Loan management adjusts the unrealized (paper) loss but must + // never drive it negative. + if (afterVault.lossUnrealized < kZero) + { + JLOG(j.fatal()) << // + "Invariant failed: loan manage must not make loss " + "unrealized negative"; + result = false; + } + + // A loan manage neither mints nor burns vault shares. + if (beforeShares && updatedShares && + beforeShares->sharesTotal != updatedShares->sharesTotal) + { + JLOG(j.fatal()) << // + "Invariant failed: loan manage must not change shares " + "outstanding"; + result = false; + } + + // --- Checks specific to each loan manage sub-operation --- + + if (tx.isFlag(tfLoanImpair) || tx.isFlag(tfLoanUnimpair)) + { + // Impair / unimpair only move the paper (unrealized) loss; + // they touch neither balances nor assets. + if (assetAvailableDelta != kZero) + { + JLOG(j.fatal()) << // + "Invariant failed: loan impair/unimpair must not " + "change assets available"; + result = false; + } + + if (assetTotalDelta != kZero) + { + JLOG(j.fatal()) << // + "Invariant failed: loan impair/unimpair must not " + "change assets outstanding"; + result = false; + } + } + else if (tx.isFlag(tfLoanDefault)) + { + // A default returns first-loss capital to the vault, so + // assets available (and the vault balance) may only grow. + if (assetAvailableDelta < kZero) + { + JLOG(j.fatal()) << // + "Invariant failed: loan default must not decrease " + "assets available"; + result = false; + } + + // A default realizes (writes off) the uncovered portion of + // the loan, so assets outstanding may only shrink. + if (assetTotalDelta > kZero) + { + JLOG(j.fatal()) << // + "Invariant failed: loan default must not increase " + "assets outstanding"; + result = false; + } + } + + return result; + } + case ttLOAN_PAY: { - // TBD - return true; + bool result = true; + + XRPL_ASSERT( + !beforeVault_.empty(), "xrpl::ValidVault::finalize : loan pay updated a vault"); + auto const& beforeVault = beforeVault_[0]; + + // A loan payment moves the paid principal and interest into the + // vault pseudo-account (fees go to the broker), so the vault + // balance and the assets available both increase by that amount. + auto const maybeVaultDeltaAssets = deltaAssets(afterVault.pseudoId); + if (!maybeVaultDeltaAssets) + { + JLOG(j.fatal()) << // + "Invariant failed: loan pay must change vault balance"; + return false; // That's all we can do + } + + // Get the posterior scale to round calculations to + auto const minScale = computeVaultMinScale(*maybeVaultDeltaAssets, view.rules()); + + auto const vaultDeltaAssets = + roundToAsset(vaultAsset, maybeVaultDeltaAssets->delta, minScale); + auto const assetAvailableDelta = roundToAsset( + vaultAsset, afterVault.assetsAvailable - beforeVault.assetsAvailable, minScale); + + // Assets available always tracks the real vault balance: any + // change to one is matched by the other. + if (assetAvailableDelta != vaultDeltaAssets) + { + JLOG(j.fatal()) << // + "Invariant failed: loan pay vault balance and assets " + "available must add up"; + result = false; + } + + // A payment always adds funds to the vault, so assets available + // (and the vault balance) must increase. + if (assetAvailableDelta <= kZero) + { + JLOG(j.fatal()) << // + "Invariant failed: loan pay must increase assets " + "available"; + result = false; + } + + // The vault only receives the principal and interest portion of + // the borrower's payment (the fee goes to the broker), so it can + // never grow by more than the amount paid. + auto const amountPaid = roundToAsset(vaultAsset, tx[sfAmount], minScale); + if (assetAvailableDelta > amountPaid) + { + JLOG(j.fatal()) << // + "Invariant failed: loan pay must not increase assets " + "available by more than the amount paid"; + result = false; + } + + // A payment unimpairs the loan, so it may reduce the unrealized + // (paper) loss, but must never drive it negative. + if (afterVault.lossUnrealized < kZero) + { + JLOG(j.fatal()) << // + "Invariant failed: loan pay must not make loss " + "unrealized negative"; + result = false; + } + + // A loan pay neither mints nor burns vault shares. + if (beforeShares && updatedShares && + beforeShares->sharesTotal != updatedShares->sharesTotal) + { + JLOG(j.fatal()) << // + "Invariant failed: loan pay must not change shares " + "outstanding"; + result = false; + } + + // The vault's total assets equal its available cash plus the + // claim it holds on outstanding loans (each loan's total value + // owed, less the broker's management fee, which belongs to the + // broker). A payment only moves value between those two pools, + // so the change in assets outstanding must equal the cash + // received plus the change in the paid loan's claim on the + // vault. This is an independent check that the borrower's + // payment was split correctly between principal and interest. + if (afterLoan_.size() != 1 || beforeLoan_.size() != 1 || + afterLoan_[0].key != beforeLoan_[0].key) + { + JLOG(j.fatal()) << // + "Invariant failed: loan pay must modify exactly one " + "loan"; + result = false; + } + else + { + auto const claimDelta = roundToAsset( + vaultAsset, afterLoan_[0].claim() - beforeLoan_[0].claim(), minScale); + auto const assetsTotalDelta = roundToAsset( + vaultAsset, afterVault.assetsTotal - beforeVault.assetsTotal, minScale); + if (assetsTotalDelta != assetAvailableDelta + claimDelta) + { + JLOG(j.fatal()) << // + "Invariant failed: loan pay assets outstanding must " + "match the cash received and the change in the loan " + "claim"; + result = false; + } + } + + return result; } default: diff --git a/src/test/app/Invariants_test.cpp b/src/test/app/Invariants_test.cpp index 79fdbb54ed..bfc7c7296d 100644 --- a/src/test/app/Invariants_test.cpp +++ b/src/test/app/Invariants_test.cpp @@ -2667,6 +2667,15 @@ class Invariants_test : public beast::unit_test::Suite AccountID account; int amount; }; + // Parameters for a synthetic loan object created alongside a vault + // adjustment. The interest due booked to the vault is + // totalValueOutstanding - principalOutstanding - managementFeeOutstanding. + struct LoanParams + { + int principalOutstanding = 0; + int totalValueOutstanding = 0; + int managementFeeOutstanding = 0; + }; struct Adjustments { // NOLINTBEGIN(readability-redundant-member-init) @@ -2678,6 +2687,10 @@ class Invariants_test : public beast::unit_test::Suite std::optional vaultAssets = std::nullopt; std::optional accountAssets = std::nullopt; std::optional accountShares = std::nullopt; + std::optional createLoan = std::nullopt; + // Number of loan objects to create (only used when createLoan is + // set); a valid loan set creates exactly one. + int loanCount = 1; // NOLINTEND(readability-redundant-member-init) }; constexpr auto kAdjust = [&](ApplyView& ac, xrpl::Keylet keylet, Adjustments args) { @@ -2776,6 +2789,26 @@ class Invariants_test : public beast::unit_test::Suite (*sleMPToken)[sfMPTAmount] = *(*sleMPToken)[sfMPTAmount] + pair.amount; ac.update(sleMPToken); } + + if (args.createLoan) + { + auto const& lp = *args.createLoan; + bool const anyOutstanding = lp.principalOutstanding != 0 || + lp.totalValueOutstanding != 0 || lp.managementFeeOutstanding != 0; + for (std::uint32_t seq = 1; seq <= static_cast(args.loanCount); + ++seq) + { + auto sleLoan = std::make_shared(keylet::loan(keylet.key, seq)); + sleLoan->at(sfPrincipalOutstanding) = Number(lp.principalOutstanding); + sleLoan->at(sfTotalValueOutstanding) = Number(lp.totalValueOutstanding); + sleLoan->at(sfManagementFeeOutstanding) = Number(lp.managementFeeOutstanding); + // ValidLoan requires a positive periodic payment, and that a + // loan with payments remaining is not fully paid off. + sleLoan->at(sfPeriodicPayment) = Number(1); + sleLoan->setFieldU32(sfPaymentRemaining, anyOutstanding ? 1 : 0); + ac.insert(sleLoan); + } + } return true; }; @@ -3361,6 +3394,411 @@ class Invariants_test : public beast::unit_test::Suite precloseXrp, TxAccount::A2); + testcase << "Vault loan operations"; + + // ttLOAN_SET: the vault (pseudo-account) balance must change + doInvariantCheck( + {"loan set must change vault balance"}, + [&](Account const& a1, Account const& a2, ApplyContext& ac) { + auto const keylet = keylet::vault(a1.id(), ac.view().seq()); + return kAdjust(ac.view(), keylet, Adjustments{}); + }, + XRPAmount{}, + STTx{ttLOAN_SET, [](STObject& tx) { tx.at(sfPrincipalRequested) = Number(200); }}, + {tecINVARIANT_FAILED, tecINVARIANT_FAILED}, + precloseXrp); + + // ttLOAN_SET: the balance decreases, but not by the principal requested + doInvariantCheck( + {"loan set must decrease vault balance by the principal requested", + "loan set must decrease assets available by the principal requested"}, + [&](Account const& a1, Account const& a2, ApplyContext& ac) { + auto const keylet = keylet::vault(a1.id(), ac.view().seq()); + return kAdjust( + ac.view(), + keylet, + Adjustments{ + .assetsAvailable = -100, + .vaultAssets = -100, + .accountAssets = AccountAmount{.account = a2.id(), .amount = 100}, + .createLoan = LoanParams{}}); + }, + XRPAmount{}, + STTx{ttLOAN_SET, [](STObject& tx) { tx.at(sfPrincipalRequested) = Number(200); }}, + {tecINVARIANT_FAILED, tecINVARIANT_FAILED}, + precloseXrp); + + // ttLOAN_SET: principal matches, but no loan object is created + doInvariantCheck( + {"loan set must create exactly one loan"}, + [&](Account const& a1, Account const& a2, ApplyContext& ac) { + auto const keylet = keylet::vault(a1.id(), ac.view().seq()); + return kAdjust( + ac.view(), + keylet, + Adjustments{ + .assetsAvailable = -200, + .vaultAssets = -200, + .accountAssets = AccountAmount{.account = a2.id(), .amount = 200}}); + }, + XRPAmount{}, + STTx{ttLOAN_SET, [](STObject& tx) { tx.at(sfPrincipalRequested) = Number(200); }}, + {tecINVARIANT_FAILED, tecINVARIANT_FAILED}, + precloseXrp); + + // ttLOAN_SET: principal matches, but more than one loan is created + doInvariantCheck( + {"loan set must create exactly one loan"}, + [&](Account const& a1, Account const& a2, ApplyContext& ac) { + auto const keylet = keylet::vault(a1.id(), ac.view().seq()); + return kAdjust( + ac.view(), + keylet, + Adjustments{ + .assetsAvailable = -200, + .vaultAssets = -200, + .accountAssets = AccountAmount{.account = a2.id(), .amount = 200}, + .createLoan = LoanParams{}, + .loanCount = 2}); + }, + XRPAmount{}, + STTx{ttLOAN_SET, [](STObject& tx) { tx.at(sfPrincipalRequested) = Number(200); }}, + {tecINVARIANT_FAILED, tecINVARIANT_FAILED}, + precloseXrp); + + // ttLOAN_SET: principal matches, but assets outstanding change does not + // equal the interest due booked on the created loan (0 here) + doInvariantCheck( + {"loan set must increase assets outstanding by the interest due"}, + [&](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 = -50, + .assetsAvailable = -200, + .vaultAssets = -200, + .accountAssets = AccountAmount{.account = a2.id(), .amount = 200}, + .createLoan = LoanParams{}}); + }, + XRPAmount{}, + STTx{ttLOAN_SET, [](STObject& tx) { tx.at(sfPrincipalRequested) = Number(200); }}, + {tecINVARIANT_FAILED, tecINVARIANT_FAILED}, + precloseXrp); + + // ttLOAN_SET: principal matches, but shares outstanding changes + doInvariantCheck( + {"loan set must not change shares 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{ + .assetsAvailable = -200, + .sharesTotal = 10, + .vaultAssets = -200, + .accountAssets = AccountAmount{.account = a2.id(), .amount = 200}, + .accountShares = AccountAmount{.account = a2.id(), .amount = 10}, + .createLoan = LoanParams{}}); + }, + XRPAmount{}, + STTx{ttLOAN_SET, [](STObject& tx) { tx.at(sfPrincipalRequested) = Number(200); }}, + {tecINVARIANT_FAILED, tecINVARIANT_FAILED}, + precloseXrp); + + // ttLOAN_MANAGE: vault balance and assets available do not add up + doInvariantCheck( + {"loan manage vault balance and assets available must add up"}, + [&](Account const& a1, Account const& a2, ApplyContext& ac) { + auto const keylet = keylet::vault(a1.id(), ac.view().seq()); + return kAdjust(ac.view(), keylet, Adjustments{.assetsAvailable = -100}); + }, + XRPAmount{}, + STTx{ttLOAN_MANAGE, [](STObject&) {}}, + {tecINVARIANT_FAILED, tecINVARIANT_FAILED}, + precloseXrp); + + // ttLOAN_MANAGE: loss unrealized driven negative + doInvariantCheck( + {"loan manage must not make loss unrealized negative"}, + [&](Account const& a1, Account const& a2, ApplyContext& ac) { + auto const keylet = keylet::vault(a1.id(), ac.view().seq()); + return kAdjust(ac.view(), keylet, Adjustments{.lossUnrealized = -1}); + }, + XRPAmount{}, + STTx{ttLOAN_MANAGE, [](STObject&) {}}, + {tecINVARIANT_FAILED, tecINVARIANT_FAILED}, + precloseXrp); + + // ttLOAN_MANAGE: shares outstanding changes + doInvariantCheck( + {"loan manage must not change shares 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{ + .sharesTotal = 10, + .accountShares = AccountAmount{.account = a2.id(), .amount = 10}}); + }, + XRPAmount{}, + STTx{ttLOAN_MANAGE, [](STObject&) {}}, + {tecINVARIANT_FAILED, tecINVARIANT_FAILED}, + precloseXrp); + + // ttLOAN_MANAGE (impair): assets available must not change + doInvariantCheck( + {"loan impair/unimpair must not change assets available"}, + [&](Account const& a1, Account const& a2, ApplyContext& ac) { + auto const keylet = keylet::vault(a1.id(), ac.view().seq()); + return kAdjust( + ac.view(), + keylet, + Adjustments{ + .assetsAvailable = -100, + .vaultAssets = -100, + .accountAssets = AccountAmount{.account = a2.id(), .amount = 100}}); + }, + XRPAmount{}, + STTx{ttLOAN_MANAGE, [](STObject& tx) { tx.setFieldU32(sfFlags, tfLoanImpair); }}, + {tecINVARIANT_FAILED, tecINVARIANT_FAILED}, + precloseXrp); + + // ttLOAN_MANAGE (default): assets available must not decrease + doInvariantCheck( + {"loan default must not decrease assets available"}, + [&](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, + .assetsAvailable = -100, + .vaultAssets = -100, + .accountAssets = AccountAmount{.account = a2.id(), .amount = 100}}); + }, + XRPAmount{}, + STTx{ttLOAN_MANAGE, [](STObject& tx) { tx.setFieldU32(sfFlags, tfLoanDefault); }}, + {tecINVARIANT_FAILED, tecINVARIANT_FAILED}, + precloseXrp); + + // ttLOAN_MANAGE (default): assets outstanding must not increase + doInvariantCheck( + {"loan default must not increase 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, + .assetsAvailable = 100, + .vaultAssets = 100, + .accountAssets = AccountAmount{.account = a2.id(), .amount = -100}}); + }, + XRPAmount{}, + STTx{ttLOAN_MANAGE, [](STObject& tx) { tx.setFieldU32(sfFlags, tfLoanDefault); }}, + {tecINVARIANT_FAILED, tecINVARIANT_FAILED}, + precloseXrp); + + // ttLOAN_PAY: the vault (pseudo-account) balance must change + doInvariantCheck( + {"loan pay must change vault balance"}, + [&](Account const& a1, Account const& a2, ApplyContext& ac) { + auto const keylet = keylet::vault(a1.id(), ac.view().seq()); + return kAdjust(ac.view(), keylet, Adjustments{}); + }, + XRPAmount{}, + STTx{ttLOAN_PAY, [](STObject& tx) { tx.setFieldAmount(sfAmount, XRPAmount(200)); }}, + {tecINVARIANT_FAILED, tecINVARIANT_FAILED}, + precloseXrp); + + // ttLOAN_PAY: assets available must increase + doInvariantCheck( + {"loan pay must increase assets available"}, + [&](Account const& a1, Account const& a2, ApplyContext& ac) { + auto const keylet = keylet::vault(a1.id(), ac.view().seq()); + return kAdjust( + ac.view(), + keylet, + Adjustments{ + .assetsAvailable = -100, + .vaultAssets = -100, + .accountAssets = AccountAmount{.account = a2.id(), .amount = 100}}); + }, + XRPAmount{}, + STTx{ttLOAN_PAY, [](STObject& tx) { tx.setFieldAmount(sfAmount, XRPAmount(200)); }}, + {tecINVARIANT_FAILED, tecINVARIANT_FAILED}, + precloseXrp); + + // ttLOAN_PAY: assets available increases by more than the amount paid + doInvariantCheck( + {"loan pay must not increase assets available by more than the amount paid"}, + [&](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 = 300, + .assetsAvailable = 300, + .vaultAssets = 300, + .accountAssets = AccountAmount{.account = a2.id(), .amount = -300}}); + }, + XRPAmount{}, + STTx{ttLOAN_PAY, [](STObject& tx) { tx.setFieldAmount(sfAmount, XRPAmount(100)); }}, + {tecINVARIANT_FAILED, tecINVARIANT_FAILED}, + precloseXrp); + + // ttLOAN_PAY: shares outstanding changes + doInvariantCheck( + {"loan pay must not change shares 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, + .assetsAvailable = 100, + .sharesTotal = 10, + .vaultAssets = 100, + .accountAssets = AccountAmount{.account = a2.id(), .amount = -100}, + .accountShares = AccountAmount{.account = a2.id(), .amount = 10}}); + }, + XRPAmount{}, + STTx{ttLOAN_PAY, [](STObject& tx) { tx.setFieldAmount(sfAmount, XRPAmount(200)); }}, + {tecINVARIANT_FAILED, tecINVARIANT_FAILED}, + precloseXrp); + + // ttLOAN_PAY: assets outstanding must match the cash received and the + // change in the paid loan's claim on the vault. A payment only moves + // value between the vault's available cash and its claim on the loan, + // so bumping assets outstanding by more than that must be caught. This + // needs a loan that already exists in the base ledger (so modifying it + // is seen as a before/after change), which the shared harness cannot + // set up, hence the bespoke view construction below. + { + Env env{*this, defaultAmendments()}; + Account const a1{"A1"}; + Account const a2{"A2"}; + env.fund(XRP(1000), a1, a2); + BEAST_EXPECT(precloseXrp(a1, a2, env)); + env.close(); + + OpenView ov{*env.current()}; + + auto const vaultKeylet = keylet::vault(a1.id(), ov.seq()); + // Insert a pre-existing loan into the base view; modifying it in the + // apply view is then seen as a loan modification (before/after). + auto const loanKeylet = keylet::loan(vaultKeylet.key, 1); + { + auto sleLoan = std::make_shared(loanKeylet); + sleLoan->at(sfPrincipalOutstanding) = Number(100); + sleLoan->at(sfTotalValueOutstanding) = Number(150); + sleLoan->at(sfManagementFeeOutstanding) = Number(0); + sleLoan->at(sfPeriodicPayment) = Number(1); + sleLoan->setFieldU32(sfPaymentRemaining, 1); + ov.rawInsert(sleLoan); + } + + STTx const tx{ + ttLOAN_PAY, [](STObject& t) { t.setFieldAmount(sfAmount, XRPAmount(100)); }}; + test::StreamSink sink{beast::Severity::Warning}; + beast::Journal const jlog{sink}; + ApplyContext ac{ + env.app(), ov, tx, tesSUCCESS, env.current()->fees().base, TapNone, jlog}; + CurrentTransactionRulesGuard const rulesGuard(ov.rules()); + + // Cash received: assets available and the vault balance both grow by + // 60, paid by a2. The paid loan's claim drops by 60 (total value + // 150 -> 90). Conservation requires assets outstanding to be + // unchanged, but we bump it by 10 to violate the identity. + if (!BEAST_EXPECT(kAdjust( + ac.view(), + vaultKeylet, + Adjustments{ + .assetsTotal = 10, + .assetsAvailable = 60, + .vaultAssets = 60, + .accountAssets = AccountAmount{.account = a2.id(), .amount = -60}}))) + return; + + auto sleLoan = ac.view().peek(loanKeylet); + if (!BEAST_EXPECT(sleLoan)) + return; + sleLoan->at(sfPrincipalOutstanding) = Number(40); + sleLoan->at(sfTotalValueOutstanding) = Number(90); + ac.view().update(sleLoan); + + auto transactor = makeTransactor(ac); + if (!BEAST_EXPECT(transactor)) + return; + TER const result = transactor->checkInvariants(tesSUCCESS, XRPAmount{}); + BEAST_EXPECT(result == tecINVARIANT_FAILED); + BEAST_EXPECT(sink.messages().str().contains( + "loan pay assets outstanding must match the cash received and " + "the change in the loan claim")); + } + + // ttVAULT_SET: owner is immutable + doInvariantCheck( + {"violation of vault immutable data"}, + [&](Account const& a1, Account const& a2, ApplyContext& ac) { + auto const keylet = keylet::vault(a1.id(), ac.view().seq()); + auto sleVault = ac.view().peek(keylet); + if (!sleVault) + return false; + sleVault->setAccountID(sfOwner, a2.id()); + ac.view().update(sleVault); + return true; + }, + XRPAmount{}, + STTx{ttVAULT_SET, [](STObject& tx) {}}, + {tecINVARIANT_FAILED, tecINVARIANT_FAILED}, + precloseXrp); + + // ttVAULT_SET: withdrawal policy is immutable + doInvariantCheck( + {"violation of vault immutable data"}, + [&](Account const& a1, Account const& a2, ApplyContext& ac) { + auto const keylet = keylet::vault(a1.id(), ac.view().seq()); + auto sleVault = ac.view().peek(keylet); + if (!sleVault) + return false; + sleVault->setFieldU8( + sfWithdrawalPolicy, + static_cast(sleVault->getFieldU8(sfWithdrawalPolicy) + 1)); + ac.view().update(sleVault); + return true; + }, + XRPAmount{}, + STTx{ttVAULT_SET, [](STObject& tx) {}}, + {tecINVARIANT_FAILED, tecINVARIANT_FAILED}, + precloseXrp); + + // ttVAULT_SET: scale is immutable + doInvariantCheck( + {"violation of vault immutable data"}, + [&](Account const& a1, Account const& a2, ApplyContext& ac) { + auto const keylet = keylet::vault(a1.id(), ac.view().seq()); + auto sleVault = ac.view().peek(keylet); + if (!sleVault) + return false; + sleVault->setFieldU8( + sfScale, static_cast(sleVault->getFieldU8(sfScale) + 1)); + ac.view().update(sleVault); + return true; + }, + XRPAmount{}, + STTx{ttVAULT_SET, [](STObject& tx) {}}, + {tecINVARIANT_FAILED, tecINVARIANT_FAILED}, + precloseXrp); + testcase << "Vault create"; doInvariantCheck( {