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:
- 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."
- 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)
-
Substrate-side fee charged to the signer on the signed branch. Already works on the existing dispatch_permit when submitted signed (see receipts above).
-
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.
-
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.
-
Nonce on the call-permit precompile still advances against from on a successful dispatch (either branch).
-
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.)
-
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.
-
(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:
permit.validate() — drops permits with bad signature / wrong nonce / expired deadline.
- 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
- 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?
- 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
Summary
Extend
multiTransactionPayment::dispatch_permitto accept both unsigned and signed origin. Today the call is gated byensure_none(origin)— submitting it signed returnsBadOrigin. We want a signed branch where the extrinsic signer (a paymaster account) pays the full cost of the dispatch, leavingpermit.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:
The signed-origin branch on
dispatch_permitis 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:
Empirical evidence (lark.2,
hydradx@420)Three runs submitting
multiTransactionPayment::dispatch_permitas a signed extrinsic from//Alice, withpermit.frombeing a distinct EVM key.permit.fromstateBadOriginBadOriginBadOriginWeight 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_permitto branch on origin. Sketch (illustration of intent, not a final patch shape):Behavioural requirements (in priority order)
Substrate-side fee charged to the signer on the signed branch. Already works on the existing
dispatch_permitwhen submitted signed (see receipts above).If Frontier's EVM-side precompile gas is billed as a separate debit against
permit.from(rather than rolled into the Substrate extrinsic weight ondispatch_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.EIP-712 verification against
fromis unchanged on both branches. The user's authorization for the action still comes from their EIP-712 signature; the signer only pays.Nonce on the call-permit precompile still advances against
fromon a successful dispatch (either branch).Replicate both
validate_unsignedguards 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:from, nonce match,deadline > now. On failure →Error::InvalidPermit.validate_unsigneddoes 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 unsigneddispatch_permitfor 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.)
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.(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_permitif anything reaches block execution that "shouldn't" have made it past the unsigned tx pool. Today that pool runs two guards invalidate_unsignedbefore letting a tx into a block:permit.validate()— drops permits with bad signature / wrong nonce / expired deadline.A naive signed
dispatch_permitskipsvalidate_unsignedentirely, so without inline replication of both checks, two distinct DOS vectors open up against the unsigned consumer of the same call:dispatch_permitis paused for everyone.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):
Two complementary negative tests — one per autopause-protection guard:
Out of scope (to keep the ask tight)
dispatch_permitbehaviour —validate_unsigned, weight, dispatch path, autopause semantics all preserved for backwards compatibility.accountCurrencyMapselects, the same way any signed extrinsic's fee is decided today.Open questions
dispatch_permitdispatchable routes into?dispatch_permit, is the precompile gas (call-permit signature verification + the inner call into the substrate-dispatch precompile at0x...0401) already rolled into the Substrate extrinsic weight, or does Frontier charge it separately againstpermit.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