Clean-room ERC-4337 v0.7 paymasters for account-abstraction gas sponsorship, with one design goal driving the whole codebase: the paymaster validation path must never revert on crafted input.
A paymaster is the contract that agrees to pay an account's gas so the end user does not
need to hold ETH — the core of gasless UX. It fronts ETH from a deposit held by the
EntryPoint, and every op it sponsors runs validatePaymasterUserOp on the bundler's hot
path. If that function can be made to revert by malformed paymasterAndData, a crafted op
becomes a griefing vector against the bundler. This suite treats that as the primary
threat and engineers around it.
validatePaymasterUserOp resolves every bad input to the ERC-4337
SIG_VALIDATION_FAILED bit instead of a revert. In VerifyingPaymaster this covers three
distinct malformed-input paths that a naive implementation reverts on:
- Short
paymasterAndData— a tail too short to hold the(validUntil, validAfter)window. A raw[52:116]calldata slice would panic on empty data; here it is length-guarded and returns the failure bit. - Out-of-range validity window — read by truncation rather than
abi.decode(..., (uint48, uint48)), which reverts on dirty high bits. For a well-formed op the result is identical; garbage truncates instead of reverting. - Wrong-length signature — routed through
ECDSA.tryRecover, which returns an error for any non-65-byte signature rather than throwing; the error folds into the failure bit.
A fuzz test (testFuzz_ValidateNeverRevertsOnBadTail) drives an arbitrary tail through
validation and asserts it never reverts. This robustness was the substance of the repo's
v2 security pass, which fixed the two revert paths above (short tail, out-of-range window)
that the first version shipped with — see the git history and the
test_MalformedShortData_* / test_WrongSignatureLength_* cases.
Beyond non-reverting validation, the security posture is:
- Sponsorship bound to this chain and this contract. The EIP-712 domain
(
"VerifyingPaymaster", version"1") bindschainIdand the paymaster address, and the signed struct binds sender, nonce, initCode/callData hashes, all gas fields, the paymaster gas limits, and the validity window. A signature cannot be replayed onto a different op, a different chain, or a different deployment. - Spend caps bound a leaked signer's blast radius. Global and per-sender budgets
(
setSpendCaps, in wei of gas cost) stop sponsorship once hit — even for a validly signed op — so a compromised off-chain signer cannot drain the whole deposit. The two caps share one storage slot, so the common no-cap validate path pays only a single extraSLOAD. - No privileged escape hatch over user funds. The owner surface is signer rotation,
spend caps, token price/markup (bounded
<= 20%), and the paymaster's own EntryPoint deposit/stake. There is no path for the owner to touch a user's account or an in-flight op's charge —TokenPaymastersnapshots price and markup into the postOp context at validation time, so a mid-flight owner price change cannot alter an already-validated op's charge.
BasePaymaster— shared machinery. Binds a single immutableentryPoint, gatesvalidatePaymasterUserOp/postOpso only that EntryPoint can call them, and proxies owner-only deposit (deposit/withdrawTo/getDeposit) and stake (addStake/unlockStake/withdrawStake) management to the EntryPoint.VerifyingPaymaster— sponsored transactions. An off-chainverifyingSignersigns an EIP-712 digest over the op plus a(validUntil, validAfter)window; on-chain the paymaster recovers the signer and encodes the result asvalidationData. Rotatable signer, optional spend caps. This is how "sign in and go" gasless onboarding works.TokenPaymaster— pay gas in an ERC-20. Validation checks the sender holds and has approved enough token at an owner-settokenPricePerGas;postOppulls the actual gas cost converted at the snapshotted price, scaled by a bounded markup (markupBps <= 2000). The markup is the operator's margin over the ETH it fronts.
Constructors (verify against src/):
new VerifyingPaymaster(IEntryPoint entryPoint, address initialOwner, address verifyingSigner);
new TokenPaymaster(IEntryPoint entryPoint, address initialOwner, IERC20 token, uint256 tokenPricePerGas, uint256 markupBps);The off-chain sponsor backend signs the paymaster's EIP-712 digest and packs the v0.7
paymasterAndData tail (offsets are exactly what the contract parses):
// 1. Sponsor decides validity window and signs the digest the contract will rebuild.
bytes32 digest = paymaster.getHash(userOp, validUntil, validAfter);
(uint8 v, bytes32 r, bytes32 s) = vm.sign(signerKey, digest); // off-chain: your signer
bytes memory signature = abi.encodePacked(r, s, v);
// 2. Pack paymasterAndData: [0:20] paymaster | [20:36] verifGasLimit |
// [36:52] postOpGasLimit | [52:116] abi.encode(validUntil, validAfter) | [116:] sig
userOp.paymasterAndData = abi.encodePacked(
address(paymaster),
uint128(verificationGasLimit),
uint128(postOpGasLimit),
abi.encode(validUntil, validAfter),
signature
);The contract rebuilds the same digest (getHash), recovers the signer, and — crucially —
returns the failure bit rather than reverting if any of that is malformed. Optionally cap
exposure:
paymaster.setSpendCaps(uint128(globalCapWei), uint128(perSenderCapWei)); // 0 = unlimited41 tests pass (forge test), including 2 fuzz tests — the never-revert
invariant on VerifyingPaymaster validation and a TokenPaymaster fuzz asserting the
postOp charge can never exceed the amount validated against balance/allowance. There are
no invariant-test suites. Coverage spans valid/invalid signatures (failure bit, not
reverts), malformed and short paymasterAndData, wrong signature length, time-window
encoding, signer rotation, spend-cap enforcement and postOp accounting, token
balance/allowance gating, exact price x markup math, the markup cap, EntryPoint-only
gating, and owner-only deposit/withdraw/stake.
Tested against a mock EntryPoint, not the real one. The suite drives
validate/postOpthrough aMockEntryPointthat calls the paymasters exactly as the real EntryPoint does (msg.sender == entryPoint), so caller-gating and the validate/postOp handshake are exercised for real — but this is not an integration test against the deployed EntryPoint or a live bundler. Do that on a testnet before trusting it. See DEPLOY.md.
forge test
forge fmt --check
forge snapshot --check # against the committed .gas-snapshotMeasured with forge test --gas-report against the mock EntryPoint (single-call figures,
so they include cold-storage costs; treat as indicative, not a benchmark against the real
EntryPoint):
| Contract | validatePaymasterUserOp |
postOp |
|---|---|---|
| VerifyingPaymaster | ~26,077 | ~22,348 |
| TokenPaymaster | ~24,716 | ~22,989 |
The hot validate path is kept lean deliberately: the two spend caps are packed into a
single storage slot (one SLOAD, skipped entirely when unset), and the malformed-input
hardening replaces abi.decode's range check with a cheaper truncation. A per-commit
.gas-snapshot is committed so regressions surface in review.
The v0.7 types (PackedUserOperation, IPaymaster with PostOpMode, and a minimal
IEntryPoint deposit + stake surface) are defined locally in src/interfaces/, based on
the canonical eth-infinitism v0.7 definitions — no network dependency on the
account-abstraction package. Built with solc 0.8.26, via_ir, cancun.
Unaudited. No third-party audit; the "never reverts" property is asserted by the project's own fuzz test, not by external mutation testing or formal verification. Treat this as reference-quality infrastructure to review and testnet, not turnkey mainnet code.
MIT.