Skip to content
Merged
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
114 changes: 114 additions & 0 deletions src/SessionGrantLib.sol
Original file line number Diff line number Diff line change
@@ -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);
}
}
}
229 changes: 229 additions & 0 deletions test/unit/SessionGrantReferenceTest.t.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,229 @@
// 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 {Ownable} from "@openzeppelin/contracts/access/Ownable.sol";
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 {
error BadGrantDomain();
error BadGrantKey();
error GrantExpired();
error GrantRevoked();
error BadGrantSig();

address public currentSession;
uint256 public sessionGrantNonce;

constructor(address owner_, address session_) EIP712("SessionHandler", "1") Ownable(owner_) {
currentSession = session_;
}

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(SessionGrantLib.hashStruct(g));
}

function leaf(address target, bytes4 selector) public pure returns (bytes32) {
return SessionGrantLib.leaf(target, selector);
}

/// @dev Same shape as `SessionHandler._guardSessionExecution`, plus the owner-signed grant + proofs.
function guardGrantedExecution(
Mode mode,
bytes calldata executionCalldata,
SessionGrant calldata g,
bytes calldata ownerSig,
bytes32[][] calldata proofs
) external view {
// 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();

// Per-call (target, selector) admission -> the reusable, recomputable library check.
SessionGrantLib.checkScope(mode, executionCalldata, g.callsRoot, proofs);
}
}

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 _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;
}

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(SessionGrantLib.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(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);
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(SessionGrantLib.SessionDelegateCallForbidden.selector);
acct.guardGrantedExecution(m, hex"", g, sig, pr);
}
}
Loading