Skip to content

pallet_multi_transaction_payment: accept signed origin on dispatch_permit (paymaster fee-payer routing) #1458

Description

@acyberduck

Summary

Extend multiTransactionPayment::dispatch_permit to accept both unsigned and signed origin. Today the call is gated by ensure_none(origin) — submitting it signed returns BadOrigin. We want a signed branch where the extrinsic signer (a paymaster account) pays the full cost of the dispatch, leaving permit.from's balance untouched.

The "full cost" question is where we'd love your input: our spike already showed the Substrate extrinsic fee routes cleanly to the signer (0.75 HDX charged to Alice). What we couldn't measure — because the dispatch failed at the origin check before reaching the EVM runner — is whether Frontier's EVM-side precompile gas is already rolled into that Substrate weight, or whether it's a separate debit against permit.from. If it's separate, the signed branch needs to redirect that too. See requirement #2 and open question #2 below.

mrq confirmed on Discord (2026-05-16) that this is the standard pattern for permits — "someone has to execute them for you (pay for that execution)." Martin (2026-05-15) suggested filing this as a spec, which is what this issue is.

Motivation

Northfound (a Galactic Council product vertical) is building a treasury dashboard for businesses on Turnkey MPC. Users onboard with USDC/USDT on EVM chains and increasingly deploy capital into Hydration — pools, omnipool routes, intra-product transfers, cross-chain settlement legs.

These users structurally don't hold HDX at the moment of their first Hydration action. Turnkey has confirmed they will not extend their hosted sponsored-gas relayer to Hydration, so Northfound needs to run its own paymaster. The product guarantee we want to make across every Hydration action:

A user who has never held HDX can complete their first Hydration action, and a user who DOES hold HDX never has their HDX debited as gas by a sponsored action.

The signed-origin branch on dispatch_permit is the minimal runtime primitive that makes this possible.

Why not client-side workarounds

We considered and rejected two alternatives that don't require any runtime work:

  1. Just-in-time HDX top-up. Transfer HDX to the user's substrate account before dispatching the permit. Rejected because Substrate balance is fungible — a user who already holds HDX would have their own balance drawn down by the runtime's fee debit, regardless of where the top-up came from. We can't tag balances "paymaster HDX" vs "user HDX."
  2. Custom paymaster pallet. Cleanest end state, but materially more scope than extending the existing extrinsic. The proposal here is the smallest change that unblocks Northfound; a fuller pallet is worth revisiting later if production patterns warrant it.

Empirical evidence (lark.2, hydradx@420)

Three runs submitting multiTransactionPayment::dispatch_permit as a signed extrinsic from //Alice, with permit.from being a distinct EVM key.

Run permit.from state Outcome Paymaster fee User Δ
Funded user (1) 150 HDX, providers=1 BadOrigin 0.751736 HDX 0
Zero-HDX user 0 HDX, providers=0 BadOrigin 0.751736 HDX 0
Funded user (2, decisive) 150 HDX, providers=1 BadOrigin 0.751736 HDX 0

Weight bit-identical across runs: refTime 8,448,585,000 / proofSize 16,475.

The receipts show the runtime already emits the full fee-accounting event chain against the signer:

  • balances.Withdraw { who: paymaster, amount: 0.751736 HDX }
  • currencies.Withdrawn { currencyId: 0, who: paymaster, ... }
  • balances.Deposit { who: treasury, ... } (13UVJyLnbVp9RBZYFwFGyDvVd1y27Tt8tkntv6Q7JVPhFsTB)
  • transactionPayment.TransactionFeePaid { who: paymaster, actualFee: 0.751736 HDX, tip: 0 }
  • system.ExtrinsicFailed { dispatchError: BadOrigin }

The Substrate-side fee already routes to the signer cleanly. The dispatch itself never executes because the call body's origin check rejects signed origin. Reference tx hashes (lark.2): 0x88e5...98b5, 0x1795...e652, 0x9ab0...8196.

Reproducer scripts: https://github.com/acyberduck/Northfound/tree/claude/hydration-paymaster-spike/scripts/hydration-paymaster-spike

Proposed change

Extend dispatch_permit to branch on origin. Sketch (illustration of intent, not a final patch shape):

#[pallet::call_index(N)]
#[pallet::weight(/* unchanged for unsigned; new weight for signed */)]
pub fn dispatch_permit(
    origin: OriginFor<T>,
    from: H160,
    to: H160,
    value: U256,
    data: Vec<u8>,
    gaslimit: u64,
    deadline: U256,
    v: u8,
    r: H256,
    s: H256,
) -> DispatchResultWithPostInfo {
    match ensure_signed_or_none(origin)? {
        Some(fee_payer) => {
            // SIGNED branch (new): paymaster pays.
            // validate_unsigned did NOT run — replicate BOTH of its
            // guards inline so an invalid permit (5a) or a reverting
            // inner call (5b) doesn't fire the autopause tripwire.

            // 5a — permit pre-validation
            Self::validate_permit_inline(
                &from, &to, value, &data, gaslimit, deadline, v, r, s,
            )
                .map_err(|_| Error::<T>::InvalidPermit)?;

            // 5b — inner-call dry-run (same simulation validate_unsigned
            //      does today before letting an unsigned tx into a block).
            Self::dry_run_inner_call(&from, &to, value, &data, gaslimit)
                .map_err(|_| Error::<T>::InnerCallWouldFail)?;

            // Substrate extrinsic fee already routes to `fee_payer`
            // (extrinsic signer). If the EVM-runner-side precompile gas
            // is also a separately-debited charge on `permit.from`,
            // redirect it to `fee_payer` here.
            Self::dispatch_with_fee_payer(
                from, to, value, data, gaslimit, v, r, s, fee_payer,
            )
        }
        None => {
            // UNSIGNED branch (existing): unchanged.
            // validate_unsigned ran permit.validate() at the pool; if we
            // reach here with an invalid permit, the autopause tripwire
            // fires per existing behaviour.
            Self::dispatch_unsigned_legacy(
                from, to, value, data, gaslimit, deadline, v, r, s,
            )
        }
    }
}

Behavioural requirements (in priority order)

  1. Substrate-side fee charged to the signer on the signed branch. Already works on the existing dispatch_permit when submitted signed (see receipts above).

  2. If Frontier's EVM-side precompile gas is billed as a separate debit against permit.from (rather than rolled into the Substrate extrinsic weight on dispatch_permit) — please confirm which — that charge must also route to the signer. We couldn't determine this empirically because the dispatch failed at the origin check before the EVM runner ran. The user-untouched guarantee depends on this answer; the proposal stands either way, only the implementation detail changes.

  3. EIP-712 verification against from is unchanged on both branches. The user's authorization for the action still comes from their EIP-712 signature; the signer only pays.

  4. Nonce on the call-permit precompile still advances against from on a successful dispatch (either branch).

  5. Replicate both validate_unsigned guards inline on the signed branch, before invoking EvmRunner. The unsigned path drops bad txs at the pool with two checks; signed submission skips both, so to keep the autopause's "shouldn't happen at execution" semantics intact, the signed branch must do the same checks itself:

    • 5a — Permit pre-validation. EIP-712 hash recovery against from, nonce match, deadline > now. On failure → Error::InvalidPermit.
    • 5b — Inner-call dry-run. Simulate the EVM call (the same dry-run validate_unsigned does today) before the real EvmRunner runs. On revert / OOG / EvmRunner error → Error::InnerCallWouldFail. (Raised by @dmoka on Discord — without this, a valid permit with a deliberately-reverting inner call would cheap-DOS the unsigned dispatch_permit for everyone.)

    Both errors return without firing the autopause. The real EvmRunner only ever sees calls that have already passed both checks. (See "Autopause compatibility" below.)

  6. Unsigned branch is unchanged. validate_unsigned, weight, dispatch path, and autopause semantics on the unsigned branch are exactly as today. Existing consumers of the unsigned form (relays, tooling) keep working without modification.

  7. (Nice-to-have) A new event such as multiTransactionPayment.FeeSponsored { from, fee_payer, fee_amount } emitted on the signed branch so a paymaster operator's off-chain ledger can match against the on-chain trail by event rather than by tx-history inspection.

Autopause compatibility (requirement #5 explained)

Hydration runs a safety mechanism that automatically pauses dispatch_permit if anything reaches block execution that "shouldn't" have made it past the unsigned tx pool. Today that pool runs two guards in validate_unsigned before letting a tx into a block:

  1. permit.validate() — drops permits with bad signature / wrong nonce / expired deadline.
  2. Inner-call dry-run — drops permits whose inner EVM call would revert / OOG.

A naive signed dispatch_permit skips validate_unsigned entirely, so without inline replication of both checks, two distinct DOS vectors open up against the unsigned consumer of the same call:

  • (a) Invalid permit DOS — attacker submits a signed tx with a bad permit. Permit fails at execution → autopause trips → unsigned dispatch_permit is paused for everyone.
  • (b) Reverting inner call DOS (raised by @dmoka) — attacker submits a signed tx with a valid permit but an inner call crafted to revert (any contract that always reverts works). EvmRunner errors at execution → autopause trips → same result.

Both attacks cost the attacker only the Substrate extrinsic fee per attempt. Without the inline guards, sponsorship as a feature would be a DOS amplifier on the rest of Hydration's existing relayer ecosystem.

Requirement #5's two inline guards close both. The autopause's "shouldn't happen at execution" semantics on the unsigned path stay exactly as today — that's the tripwire it was designed for, and we don't want to change what trips it.

Acceptance test

A single integration test against a Chopsticks-forked Hydration runtime (Northfound is happy to provide the harness):

Pre-state: paymaster account holds 100 HDX. permit.from holds 100 HDX (or 0 HDX — both should work). Permit is well-formed, signed by permit.from's EVM key, with a non-trivial inner call (e.g. an omnipool swap or a pool deposit).

Action: paymaster submits dispatch_permit SIGNED with their account.

Post-state: the inner call executed (asset balances reflect the swap or deposit). permit.from's HDX balance is bit-for-bit unchanged versus pre-state. Paymaster's HDX balance is reduced by the total cost of the dispatch (Substrate extrinsic fee plus any EVM-runner debit, per the answer to open question #2).

Two complementary negative tests — one per autopause-protection guard:

InvalidPermit case. Pre-state: paymaster has HDX, permit is malformed (e.g. signature recovers to a different address, or deadline is in the past). Action: paymaster submits dispatch_permit SIGNED. Post-state: extrinsic returns Error::InvalidPermit. Paymaster paid the fee for the failed extrinsic. autoPause is NOT triggered.

InnerCallWouldFail case (dmoka's scenario). Pre-state: paymaster has HDX, permit is well-formed (valid signature, valid nonce, valid deadline), but the inner EVM call targets a contract that always reverts (or consumes > gaslimit). Action: paymaster submits dispatch_permit SIGNED. Post-state: extrinsic returns Error::InnerCallWouldFail. Paymaster paid the fee for the failed extrinsic. autoPause is NOT triggered — the dry-run caught it before EvmRunner ran for real.

Out of scope (to keep the ask tight)

  • ❌ No change to the existing unsigned dispatch_permit behaviour — validate_unsigned, weight, dispatch path, autopause semantics all preserved for backwards compatibility.
  • ❌ No new fee-payment-asset semantics. The signer pays in whatever asset their accountCurrencyMap selects, the same way any signed extrinsic's fee is decided today.
  • ❌ No origin-allowlist on the signed branch. Anyone willing to pay the fee can submit. Abuse controls live on Northfound's side (per-user / global daily caps, KYB gating, pre-flight simulation, circuit breaker).
  • ❌ No on-chain refund / clawback mechanism. Northfound tracks sponsored amounts and recovers them off-chain at withdrawal time.

Open questions

  1. The two-branch shape above — does that match how you'd want it implemented, or would you rather split out the signed branch into a separate internal helper that the same dispatch_permit dispatchable routes into?
  2. Settling the EVM-side gas question definitively: on dispatch_permit, is the precompile gas (call-permit signature verification + the inner call into the substrate-dispatch precompile at 0x...0401) already rolled into the Substrate extrinsic weight, or does Frontier charge it separately against permit.from's EVM-mapped substrate balance? If the latter, what's the right hook on your side to redirect that charge — OnChargeEVMTransaction, an EvmRunner adjustment, or something else?

References

cc @martin @MRQ

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions