Retainer
Customers

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

what the customer's wallet is asked to sign
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

An upgrade after approval changes nothing
Spending checks the stored approval, not the signature: 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:248

MetaMask 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

A deliberate, scoped exception: MetaMask's delegator
One EIP-7702 delegate is accepted as an owner: MetaMask's 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…

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:

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

Not yet built: funding with a signature alone
USDC supports EIP-3009: the customer signs one more typed-data message authorising a transfer from their wallet to their smart account, and Retainer submits it and pays the gas. The customer would need no ETH at all. This has been simulated against Base Sepolia USDC — the authorised transfer moves the funds, and the same authorisation signed by any other key is refused — but it is not built, and nothing here depends on it.

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

RefusalWhat it catches
wrong_domainTyped data signed under the wrong domain version. apps/web/app/api/permissions/route.js:156
personal_signThe hash signed as a plain message rather than typed data. apps/web/app/api/permissions/route.js:152
unwrapped_hashThe 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_ownerCorrectly formed typed data from a key that does not own the account. apps/web/app/api/permissions/route.js:147
account_mismatchA permission for a smart account the signer does not own. apps/web/app/api/permissions/route.js:121
eip7702An owner upgraded under EIP-7702, as above. scripts/phase3-drills.mjs:250
contract_ownerA contract as the owner — a Safe cannot own this account, and pays by transfer instead. scripts/phase3-drills.mjs:150
policyAny term other than the configured allowance, period, expiry, start window, spender or token. apps/web/app/api/permissions/policy.js:73
rate_limitedA 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

What signing in does not do
It stops Retainer serving one customer's records to another. It does not make anything private: every permission and every charge is recorded on a public blockchain, and anyone can look up an address on Basescan. The page says so to every visitor.apps/web/components/account/account-view.tsx:327

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.