Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 7 additions & 9 deletions .github/workflows/forge-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down
6 changes: 3 additions & 3 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

```
+-------------------------------------------------------------------------+
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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. |

---

Expand Down
33 changes: 10 additions & 23 deletions contracts/.gas-snapshot
Original file line number Diff line number Diff line change
@@ -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)
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)
10 changes: 5 additions & 5 deletions contracts/account/contracts/TotalReclawAccount.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
3 changes: 3 additions & 0 deletions contracts/account/foundry.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
123 changes: 120 additions & 3 deletions contracts/account/test/TotalReclawAccount.t.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -73,7 +73,7 @@ contract TotalReclawAccountTest is Test {
///
/// NOTE: this salt is a placeholder for THIS TEST ONLY, chosen to
/// follow the existing `totalreclaw.<ContractName>.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
Expand Down Expand Up @@ -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.
// ============================================================
Expand Down Expand Up @@ -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;
Expand Down
Loading
Loading