fix: Use trustline balance direction to validate IOU PaymentMint/PaymentBurn (#7584)

This commit is contained in:
yinyiqian1
2026-06-26 18:26:53 -04:00
committed by GitHub
parent 3e9f1d0ab8
commit fd8a915243
4 changed files with 365 additions and 36 deletions

View File

@@ -106,7 +106,7 @@ public:
txToPermissionType(TxType type);
// tx type value is permission value minus one
[[nodiscard]] static TxType
[[nodiscard]] static std::optional<TxType>
permissionToTxType(std::uint32_t value);
/**

View File

@@ -13,6 +13,7 @@
#include <algorithm>
#include <cstdint>
#include <functional>
#include <limits>
#include <optional>
#include <stdexcept>
#include <string>
@@ -152,9 +153,11 @@ Permission::getPermissionName(std::uint32_t value) const
return granular;
// not a granular permission, check if it maps to a transaction type
auto const txType = permissionToTxType(value);
if (auto const* item = TxFormats::getInstance().findByType(txType); item != nullptr)
return item->getName();
if (auto const txType = permissionToTxType(value))
{
if (auto const* item = TxFormats::getInstance().findByType(*txType); item != nullptr)
return item->getName();
}
return std::nullopt;
}
@@ -231,7 +234,10 @@ Permission::isDelegable(std::uint32_t permissionValue, Rules const& rules) const
}
auto const txType = permissionToTxType(permissionValue);
auto const txIt = txDelegationMap_.find(txType);
if (!txType)
return false;
auto const txIt = txDelegationMap_.find(*txType);
// Tx-level permissions require the transaction type itself to be delegable, and
// the corresponding amendment enabled.
@@ -245,10 +251,14 @@ Permission::txToPermissionType(TxType const type)
return static_cast<uint32_t>(type) + 1;
}
TxType
std::optional<TxType>
Permission::permissionToTxType(uint32_t value)
{
XRPL_ASSERT(value > 0, "xrpl::Permission::permissionToTxType : value is greater than 0");
// Values outside this range [1, 65536] would silently truncate when cast to
// uint16_t, for example, 65537 would become 1, mapping to the Payment transaction.
if (value == 0 || value > std::numeric_limits<std::uint16_t>::max() + 1u)
return std::nullopt;
return static_cast<TxType>(value - 1);
}

View File

@@ -283,16 +283,59 @@ Payment::checkGranularSemantics(
if (tx.isFieldPresent(sfSendMax) && tx[sfSendMax].asset() != amountAsset)
return terNO_DELEGATE_PERMISSION;
// PaymentMint and PaymentBurn apply to both IOU and MPT direct payments.
if (heldGranularPermissions.contains(PaymentMint) && !isXRP(amountAsset) &&
amountAsset.getIssuer() == tx[sfAccount])
return tesSUCCESS;
if (isXRP(amountAsset))
return terNO_DELEGATE_PERMISSION;
if (heldGranularPermissions.contains(PaymentBurn) && !isXRP(amountAsset) &&
amountAsset.getIssuer() == tx[sfDestination])
return tesSUCCESS;
return amountAsset.visit(
[&](MPTIssue const& mptIssue) -> NotTEC {
// For MPT payments, the MPTokenIssuanceID encodes the issuer unambiguously,
// unlike IOU, there is no endpoint aliasing where either side of the
// trustline can appear as the issuer.
if (heldGranularPermissions.contains(PaymentMint) &&
mptIssue.getIssuer() == tx[sfAccount])
return tesSUCCESS;
if (heldGranularPermissions.contains(PaymentBurn) &&
mptIssue.getIssuer() == tx[sfDestination])
return tesSUCCESS;
return terNO_DELEGATE_PERMISSION;
},
[&](Issue const& issue) -> NotTEC {
// For IOU payments, either endpoint may be encoded as the issuer in
// sfAmount. PaySteps normalizes those endpoint aliases, so sfAmount.issuer
// alone does not reliably identify whether the transaction issues or redeems
// IOUs. We determine PaymentMint vs PaymentBurn from the trustline balance
// direction instead.
auto const account = tx[sfAccount];
auto const destination = tx[sfDestination];
return terNO_DELEGATE_PERMISSION;
// Reject if neither endpoint is the issuer.
if (issue.getIssuer() != account && issue.getIssuer() != destination)
return terNO_DELEGATE_PERMISSION;
auto const sle = view.read(keylet::trustLine(account, destination, issue.currency));
if (!sle)
return terNO_DELEGATE_PERMISSION;
bool const accountIsLow = (account < destination);
auto const destLimit = sle->getFieldAmount(accountIsLow ? sfHighLimit : sfLowLimit);
auto const rawBalance = sle->getFieldAmount(sfBalance);
bool const accountIsHolder =
accountIsLow ? rawBalance > beast::kZero : rawBalance < beast::kZero;
// PaymentMint requires the destination to be the holder and the account to be the
// issuer. destLimit > 0: destination is willing to hold account's IOUs (account is the
// issuer). !accountIsHolder: DirectStepI will issue, not redeem.
if (heldGranularPermissions.contains(PaymentMint) && destLimit > beast::kZero &&
!accountIsHolder)
return tesSUCCESS;
// PaymentBurn requires the source account to be the holder and the destination to be
// the issuer. accountIsHolder: DirectStepI will redeem, not issue.
if (heldGranularPermissions.contains(PaymentBurn) && accountIsHolder)
return tesSUCCESS;
return terNO_DELEGATE_PERMISSION;
});
}
TER

View File

@@ -53,6 +53,7 @@
#include <algorithm>
#include <cstddef>
#include <cstdint>
#include <limits>
#include <memory>
#include <string>
#include <tuple>
@@ -1105,10 +1106,43 @@ class Delegate_test : public beast::unit_test::Suite
env(delegate::set(alice, bob, {"PaymentBurn"}));
env.close();
env(pay(alice, gw, usd(30)), Sendmax(usd(30)), delegate::As(bob));
env(pay(alice, gw, usd(30)), delegate::As(bob));
env.require(Balance(alice, usd(20)));
}
// PaymentBurn is authorized by balance direction, not trust limit.
// holder is allowed to burn even if trust limit is 0.
{
Env env(*this);
Account const alice{"alice"};
Account const bob{"bob"};
Account const gw{"gateway"};
auto const gwUSD = gw["USD"];
auto const aliceUSD = alice["USD"];
env.fund(XRP(10000), alice, bob, gw);
env.trust(gwUSD(200), alice);
env.close();
env(pay(gw, alice, gwUSD(50)));
env.require(Balance(alice, gwUSD(50)));
env.close();
env(delegate::set(alice, bob, {"PaymentBurn"}));
env.close();
env.trust(gwUSD(0), alice);
env.close();
BEAST_EXPECT(env.limit(alice, gwUSD.issue()) == gwUSD(0));
env(trust(gw, aliceUSD(200)));
env.close();
env(pay(alice, gw, gwUSD(30)), delegate::As(bob));
env.require(Balance(alice, gwUSD(20)));
env.require(Balance(gw, aliceUSD(-20)));
}
// Test invalid fields or flags not allowed in granular permission template
{
Env env(*this, features);
@@ -1184,22 +1218,22 @@ class Delegate_test : public beast::unit_test::Suite
mpt.authorize({.account = alice});
mpt.authorize({.account = bob});
auto const MPT = mpt["MPT"]; // NOLINT(readability-identifier-naming)
env(pay(gw, alice, MPT(500)));
env(pay(gw, bob, MPT(500)));
auto const gwMPT = mpt["MPT"];
env(pay(gw, alice, gwMPT(500)));
env(pay(gw, bob, gwMPT(500)));
env.close();
auto aliceMPT = env.balance(alice, MPT);
auto bobMPT = env.balance(bob, MPT);
auto aliceMPT = env.balance(alice, gwMPT);
auto bobMPT = env.balance(bob, gwMPT);
// PaymentMint
{
env(delegate::set(gw, bob, {"PaymentMint"}));
env.close();
env(pay(gw, alice, MPT(50)), delegate::As(bob));
BEAST_EXPECT(env.balance(alice, MPT) == aliceMPT + MPT(50));
BEAST_EXPECT(env.balance(bob, MPT) == bobMPT);
aliceMPT = env.balance(alice, MPT);
env(pay(gw, alice, gwMPT(50)), delegate::As(bob));
BEAST_EXPECT(env.balance(alice, gwMPT) == aliceMPT + gwMPT(50));
BEAST_EXPECT(env.balance(bob, gwMPT) == bobMPT);
aliceMPT = env.balance(alice, gwMPT);
}
// PaymentBurn
@@ -1207,26 +1241,235 @@ class Delegate_test : public beast::unit_test::Suite
env(delegate::set(alice, bob, {"PaymentBurn"}));
env.close();
env(pay(alice, gw, MPT(50)), delegate::As(bob));
BEAST_EXPECT(env.balance(alice, MPT) == aliceMPT - MPT(50));
BEAST_EXPECT(env.balance(bob, MPT) == bobMPT);
aliceMPT = env.balance(alice, MPT);
env(pay(alice, gw, gwMPT(50)), delegate::As(bob));
BEAST_EXPECT(env.balance(alice, gwMPT) == aliceMPT - gwMPT(50));
BEAST_EXPECT(env.balance(bob, gwMPT) == bobMPT);
aliceMPT = env.balance(alice, gwMPT);
}
// Grant both granular permissions and tx level permission.
{
env(delegate::set(alice, bob, {"PaymentBurn", "PaymentMint", "Payment"}));
env.close();
env(pay(alice, gw, MPT(50)), delegate::As(bob));
BEAST_EXPECT(env.balance(alice, MPT) == aliceMPT - MPT(50));
BEAST_EXPECT(env.balance(bob, MPT) == bobMPT);
aliceMPT = env.balance(alice, MPT);
env(pay(alice, bob, MPT(100)), delegate::As(bob));
BEAST_EXPECT(env.balance(alice, MPT) == aliceMPT - MPT(100));
BEAST_EXPECT(env.balance(bob, MPT) == bobMPT + MPT(100));
env(pay(alice, gw, gwMPT(50)), delegate::As(bob));
BEAST_EXPECT(env.balance(alice, gwMPT) == aliceMPT - gwMPT(50));
BEAST_EXPECT(env.balance(bob, gwMPT) == bobMPT);
aliceMPT = env.balance(alice, gwMPT);
env(pay(alice, bob, gwMPT(100)), delegate::As(bob));
BEAST_EXPECT(env.balance(alice, gwMPT) == aliceMPT - gwMPT(100));
BEAST_EXPECT(env.balance(bob, gwMPT) == bobMPT + gwMPT(100));
}
}
// PaymentMint/PaymentBurn must not trust IOU issuer aliases.
// In a direct IOU payment, sfAmount.issuer may be encoded as either
// endpoint, and PaySteps normalizes those aliases to the same execution.
// These cases ensure a delegate cannot flip the encoded issuer to turn a
// mint into an apparent burn, or a burn into an apparent mint.
{
Env env(*this);
Account const alice{"alice"};
Account const bob{"bob"};
Account const gw{"gateway"};
auto const gwUSD = gw["USD"];
auto const aliceUSD = alice["USD"];
env.fund(XRP(10000), alice, bob, gw);
env.trust(gwUSD(200), alice);
env.close();
// Alice holds 100 USD issued by gw.
env(pay(gw, alice, gwUSD(100)));
env.close();
env.require(Balance(alice, gwUSD(100)));
// Delegate with only PaymentBurn tries to mint by encoding
// Amount.issuer as the destination alias, alice. The actual issuer
// is gw, so this requires PaymentMint and must be rejected.
{
env(delegate::set(gw, bob, {"PaymentBurn"}));
env.close();
// Amount.issuer = alice (destination), rejected because gw is
// the actual issuer and PaymentMint is required.
env(pay(gw, alice, aliceUSD(50)),
delegate::As(bob),
Ter(terNO_DELEGATE_PERMISSION));
env.require(Balance(alice, gwUSD(100)));
// Fails because bob holds PaymentBurn, not PaymentMint.
env(pay(gw, alice, gwUSD(50)), delegate::As(bob), Ter(terNO_DELEGATE_PERMISSION));
env.require(Balance(alice, gwUSD(100)));
}
// Delegate with only PaymentMint tries to burn by encoding
// Amount.issuer as the source alias, alice. The actual issuer is
// gw, so this requires PaymentBurn and must be rejected.
{
env(delegate::set(alice, bob, {"PaymentMint"}));
env.close();
// Amount.issuer = alice (account), rejected because gw is the
// actual issuer and PaymentBurn is required.
env(pay(alice, gw, aliceUSD(50)),
delegate::As(bob),
Ter(terNO_DELEGATE_PERMISSION));
env.require(Balance(alice, gwUSD(100)));
// Fails because bob holds PaymentMint, not PaymentBurn.
env(pay(alice, gw, gwUSD(50)), delegate::As(bob), Ter(terNO_DELEGATE_PERMISSION));
env.require(Balance(alice, gwUSD(100)));
}
}
// Neither account nor destination is issuer.
// PaymentMint and PaymentBurn do not authorize these payments.
{
// IOU
{
Env env(*this);
Account const alice{"alice"};
Account const bob{"bob"};
Account const gw{"gateway"};
Account const gw2{"gateway2"};
auto const gwUSD = gw["USD"];
env.fund(XRP(10000), alice, bob, gw, gw2);
env.close();
env.trust(gwUSD(200), alice);
env.close();
env(pay(gw, alice, gwUSD(100)));
env.close();
env(delegate::set(alice, bob, {"PaymentMint", "PaymentBurn"}));
env.close();
env(pay(alice, gw, gw2["USD"](50)),
delegate::As(bob),
Ter(terNO_DELEGATE_PERMISSION));
}
// MPT
{
Env env(*this, features);
Account const alice{"alice"};
Account const bob{"bob"};
Account const gw{"gateway"};
Account const gw2{"gateway2"};
env.fund(XRP(10000), gw2);
env.close();
MPTTester mpt(env, gw, {.holders = {alice, bob}});
mpt.create({.ownerCount = 1, .flags = tfMPTCanTransfer});
mpt.authorize({.account = alice});
mpt.authorize({.account = bob});
auto const gwMPT = mpt["MPT"];
env(pay(gw, alice, gwMPT(500)));
env(pay(gw, bob, gwMPT(500)));
env.close();
env(delegate::set(alice, bob, {"PaymentMint", "PaymentBurn"}));
env.close();
env(pay(alice, gw2, gwMPT(50)), delegate::As(bob), Ter(terNO_DELEGATE_PERMISSION));
}
}
// IOU issuer is an endpoint, but no trustline exists.
{
Env env(*this);
Account const alice{"alice"};
Account const bob{"bob"};
Account const gw{"gateway"};
env.fund(XRP(10000), alice, bob, gw);
env.close();
env(delegate::set(alice, bob, {"PaymentMint", "PaymentBurn"}));
env.close();
env(pay(alice, gw, alice["USD"](50)),
delegate::As(bob),
Ter(terNO_DELEGATE_PERMISSION));
}
// Both trust limits (who is the designated issuer) and balance direction
// (which way DirectStepI executes) must be checked. Neither alone is sufficient.
{
Env env(*this);
Account const alice{"alice"};
Account const bob{"bob"};
Account const gw{"gateway"};
auto const gwUSD = gw["USD"];
auto const aliceUSD = alice["USD"];
env.fund(XRP(10000), alice, bob, gw);
// Alice trusts gw but holds zero gw-issued USD. With balance == 0,
// DirectStepI would issue rather than redeem, so PaymentBurn must
// be rejected even though Alice's trust limit to gw is positive.
{
env.trust(gwUSD(200), alice);
env.close();
// Alice has nothing to burn.
env(delegate::set(alice, bob, {"PaymentBurn"}));
env.close();
env(pay(alice, gw, gwUSD(50)), delegate::As(bob), Ter(terNO_DELEGATE_PERMISSION));
env(pay(alice, gw, aliceUSD(50)),
delegate::As(bob),
Ter(terNO_DELEGATE_PERMISSION));
}
// Set up a trust line where gw holds alice-issued USD. DirectStepI
// would redeem rather than issue, so PaymentMint must be rejected
// even though the endpoint identity matches.
{
// Gw sets trust to accept alice-issued USD.
env(trust(gw, aliceUSD(200)));
env.close();
// Alice issues her own USD to gw; now gw holds alice's IOUs.
env(pay(alice, gw, aliceUSD(100)));
env.close();
// In gw's view, accountHolds(gw, USD, alice) > 0, so DirectStepI redeems.
// PaymentMint must be rejected because the step would redeem, not issue.
env(delegate::set(gw, bob, {"PaymentMint"}));
env.close();
env(pay(gw, alice, gwUSD(50)), delegate::As(bob), Ter(terNO_DELEGATE_PERMISSION));
env(pay(gw, alice, aliceUSD(50)),
delegate::As(bob),
Ter(terNO_DELEGATE_PERMISSION));
}
}
// Alice trusts gw but gw is not willing to hold alice's IOU (destLimit == 0).
{
Env env(*this);
Account const alice{"alice"};
Account const bob{"bob"};
Account const gw{"gateway"};
env.fund(XRP(10000), alice, bob, gw);
env.trust(gw["USD"](200), alice);
env.close();
env(delegate::set(alice, bob, {"PaymentMint"}));
env.close();
env(pay(alice, gw, gw["USD"](50)), delegate::As(bob), Ter(terNO_DELEGATE_PERMISSION));
env(pay(alice, gw, alice["USD"](50)),
delegate::As(bob),
Ter(terNO_DELEGATE_PERMISSION));
}
// Verify granular permissions of different tx types in the same SLE are scoped
// correctly. AccountSet permissions don't apply to Payment and vice versa
{
@@ -2617,6 +2860,38 @@ class Delegate_test : public beast::unit_test::Suite
BEAST_EXPECT(granularPermissions.empty());
}
void
testPermissionToTxType()
{
testcase("test Permission to Tx type");
// 0 is not a valid permission value
BEAST_EXPECT(!Permission::permissionToTxType(0));
// 1 maps to Payment transaction
BEAST_EXPECT(Permission::permissionToTxType(1) == ttPAYMENT);
// UINT16_MAX+1 is the maximum possible tx-level permission value
constexpr uint32_t maxTxPermission = std::numeric_limits<uint16_t>::max() + 1u;
BEAST_EXPECT(Permission::permissionToTxType(maxTxPermission).has_value());
// exceeding maximum value should return nullopt
BEAST_EXPECT(!Permission::permissionToTxType(maxTxPermission + 1));
// All granular permission values should return nullopt since they do not map to a TxType.
for (auto const gp : {
#pragma push_macro("GRANULAR_PERMISSION")
#undef GRANULAR_PERMISSION
#define GRANULAR_PERMISSION(type, txType, value, ...) GranularPermissionType::type,
#include <xrpl/protocol/detail/permissions.macro>
#undef GRANULAR_PERMISSION
#pragma pop_macro("GRANULAR_PERMISSION")
})
{
BEAST_EXPECT(!Permission::permissionToTxType(static_cast<uint32_t>(gp)));
}
}
void
run() override
{
@@ -2647,6 +2922,7 @@ class Delegate_test : public beast::unit_test::Suite
testTxDelegableCount();
testNonDelegableTxWithDelegate(all);
testDelegateUtilsNullptrCheck();
testPermissionToTxType();
}
};
BEAST_DEFINE_TESTSUITE(Delegate, app, xrpl);