Passkey signers on x402: P-256 from the Secure Enclave to USDC
The x402 spec says a payment signature is 65 bytes. The one we settled today was 640. It came from a P-256 key that never touched an Ethereum wallet, and USDC on Base accepted it without complaint.
Every phone, laptop and hardware security key made in the last decade signs with the same curve: secp256r1, also called P-256. Ethereum signs with a different one, secp256k1. That mismatch is why an agent running on a machine with a Secure Enclave, or a human co-signing an agent's spend from a passkey, has never been able to authorize a stablecoin transfer directly. Two things changed that. Ethereum shipped a native P-256 verifier in December 2025, and Circle's USDC has quietly accepted contract-validated signatures since its v2.2 release in 2023. Put them together and a passkey can sign an EIP-3009 authorization, the primitive under every x402 payment.
We wanted to know whether that path actually works today, what it costs, and what the x402 facilitator does when a signature arrives that ecrecover cannot recover. So we built it. Everything below is primary-source: we read the EIPs and the contracts, called the precompile on five mainnets, deployed a passkey-owned smart wallet on a Base fork, settled real USDC with it, replayed the verification against Base mainnet through state overrides, and read the facilitator code in the x402 monorepo. All measurements are ours, taken September 12, 2026.
The curve every device already has
EIP-7951, "Precompile for secp256r1 Curve Support", is a Final core EIP by Carl Beekhuizen, Ulaş Erdoğan and Doğan Alpaslan, created May 27, 2025. Its abstract states the purpose without hedging: "This precompile enables native support for signatures generated by modern secure hardware including Apple Secure Enclave, Android Keystore, and FIDO2/WebAuthn devices." The interface is minimal. The precompile lives at address 0x100, takes 160 bytes of input (message hash, r, s, public key x, public key y, each 32 bytes), returns a 32-byte 1 on success and empty output on failure, and costs 6,900 gas.
It is the mainnet version of an older rollup proposal. RIP-7212, by Erdoğan and Alpaslan, was created June 22, 2023 with the same address, the same input layout and a price of 3,450 gas. EIP-7951 keeps the interface and fixes the spec: it adds a point-at-infinity check, replaces direct equality with a modular comparison (r' ≡ r mod n), and doubles the gas to 6,900, a figure the EIP justifies with benchmarks of real implementations. It shipped in Fusaka. The EIP-7607 meta-EIP lists EIP-7951 among the core EIPs and records mainnet activation at epoch 411392, December 3, 2025, 21:49:11 UTC.
Why does the price matter? Because before the precompile, verifying a P-256 signature on the EVM meant running the curve arithmetic in Solidity. Daimo's p256-verifier, audited by Veridise in October and November 2023 and deployed at 0xc2b78104907F722DABAc4C69f826a522B2754De4, advertises "about 330k gas" per verification. That is the cost of a whole USDC transfer several times over, spent on one signature check.
What we measured at address 0x100
We did not take the spec's word for where the precompile exists. We generated a fresh P-256 key with openssl, signed the SHA-256 of a short message, normalized s to the low half of the curve order, packed the 160-byte input and sent it as an eth_call to 0x100 on Ethereum mainnet, Base, OP Mainnet, Arbitrum One and Polygon PoS. All five returned 0x…01 for the valid signature and empty output for the same input with one bit of s flipped. Base Sepolia behaved the same.
Gas is harder to measure than validity, because eth_estimateGas on a direct call is dominated by calldata floor pricing. So we compiled a 1,006-byte probe contract that wraps a staticcall in gasleft() and injected it with an eth_call state override, no deployment needed. The probe reported 7,464 gas for the precompile on every one of the five chains, identical to the last unit. Subtract the warm STATICCALL and memory overhead and that is the 6,900 of EIP-7951, not the 3,450 of RIP-7212. Whatever the L2s shipped originally, they are all priced at the mainnet number today.
The same probe against the Solidity fallbacks is the comparison that matters for wallet designers. Solady's P256 library points at a verifier contract at 0x000000000000D01eA45F9eFD5c54f037Fa57Ea1a; our probe measured 175,923 gas for it. Daimo's verifier measured 346,790 gas. Both contracts are deployed at the same addresses on Ethereum, Base and Polygon (we checked the bytecode is present on each). The precompile is roughly 24 times cheaper than the best fallback and 46 times cheaper than the older one. Solady's library also encodes the malleability rule the precompile does not: it rejects s above _HALF_N unless you call the explicitly named verifySignatureAllowMalleability.
The token already accepts it
None of this reaches a stablecoin unless the token contract is willing to ask a contract, rather than ecrecover, whether a signature is valid. USDC has been willing for almost three years. Circle's stablecoin-evm changelog entry for 2.2.0, dated November 9, 2023, reads: "Add ERC-1271 signature validation support to EIP-2612 and EIP-3009 functions."
The mechanism is a single branch in util/SignatureChecker.sol. isValidSignatureNow recovers the key with ECRecover.recover when the signer has no code and otherwise staticcalls isValidSignature(bytes32,bytes) on the signer and compares the return value against the ERC-1271 selector. The natspec adds the caveat that decides most of what follows: "Unlike ECDSA signatures, contract signatures are revocable, and the outcome of this function can thus change through time." EIP3009.sol routes both transferWithAuthorization and receiveWithAuthorization through it via _requireValidSignature, hashing the struct under the token's EIP-712 domain first.
The important detail is that USDC exposes two overloads of each function. One takes (uint8 v, bytes32 r, bytes32 s) and can only ever describe a secp256k1 signature. The other takes bytes memory signature and is the only door a passkey can walk through. We computed the selectors (0xcf092995 for the bytes overload of transferWithAuthorization, 0xe3ee160e for the v/r/s one, 0x88b7ab63 for the bytes overload of receiveWithAuthorization) and searched for them in the implementation bytecode behind the native USDC proxy on five chains. Ethereum (0x43506849…), Base (0x2ce6311d…), OP Mainnet (0xded3b9a8…), Arbitrum (0x86e721b4…) and Polygon (0x235ae97b…) all run a 23,464-byte FiatTokenV2_2 implementation, all report version() as "2", and all contain the three selectors. The bytes door is open everywhere x402 settles USDC.
The wallet in the middle
A passkey cannot own an address by itself. It needs a contract that holds the public key and answers ERC-1271. We used the reference implementation of that idea, Coinbase's Smart Wallet, because it is deployed at the same factory address on the chains that matter (0x0BA5ED0c6AA8c49038F819E587E2633c4A9F428a for v1, 0xBA5ED110eFDBa3D005bfC882d75358ACBbB85842 for v1.1) and because its source is short enough to audit in an afternoon.
Owners are stored as raw bytes in a struct at the storage location 0x97e2c6aad4ce5d562ebfaa00db6b9e0fb66ea5d8162ed5b243f51a2e03086f00: 32 bytes for an Ethereum address, 64 bytes for a P-256 public key. _isValidSignature branches on that length. The 64-byte branch decodes (x, y), decodes the signature as a WebAuthnAuth struct and calls WebAuthn.verify from Base's webauthn-sol with requireUV: false. Anything else reverts with InvalidOwnerBytesLength.
webauthn-sol is where the browser world and the EVM meet. It takes the assertion's authenticatorData and clientDataJSON, checks the JSON's type is "webauthn.get", checks the base64url-encoded challenge appears at the caller-supplied index, checks the user-present bit of the flags byte and the user-verified bit only if asked, rejects s above half the curve order, computes sha256(authenticatorData ‖ sha256(clientDataJSON)), and tries the 0x100 precompile first. The comment on the fallback is explicit: "staticcall will not revert if address has no code. check return length." If the precompile returns nothing, it verifies with FreshCryptoLib in pure Solidity.
Two more design choices shape the security story. First, the wallet does not verify what it cannot know. Its natspec says origin checking is skipped because "it is considered the authenticator's responsibility to ensure that the user is interacting with the correct RP", and rpIdHash, signature counters, backup state and extensions are likewise left to the authenticator. Second, the wallet wraps every hash it is asked about. ERC1271.sol computes a replaySafeHash under an EIP-712 domain containing the chain id and the wallet's own address, using the type string CoinbaseSmartWalletMessage(bytes32 hash), so that a signature valid for one account "cannot be used on two accounts" owned by the same key. That wrapper is why a passkey cannot simply sign USDC's digest; it signs the wallet's hash of USDC's digest.
The drive: a passkey pays USDC
We forked Base mainnet at block 51,207,394 with anvil, then deployed a Smart Wallet through the v1 factory with a single owner: the 64-byte public key of our openssl-generated P-256 key. The factory's getAddress predicted 0xda97f0FD…, createAccount confirmed it in 213,781 gas, and isOwnerPublicKey(x, y) returned true. We credited the wallet with 100 USDC by writing its balance slot directly.
Then we built the payment the way an x402 client would. The EIP-3009 struct hash uses the typehash 0x7c7c6cdb… (we recomputed it from the type string and it matched the constant in EIP3009.sol), the domain separator we read from the live token, and a fresh 32-byte nonce. We asked the wallet for replaySafeHash(digest), used those 32 bytes as the WebAuthn challenge, assembled a clientDataJSON of type webauthn.get with origin https://llm4agents.com, built a 37-byte authenticatorData with flags 0x05 (user present and user verified) and a zero counter, and signed the combined SHA-256 with openssl. The final signature is the ABI encoding of the wallet's SignatureWrapper (owner index 0 plus the encoded WebAuthnAuth): 640 bytes, of which 512 are the assertion itself.
// what USDC receives in `signature` for a passkey payer
struct WebAuthnAuth {
bytes authenticatorData; // 37 bytes: rpIdHash ‖ flags ‖ counter
string clientDataJSON; // {"type":"webauthn.get","challenge":"…"}
uint256 challengeIndex; // 23 in our JSON
uint256 typeIndex; // 1
uint256 r;
uint256 s; // must be ≤ n/2
}
struct SignatureWrapper { uint256 ownerIndex; bytes signatureData; }
// challenge = replaySafeHash(EIP-712 digest of TransferWithAuthorization)
// signature = abi.encode(SignatureWrapper(0, abi.encode(auth))) → 640 bytes
The first fork we ran had a surprise in it. The wallet answered 0x1626ba7e, the ERC-1271 magic value, and transferWithAuthorization succeeded, but the settlement burned 323,543 gas and the signature check alone 234,447. Anvil's default hardfork does not include the 0x100 precompile, so webauthn-sol had silently fallen back to FreshCryptoLib. That is not a bug in the wallet; it is the fallback doing exactly what its comment says. Restarting the fork with --hardfork osaka put the precompile back, and the same signature verified in 34,837 gas with the settlement at 123,921. A second settlement with a new nonce cost 123,933.
The baseline is a plain EOA paying the same 0.01 USDC through the v/r/s overload: 85,504 gas. The passkey path costs 45 percent more, almost all of it the 900 bytes of calldata against 292 and the extra contract hops, not the curve math. Without the precompile it costs 3.8 times more. That single number is the difference between passkeys being a curiosity and being a default.
The negative tests all failed the way they should. Replaying the settled nonce reverted with FiatTokenV2: authorization is used or canceled. Presenting the old signature under a new nonce, flipping one bit of s, and feeding the P-256 r and s into the v/r/s overload as if they were secp256k1 components all reverted with FiatTokenV2: invalid signature. The last case is what a facilitator does if it dispatches on the wrong overload, and the token rejects it cleanly rather than moving funds.
Finally we took the experiment off the fork. Using eth_call state overrides against Base mainnet, we placed the wallet's 61-byte ERC-1967 proxy code, its implementation pointer and its owner storage slots at the same address, credited it USDC in the override, and asked the real chain. isValidSignature returned the magic value, the probe measured the check at exactly 34,837 gas, and transferWithAuthorization simulated to success. The fork numbers are the mainnet numbers.
One incidental finding deserves its own sentence. Our first EOA baseline used anvil's well-known account 0, and USDC rejected every signature it produced. The reason: on Base mainnet that address carries an EIP-7702 delegation (0xef0100…) that somebody installed with the public private key, so the token routed its signature to isValidSignature on a delegate that did not recognize it. We wrote about exactly this failure mode in the EIP-7702 audit. Meeting it by accident while testing passkeys is a reminder that "has code" is the only question the token asks.
What x402 says, and what x402 does
Now the protocol layer. The exact EVM scheme spec still describes the payload's signature field as "The 65-byte signature of the transferWithAuthorization operation" and lists the first verification step as "Verify the signature is valid and recovers to the authorization.from address." Read literally, a 640-byte WebAuthn wrapper fails both sentences. ERC-1271, ERC-6492 and smart wallets are not mentioned anywhere in the scheme document.
The code says something else. typescript/packages/mechanisms/evm/src/shared/verifySignature.ts routes by bytecode, not by byte length: ecrecover when the signer has no code, IERC1271(signer).isValidSignature when it does, and the comment is unusually direct about why the module exists: "It deliberately does NOT fall back to ECDSA when EIP-1271 returns failure. That fallback makes pre-verify accept signatures that on-chain rejects." The settle path in exact/facilitator/eip3009-utils.ts then picks the overload by signature length. A 130-hex-character signature is parsed into v, r, s and sent to the ECDSA overload; anything else is passed whole to the bytes overload. Our 640-byte signature would take the second branch. The Go SDK mirrors this in VerifyUniversalSignature and verify_strict.go, whose doc comment notes that the old 65-byte fast path was removed because it caused "mismatches with on-chain verification" for ERC-7702 accounts.
This behavior was formalized on June 25, 2026, when Carson Roscoe merged PR #2658, "improve & document wallet compatibility", which added a strict verification primitive across TypeScript, Go and Python and a new document, docs/advanced-concepts/wallet-compatibility.mdx. That document defines five payer types: A, plain EOA; B, deployed smart account validated by ERC-1271; C, counterfactual wallet validated after a factory deployment via ERC-6492; D, EIP-7702 EOA with a permissive delegate; E, EIP-7702 EOA with a strict delegate. Its stated invariant is the one that matters to a facilitator's balance sheet: "There is no path that reports a payment as valid and then reverts at settlement." Type B, our passkey wallet once deployed, is supported on every scheme. Type C works for EIP-3009 only when the facilitator has configured eip6492AllowedFactories, and the counterfactual deploy runs before the transfer, gated by that allowlist, as we traced in the ERC-6492 audit. Type E is unsupported. And "Permit2 has no counterfactual mechanism, so deploy the wallet before using any Permit2 flow."
The gap between spec and code was not academic. Issue #623, opened November 9, 2025, reports a passkey-created Coinbase Smart Wallet on Base Sepolia failing with invalid_exact_evm_payload_signature because the facilitator of the day did not parse the ERC-6492 wrapper; it is still open. A week earlier, on November 1, x402-rs issue #26 described the mirror image in the Rust facilitator: logs showing sig_kind="EIP1271" immediately before a transferWithAuthorization that reverted with FiatTokenV2: invalid signature, which is precisely the wrong-overload revert we reproduced above. Meanwhile the community keeps proposing a bigger door. PR #1413, opened March 2, 2026, added an ERC-4337 UserOperation path across four SDKs, explicitly for "Safe wallets using P256 Secure Enclave signing". It was closed unmerged on March 8 with the maintainer's note that "3009 and permit2 do support 4337 wallets" and a request for a spec first; the spec-only follow-up, PR #1599, remains open. The maintainers' position is consistent with what we measured: the passkey does not need a new transfer method, it needs the existing one to route correctly.
Three seams worth naming
The first seam is user verification. Coinbase's wallet calls WebAuthn.verify with requireUV: false, and we confirmed it accepts an assertion whose flags byte is 0x01, user-present only. That is a deliberate usability choice for a consumer wallet, but for an agent treasury it means the passkey ceremony proves a touch, not a biometric. A wallet built for agent spend caps should set the bit the other way, and a facilitator cannot tell the difference from outside.
The second seam is origin. The wallet accepted an assertion whose clientDataJSON claimed origin https://evil.example, because, as its natspec says, origin and rpIdHash are the authenticator's job. On a real device the browser enforces both and a phishing page cannot borrow your passkey. On a server-side signer that fabricates clientDataJSON, as we did, those fields are decoration. That is fine for an agent that owns its P-256 key in a Secure Enclave, and it is the reason a passkey wallet's security is only as good as the authenticator in front of it.
The third seam is gas asymmetry, and it points at the facilitator. A payer of type B or C costs the sponsor more than a type A payer by construction, and the facilitator only learns how much by executing the payer's own code. The x402 verifier performs isValidSignature as a read-only call with no gas cap we could find in either the TypeScript or the Go primitive. "When HTTP 402 Meets the Blockchain", by Qinying Wang, Yong Yang, Yuan Chen, Shouling Ji and Mathias Payer (arXiv, July 21, 2026), names Gas Abuse as one of four attack vectors it found across 15 facilitators serving more than 60,000 sellers and 360,000 buyers. A well-behaved passkey wallet spends 34,837 gas answering the question. Nothing in the protocol stops a malicious one from spending the block limit.
What it means for LLM4Agents
The gateway's job is to make sure an agent's payment clears on the first try, and the payment leg of that job is the facilitator role we described in the verify, settle, supported audit. Today's result changes the set of payers we should expect. A P-256 signer is no longer exotic. It is what an agent gets for free when its key lives in an Apple Secure Enclave, an Android Keystore or a FIDO2 token, the three sources EIP-7951 was written for, and it is what a human operator holds when they co-sign a spend cap from a phone. Every one of those payers arrives at our /verify endpoint as type B or type C, with a signature that ecrecover cannot parse.
The cost model is now measurable rather than theoretical. A passkey settlement on Base is 123,921 gas against 85,504 for an EOA, and 323,543 on any chain where the wallet falls back to Solidity. Our sponsored-gas pricing for agent accounts can be exact about the surcharge and about which chains do not carry it. Since we confirmed 6,900-gas pricing on Ethereum, Base, OP Mainnet, Arbitrum and Polygon, the fallback case is mostly a concern for chains outside the fifteen in x402's network bindings, which is a bounded problem.
The threat surface also shifts. Passkey wallets are a strict improvement in key custody: the private key is non-exportable, so a prompt-injected agent cannot leak it the way it could leak an environment variable. But the ERC-1271 hop hands the facilitator's gas to code it does not control, and the wallet's own policy on user verification and origin is invisible from the outside. Both belong in the model we laid out in the agent security threat model: custody gets better, and verification-time gas becomes something we must bound ourselves.
Staying on the frontier
First, make passkey payers a tested path, not an assumed one. Add the exact fixture from this post to the gateway's facilitator test suite: a Smart Wallet owned by a P-256 key, a 640-byte WebAuthn signature, and assertions that verification routes to the bytes overload and that the v/r/s overload is never reached for a contract payer. Run it on an Osaka-rules fork so the precompile is present, and once more with the precompile absent so the fallback cost is asserted rather than discovered.
Second, bound the ERC-1271 call. Wrap isValidSignature in an explicit gas limit during pre-verification, log the gas each payer's wallet consumes, and reject signatures whose check exceeds a per-chain budget derived from the numbers above (a Coinbase passkey check is 34,837; anything an order of magnitude beyond that is not a wallet we want to sponsor). This closes the Gas Abuse vector at the point where it is cheapest to close.
Third, offer P-256 as a first-class agent identity. The agent registration flow should accept a P-256 public key alongside a secp256k1 address, and provision a passkey-owned smart account for it on Base with the factory in the facilitator's eip6492AllowedFactories list so the first payment can be counterfactual. Pair that with the session-key and spend-cap design from the account abstraction post, with user verification required for any cap increase.
Fourth, publish what we verify. The x402 wallet-compatibility document defines payer types A through E; our /supported response should say which of them each network accepts, which factories are allowlisted, and whether the chain carries the precompile. Buyers' SDKs can then choose a signer before they sign, instead of learning the answer from a revert.
Fifth, watch three threads and act when they move: the open userOp spec PR, which would add a fourth transfer method we would need to support or explicitly decline; any revision of the exact EVM scheme text that retires the "65-byte" language; and Solady's WebAuthn and P256 libraries, whose verifier addresses and malleability rules are the de facto standard the next generation of agent wallets will inherit.
Payments that clear on the first try
LLM4Agents runs stablecoin-funded agent wallets on an OpenAI-compatible gateway, and audits every signature path before it reaches your fleet.
Register your agent