From dd4af0cbd1bea746c1e0e1d6888a2873c8070bda Mon Sep 17 00:00:00 2001 From: Shxnque Date: Fri, 25 Sep 2026 19:26:24 +0000 Subject: [PATCH 1/2] test: EIP-712 per-key SessionGrant reference (addresses #1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- test/unit/SessionGrantReferenceTest.t.sol | 215 ++++++++++++++++++++++ 1 file changed, 215 insertions(+) create mode 100644 test/unit/SessionGrantReferenceTest.t.sol diff --git a/test/unit/SessionGrantReferenceTest.t.sol b/test/unit/SessionGrantReferenceTest.t.sol new file mode 100644 index 0000000..6f39f15 --- /dev/null +++ b/test/unit/SessionGrantReferenceTest.t.sol @@ -0,0 +1,215 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.33; + +import {Test} from "forge-std/Test.sol"; +import {Execution} from "@openzeppelin/contracts/interfaces/draft-IERC7579.sol"; +import { + ERC7579Utils, Mode, CallType, ExecType, ModeSelector, ModePayload +} from "@openzeppelin/contracts/account/utils/draft-ERC7579Utils.sol"; +import {EIP712} from "@openzeppelin/contracts/utils/cryptography/EIP712.sol"; +import {ECDSA} from "@openzeppelin/contracts/utils/cryptography/ECDSA.sol"; +import {MerkleProof} from "@openzeppelin/contracts/utils/cryptography/MerkleProof.sol"; +import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol"; + +/// @dev Owner signs ONCE off-chain; verified on-chain from calldata, no external service. +struct SessionGrant { + address account; // the wallet (also EIP-712 verifyingContract) + address sessionKey; // must equal currentSession + bytes32 callsRoot; // merkle root over allowed (target, selector) leaves + uint48 validUntil; // grant expiry + uint256 grantNonce; // owner-bumpable => instant revoke +} + +/// @notice Reference harness for sh-protocol#1 โ€” an EIP-712 per-key grant that extends the +/// account-side allowlist from address-granular to (target, selector)-granular, verified +/// from calldata with no service in the loop, against the SAME ERC-7579 decode path that +/// `SessionHandler._guardSessionExecution` uses (decodeMode -> SINGLE/BATCH/DELEGATECALL). +/// Additive: this does NOT modify SessionHandler; it demonstrates the shape so the logic can +/// be lifted into `_guardSessionExecution` (owner-driven `execute` stays unrestricted). +contract MockGrantedAccount is EIP712, Ownable { + using ERC7579Utils for *; + + error BadGrantDomain(); + error BadGrantKey(); + error GrantExpired(); + error GrantRevoked(); + error BadGrantSig(); + error SelectorNotGranted(address target, bytes4 selector); + error SessionDelegateCallForbidden(); + + bytes32 private constant _GRANT_TYPEHASH = keccak256( + "SessionGrant(address account,address sessionKey,bytes32 callsRoot,uint48 validUntil,uint256 grantNonce)" + ); + + address public currentSession; + uint256 public sessionGrantNonce; + + constructor(address owner_, address session_) EIP712("SessionHandler", "1") Ownable(owner_) { + currentSession = session_; + } + + function bumpGrantNonce() external onlyOwner { sessionGrantNonce++; } + + function grantDigest(SessionGrant calldata g) public view returns (bytes32) { + return _hashTypedDataV4(keccak256(abi.encode( + _GRANT_TYPEHASH, g.account, g.sessionKey, g.callsRoot, g.validUntil, g.grantNonce + ))); + } + + function leaf(address target, bytes4 selector) public pure returns (bytes32) { + return keccak256(bytes.concat(bytes20(target), bytes4(selector))); + } + + function _sel(bytes calldata cd) private pure returns (bytes4) { + return cd.length >= 4 ? bytes4(cd[:4]) : bytes4(0); + } + + function _verify(SessionGrant calldata g, address target, bytes4 selector, bytes32[] calldata proof) + private pure + { + if (!MerkleProof.verifyCalldata(proof, g.callsRoot, keccak256(bytes.concat(bytes20(target), bytes4(selector))))) + revert SelectorNotGranted(target, selector); + } + + /// @dev Same signature shape as `_guardSessionExecution`, plus the owner-signed grant + merkle proofs. + function guardGrantedExecution( + Mode mode, + bytes calldata executionCalldata, + SessionGrant calldata g, + bytes calldata ownerSig, + bytes32[][] calldata proofs + ) external view { + if (g.account != address(this)) revert BadGrantDomain(); + if (g.sessionKey != currentSession) revert BadGrantKey(); + if (block.timestamp > g.validUntil) revert GrantExpired(); + if (g.grantNonce != sessionGrantNonce) revert GrantRevoked(); + if (ECDSA.recover(grantDigest(g), ownerSig) != owner()) revert BadGrantSig(); + + (CallType callType,,,) = ERC7579Utils.decodeMode(mode); + if (callType == ERC7579Utils.CALLTYPE_SINGLE) { + (address target,, bytes calldata cd) = ERC7579Utils.decodeSingle(executionCalldata); + _verify(g, target, _sel(cd), proofs[0]); + } else if (callType == ERC7579Utils.CALLTYPE_BATCH) { + Execution[] calldata batch = ERC7579Utils.decodeBatch(executionCalldata); + for (uint256 i; i < batch.length; ++i) { + _verify(g, batch[i].target, _sel(batch[i].callData), proofs[i]); + } + } else if (callType == ERC7579Utils.CALLTYPE_DELEGATECALL) { + revert SessionDelegateCallForbidden(); + } + } +} + +contract SessionGrantReferenceTest is Test { + MockGrantedAccount acct; + uint256 ownerPk = 0xA11CE; + address owner; + address session = address(0x5E5510); + address target = address(0xDEF1); + + bytes4 constant SEL_A = 0x11111111; + bytes4 constant SEL_B = 0x22222222; + bytes4 constant SEL_C = 0x33333333; // NOT granted + + bytes32 leafA; + bytes32 leafB; + bytes32 root; + + function setUp() public { + owner = vm.addr(ownerPk); + acct = new MockGrantedAccount(owner, session); + leafA = acct.leaf(target, SEL_A); + leafB = acct.leaf(target, SEL_B); + root = _commutative(leafA, leafB); + } + + function _commutative(bytes32 a, bytes32 b) internal pure returns (bytes32) { + return a < b ? keccak256(abi.encodePacked(a, b)) : keccak256(abi.encodePacked(b, a)); + } + function _grant(address account, uint48 validUntil, uint256 nonce) internal view returns (SessionGrant memory) { + return SessionGrant({account: account, sessionKey: session, callsRoot: root, validUntil: validUntil, grantNonce: nonce}); + } + function _sign(uint256 pk, SessionGrant memory g) internal view returns (bytes memory) { + (uint8 v, bytes32 r, bytes32 s) = vm.sign(pk, acct.grantDigest(g)); + return abi.encodePacked(r, s, v); + } + function _batchMode() internal pure returns (Mode) { + return ERC7579Utils.encodeMode(ERC7579Utils.CALLTYPE_BATCH, ERC7579Utils.EXECTYPE_DEFAULT, ModeSelector.wrap(bytes4(0)), ModePayload.wrap(bytes22(0))); + } + function _execs(bytes4 sel) internal view returns (bytes memory) { + Execution[] memory e = new Execution[](1); + e[0] = Execution({target: target, value: 0, callData: abi.encodePacked(sel, uint256(1))}); + return ERC7579Utils.encodeBatch(e); + } + function _proof1(bytes32 sibling) internal pure returns (bytes32[][] memory p) { + p = new bytes32[][](1); p[0] = new bytes32[](1); p[0][0] = sibling; + } + + function test_grantedSelector_passes() public view { + SessionGrant memory g = _grant(address(acct), uint48(block.timestamp + 1 days), 0); + acct.guardGrantedExecution(_batchMode(), _execs(SEL_A), g, _sign(ownerPk, g), _proof1(leafB)); + } + + function test_ungrantedSelector_reverts() public { + SessionGrant memory g = _grant(address(acct), uint48(block.timestamp + 1 days), 0); + Mode m = _batchMode(); bytes memory ec = _execs(SEL_C); bytes memory sig = _sign(ownerPk, g); bytes32[][] memory pr = _proof1(leafB); + vm.expectRevert(abi.encodeWithSelector(MockGrantedAccount.SelectorNotGranted.selector, target, SEL_C)); + acct.guardGrantedExecution(m, ec, g, sig, pr); + } + + function test_mixedBatch_reverts_atomic() public { + SessionGrant memory g = _grant(address(acct), uint48(block.timestamp + 1 days), 0); + Execution[] memory e = new Execution[](2); + e[0] = Execution({target: target, value: 0, callData: abi.encodePacked(SEL_A, uint256(1))}); + e[1] = Execution({target: target, value: 0, callData: abi.encodePacked(SEL_C, uint256(1))}); + bytes memory ec = ERC7579Utils.encodeBatch(e); + bytes memory sig = _sign(ownerPk, g); + bytes32[][] memory pr = new bytes32[][](2); + pr[0] = new bytes32[](1); pr[0][0] = leafB; + pr[1] = new bytes32[](1); pr[1][0] = leafB; + Mode m = _batchMode(); + vm.expectRevert(abi.encodeWithSelector(MockGrantedAccount.SelectorNotGranted.selector, target, SEL_C)); + acct.guardGrantedExecution(m, ec, g, sig, pr); + } + + function test_expiry_inclusive() public { + uint48 exp = uint48(block.timestamp + 100); + SessionGrant memory g = _grant(address(acct), exp, 0); + Mode m = _batchMode(); bytes memory ec = _execs(SEL_A); bytes memory sig = _sign(ownerPk, g); bytes32[][] memory pr = _proof1(leafB); + vm.warp(exp); + acct.guardGrantedExecution(m, ec, g, sig, pr); // == validUntil: passes + vm.warp(uint256(exp) + 1); + vm.expectRevert(MockGrantedAccount.GrantExpired.selector); + acct.guardGrantedExecution(m, ec, g, sig, pr); + } + + function test_wrongAccount_reverts() public { + SessionGrant memory g = _grant(address(0xBEEF), uint48(block.timestamp + 1 days), 0); + Mode m = _batchMode(); bytes memory ec = _execs(SEL_A); bytes memory sig = _sign(ownerPk, g); bytes32[][] memory pr = _proof1(leafB); + vm.expectRevert(MockGrantedAccount.BadGrantDomain.selector); + acct.guardGrantedExecution(m, ec, g, sig, pr); + } + + function test_staleNonce_reverts() public { + SessionGrant memory g = _grant(address(acct), uint48(block.timestamp + 1 days), 0); + Mode m = _batchMode(); bytes memory ec = _execs(SEL_A); bytes memory sig = _sign(ownerPk, g); bytes32[][] memory pr = _proof1(leafB); + vm.prank(owner); acct.bumpGrantNonce(); + vm.expectRevert(MockGrantedAccount.GrantRevoked.selector); + acct.guardGrantedExecution(m, ec, g, sig, pr); + } + + function test_badSignature_reverts() public { + SessionGrant memory g = _grant(address(acct), uint48(block.timestamp + 1 days), 0); + Mode m = _batchMode(); bytes memory ec = _execs(SEL_A); bytes memory badSig = _sign(0xB0B, g); bytes32[][] memory pr = _proof1(leafB); + vm.expectRevert(MockGrantedAccount.BadGrantSig.selector); + acct.guardGrantedExecution(m, ec, g, badSig, pr); + } + + function test_delegatecall_forbidden() public { + SessionGrant memory g = _grant(address(acct), uint48(block.timestamp + 1 days), 0); + Mode m = ERC7579Utils.encodeMode(ERC7579Utils.CALLTYPE_DELEGATECALL, ERC7579Utils.EXECTYPE_DEFAULT, ModeSelector.wrap(bytes4(0)), ModePayload.wrap(bytes22(0))); + bytes memory sig = _sign(ownerPk, g); bytes32[][] memory pr = _proof1(leafB); + vm.expectRevert(MockGrantedAccount.SessionDelegateCallForbidden.selector); + acct.guardGrantedExecution(m, hex"", g, sig, pr); + } +} From f2a341ac6faf5935a4e8abeaee6ae1f29c58a93b Mon Sep 17 00:00:00 2001 From: Shxnque Date: Sat, 26 Sep 2026 08:55:15 +0000 Subject: [PATCH 2/2] feat: extract SessionGrant reference into merge-ready src/SessionGrantLib.sol (addresses #1) Harden PR #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. --- src/SessionGrantLib.sol | 114 +++++++++++++++ test/unit/SessionGrantReferenceTest.t.sol | 164 ++++++++++++---------- 2 files changed, 203 insertions(+), 75 deletions(-) create mode 100644 src/SessionGrantLib.sol diff --git a/src/SessionGrantLib.sol b/src/SessionGrantLib.sol new file mode 100644 index 0000000..8ea904a --- /dev/null +++ b/src/SessionGrantLib.sol @@ -0,0 +1,114 @@ +// SPDX-License-Identifier: BUSL-1.1 +// Copyright (C) 2026 Conrad Japhet +// Use of this software is governed by the Business Source License included in the LICENSE file. +// Change Date: 2029-06-12. Change License: MIT. +pragma solidity ^0.8.24; + +import {Execution} from "@openzeppelin/contracts/interfaces/draft-IERC7579.sol"; +import {ERC7579Utils, Mode, CallType} from "@openzeppelin/contracts/account/utils/draft-ERC7579Utils.sol"; +import {MerkleProof} from "@openzeppelin/contracts/utils/cryptography/MerkleProof.sol"; + +/// @notice An owner-signed, per-key authorization. The owner signs it ONCE off-chain; it is verified +/// on-chain from calldata with no service in the loop. It extends a session key from a bare +/// signer (bounded only by the USD cap, {SessionHandler-_requireUnrestrictedTarget}, and +/// expiry) to one admissible only for an explicit set of (target, selector) pairs. +struct SessionGrant { + address account; // the wallet; also the EIP-712 verifyingContract + address sessionKey; // must equal SessionHandler.currentSession + bytes32 callsRoot; // merkle root over allowed (target, selector) leaves + uint48 validUntil; // grant expiry (inclusive, matching the key's own <= semantics) + uint256 grantNonce; // owner-bumpable => instant revoke of every prior grant +} + +/** + * @title SessionGrantLib + * @notice Reference implementation for sh-protocol#1: per-key (target, selector) scope for session + * keys, closing the gap the USD cap structurally cannot see (a key may only *value*-spend + * under the cap, but today may call ANY selector on any non-restricted target -- THREAT_MODEL + * ยง3.13). The admission decision is made a *validate-time*, per-call, recomputable check: + * given the same grant and calldata, any party recomputes the identical PASS/REVERT. + * + * @dev This is additive and non-invasive: it does NOT change {SessionHandler} storage or the ABI of + * {execute}. It is written against the SAME decode path {SessionHandler-_guardSessionExecution} + * already uses ({ERC7579Utils-decodeMode} -> SINGLE / BATCH / DELEGATECALL), so it can be lifted + * in directly. The intended wiring, once the owner-signed grant + proofs ride alongside the op: + * + * ```solidity + * // inside SessionHandler, for a non-owner (session-key) execution: + * // 1. account-level checks (domain, key == currentSession, expiry, nonce, owner EIP-712 sig) + * // stay in the account, which already is an EIP712 + Ownable context; then: + * SessionGrantLib.checkScope(execMode, executionCalldata, grant.callsRoot, proofs); + * // existing address-granular guard remains as defence-in-depth: + * // _guardSessionExecution(execMode, executionCalldata); + * ``` + * + * @dev Why merkle, not an on-chain mapping: an owner can authorize an arbitrarily large scope with a + * single off-chain signature (one 65-byte sig + O(log n) proof per call), rather than one owner + * transaction per (target, selector) as {SessionHandler-addAllowedTargets} requires for the + * address-granular list. Leaves are `keccak256(bytes20(target) ++ bytes4(selector))`; the root + * is committed in the signed {SessionGrant}. No JCS / no sha256 -- everything is keccak/`abi` + * native to the verifier, so it stays cheap. + * @dev Fail-closed: a call whose (target, selector) is not provable against `callsRoot` reverts; a + * batch reverts atomically on the first out-of-scope call; delegatecall is refused outright, + * exactly as {SessionHandler-_guardSessionExecution} refuses it (delegated code runs in the + * account's context and could reach the admin surface regardless of the encoded target). + * @author Contributed by Shxnque (Quelum Wilson) against sh-protocol#1. + */ +library SessionGrantLib { + /// @dev A sub-call's (target, selector) is not provable against the grant's committed root. + error SelectorNotGranted(address target, bytes4 selector); + /// @dev Delegatecall is never in scope for a session key (mirrors {SessionHandler}). + error SessionDelegateCallForbidden(); + + /// @dev EIP-712 struct type hash for {SessionGrant}. The account combines this with its own + /// domain separator ({EIP712-_hashTypedDataV4}) to bind the grant to (name, version, + /// chainid, verifyingContract) -- so a grant cannot be replayed across wallets or chains. + bytes32 internal constant GRANT_TYPEHASH = + keccak256("SessionGrant(address account,address sessionKey,bytes32 callsRoot,uint48 validUntil,uint256 grantNonce)"); + + /// @notice The EIP-712 struct hash (inner hash) of a grant. Feed to {EIP712-_hashTypedDataV4}. + function hashStruct(SessionGrant calldata g) internal pure returns (bytes32) { + return keccak256(abi.encode(GRANT_TYPEHASH, g.account, g.sessionKey, g.callsRoot, g.validUntil, g.grantNonce)); + } + + /// @notice The merkle leaf for an admissible (target, selector) pair. + function leaf(address target, bytes4 selector) internal pure returns (bytes32) { + return keccak256(bytes.concat(bytes20(target), bytes4(selector))); + } + + /// @notice The 4-byte selector of an ERC-7579 sub-call's calldata, or 0x00000000 if it carries + /// fewer than 4 bytes (a bare value transfer). A value transfer must be committed as the + /// zero selector to be admissible, so it is never silently allowed. + function selectorOf(bytes calldata cd) internal pure returns (bytes4) { + return cd.length >= 4 ? bytes4(cd[:4]) : bytes4(0); + } + + /// @notice Reverts unless every sub-call in `executionCalldata` is provable against `callsRoot`. + /// @dev Same decode path as {SessionHandler-_guardSessionExecution}; `proofs[i]` is the merkle + /// proof for sub-call `i` (for SINGLE, `proofs[0]`). Pure: decode + merkle verification are + /// all calldata/hash operations, so this is safe to call during ERC-4337 validation. + function checkScope(Mode mode, bytes calldata executionCalldata, bytes32 callsRoot, bytes32[][] calldata proofs) + internal + pure + { + (CallType callType,,,) = ERC7579Utils.decodeMode(mode); + if (callType == ERC7579Utils.CALLTYPE_SINGLE) { + (address target,, bytes calldata cd) = ERC7579Utils.decodeSingle(executionCalldata); + _admit(callsRoot, target, selectorOf(cd), proofs[0]); + } else if (callType == ERC7579Utils.CALLTYPE_BATCH) { + Execution[] calldata batch = ERC7579Utils.decodeBatch(executionCalldata); + for (uint256 i; i < batch.length; ++i) { + _admit(callsRoot, batch[i].target, selectorOf(batch[i].callData), proofs[i]); + } + } else if (callType == ERC7579Utils.CALLTYPE_DELEGATECALL) { + revert SessionDelegateCallForbidden(); + } + } + + function _admit(bytes32 root, address target, bytes4 selector, bytes32[] calldata proof) private pure { + if (!MerkleProof.verifyCalldata(proof, root, leaf(target, selector))) { + // forge-lint: disable-next-line(require-revert-in-loop) + revert SelectorNotGranted(target, selector); + } + } +} diff --git a/test/unit/SessionGrantReferenceTest.t.sol b/test/unit/SessionGrantReferenceTest.t.sol index 6f39f15..34fcaac 100644 --- a/test/unit/SessionGrantReferenceTest.t.sol +++ b/test/unit/SessionGrantReferenceTest.t.sol @@ -8,38 +8,21 @@ import { } from "@openzeppelin/contracts/account/utils/draft-ERC7579Utils.sol"; import {EIP712} from "@openzeppelin/contracts/utils/cryptography/EIP712.sol"; import {ECDSA} from "@openzeppelin/contracts/utils/cryptography/ECDSA.sol"; -import {MerkleProof} from "@openzeppelin/contracts/utils/cryptography/MerkleProof.sol"; import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol"; - -/// @dev Owner signs ONCE off-chain; verified on-chain from calldata, no external service. -struct SessionGrant { - address account; // the wallet (also EIP-712 verifyingContract) - address sessionKey; // must equal currentSession - bytes32 callsRoot; // merkle root over allowed (target, selector) leaves - uint48 validUntil; // grant expiry - uint256 grantNonce; // owner-bumpable => instant revoke -} - -/// @notice Reference harness for sh-protocol#1 โ€” an EIP-712 per-key grant that extends the -/// account-side allowlist from address-granular to (target, selector)-granular, verified -/// from calldata with no service in the loop, against the SAME ERC-7579 decode path that -/// `SessionHandler._guardSessionExecution` uses (decodeMode -> SINGLE/BATCH/DELEGATECALL). -/// Additive: this does NOT modify SessionHandler; it demonstrates the shape so the logic can -/// be lifted into `_guardSessionExecution` (owner-driven `execute` stays unrestricted). +import {SessionGrant, SessionGrantLib} from "../../src/SessionGrantLib.sol"; + +/// @notice Reference account wiring for sh-protocol#1. Demonstrates how {SessionGrantLib} is lifted +/// into a `_guardSessionExecution`-shaped path: the account keeps the stateful, domain-bound +/// checks (EIP-712 domain, key == currentSession, expiry, owner-bumpable nonce, owner sig), +/// then delegates the per-call (target, selector) admission to the library. Additive: this +/// does NOT modify SessionHandler; it proves the mechanism against the SAME ERC-7579 decode +/// path SessionHandler uses. Owner signs ONCE off-chain; verified on-chain from calldata. contract MockGrantedAccount is EIP712, Ownable { - using ERC7579Utils for *; - error BadGrantDomain(); error BadGrantKey(); error GrantExpired(); error GrantRevoked(); error BadGrantSig(); - error SelectorNotGranted(address target, bytes4 selector); - error SessionDelegateCallForbidden(); - - bytes32 private constant _GRANT_TYPEHASH = keccak256( - "SessionGrant(address account,address sessionKey,bytes32 callsRoot,uint48 validUntil,uint256 grantNonce)" - ); address public currentSession; uint256 public sessionGrantNonce; @@ -48,30 +31,21 @@ contract MockGrantedAccount is EIP712, Ownable { currentSession = session_; } - function bumpGrantNonce() external onlyOwner { sessionGrantNonce++; } + function bumpGrantNonce() external onlyOwner { + sessionGrantNonce++; + } + /// @dev Account combines the library's struct hash with ITS OWN EIP-712 domain separator, binding + /// the grant to (name, version, chainid, verifyingContract). function grantDigest(SessionGrant calldata g) public view returns (bytes32) { - return _hashTypedDataV4(keccak256(abi.encode( - _GRANT_TYPEHASH, g.account, g.sessionKey, g.callsRoot, g.validUntil, g.grantNonce - ))); + return _hashTypedDataV4(SessionGrantLib.hashStruct(g)); } function leaf(address target, bytes4 selector) public pure returns (bytes32) { - return keccak256(bytes.concat(bytes20(target), bytes4(selector))); - } - - function _sel(bytes calldata cd) private pure returns (bytes4) { - return cd.length >= 4 ? bytes4(cd[:4]) : bytes4(0); + return SessionGrantLib.leaf(target, selector); } - function _verify(SessionGrant calldata g, address target, bytes4 selector, bytes32[] calldata proof) - private pure - { - if (!MerkleProof.verifyCalldata(proof, g.callsRoot, keccak256(bytes.concat(bytes20(target), bytes4(selector))))) - revert SelectorNotGranted(target, selector); - } - - /// @dev Same signature shape as `_guardSessionExecution`, plus the owner-signed grant + merkle proofs. + /// @dev Same shape as `SessionHandler._guardSessionExecution`, plus the owner-signed grant + proofs. function guardGrantedExecution( Mode mode, bytes calldata executionCalldata, @@ -79,24 +53,15 @@ contract MockGrantedAccount is EIP712, Ownable { bytes calldata ownerSig, bytes32[][] calldata proofs ) external view { - if (g.account != address(this)) revert BadGrantDomain(); - if (g.sessionKey != currentSession) revert BadGrantKey(); - if (block.timestamp > g.validUntil) revert GrantExpired(); - if (g.grantNonce != sessionGrantNonce) revert GrantRevoked(); + // Account-level, domain-bound checks (stay in the account, which is the EIP712 + Ownable ctx). + if (g.account != address(this)) revert BadGrantDomain(); + if (g.sessionKey != currentSession) revert BadGrantKey(); + if (block.timestamp > g.validUntil) revert GrantExpired(); + if (g.grantNonce != sessionGrantNonce) revert GrantRevoked(); if (ECDSA.recover(grantDigest(g), ownerSig) != owner()) revert BadGrantSig(); - (CallType callType,,,) = ERC7579Utils.decodeMode(mode); - if (callType == ERC7579Utils.CALLTYPE_SINGLE) { - (address target,, bytes calldata cd) = ERC7579Utils.decodeSingle(executionCalldata); - _verify(g, target, _sel(cd), proofs[0]); - } else if (callType == ERC7579Utils.CALLTYPE_BATCH) { - Execution[] calldata batch = ERC7579Utils.decodeBatch(executionCalldata); - for (uint256 i; i < batch.length; ++i) { - _verify(g, batch[i].target, _sel(batch[i].callData), proofs[i]); - } - } else if (callType == ERC7579Utils.CALLTYPE_DELEGATECALL) { - revert SessionDelegateCallForbidden(); - } + // Per-call (target, selector) admission -> the reusable, recomputable library check. + SessionGrantLib.checkScope(mode, executionCalldata, g.callsRoot, proofs); } } @@ -126,34 +91,65 @@ contract SessionGrantReferenceTest is Test { function _commutative(bytes32 a, bytes32 b) internal pure returns (bytes32) { return a < b ? keccak256(abi.encodePacked(a, b)) : keccak256(abi.encodePacked(b, a)); } + function _grant(address account, uint48 validUntil, uint256 nonce) internal view returns (SessionGrant memory) { - return SessionGrant({account: account, sessionKey: session, callsRoot: root, validUntil: validUntil, grantNonce: nonce}); + return SessionGrant({ + account: account, + sessionKey: session, + callsRoot: root, + validUntil: validUntil, + grantNonce: nonce + }); } + function _sign(uint256 pk, SessionGrant memory g) internal view returns (bytes memory) { (uint8 v, bytes32 r, bytes32 s) = vm.sign(pk, acct.grantDigest(g)); return abi.encodePacked(r, s, v); } + function _batchMode() internal pure returns (Mode) { - return ERC7579Utils.encodeMode(ERC7579Utils.CALLTYPE_BATCH, ERC7579Utils.EXECTYPE_DEFAULT, ModeSelector.wrap(bytes4(0)), ModePayload.wrap(bytes22(0))); + return ERC7579Utils.encodeMode( + ERC7579Utils.CALLTYPE_BATCH, ERC7579Utils.EXECTYPE_DEFAULT, ModeSelector.wrap(bytes4(0)), ModePayload.wrap(bytes22(0)) + ); + } + + function _singleMode() internal pure returns (Mode) { + return ERC7579Utils.encodeMode( + ERC7579Utils.CALLTYPE_SINGLE, ERC7579Utils.EXECTYPE_DEFAULT, ModeSelector.wrap(bytes4(0)), ModePayload.wrap(bytes22(0)) + ); } + function _execs(bytes4 sel) internal view returns (bytes memory) { Execution[] memory e = new Execution[](1); e[0] = Execution({target: target, value: 0, callData: abi.encodePacked(sel, uint256(1))}); return ERC7579Utils.encodeBatch(e); } + function _proof1(bytes32 sibling) internal pure returns (bytes32[][] memory p) { - p = new bytes32[][](1); p[0] = new bytes32[](1); p[0][0] = sibling; + p = new bytes32[][](1); + p[0] = new bytes32[](1); + p[0][0] = sibling; } - function test_grantedSelector_passes() public view { + function test_grantedSelector_batch_passes() public view { SessionGrant memory g = _grant(address(acct), uint48(block.timestamp + 1 days), 0); acct.guardGrantedExecution(_batchMode(), _execs(SEL_A), g, _sign(ownerPk, g), _proof1(leafB)); } + function test_grantedSelector_single_passes() public view { + SessionGrant memory g = _grant(address(acct), uint48(block.timestamp + 1 days), 0); + // ERC-7579 SINGLE execution encoding is abi.encodePacked(target, value, callData). + bytes memory ec = abi.encodePacked(target, uint256(0), SEL_A, uint256(1)); + acct.guardGrantedExecution(_singleMode(), ec, g, _sign(ownerPk, g), _proof1(leafB)); + } + function test_ungrantedSelector_reverts() public { SessionGrant memory g = _grant(address(acct), uint48(block.timestamp + 1 days), 0); - Mode m = _batchMode(); bytes memory ec = _execs(SEL_C); bytes memory sig = _sign(ownerPk, g); bytes32[][] memory pr = _proof1(leafB); - vm.expectRevert(abi.encodeWithSelector(MockGrantedAccount.SelectorNotGranted.selector, target, SEL_C)); + Mode m = _batchMode(); + bytes memory ec = _execs(SEL_C); + bytes memory sig = _sign(ownerPk, g); + bytes32[][] memory pr = _proof1(leafB); + vm.expectRevert(abi.encodeWithSelector(SessionGrantLib.SelectorNotGranted.selector, target, SEL_C)); acct.guardGrantedExecution(m, ec, g, sig, pr); } @@ -165,17 +161,22 @@ contract SessionGrantReferenceTest is Test { bytes memory ec = ERC7579Utils.encodeBatch(e); bytes memory sig = _sign(ownerPk, g); bytes32[][] memory pr = new bytes32[][](2); - pr[0] = new bytes32[](1); pr[0][0] = leafB; - pr[1] = new bytes32[](1); pr[1][0] = leafB; + pr[0] = new bytes32[](1); + pr[0][0] = leafB; + pr[1] = new bytes32[](1); + pr[1][0] = leafB; Mode m = _batchMode(); - vm.expectRevert(abi.encodeWithSelector(MockGrantedAccount.SelectorNotGranted.selector, target, SEL_C)); + vm.expectRevert(abi.encodeWithSelector(SessionGrantLib.SelectorNotGranted.selector, target, SEL_C)); acct.guardGrantedExecution(m, ec, g, sig, pr); } function test_expiry_inclusive() public { uint48 exp = uint48(block.timestamp + 100); SessionGrant memory g = _grant(address(acct), exp, 0); - Mode m = _batchMode(); bytes memory ec = _execs(SEL_A); bytes memory sig = _sign(ownerPk, g); bytes32[][] memory pr = _proof1(leafB); + Mode m = _batchMode(); + bytes memory ec = _execs(SEL_A); + bytes memory sig = _sign(ownerPk, g); + bytes32[][] memory pr = _proof1(leafB); vm.warp(exp); acct.guardGrantedExecution(m, ec, g, sig, pr); // == validUntil: passes vm.warp(uint256(exp) + 1); @@ -185,31 +186,44 @@ contract SessionGrantReferenceTest is Test { function test_wrongAccount_reverts() public { SessionGrant memory g = _grant(address(0xBEEF), uint48(block.timestamp + 1 days), 0); - Mode m = _batchMode(); bytes memory ec = _execs(SEL_A); bytes memory sig = _sign(ownerPk, g); bytes32[][] memory pr = _proof1(leafB); + Mode m = _batchMode(); + bytes memory ec = _execs(SEL_A); + bytes memory sig = _sign(ownerPk, g); + bytes32[][] memory pr = _proof1(leafB); vm.expectRevert(MockGrantedAccount.BadGrantDomain.selector); acct.guardGrantedExecution(m, ec, g, sig, pr); } function test_staleNonce_reverts() public { SessionGrant memory g = _grant(address(acct), uint48(block.timestamp + 1 days), 0); - Mode m = _batchMode(); bytes memory ec = _execs(SEL_A); bytes memory sig = _sign(ownerPk, g); bytes32[][] memory pr = _proof1(leafB); - vm.prank(owner); acct.bumpGrantNonce(); + Mode m = _batchMode(); + bytes memory ec = _execs(SEL_A); + bytes memory sig = _sign(ownerPk, g); + bytes32[][] memory pr = _proof1(leafB); + vm.prank(owner); + acct.bumpGrantNonce(); vm.expectRevert(MockGrantedAccount.GrantRevoked.selector); acct.guardGrantedExecution(m, ec, g, sig, pr); } function test_badSignature_reverts() public { SessionGrant memory g = _grant(address(acct), uint48(block.timestamp + 1 days), 0); - Mode m = _batchMode(); bytes memory ec = _execs(SEL_A); bytes memory badSig = _sign(0xB0B, g); bytes32[][] memory pr = _proof1(leafB); + Mode m = _batchMode(); + bytes memory ec = _execs(SEL_A); + bytes memory badSig = _sign(0xB0B, g); + bytes32[][] memory pr = _proof1(leafB); vm.expectRevert(MockGrantedAccount.BadGrantSig.selector); acct.guardGrantedExecution(m, ec, g, badSig, pr); } function test_delegatecall_forbidden() public { SessionGrant memory g = _grant(address(acct), uint48(block.timestamp + 1 days), 0); - Mode m = ERC7579Utils.encodeMode(ERC7579Utils.CALLTYPE_DELEGATECALL, ERC7579Utils.EXECTYPE_DEFAULT, ModeSelector.wrap(bytes4(0)), ModePayload.wrap(bytes22(0))); - bytes memory sig = _sign(ownerPk, g); bytes32[][] memory pr = _proof1(leafB); - vm.expectRevert(MockGrantedAccount.SessionDelegateCallForbidden.selector); + Mode m = ERC7579Utils.encodeMode( + ERC7579Utils.CALLTYPE_DELEGATECALL, ERC7579Utils.EXECTYPE_DEFAULT, ModeSelector.wrap(bytes4(0)), ModePayload.wrap(bytes22(0)) + ); + bytes memory sig = _sign(ownerPk, g); + bytes32[][] memory pr = _proof1(leafB); + vm.expectRevert(SessionGrantLib.SessionDelegateCallForbidden.selector); acct.guardGrantedExecution(m, hex"", g, sig, pr); } }