Repository navigation
feat(contracts): TotalReclawAccount — revocable session-key signing (P3-1, P3-2, P3-4) - #636
Conversation
…P3-1, P3-2, P3-4) Implements Option E Phase 3's account-model decision (D1, approved): a `SimpleAccount` (eth-infinitism v0.7.0) subclass, `TotalReclawAccount`, that accepts an owner-EOA signature (owner-first, byte-identical to stock) OR a scoped, revocable session-key signature — replacing the standalone-module design (`SessionKeyModule.sol`, never deployed, and per the account-model memo §5 unable to pass ERC-7562 bundler validation because its grant registry keys on `signer` rather than `msg.sender`). P3-1/P3-2 — `contracts/account/contracts/TotalReclawAccount.sol`: - Constructor-less (hardcoded canonical v0.7 EntryPoint), so CREATE2 init code is bare `creationCode`. - All new session-key state in ERC-7201 namespaced storage; zero plain state variables added (verified — see storage-layout gate below). - `_grantDigest`, the four EIP-712 typehash/domain constants, `_isCallDataInScope`/`_isBatchInScope`/`_inSelectors`, `_recoverEcdsa` (EIP-2 malleability guard), and monotonic `_minNonces` replay protection ported VERBATIM from `SessionKeyModule.sol` per the spec's explicit instruction. - P3-3's `verifyingContract` → account-address binding applied as part of the module→account move (falls out of `address(this)` now meaning the account, not a separate module). - Deliberate deviation from a literal "call stock's exact code" port: the owner path uses `ECDSA.tryRecover` instead of `ECDSA.recover` (which reverts on non-65-byte input), because a literal port would revert the whole `validateUserOp` on every session-key lazy-install UserOp before ever reaching the session-key branch. Reasoned through and documented at length in the function's NatSpec — flagged in the PR description as the one thing to scrutinize hardest. - No custom errors: `ISessionKeyModule.sol`'s three dead errors and `SessionKeyModule.sol`'s two more (never reverted, by the same defect) are not carried forward; validation fails closed via SIG_VALIDATION_FAILED and `revokeSessionKey` reuses the inherited `onlyOwner` modifier's existing "only owner" require. - `SessionKeyModule.sol` gains a `grantDigest` external pure wrapper so `SessionKeyModule.t.sol`'s `_grantDigestExt` calls the production digest path instead of a hand-copied mirror of it (the exact duplication pattern that let the §2 `bytes4[]` selector-hashing bug ship unnoticed). P3-4 — tests (`contracts/account/test/`): - `TotalReclawAccount.t.sol`: 29 fast, non-forked unit tests (fresh ERC1967Proxy + Foundry-controlled owner) — the six executeBatch scope-invariant tests ported from `SessionKeyModule.t.sol:424-591`, plus owner-path/session-key/revoke/replay/grant-binding coverage, a pinned CREATE2-address test (NOT the tautological `test_create2_address_unchanged_after_module_install` pattern), and a local slot-0 storage check. - `TotalReclawAccountFork.t.sol`: 6 read-only fork tests against live Gnosis mainnet. Upgrades the REAL, already-deployed, 66-UserOp-history account (`0x2c0CF74B2b76110708CA431796367779e3738250`) via the owner EOA's direct `upgradeToAndCall` (the audit-risk memo §5 escape hatch) and asserts owner()/slot-0/EntryPoint-nonce are byte-unchanged, plus upgrade idempotence. A second account, deployed fresh through the real, live factory with a Foundry-controlled owner (we do not and should not hold the real owner's key), exercises the full signature paths: owner-signed validates, session-key lazy-install + steady-state validates, out-of-scope is rejected, and revocation actually blocks reuse. - `storage-layout-diff.sh`: CI gate wiring `forge inspect ... storageLayout` (already emitted, never consumed before this) — asserts `TotalReclawAccount`'s storage array is byte-identical to the `SimpleAccount` it subclasses. Currently: one entry, slot 0, `owner`, in both. Isolation note: `contracts/account/` is a deliberately SEPARATE Foundry project (own `foundry.toml`/`lib`/`remappings.txt`), not new files under the existing `contracts/` tree. Adding `account-abstraction`/ `openzeppelin-contracts` to the outer project's `lib/`+`remappings.txt` was tried first and reverted: solc embeds the full project-wide `settings.remappings` into EVERY compiled contract's metadata regardless of use, which shifted `EventfulDataEdge`'s bytecode and broke `DataEdgeStagingCreate2.t.sol`'s pinned CREATE2 address for the real deployed staging DataEdge — a self-inflicted change to a contract this PR has nothing to do with. Full isolation via a separate project root was the only fix that left the outer project's compiled output byte-for-byte unaffected (re-verified: 26/26 outer tests pass unchanged). Test results: 35/35 passing in contracts/account (29 unit + 6 fork, including live-Gnosis fork tests); 26/26 unchanged in the outer contracts/ project. Out of scope (per assignment): core Rust (P3-5), Python client (P3-6+), SPA (P3-13), subgraph (P3-14), and P3-15 docs (CLAUDE.md/SECURITY.md). Never deployed anything; never sent a transaction; fork tests are read-only simulation against a public RPC. Refs: totalreclaw-internal #621 task 4 (P3-1, P3-2, P3-4). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`test_pinned_implementation_create2_address` passed locally and FAILED in
CI, predicting a different address:
CI: 0x7D12D9f0Ad437895c65F9dC7580cC712c8268066
pinned: 0x25D4E71865E8A47F010219526A618f950a8f86B4
Not a flaky test — the build was not reproducible. Foundry's default
`bytecode_hash = "ipfs"` appends a hash of the solc metadata JSON to the
bytecode, and that JSON varies with toolchain details beyond the pinned
`solc_version`, so the same source compiled in two environments produces
two different `creationCode` values and therefore two different CREATE2
addresses. Pinning `solc_version` and the optimizer settings (already
done) is necessary but not sufficient.
This matters well beyond the test. The deployment plan pins a CREATE2
address for the implementation, and a pin that depends on WHO compiled the
contract is not a pin — nobody could independently reproduce or verify the
deployed implementation, which is precisely the property an external
auditor and any user checking our deployment would rely on.
Fix: `bytecode_hash = "none"` + `cbor_metadata = false` in
contracts/account/foundry.toml. `creationCode` becomes a pure function of
(source, solc version, optimizer settings). Verified the CBOR metadata
trailer is gone from the compiled object.
Pinned address updated to the post-fix deterministic value
0x6d17938F29072B3b7E883d410076B0D73676Cf34, with the reasoning recorded on
the test so a future reader does not "fix" it by re-pinning to whatever
their own machine emits.
Trade-off accepted and noted: stripping the metadata hash makes
block-explorer verification marginally less automatic. Exact-settings
verification still works; determinism is worth more for a CREATE2 deploy.
Verified after the change: account project 29 unit + 6 fork (live Gnosis)
= 35/35; outer contracts project 26/26 unaffected.
Refs: #621 task 4, P3-1/P3-4
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Orchestrator review — verified independently, one real defect found and fixedI re-ran everything rather than accepting the implementation report, because this is the one contract in the phase where a bug is unrecoverable. Defect found: the build was not reproducible
Root cause was not a flaky test. Foundry's default This mattered well beyond the test. The deployment plan pins a CREATE2 address for the implementation. A pin that depends on who compiled it is not a pin — no auditor and no user could independently reproduce or verify the deployed bytecode, which is exactly the property being paid for. Fixed in I first hypothesised divergent source paths; a two-path local compile disproved it (byte-identical). Recording that so the next person doesn't re-run the same dead end. Security-critical paths I verified by reading, not by report
Independently re-runThe fork tests do upgrade the real deployed account Not mergingGreen and reviewed, but leaving the merge to Pedro: D2 (external audit) is being commissioned precisely because a bug here freezes vaults permanently. Merging isn't deploying, so this is a judgement call rather than a gate — say the word and I'll land it. Worth handing the auditor the reproducibility fix above as context; it is the kind of thing that is invisible in a source-only review. 🤖 Generated with Claude Code |
Summary
Implements work-leaves P3-1, P3-2, P3-4 of Option E Phase 3 (revocable session-key signing), per
docs/specs/cred/phase3-implementation-spec.md§3.1–3.4 in the private repo. Do not merge — this is unaudited code touching the one function that can permanently brick a user's vault (audit-risk memo §1); it needs a real review pass and the external audit (D2) before it goes anywhere near a real account.TotalReclawAccount: aSimpleAccount(eth-infinitism v0.7.0) subclass overriding_validateSignatureto accept an owner-EOA signature (tried first, byte-identical to stock) or a scoped, revocable session-key signature.SessionKeyModule.sol(never deployed — replaced by this design per the account-model memo §5, which found the standalone-module design cannot pass ERC-7562 bundler validation).D1 (account model: UUPS over kernel v3) is recorded as approved per session context; PF-1/PF-2 (Pimlico sponsorship of self-targeted UserOps, relay
eth_callconstraints) are noted as still open in the source docs but don't block this contract-and-tests leaf.What was ported verbatim vs. rewritten
Verbatim from
SessionKeyModule.sol(spec: "re-deriving them is pure risk"):_grantDigest+ the four EIP-712 typehash/domain constants_isCallDataInScope/_isBatchInScope/_inSelectors(the cred-6 per-inner-callexecuteBatchinvariant)_recoverEcdsawith the EIP-2 malleability guard_minNoncesreplay protection_tryDecodeInstallSig/_decodeInstallSigself-external try/catch decode patternMechanically changed (per the spec's table):
msg.sender→address(this);_grants[account][signer]/_minNonces[account][signer](module storage, the shape ERC-7562 rejects) →signer-keyed maps in the account's own ERC-7201 namespaced storage;g.verifyingContract != address(this)now binds to the account (P3-3, folds out of the module→account move for free);revokeSessionKey(account, signer)gatedmsg.sender == account→revokeSessionKey(signer)gated by the inheritedonlyOwner(accepts the owner EOA directly or a self-call — the SPA's exact revoke pattern, tested);_smartAccountOwner()'s external call → a directownerstorage read (strictly better: no external call during validation, andowneris slot 0, always ERC-7562 [STO-010]-permitted).Not carried forward (interface cleanup, spec §3.2):
ISessionKeyModule.sol's three dead errors, plus two more inSessionKeyModule.solitself I found by the same principle (UnknownGrantVersion,InvalidSignatureLength— declared, never reverted).TotalReclawAccountdeclares zero custom errors: validation fails closed viaSIG_VALIDATION_FAILED, andrevokeSessionKeyreuses the inheritedonlyOwner's existing require. The old files are left untouched on disk (still compile, still tested by their own suite) — deleting them is a separate call, not asked for in this work-leaf.One genuinely rewritten piece, flagged for scrutiny —
_validateSignature's owner path usesECDSA.tryRecoverinstead of stock'sECDSA.recover. See "Uncertainty" below; this is not a stylistic choice, it's required for the feature to function at all.Small additive fix in
SessionKeyModule.sol(in scope per spec §3.4's explicit line-numbered callout): agrantDigestexternal pure wrapper, soSessionKeyModule.t.sol's_grantDigestExtcalls the production digest path instead of hand-copying the EIP-712 formula — the exact duplication pattern that let the §2bytes4[]selector-hashing bug ship ingrant.pyunnoticed (that Python fix is P3-0, out of scope here).Structural note:
contracts/account/is a separate Foundry projectNot a stylistic choice — adding
account-abstraction/openzeppelin-contractsto the outercontracts/lib/+ acontracts/remappings.txtwas tried first and reverted after it broke an unrelated, currently-passing, real-deployed-contract test: solc embeds the full project-widesettings.remappingslist into every compiled contract's metadata, regardless of whether that contract imports anything through them. Verified empirically — adding just the two new remapping lines (nothing else) shiftedEventfulDataEdge's bytecode enough to breaktest/DataEdgeStagingCreate2.t.sol's pinned CREATE2 address for the real, live staging DataEdge (0xE7a4D2677B686e13775Ba9092631089e35F0BB91). A fully separate Foundry project root (contracts/account/foundry.toml, ownlib/, ownremappings.txt) is the only fix that leaves the outer project's compiled output byte-for-byte unaffected. Re-verified after every change in this PR: 26/26 tests still pass, unchanged, in the outercontracts/project.CI: added a second job (
forge-test-account) to.github/workflows/forge-test.yml, isolated the same way — its own working directory, own lib install, ownforge build/forge testinvocation.Fork-test results (real output)
test_fork_upgrade_preserves_real_account_stateforks live Gnosis mainnet, takes the real deployed account0x2c0CF74B2b76110708CA431796367779e3738250(66 UserOps of history as of the block last checked — re-verified live viacastimmediately before writing the test:owner()=0x9858EfFD232B4033E47d90003D41EC34EcaEda94, ERC-1967 impl slot =0x68641DE71cfEa5a5d0D29712449Ee254bb1400C2, factoryaccountImplementation()matches), upgrades it via the owner EOA's directupgradeToAndCall(the audit-risk memo §5 escape hatch — no bundler, no signature needed,vm.prankspoofsmsg.sender), and assertsowner(), raw slot 0, and the EntryPoint nonce are all byte-unchanged, plus that the owner can still driveexecute()directly post-upgrade. A second test proves upgrade idempotence on the same real account.Since I don't (and shouldn't) hold the real owner's private key, the three signature-shaped assertions (owner-signed validates, session-key validates, out-of-scope doesn't) run against a fresh account deployed through the same real, live factory + real baseline implementation, owned by a Foundry-controlled keypair — same factory, same deployment pattern, controllable signing.
Full suite: 35/35 passing (29 unit + 6 fork). Unit suite is network-free (fresh
ERC1967Proxy+ controlled keys) and covers the sixexecuteBatchscope-invariant tests ported fromSessionKeyModule.t.sol:424-591, plus grant-binding, replay, malleability, and revocation edge cases.Storage-layout gate
contracts/account/script/storage-layout-diff.shwiresforge inspect ... storageLayout(already emitted perfoundry.toml, never consumed before this) as a real CI gate — B2 mitigation (a colliding state variable at slot 0 is catastrophic and silent). Current output:TotalReclawAccountdeclares zero plain state variables; all session-key state lives in ERC-7201 namespaced storage (fixed slot, inline assembly), which correctly doesn't show up here at all.The two traps the spec called out — confirmed avoided
SessionKeyModule.t.sol:86'stest_create2_address_unchanged_after_module_install(tautological —block.chainidisn't an input to the expression it re-derives twice). Wrote a real pinned-address test instead (test_pinned_implementation_create2_address), mirroringDataEdgeStagingCreate2.t.sol:69's independently-hardcoded-literal pattern. Note: this pin is a placeholder salt, not a commitment to a real deployment address — that's decided by the (out-of-scope) deployment plan.ISessionKeyModule.sol's dead errors or its incorrect "MUST be the master wallet" docstring — see "not carried forward" above.Uncertainty — flagged, not smoothed over
The one thing I want a second pair of eyes on hardest:
_validateSignature's owner path callsECDSA.tryRecoverinstead of stockSimpleAccount'sECDSA.recover. I did not do this casually — I traced it to a real correctness requirement: stock'sECDSA.recoverreverts on any signature that isn't a well-formed 65-byte blob, and a session-key lazy-install signature is a much longerabi.encode(PermissionGrant, ecdsaSig)blob. A literal "try owner's exact code path first" port would revert the entirevalidateUserOpon every lazy-install UserOp before ever reaching the session-key branch — not merely insecure, structurally inert (the feature would never work). I reasoned through why this doesn't create a NEW acceptance the way that matters (tracedecrecover's "returns zero" failure mode as a pure function of(v,r,s), independent of which hash is checked — documented at length in the function's NatSpec) but this is exactly the kind of reasoning I'd want an auditor with real EIP-4337 depth to either confirm or refute, not take on my say-so. It's the same territory the audit-risk memo names as B1 (owner path broken → account bricked forever) and lists as reason #1 for external review.Secondary, lower-stakes items I'm reasonably but not 100% confident on:
TotalReclawAccount.solisGPL-3.0(not this repo's usualMIT) because it directly inherits from eth-infinitism's GPL-3.0SimpleAccount. Flagged in the file's own NatSpec as a licensing question for a sanity check, not resolved unilaterally.totalreclaw.TotalReclawAccount.v1) is a test-only placeholder, not a deployment commitment — the actual deployment plan (out of scope here) may choose differently, in which case the pin needs updating to match, which is cheap since nothing is deployed at that address yet.EntryPoint.handleOpssimulation (deposit funding, gas estimation) — fork tests prank as the EntryPoint directly forvalidateUserOp, which is a standard, narrower, and I believe sufficient test technique for what's being asserted, but it's a real scope choice worth naming.Constraints honored
vm.createSelectFork+ local pranks/calls, discarded at test end.CLAUDE.md/SECURITY.md— separate work-leaf).CHANGELOG-public.mdtracks packages promoted to stable registries; nothing here is released or deployed yet.Test plan
cd contracts/account && forge build --sizes— compiler run successfulcd contracts/account && forge test -vv— 35/35 passing (29 unit + 6 live-Gnosis fork)cd contracts/account && ./script/storage-layout-diff.sh— layouts byte-identicalcd contracts && forge test— 26/26 passing, unchanged (outer project untouched)🤖 Generated with Claude Code