From 31f1d5ff504f0d048c62e059d07b72b7da920a99 Mon Sep 17 00:00:00 2001 From: JCW Date: Tue, 14 Jul 2026 18:27:22 +0100 Subject: [PATCH] Fix invariant tests --- src/libxrpl/tx/invariants/VaultInvariant.cpp | 108 ++++++++++++++++++- src/test/app/Invariants_test.cpp | 53 ++++++++- 2 files changed, 153 insertions(+), 8 deletions(-) diff --git a/src/libxrpl/tx/invariants/VaultInvariant.cpp b/src/libxrpl/tx/invariants/VaultInvariant.cpp index e9306b7e4d..cfb058dfa0 100644 --- a/src/libxrpl/tx/invariants/VaultInvariant.cpp +++ b/src/libxrpl/tx/invariants/VaultInvariant.cpp @@ -479,12 +479,9 @@ ValidVault::finalize( // pending loan (Borrower present, CounterpartySignature absent). if (afterVault.assetsReserved != beforeVault.assetsReserved) { - bool const pendingLoanSet = txnType == ttLOAN_SET && - tx.isFieldPresent(sfBorrower) && - tx.isFieldPresent(sfStartDate) && - !tx.isFieldPresent(sfCounterpartySignature) && + bool const pendingLoanSet = txnType == ttLOAN_SET && tx.isFieldPresent(sfBorrower) && + tx.isFieldPresent(sfStartDate) && !tx.isFieldPresent(sfCounterpartySignature) && !tx.isFieldPresent(sfCounterparty); - !tx.isFieldPresent(sfCounterpartySignature); if (txnType != ttLOAN_DELETE && txnType != ttLOAN_ACCEPT && !pendingLoanSet) { JLOG(j.fatal()) << "Invariant failed: vault AssetsReserved changed " @@ -1438,6 +1435,107 @@ ValidVault::finalize( return result; } + case ttLOAN_ACCEPT: { + bool result = true; + + XRPL_ASSERT( + !beforeVault_.empty(), + "xrpl::ValidVault::finalize : loan accept updated a vault"); + auto const& beforeVault = beforeVault_[0]; + + // Accepting a pending loan disburses the reserved principal + // from the vault pseudo-account to the borrower (and the + // origination fee, if any, to the broker owner) and releases it + // from the reserved bucket. The assets available and assets + // outstanding were already settled when the pending loan was + // created, so they must not move now. + auto const maybeVaultDeltaAssets = deltaAssets(afterVault.pseudoId); + if (!maybeVaultDeltaAssets) + { + JLOG(j.fatal()) << // + "Invariant failed: loan accept must change vault balance"; + return false; // That's all we can do + } + + // A loan accept modifies exactly the loan being accepted; its + // principal outstanding is the amount disbursed and released. + if (afterLoan_.size() != 1 || beforeLoan_.size() != 1 || + afterLoan_[0].key != beforeLoan_[0].key) + { + JLOG(j.fatal()) << // + "Invariant failed: loan accept must modify exactly one " + "loan"; + return false; // That's all we can do + } + auto const& loan = beforeLoan_[0]; + + // Get the posterior scale to round calculations to + auto const minScale = computeVaultMinScale(*maybeVaultDeltaAssets, view.rules()); + + auto const principalDelta = + roundToAsset(vaultAsset, -loan.principalOutstanding, minScale); + + // The vault (pseudo-account) balance must fall by exactly the + // disbursed principal. + auto const vaultDeltaAssets = + roundToAsset(vaultAsset, maybeVaultDeltaAssets->delta, minScale); + if (vaultDeltaAssets != principalDelta) + { + JLOG(j.fatal()) << // + "Invariant failed: loan accept must decrease vault " + "balance by the principal outstanding"; + result = false; + } + + // The reserved principal (held back when the pending loan was + // created) must be released by exactly the disbursed principal. + auto const assetsReservedDelta = roundToAsset( + vaultAsset, afterVault.assetsReserved - beforeVault.assetsReserved, minScale); + if (assetsReservedDelta != principalDelta) + { + JLOG(j.fatal()) << // + "Invariant failed: loan accept must decrease assets " + "reserved by the principal outstanding"; + result = false; + } + + // Accepting a loan neither adds to nor removes from the pool + // tracked by assets available. + auto const assetAvailableDelta = roundToAsset( + vaultAsset, afterVault.assetsAvailable - beforeVault.assetsAvailable, minScale); + if (assetAvailableDelta != kZero) + { + JLOG(j.fatal()) << // + "Invariant failed: loan accept must not change assets " + "available"; + result = false; + } + + // Likewise the interest booked at loan creation stands: assets + // outstanding must not move on accept. + auto const assetsTotalDelta = roundToAsset( + vaultAsset, afterVault.assetsTotal - beforeVault.assetsTotal, minScale); + if (assetsTotalDelta != kZero) + { + JLOG(j.fatal()) << // + "Invariant failed: loan accept must not change assets " + "outstanding"; + result = false; + } + + // A loan accept neither mints nor burns vault shares. + if (beforeShares && updatedShares && + beforeShares->sharesTotal != updatedShares->sharesTotal) + { + JLOG(j.fatal()) << // + "Invariant failed: loan accept must not change shares " + "outstanding"; + result = false; + } + + return result; + } + // LCOV_EXCL_START default: UNREACHABLE("xrpl::ValidVault::finalize : unknown transaction type"); diff --git a/src/test/app/Invariants_test.cpp b/src/test/app/Invariants_test.cpp index 7f55da1bd5..3ccbb0c2b1 100644 --- a/src/test/app/Invariants_test.cpp +++ b/src/test/app/Invariants_test.cpp @@ -4004,15 +4004,37 @@ class Invariants_test : public beast::unit_test::Suite Account const a1{"A1"}; Account const a2{"A2"}; env.fund(XRP(1000), a1, a2); - BEAST_EXPECT(precloseXrp(a1, a2, env)); + // Create a real vault so a LoanAccept - which must modify the + // vault it lends from - can apply a self-consistent vault delta + // alongside the loan mutation under test. + Vault const vault{env}; + auto [createTx, vaultKeylet] = vault.create({.owner = a1, .asset = xrpIssue()}); + env(createTx); + env(vault.deposit({.depositor = a1, .id = vaultKeylet.key, .amount = XRP(10)})); env.close(); + // The disbursed principal shared by the loan and the vault + // delta for every LoanAccept case below. + Number const principal{100}; + OpenView ov{*env.current()}; - auto const vaultKeylet = keylet::vault(a1.id(), ov.seq()); auto const loanKeylet = keylet::loan(vaultKeylet.key, 1); + + // Seed the vault with reserved principal (as if a pending loan + // had already set it aside) so LoanAccept can release it. + AccountID vaultPseudo; + { + auto const sleVaultBase = ov.read(vaultKeylet); + if (!BEAST_EXPECT(sleVaultBase)) + return; + vaultPseudo = sleVaultBase->getAccountID(sfAccount); + auto sleVault = std::make_shared(*sleVaultBase); + sleVault->at(sfAssetsReserved) = principal; + ov.rawReplace(sleVault); + } { auto sleLoan = std::make_shared(loanKeylet); - sleLoan->at(sfPrincipalOutstanding) = Number(100); + sleLoan->at(sfPrincipalOutstanding) = principal; sleLoan->at(sfTotalValueOutstanding) = Number(150); sleLoan->at(sfManagementFeeOutstanding) = Number(0); sleLoan->at(sfPeriodicPayment) = Number(1); @@ -4038,6 +4060,31 @@ class Invariants_test : public beast::unit_test::Suite mutate(sleLoan); ac.view().update(sleLoan); + // A LoanAccept must modify the vault it lends from: release the + // reserved principal and disburse it from the vault + // pseudo-account. The disbursed XRP is credited to a2 so the + // XRP-conservation invariant is satisfied. + if (tx.getTxnType() == ttLOAN_ACCEPT) + { + auto sleVault = ac.view().peek(vaultKeylet); + auto slePseudo = ac.view().peek(keylet::account(vaultPseudo)); + auto sleDest = ac.view().peek(keylet::account(a2.id())); + if (!BEAST_EXPECT(sleVault && slePseudo && sleDest)) + return; + + auto reservedProxy = sleVault->at(sfAssetsReserved); + reservedProxy -= principal; + ac.view().update(sleVault); + + STAmount const disbursed{100}; + slePseudo->setFieldAmount( + sfBalance, slePseudo->getFieldAmount(sfBalance) - disbursed); + ac.view().update(slePseudo); + sleDest->setFieldAmount( + sfBalance, sleDest->getFieldAmount(sfBalance) + disbursed); + ac.view().update(sleDest); + } + auto transactor = makeTransactor(ac); if (!BEAST_EXPECT(transactor)) return;