Merge remote-tracking branch 'XRPLF-rippled/dangell7/token-paychan-clawback' into develop

# Conflicts:
#	src/test/app/Delegate_test.cpp
This commit is contained in:
Denis Angell
2026-09-13 19:23:39 -04:00
11 changed files with 1096 additions and 1 deletions

View File

@@ -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 <xrpl/tx/transactors/payment_channel/PaymentChannelClawback.h>
#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

View File

@@ -0,0 +1,170 @@
// This file is auto-generated. Do not edit.
#pragma once
#include <xrpl/protocol/STTx.h>
#include <xrpl/protocol/STParsedJSON.h>
#include <xrpl/protocol/jss.h>
#include <xrpl/protocol_autogen/TransactionBase.h>
#include <xrpl/protocol_autogen/TransactionBuilderBase.h>
#include <xrpl/json/json_value.h>
#include <stdexcept>
#include <optional>
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<STTx const> 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<SF_AMOUNT::type::value_type>
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<PaymentChannelClawbackBuilder>
{
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<typename SF_UINT256::type::value_type> const& channel, std::optional<SF_UINT32::type::value_type> sequence = std::nullopt,
std::optional<SF_AMOUNT::type::value_type> fee = std::nullopt
)
: TransactionBuilderBase<PaymentChannelClawbackBuilder>(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<STTx const> 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<typename SF_UINT256::type::value_type> 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<typename SF_AMOUNT::type::value_type> 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<STTx>(std::move(object_))};
}
};
} // namespace xrpl::transactions

View File

@@ -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,

View File

@@ -0,0 +1,44 @@
#pragma once
#include <xrpl/beast/utility/Journal.h>
#include <xrpl/core/ServiceRegistry.h>
#include <xrpl/ledger/ReadView.h>
#include <xrpl/protocol/STTx.h>
#include <xrpl/protocol/TER.h>
#include <xrpl/protocol/XRPAmount.h>
#include <xrpl/tx/ApplyContext.h>
#include <xrpl/tx/Transactor.h>
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

View File

@@ -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)
{

View File

@@ -0,0 +1,171 @@
#include <xrpl/tx/transactors/payment_channel/PaymentChannelClawback.h>
#include <xrpl/beast/utility/Zero.h>
#include <xrpl/ledger/ApplyView.h>
#include <xrpl/ledger/helpers/MPTokenHelpers.h>
#include <xrpl/ledger/helpers/PaymentChannelHelpers.h>
#include <xrpl/protocol/AccountID.h>
#include <xrpl/protocol/Indexes.h>
#include <xrpl/protocol/Issue.h>
#include <xrpl/protocol/Keylet.h>
#include <xrpl/protocol/LedgerFormats.h>
#include <xrpl/protocol/MPTAmount.h>
#include <xrpl/protocol/MPTIssue.h>
#include <xrpl/protocol/Protocol.h>
#include <xrpl/protocol/SField.h>
#include <xrpl/protocol/STAmount.h>
#include <xrpl/protocol/STLedgerEntry.h>
#include <xrpl/protocol/STTx.h>
#include <xrpl/protocol/TER.h>
#include <xrpl/protocol/UintTypes.h>
#include <xrpl/protocol/XRPAmount.h>
#include <xrpl/tx/Transactor.h>
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<MPTIssue>() && amount->mpt() > MPTAmount{kMaxMpTokenAmount})
return temBAD_AMOUNT;
if (amount->holds<Issue>() && badCurrency() == amount->get<Issue>().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<Issue>())
{
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<MPTIssue>().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<MPTIssue>())
{
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

View File

@@ -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,

View File

@@ -2,6 +2,7 @@
#include <test/jtx/Env.h>
#include <test/jtx/TestHelpers.h>
#include <test/jtx/amount.h>
#include <test/jtx/delegate.h>
#include <test/jtx/fee.h>
#include <test/jtx/flags.h>
#include <test/jtx/mpt.h>
@@ -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:

View File

@@ -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<STAmount> 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

View File

@@ -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<STAmount> const& amount = std::nullopt);
inline json::Value
clawback(
Account const& account,
uint256 const& channel,
std::optional<STAmount> const& amount = std::nullopt)
{
return clawback(account.id(), channel, amount);
}
} // namespace xrpl::test::jtx::paychan

View File

@@ -0,0 +1,195 @@
// Auto-generated unit tests for transaction PaymentChannelClawback
#include <gtest/gtest.h>
#include <protocol_autogen/TestHelpers.h>
#include <xrpl/protocol/SecretKey.h>
#include <xrpl/protocol/Seed.h>
#include <xrpl/protocol/STTx.h>
#include <xrpl/protocol_autogen/transactions/PaymentChannelClawback.h>
#include <xrpl/protocol_autogen/transactions/AccountSet.h>
#include <string>
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());
}
}