diff --git a/include/xrpl/tx/invariants/InvariantCheck.h b/include/xrpl/tx/invariants/InvariantCheck.h index e8dafbd301..6b27a5e6c5 100644 --- a/include/xrpl/tx/invariants/InvariantCheck.h +++ b/include/xrpl/tx/invariants/InvariantCheck.h @@ -365,6 +365,11 @@ public: class ValidPseudoAccounts { std::vector errors_; + // Live pseudo-account entries touched by the transaction. Populated in + // visitEntry and consumed by finalize to cross-check owner-field + // resolution (e.g. VaultID -> vault whose sfAccount matches the + // pseudo-account). + std::vector pseudoAccounts_; public: void diff --git a/include/xrpl/tx/invariants/LoanBrokerInvariant.h b/include/xrpl/tx/invariants/LoanBrokerInvariant.h index 979f57de35..7b1c968901 100644 --- a/include/xrpl/tx/invariants/LoanBrokerInvariant.h +++ b/include/xrpl/tx/invariants/LoanBrokerInvariant.h @@ -1,14 +1,19 @@ #pragma once +#include #include #include #include +#include +#include #include #include #include #include #include +#include +#include #include namespace xrpl { @@ -34,24 +39,55 @@ class ValidLoanBroker }; // Collect all the LoanBrokers found directly or indirectly through // pseudo-accounts. Key is the brokerID / index. It will be used to find the - // LoanBroker object if brokerBefore and brokerAfter are nullptr + // LoanBroker object if brokerBefore and brokerAfter are nullptr. Populated + // for live (created or modified) brokers only; deletions go to + // deletedBrokers_ below so the live-broker check loop does not have to + // exempt the delete case. std::map brokers_; + // Pre-deletion snapshots of brokers erased by this transaction. Deletion + // preconditions (e.g. first-loss capital was returned to the owner) are + // stated positively against this collection, rather than by exempting the + // delete case from the live-broker checks. + std::vector deletedBrokers_; // Collect all the modified trust lines. Their high and low accounts will be // loaded to look for LoanBroker pseudo-accounts. std::vector lines_; // Collect all the modified MPTokens. Their accounts will be loaded to look // for LoanBroker pseudo-accounts. std::vector mpts_; + // Every touched (created or modified) balance-bearing entry, keyed by its + // ledger key. Used by the broker-deletion checks to compute the change in + // the owner's vault-asset balance and verify it matches the returned + // first-loss capital. Deletion-side entries are not stored: their + // after-state balance is zero by construction. + std::unordered_map> + touchedBalances_; static bool goodZeroDirectory(ReadView const& view, SLE::const_ref dir, beast::Journal const& j); + // Return the balance of @p id in @p asset held by @p sle. Handles XRP + // (ACCOUNT_ROOT), IOU (RIPPLE_STATE, sign-flipped per which side is @p id) + // and MPT (MPTOKEN). Returns 0 when @p sle is null, so callers can compute + // a delta uniformly across create / modify / delete transitions. + [[nodiscard]] static Number + balanceOf(SLE::const_ref sle, AccountID const& id, Asset const& asset); + public: void visitEntry(bool, SLE::const_ref, SLE::const_ref); + // The TER parameter is named because some checks (deltas, deletion + // post-conditions, participant-flow identities) are class-2 and must gate + // on isTesSuccess(result) to avoid firing against the fee-claim-only state + // the framework re-runs in InvariantScope::ProtocolOnly. bool - finalize(STTx const&, TER const, XRPAmount const, ReadView const&, beast::Journal const&); + finalize( + STTx const&, + TER const result, + XRPAmount const, + ReadView const&, + beast::Journal const&); }; } // namespace xrpl diff --git a/include/xrpl/tx/invariants/VaultInvariant.h b/include/xrpl/tx/invariants/VaultInvariant.h index 67445c5056..9dab1282cb 100644 --- a/include/xrpl/tx/invariants/VaultInvariant.h +++ b/include/xrpl/tx/invariants/VaultInvariant.h @@ -8,6 +8,7 @@ #include #include #include +#include #include #include #include @@ -36,22 +37,29 @@ 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 + * - every lending transaction touches exactly one loan: loan set creates one, + * loan manage and loan pay each modify one * - loan set moves the requested principal out of the vault: it must create * exactly one loan, and decreases assets available (and the vault balance) * by the principal * - 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), and assets outstanding may only shrink (realized - * loss); a loan manage with none of the sub-operation flags (impair, + * capital on a default, which leaves the loan-broker pseudo-account by the + * same amount), and assets outstanding may only shrink (realized loss); loss + * unrealized moves in the direction of the sub-operation (up on impair, down + * on unimpair and on default, as the paper loss is either reversed or + * realized); 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; the combined inflow to the vault pseudo-account, the * loan-broker pseudo-account and the loan-broker owner never exceeds the - * amount paid (no value is manufactured); 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 + * amount paid (no value is manufactured); the vault's claim on the paid loan + * may only shrink; 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 (the claim being the exposure the vault recognizes under its + * accounting basis), which verifies the payment was split correctly between + * principal and interest * - shares outstanding may only change through deposit, withdraw, or clawback * - no vault transaction can change loss unrealized (it's updated by loan * transactions) @@ -82,8 +90,12 @@ class ValidVault Number assetsAvailable = 0; Number assetsMaximum = 0; Number lossUnrealized = 0; + std::uint32_t flags = 0; std::uint8_t withdrawalPolicy = 0; std::uint8_t scale = 0; + // Recognition model (accrual vs. cash-basis) this vault was created + // with; absent sfLEVersion means the legacy, accrual-basis model. + VaultVersion version = VaultVersion::Legacy; std::optional vaultKind; std::optional subscriptionDate; std::optional redemptionDate; @@ -96,31 +108,86 @@ class ValidVault MPTIssue share; std::uint64_t sharesTotal = 0; std::uint64_t sharesMaximum = 0; + // Static share MPTokenIssuance fields snapshotted for post-creation + // invariants (Items 3, 4). These are all set at VaultCreate time and + // must not drift on any subsequent transaction. + std::uint16_t transferFee = 0; + std::uint8_t assetScale = 0; + std::uint32_t flags = 0; Shares static make(SLE const&); }; + // Change in an MPToken holder's balance for a single share issuance. The + // holder is preserved so a future check can point at the offending + // account rather than just reporting an aggregate mismatch. + struct ShareHoldingDelta final + { + AccountID holder; + Number delta = kNumZero; + }; + struct Loan final { uint256 key = beast::kZero; uint256 loanBrokerID = beast::kZero; + // Borrower of the loan. Snapshotted so the loan-set funding checks can + // verify the principal, net of the origination fee, was credited to + // this account. + AccountID borrower; + // Origination fee routed to the broker owner when the loan is funded. + // Absent on the ledger entry when zero; the snapshot normalizes to + // Number{0} in that case. + Number originationFee = 0; Number principalOutstanding = 0; Number totalValueOutstanding = 0; Number managementFeeOutstanding = 0; + // Whether lsfLoanImpaired was set on the ledger entry. Required by the + // LossUnrealized magnitude checks in finalizeLoanManage, which switch + // on the pre-transaction impairment state of a defaulted loan. + bool impaired = false; // 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). + // The vault's claim on the loan, i.e. its exposure to the loan. This is + // accounting-basis dependent: under accrual it is the total value owed + // less the broker's management fee (which belongs to the broker, not + // the vault); under cash-basis, where interest is only recognised once + // received, it is the outstanding principal alone. Mirrors + // loanVaultExposure in LendingHelpers.cpp. [[nodiscard]] Number - claim() const; + claim(VaultVersion version) const; + + // The vault's exposure to the loan, used by the LossUnrealized + // bookkeeping checks. Numerically identical to claim() under either + // basis (see the note above), but named separately so that + // impair / unimpair / default / pay checks read as "exposure" while + // the LoanPay assets-outstanding identity reads as "claim". + [[nodiscard]] Number + exposure(VaultVersion version) const; Loan static make(SLE const&); }; + // Snapshot of a LoanBroker ledger entry. Populated by visitEntry whenever a + // broker is touched by the transaction, so the lending-side finalizers can + // compute deltas on DebtTotal, CoverAvailable and OwnerCount without a + // separate read of the after-state. + struct Broker final + { + uint256 key = beast::kZero; + AccountID owner; + uint256 vaultID = beast::kZero; + Number debtTotal = 0; + Number coverAvailable = 0; + std::uint32_t ownerCount = 0; + + Broker static make(SLE const&); + }; + public: struct DeltaInfo final { @@ -136,10 +203,17 @@ private: std::vector afterVault_; std::vector afterMPTs_; std::vector afterLoan_; + std::vector afterBroker_; std::vector beforeVault_; std::vector beforeMPTs_; std::vector beforeLoan_; + std::vector beforeBroker_; std::unordered_map deltas_; + // Per-issuance holder-side share deltas, populated for every touched + // ltMPTOKEN. The universal share-conservation check in finalize sums + // these for the vault's own share issuance and matches the total against + // the issuance's OutstandingAmount delta. + std::unordered_map> shareHoldings_; /** * @brief Compute the minimum STAmount scale for rounding invariant @@ -149,6 +223,12 @@ private: * @c assetsTotal scale. Pre-amendment it is the coarsest scale across * @p vaultDelta and both asset-field deltas. * + * @pre @c afterVault_ is non-empty. Under the pre-amendment branch also + * @c beforeVault_ is non-empty. Both preconditions are asserted at + * runtime and hold for every current caller; the assert catches a + * future reuse (e.g. from a new transactor via @c checkLoanFunding) + * that reaches this helper without a snapshot. + * * @param vaultDelta Delta of the vault's asset balance for this transaction. * @param rules Active ledger rules (used to check the amendment). * @return The minimum scale to apply when rounding vault-related amounts. @@ -208,21 +288,85 @@ private: [[nodiscard]] static bool isVaultEmpty(Vault const& vault); + /** + * @brief Verify that the transaction touched exactly one loan. + * + * Every lending transaction operates on a single loan: @c ttLOAN_SET creates one, while + * @c ttLOAN_MANAGE and @c ttLOAN_PAY each modify one. The per-transaction checks below index + * the loan snapshots directly, so this must hold before they run. + * + * @param isCreate Whether the loan is expected to be created (rather than modified). + * @param j Journal for logging invariant failures. + * @return @c true when exactly one loan was created, respectively modified. + */ + [[nodiscard]] bool + exactlyOneLoan(bool isCreate, beast::Journal const& j) const; + + /** + * @brief Creation-side invariants of a loan-origination transaction. + * + * Enforces the phase gate (closed-ended vaults must be in the Investment phase to originate a + * loan; open-ended vaults fall through) and, under @c fixCleanup3_4_0, the exactly-one-loan + * cardinality expected of a creation. Extracted so the checks are callable from any transactor + * that ends up creating a loan (see the eventual @c LoanAccept split); reads the transaction's + * effect on the vault and loan snapshots directly from @c this. + * + * @param tx The transaction being applied. + * @param view Active ledger view (used for rules). + * @param j Journal for logging invariant failures. + * @return @c true when the creation-side invariants hold. + */ + [[nodiscard]] bool + checkLoanCreation(STTx const& tx, ReadView const& view, beast::Journal const& j) const; + + /** + * @brief Funding-side invariants of a loan-origination transaction. + * + * Verifies that the transaction moved the requested principal out of the vault + * pseudo-account, and that the created loan records exactly that principal. Under + * @c featureLendingProtocolV1_1 also verifies the participant-side accounting: the broker's + * @c DebtTotal grows by the new loan's exposure (basis-aware), the borrower and broker owner + * receive their respective portions of the principal, and the vault's @c AssetsTotal / + * @c AssetsAvailable / claim identity holds at origination. Assumes @c checkLoanCreation has + * already run and returned @c true (in particular that the loan cardinality is one), so it may + * index @c afterLoan_[0] directly. Extracted so a future @c LoanAccept transactor can reuse + * the funding checks independently of the creation ones. + * + * @param tx The transaction being applied. + * @param fee Fee charged by this transaction; added back when the fee-payer is one + * of the participants whose vault-asset flow we compare. + * @param view Active ledger view (used for rules). + * @param j Journal for logging invariant failures. + * @return @c true when the funding-side invariants hold. + */ + [[nodiscard]] bool + checkLoanFunding( + STTx const& tx, + XRPAmount fee, + ReadView const& view, + beast::Journal const& j) const; + /** * @brief Invariant check for @c ttLOAN_SET. * * For a closed-ended vault, a loan may only be originated while the vault is in the Investment * phase (strictly past @c SubscriptionDate and before @c RedemptionDate). Open-ended vaults (@c - * NoPhase) are unaffected. The complementary maturity bound (final payment strictly precedes @c - * RedemptionDate) is enforced by @c ValidLoan. + * NoPhase) are exempt from the phase gate only; the funding checks apply to every vault kind. + * The complementary maturity bound (final payment strictly precedes @c RedemptionDate) is + * enforced by @c ValidLoan. * * @param tx The transaction being applied. + * @param fee Fee charged by this transaction. * @param view Active ledger view (used for rules). * @param j Journal for logging invariant failures. * @return @c true when all @c ttLOAN_SET invariants hold. */ [[nodiscard]] bool - finalizeLoanSet(STTx const& tx, ReadView const& view, beast::Journal const& j) const; + finalizeLoanSet( + STTx const& tx, + XRPAmount fee, + ReadView const& view, + beast::Journal const& j) const; /** * @brief Enforce the invariants specific to a @c ttLOAN_MANAGE diff --git a/src/libxrpl/tx/invariants/InvariantCheck.cpp b/src/libxrpl/tx/invariants/InvariantCheck.cpp index e5bf44c02b..2076ab8a7a 100644 --- a/src/libxrpl/tx/invariants/InvariantCheck.cpp +++ b/src/libxrpl/tx/invariants/InvariantCheck.cpp @@ -1026,6 +1026,8 @@ ValidPseudoAccounts::visitEntry(bool isDelete, SLE::const_ref before, SLE::const }(); if (isPseudo) { + // Snapshot for finalize-time owner-field resolution (item 28). + pseudoAccounts_.push_back(after); // Pseudo accounts must have the following properties: // 1. Exactly one of the pseudo-account fields is set. // 2. The sequence number is not changed. @@ -1091,6 +1093,43 @@ ValidPseudoAccounts::finalize( if (enforce) return false; } + + // Item 28: for every pseudo-account touched, verify that the owner field + // resolves to an object whose sfAccount points back to the pseudo-account. + // Prevents a dangling pseudo-account from surviving a partial deletion. + if (view.rules().enabled(featureLendingProtocolV1_1)) + { + for (auto const& sle : pseudoAccounts_) + { + if (!sle) + continue; + AccountID const accID = sle->at(sfAccount); + if (auto const vaultID = (*sle)[~sfVaultID]) + { + auto const vault = view.read(keylet::vault(*vaultID)); + if (!vault || vault->at(sfAccount) != accID) + { + JLOG(j.fatal()) + << "Invariant failed: pseudo-account VaultID does not " + "resolve to a vault referencing this account"; + if (enforce) + return false; + } + } + if (auto const brokerID = (*sle)[~sfLoanBrokerID]) + { + auto const broker = view.read(keylet::loanBroker(*brokerID)); + if (!broker || broker->at(sfAccount) != accID) + { + JLOG(j.fatal()) + << "Invariant failed: pseudo-account LoanBrokerID does " + "not resolve to a broker referencing this account"; + if (enforce) + return false; + } + } + } + } return true; } diff --git a/src/libxrpl/tx/invariants/LoanBrokerInvariant.cpp b/src/libxrpl/tx/invariants/LoanBrokerInvariant.cpp index b70c02947f..a2a76c0d40 100644 --- a/src/libxrpl/tx/invariants/LoanBrokerInvariant.cpp +++ b/src/libxrpl/tx/invariants/LoanBrokerInvariant.cpp @@ -22,6 +22,21 @@ namespace xrpl { void ValidLoanBroker::visitEntry(bool isDelete, SLE::const_ref before, SLE::const_ref after) { + // The framework passes a non-null `after` even for deletions (it holds + // the state at the time of erase), so distinguishing deletion from + // modification requires isDelete rather than the presence of `after`. + // Split deleted brokers into deletedBrokers_ - and skip the placeholder + // emplace for deleted pseudo-accounts referencing them - so the live + // check loop no longer has to exempt ttLOAN_BROKER_DELETE. + if (isDelete) + { + if (before && before->getType() == ltLOAN_BROKER) + deletedBrokers_.push_back(before); + // Deleted trust lines / MPTokens for a broker's pseudo-account can no + // longer meaningfully point at a live broker; there is nothing to + // cross-check against, so ignore them here. + return; + } if (after) { if (after->getType() == ltLOAN_BROKER) @@ -44,9 +59,48 @@ ValidLoanBroker::visitEntry(bool isDelete, SLE::const_ref before, SLE::const_ref { mpts_.emplace_back(after); } + + // Snapshot balance-bearing entries so broker-deletion checks can + // compute the change in the owner's vault-asset balance. Non-mutually + // exclusive with the branches above - an ACCOUNT_ROOT with an + // sfLoanBrokerID is both a broker pseudo-account reference and a + // balance holder. + if (after->getType() == ltACCOUNT_ROOT || after->getType() == ltRIPPLE_STATE || + after->getType() == ltMPTOKEN) + { + touchedBalances_[after->key()] = {before, after}; + } } } +Number +ValidLoanBroker::balanceOf(SLE::const_ref sle, AccountID const& id, Asset const& asset) +{ + if (!sle) + return Number{}; + + return std::visit( + [&](TIss const& issue) -> Number { + if constexpr (std::is_same_v) + { + if (isXRP(issue)) + return static_cast(sle->getFieldAmount(sfBalance).xrp().drops()); + // Trust-line balance is stored from the low-account's + // perspective; flip the sign when @p id is the high account so + // the result is in @p id's terms. + auto bal = Number{sle->getFieldAmount(sfBalance)}; + if (id > issue.getIssuer()) + bal = -bal; + return bal; + } + else if constexpr (std::is_same_v) + { + return Number{static_cast(sle->getFieldU64(sfMPTAmount))}; + } + }, + asset.value()); +} + bool ValidLoanBroker::goodZeroDirectory( ReadView const& view, @@ -91,8 +145,8 @@ ValidLoanBroker::goodZeroDirectory( bool ValidLoanBroker::finalize( STTx const& tx, - TER const, - XRPAmount const, + TER const result, + XRPAmount const fee, ReadView const& view, beast::Journal const& j) { @@ -129,6 +183,120 @@ ValidLoanBroker::finalize( } } + // Item 29 (XLS-65 §3.4, XLS-66 §3.4): VaultDelete requires the vault's + // pseudo-account owner directory to be empty; a broker referencing the + // vault would still be on that directory, so a successful VaultDelete + // implies no broker was touched. The pseudo-account owner-directory + // residual check in ValidVault enforces this indirectly (item 9); this + // check catches a compound transaction that would modify a broker while + // deleting a vault. Class-2 (transaction post-condition); gate on + // isTesSuccess. + if (isTesSuccess(result) && + view.rules().enabled(featureLendingProtocolV1_1) && + tx.getTxnType() == ttVAULT_DELETE && + (!brokers_.empty() || !deletedBrokers_.empty())) + { + JLOG(j.fatal()) << "Invariant failed: VaultDelete must not touch any " + "loan broker"; + return false; + } + + // Deletion-time preconditions (XLS-66 §3.1.5), stated positively over + // deletedBrokers_ rather than by exempting the delete case from the live + // check loop below. Gated on featureLendingProtocolV1_1 as new invariants. + if (view.rules().enabled(featureLendingProtocolV1_1)) + { + for (auto const& deletedBroker : deletedBrokers_) + { + // §3.1.5 precondition 1: all Loans associated with the LoanBroker + // must be deleted first. The transactor checks this via preclaim + // (tecHAS_OBLIGATIONS on OwnerCount != 0); re-asserting the + // substantive property here catches an OwnerCount-tracking bug + // that would defeat both the preclaim and this check together. + // Class-1 (pure before-state fact), so no TER gate needed. + if (deletedBroker->at(sfOwnerCount) != 0) + { + JLOG(j.fatal()) << "Invariant failed: deleted LoanBroker must " + "have no outstanding loans"; + return false; + } + + // §3.1.5 precondition 3: first-loss capital must return to the + // broker owner. Value conservation - what the pseudo-account + // released must appear in the owner's balance. Class-2 (delta + // check), so gate on isTesSuccess(result) to avoid firing against + // the fee-claim-only state in InvariantScope::ProtocolOnly. + if (!isTesSuccess(result)) + continue; + + auto const vaultSle = view.read(keylet::vault(deletedBroker->at(sfVaultID))); + if (!vaultSle) + { + // The reverse "vault missing" case is caught by the live-broker + // check loop below and by ValidVault; nothing useful to add + // here. + continue; + } + + AccountID const owner = deletedBroker->at(sfOwner); + Asset const vaultAsset = vaultSle->at(sfAsset); + + // Resolve the ledger key of the owner's asset holding: account + // root for XRP, trust line for IOU, MPToken for MPT. + uint256 const ownerKey = std::visit( + [&](TIss const& issue) -> uint256 { + if constexpr (std::is_same_v) + { + if (isXRP(issue)) + return keylet::account(owner).key; + return keylet::trustLine(owner, issue).key; + } + else if constexpr (std::is_same_v) + { + return keylet::mptoken(issue.getMptID(), owner).key; + } + }, + vaultAsset.value()); + + Number const beforeCover = deletedBroker->at(sfCoverAvailable); + + auto const it = touchedBalances_.find(ownerKey); + if (it == touchedBalances_.end()) + { + // Owner's asset holding was not touched. Consistent only with + // a zero cover return - if any capital was actually released, + // it went nowhere the owner can see. + if (beforeCover != Number{}) + { + JLOG(j.fatal()) + << "Invariant failed: broker delete must return first-loss capital " + "to the owner"; + return false; + } + continue; + } + + auto const& [beforeSle, afterSle] = it->second; + auto delta = balanceOf(afterSle, owner, vaultAsset) - + balanceOf(beforeSle, owner, vaultAsset); + + // Fee-adjust: if the owner also paid the transaction fee (XRP + // vaults only), their ACCOUNT_ROOT balance dropped by the fee in + // addition to receiving the cover. Add the fee back so `delta` + // reflects the vault-side flow alone. + if (vaultAsset.native() && tx.getFeePayerID() == owner) + delta += fee.drops(); + + if (delta != beforeCover) + { + JLOG(j.fatal()) + << "Invariant failed: broker delete must transfer first-loss capital " + "to the owner in full"; + return false; + } + } + } + return std::ranges::all_of(brokers_, [&](auto const& entry) { auto const& [brokerID, broker] = entry; auto const& after = @@ -156,6 +324,20 @@ ValidLoanBroker::finalize( return false; } } + + // With no loans left the broker must carry no debt. LoanDelete + // forgives sub-scale residues under XRPL_ASSERT_PARTS, which is + // compiled out in release builds - so a live-network residual would + // slip past the preclaim in LoanBrokerDelete and let a broker be + // deleted while still owing the vault. Class-1 (pure after-state + // implication); safe to place without a TER guard. + if (view.rules().enabled(featureLendingProtocolV1_1) && + after->at(sfDebtTotal) != 0) + { + JLOG(j.fatal()) << "Invariant failed: Loan Broker with zero " + "OwnerCount must have zero DebtTotal"; + return false; + } } if (before && before->at(sfLoanSequence) > after->at(sfLoanSequence)) { @@ -179,6 +361,34 @@ ValidLoanBroker::finalize( JLOG(j.fatal()) << "Invariant failed: Loan Broker vault ID is invalid"; return false; } + + // Item 16 (XLS-66 §3.7.1): reverse directory linkage. The broker + // must appear in the vault pseudo-account's owner directory at page + // LoanBroker.VaultNode. The forward Broker→Vault link is checked + // above; this catches a bug that would leave the page pointer + // pointing at a page that no longer references the broker. + if (view.rules().enabled(featureLendingProtocolV1_1)) + { + auto const dirPage = view.read(keylet::page( + keylet::ownerDir(vault->at(sfAccount)), + after->at(sfVaultNode))); + if (!dirPage) + { + JLOG(j.fatal()) << "Invariant failed: Loan Broker VaultNode " + "page does not exist in the vault " + "pseudo-account owner directory"; + return false; + } + auto const& indexes = dirPage->getFieldV256(sfIndexes); + if (std::find(indexes.begin(), indexes.end(), after->key()) == + indexes.end()) + { + JLOG(j.fatal()) << "Invariant failed: Loan Broker VaultNode " + "page does not reference the broker"; + return false; + } + } + auto const& vaultAsset = vault->at(sfAsset); auto const pseudoBalance = accountHolds( view, @@ -196,16 +406,132 @@ ValidLoanBroker::finalize( if (view.rules().enabled(fixCleanup3_1_3)) { - // Don't check the balance when LoanBroker is deleted, - // sfCoverAvailable is not zeroed - if (tx.getTxnType() != ttLOAN_BROKER_DELETE && - after->at(sfCoverAvailable) > pseudoBalance) + // Deleted brokers are handled separately in deletedBrokers_ above, + // so the delete-case exemption that previously guarded this check + // is no longer needed - a live broker whose CoverAvailable exceeds + // its pseudo-account balance always indicates a real accounting + // bug. + if (after->at(sfCoverAvailable) > pseudoBalance) { JLOG(j.fatal()) << "Invariant failed: Loan Broker cover available is greater " "than pseudo-account asset balance"; return false; } } + + if (view.rules().enabled(featureLendingProtocolV1_1)) + { + // §3.1.10: DebtTotal must not exceed DebtMaximum. Currently only + // enforced at LoanBrokerSet / LoanSet preclaim; making it universal + // catches an accrual booking that would otherwise sneak through. + // DebtMaximum == 0 disables the cap. + if (after->at(sfDebtMaximum) != 0 && + after->at(sfDebtTotal) > after->at(sfDebtMaximum)) + { + JLOG(j.fatal()) << "Invariant failed: Loan Broker debt total exceeds " + "debt maximum"; + return false; + } + + // §3.1.2: rate bounds. Rates are immutable, so this is a + // create-time hardening in practice, but cheap to make universal + // so it also catches ledger-import / forced-mutation paths. + // ManagementFeeRate is TenthBips16 (max 10 000); the two cover + // rates are TenthBips32 (max 100 000). Minimum and liquidation + // cover rates must both be zero or both non-zero (spec: the pair + // together disables or enables first-loss capital). + if (after->at(sfManagementFeeRate) > 10000) + { + JLOG(j.fatal()) << "Invariant failed: Loan Broker management fee rate " + "out of range"; + return false; + } + if (after->at(sfCoverRateMinimum) > 100000 || + after->at(sfCoverRateLiquidation) > 100000) + { + JLOG(j.fatal()) << "Invariant failed: Loan Broker cover rate " + "out of range"; + return false; + } + bool const minZero = after->at(sfCoverRateMinimum) == 0; + bool const liqZero = after->at(sfCoverRateLiquidation) == 0; + if (minZero != liqZero) + { + JLOG(j.fatal()) << "Invariant failed: Loan Broker cover rate minimum " + "and liquidation must both be zero or both non-zero"; + return false; + } + + // Δ OwnerCount == +1 on ttLOAN_SET, -1 on ttLOAN_DELETE, and + // unchanged on every other transaction type that touches the + // broker. Complements the LoanSequence monotonicity check above. + // Class-2 (delta reasoning + tx type); gate on isTesSuccess. + if (isTesSuccess(result) && before) + { + std::int64_t const beforeCount = before->at(sfOwnerCount); + std::int64_t const afterCount = after->at(sfOwnerCount); + std::int64_t const delta = afterCount - beforeCount; + auto const expected = [&]() -> std::int64_t { + if (tx.getTxnType() == ttLOAN_SET) + return 1; + if (tx.getTxnType() == ttLOAN_DELETE) + return -1; + return 0; + }(); + if (delta != expected) + { + JLOG(j.fatal()) << "Invariant failed: Loan Broker owner count " + "must change by +1 on LoanSet, -1 on LoanDelete, " + "and be unchanged otherwise"; + return false; + } + } + + // Item 24 (§3.11.5 fee routing): on ttLOAN_PAY the management fee + // is routed to exactly one of {broker.Owner, broker.Account}, + // never both. LoanPay::doApply picks a single `brokerPayee` (line + // 374) and sends the fee there via accountSendMulti - a bug that + // credited both would silently succeed. Class-2 (delta reasoning + // + tx type); gate on isTesSuccess. + if (isTesSuccess(result) && tx.getTxnType() == ttLOAN_PAY) + { + Asset const asset = vault->at(sfAsset); + auto const brokerDeltaFor = [&](AccountID const& id) -> Number { + uint256 const key = std::visit( + [&](TIss const& issue) -> uint256 { + if constexpr (std::is_same_v) + { + if (isXRP(issue)) + return keylet::account(id).key; + return keylet::trustLine(id, issue).key; + } + else if constexpr (std::is_same_v) + { + return keylet::mptoken(issue.getMptID(), id).key; + } + }, + asset.value()); + auto const it = touchedBalances_.find(key); + if (it == touchedBalances_.end()) + return Number{}; + auto const& [b, a] = it->second; + return balanceOf(a, id, asset) - balanceOf(b, id, asset); + }; + + AccountID const brokerOwner = after->at(sfOwner); + AccountID const brokerPseudo = after->at(sfAccount); + Number const ownerDelta = brokerDeltaFor(brokerOwner); + Number const pseudoDelta = brokerDeltaFor(brokerPseudo); + + if (ownerDelta > Number{} && pseudoDelta > Number{}) + { + JLOG(j.fatal()) << "Invariant failed: loan pay fee must be routed " + "to the broker owner or the broker pseudo-account, " + "not both"; + return false; + } + } + } return true; }); } diff --git a/src/libxrpl/tx/invariants/LoanInvariant.cpp b/src/libxrpl/tx/invariants/LoanInvariant.cpp index 8de70c8ee2..561ff49fea 100644 --- a/src/libxrpl/tx/invariants/LoanInvariant.cpp +++ b/src/libxrpl/tx/invariants/LoanInvariant.cpp @@ -14,6 +14,7 @@ #include // IWYU pragma: keep #include #include +#include #include #include @@ -107,6 +108,104 @@ ValidLoan::finalize( JLOG(j.fatal()) << "Invariant failed: Loan Overpayment flag changed"; return false; } + // LoanManage sub-operation flag preconditions. These are transaction + // post-conditions, so they only apply on a successful apply: after a + // reset the ledger flags revert and the checks would spuriously fail. + if (before && isTesSuccess(result) && txType == ttLOAN_MANAGE && + view.rules().enabled(fixCleanup3_4_0)) + { + bool const wasImpaired = before->isFlag(lsfLoanImpaired); + bool const isImpaired = after->isFlag(lsfLoanImpaired); + bool const wasDefaulted = before->isFlag(lsfLoanDefault); + bool const isDefaulted = after->isFlag(lsfLoanDefault); + + if (tx.isFlag(tfLoanImpair) && (wasImpaired || !isImpaired)) + { + JLOG(j.fatal()) << "Invariant failed: LoanManage(tfLoanImpair) " + "must set lsfLoanImpaired on a non-impaired loan"; + return false; + } + if (tx.isFlag(tfLoanUnimpair) && (!wasImpaired || isImpaired)) + { + JLOG(j.fatal()) << "Invariant failed: LoanManage(tfLoanUnimpair) " + "must clear lsfLoanImpaired on an impaired loan"; + return false; + } + if (tx.isFlag(tfLoanDefault) && (wasDefaulted || !isDefaulted)) + { + JLOG(j.fatal()) << "Invariant failed: LoanManage(tfLoanDefault) " + "must newly set lsfLoanDefault"; + return false; + } + + // Item 19 (XLS-66 §3.10.5 default): a defaulted loan transitions to a + // terminal state atomically. The lsfLoanDefault transition is + // covered above; balance zeroing is covered by the + // PaymentRemaining==0 rule higher up. What remains is + // NextPaymentDueDate: LoanManage::defaultLoan clears it (sets to 0) + // so it is dropped from the ledger entry. + if (view.rules().enabled(featureLendingProtocolV1_1) && + tx.isFlag(tfLoanDefault) && + after->at(~sfNextPaymentDueDate).value_or(0) != 0) + { + JLOG(j.fatal()) << "Invariant failed: defaulted loan must have zero " + "next payment due date"; + return false; + } + } + + // Item 20 (XLS-66 §3.2.3): lsfLoanDefault is set-once - never cleared, + // only ever set (by ttLOAN_MANAGE(tfLoanDefault), gated above). The + // LoanManage-scoped block already verifies the specific transition; + // this universal form catches any other transaction path that would + // clear the flag. Class-2 (transaction post-condition on a specific + // before→after transition); gate on isTesSuccess. + if (before && isTesSuccess(result) && + view.rules().enabled(featureLendingProtocolV1_1) && + before->isFlag(lsfLoanDefault) && !after->isFlag(lsfLoanDefault)) + { + JLOG(j.fatal()) << "Invariant failed: lsfLoanDefault must never be cleared"; + return false; + } + + // Item 21 (XLS-66 §3.11.5 non-full payment): after a LoanPay that did + // not fully repay the loan, PrincipalOutstanding strictly decreases, + // PaymentRemaining decreases by at least 1, and NextPaymentDueDate + // advances by a positive multiple of PaymentInterval. Class-2. Skip + // the check when the payment fully repaid the loan (PaymentRemaining + // and balances go to zero - covered by the fully-paid-off rule + // above). + if (before && isTesSuccess(result) && txType == ttLOAN_PAY && + view.rules().enabled(featureLendingProtocolV1_1) && + after->at(sfPaymentRemaining) != 0) + { + if (!(after->at(sfPrincipalOutstanding) < before->at(sfPrincipalOutstanding))) + { + JLOG(j.fatal()) << "Invariant failed: loan pay must strictly decrease " + "PrincipalOutstanding on a non-full-repayment"; + return false; + } + if (!(after->at(sfPaymentRemaining) < before->at(sfPaymentRemaining))) + { + JLOG(j.fatal()) << "Invariant failed: loan pay must decrease " + "PaymentRemaining on a non-full-repayment"; + return false; + } + // NextPaymentDueDate advances by a positive multiple of + // PaymentInterval. PaymentInterval is immutable so before/after + // agree; use after's value. + std::uint32_t const beforeDue = before->at(~sfNextPaymentDueDate).value_or(0); + std::uint32_t const afterDue = after->at(~sfNextPaymentDueDate).value_or(0); + std::uint32_t const interval = after->at(sfPaymentInterval); + if (afterDue <= beforeDue || interval == 0 || + (afterDue - beforeDue) % interval != 0) + { + JLOG(j.fatal()) << "Invariant failed: loan pay must advance " + "NextPaymentDueDate by a positive multiple of " + "PaymentInterval on a non-full-repayment"; + return false; + } + } // Must not be negative - STNumber for (auto const field : {&sfLoanServiceFee, @@ -172,6 +271,21 @@ ValidLoan::finalize( return false; } + // Item 54 (XLS-66 §3.1.5 precondition 1): LoanBrokerDelete's preclaim + // rejects a broker with OwnerCount != 0, so no loan should exist that + // references it; touching any loan alongside the delete is + // inconsistent with that precondition and points at either an + // OwnerCount-tracking bug or a spurious cascading write. Class-2 + // (transaction post-condition); gate on isTesSuccess. + if (isTesSuccess(result) && + view.rules().enabled(featureLendingProtocolV1_1) && + txType == ttLOAN_BROKER_DELETE && !loans_.empty()) + { + JLOG(j.fatal()) << "Invariant failed: LoanBrokerDelete must not " + "touch any loan"; + return false; + } + // 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. diff --git a/src/libxrpl/tx/invariants/VaultInvariant.cpp b/src/libxrpl/tx/invariants/VaultInvariant.cpp index 4c4f8ca5ea..d4ee22d0f7 100644 --- a/src/libxrpl/tx/invariants/VaultInvariant.cpp +++ b/src/libxrpl/tx/invariants/VaultInvariant.cpp @@ -64,6 +64,12 @@ ValidVault::Vault::make(SLE const& from) self.lossUnrealized = from.at(sfLossUnrealized); self.withdrawalPolicy = from.at(sfWithdrawalPolicy); self.scale = from.at(sfScale); + self.flags = from.getFlags(); + // Mirrors getVaultVersion: an absent or unrecognised sfLEVersion resolves + // to the legacy, accrual-basis model. + self.version = from[~sfLEVersion] == std::to_underlying(VaultVersion::CashBasis) + ? VaultVersion::CashBasis + : VaultVersion::Legacy; self.vaultKind = from[~sfVaultKind]; self.subscriptionDate = from[~sfSubscriptionDate]; self.redemptionDate = from[~sfRedemptionDate]; @@ -81,6 +87,9 @@ ValidVault::Shares::make(SLE const& from) self.share = MPTIssue(makeMptID(from.getFieldU32(sfSequence), from.getAccountID(sfIssuer))); self.sharesTotal = from.at(sfOutstandingAmount); self.sharesMaximum = from[~sfMaximumAmount].value_or(kMaxMpTokenAmount); + self.transferFee = from[~sfTransferFee].value_or(0); + self.assetScale = from[~sfAssetScale].value_or(0); + self.flags = from.getFlags(); return self; } @@ -91,11 +100,19 @@ ValidVault::Loan::interestDue() const } Number -ValidVault::Loan::claim() const +ValidVault::Loan::claim(VaultVersion version) const { + if (version == VaultVersion::CashBasis) + return principalOutstanding; return totalValueOutstanding - managementFeeOutstanding; } +Number +ValidVault::Loan::exposure(VaultVersion version) const +{ + return claim(version); +} + ValidVault::Loan ValidVault::Loan::make(SLE const& from) { @@ -104,9 +121,30 @@ ValidVault::Loan::make(SLE const& from) ValidVault::Loan self; self.key = from.key(); self.loanBrokerID = from.at(sfLoanBrokerID); + self.borrower = from.at(sfBorrower); + // sfLoanOriginationFee is optional on the ledger entry (absent when zero); + // normalize to Number{0} so callers can read it unconditionally. + self.originationFee = from[~sfLoanOriginationFee].value_or(Number{}); self.principalOutstanding = from.at(sfPrincipalOutstanding); self.totalValueOutstanding = from.at(sfTotalValueOutstanding); self.managementFeeOutstanding = from.at(sfManagementFeeOutstanding); + self.impaired = from.isFlag(lsfLoanImpaired); + return self; +} + +ValidVault::Broker +ValidVault::Broker::make(SLE const& from) +{ + XRPL_ASSERT( + from.getType() == ltLOAN_BROKER, "ValidVault::Broker::make : from LoanBroker object"); + + ValidVault::Broker self; + self.key = from.key(); + self.owner = from.at(sfOwner); + self.vaultID = from.at(sfVaultID); + self.debtTotal = from.at(sfDebtTotal); + self.coverAvailable = from.at(sfCoverAvailable); + self.ownerCount = from.at(sfOwnerCount); return self; } @@ -174,6 +212,12 @@ ValidVault::visitEntry(bool isDelete, SLE::const_ref before, SLE::const_ref afte // vault's claim on the loan. beforeLoan_.push_back(Loan::make(*before)); break; + case ltLOAN_BROKER: + // Snapshot the broker so the lending-side finalizers can compute + // deltas on DebtTotal, CoverAvailable and OwnerCount without a + // separate read of the after-state. + beforeBroker_.push_back(Broker::make(*before)); + break; default:; } } @@ -226,6 +270,11 @@ ValidVault::visitEntry(bool isDelete, SLE::const_ref before, SLE::const_ref afte // the loan. afterLoan_.push_back(Loan::make(*after)); break; + case ltLOAN_BROKER: + // See beforeBroker_; captured on both sides so the funding + // invariants can read `Δ DebtTotal` directly. + afterBroker_.push_back(Broker::make(*after)); + break; default:; } } @@ -242,6 +291,27 @@ ValidVault::visitEntry(bool isDelete, SLE::const_ref before, SLE::const_ref afte balanceDelta.delta *= sign; deltas_[key] = balanceDelta; } + + // Record every touched MPToken holding under its issuance so the universal + // share-conservation check in finalize can sum across all holders. Uses + // whichever of before/after carries the identifying fields. + auto const isMPToken = [](SLE::const_ref sle) { + return sle && sle->getType() == ltMPTOKEN; + }; + if (isMPToken(before) || isMPToken(after)) + { + auto const& identity = before ? before : after; + auto const issuanceID = identity->getFieldH192(sfMPTokenIssuanceID); + auto const holder = identity->getAccountID(sfAccount); + Number const beforeAmount = before + ? Number(static_cast(before->getFieldU64(sfMPTAmount))) + : kNumZero; + Number const afterAmount = (!isDelete && after) + ? Number(static_cast(after->getFieldU64(sfMPTAmount))) + : kNumZero; + shareHoldings_[issuanceID].push_back( + ShareHoldingDelta{.holder = holder, .delta = afterAmount - beforeAmount}); + } } std::optional @@ -326,6 +396,10 @@ ValidVault::computeVaultMinScale(DeltaInfo const& vaultDelta, Rules const& rules // // 2. The scale may decrease (withdraw/clawback) or increase (deposit). In both cases // we ensure the vault is in a legitimate state in the post-transaction scale. + XRPL_ASSERT_PARTS( + !afterVault_.empty(), + "xrpl::ValidVault::computeVaultMinScale", + "afterVault_ non-empty"); auto const& afterVault = afterVault_[0]; auto const& vaultAsset = afterVault.asset; if (rules.enabled(fixCleanup3_2_0)) @@ -334,6 +408,17 @@ ValidVault::computeVaultMinScale(DeltaInfo const& vaultDelta, Rules const& rules return scale(afterVault.assetsTotal, vaultAsset); } + // Pre-fixCleanup3_2_0 the coarsest-scale form dereferences beforeVault_[0]. + // Every current caller enters this function past a gate that guarantees + // beforeVault_ is populated (ttVAULT_CREATE goes down the fixCleanup3_2_0 + // branch above); the assert makes the precondition explicit so that a + // future reuse of this helper - for instance from the eventual LoanAccept + // transactor via checkLoanFunding - fails fast in an assert build rather + // than dereferencing an empty vector in release. + XRPL_ASSERT_PARTS( + !beforeVault_.empty(), + "xrpl::ValidVault::computeVaultMinScale", + "beforeVault_ non-empty"); auto const& beforeVault = beforeVault_[0]; auto const totalDelta = DeltaInfo::makeDelta(beforeVault.assetsTotal, afterVault.assetsTotal, vaultAsset); @@ -343,48 +428,74 @@ ValidVault::computeVaultMinScale(DeltaInfo const& vaultDelta, Rules const& rules } bool -ValidVault::finalizeLoanSet(STTx const& tx, ReadView const& view, beast::Journal const& j) const +ValidVault::exactlyOneLoan(bool isCreate, beast::Journal const& j) const { - if (afterVault_.empty()) + if (afterLoan_.size() != 1) { - // LCOV_EXCL_START - UNREACHABLE("xrpl::ValidVault::finalizeLoanSet : vault exists"); + JLOG(j.fatal()) << // + "Invariant failed: lending transaction must touch exactly one loan"; return false; - // LCOV_EXCL_STOP } + // A created loan has no prior state; a modified one must be the very loan + // whose prior state was captured, otherwise the deltas computed from the + // two snapshots are meaningless. + if (isCreate ? !beforeLoan_.empty() + : (beforeLoan_.size() != 1 || beforeLoan_[0].key != afterLoan_[0].key)) + { + JLOG(j.fatal()) << // + (isCreate ? "Invariant failed: lending transaction must not modify an existing loan" + : "Invariant failed: lending transaction must modify exactly one loan"); + return false; + } + + return true; +} + +bool +ValidVault::checkLoanCreation(STTx const&, ReadView const& view, beast::Journal const& j) const +{ auto const& afterVault = afterVault_[0]; // Loan origination against a closed-ended vault is only permitted while the vault is in the // Investment phase - strictly past SubscriptionDate and before RedemptionDate. Open-ended - // vaults have NoPhase and are unaffected. + // vaults have NoPhase and are unaffected by the phase gate, but the funding checks below + // apply to every vault kind, so NoPhase must fall through rather than return early. auto const phase = getVaultPhase( view, afterVault.vaultKind, afterVault.subscriptionDate, afterVault.redemptionDate); - if (phase == VaultPhase::NoPhase) - return true; - - if (phase != VaultPhase::Investment) + if (phase != VaultPhase::NoPhase && phase != VaultPhase::Investment) { JLOG(j.fatal()) << // "Invariant failed: loan origination only allowed in Investment phase"; return false; } + // The cardinality check was introduced with the rest of the loan-set funding + // logic under fixCleanup3_4_0; preserve that gating here so pre-amendment + // behaviour is unchanged. if (!view.rules().enabled(fixCleanup3_4_0)) return true; - XRPL_ASSERT( - !beforeVault_.empty(), "xrpl::ValidVault::finalizeLoanSet : loan set updated a vault"); - auto const& vaultAsset = afterVault.asset; - // 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 - } + return exactlyOneLoan(/*isCreate=*/true, j); +} + +bool +ValidVault::checkLoanFunding( + STTx const& tx, + XRPAmount const fee, + ReadView const& view, + beast::Journal const& j) const +{ + XRPL_ASSERT( + !beforeVault_.empty(), "xrpl::ValidVault::checkLoanFunding : loan set updated a vault"); + XRPL_ASSERT( + !afterLoan_.empty(), "xrpl::ValidVault::checkLoanFunding : loan cardinality enforced"); + + auto const& beforeVault = beforeVault_[0]; + auto const& afterVault = afterVault_[0]; + auto const& vaultAsset = afterVault.asset; auto const& loan = afterLoan_[0]; // Funding a loan moves the requested principal out of the vault @@ -426,9 +537,147 @@ ValidVault::finalizeLoanSet(STTx const& tx, ReadView const& view, beast::Journal result = false; } + // The remaining participant-side checks are new under featureLendingProtocolV1_1 + // and use the basis-aware exposure / claim accessors; keep them behind that gate + // so pre-V1_1 behaviour is unchanged. + if (!view.rules().enabled(featureLendingProtocolV1_1)) + return result; + + // The broker whose DebtTotal must reflect the newly-originated loan is the + // one the loan points at. Verify the snapshot corresponds and is populated + // on both sides - a loan set that failed to modify the referenced broker + // is itself an invariant violation. + if (beforeBroker_.size() != 1 || afterBroker_.size() != 1 || + beforeBroker_[0].key != afterBroker_[0].key || + afterBroker_[0].key != loan.loanBrokerID) + { + JLOG(j.fatal()) << // + "Invariant failed: loan set must modify exactly the loan's broker"; + return false; // That's all we can do + } + + auto const& beforeBroker = beforeBroker_[0]; + auto const& afterBroker = afterBroker_[0]; + + // The broker's DebtTotal tracks the vault's aggregate exposure to its + // loans (basis-aware). A new loan's contribution is its exposure at + // origination, so `Δ DebtTotal == loan.exposure(version)`. DebtTotal is + // written via adjustImpreciseNumber (rounded to the vault scale), so + // compare via a once-rounded residual to avoid a false failure from + // independently-rounded operands. + { + auto const expected = loan.exposure(afterVault.version); + auto const residual = roundToAsset( + vaultAsset, + (afterBroker.debtTotal - beforeBroker.debtTotal) - expected, + minScale); + if (residual != kZero) + { + JLOG(j.fatal()) << // + "Invariant failed: loan set must increase broker debt total by the new loan's " + "exposure"; + result = false; + } + } + + // Value routing: the vault pseudo-account paid out `principalRequested`, + // split between the borrower (`principalRequested - originationFee`) and + // the broker owner (`originationFee`). Verify each participant received + // their portion. `deltaAssets` reads the raw balance change; when the + // participant also paid the transaction fee (XRP vaults only) that fee + // must be added back so the observed delta reflects the vault-side flow + // alone. + auto const adjustForFee = [&](std::optional& d, AccountID const& id) { + if (d && vaultAsset.native() && tx.getFeePayerID() == id) + d->delta += fee.drops(); + }; + + auto maybeBorrowerDelta = deltaAssets(loan.borrower); + adjustForFee(maybeBorrowerDelta, loan.borrower); + + auto const borrowerExpected = + roundToAsset(vaultAsset, tx[sfPrincipalRequested] - loan.originationFee, minScale); + auto const borrowerReceived = maybeBorrowerDelta + ? roundToAsset(vaultAsset, maybeBorrowerDelta->delta, minScale) + : kZero; + if (borrowerReceived != borrowerExpected) + { + JLOG(j.fatal()) << // + "Invariant failed: loan set must credit the borrower with the principal net of " + "origination fee"; + result = false; + } + + // The broker owner is only touched when a non-zero origination fee is + // routed - a zero-fee loan set leaves them unchanged, so a missing delta + // is consistent with `originationFee == 0`. + auto maybeBrokerOwnerDelta = deltaAssets(afterBroker.owner); + adjustForFee(maybeBrokerOwnerDelta, afterBroker.owner); + + auto const brokerOwnerExpected = roundToAsset(vaultAsset, loan.originationFee, minScale); + auto const brokerOwnerReceived = maybeBrokerOwnerDelta + ? roundToAsset(vaultAsset, maybeBrokerOwnerDelta->delta, minScale) + : kZero; + if (brokerOwnerReceived != brokerOwnerExpected) + { + JLOG(j.fatal()) << // + "Invariant failed: loan set must credit the broker owner with the origination fee"; + result = false; + } + + // Vault-side accounting identity at origination, mirroring the one enforced + // for loan payments: `Δ AssetsTotal - Δ AssetsAvailable == Δ claim`. Before + // the transaction the loan did not exist, so `Δ claim == loan.claim(version)`. + // Basis-aware via claim(): under accrual it books the interest into + // AssetsTotal, under cash-basis it does not. The residual is rounded once + // for the same reason as in finalizeLoanPay - the underlying identity holds + // exactly, but a term-wise comparison of independently-rounded operands can + // drift by a ULP. + { + auto const residual = roundToAsset( + vaultAsset, + (afterVault.assetsTotal - beforeVault.assetsTotal) - + (afterVault.assetsAvailable - beforeVault.assetsAvailable) - + loan.claim(afterVault.version), + minScale); + if (residual != kZero) + { + JLOG(j.fatal()) << // + "Invariant failed: loan set assets outstanding must match the principal released " + "and the new loan's claim"; + result = false; + } + } + return result; } +bool +ValidVault::finalizeLoanSet( + STTx const& tx, + XRPAmount const fee, + ReadView const& view, + beast::Journal const& j) const +{ + if (afterVault_.empty()) + { + // LCOV_EXCL_START + UNREACHABLE("xrpl::ValidVault::finalizeLoanSet : vault exists"); + return false; + // LCOV_EXCL_STOP + } + + if (!checkLoanCreation(tx, view, j)) + return false; + + // Pre-fixCleanup3_4_0 the funding-side checks did not run at all; preserve + // that behaviour by dispatching only when the amendment is enabled. + if (!view.rules().enabled(fixCleanup3_4_0)) + return true; + + return checkLoanFunding(tx, fee, view, j); +} + bool ValidVault::finalizeLoanManage(STTx const& tx, ReadView const& view, beast::Journal const& j) const { @@ -444,6 +693,12 @@ ValidVault::finalizeLoanManage(STTx const& tx, ReadView const& view, beast::Jour auto const& afterVault = afterVault_[0]; auto const& vaultAsset = afterVault.asset; + // Every sub-operation acts on the single loan named by the transaction. The + // vault-only checks below do not read the loan, so they are still performed + // when this fails; only the checks which need the loan are skipped. + bool const oneLoan = exactlyOneLoan(/*isCreate=*/false, j); + result = result && oneLoan; + // 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 @@ -460,6 +715,10 @@ ValidVault::finalizeLoanManage(STTx const& tx, ReadView const& view, beast::Jour vaultAsset, afterVault.assetsAvailable - beforeVault.assetsAvailable, minScale); auto const assetTotalDelta = roundToAsset(vaultAsset, afterVault.assetsTotal - beforeVault.assetsTotal, minScale); + // Loss unrealized is maintained at the vault scale, so the delta is rounded + // the same way as the asset fields above. + auto const lossUnrealizedDelta = roundToAsset( + vaultAsset, afterVault.lossUnrealized - beforeVault.lossUnrealized, minScale); // --- Checks specific to each loan manage sub-operation --- @@ -482,6 +741,43 @@ ValidVault::finalizeLoanManage(STTx const& tx, ReadView const& view, beast::Jour "change assets outstanding"; result = false; } + + // Impairing records the vault's exposure to the loan as a paper loss, + // unimpairing reverses it. The bounds are not strict because either + // adjustment can round to nothing at the vault scale. + if (tx.isFlag(tfLoanImpair) ? lossUnrealizedDelta < kZero : lossUnrealizedDelta > kZero) + { + JLOG(j.fatal()) << // + "Invariant failed: loan impair must not decrease, and loan " + "unimpair must not increase, loss unrealized"; + result = false; + } + + // Magnitude: LossUnrealized must move by exactly the loan's exposure + // snapshotted before this transaction. Impair grows it, unimpair + // shrinks it. The residual is rounded once (mirrors the LoanPay + // conservation identity in finalizeLoanPay) so that comparing the + // two independently-scaled operands cannot drift by a ULP and + // produce a false failure. + if (oneLoan) + { + auto const exposure = beforeLoan_[0].exposure(afterVault.version); + auto const expectedDelta = tx.isFlag(tfLoanImpair) ? exposure : -exposure; + auto const residual = roundToAsset( + vaultAsset, + (afterVault.lossUnrealized - beforeVault.lossUnrealized) - expectedDelta, + minScale); + if (residual != kZero) + { + JLOG(j.fatal()) << // + (tx.isFlag(tfLoanImpair) + ? "Invariant failed: loan impair must increase loss " + "unrealized by exactly the loan's exposure" + : "Invariant failed: loan unimpair must decrease loss " + "unrealized by exactly the loan's exposure"); + result = false; + } + } } else if (tx.isFlag(tfLoanDefault)) { @@ -504,6 +800,150 @@ ValidVault::finalizeLoanManage(STTx const& tx, ReadView const& view, beast::Jour "assets outstanding"; result = false; } + + // A default realizes the loss: any paper loss carried for this loan is + // released, and no new paper loss may be recorded. As above, the bound + // is not strict - the loan need not have been impaired, in which case + // there was no paper loss to release. + if (lossUnrealizedDelta > kZero) + { + JLOG(j.fatal()) << // + "Invariant failed: loan default must not increase loss " + "unrealized"; + result = false; + } + + // Magnitude: if the loan was impaired before this transaction its + // pre-tx exposure was carried as an unrealized loss, and default + // releases exactly that amount (the loss transitions from paper to + // realized). If the loan was not impaired there is nothing to + // release and LossUnrealized is unchanged. As with impair/unimpair + // the residual is rounded once. + if (oneLoan) + { + Number const expectedDelta = beforeLoan_[0].impaired + ? -beforeLoan_[0].exposure(afterVault.version) + : kZero; + auto const residual = roundToAsset( + vaultAsset, + (afterVault.lossUnrealized - beforeVault.lossUnrealized) - expectedDelta, + minScale); + if (residual != kZero) + { + JLOG(j.fatal()) << // + "Invariant failed: loan default must decrease loss " + "unrealized by the pre-transaction exposure of an " + "impaired loan, or leave it unchanged otherwise"; + result = false; + } + } + + // The first-loss capital the vault receives comes out of the + // loan-broker pseudo-account, so the two balances must move by exactly + // opposite amounts. A default that credited the vault from anywhere + // else would manufacture value. The broker can only be located through + // the defaulted loan, so this is skipped when that loan is unknown. + if (oneLoan) + { + auto const brokerSle = view.read(keylet::loanBroker(afterLoan_[0].loanBrokerID)); + if (!brokerSle) + { + JLOG(j.fatal()) << // + "Invariant failed: loan default loan broker must exist"; + result = false; + } + else + { + auto const maybeBrokerDelta = deltaAssets(brokerSle->at(sfAccount)); + auto const brokerDelta = maybeBrokerDelta.value_or(DeltaInfo{ + .delta = kZero, .scale = scale(afterVault.assetsTotal, vaultAsset)}); + auto const coverScale = + std::max(minScale, computeCoarsestScale({vaultDelta, brokerDelta})); + auto const coverResidual = + roundToAsset(vaultAsset, vaultDelta.delta + brokerDelta.delta, coverScale); + if (coverResidual != kZero) + { + JLOG(j.fatal()) << // + "Invariant failed: loan default must move the first-loss " + "capital from the loan broker to the vault"; + result = false; + } + } + } + + // Under featureLendingProtocolV1_1 the broker snapshot lets us tie the + // vault accounting field, the vault balance, and the broker's cover + // together as a single identity. The pre-V1_1 cover-residual above only + // catches asymmetric movements; a default that touched neither side + // would slip through it because both deltas fall back to zero. + if (view.rules().enabled(featureLendingProtocolV1_1) && oneLoan) + { + if (beforeBroker_.size() != 1 || afterBroker_.size() != 1 || + afterBroker_[0].key != afterLoan_[0].loanBrokerID) + { + JLOG(j.fatal()) << // + "Invariant failed: loan default must modify exactly the loan's broker"; + result = false; + } + else + { + auto const& beforeBroker = beforeBroker_[0]; + auto const& afterBroker = afterBroker_[0]; + + // If the broker returned first-loss capital, the vault balance + // ledger entry must reflect it. Redundant with the identity + // below when combined with the universal AssetsAvailable / vault + // balance check, but stated explicitly to catch the specific + // "touched neither" bug. + if (beforeBroker.coverAvailable != afterBroker.coverAvailable && + !maybeVaultDeltaAssets) + { + JLOG(j.fatal()) << // + "Invariant failed: loan default must change vault balance when first-loss " + "capital is returned"; + result = false; + } + + // Δ AssetsAvailable == DefaultCovered: the vault credits its + // available assets by exactly what the broker released. Rounded + // once to avoid a false failure from independently-rounded + // operands - beforeBroker.coverAvailable/afterBroker.coverAvailable + // are stored at vault scale (adjustImpreciseNumber in LoanManage), + // and defaultCovered itself is rounded at loan scale. + auto const defaultCovered = beforeBroker.coverAvailable - afterBroker.coverAvailable; + auto const availableResidual = roundToAsset( + vaultAsset, + (afterVault.assetsAvailable - beforeVault.assetsAvailable) - defaultCovered, + minScale); + if (availableResidual != kZero) + { + JLOG(j.fatal()) << // + "Invariant failed: loan default must increase assets available by the " + "default covered amount"; + result = false; + } + + // Broker debt tracks the vault's aggregate exposure: on + // default the loan's exposure drops to zero (all balance + // fields are zeroed), and DebtTotal drops by the same + // amount (LoanManage.cpp:244 decrements by + // loanVaultExposure). Same delta identity as finalizeLoanPay + // (items 22/23), specialised to the default sub-op. + auto const brokerResidual = roundToAsset( + vaultAsset, + (afterBroker.debtTotal - beforeBroker.debtTotal) - + (afterLoan_[0].exposure(afterVault.version) - + beforeLoan_[0].exposure(afterVault.version)), + minScale); + if (brokerResidual != kZero) + { + JLOG(j.fatal()) << // + "Invariant failed: loan default broker debt total must " + "track the change in the loan's exposure"; + result = false; + } + } + } } else { @@ -533,6 +973,11 @@ ValidVault::finalizeLoanPay(STTx const& tx, ReadView const& view, beast::Journal auto const& afterVault = afterVault_[0]; auto const& vaultAsset = afterVault.asset; + // A payment is made against the single loan named by the transaction. The + // cash-flow checks immediately below do not read the loan, so they are still + // performed when this fails; the loan-dependent ones are then skipped. + bool const oneLoan = exactlyOneLoan(/*isCreate=*/false, j); + // 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. @@ -572,6 +1017,9 @@ ValidVault::finalizeLoanPay(STTx const& tx, ReadView const& view, beast::Journal result = false; } + if (!oneLoan) + return false; // That's all we can do + // The vault, the broker pseudo-account and the broker owner are // the only three destinations of a loan payment (the fee goes to // either the pseudo-account or the owner, never both). Their @@ -585,42 +1033,84 @@ ValidVault::finalizeLoanPay(STTx const& tx, ReadView const& view, beast::Journal // false-positives); the split correctness in that corner case // is still policed by the assets-outstanding balance check // below. - if (!afterLoan_.empty()) + auto const brokerSle = view.read(keylet::loanBroker(afterLoan_[0].loanBrokerID)); + if (!brokerSle) { - auto const brokerSle = view.read(keylet::loanBroker(afterLoan_[0].loanBrokerID)); - if (!brokerSle) + JLOG(j.fatal()) << // + "Invariant failed: loan pay loan broker must exist"; + result = false; + } + else + { + auto const brokerPseudoDelta = deltaAssets(brokerSle->at(sfAccount)); + auto const brokerOwnerDelta = deltaAssets(brokerSle->at(sfOwner)); + + std::vector deltas{*maybeVaultDeltaAssets}; + if (brokerPseudoDelta) + deltas.push_back(*brokerPseudoDelta); + if (brokerOwnerDelta) + deltas.push_back(*brokerOwnerDelta); + auto const totalScale = std::max(minScale, computeCoarsestScale(deltas)); + + Number totalReceivedRaw = maybeVaultDeltaAssets->delta; + if (brokerPseudoDelta) + totalReceivedRaw += brokerPseudoDelta->delta; + if (brokerOwnerDelta) + totalReceivedRaw += brokerOwnerDelta->delta; + + auto const totalReceived = roundToAsset(vaultAsset, totalReceivedRaw, totalScale); + auto const totalAmount = roundToAsset(vaultAsset, tx[sfAmount], totalScale); + if (totalReceived > totalAmount) { JLOG(j.fatal()) << // - "Invariant failed: loan pay loan broker must exist"; + "Invariant failed: loan pay vault and broker must not " + "receive more than the amount paid"; result = false; } - else + } + + // The vault's claim is accounting-basis dependent, so both checks below are + // evaluated with the claim the vault actually recognizes. Under accrual the + // claim carries the interest, which assets outstanding already booked at + // origination; under cash-basis the claim is principal only and assets + // outstanding grow by the interest as it is received. + auto const version = afterVault.version; + auto const claimDelta = roundToAsset( + vaultAsset, afterLoan_[0].claim(version) - beforeLoan_[0].claim(version), minScale); + + // A payment services the loan, so the vault's claim on it can only shrink. + // Penalties and fees charged on a late payment or an overpayment are + // settled from the same payment rather than added to the loan, so they + // cannot grow the claim either. + if (claimDelta > kZero) + { + JLOG(j.fatal()) << // + "Invariant failed: loan pay must not increase the vault's claim on " + "the loan"; + result = false; + } + + // LoanPay::doApply calls LoanManage::unimpairLoan before applying the + // payment, so a payment on a pre-impaired loan legitimately releases the + // paper loss the impairment recorded - LossUnrealized falls by exactly + // the pre-transaction exposure. A payment on a non-impaired loan does + // not touch LossUnrealized. Mirrors item 12 in finalizeLoanManage; the + // residual is rounded once for the same reason. + { + Number const expectedDelta = beforeLoan_[0].impaired + ? -beforeLoan_[0].exposure(version) + : kZero; + auto const residual = roundToAsset( + vaultAsset, + (afterVault.lossUnrealized - beforeVault.lossUnrealized) - expectedDelta, + minScale); + if (residual != kZero) { - auto const brokerPseudoDelta = deltaAssets(brokerSle->at(sfAccount)); - auto const brokerOwnerDelta = deltaAssets(brokerSle->at(sfOwner)); - - std::vector deltas{*maybeVaultDeltaAssets}; - if (brokerPseudoDelta) - deltas.push_back(*brokerPseudoDelta); - if (brokerOwnerDelta) - deltas.push_back(*brokerOwnerDelta); - auto const totalScale = std::max(minScale, computeCoarsestScale(deltas)); - - Number totalReceivedRaw = maybeVaultDeltaAssets->delta; - if (brokerPseudoDelta) - totalReceivedRaw += brokerPseudoDelta->delta; - if (brokerOwnerDelta) - totalReceivedRaw += brokerOwnerDelta->delta; - - auto const totalReceived = roundToAsset(vaultAsset, totalReceivedRaw, totalScale); - auto const totalAmount = roundToAsset(vaultAsset, tx[sfAmount], totalScale); - if (totalReceived > totalAmount) - { - JLOG(j.fatal()) << // - "Invariant failed: loan pay vault and broker must not " - "receive more than the amount paid"; - result = false; - } + JLOG(j.fatal()) << // + "Invariant failed: loan pay must decrease loss unrealized by " + "the pre-transaction exposure of an impaired loan, or leave " + "it unchanged otherwise"; + result = false; } } @@ -632,28 +1122,58 @@ ValidVault::finalizeLoanPay(STTx const& tx, ReadView const& view, beast::Journal // 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) + // + // The residual is rounded once, rather than comparing three independently + // rounded terms: each rounding can move a term by up to one unit in the + // last place, so the rounded-terms form can differ by several ULP even when + // the underlying identity holds exactly. + auto const residual = roundToAsset( + vaultAsset, + (afterVault.assetsTotal - beforeVault.assetsTotal) - + (afterVault.assetsAvailable - beforeVault.assetsAvailable) - + (afterLoan_[0].claim(version) - beforeLoan_[0].claim(version)), + minScale); + if (residual != kZero) { JLOG(j.fatal()) << // - "Invariant failed: loan pay must modify exactly one " - "loan"; + "Invariant failed: loan pay assets outstanding must " + "match the cash received and the change in the loan " + "claim"; result = false; } - else + + // Under featureLendingProtocolV1_1, tie the broker's aggregate exposure + // (DebtTotal) to the touched loan's exposure delta: since a LoanPay + // touches exactly one loan, `Δ DebtTotal == Δ exposure(loan)`. The + // universal `DebtTotal == Σ exposure` (item 22) reduces to this delta + // check because loans are modified one at a time. Basis-aware via + // exposure(); the residual is rounded once for the same reason as the + // identity above. + if (view.rules().enabled(featureLendingProtocolV1_1)) { - 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) + if (beforeBroker_.size() != 1 || afterBroker_.size() != 1 || + afterBroker_[0].key != afterLoan_[0].loanBrokerID) { JLOG(j.fatal()) << // - "Invariant failed: loan pay assets outstanding must " - "match the cash received and the change in the loan " - "claim"; + "Invariant failed: loan pay must modify exactly the loan's broker"; result = false; } + else + { + auto const brokerResidual = roundToAsset( + vaultAsset, + (afterBroker_[0].debtTotal - beforeBroker_[0].debtTotal) - + (afterLoan_[0].exposure(version) - + beforeLoan_[0].exposure(version)), + minScale); + if (brokerResidual != kZero) + { + JLOG(j.fatal()) << // + "Invariant failed: loan pay broker debt total must " + "track the change in the loan's exposure"; + result = false; + } + } } return result; @@ -765,6 +1285,33 @@ ValidVault::finalize( result = false; } + // Item 9 (XLS-65 §3.4.2.2): VaultDelete residuals. VaultDelete erases + // the share MPTokenIssuance and the pseudo-account (with its owner + // directory) as part of doApply; verify no orphan remains on the + // after-state ledger. Defence-in-depth against a future refactor + // that would skip one of those erases. + if (view.rules().enabled(featureLendingProtocolV1_1)) + { + if (view.read(keylet::mptokenIssuance(beforeVault.shareMPTID))) + { + JLOG(j.fatal()) << "Invariant failed: deleted vault must also " + "erase share MPTokenIssuance"; + result = false; + } + if (view.read(keylet::account(beforeVault.pseudoId))) + { + JLOG(j.fatal()) << "Invariant failed: deleted vault must also " + "erase pseudo-account"; + result = false; + } + if (view.read(keylet::ownerDir(beforeVault.pseudoId))) + { + JLOG(j.fatal()) << "Invariant failed: deleted vault must also " + "erase pseudo-account owner directory"; + result = false; + } + } + return result; } if (txnType == ttVAULT_DELETE) @@ -851,6 +1398,59 @@ ValidVault::finalize( result = false; } + if (view.rules().enabled(featureLendingProtocolV1_1)) + { + // Item 3 (XLS-65 §3.1.6.2.1): share MPTokenIssuance static invariants. + // TransferFee, MaximumAmount and AssetScale are set at VaultCreate + // and must never drift. Issuer is already checked at create. + if (updatedShares->transferFee != 0) + { + JLOG(j.fatal()) << "Invariant failed: share MPTokenIssuance " + "TransferFee must be zero"; + result = false; + } + if (updatedShares->sharesMaximum != kMaxMpTokenAmount) + { + JLOG(j.fatal()) << "Invariant failed: share MPTokenIssuance " + "MaximumAmount must be the default maximum"; + result = false; + } + std::uint8_t const expectedAssetScale = + afterVault.asset.integral() ? 0 : afterVault.scale; + if (updatedShares->assetScale != expectedAssetScale) + { + JLOG(j.fatal()) << "Invariant failed: share MPTokenIssuance " + "AssetScale must match vault Scale (0 for XRP/MPT)"; + result = false; + } + + // Item 4 (XLS-65 §3.1.6.2.1 flags table): the share MPTokenIssuance + // flags are determined at VaultCreate by whether the vault is + // transferable and public/private. VaultCreate stores only the + // sfVaultPrivate bit on the vault; non-transferability is expressed + // by the absence of lsfMPTCanTransfer on the share issuance itself. + // + // public + transferable → CanEscrow|CanTrade|CanTransfer + // public + non-transferable → no flags + // private + transferable → CanEscrow|CanTrade|CanTransfer|RequireAuth + // private + non-transferable → RequireAuth + std::uint32_t const transferableSet = + lsfMPTCanEscrow | lsfMPTCanTrade | lsfMPTCanTransfer; + bool const isTransferable = (updatedShares->flags & lsfMPTCanTransfer) != 0; + bool const isPrivate = (afterVault.flags & lsfVaultPrivate) != 0; + std::uint32_t const expectedFlags = + (isTransferable ? transferableSet : 0u) | + (isPrivate ? lsfMPTRequireAuth : 0u); + std::uint32_t const relevantFlags = + transferableSet | lsfMPTRequireAuth; + if ((updatedShares->flags & relevantFlags) != expectedFlags) + { + JLOG(j.fatal()) << "Invariant failed: share MPTokenIssuance flags " + "do not match the vault transferability/publicity"; + result = false; + } + } + if (afterVault.assetsAvailable < kZero) { JLOG(j.fatal()) << "Invariant failed: assets available must not be negative"; @@ -889,6 +1489,38 @@ ValidVault::finalize( result = false; } + if (view.rules().enabled(featureLendingProtocolV1_1)) + { + // §3.1.2/§3.3.4: AssetsMaximum caps AssetsTotal. Currently only + // checked in the ttVAULT_SET and ttVAULT_DEPOSIT branches; the + // ttLOAN_SET accrual booking is unchecked without a universal form. + // AssetsMaximum == 0 disables the cap by convention. + if (afterVault.assetsMaximum > kZero && + afterVault.assetsTotal > afterVault.assetsMaximum) + { + JLOG(j.fatal()) << "Invariant failed: assets outstanding must " + "not exceed assets maximum"; + result = false; + } + + // §3.1.6.1: Scale is bounded by [0, 18], and must be zero when the + // vault asset is integer-only (XRP or MPT). Currently only enforced + // at VaultCreate preflight; the universal form catches ledger-import + // and forced-mutation paths, and future code that would attempt to + // set Scale on an integer-only vault. + if (afterVault.scale > 18) + { + JLOG(j.fatal()) << "Invariant failed: vault scale must not exceed 18"; + result = false; + } + if (afterVault.asset.integral() && afterVault.scale != 0) + { + JLOG(j.fatal()) << "Invariant failed: vault scale must be zero for " + "XRP and MPT-asset vaults"; + 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) @@ -953,6 +1585,62 @@ ValidVault::finalize( result = false; } + // Item 5 (XLS-65 §3.1.6.1.3, §3.6): shares of a non-transferable vault + // (tfVaultShareNonTransferable → lsfMPTCanTransfer absent on the share + // issuance) may only be issued or burned by the vault's own + // deposit/withdraw/clawback flow. Any other transaction (notably Payment) + // that touches share MPTokens of such an issuance violates the ban. + if (view.rules().enabled(featureLendingProtocolV1_1) && + (updatedShares->flags & lsfMPTCanTransfer) == 0 && + txnType != ttVAULT_DEPOSIT && txnType != ttVAULT_WITHDRAW && + txnType != ttVAULT_CLAWBACK && txnType != ttVAULT_CREATE && + txnType != ttVAULT_DELETE) + { + auto const it = shareHoldings_.find(afterVault.shareMPTID); + if (it != shareHoldings_.end() && !it->second.empty()) + { + JLOG(j.fatal()) << // + "Invariant failed: non-transferable vault shares must not " + "move outside of deposit, withdraw, or clawback"; + result = false; + } + } + + // Universal share conservation: the change in this vault's share + // OutstandingAmount must equal the sum of the changes to every touched + // MPToken for that same issuance. The per-transaction pairwise checks + // downstream cover the single-holder case; this covers the aggregate, + // catching a bug that split shares across more than one holder or + // credited a different holder than the transaction's primary account. + // Shares are integral MPT, so no rounding is needed. An unchanged + // issuance (no beforeShares entry) contributes a zero delta, which lets + // this catch a movement between holders that skipped the issuance. + if (sharesCheckActive) + { + auto const it = shareHoldings_.find(afterVault.shareMPTID); + bool const anyHolderChange = it != shareHoldings_.end() && !it->second.empty(); + if (beforeShares || anyHolderChange) + { + Number const outstandingDelta = (beforeShares && updatedShares) + ? Number(static_cast(updatedShares->sharesTotal)) - + Number(static_cast(beforeShares->sharesTotal)) + : kNumZero; + Number holdersDelta = kNumZero; + if (anyHolderChange) + { + for (auto const& hd : it->second) + holdersDelta += hd.delta; + } + if (outstandingDelta != holdersDelta) + { + JLOG(j.fatal()) << // + "Invariant failed: shares outstanding delta must equal the " + "sum of holder share deltas"; + result = false; + } + } + } + auto const& vaultAsset = afterVault.asset; // Assets available always tracks the real vault balance: any change @@ -1025,6 +1713,19 @@ ValidVault::finalize( result = false; } + // A vault creation must not simultaneously touch any loan; the + // existing emptiness check above covers the vault fields but + // says nothing about lending state. Gated on + // featureLendingProtocolV1_1 as a new invariant. + if (view.rules().enabled(featureLendingProtocolV1_1) && + (!beforeLoan_.empty() || !afterLoan_.empty())) + { + JLOG(j.fatal()) // + << "Invariant failed: vault create must not touch any " + "loan"; + result = false; + } + if (afterVault.pseudoId != updatedShares->share.getIssuer()) { JLOG(j.fatal()) // @@ -1096,6 +1797,12 @@ ValidVault::finalize( result = false; } + // VaultSet is defined to be balance-neutral; the exact `!=` + // comparison here (and on assetsAvailable below) is + // deliberately stricter than the roundToAsset-based delta + // checks used by the other branches. Rounding would weaken it + // by permitting sub-scale drift on a transaction that must + // not change these fields at all. if (beforeVault.assetsTotal != afterVault.assetsTotal) { JLOG(j.fatal()) << // @@ -1282,6 +1989,88 @@ ValidVault::finalize( result = false; } + // Item 6 (XLS-65 §3.2 share-price safety): deposit must not + // decrease the exchange rate `AssetsTotal / SharesTotal`. Skip + // the empty-vault seed (before or after) where the ratio is + // undefined. Compare via cross-multiplication and normalise + // the residual to the vault asset scale so sub-ULP drift is + // tolerated. + if (view.rules().enabled(featureLendingProtocolV1_1) && + beforeShares && updatedShares && + beforeShares->sharesTotal > 0 && updatedShares->sharesTotal > 0) + { + Number const beforeS( + static_cast(beforeShares->sharesTotal)); + Number const afterS( + static_cast(updatedShares->sharesTotal)); + Number const residual = + (afterVault.assetsTotal * beforeS - + beforeVault.assetsTotal * afterS) / + beforeS; + auto const roundedResidual = roundToAsset(vaultAsset, residual, minScale); + if (roundedResidual < kZero) + { + JLOG(j.fatal()) + << "Invariant failed: deposit must not decrease " + "vault exchange rate"; + result = false; + } + } + + // Item 7 (XLS-65 §3.1.7.2.1): deposit share ratio. + // - Non-empty vault: Δshares * beforeAssetsTotal <= + // Δassets * beforeSharesTotal (share amount is rounded + // down, so it cannot exceed the ideal proportional + // value). + // - Initial deposit into an empty vault: Δshares equals + // Δassets * 10^Scale. + // Δshares and Δassets are the positive magnitudes of the + // outstanding-shares and AssetsTotal changes. + if (view.rules().enabled(featureLendingProtocolV1_1) && updatedShares) + { + Number const beforeS = + beforeShares + ? Number(static_cast(beforeShares->sharesTotal)) + : Number{}; + Number const afterS( + static_cast(updatedShares->sharesTotal)); + Number const deltaShares = afterS - beforeS; + Number const deltaAssets = + afterVault.assetsTotal - beforeVault.assetsTotal; + + if (beforeS == Number{}) + { + // Initial deposit: Δshares == Δassets * 10^Scale + Number const sigma(1, afterVault.scale); + Number const expected = deltaAssets * sigma; + auto const residual = + roundToAsset(vaultAsset, deltaShares - expected, minScale); + if (residual != kZero) + { + JLOG(j.fatal()) + << "Invariant failed: initial deposit shares must " + "equal assets deposited scaled by 10^Scale"; + result = false; + } + } + else + { + // Subsequent deposit: shares rounded down implies + // Δshares * beforeAssetsTotal <= Δassets * beforeSharesTotal. + Number const lhs = deltaShares * beforeVault.assetsTotal; + Number const rhs = deltaAssets * beforeS; + auto const residual = + roundToAsset(vaultAsset, (lhs - rhs) / beforeS, minScale); + if (residual > kZero) + { + JLOG(j.fatal()) + << "Invariant failed: deposit shares issued exceed " + "proportional share of vault assets"; + result = false; + } + } + } + return result; } case ttVAULT_WITHDRAW: { @@ -1486,6 +2275,65 @@ ValidVault::finalize( result = false; } + // Item 6 (XLS-65 §3.2 share-price safety): withdrawal must not + // increase the exchange rate `AssetsTotal / SharesTotal`. Skip + // when the after-state has zero shares (full drain); the + // ratio is undefined there and other checks already require + // AssetsTotal to be zero as well. Sub-ULP drift is tolerated + // via roundToAsset on the residual. + if (view.rules().enabled(featureLendingProtocolV1_1) && + beforeShares && updatedShares && + beforeShares->sharesTotal > 0 && updatedShares->sharesTotal > 0) + { + Number const beforeS( + static_cast(beforeShares->sharesTotal)); + Number const afterS( + static_cast(updatedShares->sharesTotal)); + Number const residual = + (afterVault.assetsTotal * beforeS - + beforeVault.assetsTotal * afterS) / + beforeS; + auto const roundedResidual = roundToAsset(vaultAsset, residual, minScale); + if (roundedResidual > kZero) + { + JLOG(j.fatal()) + << "Invariant failed: withdrawal must not increase " + "vault exchange rate"; + result = false; + } + } + + // Item 8 (XLS-65 §3.1.7.2.2 / §3.1.7.2.3): withdraw / redeem + // share ratio. Assets paid out are rounded down, hence: + // Δassets * beforeSharesTotal + // <= Δshares * (beforeAssetsTotal - beforeLossUnrealized) + // where Δassets and Δshares are the positive magnitudes of + // the outstanding-asset and outstanding-shares decreases. + if (view.rules().enabled(featureLendingProtocolV1_1) && + beforeShares && updatedShares && + beforeShares->sharesTotal > 0) + { + Number const beforeS( + static_cast(beforeShares->sharesTotal)); + Number const afterS( + static_cast(updatedShares->sharesTotal)); + Number const deltaShares = beforeS - afterS; + Number const deltaAssets = + beforeVault.assetsTotal - afterVault.assetsTotal; + Number const effectiveValue = + beforeVault.assetsTotal - beforeVault.lossUnrealized; + Number const lhs = deltaAssets * beforeS; + Number const rhs = deltaShares * effectiveValue; + auto const residual = roundToAsset(vaultAsset, (lhs - rhs) / beforeS, minScale); + if (residual > kZero) + { + JLOG(j.fatal()) + << "Invariant failed: withdrawal assets exceed the " + "proportional share of vault net value"; + result = false; + } + } + return result; } case ttVAULT_CLAWBACK: { @@ -1542,8 +2390,18 @@ ValidVault::finalize( result = false; } } - else if (!isVaultEmpty(beforeVault)) + else if ( + !isVaultEmpty(beforeVault) && + beforeVault.assetsTotal != beforeVault.lossUnrealized) { + // A fully-impaired pool (assetsTotal == lossUnrealized) has + // no effective value backing its shares, so a clawback + // against it legitimately moves zero assets - mirrors the + // withdrawal branch above. VaultClawback::doApply blocks + // the "positive value rounds down to zero" case with + // tecPRECISION_LOSS under fixCleanup3_4_0, so a missing + // delta while the pool still held positive effective value + // remains an invariant failure. JLOG(j.fatal()) << // "Invariant failed: clawback must change vault balance"; return false; // That's all we can do @@ -1584,7 +2442,7 @@ ValidVault::finalize( } case ttLOAN_SET: - return finalizeLoanSet(tx, view, j); + return finalizeLoanSet(tx, fee, view, j); case ttLOAN_MANAGE: return finalizeLoanManage(tx, view, j); diff --git a/src/test/app/Invariants_test.cpp b/src/test/app/Invariants_test.cpp index b574088a72..dd89b4e9ba 100644 --- a/src/test/app/Invariants_test.cpp +++ b/src/test/app/Invariants_test.cpp @@ -2910,6 +2910,28 @@ class Invariants_test : public beast::unit_test::Suite STTx{ttLOAN_BROKER_SET, [](STObject& tx) {}}, {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, createLoanBroker); + + // Item 29 (XLS-65 §3.4, XLS-66 §3.4): a VaultDelete transaction + // must not touch any loan broker. The pseudo-account + // owner-directory residual check in ValidVault (item 9) enforces + // the substantive property indirectly; this check catches a + // compound transaction that would modify a broker while + // deleting a vault. Requires featureLendingProtocolV1_1. + doInvariantCheck( + makeEnv(defaultAmendments() | featureLendingProtocolV1_1), + {"vault operation succeeded without modifying a vault", + "VaultDelete must not touch any loan broker"}, + [&](Account const&, Account const&, ApplyContext& ac) { + auto sle = ac.view().peek(loanBrokerKeylet); + if (!BEAST_EXPECT(sle)) + return false; + ac.view().update(sle); + return true; + }, + XRPAmount{}, + STTx{ttVAULT_DELETE, [](STObject&) {}}, + {tecINVARIANT_FAILED, tecINVARIANT_FAILED}, + createLoanBroker); } } @@ -3481,8 +3503,11 @@ class Invariants_test : public beast::unit_test::Suite precloseXrp, TxAccount::A2); + // Under fixCleanup3_4_0 vault immutability is enforced by + // NoModifiedUnmodifiableFields (class-1, both passes), which reports + // "changed an unchangeable field" and escalates to tef on pass 2. doInvariantCheck( - {"violation of vault immutable data"}, + {"changed an unchangeable field"}, [&](Account const& a1, Account const& a2, ApplyContext& ac) { auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq())); auto sleVault = ac.view().peek(keylet); @@ -3494,11 +3519,11 @@ class Invariants_test : public beast::unit_test::Suite }, XRPAmount{}, STTx{ttVAULT_SET, [](STObject& tx) {}}, - {tecINVARIANT_FAILED, tecINVARIANT_FAILED}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, precloseXrp); doInvariantCheck( - {"violation of vault immutable data"}, + {"changed an unchangeable field"}, [&](Account const& a1, Account const& a2, ApplyContext& ac) { auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq())); auto sleVault = ac.view().peek(keylet); @@ -3510,11 +3535,11 @@ class Invariants_test : public beast::unit_test::Suite }, XRPAmount{}, STTx{ttVAULT_SET, [](STObject& tx) {}}, - {tecINVARIANT_FAILED, tecINVARIANT_FAILED}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, precloseXrp); doInvariantCheck( - {"violation of vault immutable data"}, + {"changed an unchangeable field"}, [&](Account const& a1, Account const& a2, ApplyContext& ac) { auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq())); auto sleVault = ac.view().peek(keylet); @@ -3526,7 +3551,7 @@ class Invariants_test : public beast::unit_test::Suite }, XRPAmount{}, STTx{ttVAULT_SET, [](STObject& tx) {}}, - {tecINVARIANT_FAILED, tecINVARIANT_FAILED}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, precloseXrp); doInvariantCheck( @@ -3691,6 +3716,31 @@ class Invariants_test : public beast::unit_test::Suite precloseXrp, TxAccount::A2); + // Universal share conservation: the issuance's OutstandingAmount + // delta must equal the sum of MPToken deltas across all touched + // holders. Bump the depositor's holding by 10 but only bump the + // issuance by 5, so the aggregate identity is off by 5. + doInvariantCheck( + {"shares outstanding delta must equal the sum of holder share deltas"}, + [&](Account const& a1, Account const& a2, ApplyContext& ac) { + auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq())); + return kAdjust( + ac.view(), + keylet, + Adjustments{ + .assetsTotal = 10, + .assetsAvailable = 10, + .sharesTotal = 5, + .vaultAssets = 10, + .accountAssets = AccountAmount{.account = a2.id(), .amount = -10}, + .accountShares = AccountAmount{.account = a2.id(), .amount = 10}}); + }, + XRPAmount{}, + STTx{ttVAULT_DEPOSIT, [](STObject&) {}}, + {tecINVARIANT_FAILED, tecINVARIANT_FAILED}, + precloseXrp, + TxAccount::A2); + testcase << "Vault loan operations"; // ttLOAN_MANAGE (impair): assets outstanding must not change. Only @@ -3741,7 +3791,7 @@ class Invariants_test : public beast::unit_test::Suite }, XRPAmount{}, STTx{ttLOAN_SET, [](STObject& tx) { tx.at(sfPrincipalRequested) = Number(200); }}, - {tecINVARIANT_FAILED, tecINVARIANT_FAILED}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, precloseXrp); // ttLOAN_SET: the balance decreases, but not by the principal requested @@ -3769,7 +3819,7 @@ class Invariants_test : public beast::unit_test::Suite tx.at(sfPrincipalRequested) = Number(200); tx.makeFieldPresent(sfCounterpartySignature); }}, - {tecINVARIANT_FAILED, tecINVARIANT_FAILED}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, precloseXrp); // ttLOAN_SET: vault balance decreases by the principal requested, but @@ -3799,12 +3849,12 @@ class Invariants_test : public beast::unit_test::Suite tx.at(sfPrincipalRequested) = Number(200); tx.makeFieldPresent(sfCounterpartySignature); }}, - {tecINVARIANT_FAILED, tecINVARIANT_FAILED}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, precloseXrp); // ttLOAN_SET: principal matches, but no loan object is created doInvariantCheck( - {"loan set must create exactly one loan"}, + {"lending transaction must touch exactly one loan"}, [&](Account const& a1, Account const& a2, ApplyContext& ac) { auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq())); return kAdjust( @@ -3822,7 +3872,7 @@ class Invariants_test : public beast::unit_test::Suite // ttLOAN_SET: principal matches, but more than one loan is created doInvariantCheck( - {"loan set must create exactly one loan"}, + {"lending transaction must touch exactly one loan"}, [&](Account const& a1, Account const& a2, ApplyContext& ac) { auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq())); return kAdjust( @@ -3842,9 +3892,74 @@ class Invariants_test : public beast::unit_test::Suite }, XRPAmount{}, STTx{ttLOAN_SET, [](STObject& tx) { tx.at(sfPrincipalRequested) = Number(200); }}, - {tecINVARIANT_FAILED, tecINVARIANT_FAILED}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, precloseXrp); + // ttLOAN_SET: no new loan is created, but an existing loan is modified + // instead. The cardinality helper distinguishes create from modify; + // a set that touches a pre-existing loan is spurious. + { + 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(), SeqProxy::rawSequence(ov.seq())); + auto const loanKeylet = keylet::loan(vaultKeylet.key, SeqProxy::rawSequence(1)); + // Pre-existing loan in the base view; modifying it in the apply + // view is what the cardinality check must reject for a set. + { + auto sleLoan = std::make_shared(loanKeylet); + sleLoan->at(sfPrincipalOutstanding) = Number(100); + sleLoan->at(sfTotalValueOutstanding) = Number(100); + sleLoan->at(sfManagementFeeOutstanding) = Number(0); + sleLoan->at(sfPeriodicPayment) = Number(1); + sleLoan->setFieldU32(sfPaymentRemaining, 1); + ov.rawInsert(sleLoan); + } + + STTx const tx{ + ttLOAN_SET, [](STObject& t) { t.at(sfPrincipalRequested) = Number(200); }}; + 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()); + + // Move the vault balance and available assets to match the + // principal requested, so the funding checks pass and only the + // cardinality shape is left to trip. + if (!BEAST_EXPECT(kAdjust( + ac.view(), + vaultKeylet, + Adjustments{ + .assetsAvailable = -200, + .vaultAssets = -200, + .accountAssets = AccountAmount{.account = a2.id(), .amount = 200}}))) + return; + // Modify the pre-existing loan so afterLoan_ has one entry with a + // non-empty beforeLoan_ counterpart: this is the "modify" shape + // that a set transaction must never produce. + auto sleLoan = ac.view().peek(loanKeylet); + if (!BEAST_EXPECT(sleLoan)) + return; + sleLoan->at(sfPrincipalOutstanding) = Number(50); + ac.view().update(sleLoan); + + auto transactor = makeTransactor(ac); + if (!BEAST_EXPECT(transactor)) + return; + TER const result = transactor->checkInvariants( + tesSUCCESS, XRPAmount{}, Transactor::InvariantScope::Full); + BEAST_EXPECT(result == tecINVARIANT_FAILED); + BEAST_EXPECT(sink.messages().str().contains( + "lending transaction must not modify an existing loan")); + } + // ttLOAN_SET: principal matches, but shares outstanding changes doInvariantCheck( {"shares outstanding must only change by deposit, withdraw, or clawback"}, @@ -3872,7 +3987,7 @@ class Invariants_test : public beast::unit_test::Suite tx.at(sfPrincipalRequested) = Number(200); tx.makeFieldPresent(sfCounterpartySignature); }}, - {tecINVARIANT_FAILED, tecINVARIANT_FAILED}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, precloseXrp); // ttLOAN_SET: everything balances (principal released, exactly one loan @@ -3901,9 +4016,43 @@ class Invariants_test : public beast::unit_test::Suite }, XRPAmount{}, STTx{ttLOAN_SET, [](STObject& tx) { tx.at(sfPrincipalRequested) = Number(200); }}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, + precloseXrp); + + // ttLOAN_MANAGE: no loan is touched at all. Every lending transaction + // must operate on exactly one loan; a manage with none is spurious. + doInvariantCheck( + {"lending transaction must touch exactly one loan"}, + [&](Account const& a1, Account const& a2, ApplyContext& ac) { + auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq())); + return kAdjust(ac.view(), keylet, Adjustments{}); + }, + XRPAmount{}, + STTx{ttLOAN_MANAGE, [](STObject& tx) { tx.setFieldU32(sfFlags, tfLoanImpair); }}, {tecINVARIANT_FAILED, tecINVARIANT_FAILED}, precloseXrp); + // ttLOAN_MANAGE: a loan is created rather than modified. The cardinality + // helper distinguishes create from modify and rejects the wrong shape. + doInvariantCheck( + {"lending transaction must modify exactly one loan"}, + [&](Account const& a1, Account const& a2, ApplyContext& ac) { + auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq())); + return kAdjust( + ac.view(), + keylet, + Adjustments{ + .createLoan = LoanParams{ + .principalOutstanding = 100, + .totalValueOutstanding = 100, + .borrower = a1.id(), + }}); + }, + XRPAmount{}, + STTx{ttLOAN_MANAGE, [](STObject& tx) { tx.setFieldU32(sfFlags, tfLoanImpair); }}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, + precloseXrp); + // ttLOAN_MANAGE: vault balance and assets available do not add up doInvariantCheck( {"vault balance and assets available must add up"}, @@ -4001,6 +4150,90 @@ class Invariants_test : public beast::unit_test::Suite {tecINVARIANT_FAILED, tecINVARIANT_FAILED}, precloseXrp); + // ttLOAN_MANAGE (unimpair): loss unrealized must not increase. Bumping + // loss unrealized upward is the wrong direction for unimpair, which + // reverses a paper loss. + doInvariantCheck( + {"loan impair must not decrease, and loan unimpair must not " + "increase, loss unrealized"}, + [&](Account const& a1, Account const& a2, ApplyContext& ac) { + auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq())); + return kAdjust(ac.view(), keylet, Adjustments{.lossUnrealized = 5}); + }, + XRPAmount{}, + STTx{ttLOAN_MANAGE, [](STObject& tx) { tx.setFieldU32(sfFlags, tfLoanUnimpair); }}, + {tecINVARIANT_FAILED, tecINVARIANT_FAILED}, + precloseXrp); + + // ttLOAN_MANAGE (impair): loss unrealized must not decrease. The base + // ledger cannot express a nonzero prior lossUnrealized through the + // shared harness, so the setup is bespoke: seed the vault with a small + // paper loss and then drop it back to zero under an impair — the + // wrong direction for impair, which only ever grows the paper loss. + { + 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(), SeqProxy::rawSequence(ov.seq())); + // Seed the vault with a paper loss in the base view so a + // decrease in the apply view registers as a negative delta. The + // SLE is cloned so the base and apply views hold separate copies. + { + auto const sleVaultRead = ov.read(vaultKeylet); + if (!BEAST_EXPECT(sleVaultRead)) + return; + auto sleVault = std::make_shared(*sleVaultRead); + sleVault->at(sfLossUnrealized) = Number(5); + ov.rawReplace(sleVault); + } + + STTx const tx{ + ttLOAN_MANAGE, [](STObject& t) { t.setFieldU32(sfFlags, tfLoanImpair); }}; + 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()); + + // Reset lossUnrealized to zero under an impair: after (0) < before + // (5), so the delta is negative and the sign check must reject. + auto sleVault = ac.view().peek(vaultKeylet); + if (!BEAST_EXPECT(sleVault)) + return; + sleVault->at(sfLossUnrealized) = Number(0); + ac.view().update(sleVault); + + auto transactor = makeTransactor(ac); + if (!BEAST_EXPECT(transactor)) + return; + TER const result = transactor->checkInvariants( + tesSUCCESS, XRPAmount{}, Transactor::InvariantScope::Full); + BEAST_EXPECT(result == tecINVARIANT_FAILED); + BEAST_EXPECT(sink.messages().str().contains( + "loan impair must not decrease, and loan unimpair must not " + "increase, loss unrealized")); + } + + // ttLOAN_MANAGE (default): loss unrealized must not increase. A default + // realizes the paper loss (or leaves it at zero for a non-impaired + // loan); it can never grow it. + doInvariantCheck( + {"loan default must not increase loss unrealized"}, + [&](Account const& a1, Account const& a2, ApplyContext& ac) { + auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq())); + return kAdjust(ac.view(), keylet, Adjustments{.lossUnrealized = 5}); + }, + 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"}, @@ -4013,6 +4246,54 @@ class Invariants_test : public beast::unit_test::Suite {tecINVARIANT_FAILED, tecINVARIANT_FAILED}, precloseXrp); + // ttLOAN_PAY: cash is credited to the vault but no loan is touched. + // The vault-balance check passes because a real inflow was recorded; + // it is the cardinality helper that must catch the missing loan. + doInvariantCheck( + {"lending transaction must touch exactly one loan"}, + [&](Account const& a1, Account const& a2, ApplyContext& ac) { + auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq())); + return kAdjust( + ac.view(), + keylet, + Adjustments{ + .assetsTotal = 50, + .assetsAvailable = 50, + .vaultAssets = 50, + .accountAssets = AccountAmount{.account = a2.id(), .amount = -50}}); + }, + XRPAmount{}, + STTx{ttLOAN_PAY, [](STObject& tx) { tx.setFieldAmount(sfAmount, XRPAmount(50)); }}, + {tecINVARIANT_FAILED, tecINVARIANT_FAILED}, + precloseXrp); + + // ttLOAN_PAY: cash is credited to the vault and a loan is created + // rather than modified. A payment services an existing loan, so a + // create is the wrong shape and the cardinality helper must reject + // it. + doInvariantCheck( + {"lending transaction must modify exactly one loan"}, + [&](Account const& a1, Account const& a2, ApplyContext& ac) { + auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq())); + return kAdjust( + ac.view(), + keylet, + Adjustments{ + .assetsTotal = 50, + .assetsAvailable = 50, + .vaultAssets = 50, + .accountAssets = AccountAmount{.account = a2.id(), .amount = -50}, + .createLoan = LoanParams{ + .principalOutstanding = 100, + .totalValueOutstanding = 100, + .borrower = a1.id(), + }}); + }, + XRPAmount{}, + STTx{ttLOAN_PAY, [](STObject& tx) { tx.setFieldAmount(sfAmount, XRPAmount(50)); }}, + {tecINVARIANT_FAILED, tefINVARIANT_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. @@ -4175,11 +4456,84 @@ class Invariants_test : public beast::unit_test::Suite auto transactor = makeTransactor(ac); if (!BEAST_EXPECT(transactor)) return; - TER const result = transactor->checkInvariants(tesSUCCESS, XRPAmount{}); + TER const result = transactor->checkInvariants( + tesSUCCESS, XRPAmount{}, Transactor::InvariantScope::Full); 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")); + // The pre-inserted loan carries a default (zero) sfLoanBrokerID, + // which does not resolve to a live broker; the broker-existence + // check must therefore also fire in the same walk. + BEAST_EXPECT(sink.messages().str().contains( + "loan pay loan broker must exist")); + } + + // ttLOAN_PAY: the vault's claim on the loan may only shrink. A payment + // pays the loan down, so total value outstanding (net of management + // fee) can only fall. The bespoke setup mirrors the conservation test + // above: a pre-existing loan is inserted so modifying it registers as a + // before/after change. + { + 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(), SeqProxy::rawSequence(ov.seq())); + auto const loanKeylet = keylet::loan(vaultKeylet.key, SeqProxy::rawSequence(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(50)); }}; + 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 inflow of 50 (paid by a2), assets outstanding grows by 100 + // to keep the conservation identity honest (Δtotal - Δavailable - + // Δclaim = 100 - 50 - 50 = 0). Push both principal (100 → 150) + // and total value (150 → 200): under either accounting basis the + // vault's claim grows by 50, which the sign check must reject. + if (!BEAST_EXPECT(kAdjust( + ac.view(), + vaultKeylet, + Adjustments{ + .assetsTotal = 100, + .assetsAvailable = 50, + .vaultAssets = 50, + .accountAssets = AccountAmount{.account = a2.id(), .amount = -50}}))) + return; + + auto sleLoan = ac.view().peek(loanKeylet); + if (!BEAST_EXPECT(sleLoan)) + return; + sleLoan->at(sfPrincipalOutstanding) = Number(150); + sleLoan->at(sfTotalValueOutstanding) = Number(200); + ac.view().update(sleLoan); + + auto transactor = makeTransactor(ac); + if (!BEAST_EXPECT(transactor)) + return; + TER const result = transactor->checkInvariants( + tesSUCCESS, XRPAmount{}, Transactor::InvariantScope::Full); + BEAST_EXPECT(result == tecINVARIANT_FAILED); + BEAST_EXPECT(sink.messages().str().contains( + "loan pay must not increase the vault's claim on the loan")); } // ttLOAN_PAY: the vault, the loan-broker pseudo-account and the @@ -4266,12 +4620,160 @@ class Invariants_test : public beast::unit_test::Suite auto transactor = makeTransactor(ac); if (!BEAST_EXPECT(transactor)) return; - TER const result = transactor->checkInvariants(tesSUCCESS, XRPAmount{}); + TER const result = transactor->checkInvariants( + tesSUCCESS, XRPAmount{}, Transactor::InvariantScope::Full); BEAST_EXPECT(result == tecINVARIANT_FAILED); BEAST_EXPECT(sink.messages().str().contains( "loan pay vault and broker must not receive more than the amount paid")); } + // ttLOAN_MANAGE (default): the first-loss capital the vault receives + // comes out of the loan-broker pseudo-account, so the two balances + // must move by exactly opposite amounts. Credit the vault by 50 while + // leaving the broker pseudo-account untouched: the residual is 50, + // not zero, and the cover-conservation check must fire. The setup is + // bespoke to give the invariant a real broker for the loan lookup. + { + Env env{*this, defaultAmendments()}; + Account const a1{"A1"}; + Account const a2{"A2"}; + env.fund(XRP(1000), a1, a2); + env.close(); + + PrettyAsset const xrpAsset{xrpIssue(), 1'000'000}; + auto const brokerKeylet = createLoanBroker(a1, env, xrpAsset); + if (!BEAST_EXPECT(env.le(brokerKeylet))) + return; + env.close(); + + auto const sleBrokerBase = env.le(brokerKeylet); + if (!BEAST_EXPECT(sleBrokerBase)) + return; + auto const vaultKeylet = keylet::vault(sleBrokerBase->at(sfVaultID)); + + Vault const vault{env}; + env(vault.deposit({.depositor = a2, .id = vaultKeylet.key, .amount = XRP(500)})); + env.close(); + + OpenView ov{*env.current()}; + + auto const loanKeylet = keylet::loan(vaultKeylet.key, SeqProxy::rawSequence(1)); + { + auto sleLoan = std::make_shared(loanKeylet); + sleLoan->at(sfLoanBrokerID) = brokerKeylet.key; + 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_MANAGE, [](STObject& t) { t.setFieldU32(sfFlags, tfLoanDefault); }}; + 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()); + + // Vault balance and assetsAvailable both +50 (as if first-loss + // capital were returned), sourced from a2 rather than the broker + // pseudo-account. The broker balance stays put, so the two + // deltas do not cancel. + if (!BEAST_EXPECT(kAdjust( + ac.view(), + vaultKeylet, + Adjustments{ + .assetsAvailable = 50, + .vaultAssets = 50, + .accountAssets = AccountAmount{.account = a2.id(), .amount = -50}}))) + return; + + // Modify the loan (before/after) so exactlyOneLoan passes. + auto sleLoan = ac.view().peek(loanKeylet); + if (!BEAST_EXPECT(sleLoan)) + return; + sleLoan->at(sfPrincipalOutstanding) = Number(0); + sleLoan->at(sfTotalValueOutstanding) = Number(0); + sleLoan->setFieldU32(sfPaymentRemaining, 0); + ac.view().update(sleLoan); + + auto transactor = makeTransactor(ac); + if (!BEAST_EXPECT(transactor)) + return; + TER const result = transactor->checkInvariants( + tesSUCCESS, XRPAmount{}, Transactor::InvariantScope::Full); + BEAST_EXPECT(result == tecINVARIANT_FAILED); + BEAST_EXPECT(sink.messages().str().contains( + "loan default must move the first-loss capital from the loan " + "broker to the vault")); + } + + // ttLOAN_MANAGE (default): the invariant reads the broker through the + // defaulted loan. If the loan carries a stale or zero LoanBrokerID + // the lookup fails, so the check that guards the cover-conservation + // step must report it explicitly rather than silently skip. + { + 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(), SeqProxy::rawSequence(ov.seq())); + auto const loanKeylet = keylet::loan(vaultKeylet.key, SeqProxy::rawSequence(1)); + // Pre-insert a loan carrying a default (zero) sfLoanBrokerID so + // the invariant's broker lookup returns nullopt. + { + auto sleLoan = std::make_shared(loanKeylet); + sleLoan->at(sfPrincipalOutstanding) = Number(100); + sleLoan->at(sfTotalValueOutstanding) = Number(100); + sleLoan->at(sfManagementFeeOutstanding) = Number(0); + sleLoan->at(sfPeriodicPayment) = Number(1); + sleLoan->setFieldU32(sfPaymentRemaining, 1); + ov.rawInsert(sleLoan); + } + + STTx const tx{ + ttLOAN_MANAGE, [](STObject& t) { t.setFieldU32(sfFlags, tfLoanDefault); }}; + 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()); + + // Touch the vault so ValidVault::finalize enters + // finalizeLoanManage; the tfLoanDefault path is what carries the + // "loan default loan broker must exist" check we are asserting. + auto sleVault = ac.view().peek(vaultKeylet); + if (!BEAST_EXPECT(sleVault)) + return; + ac.view().update(sleVault); + + // Modify the loan (before/after) so exactlyOneLoan passes and the + // broker lookup is actually reached. + auto sleLoan = ac.view().peek(loanKeylet); + if (!BEAST_EXPECT(sleLoan)) + return; + sleLoan->at(sfPrincipalOutstanding) = Number(0); + sleLoan->at(sfTotalValueOutstanding) = Number(0); + sleLoan->setFieldU32(sfPaymentRemaining, 0); + ac.view().update(sleLoan); + + auto transactor = makeTransactor(ac); + if (!BEAST_EXPECT(transactor)) + return; + TER const result = transactor->checkInvariants( + tesSUCCESS, XRPAmount{}, Transactor::InvariantScope::Full); + BEAST_EXPECT(result == tecINVARIANT_FAILED); + BEAST_EXPECT(sink.messages().str().contains( + "loan default loan broker must exist")); + } + // 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 @@ -4340,6 +4842,27 @@ class Invariants_test : public beast::unit_test::Suite STTx{ttLOAN_DELETE, [](STObject&) {}}, {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, precloseLoan); + + // Item 54 (XLS-66 §3.1.5 precondition 1): a LoanBrokerDelete + // transaction must not touch any loan. Broker delete requires + // OwnerCount == 0 (no loans reference the broker); touching a + // loan alongside the delete points at either an + // OwnerCount-tracking bug or a spurious cascading write. + // Requires featureLendingProtocolV1_1. + doInvariantCheck( + makeEnv(defaultAmendments() | featureLendingProtocolV1_1), + {"LoanBrokerDelete must not touch any loan"}, + [&loanKeylet](Account const&, Account const&, ApplyContext& ac) { + auto sle = ac.view().peek(loanKeylet); + if (!sle) + return false; + ac.view().update(sle); + return true; + }, + XRPAmount{}, + STTx{ttLOAN_BROKER_DELETE, [](STObject&) {}}, + {tecINVARIANT_FAILED, tecINVARIANT_FAILED}, + precloseLoan); } // Loan interest due (total value less principal and management fee) @@ -4421,9 +4944,10 @@ class Invariants_test : public beast::unit_test::Suite return true; }); - // ttVAULT_SET: owner is immutable + // ttVAULT_SET: owner is immutable (enforced by + // NoModifiedUnmodifiableFields under fixCleanup3_4_0). doInvariantCheck( - {"violation of vault immutable data"}, + {"changed an unchangeable field"}, [&](Account const& a1, Account const& a2, ApplyContext& ac) { auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq())); auto sleVault = ac.view().peek(keylet); @@ -4435,12 +4959,12 @@ class Invariants_test : public beast::unit_test::Suite }, XRPAmount{}, STTx{ttVAULT_SET, [](STObject& tx) {}}, - {tecINVARIANT_FAILED, tecINVARIANT_FAILED}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, precloseXrp); // ttVAULT_SET: withdrawal policy is immutable doInvariantCheck( - {"violation of vault immutable data"}, + {"changed an unchangeable field"}, [&](Account const& a1, Account const& a2, ApplyContext& ac) { auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq())); auto sleVault = ac.view().peek(keylet); @@ -4454,12 +4978,12 @@ class Invariants_test : public beast::unit_test::Suite }, XRPAmount{}, STTx{ttVAULT_SET, [](STObject& tx) {}}, - {tecINVARIANT_FAILED, tecINVARIANT_FAILED}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, precloseXrp); // ttVAULT_SET: scale is immutable doInvariantCheck( - {"violation of vault immutable data"}, + {"changed an unchangeable field"}, [&](Account const& a1, Account const& a2, ApplyContext& ac) { auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq())); auto sleVault = ac.view().peek(keylet); @@ -4472,7 +4996,7 @@ class Invariants_test : public beast::unit_test::Suite }, XRPAmount{}, STTx{ttVAULT_SET, [](STObject& tx) {}}, - {tecINVARIANT_FAILED, tecINVARIANT_FAILED}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, precloseXrp); testcase << "Vault create";