fix: Reject PaymentBurn payments that cross zero balance

This commit is contained in:
yinyiqian1
2026-09-14 17:24:15 -04:00
committed by Bart
parent 00eeb0a005
commit c8e767afa4
2 changed files with 105 additions and 3 deletions

View File

@@ -339,17 +339,36 @@ Payment::checkGranularSemantics(
bool const accountIsHolder =
accountIsLow ? rawBalance > beast::kZero : rawBalance < beast::kZero;
bool const mayIssue =
heldGranularPermissions.contains(PaymentMint) && destLimit > 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)
if (mayIssue && !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;
{
if (view.rules().enabled(fixCleanup3_4_0))
{
// Redeeming stops at the balance held; beyond that the payment engine
// crosses zero and issues the account's own IOUs, which is a mint. So with
// only PaymentBurn we must check the amount against the balance held. The
// granular template forbids sfPaths, tfPartialPayment and a cross-asset
// sfSendMax, so this is a single direct step, sfAmount is what the
// trustline is debited.
STAmount const held = accountIsLow ? rawBalance : -rawBalance;
if (dstAmount <= held || mayIssue)
return tesSUCCESS;
}
else
{
return tesSUCCESS;
}
}
return terNO_DELEGATE_PERMISSION;
});

View File

@@ -1144,6 +1144,88 @@ class Delegate_test : public beast::unit_test::Suite
env.require(Balance(gw, aliceUSD(-20)));
}
// PaymentBurn must not exceed the balance the account holds. Redeeming past
// zero makes the payment engine issue the account's own IOUs, which is a mint.
{
Env env(*this, features);
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.close();
env.require(Balance(alice, gwUSD(50)));
// gw accepts alice-issued USD, so the engine has issuing liquidity
// available once the trustline reaches zero.
env(trust(gw, aliceUSD(200)));
env.close();
env(delegate::set(alice, bob, {"PaymentBurn"}));
env.close();
if (!features[fixCleanup3_4_0])
{
// Pre-fixCleanup3_4_0: the balance direction alone authorizes the payment, so it
// redeems alice's 50 and then mints 50 alice-issued USD.
env(pay(alice, gw, gwUSD(100)), delegate::As(bob));
env.require(Balance(alice, gwUSD(-50)));
env.require(Balance(gw, aliceUSD(50)));
}
else
{
// Post-fixCleanup3_4_0: Rejected because it exceeds what alice holds.
env(pay(alice, gw, gwUSD(100)), delegate::As(bob), Ter(terNO_DELEGATE_PERMISSION));
env.require(Balance(alice, gwUSD(50)));
env.require(Balance(gw, aliceUSD(-50)));
// Allowed because it is less than what alice holds.
env(pay(alice, gw, gwUSD(20)), delegate::As(bob));
env.require(Balance(alice, gwUSD(30)));
env.close();
// Exactly what alice holds: allowed, and settles at zero.
env(pay(alice, gw, gwUSD(30)), delegate::As(bob));
env.require(Balance(alice, gwUSD(0)));
env.close();
// Nothing left to burn: rejected.
env(pay(alice, gw, gwUSD(1)), delegate::As(bob), Ter(terNO_DELEGATE_PERMISSION));
env.require(Balance(gw, aliceUSD(0)));
}
}
// A delegate holding both PaymentMint and PaymentBurn may cross zero.
{
Env env(*this, features);
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(trust(gw, aliceUSD(200)));
env.close();
env(delegate::set(alice, bob, {"PaymentBurn", "PaymentMint"}));
env.close();
env(pay(alice, gw, gwUSD(100)), delegate::As(bob));
env.require(Balance(alice, gwUSD(-50)));
env.require(Balance(gw, aliceUSD(50)));
}
// Test invalid fields or flags not allowed in granular permission template
{
Env env(*this, features);
@@ -2916,6 +2998,7 @@ class Delegate_test : public beast::unit_test::Suite
testAccountDelete();
testDelegateTransaction();
testPaymentGranular(all);
testPaymentGranular(all - fixCleanup3_4_0);
testTrustSetGranular();
testAccountSetGranular();
testMPTokenIssuanceSetGranular();