[evm, simplex]: PERMIT2 mode for SimplexPaymaster - #1147
Conversation
Security review of the PERMIT2 pathBacked by fork tests against the real Permit2, USDT/USDC, the real EntryPoint v0.8 and the live deployed contracts ( 1. Bugs in the added codeReviewed
2. Can a compromised paymaster drain a filler who approved
|
# 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
| /// uint256(nonce), uint256(deadline), bytes(signature)) | ||
| /// signature is 65 bytes (r, s, v); the signed spender is this paymaster. |
There was a problem hiding this comment.
lets actually prefer the full r, s, v instead of bytes
|
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 |
Adds mode
0x02 PERMIT2toSimplexPaymaster: the prefund is pulled through Permit2permitTransferFrom(owner =userOp.sender, spender = paymaster, single-use nonce, deadline surfaced asvalidUntil), so tokens without EIP-2612 need one lifetimeapprove(Permit2, max)instead of a capped allowance to the paymaster refilled with native-funded approvals. No storage changes; the live proxies remain upgradeable.Client (
simplex0.10.0): selection is PERMIT, then PERMIT2 when the token is approved to Permit2, then APPROVE while a legacy allowance lasts, then a one-timeapprove(Permit2, max)bootstrap. Mode 2 is only emitted against deployments that exposePERMIT2(), so it is safe before the redeploy lands.forceApproveModebecameskipPermit.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'sallowance[owner][Permit2]are neither (nested mappings hash the owner on the inner level), so a spec-enforcing bundler could reject mode 2 ateth_sendUserOperationno matter how correct the contract is, and staking cannot fix it. Permit2 also executesTIMESTAMPfor the deadline check.Why it was expected to work anyway: this paymaster already reads
block.timestamp(equally banned) in_getOraclePricein 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 freshSimplexPaymasteron 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 throughSolverAccount), withPermit2Executedlanding 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.
addStakewas ungated whileunlockStake/withdrawStakeroute 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 calladdStake{value: 1 wei}(type(uint32).max)to push the unstake delay from one day to 136 years, defeating even a future upgrade. AddsRequestKind.UnlockStake/WithdrawStake(both empty-payload, always paying out to the treasury, with matching pallet extrinsics) and gatesaddStaketo the treasury.The postOp gas cap was burning half the margin. The EntryPoint penalises the unused part of
paymasterPostOpGasLimitafter fixing the cost handed topostOp, so that penalty is never billed to the user, and it waives the penalty entirely whilegasLimit <= gasUsed + 40_000. The SDK pinned the limit at the contract's 100k cap while postOp needs ~8-12k.MAX_POST_OP_GAS_LIMITis 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 sharedPOST_OP_GAS_LIMITsplits 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 fullhandleOpsround 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.