Skip to content

Commit c058ad5

Browse files
committed
feat: add exact execution batch enforcer
1 parent ae51213 commit c058ad5

File tree

2 files changed

+396
-0
lines changed

2 files changed

+396
-0
lines changed
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
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+
7+
import { CaveatEnforcer } from "./CaveatEnforcer.sol";
8+
import { ModeCode, Execution } from "../utils/Types.sol";
9+
10+
/**
11+
* @title ExactExecutionBatchEnforcer
12+
* @notice Ensures that each execution in the batch matches exactly with the expected execution (target, value, and calldata).
13+
* @dev This caveat enforcer operates only in batch execution mode.
14+
*/
15+
contract ExactExecutionBatchEnforcer is CaveatEnforcer {
16+
using ExecutionLib for bytes;
17+
using ModeLib for ModeCode;
18+
19+
////////////////////////////// Public Methods //////////////////////////////
20+
21+
/**
22+
* @notice Validates that each execution in the batch matches exactly with the expected execution.
23+
* @param _terms The encoded expected Executions.
24+
* @param _mode The execution mode, which must be batch.
25+
* @param _executionCallData The batch execution calldata.
26+
*/
27+
function beforeHook(
28+
bytes calldata _terms,
29+
bytes calldata,
30+
ModeCode _mode,
31+
bytes calldata _executionCallData,
32+
bytes32,
33+
address,
34+
address
35+
)
36+
public
37+
pure
38+
override
39+
onlyBatchExecutionMode(_mode)
40+
{
41+
Execution[] calldata executions_ = _executionCallData.decodeBatch();
42+
Execution[] memory termsExecutions_ = getTermsInfo(_terms);
43+
44+
// Validate that the number of executions matches
45+
require(executions_.length == termsExecutions_.length, "ExactExecutionBatchEnforcer:invalid-batch-size");
46+
47+
// Check each execution matches exactly (target, value, and calldata)
48+
for (uint256 i = 0; i < executions_.length; i++) {
49+
require(
50+
termsExecutions_[i].target == executions_[i].target && termsExecutions_[i].value == executions_[i].value
51+
&& keccak256(termsExecutions_[i].callData) == keccak256(executions_[i].callData),
52+
"ExactExecutionBatchEnforcer:invalid-execution"
53+
);
54+
}
55+
}
56+
57+
/**
58+
* @notice Extracts the expected executions from the provided terms.
59+
* @param _terms The encoded expected Executions.
60+
* @return executions_ Array of expected Executions.
61+
*/
62+
function getTermsInfo(bytes calldata _terms) public pure returns (Execution[] memory executions_) {
63+
executions_ = _terms.decodeBatch();
64+
}
65+
}
Lines changed: 331 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,331 @@
1+
// SPDX-License-Identifier: MIT AND Apache-2.0
2+
pragma solidity 0.8.23;
3+
4+
import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
5+
import { ModeLib } from "@erc7579/lib/ModeLib.sol";
6+
import { ExecutionLib } from "@erc7579/lib/ExecutionLib.sol";
7+
8+
import { CaveatEnforcerBaseTest } from "./CaveatEnforcerBaseTest.t.sol";
9+
import { ExactExecutionBatchEnforcer } from "../../src/enforcers/ExactExecutionBatchEnforcer.sol";
10+
import { ICaveatEnforcer } from "../../src/interfaces/ICaveatEnforcer.sol";
11+
import { Execution, Caveat, Delegation, ModeCode } from "../../src/utils/Types.sol";
12+
import { BasicERC20 } from "../utils/BasicERC20.t.sol";
13+
14+
contract ExactExecutionBatchEnforcerTest is CaveatEnforcerBaseTest {
15+
////////////////////////////// State //////////////////////////////
16+
17+
ExactExecutionBatchEnforcer public exactExecutionBatchEnforcer;
18+
BasicERC20 public basicCF20;
19+
ModeCode public singleMode = ModeLib.encodeSimpleSingle();
20+
ModeCode public batchMode = ModeLib.encodeSimpleBatch();
21+
22+
////////////////////////////// Set up //////////////////////////////
23+
24+
function setUp() public override {
25+
super.setUp();
26+
exactExecutionBatchEnforcer = new ExactExecutionBatchEnforcer();
27+
vm.label(address(exactExecutionBatchEnforcer), "Exact Calldata Batch Enforcer");
28+
basicCF20 = new BasicERC20(address(users.alice.deleGator), "TestToken1", "TestToken1", 100 ether);
29+
}
30+
31+
////////////////////////////// Valid cases //////////////////////////////
32+
33+
/// @notice Test that the enforcer passes when all executions match exactly.
34+
function test_exactExecutionMatches() public {
35+
Execution[] memory executions_ = new Execution[](2);
36+
executions_[0] = Execution({
37+
target: address(basicCF20),
38+
value: 0,
39+
callData: abi.encodeWithSelector(IERC20.transfer.selector, address(users.bob.deleGator), uint256(1 ether))
40+
});
41+
executions_[1] = Execution({
42+
target: address(basicCF20),
43+
value: 0,
44+
callData: abi.encodeWithSelector(IERC20.transfer.selector, address(users.carol.deleGator), uint256(2 ether))
45+
});
46+
47+
bytes memory executionCallData_ = ExecutionLib.encodeBatch(executions_);
48+
bytes memory terms_ = _encodeTerms(executions_);
49+
50+
vm.prank(address(delegationManager));
51+
exactExecutionBatchEnforcer.beforeHook(terms_, hex"", batchMode, executionCallData_, keccak256(""), address(0), address(0));
52+
}
53+
54+
////////////////////////////// Invalid cases //////////////////////////////
55+
56+
/// @notice Test that the enforcer reverts when target doesn't match.
57+
function test_exactExecutionFailsWhenTargetDiffers() public {
58+
Execution[] memory executions_ = new Execution[](2);
59+
executions_[0] = Execution({
60+
target: address(basicCF20),
61+
value: 0,
62+
callData: abi.encodeWithSelector(IERC20.transfer.selector, address(users.bob.deleGator), uint256(1 ether))
63+
});
64+
executions_[1] = Execution({
65+
target: address(basicCF20),
66+
value: 0,
67+
callData: abi.encodeWithSelector(IERC20.transfer.selector, address(users.carol.deleGator), uint256(2 ether))
68+
});
69+
70+
// Create terms with a different target for the second execution
71+
Execution[] memory termsExecutions_ = new Execution[](2);
72+
termsExecutions_[0] = executions_[0];
73+
termsExecutions_[1] = Execution({
74+
target: address(users.bob.deleGator), // Different target
75+
value: 0,
76+
callData: executions_[1].callData
77+
});
78+
79+
bytes memory executionCallData_ = ExecutionLib.encodeBatch(executions_);
80+
bytes memory terms_ = _encodeTerms(termsExecutions_);
81+
82+
vm.prank(address(delegationManager));
83+
vm.expectRevert("ExactExecutionBatchEnforcer:invalid-execution");
84+
exactExecutionBatchEnforcer.beforeHook(terms_, hex"", batchMode, executionCallData_, keccak256(""), address(0), address(0));
85+
}
86+
87+
/// @notice Test that the enforcer reverts when value doesn't match.
88+
function test_exactExecutionFailsWhenValueDiffers() public {
89+
Execution[] memory executions_ = new Execution[](2);
90+
executions_[0] = Execution({
91+
target: address(basicCF20),
92+
value: 0,
93+
callData: abi.encodeWithSelector(IERC20.transfer.selector, address(users.bob.deleGator), uint256(1 ether))
94+
});
95+
executions_[1] = Execution({
96+
target: address(basicCF20),
97+
value: 0,
98+
callData: abi.encodeWithSelector(IERC20.transfer.selector, address(users.carol.deleGator), uint256(2 ether))
99+
});
100+
101+
// Create terms with a different value for the second execution
102+
Execution[] memory termsExecutions_ = new Execution[](2);
103+
termsExecutions_[0] = executions_[0];
104+
termsExecutions_[1] = Execution({
105+
target: executions_[1].target,
106+
value: 1 ether, // Different value
107+
callData: executions_[1].callData
108+
});
109+
110+
bytes memory executionCallData_ = ExecutionLib.encodeBatch(executions_);
111+
bytes memory terms_ = _encodeTerms(termsExecutions_);
112+
113+
vm.prank(address(delegationManager));
114+
vm.expectRevert("ExactExecutionBatchEnforcer:invalid-execution");
115+
exactExecutionBatchEnforcer.beforeHook(terms_, hex"", batchMode, executionCallData_, keccak256(""), address(0), address(0));
116+
}
117+
118+
/// @notice Test that the enforcer reverts when calldata doesn't match.
119+
function test_exactExecutionFailsWhenCalldataDiffers() public {
120+
Execution[] memory executions_ = new Execution[](2);
121+
executions_[0] = Execution({
122+
target: address(basicCF20),
123+
value: 0,
124+
callData: abi.encodeWithSelector(IERC20.transfer.selector, address(users.bob.deleGator), uint256(1 ether))
125+
});
126+
executions_[1] = Execution({
127+
target: address(basicCF20),
128+
value: 0,
129+
callData: abi.encodeWithSelector(IERC20.transfer.selector, address(users.carol.deleGator), uint256(2 ether))
130+
});
131+
132+
// Create terms with different calldata for the second execution
133+
Execution[] memory termsExecutions_ = new Execution[](2);
134+
termsExecutions_[0] = executions_[0];
135+
termsExecutions_[1] = Execution({
136+
target: executions_[1].target,
137+
value: executions_[1].value,
138+
callData: abi.encodeWithSelector(IERC20.transfer.selector, address(users.carol.deleGator), uint256(3 ether))
139+
});
140+
141+
bytes memory executionCallData_ = ExecutionLib.encodeBatch(executions_);
142+
bytes memory terms_ = _encodeTerms(termsExecutions_);
143+
144+
vm.prank(address(delegationManager));
145+
vm.expectRevert("ExactExecutionBatchEnforcer:invalid-execution");
146+
exactExecutionBatchEnforcer.beforeHook(terms_, hex"", batchMode, executionCallData_, keccak256(""), address(0), address(0));
147+
}
148+
149+
/// @notice Test that the enforcer reverts when batch size doesn't match.
150+
function test_batchSizeMismatch() public {
151+
Execution[] memory executions_ = new Execution[](2);
152+
executions_[0] = Execution({
153+
target: address(basicCF20),
154+
value: 0,
155+
callData: abi.encodeWithSelector(IERC20.transfer.selector, address(users.bob.deleGator), uint256(1 ether))
156+
});
157+
executions_[1] = Execution({
158+
target: address(basicCF20),
159+
value: 0,
160+
callData: abi.encodeWithSelector(IERC20.transfer.selector, address(users.carol.deleGator), uint256(2 ether))
161+
});
162+
163+
// Create terms with only one execution
164+
Execution[] memory termsExecutions_ = new Execution[](1);
165+
termsExecutions_[0] = executions_[0];
166+
167+
bytes memory executionCallData_ = ExecutionLib.encodeBatch(executions_);
168+
bytes memory terms_ = _encodeTerms(termsExecutions_);
169+
170+
vm.prank(address(delegationManager));
171+
vm.expectRevert("ExactExecutionBatchEnforcer:invalid-batch-size");
172+
exactExecutionBatchEnforcer.beforeHook(terms_, hex"", batchMode, executionCallData_, keccak256(""), address(0), address(0));
173+
}
174+
175+
/// @notice Test that the enforcer reverts when single mode is used.
176+
function test_singleModeReverts() public {
177+
Execution[] memory executions_ = new Execution[](2);
178+
executions_[0] = Execution({
179+
target: address(basicCF20),
180+
value: 0,
181+
callData: abi.encodeWithSelector(IERC20.transfer.selector, address(users.bob.deleGator), uint256(1 ether))
182+
});
183+
executions_[1] = Execution({
184+
target: address(basicCF20),
185+
value: 0,
186+
callData: abi.encodeWithSelector(IERC20.transfer.selector, address(users.carol.deleGator), uint256(2 ether))
187+
});
188+
189+
bytes memory executionCallData_ = ExecutionLib.encodeBatch(executions_);
190+
bytes memory terms_ = _encodeTerms(executions_);
191+
192+
vm.prank(address(delegationManager));
193+
vm.expectRevert("CaveatEnforcer:invalid-call-type");
194+
exactExecutionBatchEnforcer.beforeHook(terms_, hex"", singleMode, executionCallData_, keccak256(""), address(0), address(0));
195+
}
196+
197+
////////////////////////////// Integration Tests //////////////////////////////
198+
199+
/// @notice Integration test: the enforcer allows a batch of token transfers when executions match exactly.
200+
function test_integration_AllowsBatchTokenTransfers() public {
201+
// Record initial balances
202+
uint256 bobInitialBalance_ = basicCF20.balanceOf(address(users.bob.deleGator));
203+
uint256 carolInitialBalance_ = basicCF20.balanceOf(address(users.carol.deleGator));
204+
205+
// Create a batch of executions
206+
Execution[] memory executions_ = new Execution[](2);
207+
executions_[0] = Execution({
208+
target: address(basicCF20),
209+
value: 0,
210+
callData: abi.encodeWithSelector(IERC20.transfer.selector, address(users.bob.deleGator), uint256(1 ether))
211+
});
212+
executions_[1] = Execution({
213+
target: address(basicCF20),
214+
value: 0,
215+
callData: abi.encodeWithSelector(IERC20.transfer.selector, address(users.carol.deleGator), uint256(2 ether))
216+
});
217+
218+
// Create terms that match exactly
219+
bytes memory terms_ = _encodeTerms(executions_);
220+
221+
Caveat[] memory caveats_ = new Caveat[](1);
222+
caveats_[0] = Caveat({ args: hex"", enforcer: address(exactExecutionBatchEnforcer), terms: terms_ });
223+
Delegation memory delegation_ = Delegation({
224+
delegate: address(users.bob.deleGator),
225+
delegator: address(users.alice.deleGator),
226+
authority: ROOT_AUTHORITY,
227+
caveats: caveats_,
228+
salt: 0,
229+
signature: hex""
230+
});
231+
delegation_ = signDelegation(users.alice, delegation_);
232+
233+
// Prepare delegation redemption parameters
234+
bytes[] memory permissionContexts_ = new bytes[](1);
235+
Delegation[] memory delegations_ = new Delegation[](1);
236+
delegations_[0] = delegation_;
237+
permissionContexts_[0] = abi.encode(delegations_);
238+
239+
bytes[] memory executionCallDatas_ = new bytes[](1);
240+
executionCallDatas_[0] = ExecutionLib.encodeBatch(executions_);
241+
242+
// Set up batch mode
243+
ModeCode[] memory oneBatchMode_ = new ModeCode[](1);
244+
oneBatchMode_[0] = batchMode;
245+
246+
// Bob redeems the delegation to execute the batch
247+
vm.prank(address(users.bob.deleGator));
248+
delegationManager.redeemDelegations(permissionContexts_, oneBatchMode_, executionCallDatas_);
249+
250+
// Verify balances changed correctly
251+
assertEq(basicCF20.balanceOf(address(users.bob.deleGator)), bobInitialBalance_ + 1 ether);
252+
assertEq(basicCF20.balanceOf(address(users.carol.deleGator)), carolInitialBalance_ + 2 ether);
253+
}
254+
255+
/// @notice Integration test: the enforcer blocks batch execution when any execution doesn't match.
256+
function test_integration_BlocksBatchWhenExecutionDiffers() public {
257+
// Record initial balances
258+
uint256 bobInitialBalance_ = basicCF20.balanceOf(address(users.bob.deleGator));
259+
uint256 carolInitialBalance_ = basicCF20.balanceOf(address(users.carol.deleGator));
260+
261+
// Create a batch of executions
262+
Execution[] memory executions_ = new Execution[](2);
263+
executions_[0] = Execution({
264+
target: address(basicCF20),
265+
value: 0,
266+
callData: abi.encodeWithSelector(IERC20.transfer.selector, address(users.bob.deleGator), uint256(1 ether))
267+
});
268+
executions_[1] = Execution({
269+
target: address(basicCF20),
270+
value: 0,
271+
callData: abi.encodeWithSelector(IERC20.transfer.selector, address(users.carol.deleGator), uint256(2 ether))
272+
});
273+
274+
// Create terms with a mismatch in the second execution
275+
Execution[] memory termsExecutions_ = new Execution[](2);
276+
termsExecutions_[0] = executions_[0];
277+
termsExecutions_[1] = Execution({
278+
target: address(basicCF20),
279+
value: 1 ether, // Different value
280+
callData: executions_[1].callData
281+
});
282+
283+
bytes memory terms_ = _encodeTerms(termsExecutions_);
284+
285+
Caveat[] memory caveats_ = new Caveat[](1);
286+
caveats_[0] = Caveat({ args: hex"", enforcer: address(exactExecutionBatchEnforcer), terms: terms_ });
287+
Delegation memory delegation_ = Delegation({
288+
delegate: address(users.bob.deleGator),
289+
delegator: address(users.alice.deleGator),
290+
authority: ROOT_AUTHORITY,
291+
caveats: caveats_,
292+
salt: 0,
293+
signature: hex""
294+
});
295+
delegation_ = signDelegation(users.alice, delegation_);
296+
297+
// Prepare delegation redemption parameters
298+
bytes[] memory permissionContexts_ = new bytes[](1);
299+
Delegation[] memory delegations_ = new Delegation[](1);
300+
delegations_[0] = delegation_;
301+
permissionContexts_[0] = abi.encode(delegations_);
302+
303+
bytes[] memory executionCallDatas_ = new bytes[](1);
304+
executionCallDatas_[0] = ExecutionLib.encodeBatch(executions_);
305+
306+
// Set up batch mode
307+
ModeCode[] memory oneBatchMode_ = new ModeCode[](1);
308+
oneBatchMode_[0] = batchMode;
309+
310+
// Bob redeems the delegation to execute the batch
311+
vm.prank(address(users.bob.deleGator));
312+
vm.expectRevert("ExactExecutionBatchEnforcer:invalid-execution");
313+
delegationManager.redeemDelegations(permissionContexts_, oneBatchMode_, executionCallDatas_);
314+
315+
// Verify balances remain unchanged
316+
assertEq(basicCF20.balanceOf(address(users.bob.deleGator)), bobInitialBalance_);
317+
assertEq(basicCF20.balanceOf(address(users.carol.deleGator)), carolInitialBalance_);
318+
}
319+
320+
////////////////////////////// Helper Functions //////////////////////////////
321+
322+
/// @notice Helper function to encode terms for the batch enforcer
323+
function _encodeTerms(Execution[] memory _executions) internal pure returns (bytes memory) {
324+
return ExecutionLib.encodeBatch(_executions);
325+
}
326+
327+
////////////////////////////// Internal Overrides //////////////////////////////
328+
function _getEnforcer() internal view override returns (ICaveatEnforcer) {
329+
return ICaveatEnforcer(address(exactExecutionBatchEnforcer));
330+
}
331+
}

0 commit comments

Comments
 (0)