From b20a18f3f08c608e72ab037eec583049ee134933 Mon Sep 17 00:00:00 2001 From: TerriClaw Date: Fri, 10 Apr 2026 16:51:36 -0700 Subject: [PATCH] =?UTF-8?q?feat:=20add=20ExecutionBoundEnforcer=20?= =?UTF-8?q?=E2=80=94=20exact=20execution=20commitment=20at=20redemption?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- script/DeployCaveatEnforcers.s.sol | 4 + src/enforcers/ExecutionBoundEnforcer.sol | 179 ++++++++++++++ test/enforcers/ExecutionBoundEnforcer.t.sol | 251 ++++++++++++++++++++ 3 files changed, 434 insertions(+) create mode 100644 src/enforcers/ExecutionBoundEnforcer.sol create mode 100644 test/enforcers/ExecutionBoundEnforcer.t.sol diff --git a/script/DeployCaveatEnforcers.s.sol b/script/DeployCaveatEnforcers.s.sol index cff28621..6f7408a5 100644 --- a/script/DeployCaveatEnforcers.s.sol +++ b/script/DeployCaveatEnforcers.s.sol @@ -23,6 +23,7 @@ import { ExactCalldataBatchEnforcer } from "../src/enforcers/ExactCalldataBatchE import { ExactCalldataEnforcer } from "../src/enforcers/ExactCalldataEnforcer.sol"; import { ExactExecutionBatchEnforcer } from "../src/enforcers/ExactExecutionBatchEnforcer.sol"; import { ExactExecutionEnforcer } from "../src/enforcers/ExactExecutionEnforcer.sol"; +import { ExecutionBoundEnforcer } from "../src/enforcers/ExecutionBoundEnforcer.sol"; import { IdEnforcer } from "../src/enforcers/IdEnforcer.sol"; import { LimitedCallsEnforcer } from "../src/enforcers/LimitedCallsEnforcer.sol"; import { LogicalOrWrapperEnforcer } from "../src/enforcers/LogicalOrWrapperEnforcer.sol"; @@ -122,6 +123,9 @@ contract DeployCaveatEnforcers is Script { deployedAddress = address(new ExactExecutionEnforcer{ salt: salt }()); console2.log("ExactExecutionEnforcer: %s", deployedAddress); + deployedAddress = address(new ExecutionBoundEnforcer{ salt: salt }()); + console2.log("ExecutionBoundEnforcer: %s", deployedAddress); + deployedAddress = address(new IdEnforcer{ salt: salt }()); console2.log("IdEnforcer: %s", deployedAddress); diff --git a/src/enforcers/ExecutionBoundEnforcer.sol b/src/enforcers/ExecutionBoundEnforcer.sol new file mode 100644 index 00000000..62e005a6 --- /dev/null +++ b/src/enforcers/ExecutionBoundEnforcer.sol @@ -0,0 +1,179 @@ +// SPDX-License-Identifier: MIT AND Apache-2.0 +pragma solidity 0.8.23; + +import { ExecutionLib } from "@erc7579/lib/ExecutionLib.sol"; +import { ModeLib } from "@erc7579/lib/ModeLib.sol"; +import { SignatureChecker } from "@openzeppelin/contracts/utils/cryptography/SignatureChecker.sol"; +import { EIP712 } from "@openzeppelin/contracts/utils/cryptography/EIP712.sol"; + +import { CaveatEnforcer } from "./CaveatEnforcer.sol"; +import { ModeCode } from "../utils/Types.sol"; + +/** + * @title ExecutionBoundEnforcer + * @notice Enforces that the actual execution at redemption exactly matches a pre-signed ExecutionIntent. + * @dev Unlike ExactExecutionEnforcer (which encodes the expected execution statically in terms at + * delegation time), this enforcer binds execution dynamically at redemption time via a second + * EIP-712 signature. + * + * The delegator signs the delegation (who may redeem) and commits to an authorized signer in terms. + * The authorized signer signs the ExecutionIntent (what must be executed). + * These may be different keys, enabling session keys, agents, and co-signers. + * + * terms: abi.encode(address authorizedSigner) + * args: abi.encode(ExecutionIntent intent, bytes signature) + * + * The nonce is scoped by (delegationManager, account, nonce) and consumed only after successful + * signature verification, preventing griefing via invalid signature nonce consumption. + * Scoping by msg.sender (the delegation manager) prevents direct beforeHook calls from + * consuming nonces outside of a legitimate redemption flow. + * + * @dev This enforcer operates only in single execution call type and with default execution mode. + */ +contract ExecutionBoundEnforcer is CaveatEnforcer, EIP712 { + using ExecutionLib for bytes; + using ModeLib for ModeCode; + + ////////////////////////////// Structs ////////////////////////////// + + struct ExecutionIntent { + address account; + address target; + uint256 value; + bytes32 dataHash; + uint256 nonce; + uint256 deadline; + } + + ////////////////////////////// State ////////////////////////////// + + bytes32 private constant EXECUTION_INTENT_TYPEHASH = keccak256( + "ExecutionIntent(address account,address target,uint256 value,bytes32 dataHash,uint256 nonce,uint256 deadline)" + ); + + mapping(address delegationManager => mapping(address account => mapping(uint256 nonce => bool))) public usedNonces; + + ////////////////////////////// Events ////////////////////////////// + + event NonceConsumed(address indexed delegationManager, address indexed account, uint256 nonce); + + ////////////////////////////// Errors ////////////////////////////// + + error AccountMismatch(address intentAccount, address delegator); + error TargetMismatch(address intentTarget, address executionTarget); + error ValueMismatch(uint256 intentValue, uint256 executionValue); + error DataHashMismatch(bytes32 intentDataHash, bytes32 executionDataHash); + error IntentExpired(uint256 deadline, uint256 blockTimestamp); + error NonceAlreadyUsed(address delegationManager, address account, uint256 nonce); + error InvalidSignature(); + error InvalidTermsLength(); + + ////////////////////////////// Constructor ////////////////////////////// + + constructor() EIP712("ExecutionBoundEnforcer", "1") { } + + ////////////////////////////// Public Methods ////////////////////////////// + + /** + * @notice Enforces that the actual execution exactly matches the signed ExecutionIntent. + * @param _terms abi.encode(address authorizedSigner) — delegator commits to trusted signer. + * @param _args abi.encode(ExecutionIntent intent, bytes signature) + * @param _mode Must be single callType, default execType. + * @param _executionCallData The actual execution calldata to be validated. + * @param _delegator The delegating smart account. Must match intent.account. + */ + function beforeHook( + bytes calldata _terms, + bytes calldata _args, + ModeCode _mode, + bytes calldata _executionCallData, + bytes32, + address _delegator, + address + ) + public + override + onlySingleCallTypeMode(_mode) + onlyDefaultExecutionMode(_mode) + { + address authorizedSigner_ = getTermsInfo(_terms); + + (ExecutionIntent memory intent, bytes memory signature) = + abi.decode(_args, (ExecutionIntent, bytes)); + + (address target_, uint256 value_, bytes calldata callData_) = _executionCallData.decodeSingle(); + + if (intent.account != _delegator) revert AccountMismatch(intent.account, _delegator); + if (intent.target != target_) revert TargetMismatch(intent.target, target_); + if (intent.value != value_) revert ValueMismatch(intent.value, value_); + + bytes32 executionDataHash_ = keccak256(callData_); + if (intent.dataHash != executionDataHash_) revert DataHashMismatch(intent.dataHash, executionDataHash_); + + if (intent.deadline != 0 && block.timestamp > intent.deadline) { + revert IntentExpired(intent.deadline, block.timestamp); + } + + if (usedNonces[msg.sender][intent.account][intent.nonce]) { + revert NonceAlreadyUsed(msg.sender, intent.account, intent.nonce); + } + + usedNonces[msg.sender][intent.account][intent.nonce] = true; + emit NonceConsumed(msg.sender, intent.account, intent.nonce); + + bytes32 digest_ = _hashTypedDataV4(_hashIntent(intent)); + if (!SignatureChecker.isValidSignatureNow(authorizedSigner_, digest_, signature)) revert InvalidSignature(); + } + + /** + * @notice Decodes the terms used in this enforcer. + * @param _terms abi.encode(address authorizedSigner) + * @return authorizedSigner_ The address authorized to sign ExecutionIntents for this delegation. + */ + function getTermsInfo(bytes calldata _terms) public pure returns (address authorizedSigner_) { + if (_terms.length != 32) revert InvalidTermsLength(); + authorizedSigner_ = address(bytes20(_terms[12:32])); + } + + /** + * @notice Decodes the args used in this enforcer. + * @param _args abi.encode(ExecutionIntent intent, bytes signature) + */ + function getArgsInfo(bytes calldata _args) + public + pure + returns (ExecutionIntent memory intent_, bytes memory signature_) + { + (intent_, signature_) = abi.decode(_args, (ExecutionIntent, bytes)); + } + + /** + * @notice Computes the EIP-712 digest for a given intent. + */ + function intentDigest(ExecutionIntent calldata _intent) external view returns (bytes32) { + return _hashTypedDataV4(_hashIntent(_intent)); + } + + /** + * @notice Returns whether a nonce has been consumed. + */ + function isNonceUsed(address _delegationManager, address _account, uint256 _nonce) external view returns (bool) { + return usedNonces[_delegationManager][_account][_nonce]; + } + + ////////////////////////////// Internal Methods ////////////////////////////// + + function _hashIntent(ExecutionIntent memory _intent) internal pure returns (bytes32) { + return keccak256( + abi.encode( + EXECUTION_INTENT_TYPEHASH, + _intent.account, + _intent.target, + _intent.value, + _intent.dataHash, + _intent.nonce, + _intent.deadline + ) + ); + } +} diff --git a/test/enforcers/ExecutionBoundEnforcer.t.sol b/test/enforcers/ExecutionBoundEnforcer.t.sol new file mode 100644 index 00000000..cf5ce0f8 --- /dev/null +++ b/test/enforcers/ExecutionBoundEnforcer.t.sol @@ -0,0 +1,251 @@ +// SPDX-License-Identifier: MIT AND Apache-2.0 +pragma solidity 0.8.23; + +import { MessageHashUtils } from "@openzeppelin/contracts/utils/cryptography/MessageHashUtils.sol"; +import { ExecutionLib } from "@erc7579/lib/ExecutionLib.sol"; + +import { CaveatEnforcerBaseTest } from "./CaveatEnforcerBaseTest.t.sol"; +import { ExecutionBoundEnforcer } from "../../src/enforcers/ExecutionBoundEnforcer.sol"; +import { ICaveatEnforcer } from "../../src/interfaces/ICaveatEnforcer.sol"; +import { Caveat, Delegation } from "../../src/utils/Types.sol"; +import { BasicERC20 } from "../utils/BasicERC20.t.sol"; + +contract ExecutionBoundEnforcerTest is CaveatEnforcerBaseTest { + using MessageHashUtils for bytes32; + + ExecutionBoundEnforcer public enforcer; + BasicERC20 public basicCF20; + + uint256 signerPrivateKey = 0xA11CE; + address signer; + + function setUp() public override { + super.setUp(); + enforcer = new ExecutionBoundEnforcer(); + vm.label(address(enforcer), "Execution Bound Enforcer"); + basicCF20 = new BasicERC20(address(users.alice.deleGator), "TestToken1", "TestToken1", 100 ether); + signer = vm.addr(signerPrivateKey); + } + + // terms = abi.encode(address authorizedSigner) + function _buildTerms(address authorizedSigner_) internal pure returns (bytes memory) { + return abi.encode(authorizedSigner_); + } + + function _buildIntent( + address account_, + address target_, + uint256 value_, + bytes memory callData_, + uint256 nonce_, + uint256 deadline_ + ) internal pure returns (ExecutionBoundEnforcer.ExecutionIntent memory) { + return ExecutionBoundEnforcer.ExecutionIntent({ + account: account_, + target: target_, + value: value_, + dataHash: keccak256(callData_), + nonce: nonce_, + deadline: deadline_ + }); + } + + function _signIntent(ExecutionBoundEnforcer.ExecutionIntent memory intent_) internal view returns (bytes memory) { + bytes32 digest_ = enforcer.intentDigest(intent_); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(signerPrivateKey, digest_); + return abi.encodePacked(r, s, v); + } + + // args = abi.encode(ExecutionIntent intent, bytes signature) + function _buildArgs(ExecutionBoundEnforcer.ExecutionIntent memory intent_, bytes memory sig_) + internal pure returns (bytes memory) { + return abi.encode(intent_, sig_); + } + + function test_exactExecution_passes() public { + bytes memory callData_ = abi.encodeWithSelector( + basicCF20.transfer.selector, address(users.bob.deleGator), 10 ether + ); + bytes memory execCallData_ = ExecutionLib.encodeSingle(address(basicCF20), 0, callData_); + ExecutionBoundEnforcer.ExecutionIntent memory intent_ = + _buildIntent(address(users.alice.deleGator), address(basicCF20), 0, callData_, 0, 0); + bytes memory terms_ = _buildTerms(signer); + bytes memory args_ = _buildArgs(intent_, _signIntent(intent_)); + + vm.prank(address(delegationManager)); + enforcer.beforeHook(terms_, args_, singleDefaultMode, execCallData_, keccak256(""), address(users.alice.deleGator), address(users.bob.addr)); + } + + function test_nonceConsumed_afterSuccess() public { + bytes memory callData_ = abi.encodeWithSelector( + basicCF20.transfer.selector, address(users.bob.deleGator), 10 ether + ); + bytes memory execCallData_ = ExecutionLib.encodeSingle(address(basicCF20), 0, callData_); + ExecutionBoundEnforcer.ExecutionIntent memory intent_ = + _buildIntent(address(users.alice.deleGator), address(basicCF20), 0, callData_, 42, 0); + bytes memory terms_ = _buildTerms(signer); + bytes memory args_ = _buildArgs(intent_, _signIntent(intent_)); + + assertFalse(enforcer.isNonceUsed(address(delegationManager), address(users.alice.deleGator), 42)); + vm.prank(address(delegationManager)); + enforcer.beforeHook(terms_, args_, singleDefaultMode, execCallData_, keccak256(""), address(users.alice.deleGator), address(users.bob.addr)); + assertTrue(enforcer.isNonceUsed(address(delegationManager), address(users.alice.deleGator), 42)); + } + + function test_mutatedCalldata_reverts() public { + bytes memory signedCallData_ = abi.encodeWithSelector( + basicCF20.transfer.selector, address(users.bob.deleGator), 10 ether + ); + bytes memory mutatedCallData_ = abi.encodeWithSelector( + basicCF20.transfer.selector, address(users.eve.deleGator), 1000 ether + ); + bytes memory mutatedExecCallData_ = ExecutionLib.encodeSingle(address(basicCF20), 0, mutatedCallData_); + ExecutionBoundEnforcer.ExecutionIntent memory intent_ = + _buildIntent(address(users.alice.deleGator), address(basicCF20), 0, signedCallData_, 0, 0); + bytes memory terms_ = _buildTerms(signer); + bytes memory args_ = _buildArgs(intent_, _signIntent(intent_)); + + vm.prank(address(delegationManager)); + vm.expectRevert(abi.encodeWithSelector( + ExecutionBoundEnforcer.DataHashMismatch.selector, + keccak256(signedCallData_), keccak256(mutatedCallData_) + )); + enforcer.beforeHook(terms_, args_, singleDefaultMode, mutatedExecCallData_, keccak256(""), address(users.alice.deleGator), address(users.bob.addr)); + } + + function test_replay_reverts() public { + bytes memory callData_ = abi.encodeWithSelector( + basicCF20.transfer.selector, address(users.bob.deleGator), 10 ether + ); + bytes memory execCallData_ = ExecutionLib.encodeSingle(address(basicCF20), 0, callData_); + ExecutionBoundEnforcer.ExecutionIntent memory intent_ = + _buildIntent(address(users.alice.deleGator), address(basicCF20), 0, callData_, 0, 0); + bytes memory terms_ = _buildTerms(signer); + bytes memory args_ = _buildArgs(intent_, _signIntent(intent_)); + + vm.prank(address(delegationManager)); + enforcer.beforeHook(terms_, args_, singleDefaultMode, execCallData_, keccak256(""), address(users.alice.deleGator), address(users.bob.addr)); + + vm.prank(address(delegationManager)); + vm.expectRevert(abi.encodeWithSelector( + ExecutionBoundEnforcer.NonceAlreadyUsed.selector, + address(delegationManager), address(users.alice.deleGator), 0 + )); + enforcer.beforeHook(terms_, args_, singleDefaultMode, execCallData_, keccak256(""), address(users.alice.deleGator), address(users.bob.addr)); + } + + function test_unsupportedCallType_reverts() public { + bytes memory callData_ = abi.encodeWithSelector( + basicCF20.transfer.selector, address(users.bob.deleGator), 10 ether + ); + bytes memory execCallData_ = ExecutionLib.encodeSingle(address(basicCF20), 0, callData_); + ExecutionBoundEnforcer.ExecutionIntent memory intent_ = + _buildIntent(address(users.alice.deleGator), address(basicCF20), 0, callData_, 0, 0); + bytes memory terms_ = _buildTerms(signer); + bytes memory args_ = _buildArgs(intent_, _signIntent(intent_)); + + vm.prank(address(delegationManager)); + vm.expectRevert("CaveatEnforcer:invalid-call-type"); + enforcer.beforeHook(terms_, args_, batchDefaultMode, execCallData_, keccak256(""), address(users.alice.deleGator), address(users.bob.addr)); + } + + function test_signerDistinctFromDelegator_passes() public { + assertNotEq(signer, address(users.alice.deleGator)); + assertNotEq(signer, address(users.alice.addr)); + + bytes memory callData_ = abi.encodeWithSelector( + basicCF20.transfer.selector, address(users.bob.deleGator), 10 ether + ); + bytes memory execCallData_ = ExecutionLib.encodeSingle(address(basicCF20), 0, callData_); + ExecutionBoundEnforcer.ExecutionIntent memory intent_ = + _buildIntent(address(users.alice.deleGator), address(basicCF20), 0, callData_, 0, 0); + bytes memory terms_ = _buildTerms(signer); + bytes memory args_ = _buildArgs(intent_, _signIntent(intent_)); + + vm.prank(address(delegationManager)); + enforcer.beforeHook(terms_, args_, singleDefaultMode, execCallData_, keccak256(""), address(users.alice.deleGator), address(users.bob.addr)); + } + + function test_wrongSigner_reverts() public { + bytes memory callData_ = abi.encodeWithSelector( + basicCF20.transfer.selector, address(users.bob.deleGator), 10 ether + ); + bytes memory execCallData_ = ExecutionLib.encodeSingle(address(basicCF20), 0, callData_); + ExecutionBoundEnforcer.ExecutionIntent memory intent_ = + _buildIntent(address(users.alice.deleGator), address(basicCF20), 0, callData_, 0, 0); + + // terms commits to signer, but signature is from a different key + bytes memory terms_ = _buildTerms(signer); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(0xBADBAD, enforcer.intentDigest(intent_)); + bytes memory args_ = abi.encode(intent_, abi.encodePacked(r, s, v)); + + vm.prank(address(delegationManager)); + vm.expectRevert(ExecutionBoundEnforcer.InvalidSignature.selector); + enforcer.beforeHook(terms_, args_, singleDefaultMode, execCallData_, keccak256(""), address(users.alice.deleGator), address(users.bob.addr)); + } + + function test_wrongAccount_reverts() public { + bytes memory callData_ = abi.encodeWithSelector( + basicCF20.transfer.selector, address(users.bob.deleGator), 10 ether + ); + bytes memory execCallData_ = ExecutionLib.encodeSingle(address(basicCF20), 0, callData_); + ExecutionBoundEnforcer.ExecutionIntent memory intent_ = + _buildIntent(address(users.alice.deleGator), address(basicCF20), 0, callData_, 0, 0); + bytes memory terms_ = _buildTerms(signer); + bytes memory args_ = _buildArgs(intent_, _signIntent(intent_)); + + vm.prank(address(delegationManager)); + vm.expectRevert(abi.encodeWithSelector( + ExecutionBoundEnforcer.AccountMismatch.selector, + address(users.alice.deleGator), address(users.carol.deleGator) + )); + enforcer.beforeHook(terms_, args_, singleDefaultMode, execCallData_, keccak256(""), address(users.carol.deleGator), address(users.bob.addr)); + } + + function test_expiredDeadline_reverts() public { + bytes memory callData_ = abi.encodeWithSelector( + basicCF20.transfer.selector, address(users.bob.deleGator), 10 ether + ); + bytes memory execCallData_ = ExecutionLib.encodeSingle(address(basicCF20), 0, callData_); + vm.warp(1_000_000); + uint256 deadline_ = block.timestamp - 1; + ExecutionBoundEnforcer.ExecutionIntent memory intent_ = + _buildIntent(address(users.alice.deleGator), address(basicCF20), 0, callData_, 0, deadline_); + bytes memory terms_ = _buildTerms(signer); + bytes memory args_ = _buildArgs(intent_, _signIntent(intent_)); + + vm.prank(address(delegationManager)); + vm.expectRevert(abi.encodeWithSelector( + ExecutionBoundEnforcer.IntentExpired.selector, deadline_, block.timestamp + )); + enforcer.beforeHook(terms_, args_, singleDefaultMode, execCallData_, keccak256(""), address(users.alice.deleGator), address(users.bob.addr)); + } + + function test_directCall_cannotGriefNonce() public { + // Proves that calling beforeHook directly (not via delegationManager) + // uses a different msg.sender scope and cannot consume the legitimate nonce + bytes memory callData_ = abi.encodeWithSelector( + basicCF20.transfer.selector, address(users.bob.deleGator), 10 ether + ); + bytes memory execCallData_ = ExecutionLib.encodeSingle(address(basicCF20), 0, callData_); + ExecutionBoundEnforcer.ExecutionIntent memory intent_ = + _buildIntent(address(users.alice.deleGator), address(basicCF20), 0, callData_, 0, 0); + bytes memory terms_ = _buildTerms(signer); + bytes memory args_ = _buildArgs(intent_, _signIntent(intent_)); + + // Attacker calls beforeHook directly — different msg.sender + address attacker_ = makeAddr("attacker"); + vm.prank(attacker_); + enforcer.beforeHook(terms_, args_, singleDefaultMode, execCallData_, keccak256(""), address(users.alice.deleGator), address(users.bob.addr)); + + // Legitimate redemption via delegationManager still works — nonce not consumed for this manager + assertFalse(enforcer.isNonceUsed(address(delegationManager), address(users.alice.deleGator), 0)); + vm.prank(address(delegationManager)); + enforcer.beforeHook(terms_, args_, singleDefaultMode, execCallData_, keccak256(""), address(users.alice.deleGator), address(users.bob.addr)); + assertTrue(enforcer.isNonceUsed(address(delegationManager), address(users.alice.deleGator), 0)); + } + + function _getEnforcer() internal view override returns (ICaveatEnforcer) { + return ICaveatEnforcer(address(enforcer)); + } +}