Technical detail of the project's Solidity contracts (V1 → V3). For the overall flow, see architecture.md. For the simplifications, see limitations.md.
contracts/src/
├── interfaces/
│ ├── IAccount.sol ← PackedUserOperation struct + IAccount interface
│ ├── IPaymaster.sol ← IPaymaster interface + PostOpMode enum
│ └── IEntryPoint.sol ← MINIMAL EntryPoint interface (what we call)
├── BaseAccount.sol ← abstract account: validate/execute/prefund/deposit
├── SmartAccount.sol ← V1 — auth by POSSESSION (ECDSA against a fixed s_owner)
├── SecretQuestionAccount.sol ← V2 — auth by KNOWLEDGE (ECDSA against a KDF-derived signer)
├── PasskeyAccount.sol ← V3 — auth by DEVICE (WebAuthn P-256 against a stored pubkey)
├── AccountFactory.sol ← V3 — CREATE2 factory, counterfactual deployment via initCode
├── Paymaster.sol ← the gas sponsor
└── Counter.sol ← demo witness contract (UserOps target: increment())
The abstract BaseAccount owns the whole ERC-4337 account lifecycle and leaves exactly two
hooks for concrete schemes:
BaseAccount (abstract)
├─ validateUserOp() final template: _validateSignature() then _payPrefund()
├─ execute() / executeBatch()
├─ _payPrefund(), addDeposit(), getDeposit()
├─ _validateSignature() ← hook 1: is this UserOp signature valid?
└─ _authorizedAdmin() ← hook 2: who may call execute() directly, besides the EntryPoint?
│
├─ SmartAccount (V1) ECDSA recover == s_owner · admin = s_owner
├─ SecretQuestionAccount (V2) ECDSA recover == s_signerAddress · admin = s_signerAddress
└─ PasskeyAccount (V3) WebAuthn.verify against (x, y) · admin = address(0)
Adding an auth scheme must not touch the security-critical lifecycle — V3 is the proof: it
overrides the two hooks and nothing else. Deployment also differs per version: V1 once by
script, V2 per-user via the bundler's /deploy (removed in V3), V3 lazily via the
AccountFactory.
userOpHash (computed by the EntryPoint)
│
▼
toEthSignedMessageHash(userOpHash) ← EIP-191 "personal_sign" prefix
│
▼
ECDSA.tryRecover(hash, signature) == stored signer ? → 0 (success) else → 1
(s_owner in V1, s_signerAddress in V2)
Absolute invariant: the client must sign exactly this prefixed hash (viem:
signMessage({ message: { raw: userOpHash } })). If the two sides disagree, validateUserOp
always returns 1. This is the #1 source of bugs. OpenZeppelin's tryRecover (not raw
ecrecover) rejects malleable signatures and never reverts on malformed input — required, since
validateUserOp must return 1, not revert.
We use the EIP-191
personal_signconvention (v0.6/v0.7SimpleAccountstyle) rather than the v0.8 reference's raw EIP-712 hash. Both are valid — client and contract just have to agree.
V2's specificity (the key is derived in the browser from secret answers; the contract sees a
normal ECDSA signature): the V2 doc at tag
v2.0.0.
A different signature, same lifecycle: the account stores the P-256 public key (x, y), and
_validateSignature hands the WebAuthn assertion to OpenZeppelin's WebAuthn/P256 libraries.
_authorizedAdmin() is address(0) — a P-256 key has no EVM address, so everything goes through
UserOps. Details (how WebAuthn signing works, the precompile, the encoding trap):
v3-passkeys-factory.md.
validationDatais a packeduint256, not a boolean (0= success,1= failure; bits 160+ would encode a validity time window for session keys — unused here)._payPrefund: if no Paymaster sponsors, advancesmissingAccountFundsto the EntryPoint. With a Paymaster this is0and the call is a no-op.execute/executeBatch: runs the UserOp'scallData. Access control:requireFromEntryPointOrOwner— the EntryPoint (normal flow) or_authorizedAdmin().
msg.sender == i_entryPointis checked on every validation — without it, anyone could trigger validations.- The authority is immutable in every version (no setter for
s_owner/s_signerAddress, immutable(x, y)) → no takeover risk, but no rotation or recovery either (limitations.md). - Why sign instead of sending a secret? A UserOp travels in cleartext (mempool), so any
secret placed in it would be exposed and replayable. A signature reveals nothing and is bound
to one specific
userOpHash. Nothing secret ever goes on-chain, in any version.
CREATE2 factory: the account address is readable via getAddress before the account exists,
and the real deployment rides in the first UserOp's initCode. Idempotent (a retried first op
returns the existing account instead of reverting). This closed
limitations.md 🟢 b and removed V2's /deploy endpoint + deployer key.
Details: v3-passkeys-factory.md.
No ERC-4337 role: a target proving a UserOp can call another contract. increment() adds 1;
the msg.sender the Counter sees is the account's address, not the user's EOA — the concrete
demonstration of account abstraction.
Sponsors the gas. Not an EOA: it holds an accounting deposit on the EntryPoint, which the EntryPoint draws from to reimburse the bundler.
| Function | Role |
|---|---|
validatePaymasterUserOp |
Checks the caller, returns ("", 0) → accepts everything |
postOp |
No-op (never called — context is empty) |
deposit / withdrawTo / getDeposit |
Fund / withdraw / read the EntryPoint deposit |
postOp). In V3 it also sponsors the deploying first UserOp (~1.5M extra gas) with no special
case — the deposit just needs to cover that larger ceiling.
IAccount:validateUserOp(...)+ thePackedUserOperationstruct (the "packed" fields:accountGasLimits,gasFees— bit-packed to save calldata).IPaymaster:validatePaymasterUserOp,postOp,PostOpModeenum.IEntryPoint: minimal subset of the real EntryPoint — only what we call.
SmartAccount.t.sol(V1) &SecretQuestionAccount.t.sol(V2) — validation, access control, execute, batch, prefund, deposit; V2 cross-checks the V2 frontend's KDF vector against the contract (the V2 frontend and its KDF live at tagv2.0.0).PasskeyAccount.t.sol(V3) — WebAuthn validation against the locked vector: challenge binding, wrong key, malformed signature (returns1, never reverts), access control.AccountFactory.t.sol(V3) — CREATE2 determinism (getAddress== deployed address), idempotence, distinct keys → distinct addresses.Paymaster.t.sol— accept, access control, deposit/withdraw.Integration.fork.t.sol&Passkey.fork.t.sol— end-to-end against the real EntryPoint (and V3's real P-256 precompile) on a Sepolia fork. Skipped without--fork-url.fixtures/WebAuthnVector.sol— the locked WebAuthn assertion (make vector), mirrored in the frontend'swebauthn.test.ts(see v3-passkeys-factory.md).
Commands: forge build, forge test -vvv (see README.md).