diff --git a/include/xrpl/ledger/VaultHelpers.h b/include/xrpl/ledger/VaultHelpers.h index 0a72de1810..d1d2fcddbc 100644 --- a/include/xrpl/ledger/VaultHelpers.h +++ b/include/xrpl/ledger/VaultHelpers.h @@ -2,6 +2,7 @@ #include #include +#include #include #include #include @@ -95,4 +96,29 @@ computeClawback( Number const& assetsAvailable, beast::Journal j); +/** + * Updates vault state when a loan is issued: reduces available assets by the + * borrowed amount and increases total assets by the yield (accrued interest). + * With featureLendingProtocolV1_1, also tracks the yield in InterestUnrealized + * and validates vault state after modification. + * + * @param view The ledger view to read issuance from and update. + * @param vault The vault SLE to modify. Must be of type ltVAULT. + * @param amount The principal to borrow. Must be > 0 and <= AssetsAvailable. + * @param yield The accrued interest to add. Must be >= 0. Added to both + * AssetsTotal and InterestUnrealized (v1_1 only). + * @param j Journal for error logging. + * + * @return tesSUCCESS on success, tecINTERNAL on invalid inputs or if the + * resulting vault state fails validation. The caller should validate + * inputs beforehand to return a user-facing error code. + */ +[[nodiscard]] TER +borrowFromVault( + ApplyView& view, + SLE::ref vault, + Number const& amount, + Number const& yield, + beast::Journal j); + } // namespace xrpl::vault diff --git a/src/libxrpl/ledger/VaultHelpers.cpp b/src/libxrpl/ledger/VaultHelpers.cpp index 5e1bca2448..61dae15920 100644 --- a/src/libxrpl/ledger/VaultHelpers.cpp +++ b/src/libxrpl/ledger/VaultHelpers.cpp @@ -171,25 +171,43 @@ sharesToAssetsWithdraw(SLE::const_ref vault, SLE::const_ref issuance, STAmount c } // namespace v1 -// v2 vault state validation — checks ledger invariants before math. +// v2 vault state validation — checks ledger invariants. // Returns tecINTERNAL and logs on invalid state. Returns tesSUCCESS if valid. TER validateVaultState(SLE::const_ref vault, SLE::const_ref issuance, beast::Journal j) { - Number const assetTotal = vault->at(sfAssetsTotal); - if (assetTotal == 0) + Number const assetsTotal = vault->at(sfAssetsTotal); + if (assetsTotal == 0) return tesSUCCESS; - Number const interestUnrealized = vault->at(sfInterestUnrealized); - Number const lossUnrealized = vault->at(sfLossUnrealized); + auto const interestUnrealized = vault->at(sfInterestUnrealized); + auto const lossUnrealized = vault->at(sfLossUnrealized); + auto const assetsMaximum = vault->at(sfAssetsMaximum); + + // Cannot exceed asset cap + if (assetsMaximum > 0 && assetsTotal > assetsMaximum) + { + JLOG(j.error()) << "vault state: assets total exceeds maximum" + << " (assetsTotal=" << assetsTotal << ", assetsMaximum=" << assetsMaximum + << ")"; + return tecINTERNAL; + } + + if (vault->at(sfAssetsAvailable) > assetsTotal) + { + JLOG(j.error()) << "vault state: assets available exceeds assets total" + << " (assetsAvailable=" << vault->at(sfAssetsAvailable) + << ", assetsTotal=" << assetsTotal << ")"; + return tecINTERNAL; + } // Deposit NAV excludes loss; withdrawal NAV excludes both. // Deposit NAV <= 0 means all vault value is unrealized interest, which should be impossible. - Number const depositNAV = assetTotal - interestUnrealized; + Number const depositNAV = assetsTotal - interestUnrealized; if (depositNAV <= 0) { JLOG(j.error()) << "vault state: deposit NAV <= 0" - << " (assetsTotal=" << assetTotal + << " (assetsTotal=" << assetsTotal << ", interestUnrealized=" << interestUnrealized << ")"; return tecINTERNAL; } @@ -198,7 +216,7 @@ validateVaultState(SLE::const_ref vault, SLE::const_ref issuance, beast::Journal if (withdrawNAV < 0) { JLOG(j.error()) << "vault state: withdrawal NAV < 0" - << " (assetsTotal=" << assetTotal + << " (assetsTotal=" << assetsTotal << ", interestUnrealized=" << interestUnrealized << ", lossUnrealized=" << lossUnrealized << ")"; return tecINTERNAL; @@ -475,4 +493,50 @@ computeClawback( } } +[[nodiscard]] TER +borrowFromVault( + ApplyView& view, + SLE::ref vault, + Number const& amount, + Number const& yield, + beast::Journal j) +{ + XRPL_ASSERT(vault && vault->getType() == ltVAULT, "xrpl::vault::borrowFromVault : vault SLE"); + + if (amount <= 0 || yield < 0) + { + JLOG(j.error()) << "borrowFromVault: invalid input" + << " (amount=" << amount << ", yield=" << yield << ")"; + return tecINTERNAL; + } + + // Cannot borrow more than available assets + if (vault->at(sfAssetsAvailable) < amount) + { + JLOG(j.error()) << "borrowFromVault: insufficient available assets" + << " (available=" << *vault->at(sfAssetsAvailable) << ", amount=" << amount + << ")"; + return tecINTERNAL; + } + + // Update vault state + if (view.rules().enabled(featureLendingProtocolV1_1)) + vault->at(sfInterestUnrealized) += yield; + vault->at(sfAssetsAvailable) -= amount; + vault->at(sfAssetsTotal) += yield; + + if (view.rules().enabled(featureLendingProtocolV1_1)) + { + std::shared_ptr issuance = + view.read(keylet::mptIssuance(vault->at(sfShareMPTID))); + if (!issuance) + return tecINTERNAL; // LCOV_EXCL_LINE + + if (auto const ter = validateVaultState(vault, issuance, j)) + return ter; + } + + view.update(vault); + return tesSUCCESS; +} } // namespace xrpl::vault diff --git a/src/test/app/VaultHelpers_test.cpp b/src/test/app/VaultHelpers_test.cpp index e4dd2b707a..25661e1260 100644 --- a/src/test/app/VaultHelpers_test.cpp +++ b/src/test/app/VaultHelpers_test.cpp @@ -1,6 +1,7 @@ #include #include +#include #include #include #include @@ -706,6 +707,86 @@ class VaultHelpers_test : public beast::unit_test::suite } } + // Creates a Sandbox with vault and issuance SLEs inserted. + Sandbox + makeSandbox(SLE::pointer vault, SLE::pointer issuance) + { + Sandbox sb(env_->current().get(), tapNONE); + sb.insert(vault); + sb.insert(issuance); + return sb; + } + + void + testBorrowFromVault() + { + using namespace vault; + + testcase("borrowFromVault: normal"); + { + auto vaultSle = makeVault(1000, 0, 0, 0); + (*vaultSle)[sfAssetsAvailable] = STNumber{sfAssetsAvailable, 1000}; + (*vaultSle)[sfAssetsMaximum] = STNumber{sfAssetsMaximum, 0}; + auto issuanceSle = makeIssuance(1000); + + auto sb = makeSandbox(vaultSle, issuanceSle); + auto const ter = borrowFromVault(sb, vaultSle, 100, 10, env_->journal); + BEAST_EXPECT(ter == tesSUCCESS); + BEAST_EXPECT(vaultSle->at(sfAssetsAvailable) == 900); + BEAST_EXPECT(vaultSle->at(sfAssetsTotal) == 1010); + BEAST_EXPECT(vaultSle->at(sfInterestUnrealized) == 10); + } + + testcase("borrowFromVault: zero yield"); + { + auto vaultSle = makeVault(1000, 0, 0, 0); + (*vaultSle)[sfAssetsAvailable] = STNumber{sfAssetsAvailable, 1000}; + (*vaultSle)[sfAssetsMaximum] = STNumber{sfAssetsMaximum, 0}; + auto issuanceSle = makeIssuance(1000); + + auto sb = makeSandbox(vaultSle, issuanceSle); + auto const ter = borrowFromVault(sb, vaultSle, 100, 0, env_->journal); + BEAST_EXPECT(ter == tesSUCCESS); + BEAST_EXPECT(vaultSle->at(sfAssetsAvailable) == 900); + BEAST_EXPECT(vaultSle->at(sfAssetsTotal) == 1000); + BEAST_EXPECT(vaultSle->at(sfInterestUnrealized) == 0); + } + + testcase("borrowFromVault: invalid amount"); + { + auto vaultSle = makeVault(1000, 0, 0, 0); + (*vaultSle)[sfAssetsAvailable] = STNumber{sfAssetsAvailable, 1000}; + (*vaultSle)[sfAssetsMaximum] = STNumber{sfAssetsMaximum, 0}; + auto issuanceSle = makeIssuance(1000); + + auto sb = makeSandbox(vaultSle, issuanceSle); + BEAST_EXPECT(borrowFromVault(sb, vaultSle, 0, 10, env_->journal) == tecINTERNAL); + BEAST_EXPECT(borrowFromVault(sb, vaultSle, -1, 10, env_->journal) == tecINTERNAL); + } + + testcase("borrowFromVault: negative yield"); + { + auto vaultSle = makeVault(1000, 0, 0, 0); + (*vaultSle)[sfAssetsAvailable] = STNumber{sfAssetsAvailable, 1000}; + (*vaultSle)[sfAssetsMaximum] = STNumber{sfAssetsMaximum, 0}; + auto issuanceSle = makeIssuance(1000); + + auto sb = makeSandbox(vaultSle, issuanceSle); + BEAST_EXPECT(borrowFromVault(sb, vaultSle, 100, -1, env_->journal) == tecINTERNAL); + } + + testcase("borrowFromVault: insufficient available"); + { + auto vaultSle = makeVault(1000, 0, 0, 0); + (*vaultSle)[sfAssetsAvailable] = STNumber{sfAssetsAvailable, 50}; + (*vaultSle)[sfAssetsMaximum] = STNumber{sfAssetsMaximum, 0}; + auto issuanceSle = makeIssuance(1000); + + auto sb = makeSandbox(vaultSle, issuanceSle); + BEAST_EXPECT(borrowFromVault(sb, vaultSle, 100, 10, env_->journal) == tecINTERNAL); + } + } + public: void run() override @@ -725,6 +806,7 @@ public: testComputeWithdrawByAssets(); testComputeWithdrawByShares(); testComputeClawback(); + testBorrowFromVault(); } };