Skip to content

Commit 118ead0

Browse files
committed
feat: add ExecutionBoundEnforcer — exact execution commitment at redemption
1 parent 2366f71 commit 118ead0

File tree

3 files changed

+434
-0
lines changed

3 files changed

+434
-0
lines changed

script/DeployCaveatEnforcers.s.sol

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import { ExactCalldataBatchEnforcer } from "../src/enforcers/ExactCalldataBatchE
2323
import { ExactCalldataEnforcer } from "../src/enforcers/ExactCalldataEnforcer.sol";
2424
import { ExactExecutionBatchEnforcer } from "../src/enforcers/ExactExecutionBatchEnforcer.sol";
2525
import { ExactExecutionEnforcer } from "../src/enforcers/ExactExecutionEnforcer.sol";
26+
import { ExecutionBoundEnforcer } from "../src/enforcers/ExecutionBoundEnforcer.sol";
2627
import { IdEnforcer } from "../src/enforcers/IdEnforcer.sol";
2728
import { LimitedCallsEnforcer } from "../src/enforcers/LimitedCallsEnforcer.sol";
2829
import { LogicalOrWrapperEnforcer } from "../src/enforcers/LogicalOrWrapperEnforcer.sol";
@@ -122,6 +123,9 @@ contract DeployCaveatEnforcers is Script {
122123
deployedAddress = address(new ExactExecutionEnforcer{ salt: salt }());
123124
console2.log("ExactExecutionEnforcer: %s", deployedAddress);
124125

126+
deployedAddress = address(new ExecutionBoundEnforcer{ salt: salt }());
127+
console2.log("ExecutionBoundEnforcer: %s", deployedAddress);
128+
125129
deployedAddress = address(new IdEnforcer{ salt: salt }());
126130
console2.log("IdEnforcer: %s", deployedAddress);
127131

Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
// SPDX-License-Identifier: MIT AND Apache-2.0
2+
pragma solidity 0.8.23;
3+
4+
import { ExecutionLib } from "@erc7579/lib/ExecutionLib.sol";
5+
import { ModeLib } from "@erc7579/lib/ModeLib.sol";
6+
import { SignatureChecker } from "@openzeppelin/contracts/utils/cryptography/SignatureChecker.sol";
7+
import { EIP712 } from "@openzeppelin/contracts/utils/cryptography/EIP712.sol";
8+
9+
import { CaveatEnforcer } from "./CaveatEnforcer.sol";
10+
import { ModeCode } from "../utils/Types.sol";
11+
12+
/**
13+
* @title ExecutionBoundEnforcer
14+
* @notice Enforces that the actual execution at redemption exactly matches a pre-signed ExecutionIntent.
15+
* @dev Unlike ExactExecutionEnforcer (which encodes the expected execution statically in terms at
16+
* delegation time), this enforcer binds execution dynamically at redemption time via a second
17+
* EIP-712 signature.
18+
*
19+
* The delegator signs the delegation (who may redeem) and commits to an authorized signer in terms.
20+
* The authorized signer signs the ExecutionIntent (what must be executed).
21+
* These may be different keys, enabling session keys, agents, and co-signers.
22+
*
23+
* terms: abi.encode(address authorizedSigner)
24+
* args: abi.encode(ExecutionIntent intent, bytes signature)
25+
*
26+
* The nonce is scoped by (delegationManager, account, nonce) and consumed only after successful
27+
* signature verification, preventing griefing via invalid signature nonce consumption.
28+
* Scoping by msg.sender (the delegation manager) prevents direct beforeHook calls from
29+
* consuming nonces outside of a legitimate redemption flow.
30+
*
31+
* @dev This enforcer operates only in single execution call type and with default execution mode.
32+
*/
33+
contract ExecutionBoundEnforcer is CaveatEnforcer, EIP712 {
34+
using ExecutionLib for bytes;
35+
using ModeLib for ModeCode;
36+
37+
////////////////////////////// Structs //////////////////////////////
38+
39+
struct ExecutionIntent {
40+
address account;
41+
address target;
42+
uint256 value;
43+
bytes32 dataHash;
44+
uint256 nonce;
45+
uint256 deadline;
46+
}
47+
48+
////////////////////////////// State //////////////////////////////
49+
50+
bytes32 private constant EXECUTION_INTENT_TYPEHASH = keccak256(
51+
"ExecutionIntent(address account,address target,uint256 value,bytes32 dataHash,uint256 nonce,uint256 deadline)"
52+
);
53+
54+
mapping(address delegationManager => mapping(address account => mapping(uint256 nonce => bool))) public usedNonces;
55+
56+
////////////////////////////// Events //////////////////////////////
57+
58+
event NonceConsumed(address indexed delegationManager, address indexed account, uint256 nonce);
59+
60+
////////////////////////////// Errors //////////////////////////////
61+
62+
error AccountMismatch(address intentAccount, address delegator);
63+
error TargetMismatch(address intentTarget, address executionTarget);
64+
error ValueMismatch(uint256 intentValue, uint256 executionValue);
65+
error DataHashMismatch(bytes32 intentDataHash, bytes32 executionDataHash);
66+
error IntentExpired(uint256 deadline, uint256 blockTimestamp);
67+
error NonceAlreadyUsed(address delegationManager, address account, uint256 nonce);
68+
error InvalidSignature();
69+
error InvalidTermsLength();
70+
71+
////////////////////////////// Constructor //////////////////////////////
72+
73+
constructor() EIP712("ExecutionBoundEnforcer", "1") { }
74+
75+
////////////////////////////// Public Methods //////////////////////////////
76+
77+
/**
78+
* @notice Enforces that the actual execution exactly matches the signed ExecutionIntent.
79+
* @param _terms abi.encode(address authorizedSigner) — delegator commits to trusted signer.
80+
* @param _args abi.encode(ExecutionIntent intent, bytes signature)
81+
* @param _mode Must be single callType, default execType.
82+
* @param _executionCallData The actual execution calldata to be validated.
83+
* @param _delegator The delegating smart account. Must match intent.account.
84+
*/
85+
function beforeHook(
86+
bytes calldata _terms,
87+
bytes calldata _args,
88+
ModeCode _mode,
89+
bytes calldata _executionCallData,
90+
bytes32,
91+
address _delegator,
92+
address
93+
)
94+
public
95+
override
96+
onlySingleCallTypeMode(_mode)
97+
onlyDefaultExecutionMode(_mode)
98+
{
99+
address authorizedSigner_ = getTermsInfo(_terms);
100+
101+
(ExecutionIntent memory intent, bytes memory signature) =
102+
abi.decode(_args, (ExecutionIntent, bytes));
103+
104+
(address target_, uint256 value_, bytes calldata callData_) = _executionCallData.decodeSingle();
105+
106+
if (intent.account != _delegator) revert AccountMismatch(intent.account, _delegator);
107+
if (intent.target != target_) revert TargetMismatch(intent.target, target_);
108+
if (intent.value != value_) revert ValueMismatch(intent.value, value_);
109+
110+
bytes32 executionDataHash_ = keccak256(callData_);
111+
if (intent.dataHash != executionDataHash_) revert DataHashMismatch(intent.dataHash, executionDataHash_);
112+
113+
if (intent.deadline != 0 && block.timestamp > intent.deadline) {
114+
revert IntentExpired(intent.deadline, block.timestamp);
115+
}
116+
117+
if (usedNonces[msg.sender][intent.account][intent.nonce]) {
118+
revert NonceAlreadyUsed(msg.sender, intent.account, intent.nonce);
119+
}
120+
121+
bytes32 digest_ = _hashTypedDataV4(_hashIntent(intent));
122+
if (!SignatureChecker.isValidSignatureNow(authorizedSigner_, digest_, signature)) revert InvalidSignature();
123+
124+
usedNonces[msg.sender][intent.account][intent.nonce] = true;
125+
emit NonceConsumed(msg.sender, intent.account, intent.nonce);
126+
}
127+
128+
/**
129+
* @notice Decodes the terms used in this enforcer.
130+
* @param _terms abi.encode(address authorizedSigner)
131+
* @return authorizedSigner_ The address authorized to sign ExecutionIntents for this delegation.
132+
*/
133+
function getTermsInfo(bytes calldata _terms) public pure returns (address authorizedSigner_) {
134+
if (_terms.length != 32) revert InvalidTermsLength();
135+
authorizedSigner_ = address(bytes20(_terms[12:32]));
136+
}
137+
138+
/**
139+
* @notice Decodes the args used in this enforcer.
140+
* @param _args abi.encode(ExecutionIntent intent, bytes signature)
141+
*/
142+
function getArgsInfo(bytes calldata _args)
143+
public
144+
pure
145+
returns (ExecutionIntent memory intent_, bytes memory signature_)
146+
{
147+
(intent_, signature_) = abi.decode(_args, (ExecutionIntent, bytes));
148+
}
149+
150+
/**
151+
* @notice Computes the EIP-712 digest for a given intent.
152+
*/
153+
function intentDigest(ExecutionIntent calldata _intent) external view returns (bytes32) {
154+
return _hashTypedDataV4(_hashIntent(_intent));
155+
}
156+
157+
/**
158+
* @notice Returns whether a nonce has been consumed.
159+
*/
160+
function isNonceUsed(address _delegationManager, address _account, uint256 _nonce) external view returns (bool) {
161+
return usedNonces[_delegationManager][_account][_nonce];
162+
}
163+
164+
////////////////////////////// Internal Methods //////////////////////////////
165+
166+
function _hashIntent(ExecutionIntent memory _intent) internal pure returns (bytes32) {
167+
return keccak256(
168+
abi.encode(
169+
EXECUTION_INTENT_TYPEHASH,
170+
_intent.account,
171+
_intent.target,
172+
_intent.value,
173+
_intent.dataHash,
174+
_intent.nonce,
175+
_intent.deadline
176+
)
177+
);
178+
}
179+
}

0 commit comments

Comments
 (0)