You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Copy file name to clipboardExpand all lines: documents/CaveatEnforcers.md
+133Lines changed: 133 additions & 0 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -174,6 +174,139 @@ Note that in this scenario we have the same end recipient (treasury) and the sam
174
174
175
175
If you are delegating to an EOA in a delegation chain, the EOA cannot execute directly since it cannot redeem inner delegations. The EOA can become a deleGator by using EIP7702 or it can use an adapter contract to execute the delegation. An example for that is available in `./src/helpers/DelegationMetaSwapAdapter.sol`.
176
176
177
+
### ApprovalRevocationEnforcer
178
+
179
+
The `ApprovalRevocationEnforcer` lets a delegator grant a delegate the narrow authority to **clear an existing token approval** on the delegator's behalf, without granting any other power over the delegator's assets. It covers six revocation primitives — three standard token-contract primitives and three against the canonical Permit2 deployment:
The Permit2 branches are restricted to the canonical deployment at `0x000000000022D473030F116dDEE9F6B43aC78BA3` (deterministic across mainnet, Base, Arbitrum, Optimism, etc.). On chains where canonical Permit2 is not deployed, do not enable the Permit2 bits — see [Trust Assumptions](#trust-assumptions) below.
189
+
190
+
#### Terms
191
+
192
+
The enforcer reads a **1-byte bitmask** from `terms` to control which revocation primitives the delegate may use:
|`approve(_,_,0,0)`| yes | yes (set to `block.timestamp`) | no | no |
229
+
|`lockdown(pairs)`| yes | no | no | no |
230
+
|`invalidateNonces(…)`| no | no | yes | yes |
231
+
232
+
To **fully sever** a delegator's Permit2 exposure to a `(token, spender)` pair, both an on-chain allowance revocation (bit 3 or 4) **and** a nonce invalidation (bit 5) are typically required. Enabling only on-chain revocation leaves any signed-but-unredeemed `permit` payloads live; enabling only nonce invalidation leaves the existing on-chain allowance intact. Bit-mask `0x38` enables all three.
233
+
234
+
> **Note (DoS surface on bit 5).** A delegate granted `invalidateNonces` (bit `0x20`) can advance the stored nonce for any `(token, spender)` pair the caveat does not pin (Permit2 caps the per-call delta at `type(uint16).max`, but a determined delegate can repeat until `nonce == type(uint48).max`, after which the root delegator can no longer sign new permits for that pair). This is never an authority escalation — it can only invalidate, never create — but it is a denial-of-service vector for the delegator's future signed-permit flow. When granting bit 5, pin the `(token, spender)` pair via `AllowedCalldataEnforcer` / `ExactCalldataEnforcer` and/or rate-limit the delegation with `LimitedCallsEnforcer`.
235
+
236
+
#### How It Works
237
+
238
+
The enforcer runs only in single call type and default execution mode and makes no assumption about the target contract (other than the Permit2 branches, which require the canonical Permit2 address). In `beforeHook` it:
239
+
240
+
1. Decodes and validates the 1-byte terms bitmask (rejects empty, zero, or reserved-bit-set terms).
241
+
2. Requires the execution to transfer zero native value and to carry at least 4 bytes of calldata.
242
+
3. Dispatches by selector and applies the permitted-primitive check (per the bitmask), then branches:
243
+
-**Permit2 `approve(address,address,uint160,uint48)`** — requires `target == _PERMIT2`, calldata length `== 132`, `amount == 0`, and `expiration == 0`. No on-chain liveness check is performed.
244
+
-**Permit2 `lockdown((address,address)[])`** — requires `target == _PERMIT2`. The calldata is otherwise unconstrained: every entry of the array structurally forces `amount = 0` for the corresponding `(token, spender)` pair (`expiration` and `nonce` are left untouched), so no parameter the delegate could supply can grant new authority. A malformed array reverts inside Permit2 itself.
245
+
-**Permit2 `invalidateNonces(address,address,uint48)`** — requires `target == _PERMIT2`. The calldata is otherwise unconstrained: Permit2 enforces strict nonce monotonicity (and a per-call delta capped at `type(uint16).max`), so the call can only invalidate signed-but-unredeemed `permit` payloads, never create or extend an allowance.
246
+
-**`setApprovalForAll(address operator, bool approved)`** (calldata length 68) — requires `approved == false` and `isApprovedForAll(delegator, operator) == true` on the target.
247
+
-**`approve(address, uint256)`** (calldata length 68, shared by ERC-20 and ERC-721) — disambiguated by the first parameter:
248
+
- First parameter is `address(0)` → treated as an ERC-721 per-token revocation; requires `getApproved(tokenId)` on the target to return a non-zero address.
249
+
- First parameter is non-zero → treated as an ERC-20 revocation; requires the second parameter (amount) to be zero and `allowance(delegator, spender) > 0` on the target.
250
+
4. Reverts on any other selector.
251
+
252
+
All six accepted calldatas structurally reduce permissions (amount `0`, spender `address(0)`, `approved``false`, per-pair Permit2 amount zeroing, or strictly monotonic Permit2 nonce bump). A delegate using this enforcer can therefore **never be granted new authority** over the delegator's assets — only existing approvals can be cleared and pending Permit2 signatures invalidated.
253
+
254
+
#### Liveness vs. Race-Freedom
255
+
256
+
The ERC-20, ERC-721, and `setApprovalForAll` branches each include a "pre-existing approval" check on the target token contract. This is a liveness / sanity guard ensuring the call is not a no-op at the time the hook runs. It is **not** a race-free invariant: the delegator could independently clear the approval between the hook and the execution. In that case the execution is still safe — it simply becomes a no-op on the token contract.
257
+
258
+
The three Permit2 branches intentionally **omit** this on-chain liveness pre-check. Permit2 silently overwrites any existing allowance, so a call against a `(token, spender)` pair with no live allowance is a harmless no-op (or, for `invalidateNonces` against a triple whose stored nonce already meets the new value, reverts inside Permit2 itself). The structural constraints (canonical Permit2 target, fixed selector, and — for `approve` — zero amount and zero expiration) already guarantee the call can only reduce permissions.
259
+
260
+
#### Trust Assumptions
261
+
262
+
The Permit2 branches assume the canonical Uniswap-deployed Permit2 contract is at `_PERMIT2 = 0x000000000022D473030F116dDEE9F6B43aC78BA3` on the target chain. On chains where Uniswap has deployed Permit2 this is a safe deterministic address. On chains where canonical Permit2 is **not** deployed:
263
+
264
+
- if the address is empty, the executor's call returns successfully with no effect (harmless no-op);
265
+
- if a *different* contract happens to live at that address, the selector dispatches into whatever that contract does. The `approve(0, 0)` branch is partially self-protected by its structural calldata checks (any contract under that selector would have to interpret the layout identically to grant authority), but `lockdown` and `invalidateNonces` have no such structural moat.
266
+
267
+
Delegators on chains without canonical Permit2 should NOT enable bits 3, 4, or 5.
268
+
269
+
#### Incompatibility: ERC-20 Tokens That Revert on Zero-Value `approve`
270
+
271
+
A small number of non-standard ERC-20 tokens — most notably **BNB on Ethereum mainnet**, and a handful of older tokens — revert when `approve(spender, 0)` is called. Because the ERC-20 branch of this enforcer strictly requires `amount == 0` and provides no alternative revocation primitive (e.g. `decreaseAllowance`), **allowances previously granted on such tokens cannot be revoked through this enforcer**: the executed `approve(spender, 0)` reverts inside the token contract.
272
+
273
+
Implications for delegators:
274
+
275
+
- Do not rely on a delegation carrying `ApprovalRevocationEnforcer` (bit `0x01`) to clear an outstanding allowance for one of these tokens.
276
+
- Be aware before signing a **batch** that includes this enforcer for such a token — the entire batch will revert at the token call.
277
+
- Revoke these allowances directly from the owning account (or via a different revocation path) instead.
278
+
279
+
The ERC-721, `setApprovalForAll`, and Permit2 branches are unaffected.
280
+
281
+
#### Use Cases
282
+
283
+
-**Revocation bots / keepers**: Delegate to a third party that can proactively clean up stale or compromised approvals.
284
+
-**Post-incident remediation**: Issue a short-lived delegation to revoke a specific approval after a spender contract is found to be malicious. For Permit2, combine bit 3/4 (on-chain) with bit 5 (signature invalidation) to fully sever the spender.
285
+
-**User-facing "revoke all" flows**: Let a UI batch revocations on the user's behalf without asking for a new signature per clear. `lockdown` is particularly useful here for clearing many Permit2 allowances in a single transaction; pair it with `invalidateNonces` if the user also wants to kill any outstanding signed permits.
286
+
287
+
#### Composition
288
+
289
+
The enforcer is intentionally not scoped to any particular spender, operator, or `(token, spender)` pair. To restrict it further, compose it with existing enforcers:
290
+
291
+
-`AllowedTargetsEnforcer` — restrict revocation to specific token contracts. Note that for the Permit2 branches the target is already pinned to the canonical Permit2 address by the enforcer itself.
292
+
-`AllowedCalldataEnforcer` / `ExactCalldataEnforcer` — pin the exact spender, operator, tokenId, or `(token, spender, newNonce)` triple. For the static branches (`approve`, `setApprovalForAll`, Permit2 `approve`, Permit2 `invalidateNonces`) these compare cleanly against fixed offsets. For Permit2 `lockdown` the calldata is dynamic (offset + array length + entries), so `ExactCalldataEnforcer` is usually the cleaner option for pinning a specific list of pairs.
293
+
294
+
#### Redelegation Caveat (Link-Local Semantics)
295
+
296
+
The `_delegator` argument passed to `beforeHook` is the delegator of the specific delegation that carries the caveat, **not** the root of a redelegation chain. The `DelegationManager` always executes the downstream call against the root delegator's account. On a root-level delegation (chain length 1) the two are the same and the pre-check queries the account whose storage will actually be mutated — this is the intended usage.
297
+
298
+
On an intermediate (redelegation) link the two differ. The implications are different per primitive group:
299
+
300
+
-**ERC-20 / ERC-721 / `setApprovalForAll` branches** — the pre-check queries the intermediate delegator's approval state while the execution mutates the root delegator's storage. Concretely:
301
+
- If the intermediate delegator has no matching approval, the hook reverts even when the root does (the chain cannot be used, even though the revocation would have been valid for the root).
302
+
- If the intermediate delegator happens to have some approval, the hook passes and the execution clears the root's approval regardless of whether the root actually had one to clear.
303
+
304
+
-**Permit2 branches** — no per-delegator pre-check is performed. On an intermediate link the link-local sanity guard is simply absent: the hook always passes (subject only to the per-flag and target checks), and the executed call zeros / bumps the root delegator's Permit2 state for whatever `(token, spender)` pair the delegate supplies. Composition with `AllowedCalldataEnforcer` / `ExactCalldataEnforcer` to pin the pair is therefore **load-bearing** — not belt-and-suspenders — for any redelegated Permit2 caveat.
305
+
306
+
Neither case is an authority escalation (the structural constraints above still hold — the call can only reduce permissions), but the sanity guard is misaligned with the executed effect for the standard branches and absent entirely for the Permit2 branches.
307
+
308
+
If a redelegator needs a root-scoped guarantee (e.g. "Carol may only revoke one of Alice's specific approvals"), they should rely on structural caveats that compose cleanly across links, such as `AllowedTargetsEnforcer`, `AllowedCalldataEnforcer`, or `ExactCalldataEnforcer`. Placing `ApprovalRevocationEnforcer` on an intermediate link in the hope of validating the root's approval state does not achieve that.
309
+
177
310
## LogicalOrWrapperEnforcer Context Switching
178
311
179
312
The `LogicalOrWrapperEnforcer` enables logical OR functionality between groups of enforcers, allowing flexibility in delegation constraints. This enforcer is designed for a narrow set of use cases, and careful attention must be given when constructing caveats. The enforcer introduces an important architectural consideration: **context switching**.
0 commit comments