Signing with any wallet
A customer does not need a Base Account. Any wallet that can sign typed data — MetaMask, Rabby, the Coinbase extension — can own a smart account that holds the spend permission. This page is how, and what it costs the customer.
Coinbase's hosted consent screen refuses newly created Base Accounts on Base Sepolia, which until now meant nobody but the project's own scripted wallet could sign a permission on testnet. That is base/account-sdk#363. A wallet-owned smart account never reaches that screen.
The shape: the wallet owns the account, it is not the account
A spend permission's account must be a CoinbaseSmartWallet, because the manager moves money by callingexecute() on it. An ordinary wallet address cannot be that account — but it can ownone. So the customer's wallet address is made the first owner of a smart account created for it, and the SpendPermissionManager the second.packages/chain/src/smart-account.js:48
- The address is known before the account exists. It is derived from the owners and nonce 0, so one wallet always maps to one smart account, which can be shown to the customer and funded before anything is deployed. packages/chain/src/smart-account.js:22
- The manager is an owner from creation. Without it every charge reverts, because
execute()only accepts an owner. Naming it in the initial owners removes the extra transaction the Phase 1 setup script needed. The drill proves both halves: the manager is an owner of the freshly created account, and the charge settles. scripts/phase3-drills.mjs:162
What the wallet actually signs
MetaMask will not sign a raw 32-byte hash, and it does not have to. The account validates a signature over its ownreplaySafeHash(hash), which is the EIP-712 digest of CoinbaseSmartWalletMessage{hash}under the account's domain — so ordinary eth_signTypedData_v4 produces exactly what it checks.packages/chain/src/smart-account.js:74smart-wallet ERC1271.sol:69
domain "Coinbase Smart Wallet", version 1, chain 84532, verifyingContract = their smart account
primaryType CoinbaseSmartWalletMessage
message { hash: <the permission's hash, from SpendPermissionManager.getHash> }One signature, no separate deployment
The customer signs before their account exists. The server wraps the signature in ERC-6492 — the factory, thecreateAccount call, and the signature — and submits it to approveWithSignature, which the executor already pays for. The manager's validator creates the account, then checks the signature, then approves the permission, in one transaction. packages/chain/src/smart-account.js:106 contracts/src/SpendPermissionManager.sol:299
The server always sends the wrapped form. For a returning customer whose account already exists, the validator tries the inner signature first and skips the deployment. One path, new or returning. apps/web/app/api/permissions/route.js:163
EIP-7702 accounts are detected before anyone signs
The signature check uses plain ecrecover only when the owner has no code. A wallet that has been upgraded under EIP-7702 has code — a delegation designator — so its signatures are judged by its delegate contract instead, and whether that contract accepts this message has not been verified.solady SignatureCheckerLib.sol:30
So the owner's code is read from the chain the moment the wallet connects, and a 7702 account delegating to anything but the one verified delegator below is told plainly instead of being asked to sign into a failure. apps/web/components/sign/sign-flow.tsx:120 The server checks again before doing anything that costs gas. apps/web/app/api/permissions/route.js:130This is the specific thing the hosted flow gets wrong: its capability check reports the chain as supported, and the popup then refuses. Here the answer comes from the chain, not from the wallet's description of itself. packages/chain/src/smart-account.js:120
spend requires only that the permission is approved and not revoked.contracts/src/SpendPermissionManager.sol:496 contracts/src/SpendPermissionManager.sol:695 The drill demonstrates it on-chain — it upgrades the owner to a 7702 account after approval and simulates a spend at that block, which still validates. scripts/phase3-drills.mjs:248MetaMask upgrades accounts by itself
In the first live session MetaMask converted both customer accounts to EIP-7702 accounts without this page asking it to. Each time it happened inside a revoke: MetaMask sent the revoke through its own relayer and its DelegationManager, carrying an authorisation that delegated the account to its EIP7702StatelessDeleGator in the same transaction. 0x2b19810b… 0x48c02895…
Existing permissions are unaffected, since spending checks the stored approval. Their owner sees and revokes them on the permissions page after signing in apps/web/components/account/account-view.tsx:157 — and signing in needs only the key, so it works for an upgraded account whatever its delegate. packages/chain/src/signin.js:56 A new registration from an upgraded account was refused at first, because its signatures are judged by the delegate — which is what the exception below resolves.
MetaMask also connects whichever account is selected in it, so a returning customer can arrive as a different account without noticing. The page shows the connected account prominently and says when it differs from one that registered earlier in the same browser sessionapps/web/components/sign/sign-flow.tsx:369; re-runs every check when the wallet reports a different account, so a refusal for one account never lingers for another apps/web/components/sign/sign-flow.tsx:169; and offers MetaMask's own account picker from the refusal apps/web/components/sign/sign-flow.tsx:182. That behaviour is checked in a browser, with a negative control run against the previous page. scripts/check-sign-ui.mjs:103
EIP7702StatelessDeleGator, by its single address, hardcoded with the reasoning beside it. packages/chain/src/smart-account.js:153 Its verified source checks a signature withECDSA.recover(hash, signature) == address(this)— a plain signature by the account's own key, with no wrapping of its own — so the security property is the same as a plain account's, which is what the rest of this flow already relies on.packages/chain/src/smart-account.js:136 It was tested before it was trusted: a key delegated to it registers, and the same message signed by any other key is refused. 0x930de800…The trust is pinned to code, not just to the address. Every check hashes the code deployed at that address and compares it with the version that was reviewed; if they differ, the owner is refused. packages/chain/src/smart-account.js:156packages/chain/src/smart-account.js:183 Every other delegate is still refused, and so is every contract account that is not a 7702 delegation. packages/chain/src/smart-account.js:179 The server applies the same decision before any gas.apps/web/app/api/permissions/route.js:130
The drill keeps all of it honest on every run: an owner delegated to MetaMask's delegator registersscripts/phase3-drills.mjs:291, a different key's signature for that account is refusedscripts/phase3-drills.mjs:288, the same address with a different code hash is refusedscripts/phase3-drills.mjs:300, and an owner delegating to any other contract is refused outrightscripts/phase3-drills.mjs:250. It was first exercised end to end on-chain here. 0xbb7dd1d2…
The consent screen is the whole consent
Coinbase's screen shows the terms. MetaMask shows an opaque hash. So on this path the customer's understanding rests entirely on the sign page, and it is built around that:
- The terms come first, before any wallet is connected: token, exact cap, period, start, expiry, who collects, where it goes, and how to stop it.
- Every figure is rendered from the exact struct that gets hashed and signed.
- The sign button stays disabled until the hash computed on the page equals the manager's own
getHash, read from the chain. apps/web/components/sign/sign-flow.tsx:283 apps/web/components/sign/sign-flow.tsx:290 - That same hash is shown as the fingerprint the wallet will display, with an instruction not to sign if it differs — which turns a blind signature into one the customer can check. apps/web/components/sign/sign-flow.tsx:468
- The server recomputes the hash itself and refuses a mismatch, so a page that showed one thing and submitted another is caught. apps/web/app/api/permissions/route.js:106
Funding: the real cost of this path
Charges draw from the smart account, not from the customer's wallet address, and it starts empty. A customer has to move USDC into an address they had never seen a minute earlier. The sign page makes this its own step, with the live balance, a one-click transfer from the connected wallet, and an explicit acknowledgement if they choose to sign first. Whatever is in the account can be withdrawn to the owner's wallet from the same step. apps/web/components/sign/sign-flow.tsx:210apps/web/components/sign/sign-flow.tsx:418
What the server refuses, and why
Every refusal is sent before a transaction is built, and the drill asserts each one three ways: the right reason, no executor transaction, no row written. scripts/phase3-drills.mjs:130
| Refusal | What it catches |
|---|---|
wrong_domain | Typed data signed under the wrong domain version. apps/web/app/api/permissions/route.js:156 |
personal_sign | The hash signed as a plain message rather than typed data. apps/web/app/api/permissions/route.js:152 |
unwrapped_hash | The bare permission hash, without the account's replay-safe wrapper — which would be valid for any account. apps/web/app/api/permissions/route.js:154 |
not_owner | Correctly formed typed data from a key that does not own the account. apps/web/app/api/permissions/route.js:147 |
account_mismatch | A permission for a smart account the signer does not own. apps/web/app/api/permissions/route.js:121 |
eip7702 | An owner upgraded under EIP-7702, as above. scripts/phase3-drills.mjs:250 |
contract_owner | A contract as the owner — a Safe cannot own this account, and pays by transfer instead. scripts/phase3-drills.mjs:150 |
policy | Any term other than the configured allowance, period, expiry, start window, spender or token. apps/web/app/api/permissions/policy.js:73 |
rate_limited | A second registration from one signer inside the cooldown, or too many from one client or overall. apps/web/app/api/permissions/route.js:191 |
Before this path existed the server checked only the spender and the routing, so a caller could make the executor pay to register any allowance, period or expiry. Every term is now pinned to configuration, and the transaction is simulated before it is sent.apps/web/app/api/permissions/policy.js:26 apps/web/app/api/permissions/route.js:211
Revoking
The manager accepts revoke only from the account itself. contracts/src/SpendPermissionManager.sol:397 The account accepts executefrom its owner, so the permissions page asks the customer's wallet for one call:account.execute(manager, revoke(permission)). apps/web/components/account/account-view.tsx:157The drill sends exactly that from the customer's own key and confirms the permission is revoked on-chain. scripts/phase3-drills.mjs:258 In the live session MetaMask submitted the same call through its own relayer instead, and upgraded the account in the same transaction; the revocation landed all the same. 0x2b19810b…
Nothing indexes the revocation event, and a customer's revoke never passes through our server, so the page reports it afterwards. The record is written only if that transaction succeeded, contains the manager's revocation event for exactly this permission, and the manager reports it revoked now. apps/web/app/api/permissions/revoke/route.js:57
Coming back: signing in to see your permissions
A customer returns to /accountto see what each permission allows, what has been taken under it, and to cancel it. Those records are served only to a browser that has proven it controls the wallet: the wallet signs a short message, and the site sets a session from it. Until 2026-09-11 anyone could list any address's permissions by naming it; that route now refuses. apps/web/app/api/permissions/route.js:26
- The message cannot be mistaken for a permission. Its domain is
Retainer Sign-In, with no verifying contract and its own type, and it says it costs nothing and authorises no payment. packages/chain/src/signin.js:19 - One nonce, one try, five minutes, one site. The server issues a nonce bound to the address and the requesting origin and keeps its own copy, so it rebuilds the message itself. packages/db/migrations/006_session_nonces.sql:7 It refuses another origin, apps/web/app/api/session/route.js:38 a used nonce, apps/web/app/api/session/route.js:49 an expired one, apps/web/app/api/session/route.js:51 and spends the nonce before checking the signature. apps/web/app/api/session/route.js:58
- Only the key — not the registration rule. Sign-in needs one fact: that the person holds this wallet's key. The signature is checked by plain ECDSA recovery against the address, never through a contract, apps/web/app/api/session/route.js:64 so it covers a plain account and every EIP-7702-upgraded one without consulting, or trusting, its delegate. A true contract account has no key and is refused. apps/web/app/api/session/route.js:54 Registration keeps its own, stricter rule, because there the owner's code is what the smart account will ask to check signatures — the reasoning for the difference is beside the code. packages/chain/src/signin.js:37
- An HTTP-only cookie, MACed with a server secret and compared in constant time, lasting 12 hours. apps/web/lib/session.js:38 apps/web/lib/session.js:53
- The address comes from the cookie and nowhere else — the route reads no parameter, so there is nothing to change to see another customer. apps/web/app/api/me/permissions/route.js:22
- The page shows data only while the signed-in address is the connected wallet. Switching accounts in the wallet clears it at once. apps/web/components/account/account-view.tsx:77 apps/web/components/account/account-view.tsx:80
Each refusal — another key's signature, a replayed nonce, an expired one, another origin, a tampered cookie, and one customer's session asking for another's data — is checked beside the same request done right by scripts/check-signin.mjs; the two-wallet test in a real browser is scripts/check-account-ui.mjs.
How it is recorded
Each permission records how it was signed — base_account or eoa_owned — and, for the second, which wallet owns the account. Permissions created before this was recorded keep an empty value rather than a guess.packages/db/migrations/004_signing_path.sql:15 packages/db/migrations/004_signing_path.sql:26
Not yet proven
- What MetaMask displays around the signature. Signing with the real MetaMask client is proven: permission #18 was registered from a MetaMask account 0xa647cbb0…, and the fingerprint on the page matched the hash MetaMask showed. What else MetaMask displayed — including any security warning — is being recorded from that session and is not written up here yet.
- A stranger with MetaMask, on the public site. The public deployment registers permissions and a hosted worker charges them. The whole flow has run there through the real pages with a scripted wallet — registration of permission #26 0x94d36955… — but not yet with a fresh MetaMask account on the public URL.
- Mainnet. The one-signature path depends on Solady's ERC-6492 verifier being deployed on the chain. It is on Base Sepolia; mainnet has not been checked.