Skip to content

Commit b898af3

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

3 files changed

Lines changed: 449 additions & 0 deletions

File tree

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: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,194 @@
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 { MessageHashUtils } from "@openzeppelin/contracts/utils/cryptography/MessageHashUtils.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 {
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 DOMAIN_TYPEHASH =
51+
keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)");
52+
53+
bytes32 private constant EXECUTION_INTENT_TYPEHASH = keccak256(
54+
"ExecutionIntent(address account,address target,uint256 value,bytes32 dataHash,uint256 nonce,uint256 deadline)"
55+
);
56+
57+
bytes32 public immutable domainSeparator;
58+
59+
mapping(address delegationManager => mapping(address account => mapping(uint256 nonce => bool))) public usedNonces;
60+
61+
////////////////////////////// Events //////////////////////////////
62+
63+
event NonceConsumed(address indexed delegationManager, address indexed account, uint256 nonce);
64+
65+
////////////////////////////// Errors //////////////////////////////
66+
67+
error AccountMismatch(address intentAccount, address delegator);
68+
error TargetMismatch(address intentTarget, address executionTarget);
69+
error ValueMismatch(uint256 intentValue, uint256 executionValue);
70+
error DataHashMismatch(bytes32 intentDataHash, bytes32 executionDataHash);
71+
error IntentExpired(uint256 deadline, uint256 blockTimestamp);
72+
error NonceAlreadyUsed(address delegationManager, address account, uint256 nonce);
73+
error InvalidSignature();
74+
error InvalidTermsLength();
75+
76+
////////////////////////////// Constructor //////////////////////////////
77+
78+
constructor() {
79+
domainSeparator = keccak256(
80+
abi.encode(
81+
DOMAIN_TYPEHASH,
82+
keccak256("ExecutionBoundEnforcer"),
83+
keccak256("1"),
84+
block.chainid,
85+
address(this)
86+
)
87+
);
88+
}
89+
90+
////////////////////////////// Public Methods //////////////////////////////
91+
92+
/**
93+
* @notice Enforces that the actual execution exactly matches the signed ExecutionIntent.
94+
* @param _terms abi.encode(address authorizedSigner) — delegator commits to trusted signer.
95+
* @param _args abi.encode(ExecutionIntent intent, bytes signature)
96+
* @param _mode Must be single callType, default execType.
97+
* @param _executionCallData The actual execution calldata to be validated.
98+
* @param _delegator The delegating smart account. Must match intent.account.
99+
*/
100+
function beforeHook(
101+
bytes calldata _terms,
102+
bytes calldata _args,
103+
ModeCode _mode,
104+
bytes calldata _executionCallData,
105+
bytes32,
106+
address _delegator,
107+
address
108+
)
109+
public
110+
override
111+
onlySingleCallTypeMode(_mode)
112+
onlyDefaultExecutionMode(_mode)
113+
{
114+
address authorizedSigner_ = getTermsInfo(_terms);
115+
116+
(ExecutionIntent memory intent, bytes memory signature) =
117+
abi.decode(_args, (ExecutionIntent, bytes));
118+
119+
(address target_, uint256 value_, bytes calldata callData_) = _executionCallData.decodeSingle();
120+
121+
if (intent.account != _delegator) revert AccountMismatch(intent.account, _delegator);
122+
if (intent.target != target_) revert TargetMismatch(intent.target, target_);
123+
if (intent.value != value_) revert ValueMismatch(intent.value, value_);
124+
125+
bytes32 executionDataHash_ = keccak256(callData_);
126+
if (intent.dataHash != executionDataHash_) revert DataHashMismatch(intent.dataHash, executionDataHash_);
127+
128+
if (intent.deadline != 0 && block.timestamp > intent.deadline) {
129+
revert IntentExpired(intent.deadline, block.timestamp);
130+
}
131+
132+
if (usedNonces[msg.sender][intent.account][intent.nonce]) {
133+
revert NonceAlreadyUsed(msg.sender, intent.account, intent.nonce);
134+
}
135+
136+
bytes32 digest_ = MessageHashUtils.toTypedDataHash(domainSeparator, _hashIntent(intent));
137+
if (!SignatureChecker.isValidSignatureNow(authorizedSigner_, digest_, signature)) revert InvalidSignature();
138+
139+
usedNonces[msg.sender][intent.account][intent.nonce] = true;
140+
emit NonceConsumed(msg.sender, intent.account, intent.nonce);
141+
}
142+
143+
/**
144+
* @notice Decodes the terms used in this enforcer.
145+
* @param _terms abi.encode(address authorizedSigner)
146+
* @return authorizedSigner_ The address authorized to sign ExecutionIntents for this delegation.
147+
*/
148+
function getTermsInfo(bytes calldata _terms) public pure returns (address authorizedSigner_) {
149+
if (_terms.length != 32) revert InvalidTermsLength();
150+
authorizedSigner_ = address(bytes20(_terms[12:32]));
151+
}
152+
153+
/**
154+
* @notice Decodes the args used in this enforcer.
155+
* @param _args abi.encode(ExecutionIntent intent, bytes signature)
156+
*/
157+
function getArgsInfo(bytes calldata _args)
158+
public
159+
pure
160+
returns (ExecutionIntent memory intent_, bytes memory signature_)
161+
{
162+
(intent_, signature_) = abi.decode(_args, (ExecutionIntent, bytes));
163+
}
164+
165+
/**
166+
* @notice Computes the EIP-712 digest for a given intent.
167+
*/
168+
function intentDigest(ExecutionIntent calldata _intent) external view returns (bytes32) {
169+
return MessageHashUtils.toTypedDataHash(domainSeparator, _hashIntent(_intent));
170+
}
171+
172+
/**
173+
* @notice Returns whether a nonce has been consumed.
174+
*/
175+
function isNonceUsed(address _delegationManager, address _account, uint256 _nonce) external view returns (bool) {
176+
return usedNonces[_delegationManager][_account][_nonce];
177+
}
178+
179+
////////////////////////////// Internal Methods //////////////////////////////
180+
181+
function _hashIntent(ExecutionIntent memory _intent) internal pure returns (bytes32) {
182+
return keccak256(
183+
abi.encode(
184+
EXECUTION_INTENT_TYPEHASH,
185+
_intent.account,
186+
_intent.target,
187+
_intent.value,
188+
_intent.dataHash,
189+
_intent.nonce,
190+
_intent.deadline
191+
)
192+
);
193+
}
194+
}

0 commit comments

Comments
 (0)