From 468c8d87b60b4e7aeaaf906789234e0585909a0d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Migone?= Date: Thu, 15 Oct 2026 10:53:32 -0300 Subject: [PATCH] chore: remove deprecated contracts and dead code MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Tomás Migone --- .github/workflows/forge-test.yml | 16 +- CLAUDE.md | 6 +- contracts/.gas-snapshot | 33 +- .../account/contracts/TotalReclawAccount.sol | 10 +- contracts/account/foundry.toml | 3 + .../account/test/TotalReclawAccount.t.sol | 123 ++- contracts/contracts/SessionKeyModule.sol | 490 ----------- contracts/contracts/TotalReclawPaymaster.sol | 178 ---- .../contracts/interfaces/IEntryPoint.sol | 11 - .../interfaces/ISessionKeyModule.sol | 101 --- .../contracts/interfaces/IUserOperation.sol | 28 - contracts/deployed-addresses.json | 12 +- contracts/foundry.toml | 27 +- contracts/package.json | 2 +- contracts/script/DeploySessionKeyModule.s.sol | 114 --- contracts/test/SessionKeyModule.t.sol | 826 ------------------ python/src/totalreclaw/grant.py | 30 +- python/src/totalreclaw/userop.py | 15 +- python/tests/test_session_key_signing.py | 11 +- .../fixtures/generate-session-key-grant-v1.ts | 13 +- .../parity/fixtures/session-key-grant-v1.json | 2 +- tests/parity/session-key-grant-roundtrip.ts | 10 +- 22 files changed, 211 insertions(+), 1850 deletions(-) delete mode 100644 contracts/contracts/SessionKeyModule.sol delete mode 100644 contracts/contracts/TotalReclawPaymaster.sol delete mode 100644 contracts/contracts/interfaces/IEntryPoint.sol delete mode 100644 contracts/contracts/interfaces/ISessionKeyModule.sol delete mode 100644 contracts/contracts/interfaces/IUserOperation.sol delete mode 100644 contracts/script/DeploySessionKeyModule.s.sol delete mode 100644 contracts/test/SessionKeyModule.t.sol diff --git a/.github/workflows/forge-test.yml b/.github/workflows/forge-test.yml index 1194ffaf..0105d1ce 100644 --- a/.github/workflows/forge-test.yml +++ b/.github/workflows/forge-test.yml @@ -3,9 +3,8 @@ name: Forge Test (Solidity contracts) # Coordinator-created gate per Pedro's 2026-05-28 12:49Z requirement on #278: # "ensure we add a forge-test.yml workflow before cred-6 hits stable promote." # -# Runs `forge build` + `forge test` against contracts/ on every PR + push to -# main. Gates SessionKeyModule + cred-6/cred-7 work. Hardhat side (TS tests) -# stays on the existing CI; this workflow is Foundry-only. +# Runs `forge build` + `forge test` against contracts/ (EventfulDataEdge) and +# contracts/account/ (TotalReclawAccount) on every PR + push to main. on: push: @@ -62,12 +61,11 @@ jobs: - name: Run unit tests run: forge test -vvv - # Cred-6 spec §6.1 mandates a `forge snapshot` gas budget on the - # executeBatch per-inner-call validator (< 5K gas per inner). Since - # #650 the baseline `.gas-snapshot` is COMMITTED, so `--diff` output - # in PR logs is meaningful — but enforcement (failing on drift) stays - # deferred until the cred-6 baseline lands with the paused Phase 3 - # work. Regenerate locally with `cd contracts && forge snapshot`. + # Diagnostic only: the baseline `.gas-snapshot` is committed, so the + # `--diff` output in PR logs is meaningful, but drift does not fail the + # job. (The cred-6 < 5K-gas-per-inner budget is enforced as an assertion + # in contracts/account/test/TotalReclawAccount.t.sol.) Regenerate locally + # with `cd contracts && forge snapshot`. - name: Snapshot gas (diagnostic vs committed baseline) run: forge snapshot --check --diff || true continue-on-error: true diff --git a/CLAUDE.md b/CLAUDE.md index 70286f32..fea929eb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -27,7 +27,7 @@ Managed Service chain routing (**single-chain Gnosis — ops-1 shipped 2026-06-0 Smart Account addresses are deterministic (CREATE2) and byte-equal across chains. **Production** DataEdge = `0xC445af1D4EB9fce4e1E61fE96ea7B8feBF03c5ca` (both tiers), indexed by the `total-reclaw-gnosis` subgraph. **Staging is on-chain isolated** (shipped): its own DataEdge `0xE7a4D2677B686e13775Ba9092631089e35F0BB91` + dedicated `total-reclaw-gnosis-staging` subgraph, so staging writes never touch production data. See `totalreclaw-internal/docs/specs/ops/staging-chain-isolation.md`. -**On-chain footprint — what of ours is actually deployed (verified 2026-09-15):** exactly ONE TotalReclaw-owned contract family is live: `EventfulDataEdge`, at the two addresses above. Everything else in `contracts/` is **not deployed**: `TotalReclawAccount` (Option E Phase 3 revocable session-key signing — implementation merged via #636, never deployed, **parked by Pedro 2026-09-15**), `SessionKeyModule` (superseded; its validator logic was absorbed into `TotalReclawAccount`), and `TotalReclawPaymaster` (Base Sepolia era only — on Gnosis, gas sponsorship goes through **Pimlico's** paymaster, not ours). User Smart Accounts are third-party infrastructure (eth-infinitism `SimpleAccount` proxies from the canonical factory + the v0.7 EntryPoint singleton `0x0000000071727De22E5E9d8BAf0edAc6f37da032`). The subgraph YAMLs (`subgraph/subgraph-gnosis-*.yaml`) are the authoritative record of what is indexed where — the top-level block of `contracts/deployed-addresses.json` still carries stale March-era `baseSepolia` labels next to what is now the Gnosis prod DataEdge. +**On-chain footprint — what of ours is actually deployed (verified 2026-10-15):** exactly ONE TotalReclaw-owned contract is **in use**: `EventfulDataEdge`, at the two addresses above. `TotalReclawPaymaster` **is deployed but inert** at `0xd10Be4669c739C3315616f7E6a2D7Dbd7033e7aD` on both Gnosis (2026-03-18) and Base Sepolia (2026-03-20) — zero transactions, zero internal calls, zero balance/EntryPoint deposit; gas sponsorship goes through **Pimlico's** paymaster, not ours. Its source and `SessionKeyModule` (superseded by `TotalReclawAccount`, never deployed) were removed from the repo; both are recorded in `contracts/deployed-addresses.json` → `historical` / git history. The only other contract in `contracts/` is `TotalReclawAccount` (`contracts/account/`, Option E Phase 3 revocable session-key signing — merged via #636, never deployed, **parked by Pedro 2026-09-15**). User Smart Accounts are third-party infrastructure (eth-infinitism `SimpleAccount` proxies from the canonical factory + the v0.7 EntryPoint singleton `0x0000000071727De22E5E9d8BAf0edAc6f37da032`). The subgraph YAMLs (`subgraph/subgraph-gnosis-*.yaml`) are the authoritative record of what is indexed where. ``` +-------------------------------------------------------------------------+ @@ -87,7 +87,7 @@ Smart Account addresses are deterministic (CREATE2) and byte-equal across chains ├── app/ # Vault SPA (Vite + React + TS, managed-mode reads via relay subgraph proxy) ├── tools/ # QA + ops helpers │ └── qa-vault.mjs # Playwright vault driver (keychain or QA_RECOVERY_PHRASE env) -├── contracts/ # Solidity smart contracts (EventfulDataEdge, Paymaster) +├── contracts/ # Solidity smart contracts (EventfulDataEdge; TotalReclawAccount under account/, parked) ├── subgraph/ # Graph Node indexer (AssemblyScript mappings) ├── database/ # Database schema (schema.sql) ├── tests/ # Integration tests @@ -410,7 +410,7 @@ Before telling the user a session can close, run the **`totalreclaw-session-wrap | Startup validation | MEDIUM | Validate Pimlico/Stripe/Subgraph reachability on relay boot | | DB backup monitoring | LOW | Add alerting (Slack/email) if daily R2 backup fails | | Graceful shutdown | LOW | Not yet configured in uvicorn | -| Signing key not decoupled from the vault root | MEDIUM | `SessionKeyModule.sol` is merged and tested but **inert**: canonical `SimpleAccount` has no path that routes `validateUserOp` to a module, and it is not deployed. Making it reachable requires either a UUPS upgrade of existing account proxies (address-preserving) or migration to a modular account (**not** address-preserving — would orphan facts indexed by the current Smart Account address). The accepted spec signed off on kernel v3 while the shipped module assumes a `SimpleAccount`-shaped `owner()`; that needs reconciling before any contract work. | +| Signing key not decoupled from the vault root | MEDIUM | Hosts sign with the unscoped, non-revocable owner EOA. The fix is `TotalReclawAccount` (`contracts/account/`, #636): a `SimpleAccount` subclass that also accepts scoped, revocable session keys, reached by a UUPS upgrade of existing account proxies (address-preserving, so facts indexed under the current Smart Account address are kept). It replaced the standalone `SessionKeyModule` (removed — a module's grant storage fails ERC-7562 bundler validation). Merged and tested, **never deployed, parked by Pedro 2026-09-15**; needs an audit before deploy. | --- diff --git a/contracts/.gas-snapshot b/contracts/.gas-snapshot index 84c3043d..c3906383 100644 --- a/contracts/.gas-snapshot +++ b/contracts/.gas-snapshot @@ -1,26 +1,13 @@ DataEdgeStagingCreate2Test:test_pinned_predicted_address() (gas: 2495) DataEdgeStagingCreate2Test:test_staging_address_byte_equal_across_chains() (gas: 8171) DataEdgeStagingCreate2Test:test_staging_address_isolated_from_production() (gas: 2478) -SessionKeyModuleTest:test_create2_address_unchanged_after_module_install() (gas: 26683) -SessionKeyModuleTest:test_cross_chain_grant_replay_rejected() (gas: 58316) -SessionKeyModuleTest:test_first_call_installs_grant_and_emits_event() (gas: 190113) -SessionKeyModuleTest:test_get_session_key_grant_empty_for_unknown_signer() (gas: 17784) -SessionKeyModuleTest:test_is_session_key_active_false_after_revoke() (gas: 164332) -SessionKeyModuleTest:test_malformed_executeBatch_calldata_rejected() (gas: 196041) -SessionKeyModuleTest:test_master_signature_mismatch_rejects() (gas: 70497) -SessionKeyModuleTest:test_nonzero_value_rejected() (gas: 197124) -SessionKeyModuleTest:test_parity_fixture_install_blob_decodes_to_same_grant() (gas: 108369) -SessionKeyModuleTest:test_parity_fixture_matches_solidity_digest_and_recovers_master() (gas: 166168) -SessionKeyModuleTest:test_replay_nonce_protection() (gas: 307058) -SessionKeyModuleTest:test_revoke_idempotent() (gas: 171071) -SessionKeyModuleTest:test_revoke_only_callable_by_account() (gas: 184598) -SessionKeyModuleTest:test_session_key_accepts_batch_with_all_in_scope_inner_calls() (gas: 216386) -SessionKeyModuleTest:test_session_key_batch_per_inner_gas_under_5k_budget() (gas: 263082) -SessionKeyModuleTest:test_session_key_rejects_batch_with_nonzero_value_inner() (gas: 210828) -SessionKeyModuleTest:test_session_key_rejects_batch_with_out_of_scope_inner_call() (gas: 215746) -SessionKeyModuleTest:test_session_key_rejects_batch_with_out_of_scope_inner_selector() (gas: 213504) -SessionKeyModuleTest:test_session_key_rejects_empty_batch() (gas: 201239) -SessionKeyModuleTest:test_steady_state_userop_after_install_succeeds() (gas: 197181) -SessionKeyModuleTest:test_validate_invalid_signature_returns_failed() (gas: 197910) -SessionKeyModuleTest:test_validate_rejects_unknown_selector() (gas: 192929) -SessionKeyModuleTest:test_validate_rejects_unknown_target() (gas: 197105) \ No newline at end of file +EventfulDataEdgeTest:test_deployment_setsDeployerAsOwner() (gas: 18391) +EventfulDataEdgeTest:test_fallback_emitsExactPayloadBytes() (gas: 39376) +EventfulDataEdgeTest:test_fallback_emitsLogAtEdgeAddress() (gas: 33835) +EventfulDataEdgeTest:test_fallback_handlesLargePayload1KB() (gas: 118568) +EventfulDataEdgeTest:test_fallback_isPayable_acceptsCalldataWithValue() (gas: 43583) +EventfulDataEdgeTest:test_fallback_isPermissionless_nonOwnerWritesSucceed() (gas: 36135) +EventfulDataEdgeTest:test_receive_acceptsEthWithoutCalldataAndDoesNotEmit() (gas: 39482) +EventfulDataEdgeTest:test_transferOwnership_ownerCanTransferToNewOwner() (gas: 39507) +EventfulDataEdgeTest:test_transferOwnership_revertWhen_callerIsNotOwner() (gas: 37720) +EventfulDataEdgeTest:test_transferOwnership_revertWhen_newOwnerIsZeroAddress() (gas: 34909) \ No newline at end of file diff --git a/contracts/account/contracts/TotalReclawAccount.sol b/contracts/account/contracts/TotalReclawAccount.sol index 56d8f74c..5da4aef9 100644 --- a/contracts/account/contracts/TotalReclawAccount.sol +++ b/contracts/account/contracts/TotalReclawAccount.sol @@ -115,11 +115,11 @@ import {MessageHashUtils} from "@openzeppelin/contracts/utils/cryptography/Messa * corrected here: `onlyOwner` genuinely accepts the owner EOA directly OR * a self-call, and this doc says so plainly. * - * `SessionKeyModule.sol` and `ISessionKeyModule.sol` are left untouched - * on disk -- they are dead code (never deployed, per the account-model - * memo §6) but still compile and their own test suite - * (`test/SessionKeyModule.t.sol`) still exercises them; deleting them is - * out of scope for this change. + * `SessionKeyModule.sol` and `ISessionKeyModule.sol` (never deployed, per + * the account-model memo §6) have since been removed from the tree; the + * references to them in this file describe where code was ported from and + * can be followed in git history. Their parity-fixture and gas-budget tests + * now live in `test/TotalReclawAccount.t.sol`. * ============================================================ */ contract TotalReclawAccount is SimpleAccount { diff --git a/contracts/account/foundry.toml b/contracts/account/foundry.toml index 126d7cd8..00b8b034 100644 --- a/contracts/account/foundry.toml +++ b/contracts/account/foundry.toml @@ -43,6 +43,9 @@ optimizer = true optimizer_runs = 200 via_ir = false extra_output = ["storageLayout"] +# cred-9 parity tests read the shared grant fixture at +# `tests/parity/fixtures/session-key-grant-v1.json` (two levels up). +fs_permissions = [{ access = "read", path = "../../tests/parity/fixtures/" }] [fmt] line_length = 100 diff --git a/contracts/account/test/TotalReclawAccount.t.sol b/contracts/account/test/TotalReclawAccount.t.sol index b65b4a9d..792b3ff0 100644 --- a/contracts/account/test/TotalReclawAccount.t.sol +++ b/contracts/account/test/TotalReclawAccount.t.sol @@ -58,7 +58,7 @@ contract TotalReclawAccountTest is Test { // ============================================================ /// @dev Pinned-address test, mirroring `DataEdgeStagingCreate2.t.sol - /// :69` -- NOT `SessionKeyModule.t.sol:86`'s tautological + /// :69` -- NOT the removed `SessionKeyModule.t.sol`'s tautological /// version. `test_create2_address_unchanged_after_module_install` /// recomputes `keccak256(0xff || factory || salt || initCodeHash)` /// under different `vm.chainId` values and asserts the two @@ -73,7 +73,7 @@ contract TotalReclawAccountTest is Test { /// /// NOTE: this salt is a placeholder for THIS TEST ONLY, chosen to /// follow the existing `totalreclaw..v1` naming - /// convention (`DeploySessionKeyModule.SESSION_KEY_MODULE_SALT_V1`). + /// convention (as used by the removed `DeploySessionKeyModule.s.sol`). /// It does not commit to an actual deployment salt -- that is /// decided by `2026-08-02-phase3-contract-deployment-plan.md` /// (out of scope for P3-1/P3-2/P3-4). If a different salt is @@ -483,7 +483,7 @@ contract TotalReclawAccountTest is Test { // ============================================================ // executeBatch scope invariant (cred-6) -- the six tests ported from - // `SessionKeyModule.t.sol:424-591`, adapted to call `validateUserOp` + // the removed `SessionKeyModule.t.sol`, adapted to call `validateUserOp` // (pranked as the EntryPoint) against a real deployed proxy instead // of a MockSmartAccount trampoline into the standalone module. // ============================================================ @@ -605,10 +605,127 @@ contract TotalReclawAccountTest is Test { assertEq(vd, 1, "empty executeBatch must SIG_VALIDATION_FAILED"); } + /// @dev Gas-budget assertion (cred-6 acceptance criteria, moved here from + /// the removed `SessionKeyModule.t.sol`): per-inner-call validator + /// overhead must be under 5K gas. The delta between a 2-inner and a + /// 6-inner batch, divided by 4, is the per-inner cost. + function test_session_key_batch_per_inner_gas_under_5k_budget() public { + _installGrant(1); + + bytes32 h2 = keccak256("batch-gas-2"); + bytes32 h6 = keccak256("batch-gas-6"); + PackedUserOperation memory op2 = + _makePackedOp(_inScopeBatchCallData(2), _signWithKey(signerPriv, h2)); + PackedUserOperation memory op6 = + _makePackedOp(_inScopeBatchCallData(6), _signWithKey(signerPriv, h6)); + + vm.prank(ENTRY_POINT); + uint256 g0 = gasleft(); + uint256 vd2 = account.validateUserOp(op2, h2, 0); + uint256 gas2 = g0 - gasleft(); + assertEq(vd2, 0, "2-inner in-scope batch must succeed"); + + vm.prank(ENTRY_POINT); + uint256 g1 = gasleft(); + uint256 vd6 = account.validateUserOp(op6, h6, 0); + uint256 gas6 = g1 - gasleft(); + assertEq(vd6, 0, "6-inner in-scope batch must succeed"); + + uint256 perInner = (gas6 - gas2) / 4; + emit log_named_uint("per-inner validator gas", perInner); + assertLt(perInner, 5000, "per-inner-call overhead must be under 5K gas"); + } + + // ============================================================ + // cred-9 cross-language parity fixture -- moved here from the removed + // `SessionKeyModule.t.sol`. Loads `tests/parity/fixtures/session-key-grant-v1.json` + // and asserts the account's EIP-712 grant digest + ecrecover'd master + // EOA match the values the TS and Python grant builders commit to. + // Uses the production `grantDigest` (not a test-side mirror), so this + // locks the wire format the account actually validates. + // ============================================================ + + string internal constant PARITY_FIXTURE = "../../tests/parity/fixtures/session-key-grant-v1.json"; + + function test_parity_fixture_matches_grant_digest_and_recovers_master() public view { + string memory json = vm.readFile(PARITY_FIXTURE); + + TotalReclawAccount.PermissionGrant memory g; + g.version = uint8(vm.parseJsonUint(json, ".grant.version")); + g.account = vm.parseJsonAddress(json, ".grant.account"); + g.signer = vm.parseJsonAddress(json, ".grant.signer"); + g.target = vm.parseJsonAddress(json, ".grant.scope.target"); + // valueMax / nonce / issuedAt are JSON strings (uint256-safe across languages). + g.valueMax = vm.parseUint(vm.parseJsonString(json, ".grant.scope.valueMax")); + g.nonce = vm.parseUint(vm.parseJsonString(json, ".grant.nonce")); + g.issuedAt = vm.parseUint(vm.parseJsonString(json, ".grant.issuedAt")); + g.chainId = vm.parseJsonUint(json, ".domain.chainId"); + g.verifyingContract = vm.parseJsonAddress(json, ".domain.verifyingContract"); + g.masterSignature = vm.parseJsonBytes(json, ".ecdsa_signature"); + + // bytes4[] -- no native parseJsonBytes4Array; read each as bytes + cast. + g.selectors = new bytes4[](2); + g.selectors[0] = bytes4(vm.parseJsonBytes(json, ".grant.scope.selectors[0]")); + g.selectors[1] = bytes4(vm.parseJsonBytes(json, ".grant.scope.selectors[1]")); + assertEq(g.selectors[0], EXECUTE_SEL, "fixture selectors[0] is execute"); + assertEq(g.selectors[1], EXECUTE_BATCH_SEL, "fixture selectors[1] is executeBatch"); + + bytes32 expectedDigest = vm.parseJsonBytes32(json, ".eip712_hash"); + address expectedMaster = vm.parseJsonAddress(json, ".accounts.master_address"); + + // 1. The account's grantDigest matches the fixture. + assertEq(account.grantDigest(g), expectedDigest, "grantDigest matches fixture.eip712_hash"); + + // 2. ecrecover on fixture sig + digest recovers the master EOA -- the + // primitive the lazy-install path uses to authorise a grant. + (bytes32 r, bytes32 s, uint8 v) = _splitSig65(g.masterSignature); + assertEq(ecrecover(expectedDigest, v, r, s), expectedMaster, "ecrecover yields fixture master EOA"); + } + + function test_parity_fixture_install_blob_decodes_to_same_grant() public view { + string memory json = vm.readFile(PARITY_FIXTURE); + + // The fixture's install blob must decode through the account's own + // `_decodeInstallSig`: `(PermissionGrant, bytes)`. + (TotalReclawAccount.PermissionGrant memory g, bytes memory sessionSig) = + account._decodeInstallSig(vm.parseJsonBytes(json, ".abi_encoded_install_sig")); + + assertEq(uint256(g.version), vm.parseJsonUint(json, ".grant.version")); + assertEq(g.account, vm.parseJsonAddress(json, ".grant.account")); + assertEq(g.signer, vm.parseJsonAddress(json, ".grant.signer")); + assertEq(g.target, vm.parseJsonAddress(json, ".grant.scope.target")); + assertEq(g.chainId, vm.parseJsonUint(json, ".domain.chainId")); + assertEq(g.verifyingContract, vm.parseJsonAddress(json, ".domain.verifyingContract")); + assertEq(g.selectors.length, 2); + assertEq(g.selectors[0], EXECUTE_SEL); + assertEq(g.selectors[1], EXECUTE_BATCH_SEL); + assertEq(g.masterSignature.length, 65); + + // The trailing session-side sig must recover to the fixture's session + // EOA over the raw (un-prefixed) userOpHash, as the steady-state path does. + bytes32 mockUserOpHash = vm.parseJsonBytes32(json, ".session.mock_user_op_hash"); + (bytes32 r, bytes32 s, uint8 v) = _splitSig65(sessionSig); + assertEq( + ecrecover(mockUserOpHash, v, r, s), + vm.parseJsonAddress(json, ".accounts.session_address"), + "trailing session sig recovers to session EOA" + ); + } + // ============================================================ // Helpers. // ============================================================ + function _splitSig65(bytes memory sig) internal pure returns (bytes32 r, bytes32 s, uint8 v) { + require(sig.length == 65, "sig length"); + // solhint-disable-next-line no-inline-assembly + assembly { + r := mload(add(sig, 0x20)) + s := mload(add(sig, 0x40)) + v := byte(0, mload(add(sig, 0x60))) + } + } + function _twoSelectors() internal pure returns (bytes4[] memory s) { s = new bytes4[](2); s[0] = EXECUTE_SEL; diff --git a/contracts/contracts/SessionKeyModule.sol b/contracts/contracts/SessionKeyModule.sol deleted file mode 100644 index f7d5d71e..00000000 --- a/contracts/contracts/SessionKeyModule.sol +++ /dev/null @@ -1,490 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity ^0.8.24; - -import {ISessionKeyModule} from "./interfaces/ISessionKeyModule.sol"; -import {PackedUserOperation} from "./interfaces/IUserOperation.sol"; - -/** - * @dev Minimal SimpleAccount-style owner interface. Used to verify that the - * master wallet (recovered from the EIP-712 grant signature) is the - * authoritative owner of the Smart Account during lazy-install. - * - * Note: kernel v3 Smart Accounts do NOT expose a top-level `owner()` - * — cred-7 (cross-chain CREATE2 deploy / kernel v3 wiring) wraps this - * lookup to support both account models. Stage 2 ships with the - * SimpleAccount lookup; this is the same model used by the current - * production deployment (see CLAUDE.md "Smart Account" memory). - */ -interface IAccountWithOwner { - function owner() external view returns (address); -} - -/** - * @title SessionKeyModule - * @notice cred-5 stage 2 + cred-6 — real validator body with per-inner-call - * executeBatch scope validation. Per - * `docs/specs/cred/session-key-delegation.md` §4.2. - * - * STAGES SHIPPED IN THIS FILE - * - validateSessionKeyUserOp (single-call path AND executeBatch per-inner) - * - revokeSessionKey (caller == account, idempotent) - * - isSessionKeyActive / getSessionKeyGrant (read-side) - * - lazy install on first UserOp via `abi.encode(PermissionGrant, ecdsaSig)` - * - EIP-712 verification of master-wallet grant signature - * - replay protection via monotonic per-(account, signer) nonce - * - * STILL PENDING (separate work-leafs) - * - cred-7: Pimlico CREATE2 cross-chain deploy + kernel v3 wiring. - * - cred-8: subgraph indexing of SessionKeyInstalled / SessionKeyRevoked - * (out of scope for the validator itself). - * - * CROSS-SPEC INVARIANTS (load-bearing) - * 1. CREATE2 address byte-equality pre/post install: this contract holds - * ALL session-key storage in its own mappings — NEVER in the parent - * Smart Account's slots. See `_grants` + `_minNonces` below. - * 2. No TTL. `issuedAt` is stored but never compared to block.timestamp. - * Grants are valid until explicit revoke (PRD-01 §9 Q2). - * 3. `valueMax` is enforced to be exactly 0 on the inner `execute` call — - * session keys cannot move ETH from the Smart Account. - * 4. Per-inner-call scope validation under `executeBatch` (cred-6): every - * inner call's target, value-zero, and selector are individually checked - * against the grant; a single out-of-scope inner fails the whole batch. - * No "outer-selector-passes" shortcut, no first-inner sampling, no - * partial-execution. The same pattern MUST be applied to any future - * call-aggregator (`multicall`, etc.) per spec §4.2. - */ -contract SessionKeyModule is ISessionKeyModule { - // ------------------------------------------------------------------------- - // ERC-4337 v0.7 validation-data constants - // ------------------------------------------------------------------------- - - /// @dev v0.7 EntryPoint treats `1` as SIG_VALIDATION_FAILED. Time-range - /// packing intentionally NOT used (no TTL — spec §3.1). - uint256 internal constant SIG_VALIDATION_FAILED = 1; - uint256 internal constant SIG_VALIDATION_SUCCESS = 0; - - /// @dev Only grant version this module accepts. Bumping requires a fresh - /// module deployment + spec §3.1 forward-compat clause. - uint8 internal constant GRANT_VERSION = 1; - - /// @dev SimpleAccount.execute(address,uint256,bytes) selector. - bytes4 internal constant EXECUTE_SELECTOR = 0xb61d27f6; - /// @dev SimpleAccount.executeBatch(address[],uint256[],bytes[]) selector. - bytes4 internal constant EXECUTE_BATCH_SELECTOR = 0x47e1da2a; - - // ------------------------------------------------------------------------- - // EIP-712 typehashes - // Cred spec §3.1 defines the canonical typed-data payload — these constants - // must match it byte-for-byte. The cred-9 cross-language parity test - // (Python ↔ Solidity) is the load-bearing check that they stay aligned. - // ------------------------------------------------------------------------- - - bytes32 internal constant DOMAIN_TYPEHASH = keccak256( - "EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)" - ); - bytes32 internal constant SCOPE_TYPEHASH = keccak256( - "Scope(address target,bytes4[] selectors,uint256 valueMax)" - ); - bytes32 internal constant GRANT_TYPEHASH = keccak256( - "SessionKeyPermissionGrant(address account,address signer,Scope scope,uint256 nonce,uint256 issuedAt)Scope(address target,bytes4[] selectors,uint256 valueMax)" - ); - bytes32 internal constant DOMAIN_NAME_HASH = keccak256(bytes("TotalReclawSessionKey")); - bytes32 internal constant DOMAIN_VERSION_HASH = keccak256(bytes("1")); - - // ------------------------------------------------------------------------- - // Storage — all session-key state lives HERE, never in the Smart Account. - // This is the load-bearing constraint that preserves CREATE2 byte-equality - // (cred spec §3.1 "Subgraph schema impact" paragraph + §8 R2). - // ------------------------------------------------------------------------- - - struct GrantStorage { - uint256 nonce; // grant nonce; 0 = uninstalled - uint256 issuedAt; // informational — never compared to block.timestamp - address target; // scope.target (DataEdge address) - uint256 valueMax; // always 0 per spec - bytes4[] selectors; // scope.selectors (execute, executeBatch) - } - - /// @dev `_grants[account][signer]` — active grant for this session key. - /// `nonce == 0` means "not installed". - mapping(address account => mapping(address signer => GrantStorage)) private _grants; - - /// @dev `_minNonces[account][signer]` — smallest acceptable install - /// nonce. Starts at 1, bumps on revoke to `revokedNonce + 1`. - /// Prevents replay of a revoked grant. - mapping(address account => mapping(address signer => uint256)) private _minNonces; - - // ------------------------------------------------------------------------- - // ABI-decoded calldata layout for the lazy-install UserOp. - // `userOp.signature = abi.encode(PermissionGrant, ecdsaSig)` where - // `PermissionGrant` is this struct and `ecdsaSig` is a 65-byte ECDSA over - // the userOpHash (signed by the session key, not the master). - // ------------------------------------------------------------------------- - - struct PermissionGrant { - uint8 version; - address account; - address signer; - address target; - bytes4[] selectors; - uint256 valueMax; - uint256 nonce; - uint256 issuedAt; - uint256 chainId; - address verifyingContract; - bytes masterSignature; // 65-byte ECDSA from master wallet over EIP-712 digest - } - - // ------------------------------------------------------------------------- - // Errors (kept inside the module — interface errors stay for ABI use) - // ------------------------------------------------------------------------- - - error UnknownGrantVersion(uint8 v); - error InvalidSignatureLength(); - - // ------------------------------------------------------------------------- - // ISessionKeyModule — mutating - // ------------------------------------------------------------------------- - - /// @inheritdoc ISessionKeyModule - function validateSessionKeyUserOp( - PackedUserOperation calldata userOp, - bytes32 userOpHash - ) external returns (uint256) { - bytes calldata sig = userOp.signature; - - // Steady-state path: 65-byte raw ECDSA. Signer must already be installed. - if (sig.length == 65) { - address signer = _recoverEcdsa(userOpHash, sig); - if (signer == address(0)) return SIG_VALIDATION_FAILED; - - GrantStorage storage g = _grants[msg.sender][signer]; - if (g.nonce == 0) return SIG_VALIDATION_FAILED; - - if (!_isCallDataInScope(userOp.callData, g.target, g.selectors)) { - return SIG_VALIDATION_FAILED; - } - return SIG_VALIDATION_SUCCESS; - } - - // Lazy-install path: signature is abi.encode(PermissionGrant, ecdsaSig). - // Defensive decode — any malformed input → SIG_VALIDATION_FAILED. - // We need bytes (not bytes calldata) here because abi.decode produces - // memory copies; callData stays calldata. - (bool ok, PermissionGrant memory grant, bytes memory ecdsaSig) = _tryDecodeInstallSig(sig); - if (!ok) return SIG_VALIDATION_FAILED; - - // Verify session signer first — cheap, fails fast on tampered signatures. - address signer = _recoverEcdsa(userOpHash, ecdsaSig); - if (signer == address(0) || signer != grant.signer) return SIG_VALIDATION_FAILED; - - // Verify the grant itself. - if (!_isGrantValid(grant)) return SIG_VALIDATION_FAILED; - - // Replay protection — nonce must be at-or-above the minimum. - uint256 minN = _minNonces[msg.sender][signer]; - if (minN == 0) minN = 1; // first install - if (grant.nonce < minN) return SIG_VALIDATION_FAILED; - - // Recover the master wallet from the EIP-712 grant signature and - // verify it owns the calling Smart Account. - bytes32 digest = _grantDigest(grant); - address master = _recoverEcdsa(digest, grant.masterSignature); - if (master == address(0)) return SIG_VALIDATION_FAILED; - if (master != _smartAccountOwner(msg.sender)) return SIG_VALIDATION_FAILED; - - // All checks pass — install the grant. - _grants[msg.sender][signer] = GrantStorage({ - nonce: grant.nonce, - issuedAt: grant.issuedAt, - target: grant.target, - valueMax: grant.valueMax, - selectors: grant.selectors - }); - - emit SessionKeyInstalled(msg.sender, signer, grant.nonce); - - // After install, validate the actual userOp callData against the - // newly-stored scope — same path the steady-state branch takes. - if (!_isCallDataInScope(userOp.callData, grant.target, grant.selectors)) { - return SIG_VALIDATION_FAILED; - } - return SIG_VALIDATION_SUCCESS; - } - - /// @inheritdoc ISessionKeyModule - function revokeSessionKey(address account, address signer) external { - // Master-wallet auth: the caller must BE the Smart Account itself - // (i.e. the master wallet drove a UserOp into the SA which then - // delegated to this revoke). Anyone calling directly is rejected. - // - // Idempotent — revoking an unknown / already-revoked signer is a - // no-op (PRD-01 §11 R4). No revert, no event spam. - if (msg.sender != account) { - revert SessionKeyNotActive(account, signer); - } - - GrantStorage storage g = _grants[account][signer]; - if (g.nonce == 0) { - // Already revoked or never installed — idempotent no-op. - return; - } - - // Bump the min-nonce so this grant cannot be re-played even with the - // original signed payload. - _minNonces[account][signer] = g.nonce + 1; - delete _grants[account][signer]; - - emit SessionKeyRevoked(account, signer); - } - - // ------------------------------------------------------------------------- - // ISessionKeyModule — views - // ------------------------------------------------------------------------- - - /// @inheritdoc ISessionKeyModule - function isSessionKeyActive( - address account, - address signer - ) external view returns (bool) { - return _grants[account][signer].nonce != 0; - } - - /// @inheritdoc ISessionKeyModule - function getSessionKeyGrant( - address account, - address signer - ) - external - view - returns (uint256 nonce, uint256 issuedAt, bytes4[] memory selectors, address target) - { - GrantStorage storage g = _grants[account][signer]; - return (g.nonce, g.issuedAt, g.selectors, g.target); - } - - /// @notice External read-only wrapper around `_grantDigest`, added - /// (P3-4, Phase 3) so tests compute the EIP-712 grant digest - /// via the actual production code path instead of a - /// hand-copied mirror of it. Before this existed, - /// `test/SessionKeyModule.t.sol`'s `_grantDigestExt` was such - /// a mirror, with a comment admitting both copies had to be - /// updated together — exactly the kind of duplication that - /// let the §2 `bytes4[]` selector-hashing bug ship in the - /// Python client without a test catching it. Pure function, - /// zero behaviour change to the validator itself. - function grantDigest(PermissionGrant calldata g) external pure returns (bytes32) { - return _grantDigest(g); - } - - // ------------------------------------------------------------------------- - // Internal helpers - // ------------------------------------------------------------------------- - - /// @dev `try`/`catch` wrapper around `abi.decode` so a malformed install - /// signature returns `(false, ...)` instead of reverting the whole - /// validateUserOp callback. EntryPoint v0.7 treats a revert from - /// validateUserOp as `SIG_VALIDATION_FAILED` either way, but returning - /// cleanly is observably cheaper + keeps the trace easier to debug. - function _tryDecodeInstallSig( - bytes calldata sig - ) internal view returns (bool ok, PermissionGrant memory grant, bytes memory ecdsaSig) { - if (sig.length < 0x40) return (false, grant, ecdsaSig); - - try this._decodeInstallSig(sig) returns ( - PermissionGrant memory g, - bytes memory s - ) { - return (true, g, s); - } catch { - return (false, grant, ecdsaSig); - } - } - - /// @dev Public-but-only-self entry for the try/catch above. Marked - /// external so the catch sees revert data; `require(msg.sender == - /// address(this))` would defeat the staticcall semantics in some - /// kernel-v3 setups so we just trust the call boundary. - function _decodeInstallSig( - bytes calldata sig - ) external pure returns (PermissionGrant memory grant, bytes memory ecdsaSig) { - (grant, ecdsaSig) = abi.decode(sig, (PermissionGrant, bytes)); - } - - /// @dev Static grant-shape validation. Does NOT check the signature — - /// that lives in the caller because we want to recover the master - /// wallet only once. - function _isGrantValid(PermissionGrant memory g) internal view returns (bool) { - if (g.version != GRANT_VERSION) return false; - if (g.account != msg.sender) return false; // grant binds to this SA - if (g.chainId != block.chainid) return false; // cross-chain replay guard - if (g.verifyingContract != address(this)) return false; // module-binding - if (g.valueMax != 0) return false; // session keys cannot move ETH - if (g.target == address(0)) return false; - if (g.signer == address(0)) return false; - if (g.selectors.length == 0) return false; - if (g.masterSignature.length != 65) return false; - return true; - } - - /// @dev EIP-712 digest matching the spec §3.1 typed-data layout. - function _grantDigest(PermissionGrant memory g) internal pure returns (bytes32) { - bytes32 scopeHash = keccak256( - abi.encode( - SCOPE_TYPEHASH, - g.target, - keccak256(abi.encodePacked(g.selectors)), - g.valueMax - ) - ); - bytes32 structHash = keccak256( - abi.encode(GRANT_TYPEHASH, g.account, g.signer, scopeHash, g.nonce, g.issuedAt) - ); - bytes32 domainSep = keccak256( - abi.encode( - DOMAIN_TYPEHASH, - DOMAIN_NAME_HASH, - DOMAIN_VERSION_HASH, - g.chainId, - g.verifyingContract - ) - ); - return keccak256(abi.encodePacked(hex"19_01", domainSep, structHash)); - } - - /// @dev Looks up the SA's master wallet via the SimpleAccount-style - /// `owner()` interface. Returns address(0) if the call reverts — - /// the caller treats that as SIG_VALIDATION_FAILED. - function _smartAccountOwner(address account) internal view returns (address) { - try IAccountWithOwner(account).owner() returns (address o) { - return o; - } catch { - return address(0); - } - } - - /// @dev Scope validation for both `execute` and `executeBatch` outer calls. - /// Marked `view` (not `pure`) because the `executeBatch` branch uses - /// a self-external try/catch around `abi.decode` so malformed batch - /// payloads return false instead of reverting validateUserOp. - function _isCallDataInScope( - bytes calldata callData, - address grantTarget, - bytes4[] memory allowedSelectors - ) internal view returns (bool) { - if (callData.length < 4) return false; - bytes4 outerSel = bytes4(callData[:4]); - - // Selector must be in the SA-level allowlist. - if (!_inSelectors(outerSel, allowedSelectors)) return false; - - if (outerSel == EXECUTE_SELECTOR) { - // execute(address target, uint256 value, bytes data) - if (callData.length < 4 + 32 * 3) return false; - (address innerTarget, uint256 value, ) = abi.decode( - callData[4:], - (address, uint256, bytes) - ); - return innerTarget == grantTarget && value == 0; - } - - if (outerSel == EXECUTE_BATCH_SELECTOR) { - // executeBatch(address[] targets, uint256[] values, bytes[] datas). - // Per spec §4.2 cross-spec invariant + #281 OQ-A: every inner call - // is individually validated. First non-match → false; no partial - // execution semantics. - try this._decodeExecuteBatch(callData[4:]) returns ( - address[] memory targets, - uint256[] memory values, - bytes[] memory datas - ) { - return _isBatchInScope(targets, values, datas, grantTarget, allowedSelectors); - } catch { - return false; - } - } - - return false; - } - - /// @dev Public-but-only-self entry for the try/catch decode of the - /// executeBatch payload. External so revert-on-malformed surfaces - /// cleanly in the catch — mirrors the `_decodeInstallSig` pattern. - function _decodeExecuteBatch( - bytes calldata payload - ) - external - pure - returns (address[] memory targets, uint256[] memory values, bytes[] memory datas) - { - (targets, values, datas) = abi.decode(payload, (address[], uint256[], bytes[])); - } - - /// @dev Per-inner-call scope assertion. Reject on: - /// - empty batch (no useful work; defensive — spec is silent but - /// nothing else in the pipeline currently emits zero-inner batches) - /// - mismatched array lengths (defensive — abi.decode does not check) - /// - any inner with wrong target, non-zero value, missing selector, - /// or selector not in the grant's allowlist - /// Returns true only when EVERY inner passes. - function _isBatchInScope( - address[] memory targets, - uint256[] memory values, - bytes[] memory datas, - address grantTarget, - bytes4[] memory allowedSelectors - ) internal pure returns (bool) { - uint256 n = targets.length; - if (n == 0) return false; - if (values.length != n || datas.length != n) return false; - - for (uint256 i = 0; i < n; i++) { - if (targets[i] != grantTarget) return false; - if (values[i] != 0) return false; - - bytes memory inner = datas[i]; - if (inner.length < 4) return false; - - // First 4 bytes of `inner` — the in-memory layout is - // (length: 32 bytes)(data...), so mload at offset 0x20 yields - // a word whose top 4 bytes are the selector. - bytes4 innerSel; - // solhint-disable-next-line no-inline-assembly - assembly { - innerSel := mload(add(inner, 0x20)) - } - if (!_inSelectors(innerSel, allowedSelectors)) return false; - } - return true; - } - - function _inSelectors(bytes4 needle, bytes4[] memory hay) internal pure returns (bool) { - for (uint256 i = 0; i < hay.length; i++) { - if (hay[i] == needle) return true; - } - return false; - } - - /// @dev Minimal ECDSA recover (r, s, v). Returns address(0) on malformed - /// input or `s` in the upper half of the curve order (malleability - /// guard — same EIP-2 split-rule OpenZeppelin uses). - function _recoverEcdsa(bytes32 digest, bytes memory sig) internal pure returns (address) { - if (sig.length != 65) return address(0); - - bytes32 r; - bytes32 s; - uint8 v; - // solhint-disable-next-line no-inline-assembly - assembly { - r := mload(add(sig, 0x20)) - s := mload(add(sig, 0x40)) - v := byte(0, mload(add(sig, 0x60))) - } - // EIP-2 malleability guard - if (uint256(s) > 0x7FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF5D576E7357A4501DDFE92F46681B20A0) { - return address(0); - } - if (v != 27 && v != 28) return address(0); - return ecrecover(digest, v, r, s); - } -} diff --git a/contracts/contracts/TotalReclawPaymaster.sol b/contracts/contracts/TotalReclawPaymaster.sol deleted file mode 100644 index 10d4f16d..00000000 --- a/contracts/contracts/TotalReclawPaymaster.sol +++ /dev/null @@ -1,178 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity ^0.8.24; - -/** - * @title TotalReclawPaymaster - * @notice ERC-4337 Verifying Paymaster that sponsors gas for TotalReclaw writes. - * - * Validation rules: - * 1. UserOperation must target the EventfulDataEdge contract. - * 2. Sender must not exceed the per-window rate limit. - * 3. Paymaster must have sufficient ETH balance. - * - * Rate limiting: - * - Tracked per sender (Smart Account address). - * - Sliding window: maxOpsPerWindow operations per rateLimitWindow seconds. - * - Owner can adjust limits without redeploying. - * - * Funding: - * - Owner deposits ETH into this contract. - * - EntryPoint draws from this contract to cover gas. - * - Owner can withdraw excess ETH. - */ -contract TotalReclawPaymaster { - - // --- State --- - - address public immutable entryPoint; - address public owner; - address public dataEdge; - uint256 public maxOpsPerWindow; - uint256 public rateLimitWindow; - - struct RateLimit { - uint256 count; - uint256 windowStart; - } - - mapping(address => RateLimit) public rateLimits; - - // --- Events --- - - event Funded(address indexed from, uint256 amount); - event Withdrawn(address indexed to, uint256 amount); - event RateLimitsUpdated(uint256 maxOps, uint256 window); - event DataEdgeUpdated(address indexed newDataEdge); - event OperationSponsored(address indexed sender, uint256 opsInWindow); - - // --- Constructor --- - - constructor( - address _entryPoint, - address _dataEdge, - uint256 _maxOpsPerWindow, - uint256 _rateLimitWindow - ) { - require(_entryPoint != address(0), "Invalid entryPoint"); - require(_dataEdge != address(0), "Invalid dataEdge"); - require(_maxOpsPerWindow > 0, "Invalid maxOps"); - require(_rateLimitWindow > 0, "Invalid window"); - - entryPoint = _entryPoint; - dataEdge = _dataEdge; - maxOpsPerWindow = _maxOpsPerWindow; - rateLimitWindow = _rateLimitWindow; - owner = msg.sender; - } - - // --- Owner Functions --- - - function setRateLimits(uint256 _maxOps, uint256 _window) external { - require(msg.sender == owner, "Only owner"); - require(_maxOps > 0, "Invalid maxOps"); - require(_window > 0, "Invalid window"); - maxOpsPerWindow = _maxOps; - rateLimitWindow = _window; - emit RateLimitsUpdated(_maxOps, _window); - } - - function setDataEdge(address _newDataEdge) external { - require(msg.sender == owner, "Only owner"); - require(_newDataEdge != address(0), "Invalid dataEdge"); - dataEdge = _newDataEdge; - emit DataEdgeUpdated(_newDataEdge); - } - - function transferOwnership(address _newOwner) external { - require(msg.sender == owner, "Only owner"); - require(_newOwner != address(0), "Invalid owner"); - owner = _newOwner; - } - - function withdraw(uint256 amount) external { - require(msg.sender == owner, "Only owner"); - require(amount <= address(this).balance, "Insufficient balance"); - (bool success, ) = owner.call{value: amount}(""); - require(success, "Withdraw failed"); - emit Withdrawn(owner, amount); - } - - // --- Rate Limit Queries --- - - function getOpsCount(address sender) external view returns (uint256) { - RateLimit memory rl = rateLimits[sender]; - if (block.timestamp >= rl.windowStart + rateLimitWindow) { - return 0; // Window expired - } - return rl.count; - } - - // --- Internal: Rate Limit Check --- - - function _checkAndIncrementRateLimit(address sender) internal returns (bool) { - RateLimit storage rl = rateLimits[sender]; - - // Reset window if expired - if (block.timestamp >= rl.windowStart + rateLimitWindow) { - rl.count = 0; - rl.windowStart = block.timestamp; - } - - // Check limit - if (rl.count >= maxOpsPerWindow) { - return false; // Rate limited - } - - rl.count++; - emit OperationSponsored(sender, rl.count); - return true; - } - - // --- ERC-4337 Paymaster Interface --- - // - // The full IPaymaster interface has two functions: - // validatePaymasterUserOp(UserOperation, bytes32, uint256) -> (bytes, uint256) - // postOp(PostOpMode, bytes, uint256, uint256) - // - // For the PoC, we implement a simplified version. The production version - // should inherit from BasePaymaster in @account-abstraction/contracts. - // - // The validatePaymasterUserOp function is called by the EntryPoint during - // UserOperation validation. It must: - // 1. Verify the target is our DataEdge contract. - // 2. Check rate limits for the sender. - // 3. Return a context (bytes) and validationData (uint256). - - /** - * @notice Validates whether this paymaster will sponsor a UserOperation. - * @dev Called by EntryPoint. Simplified for PoC -- production should use - * the full PackedUserOperation struct from EntryPoint v0.7. - * @param sender The Smart Account address initiating the operation. - * @param target The contract being called (must be dataEdge). - * @return willSponsor True if the paymaster agrees to sponsor this operation. - */ - function validateOperation( - address sender, - address target - ) external returns (bool willSponsor) { - require(msg.sender == entryPoint, "Only EntryPoint"); - - // Rule 1: Target must be our DataEdge - if (target != dataEdge) { - return false; - } - - // Rule 2: Sender must not be rate-limited - if (!_checkAndIncrementRateLimit(sender)) { - return false; - } - - return true; - } - - // --- Receive ETH --- - - receive() external payable { - emit Funded(msg.sender, msg.value); - } -} diff --git a/contracts/contracts/interfaces/IEntryPoint.sol b/contracts/contracts/interfaces/IEntryPoint.sol deleted file mode 100644 index 96435880..00000000 --- a/contracts/contracts/interfaces/IEntryPoint.sol +++ /dev/null @@ -1,11 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity ^0.8.24; - -/** - * @dev Minimal IEntryPoint interface — only what EventfulDataEdge needs. - * Full ERC-4337 EntryPoint interface is provided by the account-abstraction package. - * For PoC we only use the address for msg.sender checks. - */ -interface IEntryPoint { - // Intentionally empty — we only use the address for msg.sender checks. -} diff --git a/contracts/contracts/interfaces/ISessionKeyModule.sol b/contracts/contracts/interfaces/ISessionKeyModule.sol deleted file mode 100644 index fd31a2a8..00000000 --- a/contracts/contracts/interfaces/ISessionKeyModule.sol +++ /dev/null @@ -1,101 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity ^0.8.24; - -import {PackedUserOperation} from "./IUserOperation.sol"; - -/** - * @title ISessionKeyModule - * @notice ERC-4337 module that validates UserOperations signed by a - * delegated session key (per cred spec §4.2). Master wallet - * keypair stays on the SPA; agents (Hermes etc.) receive only - * a scope-bounded session key. - * - * Cross-spec invariants (load-bearing — see cred spec §4.2): - * - `validateSessionKeyUserOp` MUST decode any `executeBatch(...)` - * inner-calls and validate each one against the session-key scope. - * Validating only the outer `executeBatch` selector breaks the imp - * spec OQ-A cost model (cf. `docs/specs/imp/281-gnosis-batching-chain-gate.md`). - * - CREATE2 address of the parent Smart Account MUST remain byte-equal - * pre- and post-module-install (cred §8 R2). - * - No TTL on the session key (cred spec §3.1 / PRD-01 §9 Q2). - * Lifetime is bounded only by explicit revoke from the SPA. - * - * This file is the contract surface only — the implementation lives in - * `SessionKeyModule.sol`. Stub implementation lands in cred-5 stage 1 - * (this PR); per-inner-call scope validation in cred-6; cross-chain - * deploy in cred-7. - */ -interface ISessionKeyModule { - // --- Errors --- - - error SessionKeyNotActive(address account, address signer); - error SessionKeyScopeMismatch(address account, address signer); - error SessionKeyInvalidSignature(); - error SessionKeyReplay(uint256 nonce); - - // --- Events --- - - /// @notice Emitted on the first UserOp that uses a fresh session key - /// (lazy-install pattern — no separate `installSessionKey()` call). - event SessionKeyInstalled( - address indexed account, - address indexed signer, - uint256 nonce - ); - - /// @notice Emitted when the master wallet (via the SPA) revokes a - /// session key. Idempotent — revoking an unknown or - /// already-revoked signer is a no-op (PRD-01 §11 R4). - event SessionKeyRevoked(address indexed account, address indexed signer); - - // --- Mutating --- - - /** - * @notice Called by the Smart Account during `validateUserOp`. - * @return validationData `0` on success, `SIG_VALIDATION_FAILED` - * (constant `1`) on any mismatch. The v0.7 time-range packing - * is NOT used — there is no TTL. - * - * Cross-spec invariant: if `userOp.callData` is an `executeBatch(...)` - * invocation, this function MUST decode the inner call array and - * verify EACH inner call individually against the session-key scope - * (`target` + `selectors`). See cred spec §4.2 + imp OQ-A. - */ - function validateSessionKeyUserOp( - PackedUserOperation calldata userOp, - bytes32 userOpHash - ) external returns (uint256 validationData); - - /** - * @notice Owner-only — invoked from the master wallet via the SPA - * "revoke" button. Idempotent. - * @param account The Smart Account address whose session key is being - * revoked. Caller MUST be the master wallet of `account`. - * @param signer The session-key signer address to revoke. - */ - function revokeSessionKey(address account, address signer) external; - - // --- Views --- - - /// @notice Used by the SPA "active sessions" list and by Hermes for - /// post-revoke clean-up surfacing. - function isSessionKeyActive( - address account, - address signer - ) external view returns (bool); - - /// @notice Returns the grant metadata stored on install. Empty - /// arrays / zero values for unknown signers (no revert). - function getSessionKeyGrant( - address account, - address signer - ) - external - view - returns ( - uint256 nonce, - uint256 issuedAt, - bytes4[] memory selectors, - address target - ); -} diff --git a/contracts/contracts/interfaces/IUserOperation.sol b/contracts/contracts/interfaces/IUserOperation.sol deleted file mode 100644 index 21f9bdae..00000000 --- a/contracts/contracts/interfaces/IUserOperation.sol +++ /dev/null @@ -1,28 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity ^0.8.24; - -/** - * @dev Packed UserOperation struct from ERC-4337 v0.7. - * - * Defined locally for the cred-5 session-key delegation work (the - * `contracts/` package is not currently wired to the `account-abstraction` - * npm package — see `interfaces/IEntryPoint.sol` for the matching minimal - * pattern). When account-abstraction v0.7 contracts are pulled in as a - * Foundry lib (cred-5 stage 2), this file is removed in favour of the - * canonical import. - * - * Field shape matches the v0.7 EIP-4337 standard verbatim — do not - * reorder, the layout is consumed by `validateUserOp` validators that - * compute the hash via abi.encodePacked. - */ -struct PackedUserOperation { - address sender; - uint256 nonce; - bytes initCode; - bytes callData; - bytes32 accountGasLimits; - uint256 preVerificationGas; - bytes32 gasFees; - bytes paymasterAndData; - bytes signature; -} diff --git a/contracts/deployed-addresses.json b/contracts/deployed-addresses.json index 76b9f751..49bbce2f 100644 --- a/contracts/deployed-addresses.json +++ b/contracts/deployed-addresses.json @@ -29,7 +29,17 @@ "deployedAt": "2026-03-20T12:55:55.353Z", "deployer": "0x5C6A2E495F032E31Fc4c042085Bea0006B9C7A06", "blockNumber": 39121533, - "status": "RETIRED \u2014 free tier lived here before the ops-1 single-chain Gnosis migration (2026-06-05). The free\u2192Base-Sepolia routing no longer exists; kept as the historical deploy record only." + "status": "RETIRED \u2014 free tier lived here before the ops-1 single-chain Gnosis migration (2026-06-05). The free\u2192Base-Sepolia routing no longer exists; kept as the historical deploy record only.", + "note": "totalReclawPaymaster here is likewise inert (zero transactions/internal calls since creation, verified 2026-10-15). Source removed from the repo (see git history)." + }, + "gnosisPaymaster": { + "network": "gnosis", + "chainId": 100, + "totalReclawPaymaster": "0xd10Be4669c739C3315616f7E6a2D7Dbd7033e7aD", + "deployedAt": "2026-03-18", + "deployTx": "0x1d9ecbc3ac8be98346c1416c281c98e70e832bd13e29179327d15e520fc0d144", + "deployer": "0x5C6A2E495F032E31Fc4c042085Bea0006B9C7A06", + "status": "INERT \u2014 deployed alongside the prod DataEdge but never used: gas sponsorship goes through Pimlico's paymaster. Verified 2026-10-15: zero transactions, zero internal calls, zero balance, zero EntryPoint deposit. Source removed from the repo (see git history)." } } } diff --git a/contracts/foundry.toml b/contracts/foundry.toml index 910d3468..90ab36b7 100644 --- a/contracts/foundry.toml +++ b/contracts/foundry.toml @@ -1,19 +1,12 @@ -# Foundry config for the TotalReclaw contracts package. +# Foundry config for the TotalReclaw contracts package (EventfulDataEdge). # -# Coexists with the existing Hardhat setup (`hardhat.config.ts` + the TS tests -# under `test/`). Foundry is added 2026-05-27 for the cred-5 session-key -# delegation work (per `docs/specs/cred/session-key-delegation.md` — the -# Solidity-side spec requires Foundry-style unit tests + a `forge snapshot` -# gas budget on the per-inner-call validator). +# Hardhat (`hardhat.config.ts`) is kept only for the localhost dev-loop +# deployer (`scripts/deploy.ts`); live deploys go through `script/*.s.sol`. # -# Conventions: -# - Solidity sources stay under `contracts/` (matches the Hardhat layout). -# - Foundry tests go under `test/` alongside the existing `.test.ts` files, -# distinguished by the `.t.sol` extension. Hardhat ignores `.t.sol`; -# Foundry ignores `.test.ts`. Both build systems can coexist on this dir. -# - External Solidity libs (ZeroDev kernel v3, account-abstraction v0.7 -# contracts) land under `lib/` via `forge install` in cred-5 stage 2; -# `remappings.txt` keeps Hardhat oblivious to them. +# `TotalReclawAccount` is a SEPARATE Foundry project under `account/` — see +# its `foundry.toml` for why it must not share this project's remappings +# (they would shift EventfulDataEdge's bytecode and break the staging +# CREATE2 pin in `test/DataEdgeStagingCreate2.t.sol`). [profile.default] src = "contracts" test = "test" @@ -23,13 +16,7 @@ solc_version = "0.8.24" optimizer = true optimizer_runs = 200 via_ir = false -# Hardhat's TS tests are filename-matched on `.test.ts`. We exclude them -# from Foundry's compilation set so `forge build` doesn't trip on TS files. extra_output = ["storageLayout"] -# cred-9 cross-language parity tests read the shared fixture at -# `tests/parity/fixtures/session-key-grant-v1.json` (one level up from the -# Foundry root). Grant read access to that path so `vm.readFile` works. -fs_permissions = [{ access = "read", path = "../tests/parity/fixtures/" }] [fmt] line_length = 100 diff --git a/contracts/package.json b/contracts/package.json index 87b58005..438e9d79 100644 --- a/contracts/package.json +++ b/contracts/package.json @@ -1,7 +1,7 @@ { "name": "@totalreclaw/contracts", "version": "0.1.0", - "description": "TotalReclaw smart contracts \u2014 EventfulDataEdge and Paymaster for Base L2", + "description": "TotalReclaw smart contracts \u2014 EventfulDataEdge (Gnosis)", "private": true, "scripts": { "compile": "hardhat compile", diff --git a/contracts/script/DeploySessionKeyModule.s.sol b/contracts/script/DeploySessionKeyModule.s.sol deleted file mode 100644 index 7f4a1f83..00000000 --- a/contracts/script/DeploySessionKeyModule.s.sol +++ /dev/null @@ -1,114 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity ^0.8.24; - -import {Script, console2} from "forge-std/Script.sol"; -import {SessionKeyModule} from "../contracts/SessionKeyModule.sol"; - -/** - * @title DeploySessionKeyModule - * @notice cred-7 stage 1 — CREATE2 deploy script. NO BROADCAST in this - * stage. Computes the predicted CREATE2 address so stage 2 (actual - * on-chain broadcast with funded deployer) can verify byte-equality - * across chains before submitting. - * - * Uses Arachnid's deterministic deployment proxy at - * `0x4e59b44847b379578588920cA78FbF26c0B4956C`. This factory exists - * at the same address on every major EVM chain via a one-shot - * raw-transaction deployment (see eip-2470 + Arachnid pattern). - * Same factory + same bytecode + same salt → byte-equal address on - * every chain. This is the load-bearing invariant for cred §8 R2 - * (CREATE2 byte-equality post module install). - * - * Stage 2 (separate PR, Pedro-supervised): - * - Funded deployer wallet (Base Sepolia faucet ETH + - * Gnosis xDAI from Pedro's hot wallet). - * - `--broadcast` on both chains. - * - Update `contracts/deployed-addresses.json` with the - * deployed address + tx hashes. - * - Verify on Etherscan / Gnosisscan. - * - Un-skip `test_create2_address_unchanged_after_module_install` - * with the on-chain assertion baked against the actual - * deployed address (already done in this stage as a predicted- - * address assertion). - * - * Usage (stage 1 — predicted-address only): - * forge script script/DeploySessionKeyModule.s.sol --rpc-url $RPC_URL - * - * The script prints the predicted CREATE2 address. Run on both - * Base Sepolia and Gnosis to confirm they match. - * - * Usage (stage 2 — when ready to broadcast, separate PR): - * forge script script/DeploySessionKeyModule.s.sol \ - * --rpc-url $BASE_SEPOLIA_RPC_URL \ - * --private-key $DEPLOYER_PRIVATE_KEY \ - * --broadcast \ - * --verify - * - * Repeat for Gnosis. Assert the resulting address matches stage-1's - * predicted address. - */ -contract DeploySessionKeyModule is Script { - // Arachnid's deterministic deployment proxy is pinned via EIP-2470 - // at `0x4e59b44847b379578588920cA78FbF26c0B4956C` on every major EVM - // chain. forge-std exposes this as `Base.CREATE2_FACTORY`; we reuse - // that constant here (do NOT redeclare — it would collide with the - // inherited Script → Base.CREATE2_FACTORY). - - /// @dev Deterministic salt for the v1 SessionKeyModule. Bumping this - /// yields a fresh address; intentional re-deploys for v2 should - /// use a different salt to avoid address collision. - bytes32 internal constant SESSION_KEY_MODULE_SALT_V1 = - keccak256("totalreclaw.SessionKeyModule.v1"); - - function run() external { - bytes memory initCode = abi.encodePacked(type(SessionKeyModule).creationCode); - bytes32 initCodeHash = keccak256(initCode); - - // Predicted CREATE2 address: keccak256(0xff ++ factory ++ salt ++ initCodeHash)[12:] - address predicted = address( - uint160( - uint256( - keccak256( - abi.encodePacked( - bytes1(0xff), - CREATE2_FACTORY, - SESSION_KEY_MODULE_SALT_V1, - initCodeHash - ) - ) - ) - ) - ); - - console2.log("=== SessionKeyModule CREATE2 deployment ==="); - console2.log("Chain id: ", block.chainid); - console2.log("Factory: ", CREATE2_FACTORY); - console2.log("Salt: "); - console2.logBytes32(SESSION_KEY_MODULE_SALT_V1); - console2.log("Init code hash: "); - console2.logBytes32(initCodeHash); - console2.log("Predicted addr: ", predicted); - - // Check if already deployed (idempotent — running script twice - // on the same chain should not redeploy). - if (predicted.code.length > 0) { - console2.log("Already deployed at predicted address. Skipping."); - return; - } - - // Stage 1: NO BROADCAST. Predicted address logged only. - // Stage 2 (Pedro-supervised) replaces the body below with the - // actual factory call: - // - // vm.startBroadcast(); - // (bool ok, bytes memory ret) = CREATE2_FACTORY.call( - // abi.encodePacked(SESSION_KEY_MODULE_SALT_V1, initCode) - // ); - // require(ok, "CREATE2 deploy failed"); - // address deployed = address(bytes20(ret[12:])); - // require(deployed == predicted, "CREATE2 address mismatch"); - // vm.stopBroadcast(); - - console2.log("Stage 1: predicted address only - no broadcast."); - } -} diff --git a/contracts/test/SessionKeyModule.t.sol b/contracts/test/SessionKeyModule.t.sol deleted file mode 100644 index 3d27e9a3..00000000 --- a/contracts/test/SessionKeyModule.t.sol +++ /dev/null @@ -1,826 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity ^0.8.24; - -import {Test} from "forge-std/Test.sol"; -import {SessionKeyModule} from "../contracts/SessionKeyModule.sol"; -import {ISessionKeyModule} from "../contracts/interfaces/ISessionKeyModule.sol"; -import {PackedUserOperation} from "../contracts/interfaces/IUserOperation.sol"; - -/** - * @dev SimpleAccount-style mock — exposes `owner()` (the lookup - * SessionKeyModule._smartAccountOwner uses) and forwards a UserOp into - * the module so `msg.sender` matches the SA address. This is enough - * for cred-5 stage 2 unit tests; cred-7 wires the real kernel-v3 - * account. - */ -contract MockSmartAccount { - address public owner; - - constructor(address _owner) { - owner = _owner; - } - - function callValidator( - SessionKeyModule mod, - PackedUserOperation calldata op, - bytes32 h - ) external returns (uint256) { - return mod.validateSessionKeyUserOp(op, h); - } - - function callRevoke(SessionKeyModule mod, address signer) external { - mod.revokeSessionKey(address(this), signer); - } -} - -/** - * @title SessionKeyModuleTest - * @notice Foundry tests for cred-5 stage 2 — real validator body. - * Per spec `docs/specs/cred/session-key-delegation.md` §6.1. - * - * The vm.skip'd tests in stage 1 are now implemented. Tests that - * depend on later work-leafs (cred-6 batch validation, cred-7 - * cross-chain deploy) remain skipped with a clarifying comment. - */ -contract SessionKeyModuleTest is Test { - SessionKeyModule internal module; - MockSmartAccount internal account; - address internal master; - uint256 internal masterPriv; - address internal signer; - uint256 internal signerPriv; - address internal constant DATA_EDGE = 0xC445af1D4EB9fce4e1E61fE96ea7B8feBF03c5ca; - bytes4 internal constant EXECUTE_SEL = 0xb61d27f6; - bytes4 internal constant EXECUTE_BATCH_SEL = 0x47e1da2a; - - function setUp() public { - module = new SessionKeyModule(); - - (master, masterPriv) = makeAddrAndKey("master"); - (signer, signerPriv) = makeAddrAndKey("signer"); - - account = new MockSmartAccount(master); - // Pin the chain id so the EIP-712 domain is stable across forks. - // Anvil's default is 31337; that's fine for unit tests. - } - - // ============================================================ - // Load-bearing invariant — CREATE2 byte-equality post install - // (cred §8 R2). Stage 1 (cred-7): asserts the PREDICTED CREATE2 - // address (from `script/DeploySessionKeyModule.s.sol`) is byte- - // equal regardless of chain id, because the computation depends - // only on (factory, salt, initCodeHash) — all chain-independent. - // Stage 2 (Pedro-supervised on-chain broadcast) replaces this - // with an actual cross-chain on-chain address assertion. - // ============================================================ - - // Arachnid's deterministic deployment proxy is exposed by forge-std - // as `Base.CREATE2_FACTORY` (inherited via Test → Base). Same address - // on every EVM chain (EIP-2470 one-shot). Do NOT redeclare here. - - /// @dev Mirror of `DeploySessionKeyModule.SESSION_KEY_MODULE_SALT_V1`. - /// Any change to the salt MUST be reflected here too. - bytes32 internal constant SESSION_KEY_MODULE_SALT_V1 = - keccak256("totalreclaw.SessionKeyModule.v1"); - - function test_create2_address_unchanged_after_module_install() public { - bytes memory initCode = abi.encodePacked( - type(SessionKeyModule).creationCode - ); - bytes32 initCodeHash = keccak256(initCode); - - // Compute predicted CREATE2 address at current block.chainid. - address predictedAtCurrentChain = address( - uint160( - uint256( - keccak256( - abi.encodePacked( - bytes1(0xff), - CREATE2_FACTORY, - SESSION_KEY_MODULE_SALT_V1, - initCodeHash - ) - ) - ) - ) - ); - - // Swap to a different chain id and recompute. The CREATE2 formula - // does NOT include chain id — same factory + same salt + same - // bytecode → same address. Any divergence indicates either the - // salt is non-deterministic (e.g. uses block.timestamp) or the - // contract has chain-dependent immutables in its constructor. - // (SessionKeyModule has no constructor — by design — so this - // assertion is the proof-of-byte-equality.) - uint256 originalChainId = block.chainid; - vm.chainId(100); // Gnosis - address predictedGnosis = address( - uint160( - uint256( - keccak256( - abi.encodePacked( - bytes1(0xff), - CREATE2_FACTORY, - SESSION_KEY_MODULE_SALT_V1, - initCodeHash - ) - ) - ) - ) - ); - vm.chainId(84532); // Base Sepolia - address predictedBaseSepolia = address( - uint160( - uint256( - keccak256( - abi.encodePacked( - bytes1(0xff), - CREATE2_FACTORY, - SESSION_KEY_MODULE_SALT_V1, - initCodeHash - ) - ) - ) - ) - ); - vm.chainId(originalChainId); - - assertEq( - predictedGnosis, - predictedBaseSepolia, - "CREATE2 byte-equality broken across Gnosis / Base Sepolia" - ); - assertEq( - predictedAtCurrentChain, - predictedGnosis, - "CREATE2 byte-equality broken vs current chain" - ); - } - - // ============================================================ - // Spec §6.1 — validator unit tests - // ============================================================ - - function test_first_call_installs_grant_and_emits_event() public { - bytes4[] memory sels = _twoSelectors(); - SessionKeyModule.PermissionGrant memory grant = _buildGrant( - address(account), - signer, - DATA_EDGE, - sels, - 1 - ); - - bytes32 userOpHash = keccak256("first-userop"); - bytes memory ecdsaSig = _signWithKey(signerPriv, userOpHash); - bytes memory sig = abi.encode(grant, ecdsaSig); - - PackedUserOperation memory op = _makePackedOp( - _executeCallData(DATA_EDGE, 0, hex"deadbeef"), - sig - ); - - vm.expectEmit(true, true, false, true, address(module)); - emit ISessionKeyModule.SessionKeyInstalled(address(account), signer, 1); - - uint256 vd = account.callValidator(module, op, userOpHash); - assertEq(vd, 0, "first install + execute must succeed"); - - assertTrue(module.isSessionKeyActive(address(account), signer)); - (uint256 n, , , address tgt) = module.getSessionKeyGrant(address(account), signer); - assertEq(n, 1); - assertEq(tgt, DATA_EDGE); - } - - function test_validate_rejects_unknown_target() public { - _installGrant(1); - - bytes32 userOpHash = keccak256("wrong-target"); - bytes memory ecdsaSig = _signWithKey(signerPriv, userOpHash); - // Inner target is NOT DataEdge — module must reject. - bytes memory cd = _executeCallData(address(0xDEAD), 0, hex"00"); - PackedUserOperation memory op = _makePackedOp(cd, ecdsaSig); - - uint256 vd = account.callValidator(module, op, userOpHash); - assertEq(vd, 1, "out-of-scope target must SIG_VALIDATION_FAILED"); - } - - function test_validate_rejects_unknown_selector() public { - // Install grant with only EXECUTE_SEL allowed (no executeBatch). - bytes4[] memory onlyExecute = new bytes4[](1); - onlyExecute[0] = EXECUTE_SEL; - - SessionKeyModule.PermissionGrant memory grant = _buildGrant( - address(account), - signer, - DATA_EDGE, - onlyExecute, - 1 - ); - - bytes32 installHash = keccak256("install-1"); - bytes memory installSig = abi.encode(grant, _signWithKey(signerPriv, installHash)); - PackedUserOperation memory installOp = _makePackedOp( - _executeCallData(DATA_EDGE, 0, hex"00"), - installSig - ); - assertEq(account.callValidator(module, installOp, installHash), 0); - - // Now try a second UserOp whose outer selector is executeBatch — not in scope. - bytes32 hash2 = keccak256("batch-attempt"); - bytes memory ecdsaSig = _signWithKey(signerPriv, hash2); - bytes memory batchCallData = abi.encodePacked(EXECUTE_BATCH_SEL, hex"00"); - PackedUserOperation memory op = _makePackedOp(batchCallData, ecdsaSig); - - uint256 vd = account.callValidator(module, op, hash2); - assertEq(vd, 1, "out-of-scope selector must SIG_VALIDATION_FAILED"); - } - - function test_revoke_idempotent() public { - // Revoke a signer that was NEVER installed — must be a no-op (no revert). - account.callRevoke(module, signer); - assertFalse(module.isSessionKeyActive(address(account), signer)); - - // Install + revoke + revoke again. Second revoke is also a no-op. - _installGrant(1); - assertTrue(module.isSessionKeyActive(address(account), signer)); - - vm.expectEmit(true, true, false, false, address(module)); - emit ISessionKeyModule.SessionKeyRevoked(address(account), signer); - account.callRevoke(module, signer); - assertFalse(module.isSessionKeyActive(address(account), signer)); - - // No revert. No second event. - account.callRevoke(module, signer); - } - - function test_revoke_only_callable_by_account() public { - _installGrant(1); - // Direct call (not from the account) — must revert. - vm.expectRevert( - abi.encodeWithSelector( - ISessionKeyModule.SessionKeyNotActive.selector, - address(account), - signer - ) - ); - module.revokeSessionKey(address(account), signer); - } - - function test_validate_invalid_signature_returns_failed() public { - _installGrant(1); - - bytes32 userOpHash = keccak256("op-x"); - // Sign with a DIFFERENT key — signer mismatch, module looks up - // grants[account][recovered] and finds none. - (, uint256 attackerPriv) = makeAddrAndKey("attacker"); - bytes memory badSig = _signWithKey(attackerPriv, userOpHash); - - PackedUserOperation memory op = _makePackedOp( - _executeCallData(DATA_EDGE, 0, hex"00"), - badSig - ); - uint256 vd = account.callValidator(module, op, userOpHash); - assertEq(vd, 1, "wrong signer must SIG_VALIDATION_FAILED"); - } - - function test_get_session_key_grant_empty_for_unknown_signer() public view { - (uint256 n, uint256 iAt, bytes4[] memory sels, address tgt) = module - .getSessionKeyGrant(address(account), address(0x1234)); - assertEq(n, 0); - assertEq(iAt, 0); - assertEq(sels.length, 0); - assertEq(tgt, address(0)); - } - - function test_is_session_key_active_false_after_revoke() public { - _installGrant(1); - assertTrue(module.isSessionKeyActive(address(account), signer)); - account.callRevoke(module, signer); - assertFalse(module.isSessionKeyActive(address(account), signer)); - } - - function test_replay_nonce_protection() public { - // Install nonce 1, revoke, then attempt to re-install nonce 1 → reject. - _installGrant(1); - account.callRevoke(module, signer); - - SessionKeyModule.PermissionGrant memory grant = _buildGrant( - address(account), - signer, - DATA_EDGE, - _twoSelectors(), - 1 - ); - bytes32 hash2 = keccak256("replay-attempt"); - bytes memory sig = abi.encode(grant, _signWithKey(signerPriv, hash2)); - PackedUserOperation memory op = _makePackedOp( - _executeCallData(DATA_EDGE, 0, hex"00"), - sig - ); - - uint256 vd = account.callValidator(module, op, hash2); - assertEq(vd, 1, "replayed nonce must SIG_VALIDATION_FAILED"); - - // A FRESH nonce (2) should succeed. - SessionKeyModule.PermissionGrant memory grant2 = _buildGrant( - address(account), - signer, - DATA_EDGE, - _twoSelectors(), - 2 - ); - bytes32 hash3 = keccak256("fresh-nonce"); - bytes memory sig2 = abi.encode(grant2, _signWithKey(signerPriv, hash3)); - PackedUserOperation memory op2 = _makePackedOp( - _executeCallData(DATA_EDGE, 0, hex"00"), - sig2 - ); - uint256 vd2 = account.callValidator(module, op2, hash3); - assertEq(vd2, 0, "fresh nonce must succeed after revoke"); - } - - function test_cross_chain_grant_replay_rejected() public { - // Grant signed for a DIFFERENT chainId — module must reject. - SessionKeyModule.PermissionGrant memory grant = _buildGrant( - address(account), - signer, - DATA_EDGE, - _twoSelectors(), - 1 - ); - grant.chainId = 999; // not block.chainid - // Re-sign the master sig over the new digest - grant.masterSignature = _signMasterGrant(grant); - - bytes32 userOpHash = keccak256("cross-chain"); - bytes memory sig = abi.encode(grant, _signWithKey(signerPriv, userOpHash)); - PackedUserOperation memory op = _makePackedOp( - _executeCallData(DATA_EDGE, 0, hex"00"), - sig - ); - uint256 vd = account.callValidator(module, op, userOpHash); - assertEq(vd, 1, "cross-chain replay must be rejected"); - } - - function test_master_signature_mismatch_rejects() public { - // Forge a grant signed by the wrong master. - (, uint256 attackerPriv) = makeAddrAndKey("attacker-master"); - - SessionKeyModule.PermissionGrant memory grant = _buildGrant( - address(account), - signer, - DATA_EDGE, - _twoSelectors(), - 1 - ); - // Replace master sig with one signed by attacker. - bytes32 digest = _grantDigestExt(grant); - grant.masterSignature = _signWithKey(attackerPriv, digest); - - bytes32 userOpHash = keccak256("forged-master"); - bytes memory sig = abi.encode(grant, _signWithKey(signerPriv, userOpHash)); - PackedUserOperation memory op = _makePackedOp( - _executeCallData(DATA_EDGE, 0, hex"00"), - sig - ); - uint256 vd = account.callValidator(module, op, userOpHash); - assertEq(vd, 1, "forged master signature must be rejected"); - } - - function test_steady_state_userop_after_install_succeeds() public { - _installGrant(1); - - // Now a second UserOp with JUST the ecdsa sig (no grant blob). - bytes32 hash2 = keccak256("steady-state"); - bytes memory ecdsaSig = _signWithKey(signerPriv, hash2); - PackedUserOperation memory op = _makePackedOp( - _executeCallData(DATA_EDGE, 0, hex"42"), - ecdsaSig - ); - uint256 vd = account.callValidator(module, op, hash2); - assertEq(vd, 0, "steady-state UserOp must succeed without re-sending grant"); - } - - function test_nonzero_value_rejected() public { - _installGrant(1); - - bytes32 userOpHash = keccak256("eth-transfer"); - bytes memory ecdsaSig = _signWithKey(signerPriv, userOpHash); - // value != 0 — session keys cannot move ETH (spec §3.1 valueMax = 0). - bytes memory cd = _executeCallData(DATA_EDGE, 1 ether, hex"00"); - PackedUserOperation memory op = _makePackedOp(cd, ecdsaSig); - - uint256 vd = account.callValidator(module, op, userOpHash); - assertEq(vd, 1, "non-zero value must SIG_VALIDATION_FAILED"); - } - - // ============================================================ - // Spec §4.2 cross-spec invariant — per-inner-call validation - // under executeBatch (cred-6). Every inner is validated against - // grant.target, value==0, and selector ∈ grant.selectors. - // ============================================================ - - function test_malformed_executeBatch_calldata_rejected() public { - _installGrant(1); - - // Outer selector is executeBatch (in the allowlist) but the payload - // is too short to abi.decode into (address[], uint256[], bytes[]). - // The validator's self-external try/catch must surface the decode - // revert as SIG_VALIDATION_FAILED, not bubble it up. - bytes32 hash2 = keccak256("malformed-batch"); - bytes memory ecdsaSig = _signWithKey(signerPriv, hash2); - bytes memory batchCallData = abi.encodePacked(EXECUTE_BATCH_SEL, hex"00"); - PackedUserOperation memory op = _makePackedOp(batchCallData, ecdsaSig); - - uint256 vd = account.callValidator(module, op, hash2); - assertEq(vd, 1, "malformed executeBatch payload must SIG_VALIDATION_FAILED"); - } - - function test_session_key_rejects_batch_with_out_of_scope_inner_call() public { - _installGrant(1); - - // 3-inner batch: first two in-scope, third targets a different - // address. Per spec §4.2 step 4 ("Return SIG_VALIDATION_FAILED on - // the first non-matching inner"), this rejects. - address[] memory targets = new address[](3); - uint256[] memory values = new uint256[](3); - bytes[] memory datas = new bytes[](3); - - targets[0] = DATA_EDGE; - targets[1] = DATA_EDGE; - targets[2] = address(0xBADC0DE); // out-of-scope target - for (uint256 i = 0; i < 3; i++) { - values[i] = 0; - datas[i] = abi.encodePacked(EXECUTE_SEL, uint256(i)); // selector ∈ allowlist - } - - bytes32 h = keccak256("out-of-scope-inner"); - bytes memory sig = _signWithKey(signerPriv, h); - bytes memory cd = abi.encodePacked(EXECUTE_BATCH_SEL, abi.encode(targets, values, datas)); - PackedUserOperation memory op = _makePackedOp(cd, sig); - - uint256 vd = account.callValidator(module, op, h); - assertEq(vd, 1, "single out-of-scope inner must fail the whole batch"); - } - - function test_session_key_accepts_batch_with_all_in_scope_inner_calls() public { - _installGrant(1); - - // 3-inner batch: every inner targets DATA_EDGE with value=0 and an - // allowed selector. Validator must return SIG_VALIDATION_SUCCESS. - bytes memory cd = _inScopeBatchCallData(3); - - bytes32 h = keccak256("all-in-scope-3"); - bytes memory sig = _signWithKey(signerPriv, h); - PackedUserOperation memory op = _makePackedOp(cd, sig); - - uint256 vd = account.callValidator(module, op, h); - assertEq(vd, 0, "all-in-scope batch must SIG_VALIDATION_SUCCESS"); - } - - function test_session_key_rejects_batch_with_nonzero_value_inner() public { - _installGrant(1); - - // Session keys cannot move ETH (spec §4.2 + invariant 3). Any - // values[i] != 0 → reject. - address[] memory targets = new address[](2); - uint256[] memory values = new uint256[](2); - bytes[] memory datas = new bytes[](2); - targets[0] = DATA_EDGE; - targets[1] = DATA_EDGE; - values[0] = 0; - values[1] = 1 wei; - datas[0] = abi.encodePacked(EXECUTE_SEL, uint256(0)); - datas[1] = abi.encodePacked(EXECUTE_SEL, uint256(1)); - - bytes32 h = keccak256("nonzero-value-inner"); - bytes memory sig = _signWithKey(signerPriv, h); - bytes memory cd = abi.encodePacked(EXECUTE_BATCH_SEL, abi.encode(targets, values, datas)); - PackedUserOperation memory op = _makePackedOp(cd, sig); - - uint256 vd = account.callValidator(module, op, h); - assertEq(vd, 1, "any nonzero value in batch must SIG_VALIDATION_FAILED"); - } - - function test_session_key_rejects_batch_with_out_of_scope_inner_selector() public { - // Install grant with ONLY execute in the allowlist (no executeBatch - // as inner selector). Then batch with an inner whose selector is - // not in the grant — reject. - bytes4[] memory onlyExec = new bytes4[](1); - onlyExec[0] = EXECUTE_BATCH_SEL; // outer must be in grant for the batch path to be reachable - - SessionKeyModule.PermissionGrant memory grant = _buildGrant( - address(account), - signer, - DATA_EDGE, - onlyExec, - 1 - ); - bytes32 installH = keccak256("install-batch-only"); - // Install batch: outer = executeBatch (in grant), inner = executeBatch (in grant). - bytes memory installCd = _batchWithSingleInner(EXECUTE_BATCH_SEL); - bytes memory installSig = abi.encode(grant, _signWithKey(signerPriv, installH)); - PackedUserOperation memory installOp = _makePackedOp(installCd, installSig); - assertEq( - account.callValidator(module, installOp, installH), - 0, - "install must succeed with all-in-scope inner" - ); - - // Now build a batch whose inner uses EXECUTE_SEL — NOT in grant. - bytes memory cd = _batchWithSingleInner(EXECUTE_SEL); - bytes32 h = keccak256("inner-sel-bad"); - bytes memory sig = _signWithKey(signerPriv, h); - PackedUserOperation memory op = _makePackedOp(cd, sig); - - uint256 vd = account.callValidator(module, op, h); - assertEq(vd, 1, "inner selector not in grant must SIG_VALIDATION_FAILED"); - } - - function test_session_key_rejects_empty_batch() public { - _installGrant(1); - - // Empty batch is defensively rejected — there is no positive - // proof of in-scope work being performed. - address[] memory targets = new address[](0); - uint256[] memory values = new uint256[](0); - bytes[] memory datas = new bytes[](0); - - bytes32 h = keccak256("empty-batch"); - bytes memory sig = _signWithKey(signerPriv, h); - bytes memory cd = abi.encodePacked(EXECUTE_BATCH_SEL, abi.encode(targets, values, datas)); - PackedUserOperation memory op = _makePackedOp(cd, sig); - - uint256 vd = account.callValidator(module, op, h); - assertEq(vd, 1, "empty executeBatch must SIG_VALIDATION_FAILED"); - } - - /// @dev Gas-budget assertion: per-inner-call validator overhead must be - /// under the 5K-gas budget set by the cred-6 issue acceptance - /// criteria. Compares the gas of a 2-inner batch and a 6-inner - /// batch; the delta divided by 4 is the per-inner cost. - function test_session_key_batch_per_inner_gas_under_5k_budget() public { - _installGrant(1); - - bytes memory cd2 = _inScopeBatchCallData(2); - bytes memory cd6 = _inScopeBatchCallData(6); - - bytes32 h2 = keccak256("batch-gas-2"); - bytes32 h6 = keccak256("batch-gas-6"); - bytes memory sig2 = _signWithKey(signerPriv, h2); - bytes memory sig6 = _signWithKey(signerPriv, h6); - - PackedUserOperation memory op2 = _makePackedOp(cd2, sig2); - PackedUserOperation memory op6 = _makePackedOp(cd6, sig6); - - uint256 g0 = gasleft(); - uint256 vd2 = account.callValidator(module, op2, h2); - uint256 gas2 = g0 - gasleft(); - assertEq(vd2, 0, "2-inner in-scope batch must succeed"); - - uint256 g1 = gasleft(); - uint256 vd6 = account.callValidator(module, op6, h6); - uint256 gas6 = g1 - gasleft(); - assertEq(vd6, 0, "6-inner in-scope batch must succeed"); - - // (gas6 - gas2) isolates the cost of the 4 additional inner calls. - uint256 perInner = (gas6 - gas2) / 4; - emit log_named_uint("per-inner validator gas", perInner); - assertLt(perInner, 5000, "per-inner-call overhead must be under 5K gas"); - } - - // ============================================================ - // cred-9 — cross-language parity fixture. - // Loads `tests/parity/fixtures/session-key-grant-v1.json` and asserts - // the Solidity-computed EIP-712 grant digest + ecrecover'd master EOA - // match the values the TS sibling (and the in-flight Python sibling - // cred-8 #333) commit to. Locks byte-equality across all three - // implementations of `_grantDigest`. - // ============================================================ - - function test_parity_fixture_matches_solidity_digest_and_recovers_master() public { - string memory json = vm.readFile( - "../tests/parity/fixtures/session-key-grant-v1.json" - ); - - SessionKeyModule.PermissionGrant memory g; - g.version = uint8(vm.parseJsonUint(json, ".grant.version")); - g.account = vm.parseJsonAddress(json, ".grant.account"); - g.signer = vm.parseJsonAddress(json, ".grant.signer"); - g.target = vm.parseJsonAddress(json, ".grant.scope.target"); - // valueMax / nonce / issuedAt are emitted as JSON strings (uint256-safe - // across languages); parse via parseJsonString + parseUint. - g.valueMax = vm.parseUint( - vm.parseJsonString(json, ".grant.scope.valueMax") - ); - g.nonce = vm.parseUint(vm.parseJsonString(json, ".grant.nonce")); - g.issuedAt = vm.parseUint(vm.parseJsonString(json, ".grant.issuedAt")); - g.chainId = vm.parseJsonUint(json, ".domain.chainId"); - g.verifyingContract = vm.parseJsonAddress(json, ".domain.verifyingContract"); - g.masterSignature = vm.parseJsonBytes(json, ".ecdsa_signature"); - - // bytes4[] — no native parseJsonBytes4Array; read each as bytes + cast. - bytes memory sel0 = vm.parseJsonBytes(json, ".grant.scope.selectors[0]"); - bytes memory sel1 = vm.parseJsonBytes(json, ".grant.scope.selectors[1]"); - g.selectors = new bytes4[](2); - g.selectors[0] = bytes4(sel0); - g.selectors[1] = bytes4(sel1); - assertEq(g.selectors[0], EXECUTE_SEL, "fixture selectors[0] is execute"); - assertEq(g.selectors[1], EXECUTE_BATCH_SEL, "fixture selectors[1] is executeBatch"); - - bytes32 expectedDigest = vm.parseJsonBytes32(json, ".eip712_hash"); - address expectedMaster = vm.parseJsonAddress(json, ".accounts.master_address"); - - // 1. Solidity-computed digest matches the fixture. - bytes32 computedDigest = _grantDigestExt(g); - assertEq( - computedDigest, - expectedDigest, - "Solidity _grantDigest matches fixture.eip712_hash" - ); - - // 2. ecrecover on fixture sig + digest recovers the master EOA. This - // is the same primitive `SessionKeyModule.validateSessionKeyUserOp` - // uses to authorise the lazy-install path. - (bytes32 r, bytes32 s, uint8 v) = _splitSig65(g.masterSignature); - address recovered = ecrecover(expectedDigest, v, r, s); - assertEq(recovered, expectedMaster, "ecrecover yields fixture master EOA"); - } - - function test_parity_fixture_install_blob_decodes_to_same_grant() public { - string memory json = vm.readFile( - "../tests/parity/fixtures/session-key-grant-v1.json" - ); - - bytes memory installSig = vm.parseJsonBytes(json, ".abi_encoded_install_sig"); - - // The fixture's install blob must decode through the same shape the - // module's `_decodeInstallSig` consumes: `(PermissionGrant, bytes)`. - (SessionKeyModule.PermissionGrant memory g, bytes memory sessionSig) = abi.decode( - installSig, - (SessionKeyModule.PermissionGrant, bytes) - ); - - // Sanity-spot-check critical fields against the fixture's grant block. - assertEq(uint256(g.version), vm.parseJsonUint(json, ".grant.version")); - assertEq(g.account, vm.parseJsonAddress(json, ".grant.account")); - assertEq(g.signer, vm.parseJsonAddress(json, ".grant.signer")); - assertEq(g.target, vm.parseJsonAddress(json, ".grant.scope.target")); - assertEq(g.chainId, vm.parseJsonUint(json, ".domain.chainId")); - assertEq(g.verifyingContract, vm.parseJsonAddress(json, ".domain.verifyingContract")); - assertEq(g.selectors.length, 2); - assertEq(g.selectors[0], EXECUTE_SEL); - assertEq(g.selectors[1], EXECUTE_BATCH_SEL); - assertEq(g.masterSignature.length, 65); - - // The trailing session-side sig in the blob must recover to the - // fixture's session EOA (same ecrecover primitive the steady-state - // validator path uses). - bytes32 mockUserOpHash = vm.parseJsonBytes32(json, ".session.mock_user_op_hash"); - address expectedSession = vm.parseJsonAddress(json, ".accounts.session_address"); - (bytes32 r, bytes32 s, uint8 v) = _splitSig65(sessionSig); - address recoveredSession = ecrecover(mockUserOpHash, v, r, s); - assertEq(recoveredSession, expectedSession, "trailing session sig recovers to session EOA"); - } - - /// @dev Splits a 65-byte (r, s, v) signature. Mirrors - /// `SessionKeyModule._recoverEcdsa` layout. - function _splitSig65( - bytes memory sig - ) internal pure returns (bytes32 r, bytes32 s, uint8 v) { - require(sig.length == 65, "sig length"); - // solhint-disable-next-line no-inline-assembly - assembly { - r := mload(add(sig, 0x20)) - s := mload(add(sig, 0x40)) - v := byte(0, mload(add(sig, 0x60))) - } - } - - // ============================================================ - // Helpers - // ============================================================ - - function _twoSelectors() internal pure returns (bytes4[] memory s) { - s = new bytes4[](2); - s[0] = EXECUTE_SEL; - s[1] = EXECUTE_BATCH_SEL; - } - - function _buildGrant( - address _account, - address _signer, - address _target, - bytes4[] memory _selectors, - uint256 _nonce - ) internal view returns (SessionKeyModule.PermissionGrant memory g) { - g.version = 1; - g.account = _account; - g.signer = _signer; - g.target = _target; - g.selectors = _selectors; - g.valueMax = 0; - g.nonce = _nonce; - g.issuedAt = block.timestamp; - g.chainId = block.chainid; - g.verifyingContract = address(module); - g.masterSignature = _signMasterGrant(g); - } - - function _installGrant(uint256 nonce) internal { - SessionKeyModule.PermissionGrant memory grant = _buildGrant( - address(account), - signer, - DATA_EDGE, - _twoSelectors(), - nonce - ); - bytes32 userOpHash = keccak256(abi.encode("install-helper", nonce)); - bytes memory sig = abi.encode(grant, _signWithKey(signerPriv, userOpHash)); - PackedUserOperation memory op = _makePackedOp( - _executeCallData(DATA_EDGE, 0, hex"00"), - sig - ); - uint256 vd = account.callValidator(module, op, userOpHash); - assertEq(vd, 0, "_installGrant helper: install must succeed"); - } - - function _signMasterGrant( - SessionKeyModule.PermissionGrant memory g - ) internal view returns (bytes memory) { - bytes32 digest = _grantDigestExt(g); - return _signWithKey(masterPriv, digest); - } - - /// @dev Calls the PRODUCTION digest computation (`SessionKeyModule - /// .grantDigest`, an external pure wrapper around the internal - /// `_grantDigest`) instead of hand-copying the EIP-712 hashing - /// formula. Previously this function WAS a hand-copied mirror of - /// `_grantDigest`, with a comment admitting both copies had to be - /// updated together — precisely the kind of duplication that let - /// the §2 `bytes4[]` selector-hashing bug ship in the Python - /// client (`grant.py`) without any test catching it (phase3-impl - /// spec §3.4 "de-duplicate the EIP-712 logic"). Fixed by adding a - /// real production entry point instead of a second copy. - function _grantDigestExt( - SessionKeyModule.PermissionGrant memory g - ) internal view returns (bytes32) { - return module.grantDigest(g); - } - - function _signWithKey(uint256 privKey, bytes32 digest) internal pure returns (bytes memory) { - (uint8 v, bytes32 r, bytes32 s) = vm.sign(privKey, digest); - return abi.encodePacked(r, s, v); - } - - function _executeCallData( - address tgt, - uint256 value, - bytes memory data - ) internal pure returns (bytes memory) { - return abi.encodePacked(EXECUTE_SEL, abi.encode(tgt, value, data)); - } - - /// @dev Build an `executeBatch(...)` call whose inners all target - /// DATA_EDGE with value=0 and use EXECUTE_SEL as the inner selector - /// (which is in the default `_twoSelectors()` grant allowlist). - function _inScopeBatchCallData(uint256 n) internal pure returns (bytes memory) { - address[] memory targets = new address[](n); - uint256[] memory values = new uint256[](n); - bytes[] memory datas = new bytes[](n); - for (uint256 i = 0; i < n; i++) { - targets[i] = DATA_EDGE; - values[i] = 0; - datas[i] = abi.encodePacked(EXECUTE_SEL, uint256(i)); - } - return abi.encodePacked(EXECUTE_BATCH_SEL, abi.encode(targets, values, datas)); - } - - /// @dev Build a single-inner `executeBatch(...)` call with the given - /// inner selector. Used to test inner-selector allowlist checks. - function _batchWithSingleInner(bytes4 innerSel) internal pure returns (bytes memory) { - address[] memory targets = new address[](1); - uint256[] memory values = new uint256[](1); - bytes[] memory datas = new bytes[](1); - targets[0] = DATA_EDGE; - values[0] = 0; - datas[0] = abi.encodePacked(innerSel, uint256(0)); - return abi.encodePacked(EXECUTE_BATCH_SEL, abi.encode(targets, values, datas)); - } - - function _makePackedOp( - bytes memory callData, - bytes memory sig - ) internal view returns (PackedUserOperation memory op) { - op.sender = address(account); - op.nonce = 0; - op.initCode = ""; - op.callData = callData; - op.accountGasLimits = 0; - op.preVerificationGas = 0; - op.gasFees = 0; - op.paymasterAndData = ""; - op.signature = sig; - } -} diff --git a/python/src/totalreclaw/grant.py b/python/src/totalreclaw/grant.py index 68bdbeb5..dd29a4cc 100644 --- a/python/src/totalreclaw/grant.py +++ b/python/src/totalreclaw/grant.py @@ -1,18 +1,19 @@ """ EIP-712 typed-data payload for ``SessionKeyPermissionGrant`` — Python side. -Mirrors the Solidity ``SessionKeyModule._grantDigest`` construction byte-for-byte +Mirrors the Solidity ``TotalReclawAccount._grantDigest`` construction byte-for-byte so a Python-signed grant verifies on-chain and a TS/viem-signed grant verifies in Python. The cross-language parity fixture (cred-9) is the locking test for that invariant. Spec: ``docs/specs/cred/session-key-delegation.md`` §3.1, §4.2, §4.3. -Solidity reference: ``contracts/contracts/SessionKeyModule.sol`` (cred-5 -stage 2, PR #272 — merged 2026-05-27). +Solidity reference: ``contracts/account/contracts/TotalReclawAccount.sol`` (Option E Phase 3, +#636), which ported the grant wire format verbatim from the original +``SessionKeyModule.sol`` (cred-5, PR #272; since removed — see git history). Cross-spec invariants enforced here: - Domain typehash, scope typehash, and grant typehash strings are duplicated - verbatim from ``SessionKeyModule.sol``. Any drift breaks on-chain verify. + verbatim from ``TotalReclawAccount.sol``. Any drift breaks on-chain verify. - ``selectors`` are hashed via ``keccak256(abi.encodePacked(selectors))`` — matching the Solidity ``keccak256(abi.encodePacked(g.selectors))`` over the ``bytes4[] memory`` field. **Non-obvious Solidity quirk (see #584):** @@ -29,8 +30,10 @@ ``tests/parity/session-key-grant-roundtrip.ts`` and ``python/tests/test_session_key_signing.py::TestKnownAnswerDigest``). - Domain name is ``"TotalReclawSessionKey"``, version ``"1"``. ``chainId`` - + ``verifyingContract`` (the SessionKeyModule address) are the binding - fields that prevent cross-chain / cross-module replay. + + ``verifyingContract`` are the binding fields that prevent cross-chain / + cross-account replay. Under ``TotalReclawAccount`` (P3-3) + ``verifyingContract`` MUST equal the Smart Account address itself (the same + value as ``account``) — the contract rejects any other value. """ from __future__ import annotations @@ -42,7 +45,7 @@ from eth_utils import keccak, to_checksum_address # --------------------------------------------------------------------------- -# Typehashes — MUST match SessionKeyModule.sol byte-for-byte +# Typehashes — MUST match TotalReclawAccount.sol byte-for-byte # --------------------------------------------------------------------------- DOMAIN_NAME: str = "TotalReclawSessionKey" @@ -65,7 +68,7 @@ DOMAIN_NAME_HASH: bytes = keccak(DOMAIN_NAME.encode()) DOMAIN_VERSION_HASH: bytes = keccak(DOMAIN_VERSION.encode()) -# Selector constants (mirrors SessionKeyModule.sol). +# Selector constants (mirrors TotalReclawAccount.sol). EXECUTE_SELECTOR: bytes = bytes.fromhex("b61d27f6") EXECUTE_BATCH_SELECTOR: bytes = bytes.fromhex("47e1da2a") @@ -125,7 +128,8 @@ class SessionKeyPermissionGrant: """EVM chain id — replay-protection field. Module rejects mismatches.""" verifying_contract: str - """SessionKeyModule address on this chain — replay-protection field.""" + """Replay-protection field. Under ``TotalReclawAccount`` this MUST be the + Smart Account address (same as ``account``); the contract rejects any other value.""" version: int = GRANT_VERSION """Grant format version. Module rejects unknown versions.""" @@ -158,7 +162,7 @@ def with_signature(self, signature: bytes) -> "SessionKeyPermissionGrant": ) # ----------------------------------------------------------------- - # EIP-712 digest — MUST match SessionKeyModule._grantDigest byte-for-byte + # EIP-712 digest — MUST match TotalReclawAccount._grantDigest byte-for-byte # ----------------------------------------------------------------- def scope_struct_hash(self) -> bytes: @@ -251,7 +255,7 @@ def recover_master(self) -> str: def to_install_struct_tuple(self) -> tuple: """Tuple matching Solidity ``PermissionGrant`` field order. - Field order (LOAD-BEARING — see SessionKeyModule.sol struct): + Field order (LOAD-BEARING — see TotalReclawAccount.sol struct): version, account, signer, target, selectors, valueMax, nonce, issuedAt, chainId, verifyingContract, masterSignature """ @@ -282,7 +286,7 @@ def sign_digest(digest: bytes, priv_key: bytes) -> bytes: Returns 65 bytes ``r ‖ s ‖ v`` with ``v`` in {27, 28} per Ethereum convention. Matches the Solidity ``ecrecover(digest, v, r, s)`` shape - used inside ``SessionKeyModule._recoverEcdsa``. + used inside ``TotalReclawAccount._recoverEcdsa``. """ if len(digest) != 32: raise ValueError(f"digest must be 32 bytes; got {len(digest)}") @@ -328,7 +332,7 @@ def encode_install_signature( ) -> bytes: """ABI-encode ``(PermissionGrant, bytes)`` for the lazy-install UserOp signature. - Matches the layout ``SessionKeyModule._decodeInstallSig`` decodes via + Matches the layout ``TotalReclawAccount._decodeInstallSig`` decodes via ``abi.decode(sig, (PermissionGrant, bytes))``. """ if len(ecdsa_sig) != 65: diff --git a/python/src/totalreclaw/userop.py b/python/src/totalreclaw/userop.py index efcd6522..967ba5a1 100644 --- a/python/src/totalreclaw/userop.py +++ b/python/src/totalreclaw/userop.py @@ -1346,16 +1346,16 @@ def sign_userop_with_session_key( - ``include_grant=False`` (steady state): raw 65-byte ECDSA over the EntryPoint UserOp hash. The session signer must already be installed - in the SessionKeyModule (lazy-installed by an earlier UserOp). + in the account (``TotalReclawAccount``, lazy-installed by an earlier UserOp). - ``include_grant=True`` (first UserOp post-pair): ``abi.encode( - PermissionGrant, ecdsaSig)`` matching ``SessionKeyModule._decodeInstallSig``. + PermissionGrant, ecdsaSig)`` matching ``TotalReclawAccount._decodeInstallSig``. The module installs the entry on first encounter and validates the current call's scope. Unlike :func:`sign_user_op_hash` (which applies the EIP-191 ``\\x19Ethereum Signed Message:\\n32`` prefix used by SimpleAccount's legacy validator), this function signs the userOpHash raw — matching - ``SessionKeyModule._recoverEcdsa(userOpHash, sig)`` exactly. + ``TotalReclawAccount._recoverEcdsa(userOpHash, sig)`` exactly. Parameters ---------- @@ -1414,9 +1414,10 @@ def sign_userop_with_session_key( # --------------------------------------------------------------------------- -# Standard TheGraph entity-collection name for the SessionKeyModule's -# ``SessionKeyInstalled(address indexed account, address indexed signer, -# uint256 nonce)`` event. The subgraph indexing of this event is open per +# Standard TheGraph entity-collection name for ``TotalReclawAccount``'s +# ``SessionKeyInstalled(address indexed signer, uint256 nonce)`` event (the +# emitting contract is the account, so the subgraph derives ``account`` from +# the event address). The subgraph indexing of this event is open per # spec §11.Q4 — when the subgraph ships, this name is the contract. _SESSION_KEY_INSTALLED_QUERY: str = ( "query SessionKeyInstalled($account: Bytes!, $signer: Bytes!) {\n" @@ -1444,7 +1445,7 @@ async def session_key_grant_was_installed( chain_id: int, relay_client: "RelayClient", ) -> bool: - """Return True iff the SessionKeyModule has emitted + """Return True iff the Smart Account (``TotalReclawAccount``) has emitted ``SessionKeyInstalled(smart_account, signer, *)`` on ``chain_id``. Callers use this to switch ``sign_userop_with_session_key`` from diff --git a/python/tests/test_session_key_signing.py b/python/tests/test_session_key_signing.py index ec485426..79cb0ef4 100644 --- a/python/tests/test_session_key_signing.py +++ b/python/tests/test_session_key_signing.py @@ -6,7 +6,8 @@ ``totalreclaw.userop.session_key_grant_was_installed`` The EIP-712 typehash constants are duplicated from Solidity -``SessionKeyModule.sol`` (cred-5 stage 2, PR #272). The cross-language +``TotalReclawAccount.sol`` (which ported it verbatim from the since-removed +``SessionKeyModule.sol``, cred-5 stage 2, PR #272). The cross-language parity fixture (cred-9) consumes the same inputs from this file. ``TestKnownAnswerDigest`` below pins ``eip712_digest()`` against that @@ -92,7 +93,7 @@ def _make_grant( # --------------------------------------------------------------------------- -# EIP-712 typehash constants — must match Solidity SessionKeyModule.sol +# EIP-712 typehash constants — must match Solidity TotalReclawAccount.sol # --------------------------------------------------------------------------- @@ -129,7 +130,7 @@ def test_domain_version_hash(self): assert DOMAIN_VERSION_HASH == keccak(b"1") def test_execute_selector_matches_solidity_constant(self): - # SessionKeyModule.sol: EXECUTE_SELECTOR = 0xb61d27f6 + # TotalReclawAccount.sol: EXECUTE_SELECTOR = 0xb61d27f6 assert EXECUTE_SELECTOR == bytes.fromhex("b61d27f6") # Sanity: matches keccak("execute(address,uint256,bytes)")[:4] assert EXECUTE_SELECTOR == keccak(b"execute(address,uint256,bytes)")[:4] @@ -277,7 +278,7 @@ class TestKnownAnswerDigest: Expected-value derivation (NOT copy-pasted blind from this codebase): - 1. Solidity's ``SessionKeyModule._grantDigest`` hashes selectors via + 1. Solidity's ``TotalReclawAccount._grantDigest`` hashes selectors via ``keccak256(abi.encodePacked(g.selectors))`` where ``g.selectors`` is ``bytes4[] memory``. ``abi.encodePacked`` on an ARRAY argument does not tightly concatenate elements the way it @@ -287,7 +288,7 @@ class TestKnownAnswerDigest: 32-byte word before concatenation. Confirmed by compiling and running ``abi.encodePacked(bytes4[2])`` in Solidity: length is 64 bytes, not 8 (see #584 + the header comment of - ``contracts/contracts/SessionKeyModule.sol`` / + ``contracts/account/contracts/TotalReclawAccount.sol`` / ``tests/parity/fixtures/generate-session-key-grant-v1.ts``). 2. ``EXPECTED_DIGEST_HEX`` below is ``eip712_hash`` from the cross-language parity fixture diff --git a/tests/parity/fixtures/generate-session-key-grant-v1.ts b/tests/parity/fixtures/generate-session-key-grant-v1.ts index c5f3c31e..f4fd059a 100644 --- a/tests/parity/fixtures/generate-session-key-grant-v1.ts +++ b/tests/parity/fixtures/generate-session-key-grant-v1.ts @@ -5,8 +5,9 @@ * Locks byte-equality between three implementations of the EIP-712 grant * digest + master ECDSA signature + ABI-encoded install blob: * - * - Solidity validator (`contracts/contracts/SessionKeyModule.sol`, - * cred-5 stage 2, MERGED #272) — source-of-truth encoder. + * - Solidity validator (`contracts/account/contracts/TotalReclawAccount.sol`, + * #636; wire format ported verbatim from the since-removed + * `SessionKeyModule.sol`, cred-5 #272) — source-of-truth encoder. * - TS / viem (`tests/parity/session-key-grant-roundtrip.ts`, this PR). * - Python / `eth_account` (`python/...`, cred-8 in flight #333). * @@ -73,7 +74,7 @@ const MOCK_USER_OP_HASH = keccak256( ); // --------------------------------------------------------------------------- -// EIP-712 typehashes — must match SessionKeyModule.sol byte-for-byte. +// EIP-712 typehashes — must match TotalReclawAccount.sol byte-for-byte. // --------------------------------------------------------------------------- const DOMAIN_TYPEHASH = keccak256( @@ -175,7 +176,7 @@ function grantDigest(args: { function signatureToHex(sig: { r: Hex; s: Hex; v: bigint | undefined; yParity?: number }): Hex { // viem returns r/s as 0x-prefixed 32-byte hex strings and v as the parity // byte (27 or 28 for legacy / EIP-191 / EIP-712 signatures). Pack to the - // 65-byte (r, s, v) layout the SessionKeyModule._recoverEcdsa expects. + // 65-byte (r, s, v) layout the TotalReclawAccount._recoverEcdsa expects. let vByte: number; if (sig.v === 27n || sig.v === 28n) { vByte = Number(sig.v); @@ -226,7 +227,7 @@ async function main(): Promise { const sessionSignature = signatureToHex(sessionRaw); // 4. ABI-encode the install blob — must decode as `(PermissionGrant, bytes)` - // inside SessionKeyModule._decodeInstallSig. + // inside TotalReclawAccount._decodeInstallSig. const permissionGrantType = { type: 'tuple', components: [ @@ -268,7 +269,7 @@ async function main(): Promise { meta: { version: 1, description: - 'Cross-language parity fixture for SessionKeyPermissionGrant (cred-9, spec §3.1 + §6.4). Locks the EIP-712 digest + master ECDSA signature + ABI-encoded install blob across Solidity (SessionKeyModule.sol), TS (viem), Python (eth_account). The Solidity validator uses a non-standard `bytes4[]` encoding — see generate-session-key-grant-v1.ts for the digest implementation siblings MUST mirror. Regenerate via: npx tsx fixtures/generate-session-key-grant-v1.ts', + 'Cross-language parity fixture for SessionKeyPermissionGrant (cred-9, spec §3.1 + §6.4). Locks the EIP-712 digest + master ECDSA signature + ABI-encoded install blob across Solidity (TotalReclawAccount.sol), TS (viem), Python (eth_account). The Solidity validator uses a non-standard `bytes4[]` encoding — see generate-session-key-grant-v1.ts for the digest implementation siblings MUST mirror. Regenerate via: npx tsx fixtures/generate-session-key-grant-v1.ts', grant_version: GRANT_VERSION, generator: 'tests/parity/fixtures/generate-session-key-grant-v1.ts', }, diff --git a/tests/parity/fixtures/session-key-grant-v1.json b/tests/parity/fixtures/session-key-grant-v1.json index d7f805ca..3948813d 100644 --- a/tests/parity/fixtures/session-key-grant-v1.json +++ b/tests/parity/fixtures/session-key-grant-v1.json @@ -1,7 +1,7 @@ { "meta": { "version": 1, - "description": "Cross-language parity fixture for SessionKeyPermissionGrant (cred-9, spec §3.1 + §6.4). Locks the EIP-712 digest + master ECDSA signature + ABI-encoded install blob across Solidity (SessionKeyModule.sol), TS (viem), Python (eth_account). The Solidity validator uses a non-standard `bytes4[]` encoding — see generate-session-key-grant-v1.ts for the digest implementation siblings MUST mirror. Regenerate via: npx tsx fixtures/generate-session-key-grant-v1.ts", + "description": "Cross-language parity fixture for SessionKeyPermissionGrant (cred-9, spec §3.1 + §6.4). Locks the EIP-712 digest + master ECDSA signature + ABI-encoded install blob across Solidity (TotalReclawAccount.sol), TS (viem), Python (eth_account). The Solidity validator uses a non-standard `bytes4[]` encoding — see generate-session-key-grant-v1.ts for the digest implementation siblings MUST mirror. Regenerate via: npx tsx fixtures/generate-session-key-grant-v1.ts", "grant_version": 1, "generator": "tests/parity/fixtures/generate-session-key-grant-v1.ts" }, diff --git a/tests/parity/session-key-grant-roundtrip.ts b/tests/parity/session-key-grant-roundtrip.ts index 2c3d845a..023a43ad 100644 --- a/tests/parity/session-key-grant-roundtrip.ts +++ b/tests/parity/session-key-grant-roundtrip.ts @@ -14,13 +14,13 @@ * 4. The session signer's signature over the mock user-op hash recovers * to the session EOA. * 5. The ABI-encoded `(PermissionGrant, bytes)` install blob (what - * `SessionKeyModule.validateSessionKeyUserOp` decodes on the lazy-install + * `TotalReclawAccount._validateSessionKeySignature` decodes on the lazy-install * path) is byte-equal to `abi_encoded_install_sig`, AND round-trip * decodes back to the original fields. * * The Python sibling (cred-8, in flight at #333) and the Foundry sibling - * (extension in `contracts/test/SessionKeyModule.t.sol` shipped in this - * PR) load the same fixture and assert the same invariants. All three + * (the `test_parity_fixture_*` tests in + * `contracts/account/test/TotalReclawAccount.t.sol`) load the same fixture and assert the same invariants. All three * passing is the cross-language guarantee. * * Run: @@ -45,7 +45,7 @@ import { privateKeyToAccount, sign } from 'viem/accounts'; const __dirname = dirname(fileURLToPath(import.meta.url)); // --------------------------------------------------------------------------- -// EIP-712 typehashes — must match SessionKeyModule.sol byte-for-byte. +// EIP-712 typehashes — must match TotalReclawAccount.sol byte-for-byte. // Duplicated from the generator on purpose: a copy-paste divergence would // surface here as a digest mismatch rather than a silent regen drift. // --------------------------------------------------------------------------- @@ -106,7 +106,7 @@ interface Fixture { } // --------------------------------------------------------------------------- -// Mirrors SessionKeyModule._grantDigest. Non-standard `bytes4[]` encoding — +// Mirrors TotalReclawAccount._grantDigest. Non-standard `bytes4[]` encoding — // see fixtures/generate-session-key-grant-v1.ts header for rationale. // ---------------------------------------------------------------------------