diff --git a/.cspell.config.yaml b/.cspell.config.yaml index e220cd0249..36b63f6533 100644 --- a/.cspell.config.yaml +++ b/.cspell.config.yaml @@ -366,3 +366,4 @@ words: - xxhash - xxhasher - CGNAT + - unimpairs 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 136c6c4a25..8936ce8b4d 100644 --- a/include/xrpl/tx/invariants/VaultInvariant.h +++ b/include/xrpl/tx/invariants/VaultInvariant.h @@ -36,6 +36,22 @@ 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; 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 + * 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 +71,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 +86,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 +120,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/LoanInvariant.cpp b/src/libxrpl/tx/invariants/LoanInvariant.cpp index ce9a7c6e03..9d25cebf2b 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 @@ -77,6 +86,15 @@ ValidLoan::finalize( return false; } } + // Interest due (the total value owed less principal and management fee) + // must never be negative. + if (after->at(sfTotalValueOutstanding) - after->at(sfPrincipalOutstanding) - + after->at(sfManagementFeeOutstanding) < + beast::kZero) + { + JLOG(j.fatal()) << "Invariant failed: Loan interest due is negative"; + return false; + } // Must be positive - STNumber for (auto const field : { &sfPeriodicPayment, @@ -90,6 +108,30 @@ 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 || + loan->at(sfTotalValueOutstanding) != beast::kZero || + loan->at(sfPrincipalOutstanding) != beast::kZero || + loan->at(sfManagementFeeOutstanding) != beast::kZero) + { + 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 a9ba0ec874..9e2c2a5223 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,13 @@ 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 and loan pay invariants can verify the interest + // booked to the vault and the change in the vault's claim on + // the loan. + afterLoan_.push_back(Loan::make(*after)); + break; default:; } } @@ -426,7 +467,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; @@ -1045,10 +1089,337 @@ ValidVault::finalize( return result; } - case ttLOAN_SET: - case ttLOAN_MANAGE: - case ttLOAN_PAY: - return true; + case ttLOAN_SET: { + bool result = true; + + XRPL_ASSERT( + !beforeVault_.empty(), "xrpl::ValidVault::finalize : loan set updated a vault"); + auto const& beforeVault = beforeVault_[0]; + + // 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 || !beforeLoan_.empty()) + { + 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]; + + // 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; + } + + // The created loan must record exactly the principal the vault + // released. Otherwise the borrower's claim (and thus the assets + // booked back to the vault on repayment) is decoupled from the + // assets actually lent, which would skew the vault's share price. + if (loan.principalOutstanding != tx[sfPrincipalRequested]) + { + JLOG(j.fatal()) << // + "Invariant failed: loan set principal outstanding must " + "equal principal requested"; + result = false; + } + + // 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; + } + } + 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; + } + + case ttLOAN_PAY: { + 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: // LCOV_EXCL_START @@ -1060,8 +1431,8 @@ ValidVault::finalize( if (!result) { - // The comment at the top of this file starting with "assert(enforce)" - // explains this assert. + // The comment in InvariantCheckPrivilege.h starting with + // "assert(enforce)" explains this assert. XRPL_ASSERT(enforce, "xrpl::ValidVault::finalize : vault invariants"); return !enforce; } diff --git a/src/test/app/Invariants_test.cpp b/src/test/app/Invariants_test.cpp index 076e39a42b..b56ac2a796 100644 --- a/src/test/app/Invariants_test.cpp +++ b/src/test/app/Invariants_test.cpp @@ -7,6 +7,7 @@ #include #include #include +#include #include #include #include @@ -2767,6 +2768,16 @@ 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; + AccountID borrower = beast::kZero; + }; struct Adjustments { // NOLINTBEGIN(readability-redundant-member-init) @@ -2778,6 +2789,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) { @@ -2876,6 +2891,28 @@ 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); + sleLoan->setAccountID(sfBorrower, lp.borrower); + sleLoan->makeFieldPresent(sfOwnerNode); + ac.insert(sleLoan); + } + } return true; }; @@ -3461,6 +3498,709 @@ class Invariants_test : public beast::unit_test::Suite precloseXrp, TxAccount::A2); + 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: exactly one loan is created, but the vault + // (pseudo-account) balance does not change, so only the balance check + // trips. A loan must be created so the "exactly one loan" check passes + // first and the balance check is actually reached. + 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{ + .createLoan = LoanParams{ + .principalOutstanding = 200, + .totalValueOutstanding = 200, + .borrower = a1.id(), + }}); + }, + 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{ + .principalOutstanding = 200, + .totalValueOutstanding = 200, + .borrower = a1.id(), + }}); + }, + XRPAmount{}, + STTx{ + ttLOAN_SET, + [](STObject& tx) { + tx.at(sfPrincipalRequested) = Number(200); + tx.makeFieldPresent(sfCounterpartySignature); + }}, + {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{ + .principalOutstanding = 100, + .totalValueOutstanding = 100, + .borrower = a1.id(), + }, + .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{ + .principalOutstanding = 200, + .totalValueOutstanding = 200, + .borrower = a1.id(), + }}); + }, + XRPAmount{}, + STTx{ + ttLOAN_SET, + [](STObject& tx) { + tx.at(sfPrincipalRequested) = Number(200); + tx.makeFieldPresent(sfCounterpartySignature); + }}, + {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{ + .principalOutstanding = 200, + .totalValueOutstanding = 200, + .borrower = a1.id(), + }}); + }, + XRPAmount{}, + STTx{ + ttLOAN_SET, + [](STObject& tx) { + tx.at(sfPrincipalRequested) = Number(200); + tx.makeFieldPresent(sfCounterpartySignature); + }}, + {tecINVARIANT_FAILED, tecINVARIANT_FAILED}, + precloseXrp); + + // ttLOAN_SET: everything balances (principal released, exactly one loan + // created booking zero interest so assets outstanding is unchanged, and + // shares are untouched), but the vault has a positive assets maximum + // below its assets outstanding, so only the maximum check trips. + doInvariantCheck( + {"loan set assets outstanding must not exceed assets maximum"}, + [&](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, + .assetsMaximum = 1, + .vaultAssets = -200, + .accountAssets = AccountAmount{.account = a2.id(), .amount = 200}, + .createLoan = LoanParams{ + .principalOutstanding = 200, + .totalValueOutstanding = 200, + .borrower = a1.id(), + }}); + }, + XRPAmount{}, + STTx{ + ttLOAN_SET, + [](STObject& tx) { + tx.at(sfPrincipalRequested) = Number(200); + tx.makeFieldPresent(sfCounterpartySignature); + }}, + {tecINVARIANT_FAILED, tecINVARIANT_FAILED}, + precloseXrp); + + // ttLOAN_SET: everything balances (principal released, exactly one loan + // created booking zero interest, shares untouched, assets outstanding + // unchanged), but the created loan records a principal outstanding that + // differs from the principal requested. The vault only released 200 to + // the borrower, yet the loan claims 300 principal, decoupling the + // borrower's debt from the assets actually lent and enabling share-price + // manipulation on repayment. + doInvariantCheck( + {"loan set principal outstanding must equal 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 = -200, + .vaultAssets = -200, + .accountAssets = AccountAmount{.account = a2.id(), .amount = 200}, + .createLoan = LoanParams{ + .principalOutstanding = 300, + .totalValueOutstanding = 300, + .borrower = a1.id(), + }}); + }, + 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 track the real vault balance. The + // vault balance (pseudo-account) grows by 50 but assets available is + // bumped by 60, so the two no longer add up. + doInvariantCheck( + {"loan pay 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 = 60, + .vaultAssets = 50, + .accountAssets = AccountAmount{.account = a2.id(), .amount = -50}}); + }, + XRPAmount{}, + STTx{ttLOAN_PAY, [](STObject& tx) { tx.setFieldAmount(sfAmount, XRPAmount(100)); }}, + {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: loss unrealized driven negative. The cash inflow is + // valid, but loss unrealized is set below zero. + doInvariantCheck( + {"loan pay 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{ + .assetsTotal = 100, + .assetsAvailable = 100, + .lossUnrealized = -1, + .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 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")); + } + + // A loan may only be deleted by a LoanDelete transaction, and only once + // it is fully paid off. Both branches are exercised by creating a real + // loan in the Preclose (so it exists in the base ledger with outstanding + // principal) and then erasing it in the Precheck. + { + Keylet loanKeylet = keylet::amendments(); + auto const precloseLoan = [&loanKeylet, this]( + Account const& a1, Account const& a2, Env& env) -> bool { + PrettyAsset const xrpAsset{xrpIssue(), 1'000'000}; + + Vault const vault{env}; + auto [tx, vaultKeylet] = vault.create({.owner = a1, .asset = xrpAsset}); + env(tx); + env.close(); + if (!BEAST_EXPECT(env.le(vaultKeylet))) + return false; + + env(vault.deposit( + {.depositor = a1, .id = vaultKeylet.key, .amount = xrpAsset(100)})); + env.close(); + + auto const brokerKeylet = keylet::loanBroker(a1.id(), env.seq(a1)); + env(loanBroker::set(a1, vaultKeylet.key), Fee(kIncrement)); + env.close(); + auto const brokerSle = env.le(brokerKeylet); + if (!BEAST_EXPECT(brokerSle)) + return false; + + loanKeylet = keylet::loan(brokerKeylet.key, brokerSle->at(sfLoanSequence)); + env(loan::set(a2, brokerKeylet.key, xrpAsset(50).value()), + Sig(sfCounterpartySignature, a1), + Fee(env.current()->fees().base * 2)); + env.close(); + return BEAST_EXPECT(env.le(loanKeylet)); + }; + + auto const eraseLoan = [&loanKeylet](Account const&, Account const&, ApplyContext& ac) { + auto sle = ac.view().peek(loanKeylet); + if (!sle) + return false; + ac.view().erase(sle); + return true; + }; + + // Deleting the loan under any transaction type other than LoanDelete + // (here the neutral ttACCOUNT_SET) is a violation, even while the + // loan still has outstanding obligations: the transaction-type check + // fires before the not-fully-paid-off check. + doInvariantCheck( + {"Loan deleted by a transaction other than LoanDelete"}, + eraseLoan, + XRPAmount{}, + STTx{ttACCOUNT_SET, [](STObject&) {}}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, + precloseLoan); + + // Deleting the loan via LoanDelete while it still has outstanding + // obligations is a violation: the transaction-type check passes and + // the not-fully-paid-off check fires. + doInvariantCheck( + {"Loan deleted while not fully paid off"}, + eraseLoan, + XRPAmount{}, + STTx{ttLOAN_DELETE, [](STObject&) {}}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, + precloseLoan); + } + + // Loan interest due (total value less principal and management fee) + // must never be negative. A neutral transaction type is used so the + // vault invariants short-circuit and only the loan check fires. The + // loan object is created directly with principal 100, total value 90 + // and management fee 0, so interest due = 90 - 100 - 0 = -10 (< 0) + // while every individual field stays non-negative. + doInvariantCheck( + {"Loan interest due is negative"}, + [&](Account const& a1, Account const&, ApplyContext& ac) { + auto const vaultKeylet = keylet::vault(a1.id(), ac.view().seq()); + auto const loanKeylet = keylet::loan(vaultKeylet.key, 1); + auto sleLoan = std::make_shared(loanKeylet); + sleLoan->at(sfPrincipalOutstanding) = Number(100); + sleLoan->at(sfTotalValueOutstanding) = Number(90); + sleLoan->at(sfManagementFeeOutstanding) = Number(0); + sleLoan->at(sfPeriodicPayment) = Number(1); + sleLoan->setFieldU32(sfPaymentRemaining, 1); + ac.view().insert(sleLoan); + return true; + }); + + // Each of these loan STNumber fields must never be negative. A neutral + // transaction type is used so the vault invariants short-circuit and + // only the loan check fires. The loan is created directly with a single + // field set negative while the paid-off bookkeeping is kept consistent + // so that only the " is negative" check trips. + for (auto const field : { + &sfLoanServiceFee, + &sfLatePaymentFee, + &sfClosePaymentFee, + &sfPrincipalOutstanding, + &sfTotalValueOutstanding, + &sfManagementFeeOutstanding, + }) + { + // The outstanding-balance fields also feed the paid-off checks, so + // a loan carrying one must still have payments remaining; a loan + // with only a negative fee stays fully paid off (zero remaining). + bool const isOutstanding = *field == sfPrincipalOutstanding || + *field == sfTotalValueOutstanding || *field == sfManagementFeeOutstanding; + doInvariantCheck( + {field->getName() + " is negative"}, + [&, field](Account const& a1, Account const&, ApplyContext& ac) { + auto const vaultKeylet = keylet::vault(a1.id(), ac.view().seq()); + auto const loanKeylet = keylet::loan(vaultKeylet.key, 1); + auto sleLoan = std::make_shared(loanKeylet); + sleLoan->at(sfPrincipalOutstanding) = Number(0); + sleLoan->at(sfTotalValueOutstanding) = Number(0); + sleLoan->at(sfManagementFeeOutstanding) = Number(0); + sleLoan->at(sfPeriodicPayment) = Number(1); + sleLoan->at(*field) = Number(-10); + sleLoan->setFieldU32(sfPaymentRemaining, isOutstanding ? 1 : 0); + ac.view().insert(sleLoan); + return true; + }); + } + + // 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( {