From c46d8941928595d230d8a8f92980621203e2f6fe Mon Sep 17 00:00:00 2001 From: Ed Hennis Date: Thu, 17 Jul 2025 20:01:20 -0400 Subject: [PATCH] Implement LoanBrokerCoverClawback and many test cases - Not all tests are passing yet --- .../xrpl/protocol/detail/transactions.macro | 22 +- src/test/app/LoanBroker_test.cpp | 223 ++++++++++-- src/test/jtx/TestHelpers.h | 29 ++ src/test/jtx/impl/TestHelpers.cpp | 10 + src/test/jtx/impl/mpt.cpp | 2 +- src/test/jtx/mpt.h | 2 +- .../app/tx/detail/LoanBrokerCoverClawback.cpp | 343 ++++++++++++++++++ .../app/tx/detail/LoanBrokerCoverClawback.h | 53 +++ 8 files changed, 648 insertions(+), 36 deletions(-) create mode 100644 src/xrpld/app/tx/detail/LoanBrokerCoverClawback.cpp create mode 100644 src/xrpld/app/tx/detail/LoanBrokerCoverClawback.h diff --git a/include/xrpl/protocol/detail/transactions.macro b/include/xrpl/protocol/detail/transactions.macro index 73d9a56ab0..968d4c0ede 100644 --- a/include/xrpl/protocol/detail/transactions.macro +++ b/include/xrpl/protocol/detail/transactions.macro @@ -880,11 +880,23 @@ TRANSACTION(ttLOAN_BROKER_COVER_WITHDRAW, 77, LoanBrokerCoverWithdraw, {sfDestinationTag, soeOPTIONAL}, })) +/** This transaction claws back First Loss Capital from a Loan Broker to + the issuer of the capital */ +#if TRANSACTION_INCLUDE +# include +#endif +TRANSACTION(ttLOAN_BROKER_COVER_CLAWBACK, 78, LoanBrokerCoverClawback, + Delegation::delegatable, + noPriv, ({ + {sfLoanBrokerID, soeOPTIONAL}, + {sfAmount, soeOPTIONAL, soeMPTSupported}, +})) + /** This transaction creates a Loan */ #if TRANSACTION_INCLUDE # include #endif -TRANSACTION(ttLOAN_SET, 78, LoanSet, +TRANSACTION(ttLOAN_SET, 80, LoanSet, Delegation::delegatable, noPriv, ({ {sfLoanBrokerID, soeREQUIRED}, @@ -911,7 +923,7 @@ TRANSACTION(ttLOAN_SET, 78, LoanSet, #if TRANSACTION_INCLUDE # include #endif -TRANSACTION(ttLOAN_DELETE, 79, LoanDelete, +TRANSACTION(ttLOAN_DELETE, 81, LoanDelete, Delegation::delegatable, noPriv, ({ {sfLoanID, soeREQUIRED}, @@ -921,7 +933,7 @@ TRANSACTION(ttLOAN_DELETE, 79, LoanDelete, #if TRANSACTION_INCLUDE # include #endif -TRANSACTION(ttLOAN_MANAGE, 80, LoanManage, +TRANSACTION(ttLOAN_MANAGE, 82, LoanManage, Delegation::delegatable, noPriv, ({ {sfLoanID, soeREQUIRED}, @@ -931,7 +943,7 @@ TRANSACTION(ttLOAN_MANAGE, 80, LoanManage, #if TRANSACTION_INCLUDE # include #endif -TRANSACTION(ttLOAN_DRAW, 81, LoanDraw, +TRANSACTION(ttLOAN_DRAW, 83, LoanDraw, Delegation::delegatable, noPriv, ({ {sfLoanID, soeREQUIRED}, @@ -942,7 +954,7 @@ TRANSACTION(ttLOAN_DRAW, 81, LoanDraw, #if TRANSACTION_INCLUDE # include #endif -TRANSACTION(ttLOAN_PAY, 82, LoanPay, +TRANSACTION(ttLOAN_PAY, 84, LoanPay, Delegation::delegatable, noPriv, ({ {sfLoanID, soeREQUIRED}, diff --git a/src/test/app/LoanBroker_test.cpp b/src/test/app/LoanBroker_test.cpp index eb133628ef..778b37b3a2 100644 --- a/src/test/app/LoanBroker_test.cpp +++ b/src/test/app/LoanBroker_test.cpp @@ -85,7 +85,17 @@ class LoanBroker_test : public beast::unit_test::suite // 2. LoanBrokerCoverWithdraw env(coverWithdraw(alice, brokerKeylet.key, asset(1000)), ter(temDISABLED)); - // 3. LoanBrokerDelete + // 3. LoanBrokerCoverClawback + env(coverClawback(alice), ter(temDISABLED)); + env(coverClawback(alice), + loanBrokerID(brokerKeylet.key), + ter(temDISABLED)); + env(coverClawback(alice), amount(asset(0)), ter(temDISABLED)); + env(coverClawback(alice), + loanBrokerID(brokerKeylet.key), + amount(asset(1000)), + ter(temDISABLED)); + // 4. LoanBrokerDelete env(del(alice, brokerKeylet.key), ter(temDISABLED)); }; failAll(all - featureMPTokensV1); @@ -98,8 +108,12 @@ class LoanBroker_test : public beast::unit_test::suite { jtx::PrettyAsset asset; uint256 vaultID; - VaultInfo(jtx::PrettyAsset const& asset_, uint256 const& vaultID_) - : asset(asset_), vaultID(vaultID_) + jtx::Account pseudoAccount; + VaultInfo( + jtx::PrettyAsset const& asset_, + uint256 const& vaultID_, + AccountID const& pseudo) + : asset(asset_), vaultID(vaultID_), pseudoAccount("vault", pseudo) { } }; @@ -108,16 +122,17 @@ class LoanBroker_test : public beast::unit_test::suite lifecycle( char const* label, jtx::Env& env, + jtx::Account const& issuer, jtx::Account const& alice, jtx::Account const& evan, jtx::Account const& bystander, VaultInfo const& vault, + VaultInfo const& badVault, std::function modifyJTx, std::function checkBroker, std::function changeBroker, std::function checkChangedBroker) { - auto const keylet = keylet::loanbroker(alice.id(), env.seq(alice)); { auto const& asset = vault.asset.raw(); testcase << "Lifecycle: " @@ -131,6 +146,31 @@ class LoanBroker_test : public beast::unit_test::suite using namespace jtx; using namespace loanBroker; + // Bogus assets to use in test cases + static PrettyAsset const badMptAsset = [&]() { + MPTTester badMptt{env, evan, mptInitNoFund}; + badMptt.create( + {.flags = tfMPTCanClawback | tfMPTCanTransfer | tfMPTCanLock}); + return badMptt["BAD"]; + }(); + static PrettyAsset const badIouAsset = evan["BAD"]; + static Account const nonExistent{"NonExistent"}; + static PrettyAsset const ghostIouAsset = nonExistent["GST"]; + PrettyAsset const vaultPseudoIouAsset = vault.pseudoAccount["PSD"]; + + auto const badKeylet = keylet::loanbroker(alice.id(), env.seq(alice)); + env(set(alice, badVault.vaultID)); + auto const badBrokerPseudo = [&]() { + if (auto const le = env.le(badKeylet); BEAST_EXPECT(le)) + { + return Account{"Bad Broker pseudo-account", le->at(sfAccount)}; + } + // Just to make the build work + return vault.pseudoAccount; + }(); + PrettyAsset const badBrokerPseudoIouAsset = badBrokerPseudo["WAT"]; + + auto const keylet = keylet::loanbroker(alice.id(), env.seq(alice)); { // Start with default values auto jtx = env.jt(set(alice, vault.vaultID)); @@ -166,6 +206,7 @@ class LoanBroker_test : public beast::unit_test::suite // Load the pseudo-account Account const pseudoAccount{ "Broker pseudo-account", broker->at(sfAccount)}; + auto const pseudoKeylet = keylet::account(pseudoAccount); if (auto const pseudo = env.le(pseudoKeylet); BEAST_EXPECT(pseudo)) { @@ -200,13 +241,16 @@ class LoanBroker_test : public beast::unit_test::suite } auto verifyCoverAmount = - [&env, &vault, &broker, &pseudoAccount, this](auto n) { + [&env, &vault, &pseudoAccount, &broker, &keylet, this](auto n) { using namespace jtx; - auto const amount = vault.asset(n); - BEAST_EXPECT( - broker->at(sfCoverAvailable) == amount.number()); - env.require(balance(pseudoAccount, amount)); + if (BEAST_EXPECT(broker = env.le(keylet))) + { + auto const amount = vault.asset(n); + BEAST_EXPECT( + broker->at(sfCoverAvailable) == amount.number()); + env.require(balance(pseudoAccount, amount)); + } }; // Test Cover funding before allowing alterations @@ -223,12 +267,80 @@ class LoanBroker_test : public beast::unit_test::suite verifyCoverAmount(0); + // Test cover clawback failure cases BEFORE depositing any cover + // Need one of brokerID or amount + env(coverClawback(alice), ter(temINVALID)); + env(coverClawback(alice), + loanBrokerID(uint256(0)), + ter(temINVALID)); + env(coverClawback(alice), amount(XRP(1000)), ter(temBAD_AMOUNT)); + env(coverClawback(alice), + amount(vault.asset(-10)), + ter(temBAD_AMOUNT)); + // Clawbacks with an MPT need to specify the broker ID + env(coverClawback(alice), amount(badMptAsset(1)), ter(temINVALID)); + env(coverClawback(evan), + loanBrokerID(vault.vaultID), + ter(tecNO_ENTRY)); + // Only the issuer can clawback + env(coverClawback(alice), + loanBrokerID(keylet.key), + ter(tecNO_PERMISSION)); + if (vault.asset.raw().native()) + { + // Can not clawback XRP under any circumstances + env(coverClawback(issuer), + loanBrokerID(keylet.key), + ter(tecNO_PERMISSION)); + } + else + { + if (vault.asset.raw().holds()) + { + // Clawbacks without a loanBrokerID need to specify an IOU + // with the broker's pseudo-account as the issuer + env(coverClawback(alice), + amount(ghostIouAsset(1)), + ter(tecNO_ENTRY)); + env(coverClawback(alice), + amount(badIouAsset(1)), + ter(tecOBJECT_NOT_FOUND)); + env(coverClawback(alice), + amount(vaultPseudoIouAsset(1)), + ter(tecNO_ENTRY)); + // If we specify a pseudo-account as the IOU amount, it + // needs to match the loan broker + env(coverClawback(alice), + loanBrokerID(keylet.key), + amount(badBrokerPseudoIouAsset(10)), + ter(tecWRONG_ASSET)); + PrettyAsset const brokerWrongCurrencyAsset = + pseudoAccount["WAT"]; + env(coverClawback(alice), + loanBrokerID(keylet.key), + amount(brokerWrongCurrencyAsset(10)), + ter(tecWRONG_ASSET)); + } + else + { + // Clawbacks with an MPT need to specify the broker ID, even + // if the asset is valid + BEAST_EXPECT(vault.asset.raw().holds()); + env(coverClawback(alice), + amount(vault.asset(10)), + ter(temINVALID)); + } + // Since no cover has been deposited, there's nothing to claw + // back + env(coverClawback(issuer), + loanBrokerID(keylet.key), + amount(vault.asset(10)), + ter(tecINSUFFICIENT_FUNDS)); + } + // Fund the cover deposit env(coverDeposit(alice, keylet.key, vault.asset(10))); - if (BEAST_EXPECT(broker = env.le(keylet))) - { - verifyCoverAmount(10); - } + verifyCoverAmount(10); // Test withdrawal failure cases env(coverWithdraw(alice, uint256(0), vault.asset(10)), @@ -258,29 +370,61 @@ class LoanBroker_test : public beast::unit_test::suite // Withdraw some of the cover amount env(coverWithdraw(alice, keylet.key, vault.asset(7))); - if (BEAST_EXPECT(broker = env.le(keylet))) - { - verifyCoverAmount(3); - } + verifyCoverAmount(3); // Add some more cover env(coverDeposit(alice, keylet.key, vault.asset(5))); - if (BEAST_EXPECT(broker = env.le(keylet))) - { - verifyCoverAmount(8); - } + verifyCoverAmount(8); // Withdraw some more. Send it to Evan. Very generous, considering // how much trouble he's been. env(coverWithdraw(alice, keylet.key, vault.asset(2)), destination(evan)); - if (BEAST_EXPECT(broker = env.le(keylet))) - { - verifyCoverAmount(6); - } + verifyCoverAmount(6); env.close(); + if (!vault.asset.raw().native()) + { + // Issuer claws back some of the cover + env(coverClawback(issuer), + loanBrokerID(keylet.key), + amount(vault.asset(2))); + verifyCoverAmount(4); + + // Deposit some back + env(coverDeposit(alice, keylet.key, vault.asset(5))); + verifyCoverAmount(9); + + // Issuer claws it all back in various different ways + for (auto const& jt : { + env.jt( + coverClawback(issuer), loanBrokerID(keylet.key)), + env.jt( + coverClawback(issuer), + loanBrokerID(keylet.key), + amount(vault.asset(0))), + env.jt( + coverClawback(issuer), + loanBrokerID(keylet.key), + amount(vault.asset(6))), + // amount will be truncated to what's available + env.jt( + coverClawback(issuer), + loanBrokerID(keylet.key), + amount(vault.asset(100))), + }) + { + // Issuer claws it all back + env(jt); + verifyCoverAmount(0); + + // Deposit some back + env(coverDeposit(alice, keylet.key, vault.asset(6))); + verifyCoverAmount(6); + } + } + // no-op env(set(alice, vault.vaultID), loanBrokerID(keylet.key)); @@ -335,6 +479,7 @@ class LoanBroker_test : public beast::unit_test::suite //} env(del(alice, keylet.key)); + env(del(alice, badKeylet.key)); env.close(); { broker = env.le(keylet); @@ -404,14 +549,27 @@ class LoanBroker_test : public beast::unit_test::suite auto [tx, keylet] = vault.create({.owner = alice, .asset = asset}); env(tx); env.close(); - BEAST_EXPECT(env.le(keylet)); - - vaults.emplace_back(asset, keylet.key); + if (auto const le = env.le(keylet); BEAST_EXPECT(env.le(keylet))) + { + vaults.emplace_back(asset, keylet.key, le->at(sfAccount)); + } env(vault.deposit( {.depositor = alice, .id = keylet.key, .amount = asset(50)})); env.close(); } + VaultInfo const badVault = [&]() -> VaultInfo { + auto [tx, keylet] = + vault.create({.owner = alice, .asset = iouAsset}); + env(tx); + env.close(); + if (auto const le = env.le(keylet); BEAST_EXPECT(env.le(keylet))) + { + return {iouAsset, keylet.key, le->at(sfAccount)}; + } + // This should never happen + return {iouAsset, keylet.key, evan.id()}; + }(); auto const aliceOriginalCount = env.ownerCount(alice); @@ -478,10 +636,12 @@ class LoanBroker_test : public beast::unit_test::suite lifecycle( "default fields", env, + issuer, alice, evan, bystander, vault, + badVault, // No modifications {}, [&](SLE::const_ref broker) { @@ -497,7 +657,7 @@ class LoanBroker_test : public beast::unit_test::suite BEAST_EXPECT(broker->at(sfCoverRateLiquidation) == 0); BEAST_EXPECT( - env.ownerCount(alice) == aliceOriginalCount + 2); + env.ownerCount(alice) == aliceOriginalCount + 4); }, [&](SLE::const_ref broker) { // Modifications @@ -563,10 +723,12 @@ class LoanBroker_test : public beast::unit_test::suite lifecycle( "non-default fields", env, + issuer, alice, evan, bystander, vault, + badVault, [&](jtx::JTx const& jv) { testData = "spam spam spam spam"; // Finally, create another Loan Broker with none of the @@ -610,6 +772,9 @@ public: { testDisabled(); testLifecycle(); + + // TODO: Write clawback failure tests with an issuer / MPT that doesn't + // have the right flags set. } }; diff --git a/src/test/jtx/TestHelpers.h b/src/test/jtx/TestHelpers.h index 4240a1e387..b7ce9287cb 100644 --- a/src/test/jtx/TestHelpers.h +++ b/src/test/jtx/TestHelpers.h @@ -177,6 +177,29 @@ public: } }; +struct stAmountField : public JTxField +{ + using SF = SF_AMOUNT; + using SV = STAmount; + using OV = Json::Value; + using base = JTxField; + +protected: + using base::value_; + +public: + explicit stAmountField(SF const& sfield, SV const& value) + : JTxField(sfield, value) + { + } + + OV + value() const override + { + return value_.getJson(JsonOptions::none); + } +}; + struct blobField : public JTxField { using SF = SF_VL; @@ -292,6 +315,8 @@ using simpleField = JTxFieldWrapper>; */ auto const data = JTxFieldWrapper(sfData); +auto const amount = JTxFieldWrapper(sfAmount); + // TODO We only need this long "requires" clause as polyfill, for C++20 // implementations which are missing header. Replace with // `std::ranges::range`, and accordingly use std::ranges::begin/end @@ -739,6 +764,10 @@ coverWithdraw( STAmount const& amount, std::uint32_t flags = 0); +// Must specify at least one of loanBrokerID or amount. +Json::Value +coverClawback(AccountID const& account, std::uint32_t flags = 0); + auto const loanBrokerID = JTxFieldWrapper(sfLoanBrokerID); auto const managementFeeRate = diff --git a/src/test/jtx/impl/TestHelpers.cpp b/src/test/jtx/impl/TestHelpers.cpp index add8cde25d..3b61604fc6 100644 --- a/src/test/jtx/impl/TestHelpers.cpp +++ b/src/test/jtx/impl/TestHelpers.cpp @@ -426,6 +426,16 @@ coverWithdraw( return jv; } +Json::Value +coverClawback(AccountID const& account, std::uint32_t flags) +{ + Json::Value jv; + jv[sfTransactionType] = jss::LoanBrokerCoverClawback; + jv[sfAccount] = to_string(account); + jv[sfFlags] = flags; + return jv; +} + } // namespace loanBroker /* Loan */ diff --git a/src/test/jtx/impl/mpt.cpp b/src/test/jtx/impl/mpt.cpp index d33432d316..4df538d0c2 100644 --- a/src/test/jtx/impl/mpt.cpp +++ b/src/test/jtx/impl/mpt.cpp @@ -413,7 +413,7 @@ MPTTester::getFlags(std::optional const& holder) const } MPT -MPTTester::operator[](std::string const& name) +MPTTester::operator[](std::string const& name) const { return MPT(name, issuanceID()); } diff --git a/src/test/jtx/mpt.h b/src/test/jtx/mpt.h index 64eaa452f5..3204bfa98b 100644 --- a/src/test/jtx/mpt.h +++ b/src/test/jtx/mpt.h @@ -214,7 +214,7 @@ public: getBalance(Account const& account) const; MPT - operator[](std::string const& name); + operator[](std::string const& name) const; private: using SLEP = std::shared_ptr; diff --git a/src/xrpld/app/tx/detail/LoanBrokerCoverClawback.cpp b/src/xrpld/app/tx/detail/LoanBrokerCoverClawback.cpp new file mode 100644 index 0000000000..0f2914887d --- /dev/null +++ b/src/xrpld/app/tx/detail/LoanBrokerCoverClawback.cpp @@ -0,0 +1,343 @@ +//------------------------------------------------------------------------------ +/* + This file is part of rippled: https://github.com/ripple/rippled + Copyright (c) 2025 Ripple Labs Inc. + + Permission to use, copy, modify, and/or distribute this software for any + purpose with or without fee is hereby granted, provided that the above + copyright notice and this permission notice appear in all copies. + + THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES + WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF + MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR + ANY SPECIAL , DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES + WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN + ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF + OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. +*/ +//============================================================================== + +#include +// +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace ripple { + +bool +LoanBrokerCoverClawback::isEnabled(PreflightContext const& ctx) +{ + return lendingProtocolEnabled(ctx); +} + +NotTEC +LoanBrokerCoverClawback::preflight(PreflightContext const& ctx) +{ + auto const brokerID = ctx.tx[~sfLoanBrokerID]; + auto const amount = ctx.tx[~sfAmount]; + + if (!brokerID && !amount) + return temINVALID; + + if (brokerID && *brokerID == beast::zero) + return temINVALID; + + if (amount) + { + if (amount->native()) + return temBAD_AMOUNT; + + // Zero is OK, and indicates "take it all" (down to the minimum cover) + if (*amount < beast::zero) + return temBAD_AMOUNT; + + // This should be redundant + if (!isLegalNet(*amount)) + return temBAD_AMOUNT; // LCOV_EXCL_LINE + + if (!brokerID) + { + if (amount->holds()) + return temINVALID; + + auto const account = ctx.tx[sfAccount]; + auto const holder = amount->getIssuer(); + if (holder == account || holder == beast::zero) + return temINVALID; + } + } + + return tesSUCCESS; +} + +Expected +determineBrokerID(ReadView const& view, STTx const& tx) +{ + if (auto const brokerID = tx[~sfLoanBrokerID]) + return *brokerID; + + auto const dstAmount = tx[~sfAmount]; + if (!dstAmount || !dstAmount->holds()) + return Unexpected{tecINTERNAL}; + + auto const holder = dstAmount->getIssuer(); + auto const sle = view.read(keylet::account(holder)); + if (!sle) + return Unexpected{tecNO_ENTRY}; + + if (auto const brokerID = sle->at(~sfLoanBrokerID)) + return *brokerID; + + // Or tecWRONG_ASSET? + return Unexpected{tecOBJECT_NOT_FOUND}; +} + +Expected +determineAsset( + ReadView const& view, + AccountID const& account, + AccountID const& brokerPseudoAccountID, + STAmount const& amount) +{ + if (amount.holds()) + return amount.asset(); + + // An IOU has an issue, which could be either end of the trust line. + // This check only applies to IOUs + auto const holder = amount.getIssuer(); + + if (holder == account) + { + return amount.asset(); + } + else if (holder == brokerPseudoAccountID) + { + // We want the asset to match the vault asset, so use the account as the + // issuer + return Issue{amount.getCurrency(), account}; + } + else + return Unexpected(tecWRONG_ASSET); +} + +Expected +determineClawAmount( + SLE const& sleBroker, + Asset const& vaultAsset, + std::optional const& amount) +{ + auto const maxClawAmount = sleBroker[sfCoverAvailable] - + tenthBipsOfValue(sleBroker[sfDebtTotal], + TenthBips32(sleBroker[sfCoverRateMinimum])); + if (maxClawAmount <= beast::zero) + return Unexpected(tecINSUFFICIENT_FUNDS); + + // Use the vaultAsset here, because it will be the right type in all + // circumstances. The amount may be an IOU indicating the pseudo-account's + // asset, which is correct, but not what is needed here. + if (!amount || *amount == beast::zero) + return STAmount{vaultAsset, maxClawAmount}; + Number const magnitude{*amount}; + if (magnitude > maxClawAmount) + return STAmount{vaultAsset, maxClawAmount}; + return STAmount{vaultAsset, magnitude}; +} + +template +static TER +preclaimHelper( + PreclaimContext const& ctx, + SLE const& sleIssuer, + STAmount const& clawAmount); + +template <> +TER +preclaimHelper( + PreclaimContext const& ctx, + SLE const& sleIssuer, + STAmount const& clawAmount) +{ + // If AllowTrustLineClawback is not set or NoFreeze is set, return no + // permission + if (!(sleIssuer.isFlag(lsfAllowTrustLineClawback)) || + (sleIssuer.isFlag(lsfNoFreeze))) + return tecNO_PERMISSION; + + return tesSUCCESS; +} + +template <> +TER +preclaimHelper( + PreclaimContext const& ctx, + SLE const& sleIssuer, + STAmount const& clawAmount) +{ + auto const issuanceKey = + keylet::mptIssuance(clawAmount.get().getMptID()); + auto const sleIssuance = ctx.view.read(issuanceKey); + if (!sleIssuance) + return tecOBJECT_NOT_FOUND; + + if (!sleIssuance->isFlag(lsfMPTCanClawback) || + !sleIssuance->isFlag(lsfMPTCanLock)) + return tecNO_PERMISSION; + + // With all the checking already done, this should be impossible + if (sleIssuance->at(sfIssuer) != sleIssuer[sfAccount]) + return tecINTERNAL; // LCOV_EXCL_LINE + + return tesSUCCESS; +} + +TER +LoanBrokerCoverClawback::preclaim(PreclaimContext const& ctx) +{ + auto const& tx = ctx.tx; + + auto const account = tx[sfAccount]; + auto const findBrokerID = determineBrokerID(ctx.view, tx); + if (!findBrokerID) + return findBrokerID.error(); + auto const brokerID = *findBrokerID; + auto const amount = tx[~sfAmount]; + + auto const sleBroker = ctx.view.read(keylet::loanbroker(brokerID)); + if (!sleBroker) + { + JLOG(ctx.j.warn()) << "LoanBroker does not exist."; + return tecNO_ENTRY; + } + + auto const brokerPseudoAccountID = sleBroker->at(sfAccount); + + auto const vault = ctx.view.read(keylet::vault(sleBroker->at(sfVaultID))); + if (!vault) + return tecINTERNAL; + + auto const vaultAsset = vault->at(sfAsset); + + if (vaultAsset.native()) + { + JLOG(ctx.j.warn()) << "Cannot clawback native asset."; + return tecNO_PERMISSION; + } + + // Only the issuer of the vault asset can claw it back from the broker's + // cover funds. + if (vaultAsset.getIssuer() != account) + { + JLOG(ctx.j.warn()) << "Account is not the issuer of the vault asset."; + return tecNO_PERMISSION; + } + + if (amount) + { + auto const findAsset = + determineAsset(ctx.view, account, brokerPseudoAccountID, *amount); + if (!findAsset) + return findAsset.error(); + auto const txAsset = *findAsset; + if (txAsset != vaultAsset) + { + JLOG(ctx.j.warn()) << "Account is the correct issuer, but trying " + "to clawback the wrong asset from LoanBroker"; + return tecWRONG_ASSET; + } + } + + auto const findClawAmount = + determineClawAmount(*sleBroker, vaultAsset, amount); + if (!findClawAmount) + { + JLOG(ctx.j.warn()) << "LoanBroker cover is already at minimum."; + return findClawAmount.error(); + } + STAmount const clawAmount = *findClawAmount; + + // Explicitly check the balance of the trust line / MPT to make sure the + // balance is actually there. It should always match `stCoverAvailable`, so + // if there isn't, this is an internal error. + if (accountHolds( + ctx.view, + brokerPseudoAccountID, + vaultAsset, + fhIGNORE_FREEZE, + ahIGNORE_AUTH, + ctx.j) < clawAmount) + return tecINTERNAL; // tecINSUFFICIENT_FUNDS; LCOV_EXCL_LINE + + // Check if the vault asset issuer has the correct flags + auto const sleIssuer = + ctx.view.read(keylet::account(vaultAsset.getIssuer())); + return std::visit( + [&](T const&) { + return preclaimHelper(ctx, *sleIssuer, clawAmount); + }, + vaultAsset.value()); +} + +TER +LoanBrokerCoverClawback::doApply() +{ + auto const& tx = ctx_.tx; + auto const account = tx[sfAccount]; + auto const findBrokerID = determineBrokerID(view(), tx); + if (!findBrokerID) + return tecINTERNAL; // LCOV_EXCL_LINE + auto const brokerID = *findBrokerID; + auto const amount = tx[~sfAmount]; + + auto sleBroker = view().peek(keylet::loanbroker(brokerID)); + if (!sleBroker) + return tecINTERNAL; // LCOV_EXCL_LINE + + auto const brokerPseudoID = *sleBroker->at(sfAccount); + + auto const vault = view().read(keylet::vault(sleBroker->at(sfVaultID))); + if (!vault) + return tecINTERNAL; // LCOV_EXCL_LINE + + auto const vaultAsset = vault->at(sfAsset); + + auto const findClawAmount = + determineClawAmount(*sleBroker, vaultAsset, amount); + if (!findClawAmount) + return tecINTERNAL; // LCOV_EXCL_LINE + STAmount const clawAmount = *findClawAmount; + // Just for paranoia's sake + if (clawAmount.native()) + return tecINTERNAL; // LCOV_EXCL_LINE + + // Decrease the LoanBroker's CoverAvailable by Amount + sleBroker->at(sfCoverAvailable) -= clawAmount; + view().update(sleBroker); + + // Transfer assets from pseudo-account to depositor. + return accountSend( + view(), brokerPseudoID, account, clawAmount, j_, WaiveTransferFee::Yes); +} + +//------------------------------------------------------------------------------ + +} // namespace ripple diff --git a/src/xrpld/app/tx/detail/LoanBrokerCoverClawback.h b/src/xrpld/app/tx/detail/LoanBrokerCoverClawback.h new file mode 100644 index 0000000000..68133b5364 --- /dev/null +++ b/src/xrpld/app/tx/detail/LoanBrokerCoverClawback.h @@ -0,0 +1,53 @@ +//------------------------------------------------------------------------------ +/* + This file is part of rippled: https://github.com/ripple/rippled + Copyright (c) 2025 Ripple Labs Inc. + + Permission to use, copy, modify, and/or distribute this software for any + purpose with or without fee is hereby granted, provided that the above + copyright notice and this permission notice appear in all copies. + + THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES + WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF + MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR + ANY SPECIAL , DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES + WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN + ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF + OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. +*/ +//============================================================================== + +#ifndef RIPPLE_TX_LOANBROKERCOVERCLAWBACK_H_INCLUDED +#define RIPPLE_TX_LOANBROKERCOVERCLAWBACK_H_INCLUDED + +#include + +namespace ripple { + +class LoanBrokerCoverClawback : public Transactor +{ +public: + static constexpr ConsequencesFactoryType ConsequencesFactory{Normal}; + + explicit LoanBrokerCoverClawback(ApplyContext& ctx) : Transactor(ctx) + { + } + + static bool + isEnabled(PreflightContext const& ctx); + + static NotTEC + preflight(PreflightContext const& ctx); + + static TER + preclaim(PreclaimContext const& ctx); + + TER + doApply() override; +}; + +//------------------------------------------------------------------------------ + +} // namespace ripple + +#endif