Skip to content

test: EIP-712 per-key SessionGrant reference (addresses #1) - #2

Merged
Conrad-sudo merged 2 commits into
Conrad-sudo:mainfrom
Shxnque:feat/session-grant-reference-test
Sep 29, 2026
Merged

Conrad-sudo merged 2 commits into
Conrad-sudo:mainfrom
Shxnque:feat/session-grant-reference-test

Conversation

@Shxnque

@Shxnque Shxnque commented Sep 25, 2026

Copy link
Copy Markdown
Contributor

Follow-up to #1. You said an EIP-712 grant-verification sketch against the real Execution[] shape would be a concrete starting point "whenever we pick it up" — so here it is as an additive reference test, take-it-or-leave-it. It does not touch SessionHandler; it's a self-contained harness so you can lift the logic into _guardSessionExecution your own way (and keep writing the real test yourself if you'd rather — no dependency imposed).

What it demonstrates — an owner-signed, account-verified (no service in the loop) EIP-712 grant that extends the session allowlist from address-granular to (target, selector)-granular, verified from calldata against the same ERC7579Utils.decodeMode → decodeSingle/decodeBatch path _guardSessionExecution already uses:

  • SessionGrant{account, sessionKey, callsRoot, validUntil, grantNonce}, EIP-712 typed, domain-bound to the account.
  • callsRoot = a merkle root over keccak256(bytes20(target) ++ bytes4(selector)) leaves, so the grant is O(1) calldata regardless of how many calls are allowed.
  • Verified with ECDSA.recover(_hashTypedDataV4(...)) == owner(), then per-execution merkle membership, failing closed on the first ungranted (target, selector) — atomic with the batch.
  • grantNonce is owner-bumpable → instant revoke of a whole grant. usd_value is deliberately untouched (stays metered in the module's postCheck).

Verified green under your toolchain (solc 0.8.33, your pinned OZ, viaIR): forge test --match-contract SessionGrantReferenceTest → 8/8 pass:

[PASS] test_grantedSelector_passes
[PASS] test_ungrantedSelector_reverts          (SelectorNotGranted on an allowlisted target)
[PASS] test_mixedBatch_reverts_atomic          (one bad element reverts the whole op)
[PASS] test_expiry_inclusive                   (== validUntil passes, +1 GrantExpired)
[PASS] test_wrongAccount_reverts               (BadGrantDomain)
[PASS] test_staleNonce_reverts                 (GrantRevoked after owner bump)
[PASS] test_badSignature_reverts               (not signed by owner)
[PASS] test_delegatecall_forbidden             (parity with your existing guard)
Suite result: ok. 8 passed; 0 failed; 0 skipped

Not addressed here (on purpose): where the grant + proofs travel in a real execute. The two no-service options are in #1 (thread through the session-key signature envelope in _validateUserOp + an EIP-1153 transient flag, for fail-fast in the 4337 window; or carry them in the executor path and verify in the guard). That's your architectural call — this PR just proves the verification shape holds against your Execution[] decode. And as noted in #1, selector scope narrows a compromised key's reach; it does not close the §3.13 netting gap.

Happy to close this unmerged if you'd rather own the test file end-to-end — the point was to hand you a working starting point, not a dependency.

Additive reference test only — does NOT touch SessionHandler. Demonstrates an owner-signed,
account-verified (no service) EIP-712 grant that extends the session allowlist from address-granular
to (target, selector)-granular, against the same ERC7579Utils decodeMode/decodeBatch path used by
_guardSessionExecution. 8/8 pass under the repo's solc 0.8.33 toolchain.
…tLib.sol (addresses Conrad-sudo#1)

Harden PR Conrad-sudo#2 from a reference test into a reusable library implementing per-key
(target, selector) scope for session keys, verified against the real ERC-7579
Execution[]/ERC7579Utils decode path SessionHandler._guardSessionExecution uses.

- src/SessionGrantLib.sol: owner-signed EIP-712 SessionGrant (merkle root over
  (target,selector) leaves) + checkScope() decode/admission; fail-closed, atomic
  batch revert, delegatecall refused. NatSpec shows the _guardSessionExecution seam.
- test: MockGrantedAccount now consumes the library (domain/key/expiry/nonce/owner-sig
  in the account; per-call admission in the lib). 9/9 pass under solc 0.8.33 + viaIR,
  incl. single + batch pass, out-of-scope/mixed-batch/expiry/wrong-account/stale-nonce/
  bad-sig/delegatecall reverts.
@Shxnque

Shxnque commented Sep 26, 2026

Copy link
Copy Markdown
Contributor Author

Hardened this PR from a reference test into a merge-ready reference implementation, per your ask on #1 for "an EIP-712 grant-verification sketch against the real Execution[] batch shape, owner-signed, account-verified, no service."

New: src/SessionGrantLib.sol — a reusable library implementing per-key (target, selector) scope:

  • Owner-signed EIP-712 SessionGrant committing a merkle root over keccak256(bytes20(target) ++ bytes4(selector)) leaves; hashStruct combines with the account's own _hashTypedDataV4 domain, so a grant can't replay across wallets/chains.
  • checkScope(mode, executionCalldata, callsRoot, proofs) runs the same ERC7579Utils.decodeMode → SINGLE/BATCH/DELEGATECALL path _guardSessionExecution already uses; fail-closed, atomic batch revert on first out-of-scope call, delegatecall refused.
  • Pure, so it's safe in the ERC-4337 validation phase. The NatSpec spells out the exact _guardSessionExecution seam to lift it in (account keeps domain/key/expiry/nonce/owner-sig; library does per-call admission), left additive so it doesn't touch SessionHandler storage or execute's ABI until you decide how grants ride alongside the op.

Tests: the mock now consumes the library; 9/9 pass under your solc 0.8.33 + viaIR toolchain — single + batch granted pass; out-of-scope, mixed-batch (atomic), expiry-inclusive, wrong-account, stale-nonce, bad-sig, and delegatecall all revert.

Happy to go the last step and wire it into _guardSessionExecution behind an optional per-key scopeRoot if you want it merged rather than kept as reference — your call on whether grants arrive via a validator-module path or appended to executionCalldata.

@Conrad-sudo
Conrad-sudo merged commit a090a5f into Conrad-sudo:main Sep 29, 2026
1 check passed
@Conrad-sudo

Copy link
Copy Markdown
Owner

Thanks for this. Merging.

To be clear about how we'll use it: this goes in as a reference for our ERC-7715 / Smart Sessions work, not as a live feature. Nothing in SessionHandler calls SessionGrantLib, and we won't wire the grant path in as it stands. So there's no need to take it further into _guardSessionExecution. We'll do that integration ourselves as part of the ERC-7715 work, where the standard decides the grant format and how the grant travels with each op.

The part we'll carry forward: checkScope

The per-call check is the piece most likely to make it into the real implementation:

  • Same decode path as our guard. It runs ERC7579Utils.decodeMode → SINGLE / BATCH / DELEGATECALL exactly as _guardSessionExecution does. It can sit next to the existing admin-surface check without adding a second way of reading an execution.
  • Each call is checked, and a batch fails as a unit. Every (target, selector) in a batch is checked on its own. The first one outside the grant reverts the whole op, so [approve, swap, approve 0] is admitted in full or not at all.
  • The leaf is computed, not supplied. _admit builds the leaf on-chain from the decoded target and selector, and the caller only provides the proof. You can't pass in a ready-made leaf for a different call than the one actually being executed.
  • Plain value transfers aren't a loophole. Calldata shorter than 4 bytes maps to selector 0x00000000, so sending ETH has to be granted explicitly, like any other call.
  • Pure. It only reads calldata and hashes, so it works in the ERC-4337 validation phase if we end up checking scope there.
  • Delegatecall is refused outright, matching our existing guard.

One small thing we'll add when we lift it: an explicit proofs.length check against the number of executions. Today a missing proof hits an index-out-of-bounds panic. That still fails closed, but it gives no readable reason.

Thanks again for turning the issue into something concrete and tested.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants