From 9abea136494213dd2c60f85051be0be54480cfc4 Mon Sep 17 00:00:00 2001 From: Denis Angell Date: Wed, 9 Jul 2025 04:48:46 +0200 Subject: [PATCH] Feature Clawback (#534) --- Builds/CMake/RippledCore.cmake | 2 + src/ripple/app/hook/impl/applyHook.cpp | 6 + src/ripple/app/tx/impl/Clawback.cpp | 140 +++ src/ripple/app/tx/impl/Clawback.h | 48 ++ src/ripple/app/tx/impl/InvariantCheck.cpp | 58 ++ src/ripple/app/tx/impl/InvariantCheck.h | 31 +- src/ripple/app/tx/impl/PayChan.cpp | 1 + src/ripple/app/tx/impl/SetAccount.cpp | 39 + src/ripple/app/tx/impl/applySteps.cpp | 11 + src/ripple/ledger/View.h | 6 + src/ripple/protocol/Feature.h | 3 +- src/ripple/protocol/LedgerFormats.h | 2 + src/ripple/protocol/TxFlags.h | 4 + src/ripple/protocol/TxFormats.h | 3 + src/ripple/protocol/impl/Feature.cpp | 1 + src/ripple/protocol/impl/TxFormats.cpp | 8 + src/ripple/protocol/jss.h | 1 + src/ripple/rpc/handlers/AccountInfo.cpp | 9 + src/test/app/Clawback_test.cpp | 985 ++++++++++++++++++++++ src/test/app/Escrow_test.cpp | 50 ++ src/test/app/PayChan_test.cpp | 49 ++ src/test/app/SetHookTSH_test.cpp | 85 ++ src/test/jtx/flags.h | 3 + src/test/jtx/impl/trust.cpp | 11 + src/test/jtx/trust.h | 3 + src/test/rpc/AccountInfo_test.cpp | 47 +- src/test/rpc/AccountSet_test.cpp | 7 + 27 files changed, 1603 insertions(+), 10 deletions(-) create mode 100644 src/ripple/app/tx/impl/Clawback.cpp create mode 100644 src/ripple/app/tx/impl/Clawback.h create mode 100644 src/test/app/Clawback_test.cpp diff --git a/Builds/CMake/RippledCore.cmake b/Builds/CMake/RippledCore.cmake index 04ee720b8..232787a57 100644 --- a/Builds/CMake/RippledCore.cmake +++ b/Builds/CMake/RippledCore.cmake @@ -440,6 +440,7 @@ target_sources (rippled PRIVATE src/ripple/app/tx/impl/CashCheck.cpp src/ripple/app/tx/impl/Change.cpp src/ripple/app/tx/impl/ClaimReward.cpp + src/ripple/app/tx/impl/Clawback.cpp src/ripple/app/tx/impl/CreateCheck.cpp src/ripple/app/tx/impl/CreateOffer.cpp src/ripple/app/tx/impl/CreateTicket.cpp @@ -721,6 +722,7 @@ if (tests) src/test/app/BaseFee_test.cpp src/test/app/Check_test.cpp src/test/app/ClaimReward_test.cpp + src/test/app/Clawback_test.cpp src/test/app/CrossingLimits_test.cpp src/test/app/DeliverMin_test.cpp src/test/app/DepositAuth_test.cpp diff --git a/src/ripple/app/hook/impl/applyHook.cpp b/src/ripple/app/hook/impl/applyHook.cpp index a0c5ff434..84754ec50 100644 --- a/src/ripple/app/hook/impl/applyHook.cpp +++ b/src/ripple/app/hook/impl/applyHook.cpp @@ -496,6 +496,12 @@ getTransactionalStakeHolders(STTx const& tx, ReadView const& rv) break; } + case ttCLAWBACK: { + auto const amount = tx.getFieldAmount(sfAmount); + ADD_TSH(amount.getIssuer(), tshWEAK); + break; + } + default: return {}; } diff --git a/src/ripple/app/tx/impl/Clawback.cpp b/src/ripple/app/tx/impl/Clawback.cpp new file mode 100644 index 000000000..ffc54d8c7 --- /dev/null +++ b/src/ripple/app/tx/impl/Clawback.cpp @@ -0,0 +1,140 @@ +//------------------------------------------------------------------------------ +/* + This file is part of rippled: https://github.com/ripple/rippled + Copyright (c) 2023 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 + +namespace ripple { + +NotTEC +Clawback::preflight(PreflightContext const& ctx) +{ + if (!ctx.rules.enabled(featureClawback)) + return temDISABLED; + + if (auto const ret = preflight1(ctx); !isTesSuccess(ret)) + return ret; + + if (ctx.tx.getFlags() & tfClawbackMask) + return temINVALID_FLAG; + + AccountID const issuer = ctx.tx[sfAccount]; + STAmount const clawAmount = ctx.tx[sfAmount]; + + // The issuer field is used for the token holder instead + AccountID const& holder = clawAmount.getIssuer(); + + if (issuer == holder || isXRP(clawAmount) || clawAmount <= beast::zero) + return temBAD_AMOUNT; + + return preflight2(ctx); +} + +TER +Clawback::preclaim(PreclaimContext const& ctx) +{ + AccountID const issuer = ctx.tx[sfAccount]; + STAmount const clawAmount = ctx.tx[sfAmount]; + AccountID const& holder = clawAmount.getIssuer(); + + auto const sleIssuer = ctx.view.read(keylet::account(issuer)); + auto const sleHolder = ctx.view.read(keylet::account(holder)); + if (!sleIssuer || !sleHolder) + return terNO_ACCOUNT; + + std::uint32_t const issuerFlagsIn = sleIssuer->getFieldU32(sfFlags); + + // If AllowTrustLineClawback is not set or NoFreeze is set, return no + // permission + if (!(issuerFlagsIn & lsfAllowTrustLineClawback) || + (issuerFlagsIn & lsfNoFreeze)) + return tecNO_PERMISSION; + + auto const sleRippleState = + ctx.view.read(keylet::line(holder, issuer, clawAmount.getCurrency())); + if (!sleRippleState) + return tecNO_LINE; + + STAmount const balance = (*sleRippleState)[sfBalance]; + + // If balance is positive, issuer must have higher address than holder + if (balance > beast::zero && issuer < holder) + return tecNO_PERMISSION; + + // If balance is negative, issuer must have lower address than holder + if (balance < beast::zero && issuer > holder) + return tecNO_PERMISSION; + + // At this point, we know that issuer and holder accounts + // are correct and a trustline exists between them. + // + // Must now explicitly check the balance to make sure + // available balance is non-zero. + // + // We can't directly check the balance of trustline because + // the available balance of a trustline is prone to new changes (eg. + // XLS-34). So we must use `accountHolds`. + if (accountHolds( + ctx.view, + holder, + clawAmount.getCurrency(), + issuer, + fhIGNORE_FREEZE, + ctx.j) <= beast::zero) + return tecINSUFFICIENT_FUNDS; + + return tesSUCCESS; +} + +TER +Clawback::doApply() +{ + AccountID const& issuer = account_; + STAmount clawAmount = ctx_.tx[sfAmount]; + AccountID const holder = clawAmount.getIssuer(); // cannot be reference + + // Replace the `issuer` field with issuer's account + clawAmount.setIssuer(issuer); + if (holder == issuer) + return tecINTERNAL; + + // Get the spendable balance. Must use `accountHolds`. + STAmount const spendableAmount = accountHolds( + view(), + holder, + clawAmount.getCurrency(), + clawAmount.getIssuer(), + fhIGNORE_FREEZE, + j_); + + return rippleCredit( + view(), + holder, + issuer, + std::min(spendableAmount, clawAmount), + true, + j_); +} + +} // namespace ripple \ No newline at end of file diff --git a/src/ripple/app/tx/impl/Clawback.h b/src/ripple/app/tx/impl/Clawback.h new file mode 100644 index 000000000..c5f072c84 --- /dev/null +++ b/src/ripple/app/tx/impl/Clawback.h @@ -0,0 +1,48 @@ +//------------------------------------------------------------------------------ +/* + This file is part of rippled: https://github.com/ripple/rippled + Copyright (c) 2023 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_CLAWBACK_H_INCLUDED +#define RIPPLE_TX_CLAWBACK_H_INCLUDED + +#include + +namespace ripple { + +class Clawback : public Transactor +{ +public: + static constexpr ConsequencesFactoryType ConsequencesFactory{Normal}; + + explicit Clawback(ApplyContext& ctx) : Transactor(ctx) + { + } + + static NotTEC + preflight(PreflightContext const& ctx); + + static TER + preclaim(PreclaimContext const& ctx); + + TER + doApply() override; +}; + +} // namespace ripple + +#endif diff --git a/src/ripple/app/tx/impl/InvariantCheck.cpp b/src/ripple/app/tx/impl/InvariantCheck.cpp index 2a8a92a8b..231828a82 100644 --- a/src/ripple/app/tx/impl/InvariantCheck.cpp +++ b/src/ripple/app/tx/impl/InvariantCheck.cpp @@ -1145,4 +1145,62 @@ NFTokenCountTracking::finalize( return true; } +//------------------------------------------------------------------------------ + +void +ValidClawback::visitEntry( + bool, + std::shared_ptr const& before, + std::shared_ptr const&) +{ + if (before && before->getType() == ltRIPPLE_STATE) + trustlinesChanged++; +} + +bool +ValidClawback::finalize( + STTx const& tx, + TER const result, + XRPAmount const, + ReadView const& view, + beast::Journal const& j) +{ + if (tx.getTxnType() != ttCLAWBACK) + return true; + + if (result == tesSUCCESS) + { + if (trustlinesChanged > 1) + { + JLOG(j.fatal()) + << "Invariant failed: more than one trustline changed."; + return false; + } + + AccountID const issuer = tx.getAccountID(sfAccount); + STAmount const amount = tx.getFieldAmount(sfAmount); + AccountID const& holder = amount.getIssuer(); + STAmount const holderBalance = accountHolds( + view, holder, amount.getCurrency(), issuer, fhIGNORE_FREEZE, j); + + if (holderBalance.signum() < 0) + { + JLOG(j.fatal()) + << "Invariant failed: trustline balance is negative"; + return false; + } + } + else + { + if (trustlinesChanged != 0) + { + JLOG(j.fatal()) << "Invariant failed: some trustlines were changed " + "despite failure of the transaction."; + return false; + } + } + + return true; +} + } // namespace ripple diff --git a/src/ripple/app/tx/impl/InvariantCheck.h b/src/ripple/app/tx/impl/InvariantCheck.h index 92700181d..de896b087 100644 --- a/src/ripple/app/tx/impl/InvariantCheck.h +++ b/src/ripple/app/tx/impl/InvariantCheck.h @@ -498,6 +498,34 @@ public: beast::Journal const&); }; +/** + * @brief Invariant: Token holder's trustline balance cannot be negative after + * Clawback. + * + * We iterate all the trust lines affected by this transaction and ensure + * that no more than one trustline is modified, and also holder's balance is + * non-negative. + */ +class ValidClawback +{ + std::uint32_t trustlinesChanged = 0; + +public: + void + visitEntry( + bool, + std::shared_ptr const&, + std::shared_ptr const&); + + bool + finalize( + STTx const&, + TER const, + XRPAmount const, + ReadView const&, + beast::Journal const&); +}; + // additional invariant checks can be declared above and then added to this // tuple using InvariantChecks = std::tuple< @@ -513,7 +541,8 @@ using InvariantChecks = std::tuple< NoZeroEscrow, ValidNewAccountRoot, ValidNFTokenPage, - NFTokenCountTracking>; + NFTokenCountTracking, + ValidClawback>; /** * @brief get a tuple of all invariant checks diff --git a/src/ripple/app/tx/impl/PayChan.cpp b/src/ripple/app/tx/impl/PayChan.cpp index 102eafed3..0ff6e5f9e 100644 --- a/src/ripple/app/tx/impl/PayChan.cpp +++ b/src/ripple/app/tx/impl/PayChan.cpp @@ -274,6 +274,7 @@ PayChanCreate::preclaim(PreclaimContext const& ctx) { TER const result = trustTransferAllowed( ctx.view, {account, dst}, amount.issue(), ctx.j, lhLOCKING); + JLOG(ctx.j.trace()) << "PayChanCreate::preclaim trustTransferAllowed result=" << result; diff --git a/src/ripple/app/tx/impl/SetAccount.cpp b/src/ripple/app/tx/impl/SetAccount.cpp index 085e36422..8cbee5fd7 100644 --- a/src/ripple/app/tx/impl/SetAccount.cpp +++ b/src/ripple/app/tx/impl/SetAccount.cpp @@ -218,6 +218,37 @@ SetAccount::preclaim(PreclaimContext const& ctx) } } + // + // Clawback + // + if (ctx.view.rules().enabled(featureClawback)) + { + if (uSetFlag == asfAllowTrustLineClawback) + { + if (uFlagsIn & lsfNoFreeze) + { + JLOG(ctx.j.trace()) << "Can't set Clawback if NoFreeze is set"; + return tecNO_PERMISSION; + } + + if (!dirIsEmpty(ctx.view, keylet::ownerDir(id))) + { + JLOG(ctx.j.trace()) << "Owner directory not empty."; + return tecOWNERS; + } + } + else if (uSetFlag == asfNoFreeze) + { + // Cannot set NoFreeze if clawback is enabled + if (uFlagsIn & lsfAllowTrustLineClawback) + { + JLOG(ctx.j.trace()) + << "Can't set NoFreeze if clawback is enabled"; + return tecNO_PERMISSION; + } + } + } + return tesSUCCESS; } @@ -587,6 +618,14 @@ SetAccount::doApply() } } + // Set flag for clawback + if (ctx_.view().rules().enabled(featureClawback) && + uSetFlag == asfAllowTrustLineClawback) + { + JLOG(j_.trace()) << "set allow clawback"; + uFlagsOut |= lsfAllowTrustLineClawback; + } + if (uFlagsIn != uFlagsOut) sle->setFieldU32(sfFlags, uFlagsOut); diff --git a/src/ripple/app/tx/impl/applySteps.cpp b/src/ripple/app/tx/impl/applySteps.cpp index 2ae333fdd..aaf103153 100644 --- a/src/ripple/app/tx/impl/applySteps.cpp +++ b/src/ripple/app/tx/impl/applySteps.cpp @@ -24,6 +24,7 @@ #include #include #include +#include #include #include #include @@ -108,6 +109,8 @@ invoke_preflight(PreflightContext const& ctx) return invoke_preflight_helper(ctx); case ttACCOUNT_SET: return invoke_preflight_helper(ctx); + case ttCLAWBACK: + return invoke_preflight_helper(ctx); case ttCHECK_CANCEL: return invoke_preflight_helper(ctx); case ttCHECK_CASH: @@ -231,6 +234,8 @@ invoke_preclaim(PreclaimContext const& ctx) return invoke_preclaim(ctx); case ttACCOUNT_SET: return invoke_preclaim(ctx); + case ttCLAWBACK: + return invoke_preclaim(ctx); case ttCHECK_CANCEL: return invoke_preclaim(ctx); case ttCHECK_CASH: @@ -316,6 +321,8 @@ invoke_calculateBaseFee(ReadView const& view, STTx const& tx) return DeleteAccount::calculateBaseFee(view, tx); case ttACCOUNT_SET: return SetAccount::calculateBaseFee(view, tx); + case ttCLAWBACK: + return Clawback::calculateBaseFee(view, tx); case ttCHECK_CANCEL: return CancelCheck::calculateBaseFee(view, tx); case ttCHECK_CASH: @@ -443,6 +450,10 @@ invoke_apply(ApplyContext& ctx) SetAccount p(ctx); return p(); } + case ttCLAWBACK: { + Clawback p(ctx); + return p(); + } case ttCHECK_CANCEL: { CancelCheck p(ctx); return p(); diff --git a/src/ripple/ledger/View.h b/src/ripple/ledger/View.h index a23d5ffc4..27659ad8d 100644 --- a/src/ripple/ledger/View.h +++ b/src/ripple/ledger/View.h @@ -670,6 +670,12 @@ trustTransferAllowed( uint32_t issuerFlags = sleIssuerAcc->getFieldU32(sfFlags); + // reject the creation of a locked balance (lhLOCKING) if the + // issuer has enabled clawback + if (lockHandling == lhLOCKING && view.rules().enabled(featureClawback) && + issuerFlags & lsfAllowTrustLineClawback) + return tecNO_PERMISSION; + bool requireAuth = issuerFlags & lsfRequireAuth; for (AccountID const& p : parties) diff --git a/src/ripple/protocol/Feature.h b/src/ripple/protocol/Feature.h index aac88b0cf..ad6d3681e 100644 --- a/src/ripple/protocol/Feature.h +++ b/src/ripple/protocol/Feature.h @@ -74,7 +74,7 @@ namespace detail { // Feature.cpp. Because it's only used to reserve storage, and determine how // large to make the FeatureBitset, it MAY be larger. It MUST NOT be less than // the actual number of amendments. A LogicError on startup will verify this. -static constexpr std::size_t numFeatures = 83; +static constexpr std::size_t numFeatures = 84; /** Amendments that this server supports and the default voting behavior. Whether they are enabled depends on the Rules defined in the validated @@ -370,6 +370,7 @@ extern uint256 const fix20250131; extern uint256 const featureHookCanEmit; extern uint256 const fixRewardClaimFlags; extern uint256 const fixProvisionalDoubleThreading; +extern uint256 const featureClawback; extern uint256 const featureDeepFreeze; } // namespace ripple diff --git a/src/ripple/protocol/LedgerFormats.h b/src/ripple/protocol/LedgerFormats.h index b26c07d59..0ecd94207 100644 --- a/src/ripple/protocol/LedgerFormats.h +++ b/src/ripple/protocol/LedgerFormats.h @@ -287,6 +287,8 @@ enum LedgerSpecificFlags { 0x40000000, // True, has minted tokens in the past lsfDisallowIncomingRemit = // True, no remits allowed to this account 0x80000000, + lsfAllowTrustLineClawback = + 0x00001000, // True, enable clawback // ltOFFER lsfPassive = 0x00010000, diff --git a/src/ripple/protocol/TxFlags.h b/src/ripple/protocol/TxFlags.h index 09ff9f1bc..a97e8b63c 100644 --- a/src/ripple/protocol/TxFlags.h +++ b/src/ripple/protocol/TxFlags.h @@ -92,6 +92,7 @@ enum AccountFlags : uint32_t { asfDisallowIncomingPayChan = 14, asfDisallowIncomingTrustline = 15, asfDisallowIncomingRemit = 16, + asfAllowTrustLineClawback = 17, }; // OfferCreate flags: @@ -196,6 +197,9 @@ constexpr std::uint32_t const tfClaimRewardMask = ~(tfUniversal | tfOptOut); // Remarks flags: constexpr std::uint32_t const tfImmutable = 1; +// Clawback flags: +constexpr std::uint32_t const tfClawbackMask = ~tfUniversal; + // clang-format on } // namespace ripple diff --git a/src/ripple/protocol/TxFormats.h b/src/ripple/protocol/TxFormats.h index 7fdd647de..e7fcc0d60 100644 --- a/src/ripple/protocol/TxFormats.h +++ b/src/ripple/protocol/TxFormats.h @@ -139,6 +139,9 @@ enum TxType : std::uint16_t /** This transaction accepts an existing offer to buy or sell an existing NFT. */ ttNFTOKEN_ACCEPT_OFFER = 29, + /** This transaction claws back issued tokens. */ + ttCLAWBACK = 30, + /** This transaction mints/burns/buys/sells a URI TOKEN */ ttURITOKEN_MINT = 45, ttURITOKEN_BURN = 46, diff --git a/src/ripple/protocol/impl/Feature.cpp b/src/ripple/protocol/impl/Feature.cpp index 8e4d9f449..22bfb217a 100644 --- a/src/ripple/protocol/impl/Feature.cpp +++ b/src/ripple/protocol/impl/Feature.cpp @@ -475,6 +475,7 @@ REGISTER_FIX (fixXahauV3, Supported::yes, VoteBehavior::De REGISTER_FIX (fix20250131, Supported::yes, VoteBehavior::DefaultYes); REGISTER_FEATURE(HookCanEmit, Supported::yes, VoteBehavior::DefaultNo); REGISTER_FIX (fixRewardClaimFlags, Supported::yes, VoteBehavior::DefaultYes); +REGISTER_FEATURE(Clawback, Supported::yes, VoteBehavior::DefaultNo); REGISTER_FIX (fixProvisionalDoubleThreading, Supported::yes, VoteBehavior::DefaultYes); REGISTER_FEATURE(DeepFreeze, Supported::yes, VoteBehavior::DefaultNo); diff --git a/src/ripple/protocol/impl/TxFormats.cpp b/src/ripple/protocol/impl/TxFormats.cpp index fb7d6e581..73bae26c3 100644 --- a/src/ripple/protocol/impl/TxFormats.cpp +++ b/src/ripple/protocol/impl/TxFormats.cpp @@ -464,6 +464,14 @@ TxFormats::TxFormats() {sfRemarks, soeREQUIRED}, }, commonFields); + + add(jss::Clawback, + ttCLAWBACK, + { + {sfAmount, soeREQUIRED}, + {sfTicketSequence, soeOPTIONAL}, + }, + commonFields); } TxFormats const& diff --git a/src/ripple/protocol/jss.h b/src/ripple/protocol/jss.h index 802727583..8bf510e2f 100644 --- a/src/ripple/protocol/jss.h +++ b/src/ripple/protocol/jss.h @@ -56,6 +56,7 @@ JSS(CheckCancel); // transaction type. JSS(CheckCash); // transaction type. JSS(CheckCreate); // transaction type. JSS(ClaimReward); // transaction type. +JSS(Clawback); // transaction type. JSS(ClearFlag); // field. JSS(CreateCode); // field. JSS(DeliverMin); // in: TransactionSign diff --git a/src/ripple/rpc/handlers/AccountInfo.cpp b/src/ripple/rpc/handlers/AccountInfo.cpp index e811baf58..ed5bc8a4e 100644 --- a/src/ripple/rpc/handlers/AccountInfo.cpp +++ b/src/ripple/rpc/handlers/AccountInfo.cpp @@ -99,6 +99,10 @@ doAccountInfo(RPC::JsonContext& context) {"disallowIncomingTrustline", lsfDisallowIncomingTrustline}, {"disallowIncomingRemit", lsfDisallowIncomingRemit}}}; + static constexpr std::pair + allowTrustLineClawbackFlag{ + "allowTrustLineClawback", lsfAllowTrustLineClawback}; + auto const sleAccepted = ledger->read(keylet::account(accountID)); if (sleAccepted) { @@ -126,6 +130,11 @@ doAccountInfo(RPC::JsonContext& context) for (auto const& lsf : disallowIncomingFlags) acctFlags[lsf.first.data()] = sleAccepted->isFlag(lsf.second); } + + if (ledger->rules().enabled(featureClawback)) + acctFlags[allowTrustLineClawbackFlag.first.data()] = + sleAccepted->isFlag(allowTrustLineClawbackFlag.second); + result[jss::account_flags] = std::move(acctFlags); // Return SignerList(s) if that is requested. diff --git a/src/test/app/Clawback_test.cpp b/src/test/app/Clawback_test.cpp new file mode 100644 index 000000000..bafb00442 --- /dev/null +++ b/src/test/app/Clawback_test.cpp @@ -0,0 +1,985 @@ +//------------------------------------------------------------------------------ +/* + This file is part of rippled: https://github.com/ripple/rippled + Copyright (c) 2023 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 + +namespace ripple { + +class Clawback_test : public beast::unit_test::suite +{ + template + static std::string + to_string(T const& t) + { + return boost::lexical_cast(t); + } + + // Helper function that returns the owner count of an account root. + static std::uint32_t + ownerCount(test::jtx::Env const& env, test::jtx::Account const& acct) + { + std::uint32_t ret{0}; + if (auto const sleAcct = env.le(acct)) + ret = sleAcct->at(sfOwnerCount); + return ret; + } + + // Helper function that returns the number of tickets held by an account. + static std::uint32_t + ticketCount(test::jtx::Env const& env, test::jtx::Account const& acct) + { + std::uint32_t ret{0}; + if (auto const sleAcct = env.le(acct)) + ret = sleAcct->at(~sfTicketCount).value_or(0); + return ret; + } + + // Helper function that returns the freeze status of a trustline + static bool + getLineFreezeFlag( + test::jtx::Env const& env, + test::jtx::Account const& src, + test::jtx::Account const& dst, + Currency const& cur) + { + if (auto sle = env.le(keylet::line(src, dst, cur))) + { + auto const useHigh = src.id() > dst.id(); + return sle->isFlag(useHigh ? lsfHighFreeze : lsfLowFreeze); + } + Throw("No line in getLineFreezeFlag"); + return false; // silence warning + } + + void + testAllowTrustLineClawbackFlag(FeatureBitset features) + { + testcase("Enable AllowTrustLineClawback flag"); + using namespace test::jtx; + + // Test that one can successfully set asfAllowTrustLineClawback flag. + // If successful, asfNoFreeze can no longer be set. + // Also, asfAllowTrustLineClawback cannot be cleared. + { + Env env(*this, features); + Account alice{"alice"}; + + env.fund(XRP(1000), alice); + env.close(); + + // set asfAllowTrustLineClawback + env(fset(alice, asfAllowTrustLineClawback)); + env.close(); + env.require(flags(alice, asfAllowTrustLineClawback)); + + // clear asfAllowTrustLineClawback does nothing + env(fclear(alice, asfAllowTrustLineClawback)); + env.close(); + env.require(flags(alice, asfAllowTrustLineClawback)); + + // asfNoFreeze cannot be set when asfAllowTrustLineClawback is set + env.require(nflags(alice, asfNoFreeze)); + env(fset(alice, asfNoFreeze), ter(tecNO_PERMISSION)); + env.close(); + } + + // Test that asfAllowTrustLineClawback cannot be set when + // asfNoFreeze has been set + { + Env env(*this, features); + Account alice{"alice"}; + + env.fund(XRP(1000), alice); + env.close(); + + env.require(nflags(alice, asfNoFreeze)); + + // set asfNoFreeze + env(fset(alice, asfNoFreeze)); + env.close(); + + // NoFreeze is set + env.require(flags(alice, asfNoFreeze)); + + // asfAllowTrustLineClawback cannot be set if asfNoFreeze is set + env(fset(alice, asfAllowTrustLineClawback), ter(tecNO_PERMISSION)); + env.close(); + + env.require(nflags(alice, asfAllowTrustLineClawback)); + } + + // Test that asfAllowTrustLineClawback is not allowed when owner dir is + // non-empty + { + Env env(*this, features); + + Account alice{"alice"}; + Account bob{"bob"}; + + env.fund(XRP(1000), alice, bob); + env.close(); + + auto const USD = alice["USD"]; + env.require(nflags(alice, asfAllowTrustLineClawback)); + + // alice issues 10 USD to bob + env.trust(USD(1000), bob); + env(pay(alice, bob, USD(10))); + env.close(); + + BEAST_EXPECT(ownerCount(env, alice) == 0); + BEAST_EXPECT(ownerCount(env, bob) == 1); + + // alice fails to enable clawback because she has trustline with bob + env(fset(alice, asfAllowTrustLineClawback), ter(tecOWNERS)); + env.close(); + + // bob sets trustline to default limit and pays alice back to delete + // the trustline + env(trust(bob, USD(0), 0)); + env(pay(bob, alice, USD(10))); + + BEAST_EXPECT(ownerCount(env, alice) == 0); + BEAST_EXPECT(ownerCount(env, bob) == 0); + + // alice now is able to set asfAllowTrustLineClawback + env(fset(alice, asfAllowTrustLineClawback)); + env.close(); + env.require(flags(alice, asfAllowTrustLineClawback)); + + BEAST_EXPECT(ownerCount(env, alice) == 0); + BEAST_EXPECT(ownerCount(env, bob) == 0); + } + + // Test that one cannot enable asfAllowTrustLineClawback when + // featureClawback amendment is disabled + { + Env env(*this, features - featureClawback); + + Account alice{"alice"}; + + env.fund(XRP(1000), alice); + env.close(); + + env.require(nflags(alice, asfAllowTrustLineClawback)); + + // alice attempts to set asfAllowTrustLineClawback flag while + // amendment is disabled. no error is returned, but the flag remains + // to be unset. + env(fset(alice, asfAllowTrustLineClawback)); + env.close(); + env.require(nflags(alice, asfAllowTrustLineClawback)); + + // now enable clawback amendment + env.enableFeature(featureClawback); + env.close(); + + // asfAllowTrustLineClawback can be set + env(fset(alice, asfAllowTrustLineClawback)); + env.close(); + env.require(flags(alice, asfAllowTrustLineClawback)); + } + } + + void + testValidation(FeatureBitset features) + { + testcase("Validation"); + using namespace test::jtx; + + // Test that Clawback tx fails for the following: + // 1. when amendment is disabled + // 2. when asfAllowTrustLineClawback flag has not been set + { + Env env(*this, features - featureClawback); + + Account alice{"alice"}; + Account bob{"bob"}; + + env.fund(XRP(1000), alice, bob); + env.close(); + + env.require(nflags(alice, asfAllowTrustLineClawback)); + + auto const USD = alice["USD"]; + + // alice issues 10 USD to bob + env.trust(USD(1000), bob); + env(pay(alice, bob, USD(10))); + env.close(); + + env.require(balance(bob, alice["USD"](10))); + env.require(balance(alice, bob["USD"](-10))); + + // clawback fails because amendment is disabled + env(claw(alice, bob["USD"](5)), ter(temDISABLED)); + env.close(); + + // now enable clawback amendment + env.enableFeature(featureClawback); + env.close(); + + // clawback fails because asfAllowTrustLineClawback has not been set + env(claw(alice, bob["USD"](5)), ter(tecNO_PERMISSION)); + env.close(); + + env.require(balance(bob, alice["USD"](10))); + env.require(balance(alice, bob["USD"](-10))); + } + + // Test that Clawback tx fails for the following: + // 1. invalid flag + // 2. negative STAmount + // 3. zero STAmount + // 4. XRP amount + // 5. `account` and `issuer` fields are same account + // 6. trustline has a balance of 0 + // 7. trustline does not exist + { + Env env(*this, features); + + Account alice{"alice"}; + Account bob{"bob"}; + + env.fund(XRP(1000), alice, bob); + env.close(); + + // alice sets asfAllowTrustLineClawback + env(fset(alice, asfAllowTrustLineClawback)); + env.close(); + env.require(flags(alice, asfAllowTrustLineClawback)); + + auto const USD = alice["USD"]; + + // alice issues 10 USD to bob + env.trust(USD(1000), bob); + env(pay(alice, bob, USD(10))); + env.close(); + + env.require(balance(bob, alice["USD"](10))); + env.require(balance(alice, bob["USD"](-10))); + + // fails due to invalid flag + env(claw(alice, bob["USD"](5)), + txflags(0x00008000), + ter(temINVALID_FLAG)); + env.close(); + + // fails due to negative amount + env(claw(alice, bob["USD"](-5)), ter(temBAD_AMOUNT)); + env.close(); + + // fails due to zero amount + env(claw(alice, bob["USD"](0)), ter(temBAD_AMOUNT)); + env.close(); + + // fails because amount is in XRP + env(claw(alice, XRP(10)), ter(temBAD_AMOUNT)); + env.close(); + + // fails when `issuer` field in `amount` is not token holder + // NOTE: we are using the `issuer` field for the token holder + env(claw(alice, alice["USD"](5)), ter(temBAD_AMOUNT)); + env.close(); + + // bob pays alice back, trustline has a balance of 0 + env(pay(bob, alice, USD(10))); + env.close(); + + // bob still owns the trustline that has 0 balance + BEAST_EXPECT(ownerCount(env, alice) == 0); + BEAST_EXPECT(ownerCount(env, bob) == 1); + env.require(balance(bob, alice["USD"](0))); + env.require(balance(alice, bob["USD"](0))); + + // clawback fails because because balance is 0 + env(claw(alice, bob["USD"](5)), ter(tecINSUFFICIENT_FUNDS)); + env.close(); + + // set the limit to default, which should delete the trustline + env(trust(bob, USD(0), 0)); + env.close(); + + // bob no longer owns the trustline + BEAST_EXPECT(ownerCount(env, alice) == 0); + BEAST_EXPECT(ownerCount(env, bob) == 0); + + // clawback fails because trustline does not exist + env(claw(alice, bob["USD"](5)), ter(tecNO_LINE)); + env.close(); + } + } + + void + testPermission(FeatureBitset features) + { + // Checks the tx submitter has the permission to clawback. + // Exercises preclaim code + testcase("Permission"); + using namespace test::jtx; + + // Clawing back from an non-existent account returns error + { + Env env(*this, features); + + Account alice{"alice"}; + Account bob{"bob"}; + + // bob's account is not funded and does not exist + env.fund(XRP(1000), alice); + env.close(); + + // alice sets asfAllowTrustLineClawback + env(fset(alice, asfAllowTrustLineClawback)); + env.close(); + env.require(flags(alice, asfAllowTrustLineClawback)); + + // bob, the token holder, does not exist + env(claw(alice, bob["USD"](5)), ter(terNO_ACCOUNT)); + env.close(); + } + + // Test that trustline cannot be clawed by someone who is + // not the issuer of the currency + { + Env env(*this, features); + + Account alice{"alice"}; + Account bob{"bob"}; + Account cindy{"cindy"}; + + env.fund(XRP(1000), alice, bob, cindy); + env.close(); + + auto const USD = alice["USD"]; + + // alice sets asfAllowTrustLineClawback + env(fset(alice, asfAllowTrustLineClawback)); + env.close(); + env.require(flags(alice, asfAllowTrustLineClawback)); + + // cindy sets asfAllowTrustLineClawback + env(fset(cindy, asfAllowTrustLineClawback)); + env.close(); + env.require(flags(cindy, asfAllowTrustLineClawback)); + + // alice issues 1000 USD to bob + env.trust(USD(1000), bob); + env(pay(alice, bob, USD(1000))); + env.close(); + + env.require(balance(bob, alice["USD"](1000))); + env.require(balance(alice, bob["USD"](-1000))); + + // cindy tries to claw from bob, and fails because trustline does + // not exist + env(claw(cindy, bob["USD"](200)), ter(tecNO_LINE)); + env.close(); + } + + // When a trustline is created between issuer and holder, + // we must make sure the holder is unable to claw back from + // the issuer by impersonating the issuer account. + // + // This must be tested bidirectionally for both accounts because the + // issuer could be either the low or high account in the trustline + // object + { + Env env(*this, features); + + Account alice{"alice"}; + Account bob{"bob"}; + + env.fund(XRP(1000), alice, bob); + env.close(); + + auto const USD = alice["USD"]; + auto const CAD = bob["CAD"]; + + // alice sets asfAllowTrustLineClawback + env(fset(alice, asfAllowTrustLineClawback)); + env.close(); + env.require(flags(alice, asfAllowTrustLineClawback)); + + // bob sets asfAllowTrustLineClawback + env(fset(bob, asfAllowTrustLineClawback)); + env.close(); + env.require(flags(bob, asfAllowTrustLineClawback)); + + // alice issues 10 USD to bob. + // bob then attempts to submit a clawback tx to claw USD from alice. + // this must FAIL, because bob is not the issuer for this + // trustline!!! + { + // bob creates a trustline with alice, and alice sends 10 USD to + // bob + env.trust(USD(1000), bob); + env(pay(alice, bob, USD(10))); + env.close(); + + env.require(balance(bob, alice["USD"](10))); + env.require(balance(alice, bob["USD"](-10))); + + // bob cannot claw back USD from alice because he's not the + // issuer + env(claw(bob, alice["USD"](5)), ter(tecNO_PERMISSION)); + env.close(); + } + + // bob issues 10 CAD to alice. + // alice then attempts to submit a clawback tx to claw CAD from bob. + // this must FAIL, because alice is not the issuer for this + // trustline!!! + { + // alice creates a trustline with bob, and bob sends 10 CAD to + // alice + env.trust(CAD(1000), alice); + env(pay(bob, alice, CAD(10))); + env.close(); + + env.require(balance(bob, alice["CAD"](-10))); + env.require(balance(alice, bob["CAD"](10))); + + // alice cannot claw back CAD from bob because she's not the + // issuer + env(claw(alice, bob["CAD"](5)), ter(tecNO_PERMISSION)); + env.close(); + } + } + } + + void + testEnabled(FeatureBitset features) + { + testcase("Enable clawback"); + using namespace test::jtx; + + // Test that alice is able to successfully clawback tokens from bob + Env env(*this, features); + + Account alice{"alice"}; + Account bob{"bob"}; + + env.fund(XRP(1000), alice, bob); + env.close(); + + auto const USD = alice["USD"]; + + // alice sets asfAllowTrustLineClawback + env(fset(alice, asfAllowTrustLineClawback)); + env.close(); + env.require(flags(alice, asfAllowTrustLineClawback)); + + // alice issues 1000 USD to bob + env.trust(USD(1000), bob); + env(pay(alice, bob, USD(1000))); + env.close(); + + env.require(balance(bob, alice["USD"](1000))); + env.require(balance(alice, bob["USD"](-1000))); + + // alice claws back 200 USD from bob + env(claw(alice, bob["USD"](200))); + env.close(); + + // bob should have 800 USD left + env.require(balance(bob, alice["USD"](800))); + env.require(balance(alice, bob["USD"](-800))); + + // alice claws back 800 USD from bob again + env(claw(alice, bob["USD"](800))); + env.close(); + + // trustline has a balance of 0 + env.require(balance(bob, alice["USD"](0))); + env.require(balance(alice, bob["USD"](0))); + } + + void + testMultiLine(FeatureBitset features) + { + // Test scenarios where multiple trustlines are involved + testcase("Multi line"); + using namespace test::jtx; + + // Both alice and bob issues their own "USD" to cindy. + // When alice and bob tries to claw back, they will only + // claw back from their respective trustline. + { + Env env(*this, features); + + Account alice{"alice"}; + Account bob{"bob"}; + Account cindy{"cindy"}; + + env.fund(XRP(1000), alice, bob, cindy); + env.close(); + + // alice sets asfAllowTrustLineClawback + env(fset(alice, asfAllowTrustLineClawback)); + env.close(); + env.require(flags(alice, asfAllowTrustLineClawback)); + + // bob sets asfAllowTrustLineClawback + env(fset(bob, asfAllowTrustLineClawback)); + env.close(); + env.require(flags(bob, asfAllowTrustLineClawback)); + + // alice sends 1000 USD to cindy + env.trust(alice["USD"](1000), cindy); + env(pay(alice, cindy, alice["USD"](1000))); + env.close(); + + // bob sends 1000 USD to cindy + env.trust(bob["USD"](1000), cindy); + env(pay(bob, cindy, bob["USD"](1000))); + env.close(); + + // alice claws back 200 USD from cindy + env(claw(alice, cindy["USD"](200))); + env.close(); + + // cindy has 800 USD left in alice's trustline after clawed by alice + env.require(balance(cindy, alice["USD"](800))); + env.require(balance(alice, cindy["USD"](-800))); + + // cindy still has 1000 USD in bob's trustline + env.require(balance(cindy, bob["USD"](1000))); + env.require(balance(bob, cindy["USD"](-1000))); + + // bob claws back 600 USD from cindy + env(claw(bob, cindy["USD"](600))); + env.close(); + + // cindy has 400 USD left in bob's trustline after clawed by bob + env.require(balance(cindy, bob["USD"](400))); + env.require(balance(bob, cindy["USD"](-400))); + + // cindy still has 800 USD in alice's trustline + env.require(balance(cindy, alice["USD"](800))); + env.require(balance(alice, cindy["USD"](-800))); + } + + // alice issues USD to both bob and cindy. + // when alice claws back from bob, only bob's USD balance is + // affected, and cindy's balance remains unchanged, and vice versa. + { + Env env(*this, features); + + Account alice{"alice"}; + Account bob{"bob"}; + Account cindy{"cindy"}; + + env.fund(XRP(1000), alice, bob, cindy); + env.close(); + + auto const USD = alice["USD"]; + + // alice sets asfAllowTrustLineClawback + env(fset(alice, asfAllowTrustLineClawback)); + env.close(); + env.require(flags(alice, asfAllowTrustLineClawback)); + + // alice sends 600 USD to bob + env.trust(USD(1000), bob); + env(pay(alice, bob, USD(600))); + env.close(); + + env.require(balance(alice, bob["USD"](-600))); + env.require(balance(bob, alice["USD"](600))); + + // alice sends 1000 USD to cindy + env.trust(USD(1000), cindy); + env(pay(alice, cindy, USD(1000))); + env.close(); + + env.require(balance(alice, cindy["USD"](-1000))); + env.require(balance(cindy, alice["USD"](1000))); + + // alice claws back 500 USD from bob + env(claw(alice, bob["USD"](500))); + env.close(); + + // bob's balance is reduced + env.require(balance(alice, bob["USD"](-100))); + env.require(balance(bob, alice["USD"](100))); + + // cindy's balance is unchanged + env.require(balance(alice, cindy["USD"](-1000))); + env.require(balance(cindy, alice["USD"](1000))); + + // alice claws back 300 USD from cindy + env(claw(alice, cindy["USD"](300))); + env.close(); + + // bob's balance is unchanged + env.require(balance(alice, bob["USD"](-100))); + env.require(balance(bob, alice["USD"](100))); + + // cindy's balance is reduced + env.require(balance(alice, cindy["USD"](-700))); + env.require(balance(cindy, alice["USD"](700))); + } + } + + void + testBidirectionalLine(FeatureBitset features) + { + testcase("Bidirectional line"); + using namespace test::jtx; + + // Test when both alice and bob issues USD to each other. + // This scenario creates only one trustline. + // In this case, both alice and bob can be seen as the "issuer" + // and they can send however many USDs to each other. + // We test that only the person who has a negative balance from their + // perspective is allowed to clawback + Env env(*this, features); + + Account alice{"alice"}; + Account bob{"bob"}; + + env.fund(XRP(1000), alice, bob); + env.close(); + + // alice sets asfAllowTrustLineClawback + env(fset(alice, asfAllowTrustLineClawback)); + env.close(); + env.require(flags(alice, asfAllowTrustLineClawback)); + + // bob sets asfAllowTrustLineClawback + env(fset(bob, asfAllowTrustLineClawback)); + env.close(); + env.require(flags(bob, asfAllowTrustLineClawback)); + + // alice issues 1000 USD to bob + env.trust(alice["USD"](1000), bob); + env(pay(alice, bob, alice["USD"](1000))); + env.close(); + + BEAST_EXPECT(ownerCount(env, alice) == 0); + BEAST_EXPECT(ownerCount(env, bob) == 1); + + // bob is the holder, and alice is the issuer + env.require(balance(bob, alice["USD"](1000))); + env.require(balance(alice, bob["USD"](-1000))); + + // bob issues 1500 USD to alice + env.trust(bob["USD"](1500), alice); + env(pay(bob, alice, bob["USD"](1500))); + env.close(); + + BEAST_EXPECT(ownerCount(env, alice) == 1); + BEAST_EXPECT(ownerCount(env, bob) == 1); + + // bob has negative 500 USD because bob issued 500 USD more than alice + // bob can now been seen as the issuer, while alice is the holder + env.require(balance(bob, alice["USD"](-500))); + env.require(balance(alice, bob["USD"](500))); + + // At this point, both alice and bob are the issuers of USD + // and can send USD to each other through one trustline + + // alice fails to clawback. Even though she is also an issuer, + // the trustline balance is positive from her perspective + env(claw(alice, bob["USD"](200)), ter(tecNO_PERMISSION)); + env.close(); + + // bob is able to successfully clawback from alice because + // the trustline balance is negative from his perspective + env(claw(bob, alice["USD"](200))); + env.close(); + + env.require(balance(bob, alice["USD"](-300))); + env.require(balance(alice, bob["USD"](300))); + + // alice pays bob 1000 USD + env(pay(alice, bob, alice["USD"](1000))); + env.close(); + + // bob's balance becomes positive from his perspective because + // alice issued more USD than the balance + env.require(balance(bob, alice["USD"](700))); + env.require(balance(alice, bob["USD"](-700))); + + // bob is now the holder and fails to clawback + env(claw(bob, alice["USD"](200)), ter(tecNO_PERMISSION)); + env.close(); + + // alice successfully claws back + env(claw(alice, bob["USD"](200))); + env.close(); + + env.require(balance(bob, alice["USD"](500))); + env.require(balance(alice, bob["USD"](-500))); + } + + void + testDeleteDefaultLine(FeatureBitset features) + { + testcase("Delete default trustline"); + using namespace test::jtx; + + // If clawback results the trustline to be default, + // trustline should be automatically deleted + Env env(*this, features); + Account alice{"alice"}; + Account bob{"bob"}; + + env.fund(XRP(1000), alice, bob); + env.close(); + + auto const USD = alice["USD"]; + + // alice sets asfAllowTrustLineClawback + env(fset(alice, asfAllowTrustLineClawback)); + env.close(); + env.require(flags(alice, asfAllowTrustLineClawback)); + + // alice issues 1000 USD to bob + env.trust(USD(1000), bob); + env(pay(alice, bob, USD(1000))); + env.close(); + + BEAST_EXPECT(ownerCount(env, alice) == 0); + BEAST_EXPECT(ownerCount(env, bob) == 1); + + env.require(balance(bob, alice["USD"](1000))); + env.require(balance(alice, bob["USD"](-1000))); + + // set limit to default, + env(trust(bob, USD(0), 0)); + env.close(); + + BEAST_EXPECT(ownerCount(env, alice) == 0); + BEAST_EXPECT(ownerCount(env, bob) == 1); + + // alice claws back full amount from bob, and should also delete + // trustline + env(claw(alice, bob["USD"](1000))); + env.close(); + + // bob no longer owns the trustline because it was deleted + BEAST_EXPECT(ownerCount(env, alice) == 0); + BEAST_EXPECT(ownerCount(env, bob) == 0); + } + + void + testFrozenLine(FeatureBitset features) + { + testcase("Frozen trustline"); + using namespace test::jtx; + + // Claws back from frozen trustline + // and the trustline should remain frozen + Env env(*this, features); + Account alice{"alice"}; + Account bob{"bob"}; + + env.fund(XRP(1000), alice, bob); + env.close(); + + auto const USD = alice["USD"]; + + // alice sets asfAllowTrustLineClawback + env(fset(alice, asfAllowTrustLineClawback)); + env.close(); + env.require(flags(alice, asfAllowTrustLineClawback)); + + // alice issues 1000 USD to bob + env.trust(USD(1000), bob); + env(pay(alice, bob, USD(1000))); + env.close(); + + env.require(balance(bob, alice["USD"](1000))); + env.require(balance(alice, bob["USD"](-1000))); + + // freeze trustline + env(trust(alice, bob["USD"](0), tfSetFreeze)); + env.close(); + + // alice claws back 200 USD from bob + env(claw(alice, bob["USD"](200))); + env.close(); + + // bob should have 800 USD left + env.require(balance(bob, alice["USD"](800))); + env.require(balance(alice, bob["USD"](-800))); + + // trustline remains frozen + BEAST_EXPECT(getLineFreezeFlag(env, alice, bob, USD.currency)); + } + + static STAmount + lockedAmount( + test::jtx::Env const& env, + test::jtx::Account const& account, + test::jtx::Account const& gw, + test::jtx::IOU const& iou) + { + auto const sle = env.le(keylet::line(account, gw, iou.currency)); + if (sle->isFieldPresent(sfLockedBalance)) + return (*sle)[sfLockedBalance]; + return STAmount(iou, 0); + } + + void + testAmountExceedsAvailable(FeatureBitset features) + { + testcase("Amount exceeds available"); + using namespace test::jtx; + + // When alice tries to claw back an amount that is greater + // than what bob holds, only the max available balance is clawed + Env env(*this, features); + Account alice{"alice"}; + Account bob{"bob"}; + + env.fund(XRP(1000), alice, bob); + env.close(); + + auto const USD = alice["USD"]; + + // alice sets asfAllowTrustLineClawback + env(fset(alice, asfAllowTrustLineClawback)); + env.close(); + env.require(flags(alice, asfAllowTrustLineClawback)); + + // alice issues 1000 USD to bob + env.trust(USD(1000), bob); + env(pay(alice, bob, USD(1000))); + env.close(); + + env.require(balance(bob, alice["USD"](1000))); + env.require(balance(alice, bob["USD"](-1000))); + + // alice tries to claw back 2000 USD + env(claw(alice, bob["USD"](2000))); + env.close(); + + // check alice and bob's balance. + // alice was only able to claw back 1000 USD at maximum + env.require(balance(bob, alice["USD"](0))); + env.require(balance(alice, bob["USD"](0))); + + // bob still owns the trustline because trustline is not in default + // state + BEAST_EXPECT(ownerCount(env, alice) == 0); + BEAST_EXPECT(ownerCount(env, bob) == 1); + + // set limit to default, + env(trust(bob, USD(0), 0)); + env.close(); + + // verify that bob's trustline was deleted + BEAST_EXPECT(ownerCount(env, alice) == 0); + BEAST_EXPECT(ownerCount(env, bob) == 0); + } + + void + testTickets(FeatureBitset features) + { + testcase("Tickets"); + using namespace test::jtx; + + // Tests clawback with tickets + Env env(*this, features); + Account alice{"alice"}; + Account bob{"bob"}; + + env.fund(XRP(1000), alice, bob); + env.close(); + + auto const USD = alice["USD"]; + + // alice sets asfAllowTrustLineClawback + env(fset(alice, asfAllowTrustLineClawback)); + env.close(); + env.require(flags(alice, asfAllowTrustLineClawback)); + + // alice issues 100 USD to bob + env.trust(USD(1000), bob); + env(pay(alice, bob, USD(100))); + env.close(); + + env.require(balance(bob, alice["USD"](100))); + env.require(balance(alice, bob["USD"](-100))); + + // alice creates 10 tickets + std::uint32_t ticketCnt = 10; + std::uint32_t aliceTicketSeq{env.seq(alice) + 1}; + env(ticket::create(alice, ticketCnt)); + env.close(); + std::uint32_t const aliceSeq{env.seq(alice)}; + BEAST_EXPECT(ticketCount(env, alice) == ticketCnt); + BEAST_EXPECT(ownerCount(env, alice) == ticketCnt); + + while (ticketCnt > 0) + { + // alice claws back 5 USD using a ticket + env(claw(alice, bob["USD"](5)), ticket::use(aliceTicketSeq++)); + env.close(); + + ticketCnt--; + BEAST_EXPECT(ticketCount(env, alice) == ticketCnt); + BEAST_EXPECT(ownerCount(env, alice) == ticketCnt); + } + + // alice clawed back 50 USD total, trustline has 50 USD remaining + env.require(balance(bob, alice["USD"](50))); + env.require(balance(alice, bob["USD"](-50))); + + // Verify that the account sequence numbers did not advance. + BEAST_EXPECT(env.seq(alice) == aliceSeq); + } + + void + testWithFeats(FeatureBitset features) + { + testAllowTrustLineClawbackFlag(features); + testValidation(features); + testPermission(features); + testEnabled(features); + testMultiLine(features); + testBidirectionalLine(features); + testDeleteDefaultLine(features); + testFrozenLine(features); + testAmountExceedsAvailable(features); + testTickets(features); + } + +public: + void + run() override + { + using namespace test::jtx; + FeatureBitset const all{supported_amendments()}; + testWithFeats(all); + } +}; + +BEAST_DEFINE_TESTSUITE(Clawback, app, ripple); +} // namespace ripple \ No newline at end of file diff --git a/src/test/app/Escrow_test.cpp b/src/test/app/Escrow_test.cpp index db8474fef..bb0a0bcb3 100644 --- a/src/test/app/Escrow_test.cpp +++ b/src/test/app/Escrow_test.cpp @@ -4320,6 +4320,54 @@ struct Escrow_test : public beast::unit_test::suite } } + void + testIOUClawback(FeatureBitset features) + { + testcase("IOU Clawback"); + using namespace test::jtx; + using namespace std::chrono; + + Env env(*this, features); + Account alice{"alice"}; + Account bob{"bob"}; + Account gw{"gw"}; + + env.fund(XRP(1000), alice, bob, gw); + env.close(); + + auto const USD = gw["USD"]; + + // gw sets asfAllowTrustLineClawback + env(fset(gw, asfAllowTrustLineClawback)); + env.close(); + + bool const clawbackEnabled = features[featureClawback]; + if (clawbackEnabled) + { + env.require(flags(gw, asfAllowTrustLineClawback)); + } + else + { + env.require(nflags(gw, asfAllowTrustLineClawback)); + } + + // gw issues 1000 USD to alice + env.trust(USD(1000), alice); + env(pay(gw, alice, USD(1000))); + env.close(); + + BEAST_EXPECT(env.balance(alice, USD) == USD(1000)); + BEAST_EXPECT(env.balance(bob, USD) == USD(0)); + + // alice escrows token fails; cannot escrow clawable tokens + auto const createResult = + clawbackEnabled ? ter(tecNO_PERMISSION) : ter(tesSUCCESS); + env(escrow::create(alice, bob, USD(10)), + escrow::finish_time(env.now() + 1s), + createResult); + env.close(); + } + static uint256 getEscrowIndex(AccountID const& account, std::uint32_t uSequence) { @@ -4589,6 +4637,7 @@ struct Escrow_test : public beast::unit_test::suite testIOUTLFreeze(features); testIOUTLINSF(features); testIOUPrecisionLoss(features); + testIOUClawback(features); } public: @@ -4599,6 +4648,7 @@ public: FeatureBitset const all{supported_amendments()}; testWithFeats(all - featurePaychanAndEscrowForTokens); testWithFeats(all); + testIOUWithFeats(all - featureClawback); testIOUWithFeats(all); testEscrowID(all); } diff --git a/src/test/app/PayChan_test.cpp b/src/test/app/PayChan_test.cpp index 39ff8c335..59a03e089 100644 --- a/src/test/app/PayChan_test.cpp +++ b/src/test/app/PayChan_test.cpp @@ -5616,6 +5616,53 @@ struct PayChan_test : public beast::unit_test::suite } } + void + testIOUClawback(FeatureBitset features) + { + testcase("IOU Clawback"); + using namespace test::jtx; + using namespace std::chrono; + + Env env(*this, features); + Account alice{"alice"}; + Account bob{"bob"}; + Account gw{"gw"}; + + env.fund(XRP(1000), alice, bob, gw); + env.close(); + + auto const USD = gw["USD"]; + + // gw sets asfAllowTrustLineClawback + env(fset(gw, asfAllowTrustLineClawback)); + env.close(); + + bool const clawbackEnabled = features[featureClawback]; + if (clawbackEnabled) + { + env.require(flags(gw, asfAllowTrustLineClawback)); + } + else + { + env.require(nflags(gw, asfAllowTrustLineClawback)); + } + + // gw issues 1000 USD to alice + env.trust(USD(1000), alice); + env(pay(gw, alice, USD(1000))); + env.close(); + + BEAST_EXPECT(env.balance(alice, USD) == USD(1000)); + BEAST_EXPECT(env.balance(bob, USD) == USD(0)); + + // alice paychan token fails; cannot escrow clawable tokens + auto const createResult = + clawbackEnabled ? ter(tecNO_PERMISSION) : ter(tesSUCCESS); + env(paychan::create(alice, bob, USD(10), 100s, alice.pk()), + ter(createResult)); + env.close(); + } + void testWithFeats(FeatureBitset features) { @@ -5672,6 +5719,7 @@ struct PayChan_test : public beast::unit_test::suite testIOUTLINSF(features); testIOUMismatchFunding(features); testIOUPrecisionLoss(features); + testIOUClawback(features); } public: @@ -5685,6 +5733,7 @@ public: all - disallowIncoming - featurePaychanAndEscrowForTokens); testWithFeats(all); testIOUWithFeats(all - disallowIncoming); + testIOUWithFeats(all - featureClawback); testIOUWithFeats(all); } }; diff --git a/src/test/app/SetHookTSH_test.cpp b/src/test/app/SetHookTSH_test.cpp index 19d7e6f58..314a23da3 100644 --- a/src/test/app/SetHookTSH_test.cpp +++ b/src/test/app/SetHookTSH_test.cpp @@ -1237,6 +1237,90 @@ private: } } + void + testClawbackTSH(FeatureBitset features) + { + testcase("clawback tsh"); + + using namespace test::jtx; + using namespace std::literals; + + // otxn: IOU issuer + // tsh issuer + // w/s: strong + for (bool const testStrong : {true, false}) + { + test::jtx::Env env{ + *this, + network::makeNetworkConfig(21337, "10", "1000000", "200000"), + features}; + + auto const issuer = Account("gw"); + auto const holder = Account("bob"); + env.fund(XRP(1000), issuer, holder); + env.close(); + + env(fset(issuer, asfAllowTrustLineClawback)); + env.close(); + + env.trust(issuer["USD"](1000), holder); + env(pay(issuer, holder, issuer["USD"](1000))); + env.close(); + + // set tsh collect + if (!testStrong) + addWeakTSH(env, issuer); + + // set tsh hook + setTSHHook(env, issuer, testStrong); + + // clawback + env(claw(issuer, holder["USD"](1000)), fee(XRP(1))); + env.close(); + + // verify tsh hook triggered + testTSHStrongWeak(env, tshSTRONG, __LINE__); + } + + // otxn: IOU issuer + // tsh holder + // w/s: weak + for (bool const testStrong : {true, false}) + { + test::jtx::Env env{ + *this, + network::makeNetworkConfig(21337, "10", "1000000", "200000"), + features}; + + auto const issuer = Account("gw"); + auto const holder = Account("bob"); + env.fund(XRP(1000), issuer, holder); + env.close(); + + env(fset(issuer, asfAllowTrustLineClawback)); + env.close(); + + env.trust(issuer["USD"](1000), holder); + env(pay(issuer, holder, issuer["USD"](1000))); + env.close(); + + // set tsh collect + if (!testStrong) + addWeakTSH(env, holder); + + // set tsh hook + setTSHHook(env, holder, testStrong); + + // clawback + env(claw(issuer, holder["USD"](1000)), fee(XRP(1))); + env.close(); + + // verify tsh hook triggered + auto const expected = testStrong ? tshNONE : tshWEAK; + testTSHStrongWeak(env, expected, __LINE__); + } + } + // DepositPreauth // | otxn | tsh | preauth | // | A | A | S | @@ -5499,6 +5583,7 @@ private: testCheckCashTSH(features); testCheckCreateTSH(features); testClaimRewardTSH(features); + testClawbackTSH(features); testDepositPreauthTSH(features); testEscrowCancelTSH(features); testEscrowIDCancelTSH(features); diff --git a/src/test/jtx/flags.h b/src/test/jtx/flags.h index a9ecaf8e2..27b6ea956 100644 --- a/src/test/jtx/flags.h +++ b/src/test/jtx/flags.h @@ -80,6 +80,9 @@ private: case asfDepositAuth: mask_ |= lsfDepositAuth; break; + case asfAllowTrustLineClawback: + mask_ |= lsfAllowTrustLineClawback; + break; default: Throw("unknown flag"); } diff --git a/src/test/jtx/impl/trust.cpp b/src/test/jtx/impl/trust.cpp index 4fd0ad596..cce4657e0 100644 --- a/src/test/jtx/impl/trust.cpp +++ b/src/test/jtx/impl/trust.cpp @@ -59,6 +59,17 @@ trust( return jv; } +Json::Value +claw(Account const& account, STAmount const& amount) +{ + Json::Value jv; + jv[jss::Account] = account.human(); + jv[jss::Amount] = amount.getJson(JsonOptions::none); + jv[jss::TransactionType] = jss::Clawback; + + return jv; +} + } // namespace jtx } // namespace test } // namespace ripple diff --git a/src/test/jtx/trust.h b/src/test/jtx/trust.h index ba0bc9959..5b6dd78b3 100644 --- a/src/test/jtx/trust.h +++ b/src/test/jtx/trust.h @@ -40,6 +40,9 @@ trust( Account const& peer, std::uint32_t flags); +Json::Value +claw(Account const& account, STAmount const& amount); + } // namespace jtx } // namespace test } // namespace ripple diff --git a/src/test/rpc/AccountInfo_test.cpp b/src/test/rpc/AccountInfo_test.cpp index ed6527b55..77a1b4486 100644 --- a/src/test/rpc/AccountInfo_test.cpp +++ b/src/test/rpc/AccountInfo_test.cpp @@ -511,13 +511,16 @@ public: Env env(*this, features); Account const alice{"alice"}; - env.fund(XRP(1000), alice); + Account const bob{"bob"}; + env.fund(XRP(1000), alice, bob); - auto getAccountFlag = [&env, &alice](std::string_view fName) { + auto getAccountFlag = [&env]( + std::string_view fName, + Account const& account) { auto const info = env.rpc( "json", "account_info", - R"({"account" : ")" + alice.human() + R"("})"); + R"({"account" : ")" + account.human() + R"("})"); std::optional res; if (info[jss::result][jss::status] == "success" && @@ -546,7 +549,7 @@ public: // as expected env(fclear(alice, asf.second)); env.close(); - auto const f1 = getAccountFlag(asf.first); + auto const f1 = getAccountFlag(asf.first, alice); BEAST_EXPECT(f1.has_value()); BEAST_EXPECT(!f1.value()); @@ -554,7 +557,7 @@ public: // as expected env(fset(alice, asf.second)); env.close(); - auto const f2 = getAccountFlag(asf.first); + auto const f2 = getAccountFlag(asf.first, alice); BEAST_EXPECT(f2.has_value()); BEAST_EXPECT(f2.value()); } @@ -578,7 +581,7 @@ public: // as expected env(fclear(alice, asf.second)); env.close(); - auto const f1 = getAccountFlag(asf.first); + auto const f1 = getAccountFlag(asf.first, alice); BEAST_EXPECT(f1.has_value()); BEAST_EXPECT(!f1.value()); @@ -586,7 +589,7 @@ public: // as expected env(fset(alice, asf.second)); env.close(); - auto const f2 = getAccountFlag(asf.first); + auto const f2 = getAccountFlag(asf.first, alice); BEAST_EXPECT(f2.has_value()); BEAST_EXPECT(f2.value()); } @@ -595,9 +598,35 @@ public: { for (auto& asf : disallowIncomingFlags) { - BEAST_EXPECT(!getAccountFlag(asf.first)); + BEAST_EXPECT(!getAccountFlag(asf.first, alice)); } } + + static constexpr std::pair + allowTrustLineClawbackFlag{ + "allowTrustLineClawback", asfAllowTrustLineClawback}; + + if (features[featureClawback]) + { + // must use bob's account because alice has noFreeze set + auto const f1 = + getAccountFlag(allowTrustLineClawbackFlag.first, bob); + BEAST_EXPECT(f1.has_value()); + BEAST_EXPECT(!f1.value()); + + // Set allowTrustLineClawback + env(fset(bob, allowTrustLineClawbackFlag.second)); + env.close(); + auto const f2 = + getAccountFlag(allowTrustLineClawbackFlag.first, bob); + BEAST_EXPECT(f2.has_value()); + BEAST_EXPECT(f2.value()); + } + else + { + BEAST_EXPECT( + !getAccountFlag(allowTrustLineClawbackFlag.first, bob)); + } } void @@ -612,6 +641,8 @@ public: ripple::test::jtx::supported_amendments()}; testAccountFlags(allFeatures); testAccountFlags(allFeatures - featureDisallowIncoming); + testAccountFlags( + allFeatures - featureDisallowIncoming - featureClawback); } }; diff --git a/src/test/rpc/AccountSet_test.cpp b/src/test/rpc/AccountSet_test.cpp index afcf227ec..19a5a12dd 100644 --- a/src/test/rpc/AccountSet_test.cpp +++ b/src/test/rpc/AccountSet_test.cpp @@ -95,6 +95,13 @@ public: continue; } + if (flag == asfAllowTrustLineClawback) + { + // The asfAllowTrustLineClawback flag can't be cleared. It + // is tested elsewhere. + continue; + } + if (std::find(goodFlags.begin(), goodFlags.end(), flag) != goodFlags.end()) {