diff --git a/documents/CaveatEnforcers.md b/documents/CaveatEnforcers.md index f7a57fb9..ac8c8dab 100644 --- a/documents/CaveatEnforcers.md +++ b/documents/CaveatEnforcers.md @@ -25,6 +25,68 @@ Enforcers can target specific call type modes: **single** or **batch**, and exec --- +### MetaSwapFlexibleSettlementEnforcer + +MetaSwap-router caveat for **one** native or ERC-20 swap (optional `approve` / reset-approve). The redeemer picks +the route; signed terms fix target, input, approval shape, output, recipient, min output, redeemer allowlist, an +optional time window, and a required order id. + +**Embedded behaviors** + +| Concern | Behavior | +| ---------- | ------------------------------------------------------------------------------------- | +| Settlement | Exact `MetaSwap.swap` ± approval legs (`BATCH_DEFAULT_MODE`) | +| Min output | ERC-20 or native balance increase ≥ `tokenOutMin` (`tokenOut == address(0)` → native) | +| Redeemer | Packed allowlist (required, ≥1 address) | +| Timestamp | Optional; `0` disables a bound (non-inclusive, like `TimestampEnforcer`) | +| Id | Required, non-zero. Bitmap per `(manager, delegator)`; a repeated id reverts | + +**Shapes** + +- Native: `swap{ value: tokenInAmount }(...)` +- ERC-20: `swap` only / `approve(amount)+swap` / `approve(0)+approve(amount)+swap` + +**Terms** + +```text +metaSwap(20) | tokenIn(20) | tokenInAmount(32) | approvalMode(1) | +tokenOut(20) | recipient(20) | tokenOutMin(32) | +timestampAfter(16) | timestampBefore(16) | id(32) | redeemers(20*N) +``` + +`ApprovalMode`: `None=0` (native only), `SkipApproval=1`, `Approve=2`, `ResetApprove=3`. Execution count must match. +`SkipApproval` does not read allowance. Args are unused. + +**Policies** + +- Timestamp: both `0` → no window; otherwise same rules as `TimestampEnforcer` +- Id: required and non-zero. One bitmap per `(manager, delegator)` blocks a replay and any other order that shares + the id. A failed fill rolls the bit back. + +```solidity +bytes memory terms = abi.encodePacked( + metaSwap, tokenIn, tokenInAmount, + uint8(MetaSwapFlexibleSettlementEnforcer.ApprovalMode.Approve), + tokenOut, recipient, tokenOutMin, + uint128(0), uint128(0), // no timestamp + uint256(42), // required order id + redeemer +); +``` + +Approve spender / swap token words must be canonical ABI addresses. Swap calldata ≥ 196 bytes +(`swap(string,address,uint256,bytes)` head). + +#### Trust Assumptions + +Route `aggregatorId` / `data` are unrestricted — trust MetaSwap, adapters, and the redeemer. Min output can be met by +any balance increase (unrelated transfers, rebases, bad token accounting). Residual allowance may remain if MetaSwap +spends less than approved. Input and output must differ. + +Deploy: `script/DeployCaveatEnforcers.s.sol`. Verify: `script/verification/verify-enforcer-contracts.sh`. + +--- + ## Enforcer Details ### NativeTokenPaymentEnforcer @@ -57,7 +119,6 @@ Balance Change Enforcers allow setting up guardrails around balance changes for 2. **Hash Key Generation**: The hash key is generated using the delegation manager address and delegation hash (plus token address and token ID for ERC1155), ensuring each delegation has its own isolated state. 3. **Balance Caching**: In `beforeHook`, the enforcer: - - Checks that the enforcer isn't already locked for this delegation - Locks the enforcer to prevent concurrent access - Caches the current balance of the recipient @@ -191,15 +252,15 @@ The Permit2 branches are restricted to the canonical deployment at `0x0000000000 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)` | +| 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 | `0x08` | Permit2 `approve(token, spender, 0, 0)` | -| 4 | `0x10` | Permit2 `lockdown((address,address)[])` | -| 5 | `0x20` | Permit2 `invalidateNonces(token, spender, newNonce)` | -| 6–7 | — | Reserved; MUST be zero | +| 3 | `0x08` | Permit2 `approve(token, spender, 0, 0)` | +| 4 | `0x10` | Permit2 `lockdown((address,address)[])` | +| 5 | `0x20` | Permit2 `invalidateNonces(token, spender, newNonce)` | +| 6–7 | — | Reserved; MUST be zero | - Terms MUST be exactly 1 byte. - A zero mask (`0x00`) is rejected — at least one primitive must be permitted. @@ -223,11 +284,11 @@ terms = 0x3F → all six primitives allowed The three Permit2 primitives target different parts of Permit2's state, and **none of them subsumes the others**: -| Primitive | Zeros `amount`? | Resets `expiration`? | Bumps `nonce`? | Invalidates pending signed permits? | -|-----------------------|-----------------|---------------------------------|----------------|-------------------------------------| +| Primitive | Zeros `amount`? | Resets `expiration`? | Bumps `nonce`? | Invalidates pending signed permits? | +| --------------------- | --------------- | ------------------------------ | -------------- | ----------------------------------- | | `approve(_,_,0,0)` | yes | yes (set to `block.timestamp`) | no | no | -| `lockdown(pairs)` | yes | no | no | no | -| `invalidateNonces(…)` | no | no | yes | yes | +| `lockdown(pairs)` | yes | no | no | no | +| `invalidateNonces(…)` | no | no | yes | yes | 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. @@ -262,7 +323,7 @@ The three Permit2 branches intentionally **omit** this on-chain liveness pre-che 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: - if the address is empty, the executor's call returns successfully with no effect (harmless no-op); -- 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. +- 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. Delegators on chains without canonical Permit2 should NOT enable bits 3, 4, or 5. diff --git a/script/DeployCaveatEnforcers.s.sol b/script/DeployCaveatEnforcers.s.sol index 629da134..babcdb04 100644 --- a/script/DeployCaveatEnforcers.s.sol +++ b/script/DeployCaveatEnforcers.s.sol @@ -26,6 +26,7 @@ import { ExactExecutionEnforcer } from "../src/enforcers/ExactExecutionEnforcer. import { IdEnforcer } from "../src/enforcers/IdEnforcer.sol"; import { LimitedCallsEnforcer } from "../src/enforcers/LimitedCallsEnforcer.sol"; import { LogicalOrWrapperEnforcer } from "../src/enforcers/LogicalOrWrapperEnforcer.sol"; +import { MetaSwapFlexibleSettlementEnforcer } from "../src/enforcers/MetaSwapFlexibleSettlementEnforcer.sol"; import { MultiTokenPeriodEnforcer } from "../src/enforcers/MultiTokenPeriodEnforcer.sol"; import { NativeBalanceChangeEnforcer } from "../src/enforcers/NativeBalanceChangeEnforcer.sol"; import { NativeTokenPaymentEnforcer } from "../src/enforcers/NativeTokenPaymentEnforcer.sol"; @@ -133,6 +134,9 @@ contract DeployCaveatEnforcers is Script { deployedAddress = address(new LogicalOrWrapperEnforcer{ salt: salt }(delegationManager)); console2.log("LogicalOrWrapperEnforcer: %s", deployedAddress); + deployedAddress = address(new MetaSwapFlexibleSettlementEnforcer{ salt: salt }()); + console2.log("MetaSwapFlexibleSettlementEnforcer: %s", deployedAddress); + deployedAddress = address(new MultiTokenPeriodEnforcer{ salt: salt }()); console2.log("MultiTokenPeriodEnforcer: %s", deployedAddress); diff --git a/src/enforcers/MetaSwapFlexibleSettlementEnforcer.sol b/src/enforcers/MetaSwapFlexibleSettlementEnforcer.sol new file mode 100644 index 00000000..4616a363 --- /dev/null +++ b/src/enforcers/MetaSwapFlexibleSettlementEnforcer.sol @@ -0,0 +1,289 @@ +// SPDX-License-Identifier: MIT AND Apache-2.0 +pragma solidity 0.8.23; + +import { BitMaps } from "@openzeppelin/contracts/utils/structs/BitMaps.sol"; +import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import { ExecutionLib } from "@erc7579/lib/ExecutionLib.sol"; + +import { CaveatEnforcer } from "./CaveatEnforcer.sol"; +import { IMetaSwap } from "../helpers/interfaces/IMetaSwap.sol"; +import { Execution, ModeCode } from "../utils/Types.sol"; + +/** + * @title MetaSwapFlexibleSettlementEnforcer + * @notice One MetaSwap router settlement (native/ERC-20, optional approval) with a minimum output. + * @dev Single caveat combining: + * - MetaSwap batch shape validation (swap ± approve / reset-approve) + * - ERC20 / native min balance increase (`tokenOut == address(0)` → native) + * - Redeemer allowlist (required) + * - Timestamp window (optional: `0` disables a bound) + * - Id bitmap (required, non-zero). Reusing an id reverts. + * + * Shapes: + * - Native: `[swap{ value: tokenInAmount }(...)]` + * - ERC-20 skip approval: `[swap(...)]` + * - ERC-20 approve: `[approve(metaSwap, tokenInAmount), swap(...)]` + * - ERC-20 reset: `[approve(metaSwap, 0), approve(metaSwap, tokenInAmount), swap(...)]` + * + * `aggregatorId` / route `data` stay unrestricted — trust MetaSwap and its adapters. + */ +contract MetaSwapFlexibleSettlementEnforcer is CaveatEnforcer { + using BitMaps for BitMaps.BitMap; + using ExecutionLib for bytes; + + enum ApprovalMode { + None, + SkipApproval, + Approve, + ResetApprove + } + + struct Terms { + address metaSwap; + address tokenIn; + uint256 tokenInAmount; + ApprovalMode approvalMode; + address tokenOut; + address recipient; + uint256 tokenOutMin; + uint128 timestampAfter; + uint128 timestampBefore; + uint256 id; + address[] redeemers; + } + + /// @dev Settlement (145) + timestampAfter (16) + timestampBefore (16) + id (32). + uint256 private constant FIXED_TERMS_LENGTH = 209; + uint256 private constant APPROVE_CALL_LENGTH = 68; + // Selector + four-word head + two dynamic length words. + uint256 private constant SWAP_CALL_MIN_LENGTH = 196; + + /// @dev One-shot and mutual exclusion. Keyed by the delegation manager and delegator. + mapping(address delegationManager => mapping(address delegator => BitMaps.BitMap id)) private isUsedId; + + /// @dev Recipient output balance between before/after hooks. + mapping(bytes32 settlementKey => uint256 balanceBefore) private balanceSnapshots; + + event SettlementConsumed( + address indexed delegationManager, bytes32 indexed delegationHash, address indexed redeemer, uint256 id + ); + + event UsedId(address indexed sender, address indexed delegator, address indexed redeemer, uint256 id); + + function getSettlementKey(address delegationManager_, bytes32 delegationHash_) external pure returns (bytes32) { + return _getSettlementKey(delegationManager_, delegationHash_); + } + + function getIsUsed(address delegationManager_, address delegator_, uint256 id_) external view returns (bool) { + return isUsedId[delegationManager_][delegator_].get(id_); + } + + /** + * @notice Validates policies and batch shape, locks one-shot state, and snapshots output balance. + */ + function beforeHook( + bytes calldata terms_, + bytes calldata, + ModeCode mode_, + bytes calldata executionCallData_, + bytes32 delegationHash_, + address delegator_, + address redeemer_ + ) + public + override + onlyBatchCallTypeMode(mode_) + onlyDefaultExecutionMode(mode_) + { + Terms memory termsInfo_ = getTermsInfo(terms_); + _validateRedeemer(termsInfo_.redeemers, redeemer_); + _validateTimestamp(termsInfo_.timestampAfter, termsInfo_.timestampBefore); + _validateExecutions(executionCallData_.decodeBatch(), termsInfo_); + _consume(termsInfo_.id, delegator_, redeemer_); + + balanceSnapshots[_getSettlementKey(msg.sender, delegationHash_)] = _balanceOf(termsInfo_.tokenOut, termsInfo_.recipient); + } + + /** + * @notice Requires the recipient's output balance to have increased by at least `tokenOutMin`. + */ + function afterHook( + bytes calldata terms_, + bytes calldata, + ModeCode, + bytes calldata, + bytes32 delegationHash_, + address, + address redeemer_ + ) + public + override + { + _requireValidTermsLength(terms_.length); + + bytes32 settlementKey_ = _getSettlementKey(msg.sender, delegationHash_); + address tokenOut_ = address(bytes20(terms_[73:93])); + address recipient_ = address(bytes20(terms_[93:113])); + uint256 tokenOutMin_ = uint256(bytes32(terms_[113:145])); + uint256 id_ = uint256(bytes32(terms_[177:209])); + uint256 balanceBefore_ = balanceSnapshots[settlementKey_]; + delete balanceSnapshots[settlementKey_]; + + uint256 balanceAfter_ = _balanceOf(tokenOut_, recipient_); + require( + balanceAfter_ >= balanceBefore_ && balanceAfter_ - balanceBefore_ >= tokenOutMin_, + "MetaSwapFlexibleSettlementEnforcer:insufficient-output" + ); + + emit SettlementConsumed(msg.sender, delegationHash_, redeemer_, id_); + } + + /** + * @notice Decodes packed terms: + * `metaSwap(20) | tokenIn(20) | tokenInAmount(32) | approvalMode(1) | tokenOut(20) | recipient(20) | + * tokenOutMin(32) | timestampAfter(16) | timestampBefore(16) | id(32) | redeemers(20*N)`. + */ + function getTermsInfo(bytes calldata terms_) public pure returns (Terms memory termsInfo_) { + _requireValidTermsLength(terms_.length); + + termsInfo_.metaSwap = address(bytes20(terms_[0:20])); + termsInfo_.tokenIn = address(bytes20(terms_[20:40])); + termsInfo_.tokenInAmount = uint256(bytes32(terms_[40:72])); + uint8 approvalMode_ = uint8(terms_[72]); + termsInfo_.tokenOut = address(bytes20(terms_[73:93])); + termsInfo_.recipient = address(bytes20(terms_[93:113])); + termsInfo_.tokenOutMin = uint256(bytes32(terms_[113:145])); + termsInfo_.timestampAfter = uint128(bytes16(terms_[145:161])); + termsInfo_.timestampBefore = uint128(bytes16(terms_[161:177])); + termsInfo_.id = uint256(bytes32(terms_[177:209])); + + require( + termsInfo_.metaSwap != address(0) && termsInfo_.tokenInAmount != 0 && termsInfo_.recipient != address(0) + && termsInfo_.tokenOutMin != 0 && termsInfo_.tokenIn != termsInfo_.tokenOut && termsInfo_.id != 0, + "MetaSwapFlexibleSettlementEnforcer:invalid-terms" + ); + + // Native input requires None; ERC-20 input requires a non-None mode. + if (termsInfo_.tokenIn == address(0)) { + require(approvalMode_ == uint8(ApprovalMode.None), "MetaSwapFlexibleSettlementEnforcer:invalid-approval-mode"); + } else { + require( + approvalMode_ > uint8(ApprovalMode.None) && approvalMode_ <= uint8(ApprovalMode.ResetApprove), + "MetaSwapFlexibleSettlementEnforcer:invalid-approval-mode" + ); + } + termsInfo_.approvalMode = ApprovalMode(approvalMode_); + + uint256 redeemerCount_ = (terms_.length - FIXED_TERMS_LENGTH) / 20; + termsInfo_.redeemers = new address[](redeemerCount_); + for (uint256 i_; i_ < redeemerCount_; ++i_) { + uint256 offset_ = FIXED_TERMS_LENGTH + (i_ * 20); + termsInfo_.redeemers[i_] = address(bytes20(terms_[offset_:offset_ + 20])); + } + } + + function _requireValidTermsLength(uint256 length_) private pure { + require(length_ >= FIXED_TERMS_LENGTH + 20, "MetaSwapFlexibleSettlementEnforcer:invalid-terms"); + require((length_ - FIXED_TERMS_LENGTH) % 20 == 0, "MetaSwapFlexibleSettlementEnforcer:invalid-terms"); + } + + function _validateRedeemer(address[] memory redeemers_, address redeemer_) private pure { + uint256 length_ = redeemers_.length; + for (uint256 i_; i_ < length_; ++i_) { + if (redeemer_ == redeemers_[i_]) return; + } + revert("MetaSwapFlexibleSettlementEnforcer:unauthorized-redeemer"); + } + + function _validateTimestamp(uint128 timestampAfter_, uint128 timestampBefore_) private view { + if (timestampAfter_ > 0) { + require(block.timestamp > timestampAfter_, "MetaSwapFlexibleSettlementEnforcer:early-delegation"); + } + if (timestampBefore_ > 0) { + require(block.timestamp < timestampBefore_, "MetaSwapFlexibleSettlementEnforcer:expired-delegation"); + } + } + + /** + * @dev One-shot keyed by `(manager, delegator, id)`. A replay uses the same id, and so does a sibling + * order that shares it, so both revert. + */ + function _consume(uint256 id_, address delegator_, address redeemer_) private { + require(!isUsedId[msg.sender][delegator_].get(id_), "MetaSwapFlexibleSettlementEnforcer:id-already-used"); + isUsedId[msg.sender][delegator_].set(id_); + emit UsedId(msg.sender, delegator_, redeemer_, id_); + } + + function _validateExecutions(Execution[] calldata executions_, Terms memory termsInfo_) private pure { + if (termsInfo_.tokenIn == address(0)) { + require(executions_.length == 1, "MetaSwapFlexibleSettlementEnforcer:invalid-batch-length"); + _validateSwap(executions_[0], termsInfo_.metaSwap, address(0), termsInfo_.tokenInAmount, termsInfo_.tokenInAmount); + return; + } + + ApprovalMode approvalMode_ = termsInfo_.approvalMode; + if (approvalMode_ == ApprovalMode.SkipApproval) { + require(executions_.length == 1, "MetaSwapFlexibleSettlementEnforcer:approval-shape-not-allowed"); + _validateSwap(executions_[0], termsInfo_.metaSwap, termsInfo_.tokenIn, termsInfo_.tokenInAmount, 0); + } else if (approvalMode_ == ApprovalMode.Approve) { + require(executions_.length == 2, "MetaSwapFlexibleSettlementEnforcer:approval-shape-not-allowed"); + _validateApproval(executions_[0], termsInfo_.tokenIn, termsInfo_.metaSwap, termsInfo_.tokenInAmount); + _validateSwap(executions_[1], termsInfo_.metaSwap, termsInfo_.tokenIn, termsInfo_.tokenInAmount, 0); + } else { + // ResetApprove — only remaining ERC-20 mode after getTermsInfo. + require(executions_.length == 3, "MetaSwapFlexibleSettlementEnforcer:approval-shape-not-allowed"); + _validateApproval(executions_[0], termsInfo_.tokenIn, termsInfo_.metaSwap, 0); + _validateApproval(executions_[1], termsInfo_.tokenIn, termsInfo_.metaSwap, termsInfo_.tokenInAmount); + _validateSwap(executions_[2], termsInfo_.metaSwap, termsInfo_.tokenIn, termsInfo_.tokenInAmount, 0); + } + } + + function _validateApproval( + Execution calldata execution_, + address tokenIn_, + address metaSwap_, + uint256 expectedAmount_ + ) + private + pure + { + bytes calldata callData_ = execution_.callData; + if ( + execution_.target != tokenIn_ || execution_.value != 0 || callData_.length != APPROVE_CALL_LENGTH + || bytes4(callData_[0:4]) != IERC20.approve.selector + || bytes32(callData_[4:36]) != bytes32(uint256(uint160(metaSwap_))) + || uint256(bytes32(callData_[36:68])) != expectedAmount_ + ) { + revert("MetaSwapFlexibleSettlementEnforcer:invalid-approval"); + } + } + + function _validateSwap( + Execution calldata execution_, + address metaSwap_, + address tokenIn_, + uint256 tokenInAmount_, + uint256 expectedValue_ + ) + private + pure + { + bytes calldata callData_ = execution_.callData; + if ( + execution_.target != metaSwap_ || execution_.value != expectedValue_ || callData_.length < SWAP_CALL_MIN_LENGTH + || bytes4(callData_[0:4]) != IMetaSwap.swap.selector + || bytes32(callData_[36:68]) != bytes32(uint256(uint160(tokenIn_))) + || uint256(bytes32(callData_[68:100])) != tokenInAmount_ + ) { + revert("MetaSwapFlexibleSettlementEnforcer:invalid-swap"); + } + } + + function _balanceOf(address token_, address recipient_) private view returns (uint256) { + return token_ == address(0) ? recipient_.balance : IERC20(token_).balanceOf(recipient_); + } + + function _getSettlementKey(address delegationManager_, bytes32 delegationHash_) private pure returns (bytes32) { + return keccak256(abi.encode(delegationManager_, delegationHash_)); + } +} diff --git a/test/enforcers/MetaSwapFlexibleSettlementEnforcer.t.sol b/test/enforcers/MetaSwapFlexibleSettlementEnforcer.t.sol new file mode 100644 index 00000000..770b9958 --- /dev/null +++ b/test/enforcers/MetaSwapFlexibleSettlementEnforcer.t.sol @@ -0,0 +1,988 @@ +// SPDX-License-Identifier: MIT AND Apache-2.0 +pragma solidity 0.8.23; + +import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import { SafeERC20 } from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +import { ExecutionLib } from "@erc7579/lib/ExecutionLib.sol"; +import { ModeLib } from "@erc7579/lib/ModeLib.sol"; + +import { CaveatEnforcerBaseTest } from "./CaveatEnforcerBaseTest.t.sol"; +import { BasicERC20 } from "../utils/BasicERC20.t.sol"; +import { DelegationManager } from "../../src/DelegationManager.sol"; +import { MetaSwapFlexibleSettlementEnforcer } from "../../src/enforcers/MetaSwapFlexibleSettlementEnforcer.sol"; +import { ICaveatEnforcer } from "../../src/interfaces/ICaveatEnforcer.sol"; +import { IMetaSwap } from "../../src/helpers/interfaces/IMetaSwap.sol"; +import { EncoderLib } from "../../src/libraries/EncoderLib.sol"; +import { Caveat, Delegation, Execution, ModeCode } from "../../src/utils/Types.sol"; + +contract FlexibleSettlementMetaSwapMock is IMetaSwap { + using SafeERC20 for IERC20; + + receive() external payable { } + + function swap(string calldata, IERC20 tokenFrom_, uint256 amount_, bytes calldata data_) external payable { + (IERC20 tokenOut_, uint256 amountOut_) = abi.decode(data_, (IERC20, uint256)); + + if (address(tokenFrom_) == address(0)) { + require(msg.value == amount_, "FlexibleSettlementMetaSwapMock:invalid-native-value"); + } else { + require(msg.value == 0, "FlexibleSettlementMetaSwapMock:unexpected-native-value"); + tokenFrom_.safeTransferFrom(msg.sender, address(this), amount_); + } + + if (address(tokenOut_) == address(0)) { + (bool success_,) = msg.sender.call{ value: amountOut_ }(""); + require(success_, "FlexibleSettlementMetaSwapMock:native-transfer-failed"); + } else { + tokenOut_.safeTransfer(msg.sender, amountOut_); + } + } + + function setAdapter(string calldata, address, bytes4, bytes calldata) external { } + + function removeAdapter(string calldata) external { } + + function adapters(string memory) external pure returns (Adapter memory adapter_) { + adapter_ = Adapter({ addr: address(0), selector: bytes4(0), data: hex"" }); + } +} + +contract MetaSwapFlexibleSettlementEnforcerTest is CaveatEnforcerBaseTest { + uint256 internal constant TOKEN_IN_AMOUNT = 100 ether; + uint256 internal constant TOKEN_OUT_MIN = 190 ether; + uint256 internal constant TOKEN_OUT_AMOUNT = 200 ether; + MetaSwapFlexibleSettlementEnforcer.ApprovalMode internal constant NONE = MetaSwapFlexibleSettlementEnforcer.ApprovalMode.None; + MetaSwapFlexibleSettlementEnforcer.ApprovalMode internal constant SKIP = + MetaSwapFlexibleSettlementEnforcer.ApprovalMode.SkipApproval; + MetaSwapFlexibleSettlementEnforcer.ApprovalMode internal constant APPROVE = + MetaSwapFlexibleSettlementEnforcer.ApprovalMode.Approve; + MetaSwapFlexibleSettlementEnforcer.ApprovalMode internal constant RESET = + MetaSwapFlexibleSettlementEnforcer.ApprovalMode.ResetApprove; + + uint256 internal constant ORDER_ID = 42; + uint128 internal constant NO_TIMESTAMP = 0; + + MetaSwapFlexibleSettlementEnforcer internal enforcer; + BasicERC20 internal tokenIn; + BasicERC20 internal tokenOut; + FlexibleSettlementMetaSwapMock internal metaSwap; + address internal alice; + address internal relayer; + uint128 internal expiresAt; + + event SettlementConsumed( + address indexed delegationManager, bytes32 indexed delegationHash, address indexed redeemer, uint256 id + ); + event UsedId(address indexed sender, address indexed delegator, address indexed redeemer, uint256 id); + + function setUp() public override { + super.setUp(); + + enforcer = new MetaSwapFlexibleSettlementEnforcer(); + tokenIn = new BasicERC20(address(this), "Token In", "TIN", 0); + tokenOut = new BasicERC20(address(this), "Token Out", "TOUT", 0); + metaSwap = new FlexibleSettlementMetaSwapMock(); + alice = address(users.alice.deleGator); + relayer = makeAddr("Relayer"); + expiresAt = uint128(block.timestamp + 1 days); + + tokenIn.mint(alice, 1_000 ether); + tokenOut.mint(address(metaSwap), 10_000 ether); + vm.deal(alice, 1_000 ether); + vm.deal(address(metaSwap), 10_000 ether); + } + + function test_getTermsInfoDecodesERC20Settlement() public { + MetaSwapFlexibleSettlementEnforcer.Terms memory info_ = + enforcer.getTermsInfo(_terms(address(tokenIn), APPROVE, address(tokenOut), alice)); + + assertEq(info_.metaSwap, address(metaSwap)); + assertEq(info_.tokenIn, address(tokenIn)); + assertEq(info_.tokenInAmount, TOKEN_IN_AMOUNT); + assertEq(uint8(info_.approvalMode), uint8(APPROVE)); + assertEq(info_.tokenOut, address(tokenOut)); + assertEq(info_.recipient, alice); + assertEq(info_.tokenOutMin, TOKEN_OUT_MIN); + assertEq(info_.timestampAfter, NO_TIMESTAMP); + assertEq(info_.timestampBefore, NO_TIMESTAMP); + assertEq(info_.id, ORDER_ID); + assertEq(info_.redeemers.length, 1); + assertEq(info_.redeemers[0], relayer); + } + + function test_getTermsInfoDecodesNativeInputSettlement() public { + MetaSwapFlexibleSettlementEnforcer.Terms memory info_ = + enforcer.getTermsInfo(_terms(address(0), NONE, address(tokenOut), alice)); + + assertEq(info_.tokenIn, address(0)); + assertEq(uint8(info_.approvalMode), uint8(NONE)); + } + + function test_getSettlementKeyUsesDelegationManagerAndDelegationHash() public { + bytes32 delegationHash_ = keccak256("delegation"); + assertEq( + enforcer.getSettlementKey(address(delegationManager), delegationHash_), + keccak256(abi.encode(address(delegationManager), delegationHash_)) + ); + } + + function test_acceptsFlexibleAggregatorAndRouteData() public { + _before( + _policyTerms(address(tokenIn), APPROVE, address(tokenOut), alice, NO_TIMESTAMP, NO_TIMESTAMP, ORDER_ID), + _erc20Executions(1, address(tokenIn), TOKEN_IN_AMOUNT, "a", hex"01"), + keccak256("first") + ); + _before( + _policyTerms(address(tokenIn), APPROVE, address(tokenOut), alice, NO_TIMESTAMP, NO_TIMESTAMP, ORDER_ID + 1), + _erc20Executions(1, address(tokenIn), TOKEN_IN_AMOUNT, "different-aggregator", new bytes(512)), + keccak256("second") + ); + } + + function test_acceptsMinimumLengthSwapCalldata() public { + Execution[] memory executions_ = _erc20Executions(1, address(tokenIn), TOKEN_IN_AMOUNT, "route", hex""); + executions_[1].callData = _minimumSwapCalldata(bytes32(uint256(uint160(address(tokenIn))))); + + assertEq(executions_[1].callData.length, 196); + _before(_terms(address(tokenIn), APPROVE, address(tokenOut), alice), executions_, keccak256("minimum-calldata")); + } + + function test_acceptsEachExactERC20ApprovalMode() public { + _before( + _policyTerms(address(tokenIn), SKIP, address(tokenOut), alice, NO_TIMESTAMP, NO_TIMESTAMP, 1), + _erc20Executions(0, address(tokenIn), TOKEN_IN_AMOUNT, "skip", hex""), + keccak256("skip") + ); + _before( + _policyTerms(address(tokenIn), APPROVE, address(tokenOut), alice, NO_TIMESTAMP, NO_TIMESTAMP, 2), + _erc20Executions(1, address(tokenIn), TOKEN_IN_AMOUNT, "approve", hex""), + keccak256("approve") + ); + _before( + _policyTerms(address(tokenIn), RESET, address(tokenOut), alice, NO_TIMESTAMP, NO_TIMESTAMP, 3), + _erc20Executions(2, address(tokenIn), TOKEN_IN_AMOUNT, "reset", hex""), + keccak256("reset") + ); + } + + function testFuzz_enforcesExactERC20ApprovalMode(uint8 rawMode_, uint8 approvalCount_) public { + rawMode_ = uint8(bound(rawMode_, uint8(SKIP), uint8(RESET))); + approvalCount_ = uint8(bound(approvalCount_, 0, 2)); + MetaSwapFlexibleSettlementEnforcer.ApprovalMode approvalMode_ = MetaSwapFlexibleSettlementEnforcer.ApprovalMode(rawMode_); + + bool validShape_ = (approvalMode_ == SKIP && approvalCount_ == 0) || (approvalMode_ == APPROVE && approvalCount_ == 1) + || (approvalMode_ == RESET && approvalCount_ == 2); + + if (!validShape_) { + vm.expectRevert("MetaSwapFlexibleSettlementEnforcer:approval-shape-not-allowed"); + } + + _before( + _terms(address(tokenIn), approvalMode_, address(tokenOut), alice), + _erc20Executions(approvalCount_, address(tokenIn), TOKEN_IN_AMOUNT, "route", hex""), + keccak256(abi.encode(rawMode_, approvalCount_)) + ); + } + + function testFuzz_nativeInputOnlyAcceptsNone(uint8 rawMode_) public { + rawMode_ = uint8(bound(rawMode_, uint8(NONE), uint8(RESET))); + MetaSwapFlexibleSettlementEnforcer.ApprovalMode approvalMode_ = MetaSwapFlexibleSettlementEnforcer.ApprovalMode(rawMode_); + + if (approvalMode_ != NONE) { + vm.expectRevert("MetaSwapFlexibleSettlementEnforcer:invalid-approval-mode"); + } + + _before( + _terms(address(0), approvalMode_, address(tokenOut), alice), + _nativeExecutions(TOKEN_IN_AMOUNT, address(tokenOut), TOKEN_OUT_AMOUNT), + keccak256(abi.encode(rawMode_)) + ); + } + + function testFuzz_revertsForUndefinedApprovalMode(uint8 rawMode_) public { + rawMode_ = uint8(bound(rawMode_, uint8(RESET) + 1, type(uint8).max)); + + vm.expectRevert("MetaSwapFlexibleSettlementEnforcer:invalid-approval-mode"); + enforcer.getTermsInfo( + _rawTerms(address(metaSwap), address(tokenIn), TOKEN_IN_AMOUNT, rawMode_, address(tokenOut), alice, TOKEN_OUT_MIN) + ); + } + + function test_acceptsNativeInputShape() public { + _before( + _terms(address(0), NONE, address(tokenOut), alice), + _nativeExecutions(TOKEN_IN_AMOUNT, address(tokenOut), TOKEN_OUT_AMOUNT), + keccak256("native") + ); + } + + function test_revertsForSingleCallMode() public { + vm.expectRevert("CaveatEnforcer:invalid-call-type"); + enforcer.beforeHook( + _terms(address(tokenIn), APPROVE, address(tokenOut), alice), + hex"", + singleDefaultMode, + ExecutionLib.encodeBatch(_erc20Executions(1, address(tokenIn), TOKEN_IN_AMOUNT, "route", hex"")), + bytes32(0), + alice, + relayer + ); + } + + function test_revertsForTryExecutionMode() public { + vm.expectRevert("CaveatEnforcer:invalid-execution-type"); + enforcer.beforeHook( + _terms(address(tokenIn), APPROVE, address(tokenOut), alice), + hex"", + batchTryMode, + ExecutionLib.encodeBatch(_erc20Executions(1, address(tokenIn), TOKEN_IN_AMOUNT, "route", hex"")), + bytes32(0), + alice, + relayer + ); + } + + function test_revertsForInvalidTermsLength() public { + vm.expectRevert("MetaSwapFlexibleSettlementEnforcer:invalid-terms"); + enforcer.getTermsInfo(new bytes(228)); + + // Long enough for the fixed header + redeemer bytes, but not a multiple of 20. + bytes memory misaligned_ = _terms(address(tokenIn), APPROVE, address(tokenOut), alice); + bytes memory padded_ = bytes.concat(misaligned_, bytes1(0x00)); + vm.expectRevert("MetaSwapFlexibleSettlementEnforcer:invalid-terms"); + enforcer.getTermsInfo(padded_); + + bytes memory truncated_ = new bytes(misaligned_.length - 1); + for (uint256 i_; i_ < truncated_.length; ++i_) { + truncated_[i_] = misaligned_[i_]; + } + vm.expectRevert("MetaSwapFlexibleSettlementEnforcer:invalid-terms"); + enforcer.getTermsInfo(truncated_); + } + + function test_revertsForInvalidRequiredTerms() public { + vm.expectRevert("MetaSwapFlexibleSettlementEnforcer:invalid-terms"); + enforcer.getTermsInfo( + _rawTerms(address(0), address(tokenIn), TOKEN_IN_AMOUNT, uint8(APPROVE), address(tokenOut), alice, TOKEN_OUT_MIN) + ); + + vm.expectRevert("MetaSwapFlexibleSettlementEnforcer:invalid-terms"); + enforcer.getTermsInfo( + _rawTerms(address(metaSwap), address(tokenIn), 0, uint8(APPROVE), address(tokenOut), alice, TOKEN_OUT_MIN) + ); + + vm.expectRevert("MetaSwapFlexibleSettlementEnforcer:invalid-terms"); + enforcer.getTermsInfo( + _rawTerms( + address(metaSwap), address(tokenIn), TOKEN_IN_AMOUNT, uint8(APPROVE), address(tokenOut), address(0), TOKEN_OUT_MIN + ) + ); + + vm.expectRevert("MetaSwapFlexibleSettlementEnforcer:invalid-terms"); + enforcer.getTermsInfo( + _rawTerms(address(metaSwap), address(tokenIn), TOKEN_IN_AMOUNT, uint8(APPROVE), address(tokenOut), alice, 0) + ); + + vm.expectRevert("MetaSwapFlexibleSettlementEnforcer:invalid-terms"); + enforcer.getTermsInfo( + _rawTerms(address(metaSwap), address(tokenIn), TOKEN_IN_AMOUNT, uint8(APPROVE), address(tokenIn), alice, TOKEN_OUT_MIN) + ); + + vm.expectRevert("MetaSwapFlexibleSettlementEnforcer:invalid-terms"); + enforcer.getTermsInfo(_policyTerms(address(tokenIn), APPROVE, address(tokenOut), alice, NO_TIMESTAMP, NO_TIMESTAMP, 0)); + } + + function test_revertsForInvalidApprovalMode() public { + vm.expectRevert("MetaSwapFlexibleSettlementEnforcer:invalid-approval-mode"); + _before( + _terms(address(0), APPROVE, address(tokenOut), alice), + _nativeExecutions(TOKEN_IN_AMOUNT, address(tokenOut), TOKEN_OUT_AMOUNT), + keccak256("native-approval-mode") + ); + + vm.expectRevert("MetaSwapFlexibleSettlementEnforcer:invalid-approval-mode"); + _before( + _terms(address(tokenIn), NONE, address(tokenOut), alice), + _erc20Executions(0, address(tokenIn), TOKEN_IN_AMOUNT, "route", hex""), + keccak256("erc20-none-mode") + ); + + vm.expectRevert("MetaSwapFlexibleSettlementEnforcer:invalid-approval-mode"); + enforcer.getTermsInfo( + _rawTerms(address(metaSwap), address(tokenIn), TOKEN_IN_AMOUNT, 4, address(tokenOut), alice, TOKEN_OUT_MIN) + ); + } + + function test_revertsWhenApprovalShapeIsNotSigned() public { + vm.expectRevert("MetaSwapFlexibleSettlementEnforcer:approval-shape-not-allowed"); + _before( + _terms(address(tokenIn), APPROVE, address(tokenOut), alice), + _erc20Executions(0, address(tokenIn), TOKEN_IN_AMOUNT, "route", hex""), + bytes32(0) + ); + + vm.expectRevert("MetaSwapFlexibleSettlementEnforcer:approval-shape-not-allowed"); + _before( + _terms(address(tokenIn), SKIP, address(tokenOut), alice), + _erc20Executions(1, address(tokenIn), TOKEN_IN_AMOUNT, "route", hex""), + bytes32(0) + ); + + vm.expectRevert("MetaSwapFlexibleSettlementEnforcer:approval-shape-not-allowed"); + _before( + _terms(address(tokenIn), APPROVE, address(tokenOut), alice), + _erc20Executions(2, address(tokenIn), TOKEN_IN_AMOUNT, "route", hex""), + bytes32(0) + ); + } + + function test_revertsForUnsupportedBatchLengths() public { + Execution[] memory empty_ = new Execution[](0); + vm.expectRevert("MetaSwapFlexibleSettlementEnforcer:approval-shape-not-allowed"); + _before(_terms(address(tokenIn), SKIP, address(tokenOut), alice), empty_, bytes32(0)); + + Execution[] memory tooLong_ = new Execution[](4); + vm.expectRevert("MetaSwapFlexibleSettlementEnforcer:approval-shape-not-allowed"); + _before(_terms(address(tokenIn), RESET, address(tokenOut), alice), tooLong_, bytes32(0)); + + Execution[] memory nativeTooLong_ = new Execution[](2); + vm.expectRevert("MetaSwapFlexibleSettlementEnforcer:invalid-batch-length"); + _before(_terms(address(0), NONE, address(tokenOut), alice), nativeTooLong_, bytes32(0)); + } + + function test_revertsForInvalidApproval() public { + Execution[] memory executions_ = _erc20Executions(1, address(tokenIn), TOKEN_IN_AMOUNT, "route", hex""); + + executions_[0].target = makeAddr("OtherToken"); + _expectInvalidApproval(executions_); + + executions_ = _erc20Executions(1, address(tokenIn), TOKEN_IN_AMOUNT, "route", hex""); + executions_[0].value = 1; + _expectInvalidApproval(executions_); + + executions_ = _erc20Executions(1, address(tokenIn), TOKEN_IN_AMOUNT, "route", hex""); + executions_[0].callData = abi.encodePacked(IERC20.approve.selector); + _expectInvalidApproval(executions_); + + executions_ = _erc20Executions(1, address(tokenIn), TOKEN_IN_AMOUNT, "route", hex""); + executions_[0].callData = abi.encodeCall(IERC20.transfer, (address(metaSwap), TOKEN_IN_AMOUNT)); + _expectInvalidApproval(executions_); + + executions_ = _erc20Executions(1, address(tokenIn), TOKEN_IN_AMOUNT, "route", hex""); + executions_[0].callData = abi.encodeCall(IERC20.approve, (makeAddr("OtherSpender"), TOKEN_IN_AMOUNT)); + _expectInvalidApproval(executions_); + + executions_ = _erc20Executions(1, address(tokenIn), TOKEN_IN_AMOUNT, "route", hex""); + executions_[0].callData = abi.encodePacked( + IERC20.approve.selector, bytes32(uint256(uint160(address(metaSwap))) | (uint256(1) << 255)), bytes32(TOKEN_IN_AMOUNT) + ); + _expectInvalidApproval(executions_); + + executions_ = _erc20Executions(1, address(tokenIn), TOKEN_IN_AMOUNT, "route", hex""); + executions_[0].callData = abi.encodeCall(IERC20.approve, (address(metaSwap), TOKEN_IN_AMOUNT - 1)); + _expectInvalidApproval(executions_); + } + + function test_revertsForInvalidResetApproval() public { + Execution[] memory executions_ = _erc20Executions(2, address(tokenIn), TOKEN_IN_AMOUNT, "route", hex""); + executions_[0].callData = abi.encodeCall(IERC20.approve, (address(metaSwap), 1)); + + vm.expectRevert("MetaSwapFlexibleSettlementEnforcer:invalid-approval"); + _before(_terms(address(tokenIn), RESET, address(tokenOut), alice), executions_, bytes32(0)); + } + + function test_revertsForInvalidSwap() public { + Execution[] memory executions_ = _erc20Executions(1, address(tokenIn), TOKEN_IN_AMOUNT, "route", hex""); + executions_[1].target = makeAddr("OtherSwap"); + _expectInvalidSwap(executions_); + + executions_ = _erc20Executions(1, address(tokenIn), TOKEN_IN_AMOUNT, "route", hex""); + executions_[1].value = 1; + _expectInvalidSwap(executions_); + + executions_ = _erc20Executions(1, address(tokenIn), TOKEN_IN_AMOUNT, "route", hex""); + executions_[1].callData = abi.encodePacked(IMetaSwap.swap.selector); + _expectInvalidSwap(executions_); + + executions_ = _erc20Executions(1, address(tokenIn), TOKEN_IN_AMOUNT, "route", hex""); + executions_[1].callData = abi.encodeCall(IERC20.approve, (address(metaSwap), TOKEN_IN_AMOUNT)); + _expectInvalidSwap(executions_); + + executions_ = _erc20Executions(1, address(tokenIn), TOKEN_IN_AMOUNT, "route", hex""); + executions_[1].callData = _minimumSwapCalldata(bytes32(uint256(uint160(address(tokenIn))) | (uint256(1) << 255))); + _expectInvalidSwap(executions_); + + _expectInvalidSwap(_erc20Executions(1, makeAddr("OtherToken"), TOKEN_IN_AMOUNT, "route", hex"")); + _expectInvalidSwap(_erc20Executions(1, address(tokenIn), TOKEN_IN_AMOUNT - 1, "route", hex"")); + } + + function test_revertsForStructurallyIncompleteSwapCalldata() public { + Execution[] memory executions_ = _erc20Executions(1, address(tokenIn), TOKEN_IN_AMOUNT, "route", hex""); + executions_[1].callData = abi.encodePacked( + IMetaSwap.swap.selector, + uint256(128), + bytes32(uint256(uint160(address(tokenIn)))), + TOKEN_IN_AMOUNT, + uint256(160), + uint256(0), + bytes31(0) + ); + + assertEq(executions_[1].callData.length, 195); + _expectInvalidSwap(executions_); + } + + function test_revertsForNativeSwapWithWrongValue() public { + Execution[] memory executions_ = _nativeExecutions(TOKEN_IN_AMOUNT - 1, address(tokenOut), TOKEN_OUT_AMOUNT); + vm.expectRevert("MetaSwapFlexibleSettlementEnforcer:invalid-swap"); + _before(_terms(address(0), NONE, address(tokenOut), alice), executions_, bytes32(0)); + } + + function test_beforeHookMarksSettlementUsedAndRejectsReuse() public { + tokenOut.mint(alice, 10); + bytes32 delegationHash_ = keccak256("settlement"); + vm.prank(address(delegationManager)); + enforcer.beforeHook( + _terms(address(tokenIn), APPROVE, address(tokenOut), alice), + hex"", + batchDefaultMode, + ExecutionLib.encodeBatch(_erc20Executions(1, address(tokenIn), TOKEN_IN_AMOUNT, "route", hex"")), + delegationHash_, + alice, + relayer + ); + + assertTrue(enforcer.getIsUsed(address(delegationManager), alice, ORDER_ID)); + + vm.prank(address(delegationManager)); + vm.expectRevert("MetaSwapFlexibleSettlementEnforcer:id-already-used"); + enforcer.beforeHook( + _terms(address(tokenIn), APPROVE, address(tokenOut), alice), + hex"", + batchDefaultMode, + ExecutionLib.encodeBatch(_erc20Executions(1, address(tokenIn), TOKEN_IN_AMOUNT, "route", hex"")), + keccak256("different-delegation"), + alice, + relayer + ); + } + + function test_sameIdIsIsolatedAcrossDelegationManagers() public { + DelegationManager secondDelegationManager_ = new DelegationManager(address(this)); + bytes32 delegationHash_ = keccak256("shared-delegation-hash"); + bytes memory terms_ = _terms(address(tokenIn), APPROVE, address(tokenOut), alice); + Execution[] memory executions_ = _erc20Executions(1, address(tokenIn), TOKEN_IN_AMOUNT, "route", hex""); + + _beforeAs(address(delegationManager), terms_, executions_, delegationHash_); + _beforeAs(address(secondDelegationManager_), terms_, executions_, delegationHash_); + + assertTrue(enforcer.getIsUsed(address(delegationManager), alice, ORDER_ID)); + assertTrue(enforcer.getIsUsed(address(secondDelegationManager_), alice, ORDER_ID)); + } + + function test_afterHookRevertsForInvalidTermsLength() public { + vm.expectRevert("MetaSwapFlexibleSettlementEnforcer:invalid-terms"); + enforcer.afterHook(new bytes(228), hex"", batchDefaultMode, hex"", bytes32(0), alice, relayer); + + bytes memory misaligned_ = bytes.concat(_terms(address(tokenIn), APPROVE, address(tokenOut), alice), bytes1(0x00)); + vm.expectRevert("MetaSwapFlexibleSettlementEnforcer:invalid-terms"); + enforcer.afterHook(misaligned_, hex"", batchDefaultMode, hex"", bytes32(0), alice, relayer); + } + + function test_afterHookConsumesSettlementAndEmitsEvent() public { + bytes32 delegationHash_ = keccak256("successful-settlement"); + bytes memory terms_ = _terms(address(tokenIn), APPROVE, address(tokenOut), alice); + _before(terms_, _erc20Executions(1, address(tokenIn), TOKEN_IN_AMOUNT, "route", hex""), delegationHash_); + tokenOut.mint(alice, TOKEN_OUT_MIN); + + vm.prank(address(delegationManager)); + vm.expectEmit(true, true, true, true, address(enforcer)); + emit SettlementConsumed(address(delegationManager), delegationHash_, relayer, ORDER_ID); + enforcer.afterHook(terms_, hex"", batchDefaultMode, hex"", delegationHash_, alice, relayer); + + assertTrue(enforcer.getIsUsed(address(delegationManager), alice, ORDER_ID)); + } + + function test_revertsForUnauthorizedRedeemer() public { + vm.expectRevert("MetaSwapFlexibleSettlementEnforcer:unauthorized-redeemer"); + _beforeAs( + address(delegationManager), + _terms(address(tokenIn), APPROVE, address(tokenOut), alice), + _erc20Executions(1, address(tokenIn), TOKEN_IN_AMOUNT, "route", hex""), + keccak256("unauthorized"), + makeAddr("Intruder") + ); + } + + function test_optionalTimestampAllowsUnboundedWindow() public { + _before( + _policyTerms(address(tokenIn), APPROVE, address(tokenOut), alice, NO_TIMESTAMP, NO_TIMESTAMP, ORDER_ID), + _erc20Executions(1, address(tokenIn), TOKEN_IN_AMOUNT, "route", hex""), + keccak256("no-timestamp") + ); + } + + function test_acceptsTimestampAfterWhenWindowIsOpen() public { + vm.warp(100); + expiresAt = uint128(block.timestamp + 1 days); + _before( + _policyTerms(address(tokenIn), APPROVE, address(tokenOut), alice, 50, expiresAt, ORDER_ID), + _erc20Executions(1, address(tokenIn), TOKEN_IN_AMOUNT, "route", hex""), + keccak256("timestamp-after-ok") + ); + } + + function test_allowsRedeemerAnywhereInAllowlist() public { + address[] memory redeemers_ = new address[](3); + redeemers_[0] = address(0); + redeemers_[1] = makeAddr("OtherSigner"); + redeemers_[2] = relayer; + + bytes memory terms_ = _rawTerms( + address(metaSwap), + address(tokenIn), + TOKEN_IN_AMOUNT, + uint8(APPROVE), + address(tokenOut), + alice, + TOKEN_OUT_MIN, + NO_TIMESTAMP, + NO_TIMESTAMP, + ORDER_ID, + redeemers_ + ); + _before(terms_, _erc20Executions(1, address(tokenIn), TOKEN_IN_AMOUNT, "route", hex""), keccak256("multi-redeemer")); + } + + function test_getIsUsedReturnsFalseForUnusedId() public { + assertFalse(enforcer.getIsUsed(address(delegationManager), alice, ORDER_ID)); + } + + function test_revertsForExpiredDelegation() public { + vm.warp(expiresAt); + vm.expectRevert("MetaSwapFlexibleSettlementEnforcer:expired-delegation"); + _before( + _policyTerms(address(tokenIn), APPROVE, address(tokenOut), alice, NO_TIMESTAMP, expiresAt, ORDER_ID), + _erc20Executions(1, address(tokenIn), TOKEN_IN_AMOUNT, "route", hex""), + keccak256("expired") + ); + } + + function test_revertsForEarlyDelegation() public { + vm.expectRevert("MetaSwapFlexibleSettlementEnforcer:early-delegation"); + _before( + _policyTerms(address(tokenIn), APPROVE, address(tokenOut), alice, uint128(block.timestamp + 1), expiresAt, ORDER_ID), + _erc20Executions(1, address(tokenIn), TOKEN_IN_AMOUNT, "route", hex""), + keccak256("early") + ); + } + + function test_successfulSettlementEmitsIdConsumption() public { + bytes memory terms_ = _policyTerms(address(tokenIn), APPROVE, address(tokenOut), alice, NO_TIMESTAMP, expiresAt, ORDER_ID); + Delegation memory delegation_ = _sign(terms_); + bytes32 delegationHash_ = EncoderLib._getDelegationHash(delegation_); + Execution[] memory executions_ = _erc20Executions( + 1, address(tokenIn), TOKEN_IN_AMOUNT, "best-route", abi.encode(IERC20(address(tokenOut)), TOKEN_OUT_AMOUNT) + ); + + vm.expectEmit(true, true, true, true, address(enforcer)); + emit UsedId(address(delegationManager), alice, relayer, ORDER_ID); + vm.expectEmit(true, true, true, true, address(enforcer)); + emit SettlementConsumed(address(delegationManager), delegationHash_, relayer, ORDER_ID); + _redeem(delegation_, executions_); + + assertTrue(enforcer.getIsUsed(address(delegationManager), alice, ORDER_ID)); + } + + function test_replacementOrdersShareIdAreMutuallyExclusive() public { + bytes memory terms_ = _policyTerms(address(tokenIn), APPROVE, address(tokenOut), alice, NO_TIMESTAMP, expiresAt, ORDER_ID); + Execution[] memory executions_ = _erc20Executions( + 1, address(tokenIn), TOKEN_IN_AMOUNT, "best-route", abi.encode(IERC20(address(tokenOut)), TOKEN_OUT_AMOUNT) + ); + Delegation memory first_ = _signWithSalt(terms_, 0); + Delegation memory second_ = _signWithSalt(terms_, 1); + + _redeem(second_, executions_); + assertTrue(enforcer.getIsUsed(address(delegationManager), alice, ORDER_ID)); + + tokenIn.mint(alice, TOKEN_IN_AMOUNT); + vm.expectRevert("MetaSwapFlexibleSettlementEnforcer:id-already-used"); + _redeem(first_, executions_); + } + + function test_idConsumptionRollsBackOnInsufficientOutput() public { + bytes memory terms_ = _policyTerms(address(tokenIn), APPROVE, address(tokenOut), alice, NO_TIMESTAMP, expiresAt, ORDER_ID); + Delegation memory delegation_ = _sign(terms_); + Execution[] memory insufficient_ = _erc20Executions( + 1, address(tokenIn), TOKEN_IN_AMOUNT, "bad-route", abi.encode(IERC20(address(tokenOut)), TOKEN_OUT_MIN - 1) + ); + + vm.expectRevert("MetaSwapFlexibleSettlementEnforcer:insufficient-output"); + _redeem(delegation_, insufficient_); + assertFalse(enforcer.getIsUsed(address(delegationManager), alice, ORDER_ID)); + + _redeem( + delegation_, + _erc20Executions( + 1, address(tokenIn), TOKEN_IN_AMOUNT, "new-route", abi.encode(IERC20(address(tokenOut)), TOKEN_OUT_MIN) + ) + ); + assertTrue(enforcer.getIsUsed(address(delegationManager), alice, ORDER_ID)); + } + + function test_afterHookRevertsForInsufficientOutput() public { + bytes32 delegationHash_ = keccak256("insufficient-settlement"); + bytes memory terms_ = _terms(address(tokenIn), APPROVE, address(tokenOut), alice); + _before(terms_, _erc20Executions(1, address(tokenIn), TOKEN_IN_AMOUNT, "route", hex""), delegationHash_); + tokenOut.mint(alice, TOKEN_OUT_MIN - 1); + + vm.prank(address(delegationManager)); + vm.expectRevert("MetaSwapFlexibleSettlementEnforcer:insufficient-output"); + enforcer.afterHook(terms_, hex"", batchDefaultMode, hex"", delegationHash_, alice, relayer); + } + + function test_redeemsERC20ApprovalSettlement() public { + _redeem( + _sign(_terms(address(tokenIn), APPROVE, address(tokenOut), alice)), + _erc20Executions( + 1, address(tokenIn), TOKEN_IN_AMOUNT, "best-route", abi.encode(IERC20(address(tokenOut)), TOKEN_OUT_AMOUNT) + ) + ); + + assertEq(tokenIn.balanceOf(alice), 900 ether); + assertEq(tokenOut.balanceOf(alice), TOKEN_OUT_AMOUNT); + } + + function test_redeemsERC20ResetApprovalSettlement() public { + vm.prank(alice); + tokenIn.approve(address(metaSwap), 1); + + _redeem( + _sign(_terms(address(tokenIn), RESET, address(tokenOut), alice)), + _erc20Executions( + 2, address(tokenIn), TOKEN_IN_AMOUNT, "best-route", abi.encode(IERC20(address(tokenOut)), TOKEN_OUT_AMOUNT) + ) + ); + + assertEq(tokenIn.balanceOf(alice), 900 ether); + assertEq(tokenIn.allowance(alice, address(metaSwap)), 0); + assertEq(tokenOut.balanceOf(alice), TOKEN_OUT_AMOUNT); + } + + function test_redeemsERC20SettlementSkippingApproval() public { + vm.prank(alice); + tokenIn.approve(address(metaSwap), TOKEN_IN_AMOUNT); + + _redeem( + _sign(_terms(address(tokenIn), SKIP, address(tokenOut), alice)), + _erc20Executions( + 0, address(tokenIn), TOKEN_IN_AMOUNT, "best-route", abi.encode(IERC20(address(tokenOut)), TOKEN_OUT_AMOUNT) + ) + ); + + assertEq(tokenIn.balanceOf(alice), 900 ether); + assertEq(tokenOut.balanceOf(alice), TOKEN_OUT_AMOUNT); + } + + function test_redeemsNativeInputSettlement() public { + uint256 nativeBefore_ = alice.balance; + _redeem( + _sign(_terms(address(0), NONE, address(tokenOut), alice)), + _nativeExecutions(TOKEN_IN_AMOUNT, address(tokenOut), TOKEN_OUT_AMOUNT) + ); + + assertEq(alice.balance, nativeBefore_ - TOKEN_IN_AMOUNT); + assertEq(tokenOut.balanceOf(alice), TOKEN_OUT_AMOUNT); + } + + function test_redeemsERC20ForNativeOutput() public { + uint256 nativeBefore_ = alice.balance; + _redeem( + _sign(_terms(address(tokenIn), APPROVE, address(0), alice)), + _erc20Executions( + 1, address(tokenIn), TOKEN_IN_AMOUNT, "native-output", abi.encode(IERC20(address(0)), TOKEN_OUT_AMOUNT) + ) + ); + + assertEq(tokenIn.balanceOf(alice), 900 ether); + assertEq(alice.balance, nativeBefore_ + TOKEN_OUT_AMOUNT); + } + + function test_revertsAtomicallyForInsufficientOutputAndAllowsRetry() public { + bytes memory terms_ = _terms(address(tokenIn), APPROVE, address(tokenOut), alice); + Delegation memory delegation_ = _sign(terms_); + Execution[] memory insufficient_ = _erc20Executions( + 1, address(tokenIn), TOKEN_IN_AMOUNT, "bad-route", abi.encode(IERC20(address(tokenOut)), TOKEN_OUT_MIN - 1) + ); + + vm.expectRevert("MetaSwapFlexibleSettlementEnforcer:insufficient-output"); + _redeem(delegation_, insufficient_); + + assertEq(tokenIn.balanceOf(alice), 1_000 ether); + assertEq(tokenOut.balanceOf(alice), 0); + assertFalse(enforcer.getIsUsed(address(delegationManager), alice, ORDER_ID)); + + _redeem( + delegation_, + _erc20Executions( + 1, address(tokenIn), TOKEN_IN_AMOUNT, "new-route", abi.encode(IERC20(address(tokenOut)), TOKEN_OUT_MIN) + ) + ); + assertEq(tokenOut.balanceOf(alice), TOKEN_OUT_MIN); + } + + function test_successfulSettlementCannotBeRedeemedAgain() public { + Delegation memory delegation_ = _sign(_terms(address(tokenIn), APPROVE, address(tokenOut), alice)); + Execution[] memory executions_ = _erc20Executions( + 1, address(tokenIn), TOKEN_IN_AMOUNT, "best-route", abi.encode(IERC20(address(tokenOut)), TOKEN_OUT_AMOUNT) + ); + _redeem(delegation_, executions_); + + assertTrue(enforcer.getIsUsed(address(delegationManager), alice, ORDER_ID)); + + vm.expectRevert("MetaSwapFlexibleSettlementEnforcer:id-already-used"); + _redeem(delegation_, executions_); + } + + function _expectInvalidApproval(Execution[] memory executions_) private { + vm.expectRevert("MetaSwapFlexibleSettlementEnforcer:invalid-approval"); + _before(_terms(address(tokenIn), APPROVE, address(tokenOut), alice), executions_, bytes32(0)); + } + + function _expectInvalidSwap(Execution[] memory executions_) private { + vm.expectRevert("MetaSwapFlexibleSettlementEnforcer:invalid-swap"); + _before(_terms(address(tokenIn), APPROVE, address(tokenOut), alice), executions_, bytes32(0)); + } + + function _terms( + address tokenIn_, + MetaSwapFlexibleSettlementEnforcer.ApprovalMode approvalMode_, + address tokenOut_, + address recipient_ + ) + private + view + returns (bytes memory) + { + return _policyTerms(tokenIn_, approvalMode_, tokenOut_, recipient_, NO_TIMESTAMP, NO_TIMESTAMP, ORDER_ID); + } + + function _policyTerms( + address tokenIn_, + MetaSwapFlexibleSettlementEnforcer.ApprovalMode approvalMode_, + address tokenOut_, + address recipient_, + uint128 timestampAfter_, + uint128 timestampBefore_, + uint256 id_ + ) + private + view + returns (bytes memory) + { + address[] memory redeemers_ = new address[](1); + redeemers_[0] = relayer; + return _rawTerms( + address(metaSwap), + tokenIn_, + TOKEN_IN_AMOUNT, + uint8(approvalMode_), + tokenOut_, + recipient_, + TOKEN_OUT_MIN, + timestampAfter_, + timestampBefore_, + id_, + redeemers_ + ); + } + + function _rawTerms( + address metaSwap_, + address tokenIn_, + uint256 tokenInAmount_, + uint8 approvalMode_, + address tokenOut_, + address recipient_, + uint256 tokenOutMin_ + ) + private + view + returns (bytes memory) + { + address[] memory redeemers_ = new address[](1); + redeemers_[0] = relayer; + return _rawTerms( + metaSwap_, + tokenIn_, + tokenInAmount_, + approvalMode_, + tokenOut_, + recipient_, + tokenOutMin_, + NO_TIMESTAMP, + NO_TIMESTAMP, + ORDER_ID, + redeemers_ + ); + } + + function _rawTerms( + address metaSwap_, + address tokenIn_, + uint256 tokenInAmount_, + uint8 approvalMode_, + address tokenOut_, + address recipient_, + uint256 tokenOutMin_, + uint128 timestampAfter_, + uint128 timestampBefore_, + uint256 id_, + address[] memory redeemers_ + ) + private + pure + returns (bytes memory) + { + bytes memory packed_ = abi.encodePacked( + metaSwap_, + tokenIn_, + tokenInAmount_, + approvalMode_, + tokenOut_, + recipient_, + tokenOutMin_, + timestampAfter_, + timestampBefore_, + id_ + ); + for (uint256 i_; i_ < redeemers_.length; ++i_) { + packed_ = abi.encodePacked(packed_, redeemers_[i_]); + } + return packed_; + } + + function _nativeExecutions( + uint256 value_, + address outputToken_, + uint256 outputAmount_ + ) + private + view + returns (Execution[] memory executions_) + { + executions_ = new Execution[](1); + executions_[0] = + _swapExecution(address(0), TOKEN_IN_AMOUNT, value_, "native-route", abi.encode(IERC20(outputToken_), outputAmount_)); + } + + function _erc20Executions( + uint8 shape_, + address swapToken_, + uint256 swapAmount_, + string memory aggregatorId_, + bytes memory routeData_ + ) + private + view + returns (Execution[] memory executions_) + { + uint256 swapIndex_ = shape_; + executions_ = new Execution[](swapIndex_ + 1); + if (shape_ == 2) executions_[0] = _approvalExecution(0); + if (shape_ != 0) executions_[swapIndex_ - 1] = _approvalExecution(TOKEN_IN_AMOUNT); + executions_[swapIndex_] = _swapExecution(swapToken_, swapAmount_, 0, aggregatorId_, routeData_); + } + + function _approvalExecution(uint256 amount_) private view returns (Execution memory) { + return + Execution({ + target: address(tokenIn), value: 0, callData: abi.encodeCall(IERC20.approve, (address(metaSwap), amount_)) + }); + } + + function _swapExecution( + address swapToken_, + uint256 swapAmount_, + uint256 value_, + string memory aggregatorId_, + bytes memory routeData_ + ) + private + view + returns (Execution memory) + { + return Execution({ + target: address(metaSwap), + value: value_, + callData: abi.encodeCall(IMetaSwap.swap, (aggregatorId_, IERC20(swapToken_), swapAmount_, routeData_)) + }); + } + + function _minimumSwapCalldata(bytes32 tokenInWord_) private pure returns (bytes memory) { + return abi.encodePacked( + IMetaSwap.swap.selector, uint256(128), tokenInWord_, TOKEN_IN_AMOUNT, uint256(160), uint256(0), uint256(0) + ); + } + + function _before(bytes memory terms_, Execution[] memory executions_, bytes32 delegationHash_) private { + _beforeAs(address(delegationManager), terms_, executions_, delegationHash_, relayer); + } + + function _beforeAs( + address delegationManager_, + bytes memory terms_, + Execution[] memory executions_, + bytes32 delegationHash_ + ) + private + { + _beforeAs(delegationManager_, terms_, executions_, delegationHash_, relayer); + } + + function _beforeAs( + address delegationManager_, + bytes memory terms_, + Execution[] memory executions_, + bytes32 delegationHash_, + address redeemer_ + ) + private + { + vm.prank(delegationManager_); + enforcer.beforeHook( + terms_, hex"", batchDefaultMode, ExecutionLib.encodeBatch(executions_), delegationHash_, alice, redeemer_ + ); + } + + function _sign(bytes memory terms_) private view returns (Delegation memory) { + return _signWithSalt(terms_, 0); + } + + function _signWithSalt(bytes memory terms_, uint256 salt_) private view returns (Delegation memory delegation_) { + Caveat[] memory caveats_ = new Caveat[](1); + caveats_[0] = Caveat({ enforcer: address(enforcer), terms: terms_, args: hex"" }); + delegation_ = Delegation({ + delegate: ANY_DELEGATE, delegator: alice, authority: ROOT_AUTHORITY, caveats: caveats_, salt: salt_, signature: hex"" + }); + delegation_ = signDelegation(users.alice, delegation_); + } + + function _redeem(Delegation memory delegation_, Execution[] memory executions_) private { + Delegation[] memory delegations_ = new Delegation[](1); + delegations_[0] = delegation_; + bytes[] memory permissionContexts_ = new bytes[](1); + permissionContexts_[0] = abi.encode(delegations_); + ModeCode[] memory modes_ = new ModeCode[](1); + modes_[0] = ModeLib.encodeSimpleBatch(); + bytes[] memory executionCallDatas_ = new bytes[](1); + executionCallDatas_[0] = ExecutionLib.encodeBatch(executions_); + + vm.prank(relayer); + delegationManager.redeemDelegations(permissionContexts_, modes_, executionCallDatas_); + } + + function _getEnforcer() internal view override returns (ICaveatEnforcer) { + return ICaveatEnforcer(address(enforcer)); + } +}