Simple Wallet Mainnet Assurance
Program of record for hosted simple-wallet GA on Base. This document is the work-breakdown (epics, tasks, exit criteria) and the phased roadmap. It does not ship the epics; it defines them so they can be ticketed without losing the two critical-path definitions.
Critical path A — recoverability: a simple wallet is recoverable in every product state.
Critical path B — fund safety: no vulnerability that can cause user fund loss or lock-up on the certified path.
A future commit must not be able to break A or B without CI going red.
Related: Security, ROADMAP.md, operator checklist in deploy/operator/README.md.
Launch bar (locked product policy)
| Item | Policy |
|---|---|
| Certified path | Hosted simple wallet = IdentityWallet with superWallet == false, auth via IdentityStore, CREATE2 via IdentityWalletFactory, Base 8453 USDC |
| Super Wallet (user) | Stays in the product, labeled beta / not fully audited. Users may convert at their own risk or stay on simple. User-facing enableSuper is out of the certification bar |
| Super Wallet (operator) | In-scope. Email restore depends on IdentityStore.recoveryOperator. Today that is a reco 2-of-3 Super (ui/e2e/identity-email-restore.spec.ts). Simple-wallet recoverability is only as safe as that operator. Treat reco Super as operator infrastructure, distinct from user Super UX |
| Second method before funds | Keep on-chain disableRestore (permanent, no re-enable). Require a second on-chain method (EOA or YubiKey) before the product treats the wallet as fundable (show receive address, deployer createAccount, send). Email-off then still has self-recovery via the remaining method |
| Legacy stack | Wallet.sol + AdminGuardianRecovery.sol are not certified for new hosted users. No new simple-legacy creates on mainnet hosted /wallet |
| HMAC partner API | Out of this certification (later epic) |
| Chain | Base mainnet 8453 only for this program |
Do not rely on Super upgrade copy that says email recovery is permanently disabled. That is true for legacy enableAdvanced → _disableRecovery(). Identity enableSuper does not call disableRestore.
Production restoreDelay is 259200 seconds (3 days) — default in scripts/deploy-identity-wallet.ts and tcmain IDENTITY_RESTORE_DELAY. Freeze that number for GA unless audit requires a change.
IDENTITY_RESTORE_ENABLED (API override of on-chain restoreEnabled) is a test hook. It must not be set on tcmain. loadConfig throws if that variable is set and TC_PRODUCT=tcmain or the host is trustless-commerce.com.
Identity implementation, factory, and store are frozen before the Base deploy. A new implementation means a new factory and new addresses. scripts/deploy-identity-wallet.ts writes gitTag (git describe --tags --always --dirty) on the deploy artifact. L4.4 records that tag with the deployed addresses. Creation bytecode for IdentityStore, IdentityWallet, and IdentityWalletFactory is snapshotted in test/snapshots/identity-bytecode.json at solc 0.8.26 (npm run test:wallet-cert).
Production IdentityStore owner is IdentityStoreTimelock. The proposer is the operator multisig. The timelock delay is 259200 seconds unless an audit sets a different break-glass delay. At the L4.4 Base deploy, tcmain points the store owner at that timelock address. setRecoveryOperator and setRestoreDelay go through schedule, wait, then execute. A direct EOA owner call reverts OwnableUnauthorizedAccount (test/IdentityStoreTimelock.ts). The Base deploy does not use this owner until that assignment.
Why this program exists (current gaps)
Recoverability is not true in every state:
disableRestorehas no re-enable; losing the last remaining method after that is permanent lock-up- Undeployed + SQLite wipe needs persist-log (
deploy/overlays/PERSIST-LOGS.md) - Worker / operator misconfig leaves restores
awaiting_operator
Fund safety is not CI-complete:
- Hardhat covers store/wallet math (
test/IdentityStore.ts,test/IdentityWalletE2e.ts) - Playwright email + operator path runs in
.github/workflows/ci.yml(identity-email-restore). It is not a required status check until branch protection says so. Precommit staysscripts/pre-commit-ci.sh - No invariant / fuzz suite
- Security still describes legacy
AdminGuardianRecovery, not IdentityStore operator - No external audit of Identity* contracts
- Store owner setters go through
IdentityStoreTimelockin tests. The Base deploy still has to assign that contract as owner (L1.7 / L4.4)
Trust diagram
flowchart TD
user[User_passkey]
email[Email_OTP]
eoa[Second_method_EOA_or_Yubi]
store[IdentityStore]
wallet[IdentityWallet_simple]
factory[IdentityWalletFactory_CREATE2]
operator[reco_2of3_Super_recoveryOperator]
owner[Store_Ownable_timelock]
bundler[Bundler_EntryPoint_v09]
deployer[wallet_deployer_worker]
sqlite[SQLite_index]
persist[Persist_log]
user --> store
eoa --> store
email --> deployer
operator --> store
store --> wallet
factory --> wallet
bundler --> wallet
deployer --> store
deployer --> factory
sqlite --> email
persist --> sqlite
owner --> store
Spend path (critical path B)
flowchart LR
userOp[UserOp] --> ep[EntryPoint_v09]
ep --> val["_rawSignatureValidation"]
val --> verify["IdentityStore.verify == identityId"]
verify --> exec[ERC7821_execute]
Certified simple wallets never take the Super SUP1 branch. Stranger signatures must fail AA24.
Lost-device restore path (critical path A)
flowchart TD
lostDevice[Lost_all_passkeys]
emailOtp[Email_OTP_recovery_request]
operator[recoveryOperator_initiateRestore]
delay[restoreDelay_3d]
cancel[Existing_method_cancelRestore]
execute[executeRestore_new_method]
secondKey[EOA_or_Yubi_addMethod]
disable[disableRestore]
lostDevice --> emailOtp --> operator --> delay
delay --> execute
delay --> cancel
disable --> secondKey
OTP session does not call initiateRestore. Operator (or an existing method via addMethod) is required.
Threat model
Actors
| Actor | Trust | Can |
|---|---|---|
| User passkey / YubiKey / EOA on identity | User | Spend, add/remove methods, cancel pending restore |
| Email inbox | Partial | Start hosted recovery request; cannot restore on-chain alone |
Reco 2-of-3 Super (recoveryOperator) |
Operator infrastructure | initiateRestore for any identity with restoreEnabled |
Store Ownable |
Break-glass | setRecoveryOperator, setRestoreDelay |
| Bundler / EntryPoint | Honest execution | Submit validated UserOps; cannot forge IdentityStore.verify |
| wallet-deployer guardian EOA | Ops | Deploy CREATE2 when funded; execute/cancel jobs only if it matches recoveryOperator (otherwise awaiting_operator) |
| SQLite | Availability, not authority | Index methods/wallets/jobs; chain is source of truth |
| Persist-log | Disaster recovery | Rebuild API index after DB wipe |
Residual risks after simple GA (accepted)
- Email inbox takeover plus social-engineered operator approval after the 3-day delay
- USDC sent to the CREATE2 address before the second-method gate (persist-log + deployer still apply; product will hide receive)
- Store-owner key compromise (mitigate with timelocked multisig in Epic 2)
- User who converts to Super accepts unaudited recovery/spend rules
disableRestore+ loss of all remaining methods (mitigate with last-method invariant + second-method-before-funds)
Explicit non-goals
- User Super Wallet certification
- HMAC wallet client recovery (wallet-client-api.md)
- Legacy
Wallet.sol/AdminGuardianRecoveryfor new hosted users - Multi-chain identity wallets
- MoonPay / card pay-in
- Commerce invoice sweeper audit (separate M5)
State × recovery matrix (critical path A)
Every cell must have a named test id before P2 exit. TBD-* rows are not yet automated; they are scheduled tasks, not optional.
Legend: Email = operator initiateRestore after OTP. Other-key = existing EOA/Yubi addMethod. Ops = documented manual procedure.
| ID | State | Email / operator | Other-key add | Evidence today | Required evidence |
|---|---|---|---|---|---|
| S1 | Undeployed, unfunded, restore on | Yes (identity methods; wallet code may stay 0x) |
Yes | Playwright unfunded Alice | wallet-cert + e2e |
| S2 | Undeployed, funded, restore on | Yes; deployer createAccount then restore |
Yes | Playwright funded Alice | wallet-cert + e2e |
| S3 | Deployed, restore on, passkey remaining | N/A (not lost) | Yes | IdentityStore pair / addMethod | keep |
| S4 | Deployed, all passkeys lost, restore on | Yes | If EOA/Yubi remains | Playwright email restore | wallet-cert + e2e |
| S5 | Pending restore, delay not elapsed | Cannot start another (RestorePending) |
Yes; cancel with old key | IdentityStore delayed restore | add race test |
| S6 | Pending, ready to execute | Anyone executeRestore |
Yes | IdentityStore + worker execute | wallet-cert |
| S7 | Cancelled pending | Can start again | Yes | IdentityStore cancel | keep + e2e cancel |
| S8 | disableRestore |
No (RestoreIsDisabled) |
Yes if a method remains | IdentityStore “disableRestore needs two methods…”; IdentityWalletE2e “remaining passkey still spends after disableRestore…” | same tests |
| S9 | Email lost, other-key remains | No OTP path | Yes | Other-keys wizard / recover prove | e2e other-keys |
| S10 | Operator Super down / EOA ≠ operator | Request stuck awaiting_operator |
Yes | Worker branch | Ops runbook TBD-S10 |
| S11 | SQLite wiped, chain intact | After persist-log replay | After reindex | CommercePersistRecoveryE2e | live drill TBD-S11 |
| S12 | Worker down, delay elapsed | Manual executeRestore (permissionless) |
Yes | — | Ops runbook TBD-S12 |
| S13 | User Super converted | Not certified | User-accepted | — | Beta banner only |
| S14 | recoveryOperator == 0 |
Initiate reverts RestoreOperatorUnset |
Yes | IdentityStore unset operator | keep |
Product gates in this tree:
- Receive UI and deployer
createAccountwait untilmethodCount >= 2(useIdentityFundable,deployIdentityIfReady, Playwrightidentity-fund-gate.spec.ts, HardhatIdentityDeployerGate) removeMethodrevertsLastMethodwhenmethodCount <= 1, including afterdisableRestoredisableRestorerevertsRestoreNeedsSecondMethodwhenmethodCount < 2, then still requires an EOA (RestoreRequiresEoa)
CREATE2 salt (golden vs factory predictAddress):
keccak256(abi.encode("TC-IDENTITY-WALLET-V1", identityId, index))
See commerce/shared/wallet-address.ts deriveIdentityWalletSalt. Existing tests: test/WalletCounterfactual.ts.
Threat table (critical path B)
| ID | Attacker / failure | Impact | Mitigation | Test / evidence |
|---|---|---|---|---|
| F1 | Unknown passkey UserOp | Drain | IdentityStore.verify must match identityId |
IdentityWalletE2e AA24 |
| F2 | Implementation upgrade of existing clone | Unexpected spend rules | No UUPS; factory walletImplementation immutable; new factory = new addresses |
Factory code review + bytecode snapshot |
| F3 | Wrong identityId / salt at first createAccount |
Permanent wrong controller | Salt binding tests; API uses deriveIdentityWalletSalt |
WalletCounterfactual + CommerceIdentity |
| F4 | USDC on undeployed CREATE2, never createAccount |
Lock-up | Deployer min-balance deploy; persist-log | persist-recovery harness + TBD-S2 |
| F5 | Compromised reco Super | Initiate restore for any restoreEnabled identity |
2-of-3; 3-day delay; cancel with remaining method; store-owner break-glass under timelock | IdentityWalletE2e 2-of-3 restore + e2e |
| F6 | Compromised store owner | Swap operator / set delay 0 | Production owner = timelocked multisig | Ops + TBD-F6 |
| F7 | disableRestore then lose last method |
Permanent lock-up | Second method before funds; cannot remove last method if restore off | IdentityStore last-method test; IdentityWalletE2e “remaining passkey still spends…” |
| F8 | Cancel vs execute race | Attacker method lands | Worker cancel-before-execute; on-chain cancel before executeAfter wins |
IdentityStore “cancel before the delay blocks executeRestore”; IdentityWalletE2e “cancel before the delay blocks execute…” |
| F9 | Email OTP only | Session / request, not on-chain restore | Operator still required | CommerceIdentity + hosted recovery tests |
| F10 | SQLite / chain desync | Login vs UserOp mismatch | Chain is authority; reindex after restore/remove | persist + TBD-S11 |
| F11 | IDENTITY_RESTORE_ENABLED=0 in prod |
Email restore silently dead | Ban on tcmain env examples; boot throws | MainnetBoot |
| F12 | User Super (enableSuper) |
Uncertified spend/recovery | Beta warning; out of bar | Copy + UX |
P0 rule: no P0 without a mitigation and a test or ops procedure id.
Epics
Each epic: goal, in/out of scope, tasks, exit, evidence.
Epic 0 — Scope freeze and threat model
Goal: One agreed launch bar, trust diagram, and residual-risk list.
In scope: This document; Security IdentityStore section; Super beta copy (UI/legal) as a follow ticket.
Out of scope: Implementing gates or CI.
Tasks:
- [x] Publish this program document
- [x] Super beta banner + legal: unaudited; stay on simple or use at own risk
- [x] Align Super upgrade strings so they do not claim Identity
enableSuperdisables email restore - [x] Freeze “no new hosted legacy Wallet.sol creates” in create/wallet-config
Exit: Policy in this file; security.md describes IdentityStore operator, not only AdminGuardian.
Evidence: this page; security.md; ROADMAP.md M-wallet.
Epic 1 — Recoverable in every state (path A)
Goal: Every matrix cell is recoverable or has an explicit residual + ops procedure.
In scope: Second-method-before-funds; last-method / disableRestore invariants; operator runbook; cancel vs execute; prod ban of restore env override.
Out of scope: User Super recovery certification.
Tasks:
- [x] API/UI: hide receive address until identity has ≥2 on-chain methods
- [x] wallet-deployer: skip
createAccountuntil ≥2 methods (persist-log still covers premature deposits) - [x] Solidity: if
restoreEnabled == false, revertremoveMethodthat would leave zero methods - [x] Solidity (preferred): revert
disableRestoreunlessmethodCount >= 2 - [ ] Operator runbook: reco 2-of-3 as
recoveryOperator; S10/S12 procedures; break-glasssetRecoveryOperatoronly via Epic 2 timelock - [x] Hardhat: cancel mined before delay wins against
executeRestore(F8) - [x] Remove or ignore
IDENTITY_RESTORE_ENABLEDon tcmain examples; fail boot if set in production-like config - [ ] Keep lost-device cancel UX on simple; do not require user Super for cancel
Exit: every matrix cell has a named test id or ops procedure; no cell is “hope the worker is up” without TBD-S12.
Epic 2 — No fund loss or lock-up (path B)
Goal: Spend only via validated simple-wallet UserOps; recovery cannot lock funds; governance is slow.
In scope: CREATE2 goldens; store-owner timelock; drain-after-restore / drain-after-disable; counterfactual USDC; email-takeover residual documented.
Tasks:
- [x] Bytecode/ABI snapshots for IdentityStore, IdentityWallet, IdentityWalletFactory
- [x] Certify in docs: new implementation requires new factory and new addresses
- [ ] Production store owner = timelocked multisig;
setRecoveryOperator/setRestoreDelayare break-glass - [x] Hardhat: spend after successful restore; spend after disableRestore using remaining EOA/Yubi; AA24 still holds
- [ ] Document F4: deployer min-balance + persist-log; never “unreachable because never deployed”
- [x] User Super: beta warning on convert; converting leaves the certified bar
Exit: threat table fully filled; no P0 without mitigation.
Epic 3 — Certification tests (commits cannot silently break A/B)
Goal: Breaking email restore, cancel, execute, second-method gate, or UserOp auth is red on main without --no-verify / skipped CI.
In scope: Tagged Hardhat wallet-cert; CI job for identity email restore (or equivalent); snapshots; CODEOWNERS.
Out of scope: Full UI e2e suite in precommit (too slow); optional Foundry later.
Tasks:
- [x] Tag suite
wallet-cert: existing IdentityStore delay/cancel/execute/disable + IdentityWallet UserOp + Super-as-operator initiateRestore (test/IdentityWalletE2e.ts“2-of-3 initiates a delayed identity restore”) + new matrix / F8 / last-method tests. The GitHub job is not a required check until branch protection says so - [ ] Required GitHub Actions job for
ui/e2e/identity-email-restore.spec.ts(or a headless subset). The workflow runs it; branch protection still has to require the check - [x] ABI/bytecode snapshots; PR fails on accidental bytecode change
- [x]
CODEOWNERSoncontracts/wallet/Identity*.soland cert tests - [ ] Optional later: Foundry invariants (method count, pending XOR restoreEnabled, operator Super threshold)
- [x] Ban
IDENTITY_RESTORE_ENABLEDindeploy/overlays/env.api.tcmain.exampleand dist copies
Exit: a commit that breaks path A or B fails CI.
Evidence today (not yet a gate):
test/IdentityStore.ts— register, verify, disableRestore, delayed initiate/cancel/execute, addMethod after disabletest/IdentityWalletE2e.ts— ping, AA24, factory idempotency, 2-of-3 operator restoretest/CommerceIdentity.ts— sessions, recover prove, restore_disabled flagtest/CommercePersistRecoveryE2e.ts/test/helpers/persist-recovery-harness.tsui/e2e/identity-email-restore.spec.ts— reco Super operator, unfunded, cancel, funded+pay, disableRestore (local stack only)
Epic 4 — Mainnet ops and live drills
Goal: Production wiring and a signed drill log, not only Hardhat.
Tasks:
- [ ]
deploy/operator/README.mdstep 6:npm run wallet:deploy:base, copyIDENTITY_STORE_ADDRESS/WALLET_FACTORY_ADDRESS, fund bundler + wallet-deployer, setrecoveryOperatorto reco 2-of-3 Super - [ ] Confirm on-chain
restoreDelay == 259200 - [ ] Live Base drill (throwaway identity): unfunded restore, funded restore+pay, cancel, disableRestore + EOA add, persist-log replay
- [ ] Monitoring: recovery job age,
awaiting_operator, failed execute, store-owner txs - [ ] SQLite backup + persist-log backup as recovery inputs
Exit: drill log with tx hashes attached here or in ops notes.
Epic 5 — External audit
Goal: Independent review of the frozen simple-wallet + operator-Super surface.
In scope: IdentityStore, IdentityWallet, IdentityWalletFactory, IdentitySigLib, IdentityTypes, IdentityErrors; operator Super only as recoveryOperator (initiateRestore UserOp).
Out of scope: User Super UX, commerce invoices, MoonPay, HMAC client API.
Tasks:
- [ ] Freeze git tag + addresses/ABIs
- [ ] Hand this document + threat model + cert test list + residuals to auditors
- [ ] Fix P0/P1; recertify CI
- [ ] Publish summary (scope, version, residuals: email+operator trust, Super beta)
Exit: audit report filed; P0/P1 closed; residuals listed for users.
Later (not a simple-GA gate)
- User Super Wallet certification (own assurance program)
- HMAC partner recovery certification
- Foundry invariant/fuzz campaign
- Multi-chain identity wallets
Roadmap
flowchart LR
p0[P0_Spec_and_gates]
p1[P1_Contract_invariants]
p2[P2_Cert_tests_in_CI]
p3[P3_Ops_drills]
p4[P4_Audit]
p5[P5_Mainnet_simple_GA]
p0 --> p1 --> p2 --> p3 --> p4 --> p5
| Phase | When | What | Exit |
|---|---|---|---|
| P0 | Week 1 | Freeze policy (this doc); Super beta copy; second-method-before-fund spec; matrix filled (tests may still be TBD) | Epic 0 |
| P1 | After P0 | Solidity last-method / disableRestore invariants; store-owner timelock design | Epic 1 contract tasks + Epic 2 governance |
| P2 | After P1 | wallet-cert Hardhat + snapshots + CODEOWNERS + required CI for identity-email-restore |
Epic 3 |
| P3 | After P2 | Base live drills, monitoring, persist-log backups | Epic 4 |
| P4 | After P3 | External audit of frozen tag | Epic 5 |
| P5 | After P4 | Simple wallet GA on https://trustless-commerce.com; Super remains beta banner |
Paths A and B certified |
User Super certification is later, not a gate for P5.
Tracking
Update checkboxes in the epics as work lands. Do not mark P5 until:
- Matrix cells S1–S12 and F1–F12 have tests or named ops procedures
wallet-cert+ identity-email-restore CI are required onmain- Live drill log exists
- Audit P0/P1 are closed
Simple GA does not wait on user Super audit.