Skip to content

Commit 7728b68

Browse files
Merge pull request #1468 from galacticcouncil/fix/signed-permit-weight-nonce
fix: signed dispatch_permit weight refund + nonce consumption on revert
2 parents 84eaa13 + abd1dbc commit 7728b68

2 files changed

Lines changed: 42 additions & 34 deletions

File tree

integration-tests/src/evm_permit.rs

Lines changed: 32 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -3126,7 +3126,7 @@ mod sponsored_paymaster {
31263126
}
31273127

31283128
#[test]
3129-
fn signed_dispatch_permit_should_fail_with_call_execution_error_when_inner_call_reverts_and_not_pause() {
3129+
fn signed_dispatch_permit_should_commit_and_consume_nonce_when_inner_call_reverts() {
31303130
TestNet::reset();
31313131

31323132
Hydra::execute_with(|| {
@@ -3163,10 +3163,13 @@ mod sponsored_paymaster {
31633163
s,
31643164
);
31653165

3166-
let err = result.expect_err("inner-call revert must produce an error");
3166+
// Inner call reverts, but the signed branch commits like the unsigned
3167+
// path: the extrinsic returns Ok and the permit nonce is consumed.
3168+
assert_ok!(result);
31673169
assert_eq!(
3168-
err.error,
3169-
pallet_transaction_multi_payment::Error::<Runtime>::EvmPermitCallExecutionError.into(),
3170+
pallet_evm_precompile_call_permit::NoncesStorage::get(from),
3171+
U256::one(),
3172+
"revert must still consume the permit nonce",
31703173
);
31713174
assert_dispatch_permit_not_paused();
31723175
});
@@ -3472,7 +3475,7 @@ mod sponsored_paymaster {
34723475
}
34733476

34743477
#[test]
3475-
fn signed_dispatch_permit_should_restore_previous_fee_payer_override_on_real_run_failure() {
3478+
fn signed_dispatch_permit_should_restore_previous_fee_payer_override_on_real_run_revert() {
34763479
TestNet::reset();
34773480

34783481
Hydra::execute_with(|| {
@@ -3511,12 +3514,13 @@ mod sponsored_paymaster {
35113514
r,
35123515
s,
35133516
);
3514-
assert!(result.is_err());
3517+
// Revert now commits (Ok); the override is still restored to the outer value.
3518+
assert_ok!(result);
35153519

35163520
assert_eq!(
35173521
hydradx_runtime::evm::evm_fee_payer(),
35183522
Some(outer),
3519-
"outer override must survive the early-return path",
3523+
"outer override must be restored after the revert-commit path",
35203524
);
35213525

35223526
hydradx_runtime::evm::clear_evm_fee_payer();
@@ -3592,15 +3596,13 @@ mod sponsored_paymaster {
35923596
});
35933597
}
35943598

3595-
// Signed-branch divergence from unsigned: when the inner call reverts,
3596-
// `do_dispatch_permit_signed` returns Err. FRAME wraps every dispatchable
3597-
// in a storage layer, so the Err rolls back the `NoncesStorage` increment
3598-
// that `T::EvmPermit::dispatch_permit` performed before checking
3599-
// `exit_reason`. Net effect: the user keeps their permit nonce and can
3600-
// re-submit the same signed payload (unsigned path does NOT roll back
3601-
// and therefore consumes the nonce on revert).
3599+
// Signed branch matches the unsigned path on revert: when the inner call
3600+
// reverts, `do_dispatch_permit_signed` returns Ok(post_info), committing the
3601+
// `NoncesStorage` increment that `T::EvmPermit::dispatch_permit` performed
3602+
// before checking `exit_reason`. Net effect: the permit nonce is consumed,
3603+
// so the same signed payload cannot be replayed.
36023604
#[test]
3603-
fn adversarial_signed_dispatch_permit_should_leave_permit_nonce_unchanged_on_real_run_revert() {
3605+
fn signed_dispatch_permit_should_consume_permit_nonce_on_real_run_revert() {
36043606
TestNet::reset();
36053607

36063608
Hydra::execute_with(|| {
@@ -3639,23 +3641,24 @@ mod sponsored_paymaster {
36393641
r,
36403642
s,
36413643
);
3642-
assert!(result.is_err());
3644+
assert_ok!(result);
36433645

36443646
let nonce_after = pallet_evm_precompile_call_permit::NoncesStorage::get(alith);
36453647
assert_eq!(
3646-
nonce_after, nonce_before,
3647-
"call-permit nonce MUST NOT advance — Err rolls back the runner's nonce bump"
3648+
nonce_after,
3649+
nonce_before + U256::one(),
3650+
"call-permit nonce MUST advance — Ok commits the runner's nonce bump"
36483651
);
36493652
assert_dispatch_permit_not_paused();
36503653
});
36513654
}
36523655

3653-
// Failed signed dispatches must cost the signer full declared weight (no
3654-
// custom refund). This is an anti-grief property: an attacker spamming
3655-
// invalid permits pays the same as a legitimate paymaster spamming valid
3656-
// ones, so griefing is uniformly expensive.
3656+
// A RunnerError (account never charged) is the one path the signed branch
3657+
// still fails hard on: it returns Err with default post info, costing the
3658+
// signer full declared weight (no refund). Anti-grief — spamming permits the
3659+
// runner rejects is as expensive as any legitimate sponsored dispatch.
36573660
#[test]
3658-
fn signed_dispatch_permit_should_use_default_post_info_on_real_run_failure() {
3661+
fn signed_dispatch_permit_should_use_default_post_info_on_runner_error() {
36593662
TestNet::reset();
36603663

36613664
Hydra::execute_with(|| {
@@ -3674,10 +3677,12 @@ mod sponsored_paymaster {
36743677
asset_in: HDX,
36753678
asset_out: DAI,
36763679
amount: 1_000_000_000,
3677-
min_buy_amount: u128::MAX,
3680+
min_buy_amount: 0,
36783681
});
3682+
// Sign for u64::MAX gas so the permit validates, then the runner rejects
3683+
// it (gas_limit exceeds the block gas limit) → EvmPermitRunnerError.
36793684
let (from, data, gas_limit, deadline, v, r, s) =
3680-
build_permit_for_call(&inner_call, 1_000_000, U256::from(1_000_000_000_000u128));
3685+
build_permit_for_call(&inner_call, u64::MAX, U256::from(1_000_000_000_000u128));
36813686

36823687
let result = MultiTransactionPayment::dispatch_permit(
36833688
RuntimeOrigin::signed(paymaster),
@@ -3692,10 +3697,10 @@ mod sponsored_paymaster {
36923697
s,
36933698
);
36943699

3695-
let err = result.expect_err("expected real-run failure");
3700+
let err = result.expect_err("expected runner error");
36963701
assert_eq!(
36973702
err.error,
3698-
pallet_transaction_multi_payment::Error::<Runtime>::EvmPermitCallExecutionError.into(),
3703+
pallet_transaction_multi_payment::Error::<Runtime>::EvmPermitRunnerError.into(),
36993704
);
37003705
// actual_weight = None → SignedExtension uses declared weight, no refund.
37013706
assert_eq!(err.post_info.actual_weight, None);

pallets/transaction-multi-payment/src/lib.rs

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -493,18 +493,21 @@ pub mod pallet {
493493
Self::restore_fee_payer(previous_fee_payer);
494494

495495
match result {
496-
Ok(_) => {
496+
Ok(post) => {
497497
Self::deposit_event(Event::<T>::FeeSponsored {
498498
from,
499499
fee_payer: signer,
500500
});
501-
Ok(().into())
501+
// Forward actual weight/`Pays::No`: charge gas used, not `gas_limit`.
502+
Ok(post)
502503
}
503-
// Signed branch must NEVER call `on_dispatch_permit_error()`:
504-
// autopause is the unsigned-path defense against free mempool
505-
// grief; on the signed path the paymaster pays the extrinsic
506-
// fee per attempt, so there is no cheap-DOS to defend against.
507-
Err(e) => Err(e.error.into()),
504+
// RunnerError: nothing charged. Keep Err (`Pays::Yes`) so the paymaster
505+
// still pays the extrinsic fee — the per-attempt cost that replaces the
506+
// unsigned-path autopause. Never call `on_dispatch_permit_error()` here.
507+
Err(e) if e.error == Error::<T>::EvmPermitRunnerError.into() => Err(e.error.into()),
508+
// Revert already consumed the nonce and charged gas; commit it like the
509+
// unsigned path. Returning Err would roll the nonce back → replayable.
510+
Err(e) => Ok(e.post_info),
508511
}
509512
}
510513

0 commit comments

Comments
 (0)