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

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