Skip to content

[evm, simplex]: PERMIT2 mode for SimplexPaymaster - #1147

Draft
royvardhan wants to merge 4 commits into
mainfrom
roy/paymaster-permit2
Draft

[evm, simplex]: PERMIT2 mode for SimplexPaymaster#1147
royvardhan wants to merge 4 commits into
mainfrom
roy/paymaster-permit2

Conversation

@royvardhan

@royvardhan royvardhan commented Aug 18, 2026

Copy link
Copy Markdown
Collaborator

Adds mode 0x02 PERMIT2 to SimplexPaymaster: the prefund is pulled through Permit2 permitTransferFrom (owner = userOp.sender, spender = paymaster, single-use nonce, deadline surfaced as validUntil), so tokens without EIP-2612 need one lifetime approve(Permit2, max) instead of a capped allowance to the paymaster refilled with native-funded approvals. No storage changes; the live proxies remain upgradeable.

Client (simplex 0.10.0): selection is PERMIT, then PERMIT2 when the token is approved to Permit2, then APPROVE while a legacy allowance lasts, then a one-time approve(Permit2, max) bootstrap. Mode 2 is only emitted against deployments that expose PERMIT2(), so it is safe before the redeploy lands. forceApproveMode became skipPermit.

ERC-7562: the anticipated blocker

Bundler validation rules only let a paymaster write storage associated with the sender or with itself. Permit2's nonceBitmap[owner][word] and the token's allowance[owner][Permit2] are neither (nested mappings hash the owner on the inner level), so a spec-enforcing bundler could reject mode 2 at eth_sendUserOperation no matter how correct the contract is, and staking cannot fix it. Permit2 also executes TIMESTAMP for the deadline check.

Why it was expected to work anyway: this paymaster already reads block.timestamp (equally banned) in _getOraclePrice in every mode and is live through Alchemy on BSC, so the bundlers in use are not running the ERC-7562 tracer. Fork tests could not settle this (they execute the EVM, not bundler policy), so it was probed live: a fresh SimplexPaymaster on Base Sepolia (evm/script/SimplexPaymasterPermit2Probe.s.sol) with Alchemy's bundler accepted mode 2 for both a fresh EOA (Permit2 ecrecover path) and a delegated account (ERC-1271 path through SolverAccount), with Permit2Executed landing on-chain. Pimlico was not probed. If a bundler ever rejects mode 2, PERMIT/APPROVE remain intact and the client can fall back to refilling the APPROVE allowance inside sponsored ops.

The probe also surfaced that the first op right after a one-confirmation bootstrap approve is rejected AA33 (the bundler's simulation node lags the RPC); the bootstrap now waits for two confirmations.

Security review fixes

A threat-model audit of the paymaster ran alongside this work, executed against mainnet forks rather than read-only. It found no path for a user, bundler, relayer or order creator to extract value, but two issues worth fixing before the redeploy are included here.

Stake was permanently unrecoverable. addStake was ungated while unlockStake/withdrawStake route through _authorizeWithdraw(), which reverts unconditionally, and no governance request kind covered stake. Confirmed against the live deployments: 0.1 native is already stuck on each of Ethereum, BSC and Arbitrum, and any unprivileged address could call addStake{value: 1 wei}(type(uint32).max) to push the unstake delay from one day to 136 years, defeating even a future upgrade. Adds RequestKind.UnlockStake / WithdrawStake (both empty-payload, always paying out to the treasury, with matching pallet extrinsics) and gates addStake to the treasury.

The postOp gas cap was burning half the margin. The EntryPoint penalises the unused part of paymasterPostOpGasLimit after fixing the cost handed to postOp, so that penalty is never billed to the user, and it waives the penalty entirely while gasLimit <= gasUsed + 40_000. The SDK pinned the limit at the contract's 100k cap while postOp needs ~8-12k. MAX_POST_OP_GAS_LIMIT is now 40,000 — the largest unconditionally penalty-free value — with a 30,000 floor closing the refund-underflow window an unbounded-below limit left open. Fork-measured margin per sponsored op rises from 9,053,104,589,778 to 21,354,113,277,112 wei, and is now identical at the cap and the floor. The SDK's shared POST_OP_GAS_LIMIT splits per paymaster, since Circle's is a different contract whose postOp was never measured here.

Deferred deliberately: per-feed oracle staleness, a non-reverting refund, governance message epochs, and a treasury pause (which would reintroduce a privileged local role, so it is a design call rather than a fix).

Tests: foundry unit cases plus fork suites (Ethereum in CI via MAINNET_FORK_URL, BSC locally) covering the full handleOps round trip against the real Permit2, the gas-accounting measurements, and the stake lock verified against the live deployed contracts. The Base Sepolia probe script and its env-gated live test are included but never run in CI.

Redeploy on the 5 chains and registry update to follow.

@royvardhan

royvardhan commented Aug 19, 2026

Copy link
Copy Markdown
Collaborator Author

Security review of the PERMIT2 path

Backed by fork tests against the real Permit2, USDT/USDC, the real EntryPoint v0.8 and the live deployed contracts (Permit2CompromiseForkTest, SimplexPaymasterPermit2ForkTest, SimplexPaymasterGasGriefTest, SimplexPaymasterStakeLockForkTest).

1. Bugs in the added code

Reviewed _prefund (mode 2), _fetchDetails, _parsePermit2Data and the client selection logic. No exploitable defect found. The invariants that make it safe:

  • The Permit2 call runs only after _fetchDetails confirms the token is registered and active, so validation never calls into an attacker-chosen token.
  • owner is always userOp.sender and to is always address(this) — neither is reachable from paymasterData.
  • prefundAmount > permitAmount reverts before any external call (nonce untouched), and Permit2 independently enforces requestedAmount <= permitted.amount, so even a manipulated oracle can't pull more than the solver signed.
  • Failures revert (never silently succeed); the refund path is unchanged and pays the sender.

_prefund now charges the IERC20 token that _fetchDetails validated rather than re-deriving it from the mode byte, so it no longer depends on the base contract's call ordering for that guarantee.

2. Can a compromised paymaster drain a filler who approved max to Permit2?

No. Proven in Permit2CompromiseForkTest:

  • The max approval is inert by itself. It approves the token to Permit2, which grants no pull right on its own — Permit2's signature-less transferFrom checks a separate internal allowance the client never sets, so the paymaster's Permit2 allowance is 0 and a signature-less drain reverts.
  • The attacker can't forge a pull. Every permitTransferFrom needs a fresh filler signature over (token, amount, spender, nonce, deadline); a permit signed with the attacker's key reverts InvalidSigner.
  • Exposure is bounded to what the filler actually signed. A malicious paymaster can redirect an outstanding, unexpired, unused permit (e.g. a losing bid) to itself — but only up to its signed amount (inflating requestedAmount reverts InvalidAmount) and only once (nonce spent). At rest, with no pending bids, exposure is zero.

This is strictly better than APPROVE mode, where a fixed allowance sits exposed to the paymaster at all times and auto-refills. "Compromised paymaster" here also means a malicious Hyperbridge-governance upgrade, not a single stolen key.

One caveat worth stating: for a policy-constrained signer (Turnkey/MPC), if the policy restricts transactions but not typed-data signing, the ceiling on what a single signature can move rises from a $5 EIP-2612 permit to the account's full balance, since nothing on-chain caps permitAmount. For a raw private-key signer this changes nothing.

3. Wider audit — two fixes folded into this PR

The review was extended to the whole contract and its usage, which found no value-extraction path but did surface issues worth fixing before the redeploy. Two are now included here:

  • Stake was permanently unrecoverable. addStake was ungated while unlockStake/withdrawStake route through _authorizeWithdraw(), which always reverts, and no governance request kind covered stake. 0.1 native is already stuck on each of Ethereum, BSC and Arbitrum, and anyone could call addStake{value: 1 wei}(type(uint32).max) to stretch the unstake delay to 136 years — defeating even a later upgrade. Now has UnlockStake/WithdrawStake request kinds plus pallet extrinsics, and addStake is treasury-gated.
  • The postOp gas cap was burning half the per-op margin. The EntryPoint penalises unused paymasterPostOpGasLimit after fixing the cost handed to postOp, so it was never billed to the user; the penalty is waived entirely at or below 40k. MAX_POST_OP_GAS_LIMIT is now 40,000 with a 30,000 floor (which also closes a refund-underflow window). Fork-measured margin per op goes from 9,053,104,589,778 to 21,354,113,277,112 wei.

Also checked and not changed: inflating callGasLimit does not drain the paymaster — the penalty is billed straight through to the user, with margin constant to five significant figures across a 166× inflation. And soft-failing the prefund instead of reverting was considered and rejected: EntryPoint v0.8 turns both a paymaster revert (AA33) and a sig-failure (AA34) into revert FailedOp, so it would trade the Permit2Failed diagnostic for no bundle-level benefit.

Deferred deliberately: per-feed oracle staleness (one maxOracleAge currently covers both a 1-hour and a 24-hour heartbeat feed; USDC/USD on mainnet was measured at 77,786s against the 90,000s bound), a non-reverting refund, governance message epochs, and a treasury pause — the last reintroduces a privileged local role, so it is a design call rather than a fix.

# Conflicts:
#	sdk/packages/simplex/docs/ai/ChangeLog.md
#	sdk/packages/simplex/docs/ai/Decisions.md
#	sdk/packages/simplex/docs/ai/Flow.md
#	sdk/packages/simplex/package.json
#	sdk/packages/simplex/src/services/paymaster/provider/simplex.ts
@royvardhan
royvardhan marked this pull request as ready for review August 19, 2026 10:23
Comment on lines +78 to +79
/// uint256(nonce), uint256(deadline), bytes(signature))
/// signature is 65 bytes (r, s, v); the signed spender is this paymaster.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

lets actually prefer the full r, s, v instead of bytes

@seunlanlege
seunlanlege marked this pull request as draft August 19, 2026 10:31
@seunlanlege

Copy link
Copy Markdown
Member

one more thing is, if simplex has to do a native EIP-7702 delegation, then execute the Permit2 unlimited allowance to the Paymaster in the same transaction

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants