diff --git a/include/xrpl/protocol/detail/transactions.macro b/include/xrpl/protocol/detail/transactions.macro index 289e8b48e0..149e4deacf 100644 --- a/include/xrpl/protocol/detail/transactions.macro +++ b/include/xrpl/protocol/detail/transactions.macro @@ -1246,6 +1246,17 @@ TRANSACTION(ttCONTRACT_CALL, 97, ContractCall, {sfGas, SoeRequired}, })) +/** This transaction claws back tokens held in a payment channel. */ +#if TRANSACTION_INCLUDE +# include +#endif +TRANSACTION(ttPAYCHAN_CLAWBACK, 98, PaymentChannelClawback, + ({.delegable = Delegation::Delegable, .amendment = featureTokenPaychan}), + ({ + {sfChannel, SoeRequired}, + {sfAmount, SoeOptional, SoeMptSupported}, +})) + /** This system-generated transaction type is used to update the status of the various amendments. For details, see: https://xrpl.org/amendments.html diff --git a/include/xrpl/protocol_autogen/transactions/PaymentChannelClawback.h b/include/xrpl/protocol_autogen/transactions/PaymentChannelClawback.h new file mode 100644 index 0000000000..6509e78848 --- /dev/null +++ b/include/xrpl/protocol_autogen/transactions/PaymentChannelClawback.h @@ -0,0 +1,170 @@ +// This file is auto-generated. Do not edit. +#pragma once + +#include +#include +#include +#include +#include +#include + +#include +#include + +namespace xrpl::transactions { + +class PaymentChannelClawbackBuilder; + +/** + * @brief Transaction: PaymentChannelClawback + * + * Type: ttPAYCHAN_CLAWBACK (92) + * Delegable: Delegation::Delegable + * Amendment: featureTokenPaychan + * Privileges: NoPriv + * + * Immutable wrapper around STTx providing type-safe field access. + * Use PaymentChannelClawbackBuilder to construct new transactions. + */ +class PaymentChannelClawback : public TransactionBase +{ +public: + static constexpr xrpl::TxType txType = ttPAYCHAN_CLAWBACK; + + /** + * @brief Construct a PaymentChannelClawback transaction wrapper from an existing STTx object. + * @throws std::runtime_error if the transaction type doesn't match. + */ + explicit PaymentChannelClawback(std::shared_ptr tx) + : TransactionBase(std::move(tx)) + { + // Verify transaction type + if (tx_->getTxnType() != txType) + { + throw std::runtime_error("Invalid transaction type for PaymentChannelClawback"); + } + } + + // Transaction-specific field getters + + /** + * @brief Get sfChannel (SoeRequired) + * @return The field value. + */ + [[nodiscard]] + SF_UINT256::type::value_type + getChannel() const + { + return this->tx_->at(sfChannel); + } + + /** + * @brief Get sfAmount (SoeOptional) + * @note This field supports MPT (Multi-Purpose Token) amounts. + * @return The field value, or std::nullopt if not present. + */ + [[nodiscard]] + protocol_autogen::Optional + getAmount() const + { + if (hasAmount()) + { + return this->tx_->at(sfAmount); + } + return std::nullopt; + } + + /** + * @brief Check if sfAmount is present. + * @return True if the field is present, false otherwise. + */ + [[nodiscard]] + bool + hasAmount() const + { + return this->tx_->isFieldPresent(sfAmount); + } +}; + +/** + * @brief Builder for PaymentChannelClawback transactions. + * + * Provides a fluent interface for constructing transactions with method chaining. + * Uses STObject internally for flexible transaction construction. + * Inherits common field setters from TransactionBuilderBase. + */ +class PaymentChannelClawbackBuilder : public TransactionBuilderBase +{ +public: + /** + * @brief Construct a new PaymentChannelClawbackBuilder with required fields. + * @param account The account initiating the transaction. + * @param channel The sfChannel field value. + * @param sequence Optional sequence number for the transaction. + * @param fee Optional fee for the transaction. + */ + PaymentChannelClawbackBuilder(SF_ACCOUNT::type::value_type account, + std::decay_t const& channel, std::optional sequence = std::nullopt, + std::optional fee = std::nullopt +) + : TransactionBuilderBase(ttPAYCHAN_CLAWBACK, account, sequence, fee) + { + setChannel(channel); + } + + /** + * @brief Construct a PaymentChannelClawbackBuilder from an existing STTx object. + * @param tx The existing transaction to copy from. + * @throws std::runtime_error if the transaction type doesn't match. + */ + PaymentChannelClawbackBuilder(std::shared_ptr tx) + { + if (tx->getTxnType() != ttPAYCHAN_CLAWBACK) + { + throw std::runtime_error("Invalid transaction type for PaymentChannelClawbackBuilder"); + } + object_ = *tx; + } + + /** + * @brief Transaction-specific field setters + */ + + /** + * @brief Set sfChannel (SoeRequired) + * @return Reference to this builder for method chaining. + */ + PaymentChannelClawbackBuilder& + setChannel(std::decay_t const& value) + { + object_[sfChannel] = value; + return *this; + } + + /** + * @brief Set sfAmount (SoeOptional) + * @note This field supports MPT (Multi-Purpose Token) amounts. + * @return Reference to this builder for method chaining. + */ + PaymentChannelClawbackBuilder& + setAmount(std::decay_t const& value) + { + object_[sfAmount] = value; + return *this; + } + + /** + * @brief Build and return the PaymentChannelClawback wrapper. + * @param publicKey The public key for signing. + * @param secretKey The secret key for signing. + * @return The constructed transaction wrapper. + */ + PaymentChannelClawback + build(PublicKey const& publicKey, SecretKey const& secretKey) + { + sign(publicKey, secretKey); + return PaymentChannelClawback{std::make_shared(std::move(object_))}; + } +}; + +} // namespace xrpl::transactions diff --git a/include/xrpl/tx/invariants/InvariantCheck.h b/include/xrpl/tx/invariants/InvariantCheck.h index e8dafbd301..008f5f81c8 100644 --- a/include/xrpl/tx/invariants/InvariantCheck.h +++ b/include/xrpl/tx/invariants/InvariantCheck.h @@ -304,6 +304,28 @@ public: finalize(STTx const&, TER const, XRPAmount const, ReadView const&, beast::Journal const&) const; }; +/** + * @brief Invariant: a token payment channel must stay structurally consistent. + * + * For any non-XRP `PayChannel` (only reachable once `featureTokenPaychan` is + * enabled): the paid-out `sfBalance` must never exceed the locked `sfAmount`, + * `sfBalance` and `sfAmount` must name the same asset, `sfBalance` must never + * decrease, and neither may go negative. This guards every operation that + * mutates a channel (create, fund, claim, clawback) against corrupting the + * amount/balance relationship. + */ +class ValidPaymentChannel +{ + bool bad_ = false; + +public: + void + visitEntry(bool, SLE::const_ref, SLE::const_ref); + + [[nodiscard]] bool + finalize(STTx const&, TER const, XRPAmount const, ReadView const&, beast::Journal const&) const; +}; + /** * @brief Invariant: a new account root must be the consequence of a payment, * must have the right starting sequence, and the payment @@ -443,6 +465,7 @@ using InvariantChecks = std::tuple< TransfersNotFrozen, NoBadOffers, NoZeroEscrow, + ValidPaymentChannel, ValidNewAccountRoot, ValidNFTokenPage, NFTokenCountTracking, diff --git a/include/xrpl/tx/transactors/payment_channel/PaymentChannelClawback.h b/include/xrpl/tx/transactors/payment_channel/PaymentChannelClawback.h new file mode 100644 index 0000000000..e595928698 --- /dev/null +++ b/include/xrpl/tx/transactors/payment_channel/PaymentChannelClawback.h @@ -0,0 +1,44 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace xrpl { + +class PaymentChannelClawback : public Transactor +{ +public: + static constexpr auto kConsequencesFactory = ConsequencesFactoryType::Normal; + + explicit PaymentChannelClawback(ApplyContext& ctx) : Transactor(ctx) + { + } + + static NotTEC + preflight(PreflightContext const& ctx); + + static TER + preclaim(PreclaimContext const& ctx); + + TER + doApply() override; + + void + visitInvariantEntry(bool isDelete, SLE::const_ref before, SLE::const_ref after) override; + + [[nodiscard]] bool + finalizeInvariants( + STTx const& tx, + TER result, + XRPAmount fee, + ReadView const& view, + beast::Journal const& j) override; +}; + +} // namespace xrpl diff --git a/src/libxrpl/tx/invariants/InvariantCheck.cpp b/src/libxrpl/tx/invariants/InvariantCheck.cpp index 0f02f16df2..426e542651 100644 --- a/src/libxrpl/tx/invariants/InvariantCheck.cpp +++ b/src/libxrpl/tx/invariants/InvariantCheck.cpp @@ -424,6 +424,60 @@ NoZeroEscrow::finalize( //------------------------------------------------------------------------------ +void +ValidPaymentChannel::visitEntry(bool isDelete, SLE::const_ref before, SLE::const_ref after) +{ + // Only token channels are constrained here; XRP channels are covered by the + // XRP conservation invariants. A deleted channel has nothing to check. + if (isDelete || !after || after->getType() != ltPAYCHAN) + return; + + auto const& amount = (*after)[sfAmount]; + if (isXRP(amount)) + return; + + auto const& balance = (*after)[sfBalance]; + + // Amount and balance must name the same asset, both non-negative, and the + // paid-out balance must never exceed the locked amount. + if (amount.asset() != balance.asset() || amount < beast::kZero || balance < beast::kZero || + amount < balance) + { + bad_ = true; + return; + } + + // The paid-out balance must never decrease. + if (before && before->getType() == ltPAYCHAN && !isXRP((*before)[sfAmount]) && + (*before)[sfBalance].asset() == balance.asset() && balance < (*before)[sfBalance]) + { + bad_ = true; + } +} + +bool +ValidPaymentChannel::finalize( + STTx const&, + TER const, + XRPAmount const, + ReadView const& view, + beast::Journal const& j) const +{ + // Token channels only exist once the amendment is active. + if (!view.rules().enabled(featureTokenPaychan)) + return true; + + if (bad_) + { + JLOG(j.fatal()) << "Invariant failed: payment channel amount/balance inconsistent"; + return false; + } + + return true; +} + +//------------------------------------------------------------------------------ + void AccountRootsNotDeleted::visitEntry(bool isDelete, SLE::const_ref before, SLE::const_ref) { diff --git a/src/libxrpl/tx/transactors/payment_channel/PaymentChannelClawback.cpp b/src/libxrpl/tx/transactors/payment_channel/PaymentChannelClawback.cpp new file mode 100644 index 0000000000..53f2288eba --- /dev/null +++ b/src/libxrpl/tx/transactors/payment_channel/PaymentChannelClawback.cpp @@ -0,0 +1,171 @@ +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace xrpl { + +NotTEC +PaymentChannelClawback::preflight(PreflightContext const& ctx) +{ + if (auto const amount = ctx.tx[~sfAmount]) + { + if (amount->native() || *amount <= beast::kZero) + return temBAD_AMOUNT; + + if (amount->holds() && amount->mpt() > MPTAmount{kMaxMpTokenAmount}) + return temBAD_AMOUNT; + + if (amount->holds() && badCurrency() == amount->get().currency) + return temBAD_CURRENCY; + } + + return tesSUCCESS; +} + +TER +PaymentChannelClawback::preclaim(PreclaimContext const& ctx) +{ + Keylet const k{ltPAYCHAN, ctx.tx[sfChannel]}; + auto const slep = ctx.view.read(k); + if (!slep) + return tecNO_TARGET; + + auto const& amount = slep->getFieldAmount(sfAmount); + + // XRP cannot be clawed back. + if (isXRP(amount)) + return tecNO_PERMISSION; + + auto const& issuer = amount.getIssuer(); + + // Only the issuer of the locked asset may claw it back. + if (ctx.tx[sfAccount] != issuer) + return tecNO_PERMISSION; + + // Defensive: the issuer can never be the channel owner. + if ((*slep)[sfAccount] == issuer) + return tecNO_PERMISSION; // LCOV_EXCL_LINE + + if (auto const clawAmount = ctx.tx[~sfAmount]; + clawAmount && clawAmount->asset() != amount.asset()) + return tecWRONG_ASSET; + + if (amount.holds()) + { + auto const sleIssuer = ctx.view.read(keylet::account(issuer)); + if (!sleIssuer) + return tecINTERNAL; // LCOV_EXCL_LINE + + if (!sleIssuer->isFlag(lsfAllowTrustLineClawback) || sleIssuer->isFlag(lsfNoFreeze)) + return tecNO_PERMISSION; + } + else + { + auto const sleIssuance = + ctx.view.read(keylet::mptokenIssuance(amount.get().getMptID())); + if (!sleIssuance) + return tecOBJECT_NOT_FOUND; // LCOV_EXCL_LINE + + if (!sleIssuance->isFlag(lsfMPTCanClawback)) + return tecNO_PERMISSION; + } + + return tesSUCCESS; +} + +TER +PaymentChannelClawback::doApply() +{ + Keylet const k{ltPAYCHAN, ctx_.tx[sfChannel]}; + auto const slep = ctx_.view().peek(k); + if (!slep) + return tecINTERNAL; // LCOV_EXCL_LINE + + auto const& chanAmt = slep->getFieldAmount(sfAmount); + auto const& chanBalance = slep->getFieldAmount(sfBalance); + + // Only the unclaimed remainder can be clawed; the destination's earned + // balance (sfBalance) is untouched. + STAmount const lockedRemaining = chanAmt - chanBalance; + if (lockedRemaining <= beast::kZero) + return tecINTERNAL; // LCOV_EXCL_LINE + + auto const clawAmount = ctx_.tx[~sfAmount]; + bool const full = !clawAmount || *clawAmount >= lockedRemaining; + + // For a full claw, set the new amount to sfBalance directly rather than + // computing chanAmt - lockedRemaining: for IOUs the latter is a - (a - b) + // and can round, leaving a dust channel undeleted or pushing sfAmount + // below sfBalance. claw is the exact amount removed. + STAmount const newAmount = full ? chanBalance : STAmount{chanAmt - *clawAmount}; + STAmount const claw = full ? lockedRemaining : *clawAmount; + + AccountID const owner = (*slep)[sfAccount]; + + // MPT: release the locked accounting back to the issuer (a redemption). + // IOU: the locked value already sits with the issuer, so only the channel + // obligation shrinks. + if (chanAmt.holds()) + { + if (auto const ret = unlockEscrowMPT(ctx_.view(), owner, accountID_, claw, claw, j_); + !isTesSuccess(ret)) + return ret; // LCOV_EXCL_LINE + } + + (*slep)[sfAmount] = newAmount; + + // Fully drained: nothing remains beyond the paid-out balance, close the + // channel (the zero remainder makes closeChannel's refund a no-op). + if (full) + { + return closeChannel( + slep, + ctx_.getApplyViewContext(), + k.key, + accountID_, + ctx_.registry.get().getJournal("View")); + } + + ctx_.view().update(slep); + + return tesSUCCESS; +} + +void +PaymentChannelClawback::visitInvariantEntry(bool, SLE::const_ref, SLE::const_ref) +{ + // No transaction-specific invariants yet (future work). +} + +bool +PaymentChannelClawback::finalizeInvariants( + STTx const&, + TER, + XRPAmount, + ReadView const&, + beast::Journal const&) +{ + // No transaction-specific invariants yet (future work). + return true; +} + +} // namespace xrpl diff --git a/src/test/app/Delegate_test.cpp b/src/test/app/Delegate_test.cpp index 61f5746397..fae34a6a18 100644 --- a/src/test/app/Delegate_test.cpp +++ b/src/test/app/Delegate_test.cpp @@ -2756,7 +2756,7 @@ class Delegate_test : public beast::unit_test::Suite // DO NOT modify expectedDelegableCount unless all scenarios, including // edge cases, have been fully tested and verified. // ==================================================================== - std::size_t const expectedDelegableCount = 62; + std::size_t const expectedDelegableCount = 63; BEAST_EXPECTS( delegableCount == expectedDelegableCount, diff --git a/src/test/app/PayChanToken_test.cpp b/src/test/app/PayChanToken_test.cpp index 27c8795a29..c24edadc16 100644 --- a/src/test/app/PayChanToken_test.cpp +++ b/src/test/app/PayChanToken_test.cpp @@ -2,6 +2,7 @@ #include #include #include +#include #include #include #include @@ -4579,6 +4580,402 @@ struct PayChanToken_test : public beast::unit_test::Suite } } + void + testIOUChannelClawback(FeatureBitset features) + { + testcase("IOU Channel Clawback"); + using namespace test::jtx; + using namespace std::literals; + + auto const alice = Account("alice"); + auto const bob = Account("bob"); + auto const gw = Account{"gateway"}; + auto const usd = gw["USD"]; + auto const settleDelay = 100s; + + auto const setup = [&](Env& env) { + env.fund(XRP(10'000), alice, bob, gw); + env(fset(gw, asfAllowTrustLineLocking)); + env(fset(gw, asfAllowTrustLineClawback)); + env.close(); + env.trust(usd(100'000), alice); + env.trust(usd(100'000), bob); + env.close(); + env(pay(gw, alice, usd(5'000))); + env.close(); + }; + + // Amendment gating: without featureTokenPaychan the transaction type + // is disabled (rejected before any channel lookup). + { + Env env{*this, features - featureTokenPaychan}; + env.fund(XRP(10'000), gw); + env.close(); + auto const chan = paychan::channel(alice, bob, env.seq(gw)); + env(paychan::clawback(gw, chan), Ter(temDISABLED)); + } + + // Full clawback of the unclaimed remainder deletes the channel; the + // issuer's trust line is untouched (value already redeemed at lock). + { + Env env{*this, features}; + setup(env); + auto const seq1 = env.seq(alice); + env(paychan::create(alice, bob, usd(4'000), settleDelay, alice.pk())); + env.close(); + auto const chan = paychan::channel(alice, bob, seq1); + BEAST_EXPECT(issuerEscrowed(env, gw, usd) == usd(4'000)); + + // A non-issuer cannot claw. + env(paychan::clawback(alice, chan), Ter(tecNO_PERMISSION)); + + env(paychan::clawback(gw, chan)); + env.close(); + BEAST_EXPECT(!paychan::channelExists(*env.current(), chan)); + BEAST_EXPECT(issuerEscrowed(env, gw, usd) == usd(0)); + BEAST_EXPECT(env.balance(alice, usd) == usd(1'000)); + } + + // Partial clawback reduces the channel amount in place; a later claim + // clamps to what remains. + { + Env env{*this, features}; + setup(env); + auto const seq1 = env.seq(alice); + env(paychan::create(alice, bob, usd(4'000), settleDelay, alice.pk())); + env.close(); + auto const chan = paychan::channel(alice, bob, seq1); + + env(paychan::clawback(gw, chan, usd(1'000))); + env.close(); + BEAST_EXPECT(paychan::channelAmount(*env.current(), chan) == usd(3'000)); + BEAST_EXPECT(issuerEscrowed(env, gw, usd) == usd(3'000)); + + // Over-claiming the reduced remainder fails. + auto const sig = paychan::signClaimAuth(alice.pk(), alice.sk(), chan, usd(4'000)); + env(paychan::claim(bob, chan, usd(4'000), usd(4'000), Slice(sig), alice.pk()), + Ter(tecUNFUNDED_PAYMENT)); + + // Claiming within the remainder succeeds. + auto const sig2 = paychan::signClaimAuth(alice.pk(), alice.sk(), chan, usd(3'000)); + env(paychan::claim(bob, chan, usd(3'000), usd(3'000), Slice(sig2), alice.pk())); + env.close(); + BEAST_EXPECT(env.balance(bob, usd) == usd(3'000)); + } + + // Clawback after a partial claim: only the unclaimed remainder is + // taken and the destination's already-claimed balance is untouched. + // This is also the regression test for the full-claw amount being set + // to sfBalance directly (rather than chanAmt - lockedRemaining, which + // rounds for IOUs). + { + Env env{*this, features}; + setup(env); + auto const seq1 = env.seq(alice); + env(paychan::create(alice, bob, usd(4'000), settleDelay, alice.pk())); + env.close(); + auto const chan = paychan::channel(alice, bob, seq1); + + auto const sig = paychan::signClaimAuth(alice.pk(), alice.sk(), chan, usd(1'000)); + env(paychan::claim(bob, chan, usd(1'000), usd(1'000), Slice(sig), alice.pk())); + env.close(); + BEAST_EXPECT(env.balance(bob, usd) == usd(1'000)); + BEAST_EXPECT(paychan::channelBalance(*env.current(), chan) == usd(1'000)); + BEAST_EXPECT(issuerEscrowed(env, gw, usd) == usd(3'000)); + + env(paychan::clawback(gw, chan)); + env.close(); + BEAST_EXPECT(!paychan::channelExists(*env.current(), chan)); + BEAST_EXPECT(env.balance(bob, usd) == usd(1'000)); + BEAST_EXPECT(issuerEscrowed(env, gw, usd) == usd(0)); + } + + // An Amount exceeding the remainder claws only the remainder. + { + Env env{*this, features}; + setup(env); + auto const seq1 = env.seq(alice); + env(paychan::create(alice, bob, usd(4'000), settleDelay, alice.pk())); + env.close(); + auto const chan = paychan::channel(alice, bob, seq1); + env(paychan::clawback(gw, chan, usd(10'000))); + env.close(); + BEAST_EXPECT(!paychan::channelExists(*env.current(), chan)); + BEAST_EXPECT(issuerEscrowed(env, gw, usd) == usd(0)); + } + + // A clawback Amount naming a different asset than the channel fails. + { + Env env{*this, features}; + setup(env); + auto const seq1 = env.seq(alice); + env(paychan::create(alice, bob, usd(4'000), settleDelay, alice.pk())); + env.close(); + auto const chan = paychan::channel(alice, bob, seq1); + auto const eur = gw["EUR"]; + env(paychan::clawback(gw, chan, eur(100)), Ter(tecWRONG_ASSET)); + } + + // Clawback ignores the channel's timers: an expired channel is still + // clawable, and the source is not refunded. + { + Env env{*this, features}; + setup(env); + auto const seq1 = env.seq(alice); + NetClock::time_point const cancelAfter = env.now() + 50s; + env(paychan::create(alice, bob, usd(4'000), settleDelay, alice.pk(), cancelAfter)); + env.close(); + auto const chan = paychan::channel(alice, bob, seq1); + env.close(env.now() + 60s); + env(paychan::clawback(gw, chan)); + env.close(); + BEAST_EXPECT(!paychan::channelExists(*env.current(), chan)); + BEAST_EXPECT(issuerEscrowed(env, gw, usd) == usd(0)); + BEAST_EXPECT(env.balance(alice, usd) == usd(1'000)); + } + + // An XRP channel can never be clawed. + { + Env env{*this, features}; + env.fund(XRP(10'000), alice, bob, gw); + env.close(); + auto const seq1 = env.seq(alice); + env(paychan::create(alice, bob, XRP(1'000), settleDelay, alice.pk())); + env.close(); + auto const chan = paychan::channel(alice, bob, seq1); + env(paychan::clawback(gw, chan), Ter(tecNO_PERMISSION)); + } + + // The clawback authority can be delegated. The tx is Delegable, which + // registers automatically from the macro; only a delegate the issuer + // authorized for PaymentChannelClawback may claw on its behalf. + if (features[featurePermissionDelegationV1_1]) + { + Env env{*this, features}; + auto const dan = Account("dan"); + env.fund(XRP(10'000), alice, bob, gw, dan); + env(fset(gw, asfAllowTrustLineLocking)); + env(fset(gw, asfAllowTrustLineClawback)); + env.close(); + env.trust(usd(100'000), alice); + env.close(); + env(pay(gw, alice, usd(5'000))); + env.close(); + auto const seq1 = env.seq(alice); + env(paychan::create(alice, bob, usd(4'000), settleDelay, alice.pk())); + env.close(); + auto const chan = paychan::channel(alice, bob, seq1); + + // Unauthorized delegate is rejected. + env(paychan::clawback(gw, chan), delegate::As(dan), Ter(terNO_DELEGATE_PERMISSION)); + + env(delegate::set(gw, dan, {"PaymentChannelClawback"})); + env.close(); + env(paychan::clawback(gw, chan), delegate::As(dan)); + env.close(); + BEAST_EXPECT(!paychan::channelExists(*env.current(), chan)); + BEAST_EXPECT(issuerEscrowed(env, gw, usd) == usd(0)); + } + + // Clawback requires the issuer opt-in (lsfAllowTrustLineClawback). + { + Env env{*this, features}; + env.fund(XRP(10'000), alice, bob, gw); + env(fset(gw, asfAllowTrustLineLocking)); + env.close(); + env.trust(usd(100'000), alice); + env.close(); + env(pay(gw, alice, usd(5'000))); + env.close(); + auto const seq1 = env.seq(alice); + env(paychan::create(alice, bob, usd(4'000), settleDelay, alice.pk())); + env.close(); + auto const chan = paychan::channel(alice, bob, seq1); + env(paychan::clawback(gw, chan), Ter(tecNO_PERMISSION)); + } + } + + void + testMPTChannelClawback(FeatureBitset features) + { + testcase("MPT Channel Clawback"); + using namespace test::jtx; + using namespace std::literals; + + auto const alice = Account("alice"); + auto const bob = Account("bob"); + auto const gw = Account("gw"); + auto const settleDelay = 100s; + + // Full clawback: the three MPT accounting fields drop and the channel + // is deleted. + { + Env env{*this, features}; + MPTTester mptGw(env, gw, {.holders = {alice, bob}}); + mptGw.create( + {.ownerCount = 1, .flags = tfMPTCanEscrow | tfMPTCanTransfer | tfMPTCanClawback}); + mptGw.authorize({.account = alice}); + mptGw.authorize({.account = bob}); + auto const mpt = mptGw["MPT"]; + env(pay(gw, alice, mpt(5'000))); + env.close(); + + auto const seq1 = env.seq(alice); + env(paychan::create(alice, bob, mpt(4'000), settleDelay, alice.pk())); + env.close(); + auto const chan = paychan::channel(alice, bob, seq1); + BEAST_EXPECT(mptEscrowed(env, alice, mpt) == 4'000); + BEAST_EXPECT(issuerMPTEscrowed(env, mpt) == 4'000); + + env(paychan::clawback(gw, chan)); + env.close(); + BEAST_EXPECT(!paychan::channelExists(*env.current(), chan)); + BEAST_EXPECT(mptEscrowed(env, alice, mpt) == 0); + BEAST_EXPECT(issuerMPTEscrowed(env, mpt) == 0); + } + + // Partial clawback decrements each field by the clawed amount. + { + Env env{*this, features}; + MPTTester mptGw(env, gw, {.holders = {alice, bob}}); + mptGw.create( + {.ownerCount = 1, .flags = tfMPTCanEscrow | tfMPTCanTransfer | tfMPTCanClawback}); + mptGw.authorize({.account = alice}); + mptGw.authorize({.account = bob}); + auto const mpt = mptGw["MPT"]; + env(pay(gw, alice, mpt(5'000))); + env.close(); + + auto const seq1 = env.seq(alice); + env(paychan::create(alice, bob, mpt(4'000), settleDelay, alice.pk())); + env.close(); + auto const chan = paychan::channel(alice, bob, seq1); + + env(paychan::clawback(gw, chan, mpt(1'500))); + env.close(); + BEAST_EXPECT(paychan::channelAmount(*env.current(), chan) == mpt(2'500)); + BEAST_EXPECT(mptEscrowed(env, alice, mpt) == 2'500); + BEAST_EXPECT(issuerMPTEscrowed(env, mpt) == 2'500); + + // A second clawback works on the reduced remainder. + env(paychan::clawback(gw, chan, mpt(1'000))); + env.close(); + BEAST_EXPECT(paychan::channelAmount(*env.current(), chan) == mpt(1'500)); + BEAST_EXPECT(mptEscrowed(env, alice, mpt) == 1'500); + BEAST_EXPECT(issuerMPTEscrowed(env, mpt) == 1'500); + } + + // Clawback after a partial claim: only the unclaimed remainder is + // taken; the three accounting fields drop by exactly that amount and + // the destination's claimed balance is untouched. + { + Env env{*this, features}; + MPTTester mptGw(env, gw, {.holders = {alice, bob}}); + mptGw.create( + {.ownerCount = 1, .flags = tfMPTCanEscrow | tfMPTCanTransfer | tfMPTCanClawback}); + mptGw.authorize({.account = alice}); + mptGw.authorize({.account = bob}); + auto const mpt = mptGw["MPT"]; + env(pay(gw, alice, mpt(5'000))); + env.close(); + + auto const seq1 = env.seq(alice); + env(paychan::create(alice, bob, mpt(4'000), settleDelay, alice.pk())); + env.close(); + auto const chan = paychan::channel(alice, bob, seq1); + + auto const sig = paychan::signClaimAuth(alice.pk(), alice.sk(), chan, mpt(1'000)); + env(paychan::claim(bob, chan, mpt(1'000), mpt(1'000), Slice(sig), alice.pk())); + env.close(); + BEAST_EXPECT(env.balance(bob, mpt) == mpt(1'000)); + BEAST_EXPECT(paychan::channelBalance(*env.current(), chan) == mpt(1'000)); + BEAST_EXPECT(mptEscrowed(env, alice, mpt) == 3'000); + BEAST_EXPECT(issuerMPTEscrowed(env, mpt) == 3'000); + + env(paychan::clawback(gw, chan)); + env.close(); + BEAST_EXPECT(!paychan::channelExists(*env.current(), chan)); + BEAST_EXPECT(env.balance(bob, mpt) == mpt(1'000)); + BEAST_EXPECT(mptEscrowed(env, alice, mpt) == 0); + BEAST_EXPECT(issuerMPTEscrowed(env, mpt) == 0); + } + + // A clawback Amount of the wrong asset is rejected. + { + Env env{*this, features}; + MPTTester mptGw(env, gw, {.holders = {alice, bob}}); + mptGw.create( + {.ownerCount = 1, .flags = tfMPTCanEscrow | tfMPTCanTransfer | tfMPTCanClawback}); + mptGw.authorize({.account = alice}); + mptGw.authorize({.account = bob}); + auto const mpt = mptGw["MPT"]; + env(pay(gw, alice, mpt(5'000))); + env.close(); + + auto const seq1 = env.seq(alice); + env(paychan::create(alice, bob, mpt(4'000), settleDelay, alice.pk())); + env.close(); + auto const chan = paychan::channel(alice, bob, seq1); + auto const usd = gw["USD"]; + env(paychan::clawback(gw, chan, usd(100)), Ter(tecWRONG_ASSET)); + } + + // Clawback waives the transfer fee: on a fee-bearing issuance the gross + // locked amount is seized (no fee is deducted, and there is no overflow + // path since divideRound is never called). + { + Env env{*this, features}; + MPTTester mptGw(env, gw, {.holders = {alice, bob}}); + mptGw.create( + {.transferFee = 25'000, + .ownerCount = 1, + .flags = tfMPTCanEscrow | tfMPTCanTransfer | tfMPTCanClawback}); + mptGw.authorize({.account = alice}); + mptGw.authorize({.account = bob}); + auto const mpt = mptGw["MPT"]; + env(pay(gw, alice, mpt(5'000))); + env.close(); + + auto const seq1 = env.seq(alice); + env(paychan::create(alice, bob, mpt(4'000), settleDelay, alice.pk())); + env.close(); + auto const chan = paychan::channel(alice, bob, seq1); + BEAST_EXPECT(issuerMPTEscrowed(env, mpt) == 4'000); + + // Partial claw takes exactly the requested gross amount. + env(paychan::clawback(gw, chan, mpt(1'000))); + env.close(); + BEAST_EXPECT(paychan::channelAmount(*env.current(), chan) == mpt(3'000)); + BEAST_EXPECT(mptEscrowed(env, alice, mpt) == 3'000); + BEAST_EXPECT(issuerMPTEscrowed(env, mpt) == 3'000); + + // Full claw of the rest; no fee remains stranded on any field. + env(paychan::clawback(gw, chan)); + env.close(); + BEAST_EXPECT(!paychan::channelExists(*env.current(), chan)); + BEAST_EXPECT(mptEscrowed(env, alice, mpt) == 0); + BEAST_EXPECT(issuerMPTEscrowed(env, mpt) == 0); + } + + // Without tfMPTCanClawback the issuer cannot claw. + { + Env env{*this, features}; + MPTTester mptGw(env, gw, {.holders = {alice, bob}}); + mptGw.create({.ownerCount = 1, .flags = tfMPTCanEscrow | tfMPTCanTransfer}); + mptGw.authorize({.account = alice}); + mptGw.authorize({.account = bob}); + auto const mpt = mptGw["MPT"]; + env(pay(gw, alice, mpt(5'000))); + env.close(); + + auto const seq1 = env.seq(alice); + env(paychan::create(alice, bob, mpt(4'000), settleDelay, alice.pk())); + env.close(); + auto const chan = paychan::channel(alice, bob, seq1); + env(paychan::clawback(gw, chan), Ter(tecNO_PERMISSION)); + } + } + void testIOUWithFeats(FeatureBitset features) { @@ -4610,6 +5007,7 @@ struct PayChanToken_test : public beast::unit_test::Suite testIOUMultiChannelDrain(features); testIOUPrecisionLoss(features); testIOUClawbackInteraction(features); + testIOUChannelClawback(features); } void @@ -4637,6 +5035,7 @@ struct PayChanToken_test : public beast::unit_test::Suite testMPTCanEscrowRequired(features); testMPTDestroy(features); testMPTClawbackInteraction(features); + testMPTChannelClawback(features); } public: diff --git a/src/test/jtx/impl/paychan.cpp b/src/test/jtx/impl/paychan.cpp index 73a4451019..61f9d83af3 100644 --- a/src/test/jtx/impl/paychan.cpp +++ b/src/test/jtx/impl/paychan.cpp @@ -155,4 +155,17 @@ rate(Env& env, Account const& account, Account const& dest, std::uint32_t const& return Rate{0}; } +json::Value +clawback(AccountID const& account, uint256 const& channel, std::optional const& amount) +{ + json::Value jv; + jv[jss::TransactionType] = jss::PaymentChannelClawback; + jv[jss::Flags] = tfFullyCanonicalSig; + jv[jss::Account] = to_string(account); + jv["Channel"] = to_string(channel); + if (amount) + jv[jss::Amount] = amount->getJson(JsonOptions::Values::None); + return jv; +} + } // namespace xrpl::test::jtx::paychan diff --git a/src/test/jtx/paychan.h b/src/test/jtx/paychan.h index f2911e00eb..48df28f9a8 100644 --- a/src/test/jtx/paychan.h +++ b/src/test/jtx/paychan.h @@ -90,4 +90,19 @@ signClaimAuth( Rate rate(Env& env, Account const& account, Account const& dest, std::uint32_t const& seq); +json::Value +clawback( + AccountID const& account, + uint256 const& channel, + std::optional const& amount = std::nullopt); + +inline json::Value +clawback( + Account const& account, + uint256 const& channel, + std::optional const& amount = std::nullopt) +{ + return clawback(account.id(), channel, amount); +} + } // namespace xrpl::test::jtx::paychan diff --git a/src/tests/libxrpl/protocol_autogen/transactions/PaymentChannelClawbackTests.cpp b/src/tests/libxrpl/protocol_autogen/transactions/PaymentChannelClawbackTests.cpp new file mode 100644 index 0000000000..ee1c260597 --- /dev/null +++ b/src/tests/libxrpl/protocol_autogen/transactions/PaymentChannelClawbackTests.cpp @@ -0,0 +1,195 @@ +// Auto-generated unit tests for transaction PaymentChannelClawback + + +#include + +#include + +#include +#include +#include +#include +#include + +#include + +namespace xrpl::transactions { + +// 1 & 4) Set fields via builder setters, build, then read them back via +// wrapper getters. After build(), validate() should succeed. +TEST(TransactionsPaymentChannelClawbackTests, BuilderSettersRoundTrip) +{ + // Generate a deterministic keypair for signing + auto const [publicKey, secretKey] = + generateKeyPair(KeyType::Secp256k1, generateSeed("testPaymentChannelClawback")); + + // Common transaction fields + auto const accountValue = calcAccountID(publicKey); + std::uint32_t const sequenceValue = 1; + auto const feeValue = canonical_AMOUNT(); + + // Transaction-specific field values + auto const channelValue = canonical_UINT256(); + auto const amountValue = canonical_AMOUNT(); + + PaymentChannelClawbackBuilder builder{ + accountValue, + channelValue, + sequenceValue, + feeValue + }; + + // Set optional fields + builder.setAmount(amountValue); + + auto tx = builder.build(publicKey, secretKey); + + std::string reason; + EXPECT_TRUE(tx.validate(reason)) << reason; + + // Verify signing was applied + EXPECT_FALSE(tx.getSigningPubKey().empty()); + EXPECT_TRUE(tx.hasTxnSignature()); + + // Verify common fields + EXPECT_EQ(tx.getAccount(), accountValue); + EXPECT_EQ(tx.getSequence(), sequenceValue); + EXPECT_EQ(tx.getFee(), feeValue); + + // Verify required fields + { + auto const& expected = channelValue; + auto const actual = tx.getChannel(); + expectEqualField(expected, actual, "sfChannel"); + } + + // Verify optional fields + { + auto const& expected = amountValue; + auto const actualOpt = tx.getAmount(); + ASSERT_TRUE(actualOpt.has_value()) << "Optional field sfAmount should be present"; + expectEqualField(expected, *actualOpt, "sfAmount"); + EXPECT_TRUE(tx.hasAmount()); + } + +} + +// 2 & 4) Start from an STTx, construct a builder from it, build a new wrapper, +// and verify all fields match. +TEST(TransactionsPaymentChannelClawbackTests, BuilderFromStTxRoundTrip) +{ + // Generate a deterministic keypair for signing + auto const [publicKey, secretKey] = + generateKeyPair(KeyType::Secp256k1, generateSeed("testPaymentChannelClawbackFromTx")); + + // Common transaction fields + auto const accountValue = calcAccountID(publicKey); + std::uint32_t const sequenceValue = 2; + auto const feeValue = canonical_AMOUNT(); + + // Transaction-specific field values + auto const channelValue = canonical_UINT256(); + auto const amountValue = canonical_AMOUNT(); + + // Build an initial transaction + PaymentChannelClawbackBuilder initialBuilder{ + accountValue, + channelValue, + sequenceValue, + feeValue + }; + + initialBuilder.setAmount(amountValue); + + auto initialTx = initialBuilder.build(publicKey, secretKey); + + // Create builder from existing STTx + PaymentChannelClawbackBuilder builderFromTx{initialTx.getSTTx()}; + + auto rebuiltTx = builderFromTx.build(publicKey, secretKey); + + std::string reason; + EXPECT_TRUE(rebuiltTx.validate(reason)) << reason; + + // Verify common fields + EXPECT_EQ(rebuiltTx.getAccount(), accountValue); + EXPECT_EQ(rebuiltTx.getSequence(), sequenceValue); + EXPECT_EQ(rebuiltTx.getFee(), feeValue); + + // Verify required fields + { + auto const& expected = channelValue; + auto const actual = rebuiltTx.getChannel(); + expectEqualField(expected, actual, "sfChannel"); + } + + // Verify optional fields + { + auto const& expected = amountValue; + auto const actualOpt = rebuiltTx.getAmount(); + ASSERT_TRUE(actualOpt.has_value()) << "Optional field sfAmount should be present"; + expectEqualField(expected, *actualOpt, "sfAmount"); + } + +} + +// 3) Verify wrapper throws when constructed from wrong transaction type. +TEST(TransactionsPaymentChannelClawbackTests, WrapperThrowsOnWrongTxType) +{ + // Build a valid transaction of a different type + auto const [pk, sk] = + generateKeyPair(KeyType::Secp256k1, generateSeed("testWrongType")); + auto const account = calcAccountID(pk); + + AccountSetBuilder wrongBuilder{account, 1, canonical_AMOUNT()}; + auto wrongTx = wrongBuilder.build(pk, sk); + + EXPECT_THROW(PaymentChannelClawback{wrongTx.getSTTx()}, std::runtime_error); +} + +// 4) Verify builder throws when constructed from wrong transaction type. +TEST(TransactionsPaymentChannelClawbackTests, BuilderThrowsOnWrongTxType) +{ + // Build a valid transaction of a different type + auto const [pk, sk] = + generateKeyPair(KeyType::Secp256k1, generateSeed("testWrongTypeBuilder")); + auto const account = calcAccountID(pk); + + AccountSetBuilder wrongBuilder{account, 1, canonical_AMOUNT()}; + auto wrongTx = wrongBuilder.build(pk, sk); + + EXPECT_THROW(PaymentChannelClawbackBuilder{wrongTx.getSTTx()}, std::runtime_error); +} + +// 5) Build with only required fields and verify optional fields return nullopt. +TEST(TransactionsPaymentChannelClawbackTests, OptionalFieldsReturnNullopt) +{ + // Generate a deterministic keypair for signing + auto const [publicKey, secretKey] = + generateKeyPair(KeyType::Secp256k1, generateSeed("testPaymentChannelClawbackNullopt")); + + // Common transaction fields + auto const accountValue = calcAccountID(publicKey); + std::uint32_t const sequenceValue = 3; + auto const feeValue = canonical_AMOUNT(); + + // Transaction-specific required field values + auto const channelValue = canonical_UINT256(); + + PaymentChannelClawbackBuilder builder{ + accountValue, + channelValue, + sequenceValue, + feeValue + }; + + // Do NOT set optional fields + + auto tx = builder.build(publicKey, secretKey); + + // Verify optional fields are not present + EXPECT_FALSE(tx.hasAmount()); + EXPECT_FALSE(tx.getAmount().has_value()); +} + +}