Skip to content

Commit 90b5342

Browse files
committed
Refactor allowance revocation enforcer
1 parent e8b202f commit 90b5342

3 files changed

Lines changed: 353 additions & 158 deletions

File tree

src/enforcers/AllowanceRevocationEnforcer.sol

Lines changed: 0 additions & 136 deletions
This file was deleted.
Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
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 { IERC721 } from "@openzeppelin/contracts/token/ERC721/IERC721.sol";
6+
import { ExecutionLib } from "@erc7579/lib/ExecutionLib.sol";
7+
8+
import { CaveatEnforcer } from "./CaveatEnforcer.sol";
9+
import { ModeCode } from "../utils/Types.sol";
10+
11+
/**
12+
* @title ApprovalRevocationEnforcer
13+
* @notice Allows a delegate to clear an existing token approval. Covers the three standard approval primitives:
14+
* - ERC-20 `approve(spender, 0)`
15+
* - ERC-721 per-token `approve(address(0), tokenId)`
16+
* - ERC-721 / ERC-1155 `setApprovalForAll(operator, false)`
17+
*
18+
* @dev ERC-721 and ERC-1155 intentionally share the `setApprovalForAll(address,bool)` selector; this enforcer
19+
* handles both via the `IERC721` interface (the selector and ABI are identical, so a typed `IERC1155` import is
20+
* unnecessary for the external call). ERC-20 and ERC-721 likewise share the `approve(address,uint256)` selector,
21+
* and are disambiguated by inspecting the first parameter (see branching rules below).
22+
*
23+
* @dev The execution must transfer zero native value and carry one of the supported approval calldatas (length 68
24+
* bytes: 4-byte selector + two 32-byte words). Branching is determined as follows:
25+
* - selector `setApprovalForAll(address operator, bool approved)`:
26+
* - `approved` MUST be false, and
27+
* - `isApprovedForAll(delegator, operator)` MUST currently be true on the target.
28+
* - selector `approve(address, uint256)` (shared by ERC-20 and ERC-721):
29+
* - if the first parameter is `address(0)` the call is treated as an ERC-721 per-token revocation:
30+
* - `getApproved(tokenId)` on the target MUST currently return a non-zero address.
31+
* - otherwise the call is treated as an ERC-20 revocation:
32+
* - the second parameter (amount) MUST be zero, and
33+
* - `allowance(delegator, spender)` on the target MUST currently return non-zero.
34+
*
35+
* @dev All three accepted calldatas structurally result in a net reduction of permissions on the target (amount
36+
* `0`, spender `address(0)`, or `approved` `false`). A delegate using this enforcer can therefore never be granted
37+
* new authority over the delegator's assets — only existing approvals can be cleared.
38+
*
39+
* @dev REDELEGATION WARNING — link-local pre-check vs. root-level execution.
40+
*
41+
* The `_delegator` argument passed to `beforeHook` is the delegator of the specific delegation that carries the
42+
* caveat, not the root of a redelegation chain. The DelegationManager always executes the downstream
43+
* `approve` / `setApprovalForAll` call against the *root* delegator's account (the account at the end of the
44+
* leaf-to-root chain). On a root-level delegation (chain length 1) the two are the same and the pre-check
45+
* queries the account whose storage will actually be mutated — this is the intended usage.
46+
*
47+
* On an intermediate (redelegation) link the two differ: the pre-check queries the *intermediate* delegator's
48+
* approval state, while the execution mutates the *root* delegator's storage. A redelegator adding this caveat
49+
* to constrain their delegate is very likely expecting the pre-check to run against the root (the account whose
50+
* approval will be cleared). That expectation is wrong — the check is link-local.
51+
*
52+
* Concrete example. Alice -> Bob -> Carol. Alice's link has no caveat (Bob has full authority over Alice).
53+
* Bob places this enforcer on his delegation to Carol, intending "Carol can only revoke an existing approval on
54+
* Alice's account". When Carol redeems, the hook fires with `_delegator = Bob`, not Alice, so:
55+
* - if Bob has no allowance to the same spender on the target, the hook reverts even when Alice does have
56+
* one (Carol cannot use the chain, even though the revocation would have been valid for Alice);
57+
* - if Bob happens to have some allowance, the hook passes and the execution clears Alice's allowance —
58+
* independently of whether Alice actually had an allowance to clear.
59+
*
60+
* This is never an authority escalation (the structural constraints above still apply — the call can only
61+
* reduce permissions), but the sanity guard is misaligned with the executed effect and will behave
62+
* unintuitively for anyone reading "the delegator's approval must exist" as a check on the root.
63+
*
64+
* If a redelegator needs a root-scoped guarantee (e.g. "Carol may only revoke one of Alice's specific
65+
* approvals") they should rely on structural caveats that compose cleanly across links, such as
66+
* `AllowedTargetsEnforcer` (restrict which token contract), `AllowedCalldataEnforcer` (pin the exact spender
67+
* or tokenId), or `ExactCalldataEnforcer`. Placing `ApprovalRevocationEnforcer` on an intermediate link in the
68+
* hope of validating the root's approval state does not achieve that.
69+
*
70+
* @dev The "pre-existing approval" check is a liveness/sanity guard ensuring the call is not a no-op at the time
71+
* the hook runs. It is not a race-free invariant: the delegator could independently clear the approval between
72+
* the hook and the execution. In that case the execution is still safe — it simply becomes a no-op.
73+
*
74+
* @dev This enforcer does not consume any terms and is not scoped to a specific target contract. Delegators who
75+
* want to restrict revocation to specific tokens should compose this enforcer with `AllowedTargetsEnforcer`.
76+
*
77+
* @dev This enforcer operates only in single call type and default execution mode.
78+
*/
79+
contract ApprovalRevocationEnforcer is CaveatEnforcer {
80+
using ExecutionLib for bytes;
81+
82+
////////////////////////////// Public Methods //////////////////////////////
83+
84+
/**
85+
* @notice Requires the execution to revoke an existing token approval owned by `_delegator`.
86+
* @param _mode Must be single call type and default execution mode.
87+
* @param _executionCallData Single execution targeting the token contract.
88+
* @param _delegator The delegator of the delegation carrying this caveat (link-local, not the chain root).
89+
* See the contract-level NatSpec for the implications in redelegation chains.
90+
*/
91+
function beforeHook(
92+
bytes calldata,
93+
bytes calldata,
94+
ModeCode _mode,
95+
bytes calldata _executionCallData,
96+
bytes32,
97+
address _delegator,
98+
address
99+
)
100+
public
101+
view
102+
override
103+
onlySingleCallTypeMode(_mode)
104+
onlyDefaultExecutionMode(_mode)
105+
{
106+
(address target_, uint256 value_, bytes calldata callData_) = _executionCallData.decodeSingle();
107+
108+
require(value_ == 0, "ApprovalRevocationEnforcer:invalid-value");
109+
// 68 = 4-byte selector + two 32-byte words. Shared by `approve(address,uint256)` and
110+
// `setApprovalForAll(address,bool)`.
111+
require(callData_.length == 68, "ApprovalRevocationEnforcer:invalid-execution-length");
112+
113+
bytes4 selector_ = bytes4(callData_[0:4]);
114+
if (selector_ == IERC721.setApprovalForAll.selector) {
115+
_validateOperatorRevocation(target_, callData_, _delegator);
116+
return;
117+
}
118+
if (selector_ == IERC20.approve.selector) {
119+
// ERC-20 and ERC-721 share `approve(address,uint256)`. Disambiguate by the first parameter: ERC-721
120+
// revokes via `approve(address(0), tokenId)`, while ERC-20 revokes via `approve(spender, 0)` with a
121+
// non-zero spender.
122+
address firstParam_ = address(uint160(uint256(bytes32(callData_[4:36]))));
123+
if (firstParam_ == address(0)) {
124+
_validateErc721Revocation(target_, callData_);
125+
} else {
126+
_validateErc20Revocation(target_, callData_, _delegator, firstParam_);
127+
}
128+
return;
129+
}
130+
revert("ApprovalRevocationEnforcer:invalid-method");
131+
}
132+
133+
////////////////////////////// Internal Methods //////////////////////////////
134+
135+
/**
136+
* @dev Validates an ERC-20 `approve(spender, 0)` revocation. Requires `allowance(delegator, spender) > 0` on
137+
* the target.
138+
*/
139+
function _validateErc20Revocation(
140+
address _target,
141+
bytes calldata _callData,
142+
address _delegator,
143+
address _spender
144+
)
145+
private
146+
view
147+
{
148+
require(uint256(bytes32(_callData[36:68])) == 0, "ApprovalRevocationEnforcer:non-zero-amount");
149+
150+
require(
151+
IERC20(_target).allowance(_delegator, _spender) != 0, "ApprovalRevocationEnforcer:no-approval-to-revoke"
152+
);
153+
}
154+
155+
/**
156+
* @dev Validates an ERC-721 `approve(address(0), tokenId)` revocation. Requires `getApproved(tokenId)` on the
157+
* target to be non-zero (i.e. an approval is currently set).
158+
*/
159+
function _validateErc721Revocation(address _target, bytes calldata _callData) private view {
160+
uint256 tokenId_ = uint256(bytes32(_callData[36:68]));
161+
162+
require(
163+
IERC721(_target).getApproved(tokenId_) != address(0), "ApprovalRevocationEnforcer:no-approval-to-revoke"
164+
);
165+
}
166+
167+
/**
168+
* @dev Validates a `setApprovalForAll(operator, false)` revocation (ERC-721 and ERC-1155 share this selector).
169+
* Requires `isApprovedForAll(delegator, operator)` on the target to currently be true.
170+
*/
171+
function _validateOperatorRevocation(address _target, bytes calldata _callData, address _delegator) private view {
172+
require(uint256(bytes32(_callData[36:68])) == 0, "ApprovalRevocationEnforcer:not-a-revocation");
173+
174+
address operator_ = address(uint160(uint256(bytes32(_callData[4:36]))));
175+
require(
176+
IERC721(_target).isApprovedForAll(_delegator, operator_),
177+
"ApprovalRevocationEnforcer:no-approval-to-revoke"
178+
);
179+
}
180+
}

0 commit comments

Comments
 (0)