diff --git a/documents/CaveatEnforcers.md b/documents/CaveatEnforcers.md index 43c3b51c..e618ad87 100644 --- a/documents/CaveatEnforcers.md +++ b/documents/CaveatEnforcers.md @@ -174,6 +174,81 @@ Note that in this scenario we have the same end recipient (treasury) and the sam 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`. +### ApprovalRevocationEnforcer + +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 the three standard approval primitives: + +- ERC-20 `approve(spender, 0)` +- ERC-721 per-token `approve(address(0), tokenId)` +- ERC-721 / ERC-1155 `setApprovalForAll(operator, false)` (both standards share the selector) + +#### Terms + +The enforcer reads a **1-byte bitmask** from `terms` to control which revocation primitives the delegate may use: + +| Bit | Hex mask | Allowed primitive | +|-----|----------|-------------------| +| 0 | `0x01` | ERC-20 `approve(spender, 0)` | +| 1 | `0x02` | ERC-721 `approve(address(0), tokenId)` | +| 2 | `0x04` | `setApprovalForAll(operator, false)` (ERC-721 & ERC-1155) | +| 3–7 | — | Reserved; MUST be zero | + +- Terms MUST be exactly 1 byte. +- A zero mask (`0x00`) is rejected — at least one primitive must be permitted. +- Any reserved bit (3–7) set is rejected. +- `0x07` enables all three primitives. + +**Common examples:** + +``` +terms = 0x01 → ERC-20 revocations only +terms = 0x04 → operator (setApprovalForAll) revocations only +terms = 0x07 → all three primitives allowed +``` + +#### How It Works + +The enforcer runs only in single call type and default execution mode and makes no assumption about the target contract. In `beforeHook` it: + +1. Decodes and validates the 1-byte terms bitmask (rejects empty, zero, or reserved-bit-set terms). +2. Requires the execution to transfer zero native value and to carry calldata of exactly 68 bytes (4-byte selector + two 32-byte words). +3. Checks that the selector matches a permitted primitive (per the bitmask), then branches: + - `setApprovalForAll(address operator, bool approved)` — requires `approved == false` and `isApprovedForAll(delegator, operator) == true` on the target. + - `approve(address, uint256)` — shared by ERC-20 and ERC-721, disambiguated by the first parameter: + - 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. + - 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. +4. Reverts on any other selector. + +All three accepted calldatas structurally reduce permissions (amount `0`, spender `address(0)`, or `approved` `false`). A delegate using this enforcer can therefore **never be granted new authority** over the delegator's assets — only existing approvals can be cleared. + +#### Use Cases + +- **Revocation bots / keepers**: Delegate to a third party that can proactively clean up stale or compromised approvals. +- **Post-incident remediation**: Issue a short-lived delegation to revoke a specific approval after a spender contract is found to be malicious. +- **User-facing "revoke all" flows**: Let a UI batch revocations on the user's behalf without asking for a new signature per clear. + +#### Composition + +The enforcer is not scoped to any particular token contract or spender. To restrict it further, compose it with existing enforcers: + +- `AllowedTargetsEnforcer` — restrict revocation to specific token contracts. +- `AllowedCalldataEnforcer` / `ExactCalldataEnforcer` — pin the exact spender, operator, or tokenId. + +#### Redelegation Caveat (Link-Local Semantics) + +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 `approve` / `setApprovalForAll` 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. + +On an intermediate (redelegation) link the two differ: the pre-check queries the intermediate delegator's approval state while the execution mutates the root delegator's storage. This is **never an authority escalation** (the structural constraints above still hold — the call can only reduce permissions), but the sanity guard becomes misaligned with the executed effect: + +- 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). +- 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. + +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. + +#### Liveness vs. Race-Freedom + +The "pre-existing approval" check 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. + ## LogicalOrWrapperEnforcer Context Switching 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**. diff --git a/script/DeployCaveatEnforcers.s.sol b/script/DeployCaveatEnforcers.s.sol index cff28621..629da134 100644 --- a/script/DeployCaveatEnforcers.s.sol +++ b/script/DeployCaveatEnforcers.s.sol @@ -41,8 +41,10 @@ import { ValueLteEnforcer } from "../src/enforcers/ValueLteEnforcer.sol"; import { ERC20MultiOperationIncreaseBalanceEnforcer } from "../src/enforcers/ERC20MultiOperationIncreaseBalanceEnforcer.sol"; import { ERC721MultiOperationIncreaseBalanceEnforcer } from "../src/enforcers/ERC721MultiOperationIncreaseBalanceEnforcer.sol"; import { ERC1155MultiOperationIncreaseBalanceEnforcer } from "../src/enforcers/ERC1155MultiOperationIncreaseBalanceEnforcer.sol"; -import { NativeTokenMultiOperationIncreaseBalanceEnforcer } from - "../src/enforcers/NativeTokenMultiOperationIncreaseBalanceEnforcer.sol"; +import { + NativeTokenMultiOperationIncreaseBalanceEnforcer +} from "../src/enforcers/NativeTokenMultiOperationIncreaseBalanceEnforcer.sol"; +import { ApprovalRevocationEnforcer } from "../src/enforcers/ApprovalRevocationEnforcer.sol"; /** * @title DeployCaveatEnforcers @@ -183,6 +185,9 @@ contract DeployCaveatEnforcers is Script { deployedAddress = address(new NativeTokenMultiOperationIncreaseBalanceEnforcer{ salt: salt }()); console2.log("NativeTokenMultiOperationIncreaseBalanceEnforcer: %s", deployedAddress); + deployedAddress = address(new ApprovalRevocationEnforcer{ salt: salt }()); + console2.log("ApprovalRevocationEnforcer: %s", deployedAddress); + vm.stopBroadcast(); } } diff --git a/script/verification/verify-enforcer-contracts.sh b/script/verification/verify-enforcer-contracts.sh index b9cbc162..ef77e9a1 100755 --- a/script/verification/verify-enforcer-contracts.sh +++ b/script/verification/verify-enforcer-contracts.sh @@ -57,6 +57,7 @@ ENFORCERS=( "ERC721MultiOperationIncreaseBalanceEnforcer" "ERC1155MultiOperationIncreaseBalanceEnforcer" "NativeTokenMultiOperationIncreaseBalanceEnforcer" + "ApprovalRevocationEnforcer" ) ADDRESSES=( @@ -94,6 +95,7 @@ ADDRESSES=( "0x44877cDAFC0d529ab144bb6B0e202eE377C90229" "0x9eB86bbdaA71D4D8d5Fb1B8A9457F04D3344797b" "0xaD551E9b971C1b0c02c577bFfCFAA20b81777276" + "0x0000000000000000000000000000000000000000" ) ############################################################################### diff --git a/src/enforcers/ApprovalRevocationEnforcer.sol b/src/enforcers/ApprovalRevocationEnforcer.sol new file mode 100644 index 00000000..eb3062c0 --- /dev/null +++ b/src/enforcers/ApprovalRevocationEnforcer.sol @@ -0,0 +1,215 @@ +// SPDX-License-Identifier: MIT AND Apache-2.0 +pragma solidity 0.8.23; + +import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import { IERC721 } from "@openzeppelin/contracts/token/ERC721/IERC721.sol"; +import { ExecutionLib } from "@erc7579/lib/ExecutionLib.sol"; + +import { CaveatEnforcer } from "./CaveatEnforcer.sol"; +import { ModeCode } from "../utils/Types.sol"; + +/** + * @title ApprovalRevocationEnforcer + * @notice Allows a delegate to clear existing token approvals. The delegator controls which of the three + * standard revocation primitives the delegate may perform via a 1-byte bitmask in `terms`: + * + * Bit 0 (`0x01`) — ERC-20 `approve(spender, 0)` (spender non-zero, amount zero) + * Bit 1 (`0x02`) — ERC-721 per-token `approve(address(0), tokenId)` + * Bit 2 (`0x04`) — ERC-721 / ERC-1155 `setApprovalForAll(operator, false)` + * Bits 3-7 — Reserved; MUST be zero. + * + * Examples: + * `0x01` — delegate may only clear ERC-20 allowances. + * `0x04` — delegate may only revoke operator approvals. + * `0x07` — delegate may use all three revocation primitives. + * + * Terms MUST be exactly 1 byte, MUST not be zero, and MUST NOT set any reserved bit. + * + * @dev ERC-721 and ERC-1155 intentionally share the `setApprovalForAll(address,bool)` selector; this enforcer + * handles both via the `IERC721` interface (the selector and ABI are identical, so a typed `IERC1155` import is + * unnecessary for the external call). ERC-20 and ERC-721 likewise share the `approve(address,uint256)` selector, + * and are disambiguated by inspecting the first parameter (see branching rules below). + * + * @dev The execution must transfer zero native value and carry one of the supported approval calldatas (length 68 + * bytes: 4-byte selector + two 32-byte words). Branching is determined as follows: + * - selector `setApprovalForAll(address operator, bool approved)`: + * - `approved` MUST be false, and + * - `isApprovedForAll(delegator, operator)` MUST currently be true on the target. + * - selector `approve(address, uint256)` (shared by ERC-20 and ERC-721): + * - if the first parameter is `address(0)` the call is treated as an ERC-721 per-token revocation: + * - `getApproved(tokenId)` on the target MUST currently return a non-zero address. + * - otherwise the call is treated as an ERC-20 revocation: + * - the second parameter (amount) MUST be zero, and + * - `allowance(delegator, spender)` on the target MUST currently return non-zero. + * + * @dev All three accepted calldatas structurally result in a net reduction of permissions on the target (amount + * `0`, spender `address(0)`, or `approved` `false`). A delegate using this enforcer can therefore never be granted + * new authority over the delegator's assets — only existing approvals can be cleared. + * + * @dev REDELEGATION WARNING — link-local pre-check vs. root-level execution. + * + * 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 + * `approve` / `setApprovalForAll` call against the *root* delegator's account (the account at the end of the + * leaf-to-root chain). 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. + * + * On an intermediate (redelegation) link the two differ: the pre-check queries the *intermediate* delegator's + * approval state, while the execution mutates the *root* delegator's storage. A redelegator adding this caveat + * to constrain their delegate is very likely expecting the pre-check to run against the root (the account whose + * approval will be cleared). That expectation is wrong — the check is link-local. + * + * Concrete example. Alice -> Bob -> Carol. Alice's link has no caveat (Bob has full authority over Alice). + * Bob places this enforcer on his delegation to Carol, intending "Carol can only revoke an existing approval on + * Alice's account". When Carol redeems, the hook fires with `_delegator = Bob`, not Alice, so: + * - if Bob has no allowance to the same spender on the target, the hook reverts even when Alice does have + * one (Carol cannot use the chain, even though the revocation would have been valid for Alice); + * - if Bob happens to have some allowance, the hook passes and the execution clears Alice's allowance — + * independently of whether Alice actually had an allowance to clear. + * + * This is never an authority escalation (the structural constraints above still apply — the call can only + * reduce permissions), but the sanity guard is misaligned with the executed effect and will behave + * unintuitively for anyone reading "the delegator's approval must exist" as a check on the root. + * + * 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` (restrict which token contract), `AllowedCalldataEnforcer` (pin the exact spender + * or tokenId), or `ExactCalldataEnforcer`. Placing `ApprovalRevocationEnforcer` on an intermediate link in the + * hope of validating the root's approval state does not achieve that. + * + * @dev The "pre-existing approval" check 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. + * + * @dev Delegators who want to restrict revocation to specific tokens should compose this enforcer with + * `AllowedTargetsEnforcer`. + * + * @dev This enforcer operates only in single call type and default execution mode. + */ +contract ApprovalRevocationEnforcer is CaveatEnforcer { + using ExecutionLib for bytes; + + ////////////////////////////// Constants ////////////////////////////// + + /// @dev Permission flags packed into the single-byte terms bitmask. + uint8 internal constant _PERMISSION_ERC20_APPROVE = 0x01; + uint8 internal constant _PERMISSION_ERC721_APPROVE = 0x02; + uint8 internal constant _PERMISSION_SET_APPROVAL_FOR_ALL = 0x04; + uint8 internal constant _PERMISSION_MASK = + _PERMISSION_ERC20_APPROVE | _PERMISSION_ERC721_APPROVE | _PERMISSION_SET_APPROVAL_FOR_ALL; + + ////////////////////////////// Public Methods ////////////////////////////// + + /** + * @notice Requires the execution to revoke an existing token approval owned by `_delegator`, and that the + * revocation primitive used is permitted by `_terms`. + * @param _terms 1-byte bitmask selecting which revocation primitives are allowed. See `getTermsInfo`. + * @param _mode Must be single call type and default execution mode. + * @param _executionCallData Single execution targeting the token contract. + * @param _delegator The delegator of the delegation carrying this caveat (link-local, not the chain root). + * See the contract-level NatSpec for the implications in redelegation chains. + */ + function beforeHook( + bytes calldata _terms, + bytes calldata, + ModeCode _mode, + bytes calldata _executionCallData, + bytes32, + address _delegator, + address + ) + public + view + override + onlySingleCallTypeMode(_mode) + onlyDefaultExecutionMode(_mode) + { + // Validate terms and capture the raw flags byte (1 stack slot vs. 3 bools). + uint8 flags_ = _parseFlags(_terms); + + (address target_, uint256 value_, bytes calldata callData_) = _executionCallData.decodeSingle(); + + require(value_ == 0, "ApprovalRevocationEnforcer:invalid-value"); + // 68 = 4-byte selector + two 32-byte words. Shared by `approve(address,uint256)` and + // `setApprovalForAll(address,bool)`. + require(callData_.length == 68, "ApprovalRevocationEnforcer:invalid-execution-length"); + + if (bytes4(callData_[0:4]) == IERC721.setApprovalForAll.selector) { + require(flags_ & _PERMISSION_SET_APPROVAL_FOR_ALL != 0, "ApprovalRevocationEnforcer:setApprovalForAll-not-allowed"); + _validateOperatorRevocation(target_, callData_, _delegator); + return; + } + if (bytes4(callData_[0:4]) == IERC20.approve.selector) { + // ERC-20 and ERC-721 share `approve(address,uint256)`. Disambiguate by the first parameter: ERC-721 + // revokes via `approve(address(0), tokenId)`, while ERC-20 revokes via `approve(spender, 0)` with a + // non-zero spender. + if (address(uint160(uint256(bytes32(callData_[4:36])))) == address(0)) { + require(flags_ & _PERMISSION_ERC721_APPROVE != 0, "ApprovalRevocationEnforcer:erc721-approve-not-allowed"); + _validateErc721Revocation(target_, callData_); + } else { + require(flags_ & _PERMISSION_ERC20_APPROVE != 0, "ApprovalRevocationEnforcer:erc20-approve-not-allowed"); + _validateErc20Revocation(target_, callData_, _delegator, address(uint160(uint256(bytes32(callData_[4:36]))))); + } + return; + } + revert("ApprovalRevocationEnforcer:invalid-method"); + } + + ////////////////////////////// Internal Methods ////////////////////////////// + + /** + * @dev Validates and returns the raw permission flags byte. Reverts on invalid terms. + */ + function _parseFlags(bytes calldata _terms) private pure returns (uint8 flags_) { + require(_terms.length == 1, "ApprovalRevocationEnforcer:invalid-terms-length"); + flags_ = uint8(_terms[0]); + require(flags_ != 0, "ApprovalRevocationEnforcer:no-methods-allowed"); + require(flags_ & ~_PERMISSION_MASK == 0, "ApprovalRevocationEnforcer:invalid-terms"); + } + + /** + * @dev Validates an ERC-20 `approve(spender, 0)` revocation. Requires `allowance(delegator, spender) > 0` on + * the target. + */ + function _validateErc20Revocation( + address _target, + bytes calldata _callData, + address _delegator, + address _spender + ) + private + view + { + require(uint256(bytes32(_callData[36:68])) == 0, "ApprovalRevocationEnforcer:non-zero-amount"); + + require( + IERC20(_target).allowance(_delegator, _spender) != 0, "ApprovalRevocationEnforcer:no-approval-to-revoke" + ); + } + + /** + * @dev Validates an ERC-721 `approve(address(0), tokenId)` revocation. Requires `getApproved(tokenId)` on the + * target to be non-zero (i.e. an approval is currently set). + */ + function _validateErc721Revocation(address _target, bytes calldata _callData) private view { + uint256 tokenId_ = uint256(bytes32(_callData[36:68])); + + require( + IERC721(_target).getApproved(tokenId_) != address(0), "ApprovalRevocationEnforcer:no-approval-to-revoke" + ); + } + + /** + * @dev Validates a `setApprovalForAll(operator, false)` revocation (ERC-721 and ERC-1155 share this selector). + * Requires `isApprovedForAll(delegator, operator)` on the target to currently be true. + */ + function _validateOperatorRevocation(address _target, bytes calldata _callData, address _delegator) private view { + require(uint256(bytes32(_callData[36:68])) == 0, "ApprovalRevocationEnforcer:not-a-revocation"); + + address operator_ = address(uint160(uint256(bytes32(_callData[4:36])))); + require( + IERC721(_target).isApprovedForAll(_delegator, operator_), + "ApprovalRevocationEnforcer:no-approval-to-revoke" + ); + } +} diff --git a/test/enforcers/ApprovalRevocationEnforcer.t.sol b/test/enforcers/ApprovalRevocationEnforcer.t.sol new file mode 100644 index 00000000..e1483b90 --- /dev/null +++ b/test/enforcers/ApprovalRevocationEnforcer.t.sol @@ -0,0 +1,595 @@ +// SPDX-License-Identifier: MIT AND Apache-2.0 +pragma solidity 0.8.23; + +import "forge-std/Test.sol"; +import { ExecutionLib } from "@erc7579/lib/ExecutionLib.sol"; +import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import { IERC721 } from "@openzeppelin/contracts/token/ERC721/IERC721.sol"; +import { IERC1155 } from "@openzeppelin/contracts/token/ERC1155/IERC1155.sol"; + +import { Execution, Caveat, Delegation, ModeCode } from "../../src/utils/Types.sol"; +import { CaveatEnforcerBaseTest } from "./CaveatEnforcerBaseTest.t.sol"; +import { BasicERC20 } from "../utils/BasicERC20.t.sol"; +import { BasicCF721 } from "../utils/BasicCF721.t.sol"; +import { BasicERC1155 } from "../utils/BasicERC1155.t.sol"; +import { ICaveatEnforcer } from "../../src/interfaces/ICaveatEnforcer.sol"; +import { ApprovalRevocationEnforcer } from "../../src/enforcers/ApprovalRevocationEnforcer.sol"; +import { EncoderLib } from "../../src/libraries/EncoderLib.sol"; + +/** + * @title ApprovalRevocationEnforcer Test + */ +contract ApprovalRevocationEnforcerTest is CaveatEnforcerBaseTest { + ////////////////////////////// State ////////////////////////////// + + ApprovalRevocationEnforcer public enforcer; + BasicERC20 public erc20; + BasicCF721 public erc721; + BasicERC1155 public erc1155; + + address public delegator; + address public spender; + address public operator; + + uint256 public mintedTokenId; + + /// @dev Permission flag constants mirroring the contract. + uint8 internal constant PERMISSION_ERC20_APPROVE = 0x01; + uint8 internal constant PERMISSION_ERC721_APPROVE = 0x02; + uint8 internal constant PERMISSION_SET_APPROVAL_FOR_ALL = 0x04; + uint8 internal constant PERMISSION_ALL = 0x07; + + ////////////////////////////// Set up ////////////////////////////// + + function setUp() public override { + super.setUp(); + enforcer = new ApprovalRevocationEnforcer(); + vm.label(address(enforcer), "ApprovalRevocationEnforcer"); + + delegator = address(users.alice.deleGator); + spender = address(users.bob.deleGator); + operator = address(users.carol.deleGator); + + erc20 = new BasicERC20(delegator, "TestToken", "TT", 100 ether); + erc721 = new BasicCF721(delegator, "TestNFT", "TNFT", ""); + erc1155 = new BasicERC1155(delegator, "Test1155", "T1155", ""); + + vm.label(address(erc20), "BasicERC20"); + vm.label(address(erc721), "BasicCF721"); + vm.label(address(erc1155), "BasicERC1155"); + + // Mint an ERC-721 token to the delegator and approve it. + vm.startPrank(delegator); + erc721.mint(delegator); + mintedTokenId = 0; + erc721.approve(spender, mintedTokenId); + + // ERC-20 allowance. + erc20.approve(spender, 42 ether); + + // setApprovalForAll on both ERC-721 and ERC-1155. + erc721.setApprovalForAll(operator, true); + erc1155.setApprovalForAll(operator, true); + vm.stopPrank(); + } + + ////////////////////////////// Helpers ////////////////////////////// + + function _terms(uint8 _flags) internal pure returns (bytes memory) { + return abi.encodePacked(_flags); + } + + function _approveCallData(address _spender, uint256 _amount) internal pure returns (bytes memory) { + return abi.encodeWithSelector(IERC20.approve.selector, _spender, _amount); + } + + function _setApprovalForAllCallData(address _operator, bool _approved) internal pure returns (bytes memory) { + return abi.encodeWithSelector(IERC721.setApprovalForAll.selector, _operator, _approved); + } + + function _encodeSingle(address _target, uint256 _value, bytes memory _callData) internal pure returns (bytes memory) { + return ExecutionLib.encodeSingle(_target, _value, _callData); + } + + function _callBeforeHook(bytes memory _termsBytes, bytes memory _executionCallData) internal { + vm.prank(address(delegationManager)); + enforcer.beforeHook(_termsBytes, hex"", singleDefaultMode, _executionCallData, bytes32(0), delegator, address(0)); + } + + function _expectRevertBeforeHook(bytes memory _termsBytes, bytes memory _executionCallData, bytes memory _revertReason) internal { + vm.prank(address(delegationManager)); + vm.expectRevert(_revertReason); + enforcer.beforeHook(_termsBytes, hex"", singleDefaultMode, _executionCallData, bytes32(0), delegator, address(0)); + } + + ////////////////////////////// Terms decoding ////////////////////////////// + + function test_terms_revertOnEmptyTerms() public { + bytes memory executionCallData_ = _encodeSingle(address(erc20), 0, _approveCallData(spender, 0)); + _expectRevertBeforeHook(hex"", executionCallData_, "ApprovalRevocationEnforcer:invalid-terms-length"); + } + + function test_terms_revertOnWrongLength() public { + bytes memory executionCallData_ = _encodeSingle(address(erc20), 0, _approveCallData(spender, 0)); + _expectRevertBeforeHook(abi.encodePacked(uint16(0x0007)), executionCallData_, "ApprovalRevocationEnforcer:invalid-terms-length"); + } + + function test_terms_revertOnZeroMask() public { + bytes memory executionCallData_ = _encodeSingle(address(erc20), 0, _approveCallData(spender, 0)); + _expectRevertBeforeHook(_terms(0x00), executionCallData_, "ApprovalRevocationEnforcer:no-methods-allowed"); + } + + function test_terms_revertOnReservedBitSet_bit3() public { + bytes memory executionCallData_ = _encodeSingle(address(erc20), 0, _approveCallData(spender, 0)); + _expectRevertBeforeHook(_terms(0x08), executionCallData_, "ApprovalRevocationEnforcer:invalid-terms"); + } + + function test_terms_revertOnReservedBitSet_highBit() public { + bytes memory executionCallData_ = _encodeSingle(address(erc20), 0, _approveCallData(spender, 0)); + _expectRevertBeforeHook(_terms(0x80), executionCallData_, "ApprovalRevocationEnforcer:invalid-terms"); + } + + function test_terms_revertOnReservedBitSet_allBits() public { + bytes memory executionCallData_ = _encodeSingle(address(erc20), 0, _approveCallData(spender, 0)); + _expectRevertBeforeHook(_terms(0xFF), executionCallData_, "ApprovalRevocationEnforcer:invalid-terms"); + } + + ////////////////////////////// Per-flag gating ////////////////////////////// + + function test_terms_onlyErc20_allowsErc20() public { + bytes memory executionCallData_ = _encodeSingle(address(erc20), 0, _approveCallData(spender, 0)); + _callBeforeHook(_terms(PERMISSION_ERC20_APPROVE), executionCallData_); + } + + function test_terms_onlyErc20_blocksErc721Approve() public { + bytes memory executionCallData_ = _encodeSingle(address(erc721), 0, _approveCallData(address(0), mintedTokenId)); + _expectRevertBeforeHook(_terms(PERMISSION_ERC20_APPROVE), executionCallData_, "ApprovalRevocationEnforcer:erc721-approve-not-allowed"); + } + + function test_terms_onlyErc20_blocksSetApprovalForAll() public { + bytes memory executionCallData_ = _encodeSingle(address(erc721), 0, _setApprovalForAllCallData(operator, false)); + _expectRevertBeforeHook(_terms(PERMISSION_ERC20_APPROVE), executionCallData_, "ApprovalRevocationEnforcer:setApprovalForAll-not-allowed"); + } + + function test_terms_onlyErc721Approve_allowsErc721() public { + bytes memory executionCallData_ = _encodeSingle(address(erc721), 0, _approveCallData(address(0), mintedTokenId)); + _callBeforeHook(_terms(PERMISSION_ERC721_APPROVE), executionCallData_); + } + + function test_terms_onlyErc721Approve_blocksErc20() public { + bytes memory executionCallData_ = _encodeSingle(address(erc20), 0, _approveCallData(spender, 0)); + _expectRevertBeforeHook(_terms(PERMISSION_ERC721_APPROVE), executionCallData_, "ApprovalRevocationEnforcer:erc20-approve-not-allowed"); + } + + function test_terms_onlyErc721Approve_blocksSetApprovalForAll() public { + bytes memory executionCallData_ = _encodeSingle(address(erc721), 0, _setApprovalForAllCallData(operator, false)); + _expectRevertBeforeHook(_terms(PERMISSION_ERC721_APPROVE), executionCallData_, "ApprovalRevocationEnforcer:setApprovalForAll-not-allowed"); + } + + function test_terms_onlySetApprovalForAll_allowsErc721() public { + bytes memory executionCallData_ = _encodeSingle(address(erc721), 0, _setApprovalForAllCallData(operator, false)); + _callBeforeHook(_terms(PERMISSION_SET_APPROVAL_FOR_ALL), executionCallData_); + } + + function test_terms_onlySetApprovalForAll_allowsErc1155() public { + bytes memory executionCallData_ = _encodeSingle(address(erc1155), 0, _setApprovalForAllCallData(operator, false)); + _callBeforeHook(_terms(PERMISSION_SET_APPROVAL_FOR_ALL), executionCallData_); + } + + function test_terms_onlySetApprovalForAll_blocksErc20Approve() public { + bytes memory executionCallData_ = _encodeSingle(address(erc20), 0, _approveCallData(spender, 0)); + _expectRevertBeforeHook(_terms(PERMISSION_SET_APPROVAL_FOR_ALL), executionCallData_, "ApprovalRevocationEnforcer:erc20-approve-not-allowed"); + } + + function test_terms_onlySetApprovalForAll_blocksErc721Approve() public { + bytes memory executionCallData_ = _encodeSingle(address(erc721), 0, _approveCallData(address(0), mintedTokenId)); + _expectRevertBeforeHook(_terms(PERMISSION_SET_APPROVAL_FOR_ALL), executionCallData_, "ApprovalRevocationEnforcer:erc721-approve-not-allowed"); + } + + function test_terms_pair_erc20AndErc721Approve_blocksSetApprovalForAll() public { + uint8 flags_ = PERMISSION_ERC20_APPROVE | PERMISSION_ERC721_APPROVE; + // Both approve variants allowed. + _callBeforeHook(_terms(flags_), _encodeSingle(address(erc20), 0, _approveCallData(spender, 0))); + _callBeforeHook(_terms(flags_), _encodeSingle(address(erc721), 0, _approveCallData(address(0), mintedTokenId))); + // setApprovalForAll blocked. + _expectRevertBeforeHook(_terms(flags_), _encodeSingle(address(erc721), 0, _setApprovalForAllCallData(operator, false)), "ApprovalRevocationEnforcer:setApprovalForAll-not-allowed"); + } + + function test_terms_pair_erc20AndSetApprovalForAll_blocksErc721Approve() public { + uint8 flags_ = PERMISSION_ERC20_APPROVE | PERMISSION_SET_APPROVAL_FOR_ALL; + _callBeforeHook(_terms(flags_), _encodeSingle(address(erc20), 0, _approveCallData(spender, 0))); + _callBeforeHook(_terms(flags_), _encodeSingle(address(erc721), 0, _setApprovalForAllCallData(operator, false))); + _expectRevertBeforeHook(_terms(flags_), _encodeSingle(address(erc721), 0, _approveCallData(address(0), mintedTokenId)), "ApprovalRevocationEnforcer:erc721-approve-not-allowed"); + } + + ////////////////////////////// Valid cases (ERC-20 approve) ////////////////////////////// + + function test_erc20_revokeSucceedsForExistingAllowance() public { + bytes memory executionCallData_ = _encodeSingle(address(erc20), 0, _approveCallData(spender, 0)); + _callBeforeHook(_terms(PERMISSION_ALL), executionCallData_); + } + + function test_erc20_revokeSucceedsForOneWeiAllowance() public { + address other_ = address(users.dave.deleGator); + vm.prank(delegator); + erc20.approve(other_, 1); + bytes memory executionCallData_ = _encodeSingle(address(erc20), 0, _approveCallData(other_, 0)); + _callBeforeHook(_terms(PERMISSION_ALL), executionCallData_); + } + + ////////////////////////////// Invalid cases (ERC-20 approve) ////////////////////////////// + + function test_erc20_revertOnNonZeroAmount() public { + bytes memory executionCallData_ = _encodeSingle(address(erc20), 0, _approveCallData(spender, 1)); + _expectRevertBeforeHook(_terms(PERMISSION_ALL), executionCallData_, "ApprovalRevocationEnforcer:non-zero-amount"); + } + + function test_erc20_revertWhenNoApproval() public { + address other_ = address(users.dave.deleGator); + assertEq(erc20.allowance(delegator, other_), 0); + bytes memory executionCallData_ = _encodeSingle(address(erc20), 0, _approveCallData(other_, 0)); + _expectRevertBeforeHook(_terms(PERMISSION_ALL), executionCallData_, "ApprovalRevocationEnforcer:no-approval-to-revoke"); + } + + function test_erc20_revertWhenAllowanceCallFails() public { + // Target is a contract with no `allowance(address,address)` function; the high-level call reverts with + // empty returndata when ABI-decoding the (empty) response fails. + bytes memory executionCallData_ = _encodeSingle(address(enforcer), 0, _approveCallData(spender, 0)); + vm.prank(address(delegationManager)); + vm.expectRevert(); + enforcer.beforeHook(_terms(PERMISSION_ALL), hex"", singleDefaultMode, executionCallData_, bytes32(0), delegator, address(0)); + } + + ////////////////////////////// Valid cases (ERC-721 approve) ////////////////////////////// + + function test_erc721_revokeSucceedsForExistingApproval() public { + bytes memory executionCallData_ = _encodeSingle(address(erc721), 0, _approveCallData(address(0), mintedTokenId)); + _callBeforeHook(_terms(PERMISSION_ALL), executionCallData_); + } + + ////////////////////////////// Invalid cases (ERC-721 approve) ////////////////////////////// + + function test_erc721_revertWhenNoApproval() public { + // Mint a fresh token without approving it. + vm.prank(delegator); + erc721.mint(delegator); + uint256 freshTokenId_ = 1; + assertEq(erc721.getApproved(freshTokenId_), address(0)); + + bytes memory executionCallData_ = _encodeSingle(address(erc721), 0, _approveCallData(address(0), freshTokenId_)); + _expectRevertBeforeHook(_terms(PERMISSION_ALL), executionCallData_, "ApprovalRevocationEnforcer:no-approval-to-revoke"); + } + + function test_erc721_revertWhenGetApprovedCallFails() public { + // Non-existent token id reverts in OpenZeppelin's getApproved; the custom error bubbles up through the + // high-level call. + bytes memory executionCallData_ = _encodeSingle(address(erc721), 0, _approveCallData(address(0), 9999)); + vm.prank(address(delegationManager)); + vm.expectRevert(abi.encodeWithSignature("ERC721NonexistentToken(uint256)", 9999)); + enforcer.beforeHook(_terms(PERMISSION_ALL), hex"", singleDefaultMode, executionCallData_, bytes32(0), delegator, address(0)); + } + + ////////////////////////////// Valid cases (setApprovalForAll) ////////////////////////////// + + function test_setApprovalForAll_erc721_revokeSucceeds() public { + assertTrue(erc721.isApprovedForAll(delegator, operator)); + bytes memory executionCallData_ = _encodeSingle(address(erc721), 0, _setApprovalForAllCallData(operator, false)); + _callBeforeHook(_terms(PERMISSION_ALL), executionCallData_); + } + + function test_setApprovalForAll_erc1155_revokeSucceeds() public { + assertTrue(erc1155.isApprovedForAll(delegator, operator)); + bytes memory executionCallData_ = _encodeSingle(address(erc1155), 0, _setApprovalForAllCallData(operator, false)); + _callBeforeHook(_terms(PERMISSION_ALL), executionCallData_); + } + + ////////////////////////////// Invalid cases (setApprovalForAll) ////////////////////////////// + + function test_setApprovalForAll_revertWhenSettingTrue() public { + bytes memory executionCallData_ = _encodeSingle(address(erc721), 0, _setApprovalForAllCallData(operator, true)); + _expectRevertBeforeHook(_terms(PERMISSION_ALL), executionCallData_, "ApprovalRevocationEnforcer:not-a-revocation"); + } + + function test_setApprovalForAll_revertWhenNotApproved() public { + address other_ = address(users.dave.deleGator); + assertFalse(erc721.isApprovedForAll(delegator, other_)); + bytes memory executionCallData_ = _encodeSingle(address(erc721), 0, _setApprovalForAllCallData(other_, false)); + _expectRevertBeforeHook(_terms(PERMISSION_ALL), executionCallData_, "ApprovalRevocationEnforcer:no-approval-to-revoke"); + } + + function test_setApprovalForAll_revertWhenIsApprovedForAllCallFails() public { + // Target is a contract with no `isApprovedForAll(address,address)` function; the high-level call reverts + // with empty returndata when ABI-decoding the (empty) response fails. + bytes memory executionCallData_ = _encodeSingle(address(enforcer), 0, _setApprovalForAllCallData(operator, false)); + vm.prank(address(delegationManager)); + vm.expectRevert(); + enforcer.beforeHook(_terms(PERMISSION_ALL), hex"", singleDefaultMode, executionCallData_, bytes32(0), delegator, address(0)); + } + + ////////////////////////////// Generic invalid cases ////////////////////////////// + + function test_revertOnNonZeroValue() public { + bytes memory executionCallData_ = _encodeSingle(address(erc20), 1, _approveCallData(spender, 0)); + _expectRevertBeforeHook(_terms(PERMISSION_ALL), executionCallData_, "ApprovalRevocationEnforcer:invalid-value"); + } + + function test_revertOnInvalidExecutionLengthShort() public { + bytes memory shortCallData_ = abi.encodePacked(IERC20.approve.selector, bytes32(uint256(uint160(spender)))); + bytes memory executionCallData_ = _encodeSingle(address(erc20), 0, shortCallData_); + _expectRevertBeforeHook(_terms(PERMISSION_ALL), executionCallData_, "ApprovalRevocationEnforcer:invalid-execution-length"); + } + + function test_revertOnInvalidExecutionLengthLong() public { + bytes memory longCallData_ = abi.encodePacked(_approveCallData(spender, 0), bytes1(0x00)); + bytes memory executionCallData_ = _encodeSingle(address(erc20), 0, longCallData_); + _expectRevertBeforeHook(_terms(PERMISSION_ALL), executionCallData_, "ApprovalRevocationEnforcer:invalid-execution-length"); + } + + function test_revertOnInvalidMethod() public { + bytes memory wrongMethodCallData_ = abi.encodeWithSelector(IERC20.transfer.selector, spender, uint256(0)); + bytes memory executionCallData_ = _encodeSingle(address(erc20), 0, wrongMethodCallData_); + _expectRevertBeforeHook(_terms(PERMISSION_ALL), executionCallData_, "ApprovalRevocationEnforcer:invalid-method"); + } + + function test_revertWithInvalidCallTypeMode() public { + bytes memory executionCallData_ = ExecutionLib.encodeBatch(new Execution[](2)); + vm.expectRevert("CaveatEnforcer:invalid-call-type"); + enforcer.beforeHook(_terms(PERMISSION_ALL), hex"", batchDefaultMode, executionCallData_, bytes32(0), delegator, address(0)); + } + + function test_revertWithInvalidExecutionMode() public { + vm.expectRevert("CaveatEnforcer:invalid-execution-type"); + enforcer.beforeHook(_terms(PERMISSION_ALL), hex"", singleTryMode, hex"", bytes32(0), delegator, address(0)); + } + + ////////////////////////////// Integration ////////////////////////////// + + function test_integration_revokesErc20Allowance() public { + assertEq(erc20.allowance(delegator, spender), 42 ether); + + Execution memory execution_ = + Execution({ target: address(erc20), value: 0, callData: _approveCallData(spender, 0) }); + + Caveat[] memory caveats_ = new Caveat[](1); + caveats_[0] = Caveat({ args: hex"", enforcer: address(enforcer), terms: _terms(PERMISSION_ALL) }); + Delegation memory delegation_ = Delegation({ + delegate: address(users.bob.deleGator), + delegator: delegator, + authority: ROOT_AUTHORITY, + caveats: caveats_, + salt: 0, + signature: hex"" + }); + delegation_ = signDelegation(users.alice, delegation_); + + Delegation[] memory delegations_ = new Delegation[](1); + delegations_[0] = delegation_; + + invokeDelegation_UserOp(users.bob, delegations_, execution_); + assertEq(erc20.allowance(delegator, spender), 0); + } + + function test_integration_revokesErc721Approval() public { + assertEq(erc721.getApproved(mintedTokenId), spender); + + Execution memory execution_ = + Execution({ target: address(erc721), value: 0, callData: _approveCallData(address(0), mintedTokenId) }); + + Caveat[] memory caveats_ = new Caveat[](1); + caveats_[0] = Caveat({ args: hex"", enforcer: address(enforcer), terms: _terms(PERMISSION_ALL) }); + Delegation memory delegation_ = Delegation({ + delegate: address(users.bob.deleGator), + delegator: delegator, + authority: ROOT_AUTHORITY, + caveats: caveats_, + salt: 0, + signature: hex"" + }); + delegation_ = signDelegation(users.alice, delegation_); + + Delegation[] memory delegations_ = new Delegation[](1); + delegations_[0] = delegation_; + + invokeDelegation_UserOp(users.bob, delegations_, execution_); + assertEq(erc721.getApproved(mintedTokenId), address(0)); + } + + function test_integration_revokesSetApprovalForAll() public { + assertTrue(erc1155.isApprovedForAll(delegator, operator)); + + Execution memory execution_ = + Execution({ target: address(erc1155), value: 0, callData: _setApprovalForAllCallData(operator, false) }); + + Caveat[] memory caveats_ = new Caveat[](1); + caveats_[0] = Caveat({ args: hex"", enforcer: address(enforcer), terms: _terms(PERMISSION_ALL) }); + Delegation memory delegation_ = Delegation({ + delegate: address(users.bob.deleGator), + delegator: delegator, + authority: ROOT_AUTHORITY, + caveats: caveats_, + salt: 0, + signature: hex"" + }); + delegation_ = signDelegation(users.alice, delegation_); + + Delegation[] memory delegations_ = new Delegation[](1); + delegations_[0] = delegation_; + + invokeDelegation_UserOp(users.bob, delegations_, execution_); + assertFalse(erc1155.isApprovedForAll(delegator, operator)); + } + + function test_integration_onlyErc20_revokesErc20AllowanceAndBlocksOtherPrimitives() public { + assertEq(erc20.allowance(delegator, spender), 42 ether); + + Caveat[] memory caveats_ = new Caveat[](1); + caveats_[0] = Caveat({ args: hex"", enforcer: address(enforcer), terms: _terms(PERMISSION_ERC20_APPROVE) }); + Delegation memory delegation_ = Delegation({ + delegate: address(users.bob.deleGator), + delegator: delegator, + authority: ROOT_AUTHORITY, + caveats: caveats_, + salt: 0, + signature: hex"" + }); + delegation_ = signDelegation(users.alice, delegation_); + + Delegation[] memory delegations_ = new Delegation[](1); + delegations_[0] = delegation_; + + // ERC-20 revocation succeeds. + Execution memory erc20Execution_ = Execution({ target: address(erc20), value: 0, callData: _approveCallData(spender, 0) }); + invokeDelegation_UserOp(users.bob, delegations_, erc20Execution_); + assertEq(erc20.allowance(delegator, spender), 0); + + // ERC-721 approve revocation is blocked (UserOp swallows revert; approval unchanged). + Execution memory erc721Execution_ = Execution({ target: address(erc721), value: 0, callData: _approveCallData(address(0), mintedTokenId) }); + invokeDelegation_UserOp(users.bob, delegations_, erc721Execution_); + assertEq(erc721.getApproved(mintedTokenId), spender); + } + + ////////////////////////////// Redelegation ////////////////////////////// + + /** + * @notice Alice -> Bob -> Carol, with the `ApprovalRevocationEnforcer` caveat on Alice's (root) link. Carol + * redeems. The caveat's `beforeHook` receives `_delegator = Alice`, matching the account whose approval is + * actually cleared at execution time. Works end-to-end. + */ + function test_integration_redelegation_caveatOnRootLink_revokesRootAllowance() public { + assertEq(erc20.allowance(delegator, spender), 42 ether); + + Caveat[] memory caveats_ = new Caveat[](1); + caveats_[0] = Caveat({ args: hex"", enforcer: address(enforcer), terms: _terms(PERMISSION_ALL) }); + Delegation memory aliceDelegation_ = Delegation({ + delegate: address(users.bob.deleGator), + delegator: delegator, + authority: ROOT_AUTHORITY, + caveats: caveats_, + salt: 0, + signature: hex"" + }); + aliceDelegation_ = signDelegation(users.alice, aliceDelegation_); + bytes32 aliceDelegationHash_ = EncoderLib._getDelegationHash(aliceDelegation_); + + Delegation memory bobDelegation_ = Delegation({ + delegate: address(users.carol.deleGator), + delegator: address(users.bob.deleGator), + authority: aliceDelegationHash_, + caveats: new Caveat[](0), + salt: 0, + signature: hex"" + }); + bobDelegation_ = signDelegation(users.bob, bobDelegation_); + + Delegation[] memory delegations_ = new Delegation[](2); + delegations_[0] = bobDelegation_; + delegations_[1] = aliceDelegation_; + + Execution memory execution_ = + Execution({ target: address(erc20), value: 0, callData: _approveCallData(spender, 0) }); + + invokeDelegation_UserOp(users.carol, delegations_, execution_); + assertEq(erc20.allowance(delegator, spender), 0); + } + + /** + * @notice Alice -> Bob -> Carol, with the caveat on Bob's (intermediate) link. The `beforeHook` runs with + * `_delegator = Bob`, so the pre-check queries `allowance(Bob, spender)`. Bob has no such allowance, so the + * hook reverts even though Alice (the root, whose account actually runs `approve`) does have one. + * + * @dev This test pins down a subtlety of redelegation semantics: caveats are evaluated against the delegator + * of their own link, not the root of the chain. For this enforcer it means an intermediate-link caveat + * checks the *intermediate* delegator's approval state, which is almost never what the delegator intends. + */ + function test_integration_redelegation_caveatOnIntermediateLink_revertsWhenIntermediateHasNoApproval() public { + assertEq(erc20.allowance(delegator, spender), 42 ether); + assertEq(erc20.allowance(address(users.bob.deleGator), spender), 0); + + Delegation memory aliceDelegation_ = Delegation({ + delegate: address(users.bob.deleGator), + delegator: delegator, + authority: ROOT_AUTHORITY, + caveats: new Caveat[](0), + salt: 0, + signature: hex"" + }); + aliceDelegation_ = signDelegation(users.alice, aliceDelegation_); + bytes32 aliceDelegationHash_ = EncoderLib._getDelegationHash(aliceDelegation_); + + Caveat[] memory caveats_ = new Caveat[](1); + caveats_[0] = Caveat({ args: hex"", enforcer: address(enforcer), terms: _terms(PERMISSION_ALL) }); + Delegation memory bobDelegation_ = Delegation({ + delegate: address(users.carol.deleGator), + delegator: address(users.bob.deleGator), + authority: aliceDelegationHash_, + caveats: caveats_, + salt: 0, + signature: hex"" + }); + bobDelegation_ = signDelegation(users.bob, bobDelegation_); + + Delegation[] memory delegations_ = new Delegation[](2); + delegations_[0] = bobDelegation_; + delegations_[1] = aliceDelegation_; + + Execution memory execution_ = + Execution({ target: address(erc20), value: 0, callData: _approveCallData(spender, 0) }); + + // UserOp swallows the enforcer revert; the effect is that the approval is NOT cleared. + invokeDelegation_UserOp(users.carol, delegations_, execution_); + assertEq(erc20.allowance(delegator, spender), 42 ether); + } + + /** + * @notice Unit-level check on the link-local `_delegator` semantics. The hook queries the external token + * using whatever address is passed as `_delegator`; it does not reach back into the chain to find the root. + */ + function test_unit_beforeHook_usesPassedDelegatorNotRoot() public { + address intermediate_ = address(users.bob.deleGator); + assertEq(erc20.allowance(intermediate_, spender), 0); + + bytes memory executionCallData_ = _encodeSingle(address(erc20), 0, _approveCallData(spender, 0)); + + vm.prank(address(delegationManager)); + vm.expectRevert("ApprovalRevocationEnforcer:no-approval-to-revoke"); + enforcer.beforeHook(_terms(PERMISSION_ALL), hex"", singleDefaultMode, executionCallData_, bytes32(0), intermediate_, address(0)); + + // And once Bob has an allowance of his own, the pre-check passes against Bob's state (regardless of who + // would actually execute the call). + vm.prank(intermediate_); + erc20.approve(spender, 1); + vm.prank(address(delegationManager)); + enforcer.beforeHook(_terms(PERMISSION_ALL), hex"", singleDefaultMode, executionCallData_, bytes32(0), intermediate_, address(0)); + } + + ////////////////////////////// Additional coverage ////////////////////////////// + + /** + * @notice `approve(non-zero, 0)` targeting an ERC-721 contract routes to the ERC-20 branch (because the + * first parameter is non-zero). The pre-check calls `allowance(delegator, spender)` on the ERC-721, which + * does not implement it and therefore reverts (empty returndata after ABI-decode). + */ + function test_crossStandard_erc721TargetWithErc20Style_reverts() public { + bytes memory executionCallData_ = _encodeSingle(address(erc721), 0, _approveCallData(spender, 0)); + vm.prank(address(delegationManager)); + vm.expectRevert(); + enforcer.beforeHook(_terms(PERMISSION_ALL), hex"", singleDefaultMode, executionCallData_, bytes32(0), delegator, address(0)); + } + + /** + * @notice `approve(address(0), 0)` on an ERC-20 is routed to the ERC-721 branch by the `firstParam == 0` + * heuristic. The branch then calls `getApproved(0)` on the target. Standard ERC-20s do not implement + * `getApproved`, so the pre-check reverts. Pins the behavior of this edge case so future refactors don't + * silently change routing. + */ + function test_edgeCase_approveAddressZeroAmountZeroOnErc20_reverts() public { + bytes memory executionCallData_ = _encodeSingle(address(erc20), 0, _approveCallData(address(0), 0)); + vm.prank(address(delegationManager)); + vm.expectRevert(); + enforcer.beforeHook(_terms(PERMISSION_ALL), hex"", singleDefaultMode, executionCallData_, bytes32(0), delegator, address(0)); + } + + function _getEnforcer() internal view override returns (ICaveatEnforcer) { + return ICaveatEnforcer(address(enforcer)); + } +}