Skip to content

Commit 47cd4ea

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

File tree

3 files changed

+393
-0
lines changed

3 files changed

+393
-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: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
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).
20+
* The signer signs the ExecutionIntent (what must be executed).
21+
* These may be different keys, enabling session keys, agents, and co-signers.
22+
*
23+
* terms: unused. Pass empty bytes.
24+
* args: abi.encode(ExecutionIntent intent, address signer, bytes signature)
25+
*
26+
* The nonce is scoped by (account, signer) and consumed only after successful signature
27+
* verification, preventing griefing via invalid signature nonce consumption.
28+
*
29+
* @dev This enforcer operates only in single execution call type and with default execution mode.
30+
*/
31+
contract ExecutionBoundEnforcer is CaveatEnforcer {
32+
using ExecutionLib for bytes;
33+
using ModeLib for ModeCode;
34+
35+
////////////////////////////// Structs //////////////////////////////
36+
37+
struct ExecutionIntent {
38+
address account;
39+
address target;
40+
uint256 value;
41+
bytes32 dataHash;
42+
uint256 nonce;
43+
uint256 deadline;
44+
}
45+
46+
////////////////////////////// State //////////////////////////////
47+
48+
bytes32 private constant DOMAIN_TYPEHASH =
49+
keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)");
50+
51+
bytes32 private constant EXECUTION_INTENT_TYPEHASH = keccak256(
52+
"ExecutionIntent(address account,address target,uint256 value,bytes32 dataHash,uint256 nonce,uint256 deadline)"
53+
);
54+
55+
bytes32 public immutable domainSeparator;
56+
57+
mapping(address account => mapping(address signer => mapping(uint256 nonce => bool))) public usedNonces;
58+
59+
////////////////////////////// Events //////////////////////////////
60+
61+
event NonceConsumed(address indexed account, address indexed signer, uint256 nonce);
62+
63+
////////////////////////////// Errors //////////////////////////////
64+
65+
error AccountMismatch(address intentAccount, address delegator);
66+
error TargetMismatch(address intentTarget, address executionTarget);
67+
error ValueMismatch(uint256 intentValue, uint256 executionValue);
68+
error DataHashMismatch(bytes32 intentDataHash, bytes32 executionDataHash);
69+
error IntentExpired(uint256 deadline, uint256 blockTimestamp);
70+
error NonceAlreadyUsed(address account, address signer, uint256 nonce);
71+
error InvalidSignature();
72+
73+
////////////////////////////// Constructor //////////////////////////////
74+
75+
constructor() {
76+
domainSeparator = keccak256(
77+
abi.encode(
78+
DOMAIN_TYPEHASH,
79+
keccak256("ExecutionBoundEnforcer"),
80+
keccak256("1"),
81+
block.chainid,
82+
address(this)
83+
)
84+
);
85+
}
86+
87+
////////////////////////////// Public Methods //////////////////////////////
88+
89+
/**
90+
* @notice Enforces that the actual execution exactly matches the signed ExecutionIntent.
91+
* @param _args abi.encode(ExecutionIntent intent, address signer, bytes signature)
92+
* @param _mode Must be single callType, default execType.
93+
* @param _executionCallData The actual execution calldata to be validated.
94+
* @param _delegator The delegating smart account. Must match intent.account.
95+
*/
96+
function beforeHook(
97+
bytes calldata,
98+
bytes calldata _args,
99+
ModeCode _mode,
100+
bytes calldata _executionCallData,
101+
bytes32,
102+
address _delegator,
103+
address
104+
)
105+
public
106+
override
107+
onlySingleCallTypeMode(_mode)
108+
onlyDefaultExecutionMode(_mode)
109+
{
110+
111+
(ExecutionIntent memory intent, address signer, bytes memory signature) =
112+
abi.decode(_args, (ExecutionIntent, address, bytes));
113+
114+
(address target_, uint256 value_, bytes calldata callData_) = _executionCallData.decodeSingle();
115+
116+
if (intent.account != _delegator) revert AccountMismatch(intent.account, _delegator);
117+
if (intent.target != target_) revert TargetMismatch(intent.target, target_);
118+
if (intent.value != value_) revert ValueMismatch(intent.value, value_);
119+
120+
bytes32 executionDataHash_ = keccak256(callData_);
121+
if (intent.dataHash != executionDataHash_) revert DataHashMismatch(intent.dataHash, executionDataHash_);
122+
123+
if (intent.deadline != 0 && block.timestamp > intent.deadline) {
124+
revert IntentExpired(intent.deadline, block.timestamp);
125+
}
126+
127+
if (usedNonces[intent.account][signer][intent.nonce]) {
128+
revert NonceAlreadyUsed(intent.account, signer, intent.nonce);
129+
}
130+
131+
bytes32 digest_ = MessageHashUtils.toTypedDataHash(domainSeparator, _hashIntent(intent));
132+
if (!SignatureChecker.isValidSignatureNow(signer, digest_, signature)) revert InvalidSignature();
133+
134+
usedNonces[intent.account][signer][intent.nonce] = true;
135+
emit NonceConsumed(intent.account, signer, intent.nonce);
136+
}
137+
138+
/**
139+
* @notice Decodes the args used in this enforcer.
140+
*/
141+
function getArgsInfo(bytes calldata _args)
142+
public
143+
pure
144+
returns (ExecutionIntent memory intent_, address signer_, bytes memory signature_)
145+
{
146+
(intent_, signer_, signature_) = abi.decode(_args, (ExecutionIntent, address, bytes));
147+
}
148+
149+
/**
150+
* @notice Computes the EIP-712 digest for a given intent.
151+
*/
152+
function intentDigest(ExecutionIntent calldata _intent) external view returns (bytes32) {
153+
return MessageHashUtils.toTypedDataHash(domainSeparator, _hashIntent(_intent));
154+
}
155+
156+
/**
157+
* @notice Returns whether a nonce has been consumed.
158+
*/
159+
function isNonceUsed(address _account, address _signer, uint256 _nonce) external view returns (bool) {
160+
return usedNonces[_account][_signer][_nonce];
161+
}
162+
163+
////////////////////////////// Internal Methods //////////////////////////////
164+
165+
function _hashIntent(ExecutionIntent memory _intent) internal pure returns (bytes32) {
166+
return keccak256(
167+
abi.encode(
168+
EXECUTION_INTENT_TYPEHASH,
169+
_intent.account,
170+
_intent.target,
171+
_intent.value,
172+
_intent.dataHash,
173+
_intent.nonce,
174+
_intent.deadline
175+
)
176+
);
177+
}
178+
}

0 commit comments

Comments
 (0)