Passkey wallet accounts (spec 041)¶
FairWins passkey accounts are self-custodial ERC-4337 smart accounts owned
by WebAuthn P-256 credentials (device biometrics). This guide covers the
architecture; the user-facing recovery story lives in
docs/user-guide/passkey-recovery.md, ops in
docs/runbooks/relayer-operations.md.
Contract stack (contracts/account/)¶
Vendored Coinbase Smart Wallet v1.1.0 + pinned dependency closure —
provenance and the vendoring rules (no logic modifications, path-only import
rewrites) are in contracts/account/README.md. Key properties the platform
relies on:
- Multi-owner: P-256 public keys (passkeys) and EOA addresses (linked
wallets) are interchangeable controllers; add/remove is owner-authorized
self-calls;
removeOwnerAtIndexreverts on the last owner (FR-020). - ERC-1271 with a per-account replay-safe hash — how passkey accounts sign spec-035 intents and USDC EIP-3009 authorizations (ERC-7598).
executeBatch— approve+act in ONE user ceremony (FR-016).- WebAuthnSol: RIP-7212 precompile first (3,450 gas), FreshCryptoLib Solidity fallback elsewhere — the same bytecode serves every network. The precompile is live on every supported chain except ETC and Mordor (see Network scope), which pay the FCL path's higher gas.
- UUPS upgradable by its owners only — FairWins holds no authority over instances (plan.md Complexity Tracking).
Deployment: scripts/deploy/deploy-account-stack.js deploys the
implementation + factory through the canonical CREATE2 deployer with a pinned
salt, so accountFactory has the same address on every network and
account addresses are chain-independent (FR-023). Recorded deployment keys:
entryPoint, accountFactory, accountImpl, p256Verifier (explicit null —
the FCL fallback is inlined). Never hardcode these; they flow through
sync:frontend-contracts.
ERC-1271 intent signing¶
The merged 035/036 rails originally verified intent signers with ECDSA only.
Spec 041 extended contracts/upgradeable/SignerIntentBase.sol with an
ECDSA-then-ERC-1271 check (OZ SignatureChecker semantics, inlined — see the
file's comment for the Cancun mcopy constraint) and
services/relay-gateway/src/intent/verify.js with the matching fail-closed
isValidSignature eth_call fallback. Ship path for live networks:
scripts/deploy/upgrade-erc1271-intents.js (in-place upgrades of both
registry facets + membershipManagerImpl, storage-layout gated; new
WagerPool template for FUTURE clones — existing clones are immutable and
stay ECDSA-only for …WithSig twins).
Scope note: the EIP-3009 payment leg (ERC3009Auth v/r/s) is still
ECDSA-only — passkey stake-moving actions ride executeBatch UserOps until
the ERC-7598 bytes leg is plumbed through the twins.
test/fork/usdc-erc1271-authorization.test.js already proves native USDC
accepts the contract-account authorization.
Frontend architecture (frontend/src/lib/passkey/, connectors/passkey.js)¶
credentials.js— WebAuthn ceremonies (PRF requested at creation), capability detection (FR-004), typed errors (CeremonyCancelled,AuthenticatorUnavailable), local credential bookkeeping.smartAccount.js— viem-native account layer (viem/account-abstraction, no vendor SDK): address derivation, owner-bytes encodings, controller mutation encoders with last-owner/screening guards, controller reads.submission.js— the routing decision table: relayed intent first (gasless), ordered bundler list for UserOps,SubmissionUnavailablewhen both are down; honest lifecycle tracking (neverincludedbefore inclusion,stalledafter the window — FR-017).intentSigner.js— drop-insigneradapter for the EXISTINGlib/relay/intentClient.signIntent: EIP-712 types imported fromlib/relay/intentTypes.js(three-way byte-identical rule), ERC-1271 WebAuthn envelope out.prfKeys.js— WebAuthn PRF → HKDF → AES-GCM master-seed wrapping: same encryption keys on every device/controller; explicit degradation on non-PRF authenticators (clarification Q1) — never silently-wrong keys.sendBatch.js— fulfillsWalletContext.sendCallsfor passkey sessions; counterfactual accounts activate automatically via initCode on the first action (FR-007).- Connector (
fairwinsPasskey) — sign-up/sign-in, silent reconnect, sessions persist until sign-out (clarification Q4),ChainNotSupportedErroron networks without passkey config (FR-022).
WalletContext exposes loginMethod (informational only — identity and
gating ALWAYS key off address), accountCapabilities.encryption, and
sendCalls. Classic-wallet paths are untouched (SC-004).
Fees & sponsorship (spec 050)¶
Spec 041 shipped with users paying their own UserOp gas from the account's native balance (FR-015: no FairWins paymaster). That strands stablecoin-only users and fails during gas spikes, so spec 050 supersedes FR-015 for the UserOp path: FairWins sponsors account-native UserOps (native + USDC transfers, controller changes, first-use deploy) via a self-hosted verifying paymaster — the user needs zero native token.
- On-chain:
FairWinsVerifyingPaymaster(EntryPoint v0.6) sponsors an op iff itspaymasterAndDatacarries a valid signature from the FairWins sponsorship signer over the op + a short validity window. FairWins funds the EntryPoint deposit (the hard exposure cap); validation is signature-only (no external storage).owner(floppy keystore) withdraws;verifyingSigner(KMS) is rotatable. - Off-chain: the relay-gateway gains
POST /v1/paymaster(ERC-7677pm_getPaymaster{Stub,}Data) — the SAME policy engine (killswitch, sanctions screen on the account, per-account + global quotas) plus a per-operation cost ceiling; on grant it KMS-signs the sponsorship. The alto bundler still submits. No new service. - Frontend:
buildAccountwires a viem paymaster client atVITE_SPONSOR_PAYMASTER_<net>→ the gateway endpoint.sendBatchfalls back to a self-funded UserOp if sponsorship is unavailable (never-stranded), and the confirm UI discloses the truthful fee — "Sponsored — no network fee" ONLY when a sponsorship was actually obtained, else "you pay the network fee" (fixing the previously-unconditional badge).
Eligibility is identity-open (any screened, in-quota FairWins passkey
account — not membership-gated). Networks: Polygon 137 first, Amoy 80002 for
validation; ETC/Mordor degrade to honest self-submit. Design +
run/rollback: specs/050-sponsored-paymaster/ and
docs/runbooks/paymaster-operations.md.
Sign-in is independent of the relayer, the bundler, and the chain¶
Signing in must never depend on network support. It is a WebAuthn ceremony plus an address
derivation, and neither touches a bundler, an EntryPoint, or an RPC. The account address is a pure
function of (owners, nonce) and the factory — all chain-independent (FR-023) — so
smartAccount.computeAccountAddress derives it locally via CREATE2 over ACCOUNT_INIT_CODE_HASH.
Gating login on submission support caused a lockout: the selected network persists in the session, so a member who switched to a chain without a bundler returned to find the passkey option refused on the chain they were already on — and switching away required being signed in. Three things enforce the separation now, and none of them may be re-tightened:
connectors/passkey.js#connecthas no network gateswitchChainallows an unsupported chain (it is never a one-way door)useConnectorAvailabilityscopes passkey availability to the device only
requirePasskeySupport is the submission gate and belongs only in buildAccount and below.
The write path discloses the limitation at the point of action instead of hiding the account.
Cross-device sign-in¶
A passkey created on a phone works on a laptop: the credential is discoverable
(residentKey: 'required') and synced by iCloud Keychain / Google Password Manager. What failed was
identifying the account — the address lived only in the first device's localStorage, and an
assertion returns a credential id and a signature but not the public key.
lib/passkey/crossDevice.js recovers the public key from the signature itself
(sha256(authenticatorData || sha256(clientDataJSON)), DER-decoded to r/s).
⚠️ A single signature is always ambiguous. Exactly two distinct P-256 keys verify any given
(r, s)— measured 200/200, because it is inherent to ECDSA. They derive two different account addresses, so resolving from one assertion could hand a member an address they can fund and never spend from. The module therefore requires two assertions over different challenges and takes the single key in both candidate sets. That is a second authenticator prompt, once per device; disclose it, and never "optimise" it away.
The second ceremony is pinned to the credential id from the first, so no chooser reappears and a different passkey cannot be substituted mid-flow.
user.id cannot carry the key. It must be supplied to credentials.create(), but the key pair
is generated by the authenticator during that call — and there is no backend to hold a
credential→account map. Recovery works for passkeys that already exist, which a user.id scheme
never could.
Limit — a passkey added as a second controller. The derivation assumes the passkey is the account's initial owner. One added later to a pre-existing account does not derive that account's address, so the result is confirmed against the chain: undeployed is accepted (it is the canonical account that key owns), deployed-and-listing-the-key is accepted, and deployed-but-not-listing it is refused rather than showing an account that is not theirs. The check soft-fails on an unreachable RPC, because sign-in must not require a chain.
Compliance¶
Screening keys off the account address everywhere. Additionally
(clarification Q2): linked wallet controllers are screened at link time
(refused when flagged OR unscreenable — fail-closed) and re-screened with the
account (usePasskeyAccount.accountFlagged); on-chain guards remain
authoritative.
Network scope¶
The account stack is deployed on all eight EVM networks, at the same two addresses everywhere (FR-023):
accountFactory |
accountImpl |
|
|---|---|---|
| Ethereum 1, Optimism 10, Base 8453, Arbitrum 42161, Polygon 137, ETC 61, Mordor 63, Amoy 80002 | 0xd519C25e9dEd0DAC586B764574100479CB318734 |
0xfC5086A397e4FbAAF8f73892807415Da8d255E61 |
Verified functionally, not just by address: every factory answers
getAddress(owners, nonce) with the same counterfactual account address on
all eight chains. The deploy script also hard-fails on any cross-network factory
divergence.
Deploying the factory is only half of enabling a network — see Enabling a network. Each chain still needs a bundler URL before members see the option.
⚠️ ETC (61) and Mordor (63) require a legacy-mode bundler¶
Both chains do not implement the BASEFEE opcode (EIP-3198 — ETC never
adopted EIP-1559). Verified directly on-chain: a probe contract whose constructor
executes BASEFEE fails to deploy on both, while the identical probe without it
succeeds, and PUSH0 works (ETC's Spiral upgrade).
EntryPoint v0.6 reads block.basefee in UserOperation.gasPrice(), but only on
one branch — contracts/account/lib/account-abstraction/interfaces/UserOperation.sol:51-61:
if (maxFeePerGas == maxPriorityFeePerGas) {
//legacy mode (for networks that don't support basefee opcode)
return maxFeePerGas;
}
return min(maxFeePerGas, maxPriorityFeePerGas + block.basefee);
So ERC-4337 works on ETC/Mordor only if every UserOp sets
maxFeePerGas == maxPriorityFeePerGas. A stock bundler config that quotes an
EIP-1559 fee pair will hit an invalid opcode at post-op and the operation fails
after execution. Before setting VITE_BUNDLER_URLS_ETC / _MORDOR, pin the
bundler to legacy fee quoting and prove one UserOp end-to-end on Mordor
first. This is separate from — and more consequential than — the missing RIP-7212
precompile on these chains, which only costs extra gas.
The per-chain prerequisites are not assumed — npm run verify:passkey-support
(scripts/ops/verify-passkey-support.js) probes every network and checks, by
bytecode hash rather than "something has code here", that the Arachnid CREATE2
proxy, EntryPoint v0.6 and the factory are the real ones. It also reports the
RIP-7212 P-256 precompile, which is present everywhere except ETC 61 and
Mordor 63 — those fall back to WebAuthnSol's inlined FCL path: correct, but
materially more gas per UserOp. Re-run the probe rather than trusting any table
here; it is a snapshot.
Enabling a network¶
Two ops steps, no code change — every EVM network already declares a
passkey block in config/networks.js:
- Deploy the stack —
npx hardhat run scripts/deploy/deploy-account-stack.js --network <net>thennpm run sync:frontend-contracts -- --network <net> --chainId <id>. The script hard-fails on any cross-network factory divergence (FR-023), so the account address stays chain-independent. - Point at a bundler — set
VITE_BUNDLER_URLS_<NET>and rebuild. OptionallyVITE_SPONSOR_PAYMASTER_<NET>for sponsored gas, which additionally needs a funded paymaster deposit on that chain (docs/runbooks/paymaster-operations.md).
Transacting is the conjunction of those two steps, and
config/passkeySupport.js#isPasskeySupported is the single gate that evaluates it — a bundler with
no factory fails at account creation, a factory with no bundler fails at submit, and it names which
half is missing. capabilities.passkeyAccounts sees only the config half (networks.js deliberately
does not import contracts.js), so a surface that asks "can a transaction be sent here" must consult
isPasskeySupported, not the capability flag. smartAccount.js#requirePasskeySupport re-checks at
the transaction boundary.
⚠️ This gate must never be applied to sign-in. Logging in is device-scoped and chain-independent
(see Sign-in is independent of the relayer, the bundler, and the chain).
useConnectorAvailability deliberately does not call it; a regression test asserts it is never
even invoked from there, because reinstating that call is exactly what locked members out.
Complexity-tracking exceptions (plan.md)¶
- The self-hosted alto bundler colocated with the relay gateway extends the spec-036 no-backend exception (same "can censor, cannot steal" bound).
- User-owned account proxies sit outside the UUPSManaged regime — only account owners hold upgrade authority.