diff --git a/.env.example b/.env.example index 4083dfd8..f304e7fa 100644 --- a/.env.example +++ b/.env.example @@ -2,6 +2,7 @@ PRIVATE_KEY= SALT=GATOR DELEGATION_MANAGER_ADDRESS= +META_SWAP_ORDER_DELEGATION_MANAGER_ADDRESS= ENTRYPOINT_ADDRESS=0x0000000071727De22E5E9d8BAf0edAc6f37da032 MULTISIG_DELEGATOR_IMPLEMENTATION_ADDRESS= META_SWAP_ADAPTER_OWNER_ADDRESS= diff --git a/documents/CaveatEnforcers.md b/documents/CaveatEnforcers.md index f7a57fb9..809ec149 100644 --- a/documents/CaveatEnforcers.md +++ b/documents/CaveatEnforcers.md @@ -25,6 +25,76 @@ Enforcers can target specific call type modes: **single** or **batch**, and exec --- +### MetaSwapFlexibleSettlementEnforcer + +Authorizes one successful MetaSwap settlement with a redeemer-selected route. It binds the input, approval behavior, +output asset, recipient, and minimum output while keeping route discovery flexible. A successful settlement is +permanently consumed; a reverted fill, including insufficient output, rolls back the consumed state and remains +retryable. + +It accepts one direct `BATCH_DEFAULT_MODE` redemption with: + +- Native input: `MetaSwap.swap{ value: tokenInAmount }(...)` +- ERC-20 skipping approval: `MetaSwap.swap(...)` +- ERC-20 approval: `approve(metaSwap, tokenInAmount)`, then `MetaSwap.swap(...)` +- ERC-20 reset approval: `approve(metaSwap, 0)`, `approve(metaSwap, tokenInAmount)`, then `MetaSwap.swap(...)` + +Terms are packed as: + +```text +metaSwap(20) | tokenIn(20) | tokenInAmount(32) | approvalMode(1) | +tokenOut(20) | recipient(20) | tokenOutMin(32) +``` + +`address(0)` represents the native token. The one-byte `ApprovalMode` enum selects exactly one execution shape: + +- `0`: `None`, required for native input +- `1`: `SkipApproval` +- `2`: `Approve` +- `3`: `ResetApprove` + +ERC-20 input requires modes `1` through `3`. The execution count must match the signed mode; caveat args are not used. +`SkipApproval` does not inspect allowance; the swap must have sufficient allowance to execute successfully. + +Example terms for an ERC-20 settlement requiring `approve(amount)`: + +```solidity +bytes memory terms = abi.encodePacked( + metaSwap, + tokenIn, + tokenInAmount, + uint8(MetaSwapFlexibleSettlementEnforcer.ApprovalMode.Approve), + tokenOut, + recipient, + tokenOutMin +); +``` + +The enforcer permanently records successful use in a boolean mapping and temporarily caches the recipient's raw +pre-execution output balance in a separate mapping. Both mappings are keyed by the DelegationManager and delegation hash. +The balance snapshot is deleted after validation for a storage refund. Any reverted settlement atomically rolls back the +consumed flag and remains retryable. + +Approval spender and swap input token arguments must use canonical 32-byte ABI address words, including zeroed upper +bytes. Swap calldata must be at least 196 bytes: the selector, four-word static head, and two dynamic length words required +by `swap(string,address,uint256,bytes)`. + +#### Trust Assumptions + +MetaSwap's `aggregatorId` and route `data` remain unrestricted. The delegator trusts the delegate to provide safe route +data and trusts the configured MetaSwap contract and its adapters. The enforcer fixes the input token, input amount, +approval spender, approval amounts, output token, output recipient, and minimum net balance increase, but it cannot +prevent arbitrary route side effects or protect unrelated assets already approved to MetaSwap or its adapters. + +The minimum output may be satisfied by any balance increase during the execution, including unrelated transfers or token +rebases. A malicious or non-standard output token may report misleading balances. A residual input allowance may remain +if MetaSwap spends less than the approved amount. + +Deployment uses `script/DeployCaveatEnforcers.s.sol`. After recording deployed addresses, verification uses the shared +`script/verification/verify-enforcer-contracts.sh` flow. + +--- + ## Enforcer Details ### NativeTokenPaymentEnforcer diff --git a/documents/MetaSwapSpecializedDelegationManagers.md b/documents/MetaSwapSpecializedDelegationManagers.md new file mode 100644 index 00000000..ed4b3bfd --- /dev/null +++ b/documents/MetaSwapSpecializedDelegationManagers.md @@ -0,0 +1,89 @@ +# MetaSwap Specialized Delegation Managers + +These experimental managers preserve the existing three-array `redeemDelegations` entrypoint but intentionally support +only one batch/default settlement, one root delegation, and one caveat. The caveat's enforcer address must be the manager +itself because settlement enforcement is internal and no caveat hooks are called. + +`executeFromExecutor` remains on the EIP-7702 account. Managers only call it. + +## MetaSwapOrderDelegationManager + +One manager for both product intents. Terms start with a one-byte `Intent`. + +### ExactCalldata (gasless) + +Replaces `ExactExecutionBatchEnforcer` + `LimitedCallsEnforcer(1)`. The user is shown the real swap batch and signs its +hash. Expiry stays off-chain (relayer stops submitting). + +```text +terms = intent(1) | executionHash(32) +executionCallDatas[0] = ExecutionLib.encodeBatch(executions) +``` + +Require `keccak256(executionCallDatas[0]) == executionHash`, then execute the batch directly. No nested self-`execute` +wrap. Supported shapes: + +- `[swap]` +- `[approve(amount), swap]` +- `[approve(0), approve(amount), swap]` +- `[swap{value}]` native + +### FlexibleSettlement (limit order) + +Same economics as `MetaSwapFlexibleSettlementEnforcer` / the hookless manager: + +```text +terms = intent(1) | metaSwap(20) | tokenIn(20) | tokenInAmount(32) | approvalMode(1) + | tokenOut(20) | recipient(20) | tokenOutMin(32) +``` + +Redeemer supplies an encoded `Execution[]`. The manager validates approval/swap shape (not `aggregatorId` / route), +snapshots the output balance in memory, executes, then enforces `tokenOutMin`. + +## Prototype managers kept for A/B + +### MetaSwapHooklessDelegationManager + +Flexible-only. Redeemer supplies a complete ABI-encoded `Execution[]`. Validates shape, then executes. + +### MetaSwapExecutionBuilderDelegationManager + +Flexible-only. Redeemer supplies `abi.encode(aggregatorId, routeData)`. Manager constructs approvals and swap. + +Signatures try ECDSA first (EOA and EIP-7702 ETH keys). If that misses, empty accounts revert; accounts with code fall back to ERC-1271. + +`disabledDelegations` is both cancel and one-shot consumption. Failed execution or insufficient output reverts atomically. + +## Redemption gas comparison (EIP-7702) + +Measured with `gasleft()` immediately around `redeemDelegations` in +`test/MetaSwapOrderDelegationManager.t.sol`. Each pair executes the same swap shape from a one-element root delegation, +using an ECDSA signature and a zero starting output-token balance. Values include the manager call but exclude top-level +transaction intrinsic calldata gas. + +| Purpose | Swap shape | Existing `DelegationManager` path | Existing gas | Order gas | Saving | +| ------------------------ | ----------------- | --------------------------------------------- | ------------ | --------- | ------ | +| Gasless exact swap | Native swap | `ExactExecutionBatch` + `LimitedCalls(1)` | `167,475` | `96,618` | 42.3% | +| Gasless exact swap | `approve + swap` | `ExactExecutionBatch` + `LimitedCalls(1)` | `230,987` | `152,242` | 34.1% | +| Gasless exact swap | `reset + approve + swap` | `ExactExecutionBatch` + `LimitedCalls(1)` | `244,355` | `157,710` | 35.5% | +| Flexible limit order | Native swap | `MetaSwapFlexibleSettlementEnforcer` | `143,414` | `101,730` | 29.1% | +| Flexible limit order | `approve + swap` | `MetaSwapFlexibleSettlementEnforcer` | `200,783` | `158,990` | 20.8% | +| Flexible limit order | `reset + approve + swap` | `MetaSwapFlexibleSettlementEnforcer` | `207,974` | `166,072` | 20.1% | + +Takeaways: + +- Exact orders save 34–42% by replacing generic delegation loops, hook calls, full execution terms, and the + `LimitedCallsEnforcer` state/event with a specialized one-shot path. +- Flexible orders save 20–29% while retaining the existing enforcer's approval-shape, swap-calldata, and output-delta + validations. +- The specialized manager's compact redemption event is part of the measured saving; the generic manager emits the full + delegation. + +## Limitations + +- Delegation chains, multiple caveats, multiple redemption batches, self-authorized empty contexts, try mode, and generic + enforcers are intentionally unsupported. +- No on-chain expiry for exact/gasless; relayers enforce freshness off-chain. +- No `enableDelegation` or pause controls. +- Flexible route data retains the same trusted-delegate and unrelated-balance-increase assumptions as the settlement + enforcer. 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/script/DeployMetaSwapOrderDelegationManager.s.sol b/script/DeployMetaSwapOrderDelegationManager.s.sol new file mode 100644 index 00000000..ec3e9576 --- /dev/null +++ b/script/DeployMetaSwapOrderDelegationManager.s.sol @@ -0,0 +1,39 @@ +// SPDX-License-Identifier: MIT AND Apache-2.0 +pragma solidity 0.8.23; + +import "forge-std/Script.sol"; +import { console2 } from "forge-std/console2.sol"; + +import { MetaSwapOrderDelegationManager } from "../src/MetaSwapOrderDelegationManager.sol"; + +/** + * @title DeployMetaSwapOrderDelegationManager + * @notice Deploys the experimental MetaSwap order delegation manager. + * @dev Experimental. EIP-7702 accounts must be wired to this manager address. + * + * forge script script/DeployMetaSwapOrderDelegationManager.s.sol --rpc-url --private-key $PRIVATE_KEY --broadcast + * + * Env: + * - SALT + */ +contract DeployMetaSwapOrderDelegationManager is Script { + bytes32 salt; + + function setUp() public { + salt = bytes32(abi.encodePacked(vm.envString("SALT"))); + + console2.log("~~~"); + console2.log("Salt:"); + console2.logBytes32(salt); + } + + function run() public { + console2.log("~~~"); + vm.startBroadcast(); + + address deployedAddress = address(new MetaSwapOrderDelegationManager{ salt: salt }()); + console2.log("MetaSwapOrderDelegationManager: %s", deployedAddress); + + vm.stopBroadcast(); + } +} diff --git a/script/verification/VerificationInstructions.md b/script/verification/VerificationInstructions.md index 1adc138c..074b0042 100644 --- a/script/verification/VerificationInstructions.md +++ b/script/verification/VerificationInstructions.md @@ -62,6 +62,17 @@ Verifies an array of enforcer contracts. ./verify-enforcer-contracts.sh ``` +#### `verify-metaswap-order-delegation-manager.sh` + +Experimental. Verifies `MetaSwapOrderDelegationManager`. +Requires `META_SWAP_ORDER_DELEGATION_MANAGER_ADDRESS`. + +**Usage:** + +```bash +./verify-metaswap-order-delegation-manager.sh +``` + ## Notes - Ensure the `.env` file is correctly configured and contains all necessary API keys. diff --git a/script/verification/verify-metaswap-order-delegation-manager.sh b/script/verification/verify-metaswap-order-delegation-manager.sh new file mode 100755 index 00000000..5e6b2112 --- /dev/null +++ b/script/verification/verify-metaswap-order-delegation-manager.sh @@ -0,0 +1,55 @@ +#!/usr/bin/env bash +# verify-metaswap-order-delegation-manager.sh +# +# Usage: +# ./verify-metaswap-order-delegation-manager.sh +# +# Experimental. Verifies MetaSwapOrderDelegationManager across configured chains. +# Requires in .env: +# META_SWAP_ORDER_DELEGATION_MANAGER_ADDRESS + +set -e + +set -o allexport +source ../../.env +set +o allexport + +source ./verify-utils.sh + +declare -a CONTRACTS + +add_contract() { + local name="$1" + local path="$2" + local address="$3" + local constructor_args="$4" + local lib_string="$5" + CONTRACTS+=("$name:$path:$address:$constructor_args:$lib_string") +} + +add_contract \ + "MetaSwapOrderDelegationManager" \ + "src/MetaSwapOrderDelegationManager.sol" \ + "${META_SWAP_ORDER_DELEGATION_MANAGER_ADDRESS}" \ + "" \ + "" + +for contract in "${CONTRACTS[@]}"; do + IFS=':' read -r name path address constructor_args lib_string <<< "$contract" + + echo "=============================================" + echo "Verifying contract: $name" + echo "=============================================" + + verify_across_chains \ + "$path" \ + "$name" \ + "$address" \ + "$constructor_args" \ + "$lib_string" + + echo "=============================================" + echo "Completed verification for: $name" + echo "=============================================" + echo +done diff --git a/src/MetaSwapDelegationManagerBase.sol b/src/MetaSwapDelegationManagerBase.sol new file mode 100644 index 00000000..c527015a --- /dev/null +++ b/src/MetaSwapDelegationManagerBase.sol @@ -0,0 +1,170 @@ +// SPDX-License-Identifier: MIT AND Apache-2.0 +pragma solidity 0.8.23; + +import { ECDSA } from "@openzeppelin/contracts/utils/cryptography/ECDSA.sol"; +import { MessageHashUtils } from "@openzeppelin/contracts/utils/cryptography/MessageHashUtils.sol"; +import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import { IERC1271 } from "@openzeppelin/contracts/interfaces/IERC1271.sol"; +import { EIP712 } from "@openzeppelin/contracts/utils/cryptography/EIP712.sol"; + +import { EncoderLib } from "./libraries/EncoderLib.sol"; +import { ERC1271Lib } from "./libraries/ERC1271Lib.sol"; +import { DELEGATION_TYPEHASH, CAVEAT_TYPEHASH } from "./utils/Constants.sol"; +import { Caveat, Delegation, ModeCode } from "./utils/Types.sol"; + +/** + * @title MetaSwapDelegationManagerBase + * @notice Cheap one-shot redeem shell for purpose-specific MetaSwap managers. + * @dev Supports exactly one root delegation containing one manager-enforced caveat. + * No redelegation chains: `delegate` is the intended redeemer (or `ANY_DELEGATE`) and + * `authority` is `ROOT_AUTHORITY`. Redemption is `SIMPLE_BATCH_MODE` only. + * Settlement-specific decoding and min-output checks live in subclasses. + */ +abstract contract MetaSwapDelegationManagerBase is EIP712 { + string public constant DOMAIN_VERSION = "1"; + bytes32 public constant ROOT_AUTHORITY = 0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff; + address public constant ANY_DELEGATE = address(0xa11); + /// @dev Equivalent to `ModeLib.encodeSimpleBatch()` (batch calltype, default exec). + ModeCode public constant SIMPLE_BATCH_MODE = ModeCode.wrap(0x0100000000000000000000000000000000000000000000000000000000000000); + + /// @notice Records delegations that were cancelled or successfully consumed. + mapping(bytes32 delegationHash => bool isUnavailable) public disabledDelegations; + + event DisabledDelegation( + bytes32 indexed delegationHash, address indexed delegator, address indexed delegate, Delegation delegation + ); + /// @dev `intent` is the first terms byte (`Intent` on MetaSwapOrderDelegationManager). + event RedeemedDelegation(address indexed rootDelegator, address indexed redeemer, bytes32 indexed delegationHash, uint8 intent); + + error AlreadyDisabled(); + error BatchDataLengthMismatch(); + error CannotUseADisabledDelegation(); + error InsufficientOutput(); + error InvalidAuthority(); + error InvalidCaveat(); + error InvalidDelegate(); + error InvalidDelegator(); + error InvalidEOASignature(); + error InvalidERC1271Signature(); + error InvalidMode(); + error InvalidPermissionContext(); + error InvalidTerms(); + + constructor(string memory name_) EIP712(name_, DOMAIN_VERSION) { } + + /** + * @notice Cancels a settlement delegation. + * @dev Successful settlements use the same state, so consumed delegations cannot be re-enabled. + * @param delegation_ Delegation to cancel. + */ + function disableDelegation(Delegation calldata delegation_) external { + if (delegation_.delegator != msg.sender) revert InvalidDelegator(); + + bytes32 delegationHash_ = EncoderLib._getDelegationHash(delegation_); + if (disabledDelegations[delegationHash_]) revert AlreadyDisabled(); + + disabledDelegations[delegationHash_] = true; + emit DisabledDelegation(delegationHash_, delegation_.delegator, delegation_.delegate, delegation_); + } + + /** + * @notice Redeems one specialized MetaSwap delegation. + * @dev These intents are leaf delegations: `delegate` is a specific redeemer (or `ANY_DELEGATE`), + * `authority` is always `ROOT_AUTHORITY` so there is no redelegation chain, and `modes_[0]` + * must be `SIMPLE_BATCH_MODE`. + * @param permissionContexts_ Must contain one ABI-encoded one-element `Delegation[]`. + * @param modes_ Must contain the canonical batch/default mode. + * @param executionContexts_ Manager-specific execution context. + */ + function redeemDelegations( + bytes[] calldata permissionContexts_, + ModeCode[] calldata modes_, + bytes[] calldata executionContexts_ + ) + external + { + if (permissionContexts_.length != 1 || modes_.length != 1 || executionContexts_.length != 1) { + revert BatchDataLengthMismatch(); + } + if (ModeCode.unwrap(modes_[0]) != ModeCode.unwrap(SIMPLE_BATCH_MODE)) revert InvalidMode(); + + Delegation[] memory delegations_ = abi.decode(permissionContexts_[0], (Delegation[])); + if (delegations_.length != 1) revert InvalidPermissionContext(); + + Delegation memory delegation_ = delegations_[0]; + if (delegation_.delegate != msg.sender && delegation_.delegate != ANY_DELEGATE) revert InvalidDelegate(); + if (delegation_.authority != ROOT_AUTHORITY) revert InvalidAuthority(); + if (delegation_.caveats.length != 1 || delegation_.caveats[0].enforcer != address(this)) revert InvalidCaveat(); + + bytes32 delegationHash_ = _getSingleCaveatDelegationHash(delegation_); + if (disabledDelegations[delegationHash_]) revert CannotUseADisabledDelegation(); + + _validateSignature(delegation_, delegationHash_); + + disabledDelegations[delegationHash_] = true; + bytes memory terms_ = delegation_.caveats[0].terms; + _executeIntent(delegation_.delegator, terms_, executionContexts_[0]); + + emit RedeemedDelegation(delegation_.delegator, msg.sender, delegationHash_, uint8(terms_[0])); + } + + /** + * @notice Returns the EIP-712 hash used to sign a delegation. + * @param delegation_ Delegation to hash. + */ + function getDelegationHash(Delegation calldata delegation_) external pure returns (bytes32) { + return EncoderLib._getDelegationHash(delegation_); + } + + /** + * @notice Returns this manager's EIP-712 domain separator. + */ + function getDomainHash() external view returns (bytes32) { + return _domainSeparatorV4(); + } + + /** + * @notice Executes the signed intent after the one-shot lock is recorded. + * @param delegator_ Root delegator account that will execute. + * @param terms_ Signed caveat terms. + * @param executionContext_ Redeemer-supplied execution context. + */ + function _executeIntent(address delegator_, bytes memory terms_, bytes calldata executionContext_) internal virtual; + + function _validateSignature(Delegation memory delegation_, bytes32 delegationHash_) private view { + bytes32 typedDataHash_ = MessageHashUtils.toTypedDataHash(_domainSeparatorV4(), delegationHash_); + (address recovered_, ECDSA.RecoverError error_,) = ECDSA.tryRecover(typedDataHash_, delegation_.signature); + if (error_ == ECDSA.RecoverError.NoError && recovered_ == delegation_.delegator) return; + + // Codeless delegators are EOAs: a non-matching recovery cannot succeed via ERC-1271. + if (delegation_.delegator.code.length == 0) revert InvalidEOASignature(); + + if ( + IERC1271(delegation_.delegator).isValidSignature(typedDataHash_, delegation_.signature) + != ERC1271Lib.EIP1271_MAGIC_VALUE + ) { + revert InvalidERC1271Signature(); + } + } + + function _getSingleCaveatDelegationHash(Delegation memory delegation_) internal pure returns (bytes32) { + Caveat memory caveat_ = delegation_.caveats[0]; + bytes32 caveatHash_ = keccak256(abi.encode(CAVEAT_TYPEHASH, caveat_.enforcer, keccak256(caveat_.terms))); + bytes32 caveatsHash_ = keccak256(abi.encodePacked(caveatHash_)); + + return keccak256( + abi.encode( + DELEGATION_TYPEHASH, + delegation_.delegate, + delegation_.delegator, + delegation_.authority, + caveatsHash_, + delegation_.salt + ) + ); + } + + function _balanceOf(address token_, address recipient_) internal view returns (uint256) { + return token_ == address(0) ? recipient_.balance : IERC20(token_).balanceOf(recipient_); + } +} diff --git a/src/MetaSwapOrderDelegationManager.sol b/src/MetaSwapOrderDelegationManager.sol new file mode 100644 index 00000000..c2ae0629 --- /dev/null +++ b/src/MetaSwapOrderDelegationManager.sol @@ -0,0 +1,207 @@ +// SPDX-License-Identifier: MIT AND Apache-2.0 +pragma solidity 0.8.23; + +import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import { ExecutionLib } from "@erc7579/lib/ExecutionLib.sol"; + +import { MetaSwapDelegationManagerBase } from "./MetaSwapDelegationManagerBase.sol"; +import { IMetaSwap } from "./helpers/interfaces/IMetaSwap.sol"; +import { IDeleGatorCore } from "./interfaces/IDeleGatorCore.sol"; +import { Execution } from "./utils/Types.sol"; + +/** + * @title MetaSwapOrderDelegationManager + * @notice One purpose-specific manager for exact gasless swaps and flexible MetaSwap limit orders. + * @dev No external caveat hooks. Both intents redeem through a direct batch/default `executeFromExecutor`. + * + * Exact terms: `intent(1) | executionHash(32)` where `executionHash = keccak256(executionCallDatas[0])`. + * Flexible terms: `intent(1) | metaSwap(20) | tokenIn(20) | tokenInAmount(32) | approvalMode(1) | + * tokenOut(20) | recipient(20) | tokenOutMin(32)`. + */ +contract MetaSwapOrderDelegationManager is MetaSwapDelegationManagerBase { + using ExecutionLib for bytes; + + enum Intent { + ExactCalldata, + FlexibleSettlement + } + + enum ApprovalMode { + None, + SkipApproval, + Approve, + ResetApprove + } + + struct FlexibleTerms { + address metaSwap; + address tokenIn; + uint256 tokenInAmount; + ApprovalMode approvalMode; + address tokenOut; + address recipient; + uint256 tokenOutMin; + } + + string public constant NAME = "MetaSwapOrderDelegationManager"; + + uint256 private constant EXACT_TERMS_LENGTH = 33; + uint256 private constant FLEXIBLE_TERMS_LENGTH = 146; + uint256 private constant APPROVE_CALL_LENGTH = 68; + uint256 private constant SWAP_CALL_MIN_LENGTH = 196; + + error ApprovalShapeNotAllowed(); + error InvalidApproval(); + error InvalidApprovalMode(); + error InvalidBatchLength(); + error InvalidExecutionHash(); + error InvalidIntent(); + error InvalidSwap(); + + constructor() MetaSwapDelegationManagerBase(NAME) { } + + /** + * @notice Decodes exact-calldata terms. + * @param terms_ Packed as `intent(1) | executionHash(32)`. + */ + function getExactTermsInfo(bytes memory terms_) public pure returns (bytes32 executionHash_) { + if (terms_.length != EXACT_TERMS_LENGTH || uint8(terms_[0]) != uint8(Intent.ExactCalldata)) { + revert InvalidTerms(); + } + assembly ("memory-safe") { + executionHash_ := mload(add(terms_, 33)) + } + } + + /** + * @notice Decodes flexible settlement terms. + * @param terms_ Packed as `intent(1) | settlement fields(145)`. + */ + function getFlexibleTermsInfo(bytes memory terms_) public pure returns (FlexibleTerms memory termsInfo_) { + if (terms_.length != FLEXIBLE_TERMS_LENGTH || uint8(terms_[0]) != uint8(Intent.FlexibleSettlement)) { + revert InvalidTerms(); + } + + assembly ("memory-safe") { + let termsData_ := add(terms_, 0x21) + mstore(termsInfo_, shr(96, mload(termsData_))) + mstore(add(termsInfo_, 0x20), shr(96, mload(add(termsData_, 20)))) + mstore(add(termsInfo_, 0x40), mload(add(termsData_, 40))) + mstore(add(termsInfo_, 0x80), shr(96, mload(add(termsData_, 73)))) + mstore(add(termsInfo_, 0xa0), shr(96, mload(add(termsData_, 93)))) + mstore(add(termsInfo_, 0xc0), mload(add(termsData_, 113))) + } + uint8 approvalMode_ = uint8(terms_[73]); + + if ( + termsInfo_.metaSwap == address(0) || termsInfo_.tokenInAmount == 0 || termsInfo_.recipient == address(0) + || termsInfo_.tokenOutMin == 0 || termsInfo_.tokenIn == termsInfo_.tokenOut + ) { + revert InvalidTerms(); + } + if (approvalMode_ > uint8(ApprovalMode.ResetApprove)) revert InvalidApprovalMode(); + termsInfo_.approvalMode = ApprovalMode(approvalMode_); + } + + function _executeIntent(address delegator_, bytes memory terms_, bytes calldata executionContext_) internal override { + if (terms_.length == 0) revert InvalidTerms(); + + uint8 intent_ = uint8(terms_[0]); + if (intent_ == uint8(Intent.ExactCalldata)) { + _executeExact(delegator_, terms_, executionContext_); + } else if (intent_ == uint8(Intent.FlexibleSettlement)) { + _executeFlexible(delegator_, terms_, executionContext_); + } else { + revert InvalidIntent(); + } + } + + function _executeExact(address delegator_, bytes memory terms_, bytes calldata executionContext_) private { + bytes32 expectedHash_ = getExactTermsInfo(terms_); + if (keccak256(executionContext_) != expectedHash_) revert InvalidExecutionHash(); + + IDeleGatorCore(delegator_).executeFromExecutor(SIMPLE_BATCH_MODE, executionContext_); + } + + function _executeFlexible(address delegator_, bytes memory terms_, bytes calldata executionContext_) private { + FlexibleTerms memory termsInfo_ = getFlexibleTermsInfo(terms_); + Execution[] calldata executions_ = executionContext_.decodeBatch(); + _validateExecutions(executions_, termsInfo_); + + uint256 balanceBefore_ = _balanceOf(termsInfo_.tokenOut, termsInfo_.recipient); + IDeleGatorCore(delegator_).executeFromExecutor(SIMPLE_BATCH_MODE, executionContext_); + uint256 balanceAfter_ = _balanceOf(termsInfo_.tokenOut, termsInfo_.recipient); + + if (balanceAfter_ < balanceBefore_ || balanceAfter_ - balanceBefore_ < termsInfo_.tokenOutMin) { + revert InsufficientOutput(); + } + } + + function _validateExecutions(Execution[] calldata executions_, FlexibleTerms memory termsInfo_) private pure { + ApprovalMode approvalMode_ = termsInfo_.approvalMode; + + if (termsInfo_.tokenIn == address(0)) { + if (approvalMode_ != ApprovalMode.None) revert InvalidApprovalMode(); + if (executions_.length != 1) revert InvalidBatchLength(); + _validateSwap(executions_[0], termsInfo_.metaSwap, address(0), termsInfo_.tokenInAmount, termsInfo_.tokenInAmount); + return; + } + + if (approvalMode_ == ApprovalMode.SkipApproval) { + if (executions_.length != 1) revert ApprovalShapeNotAllowed(); + _validateSwap(executions_[0], termsInfo_.metaSwap, termsInfo_.tokenIn, termsInfo_.tokenInAmount, 0); + } else if (approvalMode_ == ApprovalMode.Approve) { + if (executions_.length != 2) revert ApprovalShapeNotAllowed(); + _validateApproval(executions_[0], termsInfo_.tokenIn, termsInfo_.metaSwap, termsInfo_.tokenInAmount); + _validateSwap(executions_[1], termsInfo_.metaSwap, termsInfo_.tokenIn, termsInfo_.tokenInAmount, 0); + } else if (approvalMode_ == ApprovalMode.ResetApprove) { + if (executions_.length != 3) revert ApprovalShapeNotAllowed(); + _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); + } else { + revert InvalidApprovalMode(); + } + } + + 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 InvalidApproval(); + } + } + + 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 InvalidSwap(); + } + } +} diff --git a/src/enforcers/MetaSwap7702CalldataEnforcer.sol b/src/enforcers/MetaSwap7702CalldataEnforcer.sol new file mode 100644 index 00000000..a24478aa --- /dev/null +++ b/src/enforcers/MetaSwap7702CalldataEnforcer.sol @@ -0,0 +1,173 @@ +// SPDX-License-Identifier: MIT AND Apache-2.0 +pragma solidity 0.8.23; + +import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import { ExecutionLib } from "@erc7579/lib/ExecutionLib.sol"; +import { ModeLib } from "@erc7579/lib/ModeLib.sol"; + +import { CaveatEnforcer } from "./CaveatEnforcer.sol"; +import { IMetaSwap } from "../helpers/interfaces/IMetaSwap.sol"; +import { IERC7821 } from "../interfaces/IERC7821.sol"; +import { Execution, ModeCode } from "../utils/Types.sol"; + +/** + * @title MetaSwap7702CalldataEnforcer + * @notice Validates a direct MetaSwap limit-order call nested inside one EIP-7702 self-call batch. + * @dev The DelegationManager sees one single execution targeting the delegator: + * `delegator.execute(BATCH_DEFAULT_MODE, innerExecutions)`. + * + * ERC-20 input supports one of two signed shapes: + * - `[approve(metaSwap, tokenInAmount), swap(...)]` + * - `[approve(metaSwap, 0), approve(metaSwap, tokenInAmount), swap(...)]` + * + * Native input requires `[swap{ value: tokenInAmount }(...)]`. + * + * @dev MetaSwap's aggregator ID and dynamic route bytes remain completely flexible. The enforcer reads only the static + * `tokenFrom` and `amount` ABI words, avoiding allocation or decoding of either dynamic argument. Output token and + * minimum output are intentionally enforced by a separate NativeBalanceChangeEnforcer or ERC20BalanceChangeEnforcer. + * Address zero represents native input. + */ +contract MetaSwap7702CalldataEnforcer is CaveatEnforcer { + using ExecutionLib for bytes; + + struct Terms { + address metaSwap; + address tokenIn; + uint256 tokenInAmount; + bool resetApproval; + } + + uint256 private constant TERMS_LENGTH = 73; + uint256 private constant OUTER_CALL_MIN_LENGTH = 100; + uint256 private constant OUTER_DYNAMIC_OFFSET = 64; + uint256 private constant APPROVE_CALL_LENGTH = 68; + uint256 private constant SWAP_CALL_MIN_LENGTH = 132; + + /** + * @notice Validates the fixed security fields while leaving MetaSwap route selection flexible. + * @param terms_ Packed as `metaSwap(20) | tokenIn(20) | tokenInAmount(32) | resetApproval(1)`. + * @param mode_ DelegationManager execution mode; must be single/default. + * @param executionCallData_ Packed outer single execution targeting the delegator's 7702 account. + * @param delegator_ Root 7702 account that must be the outer execution target. + */ + function beforeHook( + bytes calldata terms_, + bytes calldata, + ModeCode mode_, + bytes calldata executionCallData_, + bytes32, + address delegator_, + address + ) + public + pure + override + onlySingleCallTypeMode(mode_) + onlyDefaultExecutionMode(mode_) + { + Terms memory termsInfo_ = getTermsInfo(terms_); + (address outerTarget_, uint256 outerValue_, bytes calldata outerCallData_) = executionCallData_.decodeSingle(); + + require(outerTarget_ == delegator_ && outerValue_ == 0, "MetaSwap7702CalldataEnforcer:invalid-outer-execution"); + + bytes calldata innerExecutionCallData_ = _decodeOuterExecute(outerCallData_); + Execution[] calldata executions_ = innerExecutionCallData_.decodeBatch(); + + if (termsInfo_.tokenIn == address(0)) { + require(!termsInfo_.resetApproval && executions_.length == 1, "MetaSwap7702CalldataEnforcer:invalid-batch-length"); + _validateSwap( + executions_[0], termsInfo_.metaSwap, termsInfo_.tokenIn, termsInfo_.tokenInAmount, termsInfo_.tokenInAmount + ); + return; + } + + if (termsInfo_.resetApproval) { + require(executions_.length == 3, "MetaSwap7702CalldataEnforcer:invalid-batch-length"); + _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); + } else { + require(executions_.length == 2, "MetaSwap7702CalldataEnforcer:invalid-batch-length"); + _validateApproval(executions_[0], termsInfo_.tokenIn, termsInfo_.metaSwap, termsInfo_.tokenInAmount); + _validateSwap(executions_[1], termsInfo_.metaSwap, termsInfo_.tokenIn, termsInfo_.tokenInAmount, 0); + } + } + + /** + * @notice Decodes the packed signed terms. + * @return termsInfo_ Decoded MetaSwap, input token, input amount, and approval shape. + */ + function getTermsInfo(bytes calldata terms_) public pure returns (Terms memory termsInfo_) { + require(terms_.length == TERMS_LENGTH, "MetaSwap7702CalldataEnforcer:invalid-terms"); + + termsInfo_.metaSwap = address(bytes20(terms_[0:20])); + termsInfo_.tokenIn = address(bytes20(terms_[20:40])); + termsInfo_.tokenInAmount = uint256(bytes32(terms_[40:72])); + uint8 resetApprovalValue_ = uint8(terms_[72]); + + require( + termsInfo_.metaSwap != address(0) && termsInfo_.tokenInAmount != 0 && resetApprovalValue_ <= 1, + "MetaSwap7702CalldataEnforcer:invalid-terms" + ); + termsInfo_.resetApproval = resetApprovalValue_ == 1; + } + + function _decodeOuterExecute(bytes calldata callData_) private pure returns (bytes calldata innerExecutionCallData_) { + if (callData_.length < OUTER_CALL_MIN_LENGTH || bytes4(callData_[0:4]) != IERC7821.execute.selector) { + revert("MetaSwap7702CalldataEnforcer:invalid-outer-execution"); + } + if (ModeCode.unwrap(ModeCode.wrap(bytes32(callData_[4:36]))) != ModeCode.unwrap(ModeLib.encodeSimpleBatch())) { + revert("MetaSwap7702CalldataEnforcer:invalid-inner-mode"); + } + require(uint256(bytes32(callData_[36:68])) == OUTER_DYNAMIC_OFFSET, "MetaSwap7702CalldataEnforcer:invalid-inner-encoding"); + + uint256 innerLength_ = uint256(bytes32(callData_[68:100])); + require(innerLength_ <= callData_.length - OUTER_CALL_MIN_LENGTH, "MetaSwap7702CalldataEnforcer:invalid-inner-encoding"); + + uint256 paddedInnerLength_ = (innerLength_ + 31) & ~uint256(31); + require( + callData_.length == OUTER_CALL_MIN_LENGTH + paddedInnerLength_, "MetaSwap7702CalldataEnforcer:invalid-inner-encoding" + ); + + innerExecutionCallData_ = callData_[OUTER_CALL_MIN_LENGTH:OUTER_CALL_MIN_LENGTH + innerLength_]; + } + + function _validateApproval( + Execution calldata execution_, + address tokenIn_, + address metaSwap_, + uint256 expectedAmount_ + ) + private + pure + { + if ( + execution_.target != tokenIn_ || execution_.value != 0 || execution_.callData.length != APPROVE_CALL_LENGTH + || bytes4(execution_.callData[0:4]) != IERC20.approve.selector + || address(uint160(uint256(bytes32(execution_.callData[4:36])))) != metaSwap_ + || uint256(bytes32(execution_.callData[36:68])) != expectedAmount_ + ) { + revert("MetaSwap7702CalldataEnforcer:invalid-approval"); + } + } + + function _validateSwap( + Execution calldata execution_, + address metaSwap_, + address tokenIn_, + uint256 tokenInAmount_, + uint256 expectedValue_ + ) + private + pure + { + if ( + execution_.target != metaSwap_ || execution_.value != expectedValue_ + || execution_.callData.length < SWAP_CALL_MIN_LENGTH || bytes4(execution_.callData[0:4]) != IMetaSwap.swap.selector + || address(uint160(uint256(bytes32(execution_.callData[36:68])))) != tokenIn_ + || uint256(bytes32(execution_.callData[68:100])) != tokenInAmount_ + ) { + revert("MetaSwap7702CalldataEnforcer:invalid-swap"); + } + } +} diff --git a/src/enforcers/MetaSwapApproveSwapEnforcer.sol b/src/enforcers/MetaSwapApproveSwapEnforcer.sol new file mode 100644 index 00000000..7e85ca3d --- /dev/null +++ b/src/enforcers/MetaSwapApproveSwapEnforcer.sol @@ -0,0 +1,163 @@ +// SPDX-License-Identifier: MIT AND Apache-2.0 +pragma solidity 0.8.23; + +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 MetaSwapApproveSwapEnforcer + * @notice Enforces a one-shot batch of ERC20 approval followed by a direct MetaSwap swap. + * @dev Supports `approve(amount), swap` and `approve(0), approve(amount), swap`. + */ +contract MetaSwapApproveSwapEnforcer is CaveatEnforcer { + using ExecutionLib for bytes; + + struct Terms { + address metaSwap; + address tokenIn; + address tokenOut; + uint256 tokenInAmount; + uint256 minTokenOut; + } + + struct OutputSnapshot { + uint256 balanceBefore; + bool active; + } + + mapping(address manager => mapping(bytes32 delegationHash => bool used)) public usedDelegations; + mapping(address manager => mapping(bytes32 delegationHash => OutputSnapshot snapshot)) public outputSnapshots; + + event DelegationExecuted(address indexed delegationManager, bytes32 indexed delegationHash, address indexed delegator); + + function beforeHook( + bytes calldata _terms, + bytes calldata, + ModeCode _mode, + bytes calldata _executionCallData, + bytes32 _delegationHash, + address _delegator, + address + ) + public + override + onlyBatchCallTypeMode(_mode) + onlyDefaultExecutionMode(_mode) + { + require(!usedDelegations[msg.sender][_delegationHash], "MetaSwapApproveSwapEnforcer:delegation-already-used"); + + Terms memory terms_ = abi.decode(_terms, (Terms)); + require(terms_.tokenIn != terms_.tokenOut, "MetaSwapApproveSwapEnforcer:identical-tokens"); + + Execution[] calldata executions_ = _executionCallData.decodeBatch(); + + if (executions_.length == 2) { + _validateApprove(executions_[0], terms_, terms_.tokenInAmount); + _validateSwap(executions_[1], terms_); + } else if (executions_.length == 3) { + _validateApprove(executions_[0], terms_, 0); + _validateApprove(executions_[1], terms_, terms_.tokenInAmount); + _validateSwap(executions_[2], terms_); + } else { + revert("MetaSwapApproveSwapEnforcer:invalid-batch-length"); + } + + OutputSnapshot storage snapshot_ = outputSnapshots[msg.sender][_delegationHash]; + require(!snapshot_.active, "MetaSwapApproveSwapEnforcer:output-snapshot-active"); + snapshot_.active = true; + snapshot_.balanceBefore = IERC20(terms_.tokenOut).balanceOf(_delegator); + usedDelegations[msg.sender][_delegationHash] = true; + + emit DelegationExecuted(msg.sender, _delegationHash, _delegator); + } + + function afterHook( + bytes calldata _terms, + bytes calldata, + ModeCode, + bytes calldata, + bytes32 _delegationHash, + address _delegator, + address + ) + public + override + { + Terms memory terms_ = abi.decode(_terms, (Terms)); + OutputSnapshot memory snapshot_ = outputSnapshots[msg.sender][_delegationHash]; + delete outputSnapshots[msg.sender][_delegationHash]; + + uint256 remainingAllowance_ = IERC20(terms_.tokenIn).allowance(_delegator, terms_.metaSwap); + require(remainingAllowance_ == 0, "MetaSwapApproveSwapEnforcer:remaining-allowance"); + + uint256 received_ = IERC20(terms_.tokenOut).balanceOf(_delegator) - snapshot_.balanceBefore; + require(received_ >= terms_.minTokenOut, "MetaSwapApproveSwapEnforcer:insufficient-output"); + } + + function _validateApprove(Execution calldata _execution, Terms memory _terms, uint256 _expectedAmount) private pure { + require( + _execution.target == _terms.tokenIn && _execution.value == 0, + "MetaSwapApproveSwapEnforcer:invalid-approve-call" + ); + require( + bytes4(_execution.callData[:4]) == IERC20.approve.selector, + "MetaSwapApproveSwapEnforcer:invalid-approve-call" + ); + + (address spender_, uint256 amount_) = abi.decode(_execution.callData[4:], (address, uint256)); + require( + spender_ == _terms.metaSwap && amount_ == _expectedAmount, + "MetaSwapApproveSwapEnforcer:invalid-approve-call" + ); + } + + function _validateSwap(Execution calldata _execution, Terms memory _terms) private pure { + require( + _execution.target == _terms.metaSwap && _execution.value == 0, + "MetaSwapApproveSwapEnforcer:invalid-swap-call" + ); + require( + bytes4(_execution.callData[:4]) == IMetaSwap.swap.selector, "MetaSwapApproveSwapEnforcer:invalid-swap-call" + ); + + (string memory aggregatorId_, IERC20 tokenFrom_, uint256 amountFrom_, bytes memory swapData_) = + abi.decode(_execution.callData[4:], (string, IERC20, uint256, bytes)); + aggregatorId_; + + require( + address(tokenFrom_) == _terms.tokenIn && amountFrom_ == _terms.tokenInAmount, + "MetaSwapApproveSwapEnforcer:invalid-swap-call" + ); + + (, // address(0) + IERC20 swapTokenFrom_, + IERC20 swapTokenTo_, + uint256 swapAmountFrom_, + uint256 amountTo_,, // metadata + uint256 feeAmount_,, // feeWallet + bool feeTo_ + ) = abi.decode( + abi.encodePacked(abi.encode(address(0)), swapData_), + (address, IERC20, IERC20, uint256, uint256, bytes, uint256, address, bool) + ); + + require( + swapTokenFrom_ == tokenFrom_ && address(swapTokenTo_) == _terms.tokenOut, + "MetaSwapApproveSwapEnforcer:invalid-swap-call" + ); + require(feeTo_ || feeAmount_ + swapAmountFrom_ == amountFrom_, "MetaSwapApproveSwapEnforcer:amount-from-mismatch"); + require(amountTo_ >= _terms.minTokenOut, "MetaSwapApproveSwapEnforcer:invalid-swap-call"); + } + + function getTermsInfo(bytes calldata _terms) external pure returns (Terms memory terms_) { + terms_ = abi.decode(_terms, (Terms)); + } + + function encodeTerms(Terms calldata _terms) external pure returns (bytes memory) { + return abi.encode(_terms); + } +} diff --git a/src/enforcers/MetaSwapBatchCalldataEnforcer.sol b/src/enforcers/MetaSwapBatchCalldataEnforcer.sol new file mode 100644 index 00000000..d2ab7282 --- /dev/null +++ b/src/enforcers/MetaSwapBatchCalldataEnforcer.sol @@ -0,0 +1,137 @@ +// SPDX-License-Identifier: MIT AND Apache-2.0 +pragma solidity 0.8.23; + +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 MetaSwapBatchCalldataEnforcer + * @notice Restricts a direct DelegationManager batch while leaving MetaSwap route selection flexible. + * @dev Supported signed shapes: + * - Native: `[swap{ value: tokenInAmount }(...)]` + * - ERC-20: `[approve(metaSwap, tokenInAmount), swap(...)]` + * - ERC-20 reset: `[approve(metaSwap, 0), approve(metaSwap, tokenInAmount), swap(...)]` + * + * MetaSwap's dynamic `aggregatorId` and route `data` are never copied or decoded. The enforcer reads only the selector, + * `tokenFrom`, and `amount` words from swap calldata. Output constraints belong in a separate balance-change enforcer. + */ +contract MetaSwapBatchCalldataEnforcer is CaveatEnforcer { + using ExecutionLib for bytes; + + struct Terms { + address metaSwap; + address tokenIn; + uint256 tokenInAmount; + bool resetApproval; + } + + uint256 private constant TERMS_LENGTH = 73; + uint256 private constant APPROVE_CALL_LENGTH = 68; + uint256 private constant SWAP_CALL_MIN_LENGTH = 132; + + /** + * @notice Validates the direct batch shape and all security-relevant static fields. + * @param terms_ Packed `metaSwap(20) | tokenIn(20) | tokenInAmount(32) | resetApproval(1)`. + * @param mode_ DelegationManager execution mode; must be batch/default. + * @param executionCallData_ ABI-encoded `Execution[]`. + */ + function beforeHook( + bytes calldata terms_, + bytes calldata, + ModeCode mode_, + bytes calldata executionCallData_, + bytes32, + address, + address + ) + public + pure + override + onlyBatchCallTypeMode(mode_) + onlyDefaultExecutionMode(mode_) + { + Terms memory termsInfo_ = getTermsInfo(terms_); + Execution[] calldata executions_ = executionCallData_.decodeBatch(); + + if (termsInfo_.tokenIn == address(0)) { + require(!termsInfo_.resetApproval && executions_.length == 1, "MetaSwapBatchCalldataEnforcer:invalid-batch-length"); + _validateSwap(executions_[0], termsInfo_.metaSwap, address(0), termsInfo_.tokenInAmount, termsInfo_.tokenInAmount); + return; + } + + if (termsInfo_.resetApproval) { + require(executions_.length == 3, "MetaSwapBatchCalldataEnforcer:invalid-batch-length"); + _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); + } else { + require(executions_.length == 2, "MetaSwapBatchCalldataEnforcer:invalid-batch-length"); + _validateApproval(executions_[0], termsInfo_.tokenIn, termsInfo_.metaSwap, termsInfo_.tokenInAmount); + _validateSwap(executions_[1], termsInfo_.metaSwap, termsInfo_.tokenIn, termsInfo_.tokenInAmount, 0); + } + } + + /** + * @notice Decodes the compact signed terms. + * @param terms_ Packed batch constraints. + * @return termsInfo_ Decoded MetaSwap, input token, amount, and approval shape. + */ + function getTermsInfo(bytes calldata terms_) public pure returns (Terms memory termsInfo_) { + require(terms_.length == TERMS_LENGTH, "MetaSwapBatchCalldataEnforcer:invalid-terms"); + + termsInfo_.metaSwap = address(bytes20(terms_[0:20])); + termsInfo_.tokenIn = address(bytes20(terms_[20:40])); + termsInfo_.tokenInAmount = uint256(bytes32(terms_[40:72])); + uint8 resetApprovalValue_ = uint8(terms_[72]); + require( + termsInfo_.metaSwap != address(0) && termsInfo_.tokenInAmount != 0 && resetApprovalValue_ <= 1, + "MetaSwapBatchCalldataEnforcer:invalid-terms" + ); + termsInfo_.resetApproval = resetApprovalValue_ == 1; + } + + 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 + || address(uint160(uint256(bytes32(callData_[4:36])))) != metaSwap_ + || uint256(bytes32(callData_[36:68])) != expectedAmount_ + ) { + revert("MetaSwapBatchCalldataEnforcer: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 + || address(uint160(uint256(bytes32(callData_[36:68])))) != tokenIn_ + || uint256(bytes32(callData_[68:100])) != tokenInAmount_ + ) { + revert("MetaSwapBatchCalldataEnforcer:invalid-swap"); + } + } +} diff --git a/src/enforcers/MetaSwapFlexibleSettlementEnforcer.sol b/src/enforcers/MetaSwapFlexibleSettlementEnforcer.sol new file mode 100644 index 00000000..9d1806b8 --- /dev/null +++ b/src/enforcers/MetaSwapFlexibleSettlementEnforcer.sol @@ -0,0 +1,241 @@ +// SPDX-License-Identifier: MIT AND Apache-2.0 +pragma solidity 0.8.23; + +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 Authorizes one MetaSwap settlement with a redeemer-selected route, exact input, and minimum output. + * @dev The settlement combines batch validation, one-shot consumption, and output enforcement. It accepts: + * - Native: `[swap{ value: tokenInAmount }(...)]` + * - ERC-20 without approval: `[swap(...)]` + * - ERC-20 with approval: `[approve(metaSwap, tokenInAmount), swap(...)]` + * - ERC-20 with reset: `[approve(metaSwap, 0), approve(metaSwap, tokenInAmount), swap(...)]` + * + * The signed approval mode selects one exact ERC-20 shape. MetaSwap's dynamic `aggregatorId` and route `data` + * remain unrestricted. The configured MetaSwap contract and its adapters must therefore be trusted. + */ +contract MetaSwapFlexibleSettlementEnforcer is CaveatEnforcer { + 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; + } + + uint256 private constant TERMS_LENGTH = 145; + uint256 private constant APPROVE_CALL_LENGTH = 68; + // Selector + four-word head + two dynamic length words. + uint256 private constant SWAP_CALL_MIN_LENGTH = 196; + + /// @notice Records settlements that have already been used. + mapping(bytes32 settlementKey => bool isUsed) public consumedSettlements; + + /// @dev Caches the recipient's balance between the DelegationManager's before and after hooks. + mapping(bytes32 settlementKey => uint256 balanceBefore) private balanceSnapshots; + + /** + * @notice Emitted after a settlement satisfies its minimum output and is permanently consumed. + * @param delegationManager DelegationManager that redeemed the settlement. + * @param delegationHash Hash identifying the signed delegation. + * @param redeemer Address that submitted the redemption. + */ + event SettlementConsumed(address indexed delegationManager, bytes32 indexed delegationHash, address indexed redeemer); + + /** + * @notice Returns the storage key used to isolate a settlement. + * @param delegationManager_ DelegationManager that redeems the delegation. + * @param delegationHash_ Hash identifying the delegation. + */ + function getSettlementKey(address delegationManager_, bytes32 delegationHash_) external pure returns (bytes32) { + return _getSettlementKey(delegationManager_, delegationHash_); + } + + /** + * @notice Validates the batch, caches the output balance, and locks the settlement against reuse. + * @param terms_ Packed settlement constraints. + * @param mode_ Execution mode; must be batch/default. + * @param executionCallData_ ABI-encoded `Execution[]`. + * @param delegationHash_ Hash identifying the signed delegation. + */ + function beforeHook( + bytes calldata terms_, + bytes calldata, + ModeCode mode_, + bytes calldata executionCallData_, + bytes32 delegationHash_, + address, + address + ) + public + override + onlyBatchCallTypeMode(mode_) + onlyDefaultExecutionMode(mode_) + { + Terms memory termsInfo_ = getTermsInfo(terms_); + Execution[] calldata executions_ = executionCallData_.decodeBatch(); + _validateExecutions(executions_, termsInfo_); + + bytes32 settlementKey_ = _getSettlementKey(msg.sender, delegationHash_); + require(!consumedSettlements[settlementKey_], "MetaSwapFlexibleSettlementEnforcer:settlement-already-used"); + + consumedSettlements[settlementKey_] = true; + balanceSnapshots[settlementKey_] = _balanceOf(termsInfo_.tokenOut, termsInfo_.recipient); + } + + /** + * @notice Enforces the minimum output and permanently consumes the successful settlement. + * @param terms_ Packed settlement constraints. + * @param delegationHash_ Hash identifying the signed delegation. + * @param redeemer_ Address that submitted the redemption. + */ + function afterHook( + bytes calldata terms_, + bytes calldata, + ModeCode, + bytes calldata, + bytes32 delegationHash_, + address, + address redeemer_ + ) + public + override + { + require(terms_.length == TERMS_LENGTH, "MetaSwapFlexibleSettlementEnforcer:invalid-terms"); + + 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 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_); + } + + /** + * @notice Decodes and validates signed settlement terms. + * @param terms_ Packed as + * `metaSwap(20) | tokenIn(20) | tokenInAmount(32) | approvalMode(1) | tokenOut(20) | recipient(20) | tokenOutMin(32)`. + */ + function getTermsInfo(bytes calldata terms_) public pure returns (Terms memory termsInfo_) { + require(terms_.length == TERMS_LENGTH, "MetaSwapFlexibleSettlementEnforcer:invalid-terms"); + + 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])); + + require( + termsInfo_.metaSwap != address(0) && termsInfo_.tokenInAmount != 0 && termsInfo_.recipient != address(0) + && termsInfo_.tokenOutMin != 0 && termsInfo_.tokenIn != termsInfo_.tokenOut, + "MetaSwapFlexibleSettlementEnforcer:invalid-terms" + ); + + require(approvalMode_ <= uint8(ApprovalMode.ResetApprove), "MetaSwapFlexibleSettlementEnforcer:invalid-approval-mode"); + termsInfo_.approvalMode = ApprovalMode(approvalMode_); + } + + function _validateExecutions(Execution[] calldata executions_, Terms memory termsInfo_) private pure { + ApprovalMode approvalMode_ = termsInfo_.approvalMode; + + if (termsInfo_.tokenIn == address(0)) { + require(approvalMode_ == ApprovalMode.None, "MetaSwapFlexibleSettlementEnforcer:invalid-approval-mode"); + require(executions_.length == 1, "MetaSwapFlexibleSettlementEnforcer:invalid-batch-length"); + _validateSwap(executions_[0], termsInfo_.metaSwap, address(0), termsInfo_.tokenInAmount, termsInfo_.tokenInAmount); + return; + } + + 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 if (approvalMode_ == ApprovalMode.ResetApprove) { + 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); + } else { + revert("MetaSwapFlexibleSettlementEnforcer:invalid-approval-mode"); + } + } + + 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/src/experiments/GaslessSwapDelegationManager.sol b/src/experiments/GaslessSwapDelegationManager.sol new file mode 100644 index 00000000..b9f0fa07 --- /dev/null +++ b/src/experiments/GaslessSwapDelegationManager.sol @@ -0,0 +1,271 @@ +// SPDX-License-Identifier: MIT AND Apache-2.0 +pragma solidity 0.8.23; + +import { ECDSA } from "@openzeppelin/contracts/utils/cryptography/ECDSA.sol"; +import { EIP712 } from "@openzeppelin/contracts/utils/cryptography/EIP712.sol"; +import { IERC1271 } from "@openzeppelin/contracts/interfaces/IERC1271.sol"; +import { MessageHashUtils } from "@openzeppelin/contracts/utils/cryptography/MessageHashUtils.sol"; + +import { ICaveatEnforcer } from "../interfaces/ICaveatEnforcer.sol"; +import { IDelegationManager } from "../interfaces/IDelegationManager.sol"; +import { IDeleGatorCore } from "../interfaces/IDeleGatorCore.sol"; +import { EncoderLib } from "../libraries/EncoderLib.sol"; +import { ERC1271Lib } from "../libraries/ERC1271Lib.sol"; +import { Caveat, Delegation, ModeCode } from "../utils/Types.sol"; + +/** + * @title GaslessSwapDelegationManager + * @notice Specialized ERC-7710 manager for exact one-shot swaps and one-shot limit orders. + * @dev Supports one root delegation and one redemption per call. Two signed caveat profiles are accepted: + * + * Gasless swap: + * 1. ExactExecutionEnforcer + * 2. LimitedCallsEnforcer with limit = 1 + * + * Limit order: + * 1. ExactExecutionEnforcer OR MetaSwap7702CalldataEnforcer + * 2. LimitedCallsEnforcer with limit = 1 + * 3. NativeBalanceChangeEnforcer OR ERC20BalanceChangeEnforcer, configured for a minimum increase + * of the root delegator's output-token balance. + * + * @dev Existing enforcers are reused unchanged. The manager invokes every `beforeHook`, executes once through + * `executeFromExecutor`, and invokes only the limit-order balance caveat's `afterHook`. Reverted executions or + * insufficient output revert the LimitedCalls counter too, so the same signed order remains retryable. + * @dev Designed for {EIP7702MultiManagerDeleGatorCore}: this manager must first be approved by the 7702 account. + */ +contract GaslessSwapDelegationManager is EIP712 { + using MessageHashUtils for bytes32; + + string public constant NAME = "GaslessSwapDelegationManager"; + string public constant VERSION = "1.0.0"; + string public constant DOMAIN_VERSION = "1"; + + bytes32 public constant ROOT_AUTHORITY = bytes32(type(uint256).max); + address public constant ANY_DELEGATE = address(0xa11); + + address public immutable exactExecutionEnforcer; + address public immutable metaSwap7702CalldataEnforcer; + address public immutable limitedCallsEnforcer; + address public immutable nativeBalanceChangeEnforcer; + address public immutable erc20BalanceChangeEnforcer; + + mapping(bytes32 delegationHash => bool isDisabled) public disabledDelegations; + + event RedeemedDelegation(address indexed rootDelegator, address indexed redeemer, bytes32 indexed delegationHash); + + error InvalidConfiguration(); + error InvalidProfile(); + error InvalidBalanceTerms(); + error InvalidDelegatorAccount(); + + modifier onlyDeleGator(address delegator_) { + if (delegator_ != msg.sender) revert IDelegationManager.InvalidDelegator(); + _; + } + + /** + * @notice Configures the only enforcers accepted by this manager. + * @param exactExecutionEnforcer_ ExactExecutionEnforcer deployment. + * @param metaSwap7702CalldataEnforcer_ MetaSwap7702CalldataEnforcer deployment. + * @param limitedCallsEnforcer_ LimitedCallsEnforcer deployment. + * @param nativeBalanceChangeEnforcer_ NativeBalanceChangeEnforcer deployment. + * @param erc20BalanceChangeEnforcer_ ERC20BalanceChangeEnforcer deployment. + */ + constructor( + address exactExecutionEnforcer_, + address metaSwap7702CalldataEnforcer_, + address limitedCallsEnforcer_, + address nativeBalanceChangeEnforcer_, + address erc20BalanceChangeEnforcer_ + ) + EIP712(NAME, DOMAIN_VERSION) + { + if ( + exactExecutionEnforcer_ == address(0) || metaSwap7702CalldataEnforcer_ == address(0) + || limitedCallsEnforcer_ == address(0) + || nativeBalanceChangeEnforcer_ == address(0) || erc20BalanceChangeEnforcer_ == address(0) + ) { + revert InvalidConfiguration(); + } + + exactExecutionEnforcer = exactExecutionEnforcer_; + metaSwap7702CalldataEnforcer = metaSwap7702CalldataEnforcer_; + limitedCallsEnforcer = limitedCallsEnforcer_; + nativeBalanceChangeEnforcer = nativeBalanceChangeEnforcer_; + erc20BalanceChangeEnforcer = erc20BalanceChangeEnforcer_; + + emit IDelegationManager.SetDomain(_domainSeparatorV4(), NAME, DOMAIN_VERSION, block.chainid, address(this)); + } + + /** + * @notice Permanently disables a signed swap or limit-order delegation. + * @param delegation_ Delegation being cancelled by its delegator. + */ + function disableDelegation(Delegation calldata delegation_) external onlyDeleGator(delegation_.delegator) { + bytes32 delegationHash_ = getDelegationHash(delegation_); + if (disabledDelegations[delegationHash_]) revert IDelegationManager.AlreadyDisabled(); + + disabledDelegations[delegationHash_] = true; + emit IDelegationManager.DisabledDelegation(delegationHash_, delegation_.delegator, delegation_.delegate, delegation_); + } + + /** + * @notice Redeems one exact gasless swap or one exact limit order. + * @param permissionContexts_ Exactly one `abi.encode(Delegation[])` containing one root delegation. + * @param modes_ Exactly one execution mode; ExactExecutionEnforcer requires single/default mode. + * @param executionCallDatas_ Exactly one packed single execution. + */ + function redeemDelegations( + bytes[] calldata permissionContexts_, + ModeCode[] calldata modes_, + bytes[] calldata executionCallDatas_ + ) + external + { + if (permissionContexts_.length != 1 || modes_.length != 1 || executionCallDatas_.length != 1) { + revert IDelegationManager.BatchDataLengthMismatch(); + } + + Delegation[] memory delegations_ = abi.decode(permissionContexts_[0], (Delegation[])); + if (delegations_.length != 1) revert InvalidProfile(); + + Delegation memory delegation_ = delegations_[0]; + if (delegation_.delegate != msg.sender && delegation_.delegate != ANY_DELEGATE) { + revert IDelegationManager.InvalidDelegate(); + } + if (delegation_.authority != ROOT_AUTHORITY) revert IDelegationManager.InvalidAuthority(); + if (delegation_.delegator.code.length == 0) revert InvalidDelegatorAccount(); + + bool isLimitOrder_ = _validateProfile(delegation_); + bytes32 delegationHash_ = EncoderLib._getDelegationHash(delegation_); + + _validateSignature( + delegation_.delegator, MessageHashUtils.toTypedDataHash(_domainSeparatorV4(), delegationHash_), delegation_.signature + ); + if (disabledDelegations[delegationHash_]) revert IDelegationManager.CannotUseADisabledDelegation(); + + Caveat[] memory caveats_ = delegation_.caveats; + uint256 caveatCount_ = caveats_.length; + for (uint256 i; i < caveatCount_; ++i) { + _beforeHook(caveats_[i], modes_[0], executionCallDatas_[0], delegationHash_, delegation_.delegator); + } + + IDeleGatorCore(delegation_.delegator).executeFromExecutor(modes_[0], executionCallDatas_[0]); + + if (isLimitOrder_) { + Caveat memory balanceCaveat_ = caveats_[2]; + ICaveatEnforcer(balanceCaveat_.enforcer) + .afterHook( + balanceCaveat_.terms, + balanceCaveat_.args, + modes_[0], + executionCallDatas_[0], + delegationHash_, + delegation_.delegator, + msg.sender + ); + } + + emit RedeemedDelegation(delegation_.delegator, msg.sender, delegationHash_); + } + + /** + * @notice Returns this manager's EIP-712 domain separator. + * @return domainHash_ Current domain separator. + */ + function getDomainHash() public view returns (bytes32 domainHash_) { + domainHash_ = _domainSeparatorV4(); + } + + /** + * @notice Hashes a delegation without its signature or caveat args. + * @param delegation_ Delegation to hash. + * @return delegationHash_ Delegation struct hash. + */ + function getDelegationHash(Delegation calldata delegation_) public pure returns (bytes32 delegationHash_) { + delegationHash_ = EncoderLib._getDelegationHash(delegation_); + } + + function _validateProfile(Delegation memory delegation_) private view returns (bool isLimitOrder_) { + Caveat[] memory caveats_ = delegation_.caveats; + uint256 count_ = caveats_.length; + if (count_ != 2 && count_ != 3) revert InvalidProfile(); + + bool exactExecution_ = caveats_[0].enforcer == exactExecutionEnforcer; + bool flexibleMetaSwap_ = caveats_[0].enforcer == metaSwap7702CalldataEnforcer; + if ( + (!exactExecution_ && !flexibleMetaSwap_) || (count_ == 2 && !exactExecution_) + || caveats_[1].enforcer != limitedCallsEnforcer + || caveats_[0].args.length != 0 || caveats_[1].args.length != 0 || caveats_[1].terms.length != 32 + || _loadWord(caveats_[1].terms, 0) != 1 + ) { + revert InvalidProfile(); + } + + if (count_ == 2) return false; + + Caveat memory balanceCaveat_ = caveats_[2]; + if (balanceCaveat_.args.length != 0) revert InvalidProfile(); + + if (balanceCaveat_.enforcer == nativeBalanceChangeEnforcer) { + _validateNativeBalanceTerms(balanceCaveat_.terms, delegation_.delegator); + } else if (balanceCaveat_.enforcer == erc20BalanceChangeEnforcer) { + _validateERC20BalanceTerms(balanceCaveat_.terms, delegation_.delegator); + } else { + revert InvalidProfile(); + } + + return true; + } + + function _validateNativeBalanceTerms(bytes memory terms_, address delegator_) private pure { + // packed: bool enforceDecrease | address recipient | uint256 tokenOutMin + if (terms_.length != 53 || terms_[0] != bytes1(0) || _loadAddress(terms_, 1) != delegator_ || _loadWord(terms_, 21) == 0) { + revert InvalidBalanceTerms(); + } + } + + function _validateERC20BalanceTerms(bytes memory terms_, address delegator_) private pure { + // packed: bool enforceDecrease | address tokenOut | address recipient | uint256 tokenOutMin + if ( + terms_.length != 73 || terms_[0] != bytes1(0) || _loadAddress(terms_, 1) == address(0) + || _loadAddress(terms_, 21) != delegator_ || _loadWord(terms_, 41) == 0 + ) { + revert InvalidBalanceTerms(); + } + } + + function _beforeHook( + Caveat memory caveat_, + ModeCode mode_, + bytes calldata executionCallData_, + bytes32 delegationHash_, + address delegator_ + ) + private + { + ICaveatEnforcer(caveat_.enforcer) + .beforeHook(caveat_.terms, caveat_.args, mode_, executionCallData_, delegationHash_, delegator_, msg.sender); + } + + function _validateSignature(address delegator_, bytes32 typedDataHash_, bytes memory signature_) private view { + (address recovered_, ECDSA.RecoverError error_,) = ECDSA.tryRecover(typedDataHash_, signature_); + if (error_ == ECDSA.RecoverError.NoError && recovered_ == delegator_) return; + + if (IERC1271(delegator_).isValidSignature(typedDataHash_, signature_) != ERC1271Lib.EIP1271_MAGIC_VALUE) { + revert IDelegationManager.InvalidERC1271Signature(); + } + } + + function _loadAddress(bytes memory data_, uint256 offset_) private pure returns (address value_) { + assembly { + value_ := shr(96, mload(add(add(data_, 0x20), offset_))) + } + } + + function _loadWord(bytes memory data_, uint256 offset_) private pure returns (uint256 value_) { + assembly { + value_ := mload(add(add(data_, 0x20), offset_)) + } + } +} diff --git a/src/experiments/MetaSwapExecutionBuilderDelegationManager.sol b/src/experiments/MetaSwapExecutionBuilderDelegationManager.sol new file mode 100644 index 00000000..5f075bf9 --- /dev/null +++ b/src/experiments/MetaSwapExecutionBuilderDelegationManager.sol @@ -0,0 +1,91 @@ +// SPDX-License-Identifier: MIT AND Apache-2.0 +pragma solidity 0.8.23; + +import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import { ExecutionLib } from "@erc7579/lib/ExecutionLib.sol"; + +import { MetaSwapFlexibleSettlementManagerBase } from "./MetaSwapFlexibleSettlementManagerBase.sol"; +import { IMetaSwap } from "../helpers/interfaces/IMetaSwap.sol"; +import { IDeleGatorCore } from "../interfaces/IDeleGatorCore.sol"; +import { Execution } from "../utils/Types.sol"; + +/** + * @title MetaSwapExecutionBuilderDelegationManager + * @notice Constructs and executes one signed MetaSwap settlement from redeemer-supplied route data. + * @dev Approval and swap targets, amounts, ordering, selectors, and values are created by this manager. + */ +contract MetaSwapExecutionBuilderDelegationManager is MetaSwapFlexibleSettlementManagerBase { + using ExecutionLib for Execution[]; + + string public constant NAME = "MetaSwapExecutionBuilderDelegationManager"; + + constructor() MetaSwapFlexibleSettlementManagerBase(NAME) { } + + function _executeSettlement(address delegator_, bytes calldata executionContext_, Terms memory termsInfo_) internal override { + (string memory aggregatorId_, bytes memory routeData_) = abi.decode(executionContext_, (string, bytes)); + Execution[] memory executions_ = _buildExecutions(termsInfo_, aggregatorId_, routeData_); + + IDeleGatorCore(delegator_).executeFromExecutor(SIMPLE_BATCH_MODE, executions_.encodeBatch()); + } + + function _buildExecutions( + Terms memory termsInfo_, + string memory aggregatorId_, + bytes memory routeData_ + ) + private + pure + returns (Execution[] memory executions_) + { + ApprovalMode approvalMode_ = termsInfo_.approvalMode; + + if (termsInfo_.tokenIn == address(0)) { + if (approvalMode_ != ApprovalMode.None) revert InvalidApprovalMode(); + + executions_ = new Execution[](1); + executions_[0] = _swapExecution(termsInfo_, termsInfo_.tokenInAmount, aggregatorId_, routeData_); + return executions_; + } + + uint256 swapIndex_; + if (approvalMode_ == ApprovalMode.SkipApproval) { + executions_ = new Execution[](1); + } else if (approvalMode_ == ApprovalMode.Approve) { + executions_ = new Execution[](2); + executions_[0] = _approvalExecution(termsInfo_.tokenIn, termsInfo_.metaSwap, termsInfo_.tokenInAmount); + swapIndex_ = 1; + } else if (approvalMode_ == ApprovalMode.ResetApprove) { + executions_ = new Execution[](3); + executions_[0] = _approvalExecution(termsInfo_.tokenIn, termsInfo_.metaSwap, 0); + executions_[1] = _approvalExecution(termsInfo_.tokenIn, termsInfo_.metaSwap, termsInfo_.tokenInAmount); + swapIndex_ = 2; + } else { + revert InvalidApprovalMode(); + } + + executions_[swapIndex_] = _swapExecution(termsInfo_, 0, aggregatorId_, routeData_); + } + + function _approvalExecution(address tokenIn_, address metaSwap_, uint256 amount_) private pure returns (Execution memory) { + return Execution({ target: tokenIn_, value: 0, callData: abi.encodeCall(IERC20.approve, (metaSwap_, amount_)) }); + } + + function _swapExecution( + Terms memory termsInfo_, + uint256 value_, + string memory aggregatorId_, + bytes memory routeData_ + ) + private + pure + returns (Execution memory) + { + return Execution({ + target: termsInfo_.metaSwap, + value: value_, + callData: abi.encodeCall( + IMetaSwap.swap, (aggregatorId_, IERC20(termsInfo_.tokenIn), termsInfo_.tokenInAmount, routeData_) + ) + }); + } +} diff --git a/src/experiments/MetaSwapFlexibleSettlementManagerBase.sol b/src/experiments/MetaSwapFlexibleSettlementManagerBase.sol new file mode 100644 index 00000000..a4f33f09 --- /dev/null +++ b/src/experiments/MetaSwapFlexibleSettlementManagerBase.sol @@ -0,0 +1,77 @@ +// SPDX-License-Identifier: MIT AND Apache-2.0 +pragma solidity 0.8.23; + +import { MetaSwapDelegationManagerBase } from "../MetaSwapDelegationManagerBase.sol"; + +/** + * @title MetaSwapFlexibleSettlementManagerBase + * @notice Shared flexible MetaSwap settlement decoding and min-output enforcement. + * @dev Used by the hookless and execution-builder prototype managers. + */ +abstract contract MetaSwapFlexibleSettlementManagerBase is MetaSwapDelegationManagerBase { + enum ApprovalMode { + None, + SkipApproval, + Approve, + ResetApprove + } + + struct Terms { + address metaSwap; + address tokenIn; + uint256 tokenInAmount; + ApprovalMode approvalMode; + address tokenOut; + address recipient; + uint256 tokenOutMin; + } + + uint256 internal constant TERMS_LENGTH = 145; + + error InvalidApprovalMode(); + + constructor(string memory name_) MetaSwapDelegationManagerBase(name_) { } + + /** + * @notice Decodes and validates packed settlement terms. + * @param terms_ Packed settlement terms. + */ + function getTermsInfo(bytes memory terms_) public pure returns (Terms memory termsInfo_) { + if (terms_.length != TERMS_LENGTH) revert InvalidTerms(); + + // Terms are tightly packed. Loading their fixed offsets directly avoids allocating seven temporary byte arrays. + assembly ("memory-safe") { + let termsData_ := add(terms_, 0x20) + mstore(termsInfo_, shr(96, mload(termsData_))) + mstore(add(termsInfo_, 0x20), shr(96, mload(add(termsData_, 20)))) + mstore(add(termsInfo_, 0x40), mload(add(termsData_, 40))) + mstore(add(termsInfo_, 0x80), shr(96, mload(add(termsData_, 73)))) + mstore(add(termsInfo_, 0xa0), shr(96, mload(add(termsData_, 93)))) + mstore(add(termsInfo_, 0xc0), mload(add(termsData_, 113))) + } + uint8 approvalMode_ = uint8(terms_[72]); + + if ( + termsInfo_.metaSwap == address(0) || termsInfo_.tokenInAmount == 0 || termsInfo_.recipient == address(0) + || termsInfo_.tokenOutMin == 0 || termsInfo_.tokenIn == termsInfo_.tokenOut + ) { + revert InvalidTerms(); + } + if (approvalMode_ > uint8(ApprovalMode.ResetApprove)) revert InvalidApprovalMode(); + termsInfo_.approvalMode = ApprovalMode(approvalMode_); + } + + function _executeIntent(address delegator_, bytes memory terms_, bytes calldata executionContext_) internal override { + Terms memory termsInfo_ = getTermsInfo(terms_); + uint256 balanceBefore_ = _balanceOf(termsInfo_.tokenOut, termsInfo_.recipient); + + _executeSettlement(delegator_, executionContext_, termsInfo_); + + uint256 balanceAfter_ = _balanceOf(termsInfo_.tokenOut, termsInfo_.recipient); + if (balanceAfter_ < balanceBefore_ || balanceAfter_ - balanceBefore_ < termsInfo_.tokenOutMin) { + revert InsufficientOutput(); + } + } + + function _executeSettlement(address delegator_, bytes calldata executionContext_, Terms memory termsInfo_) internal virtual; +} diff --git a/src/experiments/MetaSwapHooklessDelegationManager.sol b/src/experiments/MetaSwapHooklessDelegationManager.sol new file mode 100644 index 00000000..18806b32 --- /dev/null +++ b/src/experiments/MetaSwapHooklessDelegationManager.sol @@ -0,0 +1,106 @@ +// SPDX-License-Identifier: MIT AND Apache-2.0 +pragma solidity 0.8.23; + +import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import { ExecutionLib } from "@erc7579/lib/ExecutionLib.sol"; + +import { MetaSwapFlexibleSettlementManagerBase } from "./MetaSwapFlexibleSettlementManagerBase.sol"; +import { IMetaSwap } from "../helpers/interfaces/IMetaSwap.sol"; +import { IDeleGatorCore } from "../interfaces/IDeleGatorCore.sol"; +import { Execution } from "../utils/Types.sol"; + +/** + * @title MetaSwapHooklessDelegationManager + * @notice Executes one signed MetaSwap settlement without invoking external caveat hooks. + * @dev The redeemer supplies a complete batch, which is validated directly by this manager. + */ +contract MetaSwapHooklessDelegationManager is MetaSwapFlexibleSettlementManagerBase { + using ExecutionLib for bytes; + + string public constant NAME = "MetaSwapHooklessDelegationManager"; + + uint256 private constant APPROVE_CALL_LENGTH = 68; + uint256 private constant SWAP_CALL_MIN_LENGTH = 196; + + error ApprovalShapeNotAllowed(); + error InvalidApproval(); + error InvalidBatchLength(); + error InvalidSwap(); + + constructor() MetaSwapFlexibleSettlementManagerBase(NAME) { } + + function _executeSettlement(address delegator_, bytes calldata executionContext_, Terms memory termsInfo_) internal override { + Execution[] calldata executions_ = executionContext_.decodeBatch(); + _validateExecutions(executions_, termsInfo_); + + IDeleGatorCore(delegator_).executeFromExecutor(SIMPLE_BATCH_MODE, executionContext_); + } + + function _validateExecutions(Execution[] calldata executions_, Terms memory termsInfo_) private pure { + ApprovalMode approvalMode_ = termsInfo_.approvalMode; + + if (termsInfo_.tokenIn == address(0)) { + if (approvalMode_ != ApprovalMode.None) revert InvalidApprovalMode(); + if (executions_.length != 1) revert InvalidBatchLength(); + _validateSwap(executions_[0], termsInfo_.metaSwap, address(0), termsInfo_.tokenInAmount, termsInfo_.tokenInAmount); + return; + } + + if (approvalMode_ == ApprovalMode.SkipApproval) { + if (executions_.length != 1) revert ApprovalShapeNotAllowed(); + _validateSwap(executions_[0], termsInfo_.metaSwap, termsInfo_.tokenIn, termsInfo_.tokenInAmount, 0); + } else if (approvalMode_ == ApprovalMode.Approve) { + if (executions_.length != 2) revert ApprovalShapeNotAllowed(); + _validateApproval(executions_[0], termsInfo_.tokenIn, termsInfo_.metaSwap, termsInfo_.tokenInAmount); + _validateSwap(executions_[1], termsInfo_.metaSwap, termsInfo_.tokenIn, termsInfo_.tokenInAmount, 0); + } else if (approvalMode_ == ApprovalMode.ResetApprove) { + if (executions_.length != 3) revert ApprovalShapeNotAllowed(); + _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); + } else { + revert InvalidApprovalMode(); + } + } + + 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 InvalidApproval(); + } + } + + 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 InvalidSwap(); + } + } +} diff --git a/src/experiments/MetaSwapMinimalDelegationManager.sol b/src/experiments/MetaSwapMinimalDelegationManager.sol new file mode 100644 index 00000000..8632f1b5 --- /dev/null +++ b/src/experiments/MetaSwapMinimalDelegationManager.sol @@ -0,0 +1,270 @@ +// SPDX-License-Identifier: MIT AND Apache-2.0 +pragma solidity 0.8.23; + +import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import { ECDSA } from "@openzeppelin/contracts/utils/cryptography/ECDSA.sol"; +import { EIP712 } from "@openzeppelin/contracts/utils/cryptography/EIP712.sol"; +import { IERC1271 } from "@openzeppelin/contracts/interfaces/IERC1271.sol"; +import { MessageHashUtils } from "@openzeppelin/contracts/utils/cryptography/MessageHashUtils.sol"; +import { ExecutionLib } from "@erc7579/lib/ExecutionLib.sol"; +import { ModeLib } from "@erc7579/lib/ModeLib.sol"; + +import { IDelegationManager } from "../interfaces/IDelegationManager.sol"; +import { IDeleGatorCore } from "../interfaces/IDeleGatorCore.sol"; +import { IMetaSwap } from "../helpers/interfaces/IMetaSwap.sol"; +import { EncoderLib } from "../libraries/EncoderLib.sol"; +import { ERC1271Lib } from "../libraries/ERC1271Lib.sol"; +import { CallType, Caveat, Delegation, Execution, ExecType, ModeCode } from "../utils/Types.sol"; +import { CALLTYPE_BATCH, CALLTYPE_SINGLE, EXECTYPE_DEFAULT } from "../utils/Constants.sol"; + +/** + * @title MetaSwapMinimalDelegationManager + * @notice Minimal one-shot manager for exact gasless swaps and dynamically routed MetaSwap limit orders. + * @dev This manager does not invoke external caveat enforcers. Each delegation contains exactly one caveat whose enforcer is + * this manager and whose first terms byte selects one of two profiles: + * + * - Gasless exact (`0x00`): terms commit to `keccak256(mode || executionCallData)`. + * - Limit order (`0x01`): terms commit to MetaSwap, tokenIn, tokenOut, tokenInAmount, tokenOutMin and approval shape. + * + * For a limit order, `executionCallDatas[0]` is `abi.encode(string aggregatorId, bytes routeData)`. The manager constructs the + * approval-and-swap batch itself, so there is no caller-supplied batch to validate. Output balance is checked directly after + * execution. A reverting execution or insufficient output rolls back the one-shot marker. + */ +contract MetaSwapMinimalDelegationManager is EIP712 { + using MessageHashUtils for bytes32; + using ModeLib for ModeCode; + + string public constant NAME = "MetaSwapMinimalDelegationManager"; + string public constant VERSION = "1.0.0"; + string public constant DOMAIN_VERSION = "1"; + + bytes32 public constant ROOT_AUTHORITY = bytes32(type(uint256).max); + address public constant ANY_DELEGATE = address(0xa11); + + uint8 public constant GASLESS_EXACT_PROFILE = 0; + uint8 public constant LIMIT_ORDER_PROFILE = 1; + + uint256 private constant GASLESS_TERMS_LENGTH = 33; + uint256 private constant LIMIT_ORDER_TERMS_LENGTH = 126; + + mapping(bytes32 delegationHash => bool isDisabled) public disabledDelegations; + mapping(bytes32 delegationHash => bool isUsed) public usedDelegations; + + event RedeemedDelegation( + address indexed rootDelegator, address indexed redeemer, bytes32 indexed delegationHash, uint8 profile + ); + + error InvalidProfile(); + error InvalidTerms(); + error InvalidMode(); + error InvalidDelegatorAccount(); + error DelegationAlreadyUsed(); + error InsufficientOutput(uint256 minimum, uint256 received); + + struct LimitOrderTerms { + address metaSwap; + address tokenIn; + address tokenOut; + uint256 tokenInAmount; + uint256 tokenOutMin; + bool resetApproval; + } + + modifier onlyDeleGator(address delegator_) { + if (delegator_ != msg.sender) revert IDelegationManager.InvalidDelegator(); + _; + } + + constructor() EIP712(NAME, DOMAIN_VERSION) { + emit IDelegationManager.SetDomain(_domainSeparatorV4(), NAME, DOMAIN_VERSION, block.chainid, address(this)); + } + + /** + * @notice Permanently cancels a delegation. + * @param delegation_ Delegation being cancelled by its delegator. + */ + function disableDelegation(Delegation calldata delegation_) external onlyDeleGator(delegation_.delegator) { + bytes32 delegationHash_ = getDelegationHash(delegation_); + if (disabledDelegations[delegationHash_]) revert IDelegationManager.AlreadyDisabled(); + disabledDelegations[delegationHash_] = true; + emit IDelegationManager.DisabledDelegation(delegationHash_, delegation_.delegator, delegation_.delegate, delegation_); + } + + /** + * @notice Executes one exact gasless action or fills one dynamically routed limit order. + * @param permissionContexts_ Exactly one ABI-encoded array containing one root delegation. + * @param modes_ Gasless execution mode, or batch/default for the limit-order profile. + * @param executionCallDatas_ Exact ERC-7579 execution data for gasless, or ABI-encoded aggregator ID and route for a limit. + */ + function redeemDelegations( + bytes[] calldata permissionContexts_, + ModeCode[] calldata modes_, + bytes[] calldata executionCallDatas_ + ) + external + { + if (permissionContexts_.length != 1 || modes_.length != 1 || executionCallDatas_.length != 1) { + revert IDelegationManager.BatchDataLengthMismatch(); + } + + Delegation[] memory delegations_ = abi.decode(permissionContexts_[0], (Delegation[])); + if (delegations_.length != 1) revert InvalidProfile(); + + Delegation memory delegation_ = delegations_[0]; + if (delegation_.delegate != msg.sender && delegation_.delegate != ANY_DELEGATE) { + revert IDelegationManager.InvalidDelegate(); + } + if (delegation_.authority != ROOT_AUTHORITY) revert IDelegationManager.InvalidAuthority(); + if (delegation_.delegator.code.length == 0) revert InvalidDelegatorAccount(); + if ( + delegation_.caveats.length != 1 || delegation_.caveats[0].enforcer != address(this) + || delegation_.caveats[0].args.length != 0 || delegation_.caveats[0].terms.length == 0 + ) { + revert InvalidProfile(); + } + + bytes32 delegationHash_ = EncoderLib._getDelegationHash(delegation_); + _validateSignature( + delegation_.delegator, MessageHashUtils.toTypedDataHash(_domainSeparatorV4(), delegationHash_), delegation_.signature + ); + if (disabledDelegations[delegationHash_]) revert IDelegationManager.CannotUseADisabledDelegation(); + if (usedDelegations[delegationHash_]) revert DelegationAlreadyUsed(); + + Caveat memory profileCaveat_ = delegation_.caveats[0]; + uint8 profile_ = uint8(profileCaveat_.terms[0]); + usedDelegations[delegationHash_] = true; + + if (profile_ == GASLESS_EXACT_PROFILE) { + _executeGasless(delegation_.delegator, profileCaveat_.terms, modes_[0], executionCallDatas_[0]); + } else if (profile_ == LIMIT_ORDER_PROFILE) { + _executeLimitOrder(delegation_.delegator, profileCaveat_.terms, modes_[0], executionCallDatas_[0]); + } else { + revert InvalidProfile(); + } + + emit RedeemedDelegation(delegation_.delegator, msg.sender, delegationHash_, profile_); + } + + /** + * @notice Returns the exact gasless commitment signed in profile terms. + */ + function getGaslessExecutionHash(ModeCode mode_, bytes calldata executionCallData_) public pure returns (bytes32) { + return keccak256(abi.encodePacked(ModeCode.unwrap(mode_), executionCallData_)); + } + + /** + * @notice Returns this manager's EIP-712 domain separator. + */ + function getDomainHash() public view returns (bytes32) { + return _domainSeparatorV4(); + } + + /** + * @notice Returns the EIP-712 delegation struct hash. + */ + function getDelegationHash(Delegation calldata delegation_) public pure returns (bytes32) { + return EncoderLib._getDelegationHash(delegation_); + } + + function _executeGasless(address delegator_, bytes memory terms_, ModeCode mode_, bytes calldata executionCallData_) private { + if (terms_.length != GASLESS_TERMS_LENGTH) revert InvalidTerms(); + (CallType callType_, ExecType execType_,,) = mode_.decode(); + if ( + ExecType.unwrap(execType_) != ExecType.unwrap(EXECTYPE_DEFAULT) + || (CallType.unwrap(callType_) != CallType.unwrap(CALLTYPE_SINGLE) + && CallType.unwrap(callType_) != CallType.unwrap(CALLTYPE_BATCH)) + || bytes32(_loadWord(terms_, 1)) != getGaslessExecutionHash(mode_, executionCallData_) + ) { + revert InvalidMode(); + } + + IDeleGatorCore(delegator_).executeFromExecutor(mode_, executionCallData_); + } + + function _executeLimitOrder(address delegator_, bytes memory terms_, ModeCode mode_, bytes calldata routePayload_) private { + if (ModeCode.unwrap(mode_) != ModeCode.unwrap(ModeLib.encodeSimpleBatch())) revert InvalidMode(); + LimitOrderTerms memory order_ = _decodeLimitOrderTerms(terms_); + (string memory aggregatorId_, bytes memory routeData_) = abi.decode(routePayload_, (string, bytes)); + + uint256 balanceBefore_ = _balanceOf(order_.tokenOut, delegator_); + Execution[] memory executions_ = _buildLimitOrderExecutions(order_, aggregatorId_, routeData_); + IDeleGatorCore(delegator_).executeFromExecutor(mode_, ExecutionLib.encodeBatch(executions_)); + + uint256 balanceAfter_ = _balanceOf(order_.tokenOut, delegator_); + uint256 received_ = balanceAfter_ > balanceBefore_ ? balanceAfter_ - balanceBefore_ : 0; + if (received_ < order_.tokenOutMin) revert InsufficientOutput(order_.tokenOutMin, received_); + } + + function _decodeLimitOrderTerms(bytes memory terms_) private pure returns (LimitOrderTerms memory order_) { + if (terms_.length != LIMIT_ORDER_TERMS_LENGTH) revert InvalidTerms(); + + order_.metaSwap = _loadAddress(terms_, 1); + order_.tokenIn = _loadAddress(terms_, 21); + order_.tokenOut = _loadAddress(terms_, 41); + order_.tokenInAmount = _loadWord(terms_, 61); + order_.tokenOutMin = _loadWord(terms_, 93); + uint8 resetApprovalValue_ = uint8(terms_[125]); + if ( + order_.metaSwap == address(0) || order_.tokenInAmount == 0 || order_.tokenOutMin == 0 + || order_.tokenIn == order_.tokenOut || resetApprovalValue_ > 1 + || (order_.tokenIn == address(0) && resetApprovalValue_ == 1) + ) { + revert InvalidTerms(); + } + order_.resetApproval = resetApprovalValue_ == 1; + } + + function _buildLimitOrderExecutions( + LimitOrderTerms memory order_, + string memory aggregatorId_, + bytes memory routeData_ + ) + private + pure + returns (Execution[] memory executions_) + { + bytes memory swapCallData_ = + abi.encodeCall(IMetaSwap.swap, (aggregatorId_, IERC20(order_.tokenIn), order_.tokenInAmount, routeData_)); + + if (order_.tokenIn == address(0)) { + executions_ = new Execution[](1); + executions_[0] = Execution({ target: order_.metaSwap, value: order_.tokenInAmount, callData: swapCallData_ }); + return executions_; + } + + uint256 swapIndex_ = order_.resetApproval ? 2 : 1; + executions_ = new Execution[](swapIndex_ + 1); + if (order_.resetApproval) { + executions_[0] = + Execution({ target: order_.tokenIn, value: 0, callData: abi.encodeCall(IERC20.approve, (order_.metaSwap, 0)) }); + } + executions_[swapIndex_ - 1] = Execution({ + target: order_.tokenIn, value: 0, callData: abi.encodeCall(IERC20.approve, (order_.metaSwap, order_.tokenInAmount)) + }); + executions_[swapIndex_] = Execution({ target: order_.metaSwap, value: 0, callData: swapCallData_ }); + } + + function _balanceOf(address token_, address account_) private view returns (uint256) { + return token_ == address(0) ? account_.balance : IERC20(token_).balanceOf(account_); + } + + function _validateSignature(address delegator_, bytes32 typedDataHash_, bytes memory signature_) private view { + (address recovered_, ECDSA.RecoverError error_,) = ECDSA.tryRecover(typedDataHash_, signature_); + if (error_ == ECDSA.RecoverError.NoError && recovered_ == delegator_) return; + if (IERC1271(delegator_).isValidSignature(typedDataHash_, signature_) != ERC1271Lib.EIP1271_MAGIC_VALUE) { + revert IDelegationManager.InvalidERC1271Signature(); + } + } + + function _loadAddress(bytes memory data_, uint256 offset_) private pure returns (address value_) { + assembly { + value_ := shr(96, mload(add(add(data_, 0x20), offset_))) + } + } + + function _loadWord(bytes memory data_, uint256 offset_) private pure returns (uint256 value_) { + assembly { + value_ := mload(add(add(data_, 0x20), offset_)) + } + } +} diff --git a/test/MetaSwapOrderDelegationManager.t.sol b/test/MetaSwapOrderDelegationManager.t.sol new file mode 100644 index 00000000..2b54adac --- /dev/null +++ b/test/MetaSwapOrderDelegationManager.t.sol @@ -0,0 +1,1141 @@ +// SPDX-License-Identifier: MIT AND Apache-2.0 +pragma solidity 0.8.23; + +import { Test } from "forge-std/Test.sol"; +import { EntryPoint } from "@account-abstraction/core/EntryPoint.sol"; +import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import { SafeERC20 } from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +import { MessageHashUtils } from "@openzeppelin/contracts/utils/cryptography/MessageHashUtils.sol"; +import { ExecutionLib } from "@erc7579/lib/ExecutionLib.sol"; +import { ModeLib } from "@erc7579/lib/ModeLib.sol"; + +import { MetaSwapDelegationManagerBase } from "../src/MetaSwapDelegationManagerBase.sol"; +import { MetaSwapOrderDelegationManager } from "../src/MetaSwapOrderDelegationManager.sol"; +import { MetaSwapFlexibleSettlementManagerBase } from "../src/experiments/MetaSwapFlexibleSettlementManagerBase.sol"; +import { MetaSwapHooklessDelegationManager } from "../src/experiments/MetaSwapHooklessDelegationManager.sol"; +import { DelegationManager } from "../src/DelegationManager.sol"; +import { EIP7702StatelessDeleGator } from "../src/EIP7702/EIP7702StatelessDeleGator.sol"; +import { ExactExecutionBatchEnforcer } from "../src/enforcers/ExactExecutionBatchEnforcer.sol"; +import { LimitedCallsEnforcer } from "../src/enforcers/LimitedCallsEnforcer.sol"; +import { MetaSwapFlexibleSettlementEnforcer } from "../src/enforcers/MetaSwapFlexibleSettlementEnforcer.sol"; +import { IMetaSwap } from "../src/helpers/interfaces/IMetaSwap.sol"; +import { IDelegationManager } from "../src/interfaces/IDelegationManager.sol"; +import { BasicERC20 } from "./utils/BasicERC20.t.sol"; +import { Caveat, Delegation, Execution, ModeCode } from "../src/utils/Types.sol"; +import { ERC1271Lib } from "../src/libraries/ERC1271Lib.sol"; + +contract OrderManager1271Account { + using ExecutionLib for bytes; + + function isValidSignature(bytes32 hash_, bytes memory signature_) external pure returns (bytes4) { + if (signature_.length == 32 && bytes32(signature_) == hash_) return ERC1271Lib.EIP1271_MAGIC_VALUE; + return ERC1271Lib.SIG_VALIDATION_FAILED; + } + + function executeFromExecutor(ModeCode, bytes calldata executionCallData_) + external + payable + returns (bytes[] memory returnData_) + { + Execution[] calldata executions_ = executionCallData_.decodeBatch(); + returnData_ = new bytes[](executions_.length); + for (uint256 i; i < executions_.length; ++i) { + (bool ok_, bytes memory ret_) = executions_[i].target.call{ value: executions_[i].value }(executions_[i].callData); + require(ok_, "exec-failed"); + returnData_[i] = ret_; + } + } +} + +contract OrderManagerMetaSwapMock is IMetaSwap { + using SafeERC20 for IERC20; + + mapping(string aggregatorId => Adapter adapter) private adapters_; + + function setAdapter(string calldata aggregatorId_, address addr_, bytes4 selector_, bytes calldata data_) external { + adapters_[aggregatorId_] = Adapter({ addr: addr_, selector: selector_, data: data_ }); + } + + function removeAdapter(string calldata aggregatorId_) external { + delete adapters_[aggregatorId_]; + } + + function adapters(string memory aggregatorId_) external view returns (Adapter memory) { + return adapters_[aggregatorId_]; + } + + function swap(string calldata, IERC20 tokenFrom_, uint256 amount_, bytes calldata data_) external payable { + if (address(tokenFrom_) == address(0)) { + require(msg.value == amount_, "invalid-native-input"); + } else { + tokenFrom_.safeTransferFrom(msg.sender, address(this), amount_); + } + + (IERC20 tokenOut_, uint256 amountOut_) = abi.decode(data_, (IERC20, uint256)); + if (address(tokenOut_) == address(0)) { + (bool success_,) = msg.sender.call{ value: amountOut_ }(""); + require(success_, "native-output-failed"); + } else { + tokenOut_.safeTransfer(msg.sender, amountOut_); + } + } + + receive() external payable { } +} + +contract MetaSwapOrderDelegationManagerTest is Test { + uint256 private constant TOKEN_IN_AMOUNT = 100 ether; + uint256 private constant TOKEN_OUT_MIN = 190 ether; + uint256 private constant TOKEN_OUT_AMOUNT = 200 ether; + + uint256 private constant GENERIC_KEY = 0x1111; + uint256 private constant HOOKLESS_KEY = 0x2222; + uint256 private constant ORDER_KEY = 0x3333; + + EntryPoint private entryPoint; + OrderManagerMetaSwapMock private metaSwap; + BasicERC20 private tokenIn; + BasicERC20 private tokenOut; + + DelegationManager private genericManager; + ExactExecutionBatchEnforcer private exactBatchEnforcer; + LimitedCallsEnforcer private limitedCallsEnforcer; + MetaSwapFlexibleSettlementEnforcer private flexibleEnforcer; + MetaSwapHooklessDelegationManager private hooklessManager; + MetaSwapOrderDelegationManager private orderManager; + + address private genericAccount; + address private hooklessAccount; + address private orderAccount; + address private relayer; + + function setUp() public { + entryPoint = new EntryPoint(); + metaSwap = new OrderManagerMetaSwapMock(); + tokenIn = new BasicERC20(address(this), "Token In", "TIN", 0); + tokenOut = new BasicERC20(address(this), "Token Out", "TOUT", 0); + relayer = makeAddr("Relayer"); + + genericManager = new DelegationManager(address(this)); + exactBatchEnforcer = new ExactExecutionBatchEnforcer(); + limitedCallsEnforcer = new LimitedCallsEnforcer(); + flexibleEnforcer = new MetaSwapFlexibleSettlementEnforcer(); + hooklessManager = new MetaSwapHooklessDelegationManager(); + orderManager = new MetaSwapOrderDelegationManager(); + + genericAccount = vm.addr(GENERIC_KEY); + hooklessAccount = vm.addr(HOOKLESS_KEY); + orderAccount = vm.addr(ORDER_KEY); + + _installDeleGator(genericAccount, address(genericManager)); + _installDeleGator(hooklessAccount, address(hooklessManager)); + _installDeleGator(orderAccount, address(orderManager)); + + tokenIn.mint(genericAccount, 1_000 ether); + tokenIn.mint(hooklessAccount, 1_000 ether); + tokenIn.mint(orderAccount, 1_000 ether); + tokenOut.mint(address(metaSwap), 10_000 ether); + vm.deal(genericAccount, 1_000 ether); + vm.deal(hooklessAccount, 1_000 ether); + vm.deal(orderAccount, 1_000 ether); + vm.deal(address(metaSwap), 10_000 ether); + } + + // -------- Exact intent -------- + + function test_exactRedeemsApproveAndSwap() public { + Execution[] memory executions_ = _erc20Executions(1, TOKEN_OUT_AMOUNT); + bytes memory encoded_ = ExecutionLib.encodeBatch(executions_); + Delegation memory delegation_ = _signIntent(_exactTerms(keccak256(encoded_)), 1); + + vm.expectEmit(true, true, true, true, address(orderManager)); + emit MetaSwapDelegationManagerBase.RedeemedDelegation( + orderAccount, + relayer, + orderManager.getDelegationHash(delegation_), + uint8(MetaSwapOrderDelegationManager.Intent.ExactCalldata) + ); + _redeemIntent(delegation_, encoded_); + + assertEq(tokenIn.balanceOf(orderAccount), 900 ether); + assertEq(tokenOut.balanceOf(orderAccount), TOKEN_OUT_AMOUNT); + assertTrue(orderManager.disabledDelegations(orderManager.getDelegationHash(delegation_))); + } + + function test_exactRedeemsSkipApprovalSwap() public { + vm.prank(orderAccount); + tokenIn.approve(address(metaSwap), TOKEN_IN_AMOUNT); + + Execution[] memory executions_ = _erc20Executions(0, TOKEN_OUT_AMOUNT); + bytes memory encoded_ = ExecutionLib.encodeBatch(executions_); + Delegation memory delegation_ = _signIntent(_exactTerms(keccak256(encoded_)), 2); + + _redeemIntent(delegation_, encoded_); + assertEq(tokenOut.balanceOf(orderAccount), TOKEN_OUT_AMOUNT); + } + + function test_exactRedeemsResetApproveAndSwap() public { + vm.prank(orderAccount); + tokenIn.approve(address(metaSwap), 1); + + Execution[] memory executions_ = _erc20Executions(2, TOKEN_OUT_AMOUNT); + bytes memory encoded_ = ExecutionLib.encodeBatch(executions_); + Delegation memory delegation_ = _signIntent(_exactTerms(keccak256(encoded_)), 3); + + _redeemIntent(delegation_, encoded_); + assertEq(tokenOut.balanceOf(orderAccount), TOKEN_OUT_AMOUNT); + } + + function test_exactRedeemsNativeSwap() public { + Execution[] memory executions_ = _nativeExecutions(TOKEN_OUT_AMOUNT); + bytes memory encoded_ = ExecutionLib.encodeBatch(executions_); + Delegation memory delegation_ = _signIntent(_exactTerms(keccak256(encoded_)), 4); + uint256 nativeBefore_ = orderAccount.balance; + + _redeemIntent(delegation_, encoded_); + + assertEq(orderAccount.balance, nativeBefore_ - TOKEN_IN_AMOUNT); + assertEq(tokenOut.balanceOf(orderAccount), TOKEN_OUT_AMOUNT); + } + + function test_exactRevertsForHashMismatch() public { + Execution[] memory executions_ = _erc20Executions(1, TOKEN_OUT_AMOUNT); + bytes memory encoded_ = ExecutionLib.encodeBatch(executions_); + Delegation memory delegation_ = _signIntent(_exactTerms(keccak256(encoded_)), 5); + + executions_[1].value = 1; + vm.expectRevert(MetaSwapOrderDelegationManager.InvalidExecutionHash.selector); + _redeemIntent(delegation_, ExecutionLib.encodeBatch(executions_)); + } + + function test_exactRevertsOnReplay() public { + Execution[] memory executions_ = _erc20Executions(1, TOKEN_OUT_AMOUNT); + bytes memory encoded_ = ExecutionLib.encodeBatch(executions_); + Delegation memory delegation_ = _signIntent(_exactTerms(keccak256(encoded_)), 6); + + _redeemIntent(delegation_, encoded_); + + vm.expectRevert(MetaSwapDelegationManagerBase.CannotUseADisabledDelegation.selector); + _redeemIntent(delegation_, encoded_); + } + + function test_exactDisableDelegationCancels() public { + Execution[] memory executions_ = _erc20Executions(1, TOKEN_OUT_AMOUNT); + bytes memory encoded_ = ExecutionLib.encodeBatch(executions_); + Delegation memory delegation_ = _signIntent(_exactTerms(keccak256(encoded_)), 7); + + vm.prank(orderAccount); + orderManager.disableDelegation(delegation_); + + vm.expectRevert(MetaSwapDelegationManagerBase.CannotUseADisabledDelegation.selector); + _redeemIntent(delegation_, encoded_); + } + + function test_exactRejectsWrongSigner() public { + Execution[] memory executions_ = _erc20Executions(1, TOKEN_OUT_AMOUNT); + bytes memory encoded_ = ExecutionLib.encodeBatch(executions_); + Delegation memory delegation_ = _signIntentWithKey(HOOKLESS_KEY, _exactTerms(keccak256(encoded_)), 8); + + vm.expectRevert(MetaSwapDelegationManagerBase.InvalidERC1271Signature.selector); + _redeemIntent(delegation_, encoded_); + } + + function test_exactRejectsWrongSignerOnCodelessEOA() public { + address eoa_ = vm.addr(0xE0A); + bytes memory encoded_ = ExecutionLib.encodeBatch(_erc20Executions(1, TOKEN_OUT_AMOUNT)); + Caveat[] memory caveats_ = new Caveat[](1); + caveats_[0] = Caveat({ enforcer: address(orderManager), terms: _exactTerms(keccak256(encoded_)), args: hex"" }); + Delegation memory delegation_ = _signManager(orderManager, HOOKLESS_KEY, eoa_, caveats_, 40); + + vm.expectRevert(MetaSwapDelegationManagerBase.InvalidEOASignature.selector); + _redeemIntent(delegation_, encoded_); + } + + function test_exactRedeemsWithERC1271Fallback() public { + OrderManager1271Account account_ = new OrderManager1271Account(); + tokenIn.mint(address(account_), 1_000 ether); + + bytes memory encoded_ = ExecutionLib.encodeBatch(_erc20Executions(1, TOKEN_OUT_AMOUNT)); + bytes memory terms_ = _exactTerms(keccak256(encoded_)); + Caveat[] memory caveats_ = new Caveat[](1); + caveats_[0] = Caveat({ enforcer: address(orderManager), terms: terms_, args: hex"" }); + Delegation memory delegation_ = Delegation({ + delegate: address(0xa11), + delegator: address(account_), + authority: orderManager.ROOT_AUTHORITY(), + caveats: caveats_, + salt: 41, + signature: hex"" + }); + bytes32 typedDataHash_ = + MessageHashUtils.toTypedDataHash(orderManager.getDomainHash(), orderManager.getDelegationHash(delegation_)); + delegation_.signature = abi.encodePacked(typedDataHash_); + + _redeemIntent(delegation_, encoded_); + + assertEq(tokenOut.balanceOf(address(account_)), TOKEN_OUT_AMOUNT); + } + + function test_exactRejectsInvalidERC1271Signature() public { + OrderManager1271Account account_ = new OrderManager1271Account(); + bytes memory encoded_ = ExecutionLib.encodeBatch(_erc20Executions(1, TOKEN_OUT_AMOUNT)); + Caveat[] memory caveats_ = new Caveat[](1); + caveats_[0] = Caveat({ enforcer: address(orderManager), terms: _exactTerms(keccak256(encoded_)), args: hex"" }); + Delegation memory delegation_ = Delegation({ + delegate: address(0xa11), + delegator: address(account_), + authority: orderManager.ROOT_AUTHORITY(), + caveats: caveats_, + salt: 42, + signature: abi.encodePacked(bytes32(uint256(1))) + }); + + vm.expectRevert(MetaSwapDelegationManagerBase.InvalidERC1271Signature.selector); + _redeemIntent(delegation_, encoded_); + } + + // -------- Flexible intent -------- + + function test_flexibleRedeemsApproveAndSwap() public { + bytes memory terms_ = _flexibleTerms(address(tokenIn), _approveMode(), address(tokenOut), orderAccount); + Delegation memory delegation_ = _signIntent(terms_, 10); + bytes memory encoded_ = ExecutionLib.encodeBatch(_erc20Executions(1, TOKEN_OUT_AMOUNT)); + + vm.expectEmit(true, true, true, true, address(orderManager)); + emit MetaSwapDelegationManagerBase.RedeemedDelegation( + orderAccount, + relayer, + orderManager.getDelegationHash(delegation_), + uint8(MetaSwapOrderDelegationManager.Intent.FlexibleSettlement) + ); + _redeemIntent(delegation_, encoded_); + + assertEq(tokenIn.balanceOf(orderAccount), 900 ether); + assertEq(tokenOut.balanceOf(orderAccount), TOKEN_OUT_AMOUNT); + } + + function test_flexibleRedeemsSkipApproval() public { + vm.prank(orderAccount); + tokenIn.approve(address(metaSwap), TOKEN_IN_AMOUNT); + + bytes memory terms_ = _flexibleTerms(address(tokenIn), _skipApprovalMode(), address(tokenOut), orderAccount); + Delegation memory delegation_ = _signIntent(terms_, 11); + + _redeemIntent(delegation_, ExecutionLib.encodeBatch(_erc20Executions(0, TOKEN_OUT_AMOUNT))); + assertEq(tokenOut.balanceOf(orderAccount), TOKEN_OUT_AMOUNT); + } + + function test_flexibleRedeemsResetApprove() public { + vm.prank(orderAccount); + tokenIn.approve(address(metaSwap), 1); + + bytes memory terms_ = _flexibleTerms(address(tokenIn), _resetApproveMode(), address(tokenOut), orderAccount); + Delegation memory delegation_ = _signIntent(terms_, 12); + + _redeemIntent(delegation_, ExecutionLib.encodeBatch(_erc20Executions(2, TOKEN_OUT_AMOUNT))); + assertEq(tokenOut.balanceOf(orderAccount), TOKEN_OUT_AMOUNT); + } + + function test_flexibleRedeemsNativeInput() public { + bytes memory terms_ = _flexibleTerms(address(0), _noneMode(), address(tokenOut), orderAccount); + Delegation memory delegation_ = _signIntent(terms_, 13); + uint256 nativeBefore_ = orderAccount.balance; + + _redeemIntent(delegation_, ExecutionLib.encodeBatch(_nativeExecutions(TOKEN_OUT_AMOUNT))); + + assertEq(orderAccount.balance, nativeBefore_ - TOKEN_IN_AMOUNT); + assertEq(tokenOut.balanceOf(orderAccount), TOKEN_OUT_AMOUNT); + } + + function test_flexibleRedeemsNativeOutput() public { + bytes memory terms_ = _flexibleTerms(address(tokenIn), _approveMode(), address(0), orderAccount); + Delegation memory delegation_ = _signIntent(terms_, 19); + Execution[] memory executions_ = _erc20Executions(1, TOKEN_OUT_AMOUNT); + executions_[1].callData = abi.encodeCall( + IMetaSwap.swap, + ("redeemer-route", IERC20(address(tokenIn)), TOKEN_IN_AMOUNT, abi.encode(IERC20(address(0)), TOKEN_OUT_AMOUNT)) + ); + uint256 nativeBefore_ = orderAccount.balance; + + _redeemIntent(delegation_, ExecutionLib.encodeBatch(executions_)); + + assertEq(tokenIn.balanceOf(orderAccount), 900 ether); + assertEq(orderAccount.balance, nativeBefore_ + TOKEN_OUT_AMOUNT); + } + + function test_flexibleAllowsDifferentRouteData() public { + bytes memory terms_ = _flexibleTerms(address(tokenIn), _approveMode(), address(tokenOut), orderAccount); + + Execution[] memory first_ = _erc20Executions(1, TOKEN_OUT_AMOUNT); + first_[1].callData = abi.encodeCall( + IMetaSwap.swap, ("route-a", IERC20(address(tokenIn)), TOKEN_IN_AMOUNT, abi.encode(tokenOut, TOKEN_OUT_AMOUNT)) + ); + _redeemIntent(_signIntent(terms_, 14), ExecutionLib.encodeBatch(first_)); + + Execution[] memory second_ = _erc20Executions(1, TOKEN_OUT_AMOUNT); + second_[1].callData = abi.encodeCall( + IMetaSwap.swap, ("route-b", IERC20(address(tokenIn)), TOKEN_IN_AMOUNT, abi.encode(tokenOut, TOKEN_OUT_AMOUNT)) + ); + _redeemIntent(_signIntent(terms_, 15), ExecutionLib.encodeBatch(second_)); + + assertEq(tokenOut.balanceOf(orderAccount), TOKEN_OUT_AMOUNT * 2); + } + + function test_flexibleRevertsAtomicallyForInsufficientOutput() public { + bytes memory terms_ = _flexibleTerms(address(tokenIn), _approveMode(), address(tokenOut), orderAccount); + Delegation memory delegation_ = _signIntent(terms_, 16); + bytes32 hash_ = orderManager.getDelegationHash(delegation_); + + vm.expectRevert(MetaSwapDelegationManagerBase.InsufficientOutput.selector); + _redeemIntent(delegation_, ExecutionLib.encodeBatch(_erc20Executions(1, TOKEN_OUT_MIN - 1))); + + assertFalse(orderManager.disabledDelegations(hash_)); + assertEq(tokenIn.balanceOf(orderAccount), 1_000 ether); + } + + function test_flexibleRejectsInvalidApprovalMode() public { + bytes memory terms_ = _flexibleTerms(address(0), _approveMode(), address(tokenOut), orderAccount); + Delegation memory delegation_ = _signIntent(terms_, 17); + + vm.expectRevert(MetaSwapOrderDelegationManager.InvalidApprovalMode.selector); + _redeemIntent(delegation_, ExecutionLib.encodeBatch(_nativeExecutions(TOKEN_OUT_AMOUNT))); + } + + function test_rejectsUnknownIntent() public { + bytes memory terms_ = abi.encodePacked(uint8(2), bytes32(0)); + Delegation memory delegation_ = _signIntent(terms_, 18); + + vm.expectRevert(MetaSwapOrderDelegationManager.InvalidIntent.selector); + _redeemIntent(delegation_, ExecutionLib.encodeBatch(_erc20Executions(1, TOKEN_OUT_AMOUNT))); + } + + // -------- Flexible validation (ported from MetaSwapFlexibleSettlementEnforcer) -------- + + function test_flexibleRejectsInvalidTermsLength() public { + vm.expectRevert(MetaSwapDelegationManagerBase.InvalidTerms.selector); + orderManager.getFlexibleTermsInfo(new bytes(145)); + + vm.expectRevert(MetaSwapDelegationManagerBase.InvalidTerms.selector); + orderManager.getFlexibleTermsInfo(new bytes(147)); + } + + function test_flexibleRejectsInvalidRequiredTerms() public { + vm.expectRevert(MetaSwapDelegationManagerBase.InvalidTerms.selector); + orderManager.getFlexibleTermsInfo( + _rawFlexibleTerms(address(0), address(tokenIn), TOKEN_IN_AMOUNT, uint8(_approveMode()), address(tokenOut), orderAccount) + ); + + vm.expectRevert(MetaSwapDelegationManagerBase.InvalidTerms.selector); + orderManager.getFlexibleTermsInfo( + _rawFlexibleTerms(address(metaSwap), address(tokenIn), 0, uint8(_approveMode()), address(tokenOut), orderAccount) + ); + + vm.expectRevert(MetaSwapDelegationManagerBase.InvalidTerms.selector); + orderManager.getFlexibleTermsInfo( + _rawFlexibleTerms( + address(metaSwap), address(tokenIn), TOKEN_IN_AMOUNT, uint8(_approveMode()), address(tokenOut), address(0) + ) + ); + + vm.expectRevert(MetaSwapDelegationManagerBase.InvalidTerms.selector); + orderManager.getFlexibleTermsInfo( + _rawFlexibleTerms( + address(metaSwap), address(tokenIn), TOKEN_IN_AMOUNT, uint8(_approveMode()), address(tokenOut), orderAccount, 0 + ) + ); + + vm.expectRevert(MetaSwapDelegationManagerBase.InvalidTerms.selector); + orderManager.getFlexibleTermsInfo( + _rawFlexibleTerms( + address(metaSwap), address(tokenIn), TOKEN_IN_AMOUNT, uint8(_approveMode()), address(tokenIn), orderAccount + ) + ); + } + + function test_flexibleRejectsUndefinedApprovalMode() public { + vm.expectRevert(MetaSwapOrderDelegationManager.InvalidApprovalMode.selector); + orderManager.getFlexibleTermsInfo( + _rawFlexibleTerms(address(metaSwap), address(tokenIn), TOKEN_IN_AMOUNT, 4, address(tokenOut), orderAccount) + ); + } + + function test_flexibleRejectsErc20NoneMode() public { + bytes memory terms_ = _flexibleTerms(address(tokenIn), _noneMode(), address(tokenOut), orderAccount); + _expectFlexibleRevert( + terms_, _erc20Executions(0, TOKEN_OUT_AMOUNT), MetaSwapOrderDelegationManager.InvalidApprovalMode.selector, 50 + ); + } + + function test_flexibleRejectsWrongApprovalShape() public { + _expectFlexibleRevert( + _flexibleTerms(address(tokenIn), _approveMode(), address(tokenOut), orderAccount), + _erc20Executions(0, TOKEN_OUT_AMOUNT), + MetaSwapOrderDelegationManager.ApprovalShapeNotAllowed.selector, + 51 + ); + _expectFlexibleRevert( + _flexibleTerms(address(tokenIn), _skipApprovalMode(), address(tokenOut), orderAccount), + _erc20Executions(1, TOKEN_OUT_AMOUNT), + MetaSwapOrderDelegationManager.ApprovalShapeNotAllowed.selector, + 52 + ); + _expectFlexibleRevert( + _flexibleTerms(address(tokenIn), _approveMode(), address(tokenOut), orderAccount), + _erc20Executions(2, TOKEN_OUT_AMOUNT), + MetaSwapOrderDelegationManager.ApprovalShapeNotAllowed.selector, + 53 + ); + } + + function test_flexibleRejectsUnsupportedBatchLengths() public { + Execution[] memory empty_ = new Execution[](0); + _expectFlexibleRevert( + _flexibleTerms(address(tokenIn), _skipApprovalMode(), address(tokenOut), orderAccount), + empty_, + MetaSwapOrderDelegationManager.ApprovalShapeNotAllowed.selector, + 54 + ); + + Execution[] memory tooLong_ = new Execution[](4); + _expectFlexibleRevert( + _flexibleTerms(address(tokenIn), _resetApproveMode(), address(tokenOut), orderAccount), + tooLong_, + MetaSwapOrderDelegationManager.ApprovalShapeNotAllowed.selector, + 55 + ); + + Execution[] memory nativeTooLong_ = new Execution[](2); + _expectFlexibleRevert( + _flexibleTerms(address(0), _noneMode(), address(tokenOut), orderAccount), + nativeTooLong_, + MetaSwapOrderDelegationManager.InvalidBatchLength.selector, + 56 + ); + } + + function test_flexibleRejectsInvalidApproval() public { + Execution[] memory executions_ = _erc20Executions(1, TOKEN_OUT_AMOUNT); + executions_[0].target = makeAddr("OtherToken"); + _expectInvalidApproval(executions_, 57); + + executions_ = _erc20Executions(1, TOKEN_OUT_AMOUNT); + executions_[0].value = 1; + _expectInvalidApproval(executions_, 58); + + executions_ = _erc20Executions(1, TOKEN_OUT_AMOUNT); + executions_[0].callData = abi.encodePacked(IERC20.approve.selector); + _expectInvalidApproval(executions_, 59); + + executions_ = _erc20Executions(1, TOKEN_OUT_AMOUNT); + executions_[0].callData = abi.encodeCall(IERC20.transfer, (address(metaSwap), TOKEN_IN_AMOUNT)); + _expectInvalidApproval(executions_, 60); + + executions_ = _erc20Executions(1, TOKEN_OUT_AMOUNT); + executions_[0].callData = abi.encodeCall(IERC20.approve, (makeAddr("OtherSpender"), TOKEN_IN_AMOUNT)); + _expectInvalidApproval(executions_, 61); + + executions_ = _erc20Executions(1, TOKEN_OUT_AMOUNT); + executions_[0].callData = abi.encodePacked( + IERC20.approve.selector, bytes32(uint256(uint160(address(metaSwap))) | (uint256(1) << 255)), bytes32(TOKEN_IN_AMOUNT) + ); + _expectInvalidApproval(executions_, 62); + + executions_ = _erc20Executions(1, TOKEN_OUT_AMOUNT); + executions_[0].callData = abi.encodeCall(IERC20.approve, (address(metaSwap), TOKEN_IN_AMOUNT - 1)); + _expectInvalidApproval(executions_, 63); + } + + function test_flexibleRejectsInvalidResetApproval() public { + Execution[] memory executions_ = _erc20Executions(2, TOKEN_OUT_AMOUNT); + executions_[0].callData = abi.encodeCall(IERC20.approve, (address(metaSwap), 1)); + _expectInvalidApproval(executions_, 64); + } + + function test_flexibleRejectsInvalidSwap() public { + Execution[] memory executions_ = _erc20Executions(1, TOKEN_OUT_AMOUNT); + executions_[1].target = makeAddr("OtherSwap"); + _expectInvalidSwap(executions_, 65); + + executions_ = _erc20Executions(1, TOKEN_OUT_AMOUNT); + executions_[1].value = 1; + _expectInvalidSwap(executions_, 66); + + executions_ = _erc20Executions(1, TOKEN_OUT_AMOUNT); + executions_[1].callData = abi.encodePacked(IMetaSwap.swap.selector); + _expectInvalidSwap(executions_, 67); + + executions_ = _erc20Executions(1, TOKEN_OUT_AMOUNT); + executions_[1].callData = abi.encodeCall(IERC20.approve, (address(metaSwap), TOKEN_IN_AMOUNT)); + _expectInvalidSwap(executions_, 68); + + executions_ = _erc20Executions(1, TOKEN_OUT_AMOUNT); + executions_[1].callData = _minimumSwapCalldata(bytes32(uint256(uint160(address(tokenIn))) | (uint256(1) << 255))); + _expectInvalidSwap(executions_, 69); + + executions_ = _erc20Executions(1, TOKEN_OUT_AMOUNT); + executions_[1].callData = abi.encodeCall( + IMetaSwap.swap, ("redeemer-route", IERC20(makeAddr("OtherToken")), TOKEN_IN_AMOUNT, abi.encode(tokenOut, TOKEN_OUT_AMOUNT)) + ); + _expectInvalidSwap(executions_, 70); + + executions_ = _erc20Executions(1, TOKEN_OUT_AMOUNT); + executions_[1].callData = abi.encodeCall( + IMetaSwap.swap, ("redeemer-route", IERC20(address(tokenIn)), TOKEN_IN_AMOUNT - 1, abi.encode(tokenOut, TOKEN_OUT_AMOUNT)) + ); + _expectInvalidSwap(executions_, 71); + } + + function test_flexibleRejectsIncompleteSwapCalldata() public { + Execution[] memory executions_ = _erc20Executions(1, TOKEN_OUT_AMOUNT); + 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_, 72); + } + + function test_flexibleAcceptsMinimumLengthSwapCalldata() public { + Execution[] memory executions_ = _erc20Executions(1, TOKEN_OUT_AMOUNT); + executions_[1].callData = _minimumSwapCalldata(bytes32(uint256(uint160(address(tokenIn))))); + assertEq(executions_[1].callData.length, 196); + + bytes memory revertData_ = _redeemCatch( + _signIntent(_flexibleTerms(address(tokenIn), _approveMode(), address(tokenOut), orderAccount), 73), + ExecutionLib.encodeBatch(executions_) + ); + if (revertData_.length >= 4) { + assertTrue(bytes4(revertData_) != MetaSwapOrderDelegationManager.InvalidSwap.selector); + } + } + + function test_flexibleRejectsNativeSwapWithWrongValue() public { + Execution[] memory executions_ = _nativeExecutions(TOKEN_OUT_AMOUNT); + executions_[0].value = TOKEN_IN_AMOUNT - 1; + _expectInvalidSwap(executions_, 74, _flexibleTerms(address(0), _noneMode(), address(tokenOut), orderAccount)); + } + + // -------- Gas comparisons -------- + + function test_gas_genericExactBatchPlusLimitedCalls() public { + Execution[] memory executions_ = _erc20ExecutionsFor(genericAccount, 1, TOKEN_OUT_AMOUNT); + bytes memory encoded_ = ExecutionLib.encodeBatch(executions_); + + Caveat[] memory caveats_ = new Caveat[](2); + caveats_[0] = Caveat({ enforcer: address(exactBatchEnforcer), terms: encoded_, args: hex"" }); + caveats_[1] = Caveat({ enforcer: address(limitedCallsEnforcer), terms: abi.encode(uint256(1)), args: hex"" }); + Delegation memory delegation_ = _signGeneric(caveats_, 100); + + (bytes[] memory permissionContexts_, ModeCode[] memory modes_, bytes[] memory executionContexts_) = + _redemptionInputs(delegation_, encoded_); + + uint256 gasBefore_ = gasleft(); + vm.prank(relayer); + genericManager.redeemDelegations(permissionContexts_, modes_, executionContexts_); + emit log_named_uint("generic ExactBatch + LimitedCalls(1)", gasBefore_ - gasleft()); + } + + function test_gas_genericExactBatchPlusLimitedCallsResetApproval() public { + bytes memory encoded_ = ExecutionLib.encodeBatch(_erc20ExecutionsFor(genericAccount, 2, TOKEN_OUT_AMOUNT)); + Caveat[] memory caveats_ = new Caveat[](2); + caveats_[0] = Caveat({ enforcer: address(exactBatchEnforcer), terms: encoded_, args: hex"" }); + caveats_[1] = Caveat({ enforcer: address(limitedCallsEnforcer), terms: abi.encode(uint256(1)), args: hex"" }); + Delegation memory delegation_ = _signGeneric(caveats_, 105); + + (bytes[] memory permissionContexts_, ModeCode[] memory modes_, bytes[] memory executionContexts_) = + _redemptionInputs(delegation_, encoded_); + + uint256 gasBefore_ = gasleft(); + vm.prank(relayer); + genericManager.redeemDelegations(permissionContexts_, modes_, executionContexts_); + emit log_named_uint("generic ExactBatch + LimitedCalls(1), reset approval", gasBefore_ - gasleft()); + } + + function test_gas_genericExactBatchPlusLimitedCallsNative() public { + bytes memory encoded_ = ExecutionLib.encodeBatch(_nativeExecutions(TOKEN_OUT_AMOUNT)); + Caveat[] memory caveats_ = new Caveat[](2); + caveats_[0] = Caveat({ enforcer: address(exactBatchEnforcer), terms: encoded_, args: hex"" }); + caveats_[1] = Caveat({ enforcer: address(limitedCallsEnforcer), terms: abi.encode(uint256(1)), args: hex"" }); + Delegation memory delegation_ = _signGeneric(caveats_, 106); + + (bytes[] memory permissionContexts_, ModeCode[] memory modes_, bytes[] memory executionContexts_) = + _redemptionInputs(delegation_, encoded_); + + uint256 gasBefore_ = gasleft(); + vm.prank(relayer); + genericManager.redeemDelegations(permissionContexts_, modes_, executionContexts_); + emit log_named_uint("generic ExactBatch + LimitedCalls(1), native", gasBefore_ - gasleft()); + } + + function test_gas_genericFlexibleSettlementEnforcer() public { + bytes memory terms_ = abi.encodePacked( + address(metaSwap), + address(tokenIn), + TOKEN_IN_AMOUNT, + uint8(MetaSwapFlexibleSettlementEnforcer.ApprovalMode.Approve), + address(tokenOut), + genericAccount, + TOKEN_OUT_MIN + ); + Caveat[] memory caveats_ = new Caveat[](1); + caveats_[0] = Caveat({ enforcer: address(flexibleEnforcer), terms: terms_, args: hex"" }); + Delegation memory delegation_ = _signGeneric(caveats_, 101); + bytes memory encoded_ = ExecutionLib.encodeBatch(_erc20ExecutionsFor(genericAccount, 1, TOKEN_OUT_AMOUNT)); + + (bytes[] memory permissionContexts_, ModeCode[] memory modes_, bytes[] memory executionContexts_) = + _redemptionInputs(delegation_, encoded_); + + uint256 gasBefore_ = gasleft(); + vm.prank(relayer); + genericManager.redeemDelegations(permissionContexts_, modes_, executionContexts_); + emit log_named_uint("generic FlexibleSettlementEnforcer", gasBefore_ - gasleft()); + } + + function test_gas_genericFlexibleSettlementEnforcerResetApproval() public { + bytes memory terms_ = abi.encodePacked( + address(metaSwap), + address(tokenIn), + TOKEN_IN_AMOUNT, + uint8(MetaSwapFlexibleSettlementEnforcer.ApprovalMode.ResetApprove), + address(tokenOut), + genericAccount, + TOKEN_OUT_MIN + ); + Caveat[] memory caveats_ = new Caveat[](1); + caveats_[0] = Caveat({ enforcer: address(flexibleEnforcer), terms: terms_, args: hex"" }); + Delegation memory delegation_ = _signGeneric(caveats_, 107); + bytes memory encoded_ = ExecutionLib.encodeBatch(_erc20ExecutionsFor(genericAccount, 2, TOKEN_OUT_AMOUNT)); + + (bytes[] memory permissionContexts_, ModeCode[] memory modes_, bytes[] memory executionContexts_) = + _redemptionInputs(delegation_, encoded_); + + uint256 gasBefore_ = gasleft(); + vm.prank(relayer); + genericManager.redeemDelegations(permissionContexts_, modes_, executionContexts_); + emit log_named_uint("generic FlexibleSettlementEnforcer, reset approval", gasBefore_ - gasleft()); + } + + function test_gas_genericFlexibleSettlementEnforcerNative() public { + bytes memory terms_ = abi.encodePacked( + address(metaSwap), + address(0), + TOKEN_IN_AMOUNT, + uint8(MetaSwapFlexibleSettlementEnforcer.ApprovalMode.None), + address(tokenOut), + genericAccount, + TOKEN_OUT_MIN + ); + Caveat[] memory caveats_ = new Caveat[](1); + caveats_[0] = Caveat({ enforcer: address(flexibleEnforcer), terms: terms_, args: hex"" }); + Delegation memory delegation_ = _signGeneric(caveats_, 108); + bytes memory encoded_ = ExecutionLib.encodeBatch(_nativeExecutions(TOKEN_OUT_AMOUNT)); + + (bytes[] memory permissionContexts_, ModeCode[] memory modes_, bytes[] memory executionContexts_) = + _redemptionInputs(delegation_, encoded_); + + uint256 gasBefore_ = gasleft(); + vm.prank(relayer); + genericManager.redeemDelegations(permissionContexts_, modes_, executionContexts_); + emit log_named_uint("generic FlexibleSettlementEnforcer, native", gasBefore_ - gasleft()); + } + + function test_gas_hooklessFlexible() public { + bytes memory terms_ = abi.encodePacked( + address(metaSwap), + address(tokenIn), + TOKEN_IN_AMOUNT, + uint8(MetaSwapFlexibleSettlementManagerBase.ApprovalMode.Approve), + address(tokenOut), + hooklessAccount, + TOKEN_OUT_MIN + ); + Caveat[] memory caveats_ = new Caveat[](1); + caveats_[0] = Caveat({ enforcer: address(hooklessManager), terms: terms_, args: hex"" }); + Delegation memory delegation_ = _signManager(hooklessManager, HOOKLESS_KEY, hooklessAccount, caveats_, 102); + bytes memory encoded_ = ExecutionLib.encodeBatch(_erc20ExecutionsFor(hooklessAccount, 1, TOKEN_OUT_AMOUNT)); + + (bytes[] memory permissionContexts_, ModeCode[] memory modes_, bytes[] memory executionContexts_) = + _redemptionInputs(delegation_, encoded_); + + uint256 gasBefore_ = gasleft(); + vm.prank(relayer); + hooklessManager.redeemDelegations(permissionContexts_, modes_, executionContexts_); + emit log_named_uint("hookless flexible", gasBefore_ - gasleft()); + } + + function test_gas_orderExact() public { + bytes memory encoded_ = ExecutionLib.encodeBatch(_erc20Executions(1, TOKEN_OUT_AMOUNT)); + Delegation memory delegation_ = _signIntent(_exactTerms(keccak256(encoded_)), 103); + + (bytes[] memory permissionContexts_, ModeCode[] memory modes_, bytes[] memory executionContexts_) = + _redemptionInputs(delegation_, encoded_); + + uint256 gasBefore_ = gasleft(); + vm.prank(relayer); + orderManager.redeemDelegations(permissionContexts_, modes_, executionContexts_); + emit log_named_uint("order ExactCalldata", gasBefore_ - gasleft()); + } + + function test_gas_orderExactResetApproval() public { + bytes memory encoded_ = ExecutionLib.encodeBatch(_erc20Executions(2, TOKEN_OUT_AMOUNT)); + Delegation memory delegation_ = _signIntent(_exactTerms(keccak256(encoded_)), 109); + + (bytes[] memory permissionContexts_, ModeCode[] memory modes_, bytes[] memory executionContexts_) = + _redemptionInputs(delegation_, encoded_); + + uint256 gasBefore_ = gasleft(); + vm.prank(relayer); + orderManager.redeemDelegations(permissionContexts_, modes_, executionContexts_); + emit log_named_uint("order ExactCalldata, reset approval", gasBefore_ - gasleft()); + } + + function test_gas_orderExactNative() public { + bytes memory encoded_ = ExecutionLib.encodeBatch(_nativeExecutions(TOKEN_OUT_AMOUNT)); + Delegation memory delegation_ = _signIntent(_exactTerms(keccak256(encoded_)), 110); + + (bytes[] memory permissionContexts_, ModeCode[] memory modes_, bytes[] memory executionContexts_) = + _redemptionInputs(delegation_, encoded_); + + uint256 gasBefore_ = gasleft(); + vm.prank(relayer); + orderManager.redeemDelegations(permissionContexts_, modes_, executionContexts_); + emit log_named_uint("order ExactCalldata, native", gasBefore_ - gasleft()); + } + + function test_gas_orderFlexible() public { + bytes memory terms_ = _flexibleTerms(address(tokenIn), _approveMode(), address(tokenOut), orderAccount); + Delegation memory delegation_ = _signIntent(terms_, 104); + bytes memory encoded_ = ExecutionLib.encodeBatch(_erc20Executions(1, TOKEN_OUT_AMOUNT)); + + (bytes[] memory permissionContexts_, ModeCode[] memory modes_, bytes[] memory executionContexts_) = + _redemptionInputs(delegation_, encoded_); + + uint256 gasBefore_ = gasleft(); + vm.prank(relayer); + orderManager.redeemDelegations(permissionContexts_, modes_, executionContexts_); + emit log_named_uint("order FlexibleSettlement", gasBefore_ - gasleft()); + } + + function test_gas_orderFlexibleResetApproval() public { + bytes memory terms_ = _flexibleTerms(address(tokenIn), _resetApproveMode(), address(tokenOut), orderAccount); + Delegation memory delegation_ = _signIntent(terms_, 111); + bytes memory encoded_ = ExecutionLib.encodeBatch(_erc20Executions(2, TOKEN_OUT_AMOUNT)); + + (bytes[] memory permissionContexts_, ModeCode[] memory modes_, bytes[] memory executionContexts_) = + _redemptionInputs(delegation_, encoded_); + + uint256 gasBefore_ = gasleft(); + vm.prank(relayer); + orderManager.redeemDelegations(permissionContexts_, modes_, executionContexts_); + emit log_named_uint("order FlexibleSettlement, reset approval", gasBefore_ - gasleft()); + } + + function test_gas_orderFlexibleNative() public { + bytes memory terms_ = _flexibleTerms(address(0), _noneMode(), address(tokenOut), orderAccount); + Delegation memory delegation_ = _signIntent(terms_, 112); + bytes memory encoded_ = ExecutionLib.encodeBatch(_nativeExecutions(TOKEN_OUT_AMOUNT)); + + (bytes[] memory permissionContexts_, ModeCode[] memory modes_, bytes[] memory executionContexts_) = + _redemptionInputs(delegation_, encoded_); + + uint256 gasBefore_ = gasleft(); + vm.prank(relayer); + orderManager.redeemDelegations(permissionContexts_, modes_, executionContexts_); + emit log_named_uint("order FlexibleSettlement, native", gasBefore_ - gasleft()); + } + + // -------- Helpers -------- + + function _installDeleGator(address account_, address manager_) private { + EIP7702StatelessDeleGator implementation_ = new EIP7702StatelessDeleGator(IDelegationManager(manager_), entryPoint); + vm.etch(account_, bytes.concat(hex"ef0100", abi.encodePacked(implementation_))); + } + + function _exactTerms(bytes32 executionHash_) private pure returns (bytes memory) { + return abi.encodePacked(uint8(MetaSwapOrderDelegationManager.Intent.ExactCalldata), executionHash_); + } + + function _flexibleTerms( + address tokenIn_, + MetaSwapOrderDelegationManager.ApprovalMode approvalMode_, + address tokenOut_, + address recipient_ + ) + private + view + returns (bytes memory) + { + return abi.encodePacked( + uint8(MetaSwapOrderDelegationManager.Intent.FlexibleSettlement), + address(metaSwap), + tokenIn_, + TOKEN_IN_AMOUNT, + uint8(approvalMode_), + tokenOut_, + recipient_, + TOKEN_OUT_MIN + ); + } + + function _rawFlexibleTerms( + address metaSwap_, + address tokenIn_, + uint256 tokenInAmount_, + uint8 approvalMode_, + address tokenOut_, + address recipient_ + ) + private + pure + returns (bytes memory) + { + return _rawFlexibleTerms(metaSwap_, tokenIn_, tokenInAmount_, approvalMode_, tokenOut_, recipient_, TOKEN_OUT_MIN); + } + + function _rawFlexibleTerms( + address metaSwap_, + address tokenIn_, + uint256 tokenInAmount_, + uint8 approvalMode_, + address tokenOut_, + address recipient_, + uint256 tokenOutMin_ + ) + private + pure + returns (bytes memory) + { + return abi.encodePacked( + uint8(MetaSwapOrderDelegationManager.Intent.FlexibleSettlement), + metaSwap_, + tokenIn_, + tokenInAmount_, + approvalMode_, + tokenOut_, + recipient_, + tokenOutMin_ + ); + } + + 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 _expectFlexibleRevert( + bytes memory terms_, + Execution[] memory executions_, + bytes4 selector_, + uint256 salt_ + ) + private + { + Delegation memory delegation_ = _signIntent(terms_, salt_); + vm.expectRevert(selector_); + _redeemIntent(delegation_, ExecutionLib.encodeBatch(executions_)); + } + + function _expectInvalidApproval(Execution[] memory executions_, uint256 salt_) private { + bytes memory terms_ = executions_.length == 3 + ? _flexibleTerms(address(tokenIn), _resetApproveMode(), address(tokenOut), orderAccount) + : _flexibleTerms(address(tokenIn), _approveMode(), address(tokenOut), orderAccount); + _expectFlexibleRevert(terms_, executions_, MetaSwapOrderDelegationManager.InvalidApproval.selector, salt_); + } + + function _expectInvalidSwap(Execution[] memory executions_, uint256 salt_) private { + _expectInvalidSwap(executions_, salt_, _flexibleTerms(address(tokenIn), _approveMode(), address(tokenOut), orderAccount)); + } + + function _expectInvalidSwap(Execution[] memory executions_, uint256 salt_, bytes memory terms_) private { + _expectFlexibleRevert(terms_, executions_, MetaSwapOrderDelegationManager.InvalidSwap.selector, salt_); + } + + function _redeemCatch( + Delegation memory delegation_, + bytes memory executionContext_ + ) + private + returns (bytes memory revertData_) + { + (bytes[] memory permissionContexts_, ModeCode[] memory modes_, bytes[] memory executionContexts_) = + _redemptionInputs(delegation_, executionContext_); + vm.prank(relayer); + try orderManager.redeemDelegations(permissionContexts_, modes_, executionContexts_) { } + catch (bytes memory reason_) { + return reason_; + } + } + + function _signIntent(bytes memory terms_, uint256 salt_) private view returns (Delegation memory) { + return _signIntentWithKey(ORDER_KEY, terms_, salt_); + } + + function _signIntentWithKey( + uint256 signerKey_, + bytes memory terms_, + uint256 salt_ + ) + private + view + returns (Delegation memory delegation_) + { + Caveat[] memory caveats_ = new Caveat[](1); + caveats_[0] = Caveat({ enforcer: address(orderManager), terms: terms_, args: hex"" }); + return _signManager(orderManager, signerKey_, orderAccount, caveats_, salt_); + } + + function _signGeneric(Caveat[] memory caveats_, uint256 salt_) private view returns (Delegation memory) { + return _signManager(MetaSwapDelegationManagerBase(address(0)), GENERIC_KEY, genericAccount, caveats_, salt_, true); + } + + function _signManager( + MetaSwapDelegationManagerBase manager_, + uint256 signerKey_, + address delegator_, + Caveat[] memory caveats_, + uint256 salt_ + ) + private + view + returns (Delegation memory) + { + return _signManager(manager_, signerKey_, delegator_, caveats_, salt_, false); + } + + function _signManager( + MetaSwapDelegationManagerBase manager_, + uint256 signerKey_, + address delegator_, + Caveat[] memory caveats_, + uint256 salt_, + bool useGeneric_ + ) + private + view + returns (Delegation memory delegation_) + { + bytes32 rootAuthority_ = useGeneric_ ? genericManager.ROOT_AUTHORITY() : manager_.ROOT_AUTHORITY(); + delegation_ = Delegation({ + delegate: address(0xa11), + delegator: delegator_, + authority: rootAuthority_, + caveats: caveats_, + salt: salt_, + signature: hex"" + }); + + bytes32 delegationHash_; + bytes32 domainHash_; + if (useGeneric_) { + delegationHash_ = genericManager.getDelegationHash(delegation_); + domainHash_ = genericManager.getDomainHash(); + } else { + delegationHash_ = manager_.getDelegationHash(delegation_); + domainHash_ = manager_.getDomainHash(); + } + + bytes32 typedDataHash_ = MessageHashUtils.toTypedDataHash(domainHash_, delegationHash_); + (uint8 v_, bytes32 r_, bytes32 s_) = vm.sign(signerKey_, typedDataHash_); + delegation_ = Delegation({ + delegate: delegation_.delegate, + delegator: delegation_.delegator, + authority: delegation_.authority, + caveats: delegation_.caveats, + salt: delegation_.salt, + signature: abi.encodePacked(r_, s_, v_) + }); + } + + function _redeemIntent(Delegation memory delegation_, bytes memory executionContext_) private { + (bytes[] memory permissionContexts_, ModeCode[] memory modes_, bytes[] memory executionContexts_) = + _redemptionInputs(delegation_, executionContext_); + vm.prank(relayer); + orderManager.redeemDelegations(permissionContexts_, modes_, executionContexts_); + } + + function _redemptionInputs( + Delegation memory delegation_, + bytes memory executionContext_ + ) + private + pure + returns (bytes[] memory permissionContexts_, ModeCode[] memory modes_, bytes[] memory executionContexts_) + { + Delegation[] memory delegations_ = new Delegation[](1); + delegations_[0] = delegation_; + permissionContexts_ = new bytes[](1); + permissionContexts_[0] = abi.encode(delegations_); + modes_ = new ModeCode[](1); + modes_[0] = ModeLib.encodeSimpleBatch(); + executionContexts_ = new bytes[](1); + executionContexts_[0] = executionContext_; + } + + function _erc20Executions(uint8 approvalCount_, uint256 outputAmount_) private view returns (Execution[] memory) { + return _erc20ExecutionsFor(orderAccount, approvalCount_, outputAmount_); + } + + function _erc20ExecutionsFor( + address, + uint8 approvalCount_, + uint256 outputAmount_ + ) + private + view + returns (Execution[] memory executions_) + { + uint256 swapIndex_ = approvalCount_; + executions_ = new Execution[](swapIndex_ + 1); + if (approvalCount_ == 2) executions_[0] = _approvalExecution(0); + if (approvalCount_ != 0) executions_[swapIndex_ - 1] = _approvalExecution(TOKEN_IN_AMOUNT); + executions_[swapIndex_] = Execution({ + target: address(metaSwap), + value: 0, + callData: abi.encodeCall( + IMetaSwap.swap, + ("redeemer-route", IERC20(address(tokenIn)), TOKEN_IN_AMOUNT, abi.encode(IERC20(address(tokenOut)), outputAmount_)) + ) + }); + } + + function _nativeExecutions(uint256 outputAmount_) private view returns (Execution[] memory executions_) { + executions_ = new Execution[](1); + executions_[0] = Execution({ + target: address(metaSwap), + value: TOKEN_IN_AMOUNT, + callData: abi.encodeCall( + IMetaSwap.swap, + ("redeemer-route", IERC20(address(0)), TOKEN_IN_AMOUNT, abi.encode(IERC20(address(tokenOut)), outputAmount_)) + ) + }); + } + + 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 _noneMode() private pure returns (MetaSwapOrderDelegationManager.ApprovalMode) { + return MetaSwapOrderDelegationManager.ApprovalMode.None; + } + + function _skipApprovalMode() private pure returns (MetaSwapOrderDelegationManager.ApprovalMode) { + return MetaSwapOrderDelegationManager.ApprovalMode.SkipApproval; + } + + function _approveMode() private pure returns (MetaSwapOrderDelegationManager.ApprovalMode) { + return MetaSwapOrderDelegationManager.ApprovalMode.Approve; + } + + function _resetApproveMode() private pure returns (MetaSwapOrderDelegationManager.ApprovalMode) { + return MetaSwapOrderDelegationManager.ApprovalMode.ResetApprove; + } +} diff --git a/test/MetaSwapSpecializedDelegationManagers.t.sol b/test/MetaSwapSpecializedDelegationManagers.t.sol new file mode 100644 index 00000000..2ca7bf52 --- /dev/null +++ b/test/MetaSwapSpecializedDelegationManagers.t.sol @@ -0,0 +1,588 @@ +// SPDX-License-Identifier: MIT AND Apache-2.0 +pragma solidity 0.8.23; + +import { Test } from "forge-std/Test.sol"; +import { EntryPoint } from "@account-abstraction/core/EntryPoint.sol"; +import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import { SafeERC20 } from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +import { MessageHashUtils } from "@openzeppelin/contracts/utils/cryptography/MessageHashUtils.sol"; +import { ExecutionLib } from "@erc7579/lib/ExecutionLib.sol"; +import { ModeLib } from "@erc7579/lib/ModeLib.sol"; + +import { MetaSwapDelegationManagerBase } from "../src/MetaSwapDelegationManagerBase.sol"; +import { MetaSwapFlexibleSettlementManagerBase } from "../src/experiments/MetaSwapFlexibleSettlementManagerBase.sol"; +import { MetaSwapExecutionBuilderDelegationManager } from "../src/experiments/MetaSwapExecutionBuilderDelegationManager.sol"; +import { MetaSwapHooklessDelegationManager } from "../src/experiments/MetaSwapHooklessDelegationManager.sol"; +import { DelegationManager } from "../src/DelegationManager.sol"; +import { EIP7702StatelessDeleGator } from "../src/EIP7702/EIP7702StatelessDeleGator.sol"; +import { MetaSwapFlexibleSettlementEnforcer } from "../src/enforcers/MetaSwapFlexibleSettlementEnforcer.sol"; +import { IMetaSwap } from "../src/helpers/interfaces/IMetaSwap.sol"; +import { IDelegationManager } from "../src/interfaces/IDelegationManager.sol"; +import { BasicERC20 } from "./utils/BasicERC20.t.sol"; +import { Caveat, Delegation, Execution, ModeCode } from "../src/utils/Types.sol"; + +contract SpecializedManagerMetaSwapMock is IMetaSwap { + using SafeERC20 for IERC20; + + mapping(string aggregatorId => Adapter adapter) private adapters_; + + function setAdapter(string calldata aggregatorId_, address addr_, bytes4 selector_, bytes calldata data_) external { + adapters_[aggregatorId_] = Adapter({ addr: addr_, selector: selector_, data: data_ }); + } + + function removeAdapter(string calldata aggregatorId_) external { + delete adapters_[aggregatorId_]; + } + + function adapters(string memory aggregatorId_) external view returns (Adapter memory) { + return adapters_[aggregatorId_]; + } + + function swap(string calldata, IERC20 tokenFrom_, uint256 amount_, bytes calldata data_) external payable { + if (address(tokenFrom_) == address(0)) { + require(msg.value == amount_, "invalid-native-input"); + } else { + tokenFrom_.safeTransferFrom(msg.sender, address(this), amount_); + } + + (IERC20 tokenOut_, uint256 amountOut_) = abi.decode(data_, (IERC20, uint256)); + if (address(tokenOut_) == address(0)) { + (bool success_,) = msg.sender.call{ value: amountOut_ }(""); + require(success_, "native-output-failed"); + } else { + tokenOut_.safeTransfer(msg.sender, amountOut_); + } + } + + receive() external payable { } +} + +contract MetaSwapSpecializedDelegationManagersTest is Test { + uint256 private constant TOKEN_IN_AMOUNT = 100 ether; + uint256 private constant TOKEN_OUT_MIN = 190 ether; + uint256 private constant TOKEN_OUT_AMOUNT = 200 ether; + uint256 private constant STANDARD_KEY = 0x5151; + uint256 private constant HOOKLESS_KEY = 0xA11CE; + uint256 private constant BUILDER_KEY = 0xB0B; + + EntryPoint private entryPoint; + SpecializedManagerMetaSwapMock private metaSwap; + BasicERC20 private tokenIn; + BasicERC20 private tokenOut; + DelegationManager private standardManager; + MetaSwapFlexibleSettlementEnforcer private standardEnforcer; + MetaSwapHooklessDelegationManager private hooklessManager; + MetaSwapExecutionBuilderDelegationManager private builderManager; + address private standardAccount; + address private hooklessAccount; + address private builderAccount; + address private relayer; + + function setUp() public { + entryPoint = new EntryPoint(); + metaSwap = new SpecializedManagerMetaSwapMock(); + tokenIn = new BasicERC20(address(this), "Token In", "TIN", 0); + tokenOut = new BasicERC20(address(this), "Token Out", "TOUT", 0); + relayer = makeAddr("Relayer"); + + standardManager = new DelegationManager(address(this)); + standardEnforcer = new MetaSwapFlexibleSettlementEnforcer(); + hooklessManager = new MetaSwapHooklessDelegationManager(); + builderManager = new MetaSwapExecutionBuilderDelegationManager(); + + standardAccount = vm.addr(STANDARD_KEY); + hooklessAccount = vm.addr(HOOKLESS_KEY); + builderAccount = vm.addr(BUILDER_KEY); + _installDeleGator(standardAccount, address(standardManager)); + _installDeleGator(hooklessAccount, address(hooklessManager)); + _installDeleGator(builderAccount, address(builderManager)); + + tokenIn.mint(standardAccount, 1_000 ether); + tokenIn.mint(hooklessAccount, 1_000 ether); + tokenIn.mint(builderAccount, 1_000 ether); + tokenOut.mint(address(metaSwap), 10_000 ether); + vm.deal(standardAccount, 1_000 ether); + vm.deal(hooklessAccount, 1_000 ether); + vm.deal(builderAccount, 1_000 ether); + vm.deal(address(metaSwap), 10_000 ether); + } + + function test_hooklessManagerRedeemsValidatedExecutionBatch() public { + bytes memory terms_ = _terms(address(tokenIn), _approveMode(), address(tokenOut), hooklessAccount); + Delegation memory delegation_ = _sign(hooklessManager, HOOKLESS_KEY, hooklessAccount, terms_, 1); + + _redeemHookless(delegation_, _erc20Executions(1, address(tokenIn), TOKEN_IN_AMOUNT, TOKEN_OUT_AMOUNT)); + + assertEq(tokenIn.balanceOf(hooklessAccount), 900 ether); + assertEq(tokenOut.balanceOf(hooklessAccount), TOKEN_OUT_AMOUNT); + assertTrue(hooklessManager.disabledDelegations(hooklessManager.getDelegationHash(delegation_))); + } + + function test_hooklessManagerRedeemsSkipApprovalExecution() public { + vm.prank(hooklessAccount); + tokenIn.approve(address(metaSwap), TOKEN_IN_AMOUNT); + + bytes memory terms_ = _terms(address(tokenIn), _skipApprovalMode(), address(tokenOut), hooklessAccount); + Delegation memory delegation_ = _sign(hooklessManager, HOOKLESS_KEY, hooklessAccount, terms_, 12); + + _redeemHookless(delegation_, _erc20Executions(0, address(tokenIn), TOKEN_IN_AMOUNT, TOKEN_OUT_AMOUNT)); + + assertEq(tokenOut.balanceOf(hooklessAccount), TOKEN_OUT_AMOUNT); + } + + function test_hooklessManagerRedeemsResetApproveExecution() public { + vm.prank(hooklessAccount); + tokenIn.approve(address(metaSwap), 1); + + bytes memory terms_ = _terms(address(tokenIn), _resetApproveMode(), address(tokenOut), hooklessAccount); + Delegation memory delegation_ = _sign(hooklessManager, HOOKLESS_KEY, hooklessAccount, terms_, 13); + + _redeemHookless(delegation_, _erc20Executions(2, address(tokenIn), TOKEN_IN_AMOUNT, TOKEN_OUT_AMOUNT)); + + assertEq(tokenOut.balanceOf(hooklessAccount), TOKEN_OUT_AMOUNT); + } + + function test_hooklessManagerRedeemsNativeInputExecution() public { + bytes memory terms_ = _terms(address(0), _noneMode(), address(tokenOut), hooklessAccount); + Delegation memory delegation_ = _sign(hooklessManager, HOOKLESS_KEY, hooklessAccount, terms_, 14); + uint256 nativeBefore_ = hooklessAccount.balance; + + _redeemHookless(delegation_, _nativeExecutions(TOKEN_OUT_AMOUNT)); + + assertEq(hooklessAccount.balance, nativeBefore_ - TOKEN_IN_AMOUNT); + assertEq(tokenOut.balanceOf(hooklessAccount), TOKEN_OUT_AMOUNT); + } + + function test_gas_standardManagerWithSettlementEnforcer() public { + bytes memory terms_ = _terms(address(tokenIn), _approveMode(), address(tokenOut), standardAccount); + Delegation memory delegation_ = _signStandard(STANDARD_KEY, standardAccount, terms_, 100); + (bytes[] memory permissionContexts_, ModeCode[] memory modes_, bytes[] memory executionContexts_) = _redemptionInputs( + delegation_, ExecutionLib.encodeBatch(_erc20Executions(1, address(tokenIn), TOKEN_IN_AMOUNT, TOKEN_OUT_AMOUNT)) + ); + + uint256 gasBefore_ = gasleft(); + vm.prank(relayer); + standardManager.redeemDelegations(permissionContexts_, modes_, executionContexts_); + emit log_named_uint("standard manager + enforcer", gasBefore_ - gasleft()); + } + + function test_gas_hooklessManager() public { + bytes memory terms_ = _terms(address(tokenIn), _approveMode(), address(tokenOut), hooklessAccount); + Delegation memory delegation_ = _sign(hooklessManager, HOOKLESS_KEY, hooklessAccount, terms_, 102); + (bytes[] memory permissionContexts_, ModeCode[] memory modes_, bytes[] memory executionContexts_) = _redemptionInputs( + delegation_, ExecutionLib.encodeBatch(_erc20Executions(1, address(tokenIn), TOKEN_IN_AMOUNT, TOKEN_OUT_AMOUNT)) + ); + + uint256 gasBefore_ = gasleft(); + vm.prank(relayer); + hooklessManager.redeemDelegations(permissionContexts_, modes_, executionContexts_); + emit log_named_uint("hookless manager", gasBefore_ - gasleft()); + } + + function test_gas_executionBuilderManager() public { + bytes memory terms_ = _terms(address(tokenIn), _approveMode(), address(tokenOut), builderAccount); + Delegation memory delegation_ = _sign(builderManager, BUILDER_KEY, builderAccount, terms_, 103); + (bytes[] memory permissionContexts_, ModeCode[] memory modes_, bytes[] memory executionContexts_) = + _redemptionInputs(delegation_, abi.encode("redeemer-route", abi.encode(IERC20(address(tokenOut)), TOKEN_OUT_AMOUNT))); + + uint256 gasBefore_ = gasleft(); + vm.prank(relayer); + builderManager.redeemDelegations(permissionContexts_, modes_, executionContexts_); + emit log_named_uint("execution builder", gasBefore_ - gasleft()); + } + + function test_hooklessManagerRejectsInvalidExecutionWithoutCallingHooks() public { + bytes memory terms_ = _terms(address(tokenIn), _approveMode(), address(tokenOut), hooklessAccount); + Delegation memory delegation_ = _sign(hooklessManager, HOOKLESS_KEY, hooklessAccount, terms_, 3); + Execution[] memory executions_ = _erc20Executions(1, address(tokenIn), TOKEN_IN_AMOUNT, TOKEN_OUT_AMOUNT); + executions_[1].target = makeAddr("UnapprovedSwapTarget"); + + vm.expectRevert(MetaSwapHooklessDelegationManager.InvalidSwap.selector); + _redeemHookless(delegation_, executions_); + } + + function test_builderManagerConstructsApproveAndSwapExecutions() public { + bytes memory terms_ = _terms(address(tokenIn), _approveMode(), address(tokenOut), builderAccount); + Delegation memory delegation_ = _sign(builderManager, BUILDER_KEY, builderAccount, terms_, 4); + + _redeemBuilder(delegation_, TOKEN_OUT_AMOUNT); + + assertEq(tokenIn.balanceOf(builderAccount), 900 ether); + assertEq(tokenIn.allowance(builderAccount, address(metaSwap)), 0); + assertEq(tokenOut.balanceOf(builderAccount), TOKEN_OUT_AMOUNT); + } + + function test_builderManagerConstructsResetApproveAndSwapExecutions() public { + vm.prank(builderAccount); + tokenIn.approve(address(metaSwap), 1); + + bytes memory terms_ = _terms(address(tokenIn), _resetApproveMode(), address(tokenOut), builderAccount); + Delegation memory delegation_ = _sign(builderManager, BUILDER_KEY, builderAccount, terms_, 5); + + _redeemBuilder(delegation_, TOKEN_OUT_AMOUNT); + + assertEq(tokenIn.balanceOf(builderAccount), 900 ether); + assertEq(tokenOut.balanceOf(builderAccount), TOKEN_OUT_AMOUNT); + } + + function test_builderManagerConstructsSkipApprovalSwapExecution() public { + vm.prank(builderAccount); + tokenIn.approve(address(metaSwap), TOKEN_IN_AMOUNT); + + bytes memory terms_ = _terms(address(tokenIn), _skipApprovalMode(), address(tokenOut), builderAccount); + Delegation memory delegation_ = _sign(builderManager, BUILDER_KEY, builderAccount, terms_, 6); + + _redeemBuilder(delegation_, TOKEN_OUT_AMOUNT); + + assertEq(tokenIn.balanceOf(builderAccount), 900 ether); + assertEq(tokenOut.balanceOf(builderAccount), TOKEN_OUT_AMOUNT); + } + + function test_builderManagerConstructsNativeInputSwapExecution() public { + bytes memory terms_ = _terms(address(0), _noneMode(), address(tokenOut), builderAccount); + Delegation memory delegation_ = _sign(builderManager, BUILDER_KEY, builderAccount, terms_, 7); + uint256 nativeBefore_ = builderAccount.balance; + + _redeemBuilder(delegation_, TOKEN_OUT_AMOUNT); + + assertEq(builderAccount.balance, nativeBefore_ - TOKEN_IN_AMOUNT); + assertEq(tokenOut.balanceOf(builderAccount), TOKEN_OUT_AMOUNT); + } + + function test_builderManagerRejectsNativeApprovalMode() public { + bytes memory terms_ = _terms(address(0), _approveMode(), address(tokenOut), builderAccount); + Delegation memory delegation_ = _sign(builderManager, BUILDER_KEY, builderAccount, terms_, 15); + + vm.expectRevert(MetaSwapFlexibleSettlementManagerBase.InvalidApprovalMode.selector); + _redeemBuilder(delegation_, TOKEN_OUT_AMOUNT); + } + + function test_builderManagerRejectsNoneModeForERC20() public { + bytes memory terms_ = _terms(address(tokenIn), _noneMode(), address(tokenOut), builderAccount); + Delegation memory delegation_ = _sign(builderManager, BUILDER_KEY, builderAccount, terms_, 16); + + vm.expectRevert(MetaSwapFlexibleSettlementManagerBase.InvalidApprovalMode.selector); + _redeemBuilder(delegation_, TOKEN_OUT_AMOUNT); + } + + function test_builderManagerRevertsAtomicallyForInsufficientOutput() public { + bytes memory terms_ = _terms(address(tokenIn), _approveMode(), address(tokenOut), builderAccount); + Delegation memory delegation_ = _sign(builderManager, BUILDER_KEY, builderAccount, terms_, 8); + bytes32 delegationHash_ = builderManager.getDelegationHash(delegation_); + + vm.expectRevert(MetaSwapDelegationManagerBase.InsufficientOutput.selector); + _redeemBuilder(delegation_, TOKEN_OUT_MIN - 1); + + assertFalse(builderManager.disabledDelegations(delegationHash_)); + assertEq(tokenIn.balanceOf(builderAccount), 1_000 ether); + assertEq(tokenOut.balanceOf(builderAccount), 0); + } + + function test_successfulSettlementCannotBeReplayed() public { + bytes memory terms_ = _terms(address(tokenIn), _approveMode(), address(tokenOut), builderAccount); + Delegation memory delegation_ = _sign(builderManager, BUILDER_KEY, builderAccount, terms_, 9); + + _redeemBuilder(delegation_, TOKEN_OUT_AMOUNT); + + vm.expectRevert(MetaSwapDelegationManagerBase.CannotUseADisabledDelegation.selector); + _redeemBuilder(delegation_, TOKEN_OUT_AMOUNT); + } + + function test_disableDelegationUsesSameOneShotState() public { + bytes memory terms_ = _terms(address(tokenIn), _approveMode(), address(tokenOut), builderAccount); + Delegation memory delegation_ = _sign(builderManager, BUILDER_KEY, builderAccount, terms_, 10); + + vm.prank(builderAccount); + builderManager.disableDelegation(delegation_); + + vm.expectRevert(MetaSwapDelegationManagerBase.CannotUseADisabledDelegation.selector); + _redeemBuilder(delegation_, TOKEN_OUT_AMOUNT); + } + + function test_rejectsSignatureFromDifferentEOA() public { + bytes memory terms_ = _terms(address(tokenIn), _approveMode(), address(tokenOut), builderAccount); + Delegation memory delegation_ = _sign(builderManager, HOOKLESS_KEY, builderAccount, terms_, 11); + + vm.expectRevert(MetaSwapDelegationManagerBase.InvalidERC1271Signature.selector); + _redeemBuilder(delegation_, TOKEN_OUT_AMOUNT); + } + + function test_rejectsUnsupportedBatchShapeAndMode() public { + bytes[] memory emptyContexts_ = new bytes[](0); + ModeCode[] memory emptyModes_ = new ModeCode[](0); + vm.expectRevert(MetaSwapDelegationManagerBase.BatchDataLengthMismatch.selector); + hooklessManager.redeemDelegations(emptyContexts_, emptyModes_, emptyContexts_); + + bytes memory terms_ = _terms(address(tokenIn), _approveMode(), address(tokenOut), hooklessAccount); + Delegation memory delegation_ = _sign(hooklessManager, HOOKLESS_KEY, hooklessAccount, terms_, 17); + (bytes[] memory permissionContexts_, ModeCode[] memory modes_, bytes[] memory executionContexts_) = _redemptionInputs( + delegation_, ExecutionLib.encodeBatch(_erc20Executions(1, address(tokenIn), TOKEN_IN_AMOUNT, TOKEN_OUT_AMOUNT)) + ); + modes_[0] = ModeLib.encodeSimpleSingle(); + + vm.expectRevert(MetaSwapDelegationManagerBase.InvalidMode.selector); + hooklessManager.redeemDelegations(permissionContexts_, modes_, executionContexts_); + } + + function test_rejectsDelegationChainAndInvalidRootFields() public { + bytes memory terms_ = _terms(address(tokenIn), _approveMode(), address(tokenOut), hooklessAccount); + Delegation memory delegation_ = _sign(hooklessManager, HOOKLESS_KEY, hooklessAccount, terms_, 18); + bytes memory executionContext_ = + ExecutionLib.encodeBatch(_erc20Executions(1, address(tokenIn), TOKEN_IN_AMOUNT, TOKEN_OUT_AMOUNT)); + + Delegation[] memory delegations_ = new Delegation[](2); + delegations_[0] = delegation_; + delegations_[1] = delegation_; + bytes[] memory permissionContexts_ = new bytes[](1); + permissionContexts_[0] = abi.encode(delegations_); + ModeCode[] memory modes_ = new ModeCode[](1); + modes_[0] = ModeLib.encodeSimpleBatch(); + bytes[] memory executionContexts_ = new bytes[](1); + executionContexts_[0] = executionContext_; + vm.expectRevert(MetaSwapDelegationManagerBase.InvalidPermissionContext.selector); + hooklessManager.redeemDelegations(permissionContexts_, modes_, executionContexts_); + + delegation_.delegate = makeAddr("WrongDelegate"); + (permissionContexts_, modes_, executionContexts_) = _redemptionInputs(delegation_, executionContext_); + vm.expectRevert(MetaSwapDelegationManagerBase.InvalidDelegate.selector); + hooklessManager.redeemDelegations(permissionContexts_, modes_, executionContexts_); + + delegation_.delegate = address(0xa11); + delegation_.authority = bytes32(0); + (permissionContexts_, modes_, executionContexts_) = _redemptionInputs(delegation_, executionContext_); + vm.expectRevert(MetaSwapDelegationManagerBase.InvalidAuthority.selector); + hooklessManager.redeemDelegations(permissionContexts_, modes_, executionContexts_); + } + + function test_rejectsNonManagerCaveatAndInvalidTerms() public { + bytes memory terms_ = _terms(address(tokenIn), _approveMode(), address(tokenOut), hooklessAccount); + Delegation memory delegation_ = _sign(hooklessManager, HOOKLESS_KEY, hooklessAccount, terms_, 19); + bytes memory executionContext_ = + ExecutionLib.encodeBatch(_erc20Executions(1, address(tokenIn), TOKEN_IN_AMOUNT, TOKEN_OUT_AMOUNT)); + + delegation_.caveats[0].enforcer = makeAddr("ExternalEnforcer"); + (bytes[] memory permissionContexts_, ModeCode[] memory modes_, bytes[] memory executionContexts_) = + _redemptionInputs(delegation_, executionContext_); + vm.expectRevert(MetaSwapDelegationManagerBase.InvalidCaveat.selector); + hooklessManager.redeemDelegations(permissionContexts_, modes_, executionContexts_); + + vm.expectRevert(MetaSwapDelegationManagerBase.InvalidTerms.selector); + hooklessManager.getTermsInfo(new bytes(144)); + + // Mutating signed terms changes the hash, so signature validation fails before terms decoding. + delegation_.caveats[0].enforcer = address(hooklessManager); + delegation_.caveats[0].terms = new bytes(144); + (permissionContexts_, modes_, executionContexts_) = _redemptionInputs(delegation_, executionContext_); + vm.expectRevert(MetaSwapDelegationManagerBase.InvalidERC1271Signature.selector); + hooklessManager.redeemDelegations(permissionContexts_, modes_, executionContexts_); + } + + function test_onlyDelegatorCanDisableDelegation() public { + bytes memory terms_ = _terms(address(tokenIn), _approveMode(), address(tokenOut), builderAccount); + Delegation memory delegation_ = _sign(builderManager, BUILDER_KEY, builderAccount, terms_, 20); + + vm.expectRevert(MetaSwapDelegationManagerBase.InvalidDelegator.selector); + builderManager.disableDelegation(delegation_); + } + + function _installDeleGator(address account_, address manager_) private { + EIP7702StatelessDeleGator implementation_ = new EIP7702StatelessDeleGator(IDelegationManager(manager_), entryPoint); + vm.etch(account_, bytes.concat(hex"ef0100", abi.encodePacked(implementation_))); + } + + function _sign( + MetaSwapDelegationManagerBase manager_, + uint256 signerKey_, + address delegator_, + bytes memory terms_, + uint256 salt_ + ) + private + view + returns (Delegation memory delegation_) + { + Caveat[] memory caveats_ = new Caveat[](1); + caveats_[0] = Caveat({ enforcer: address(manager_), terms: terms_, args: hex"" }); + delegation_ = Delegation({ + delegate: address(0xa11), + delegator: delegator_, + authority: manager_.ROOT_AUTHORITY(), + caveats: caveats_, + salt: salt_, + signature: hex"" + }); + + bytes32 delegationHash_ = manager_.getDelegationHash(delegation_); + bytes32 typedDataHash_ = MessageHashUtils.toTypedDataHash(manager_.getDomainHash(), delegationHash_); + (uint8 v_, bytes32 r_, bytes32 s_) = vm.sign(signerKey_, typedDataHash_); + delegation_ = Delegation({ + delegate: delegation_.delegate, + delegator: delegation_.delegator, + authority: delegation_.authority, + caveats: delegation_.caveats, + salt: delegation_.salt, + signature: abi.encodePacked(r_, s_, v_) + }); + } + + function _signStandard( + uint256 signerKey_, + address delegator_, + bytes memory terms_, + uint256 salt_ + ) + private + view + returns (Delegation memory delegation_) + { + Caveat[] memory caveats_ = new Caveat[](1); + caveats_[0] = Caveat({ enforcer: address(standardEnforcer), terms: terms_, args: hex"" }); + delegation_ = Delegation({ + delegate: address(0xa11), + delegator: delegator_, + authority: standardManager.ROOT_AUTHORITY(), + caveats: caveats_, + salt: salt_, + signature: hex"" + }); + + bytes32 delegationHash_ = standardManager.getDelegationHash(delegation_); + bytes32 typedDataHash_ = MessageHashUtils.toTypedDataHash(standardManager.getDomainHash(), delegationHash_); + (uint8 v_, bytes32 r_, bytes32 s_) = vm.sign(signerKey_, typedDataHash_); + delegation_ = Delegation({ + delegate: delegation_.delegate, + delegator: delegation_.delegator, + authority: delegation_.authority, + caveats: delegation_.caveats, + salt: delegation_.salt, + signature: abi.encodePacked(r_, s_, v_) + }); + } + + function _redeemHookless(Delegation memory delegation_, Execution[] memory executions_) private { + _redeemHookless(hooklessManager, delegation_, executions_); + } + + function _redeemHookless( + MetaSwapHooklessDelegationManager manager_, + Delegation memory delegation_, + Execution[] memory executions_ + ) + private + { + bytes[] memory executionContexts_ = new bytes[](1); + executionContexts_[0] = ExecutionLib.encodeBatch(executions_); + _redeem(manager_, delegation_, executionContexts_); + } + + function _redeemBuilder(Delegation memory delegation_, uint256 outputAmount_) private { + bytes[] memory executionContexts_ = new bytes[](1); + executionContexts_[0] = abi.encode("redeemer-route", abi.encode(IERC20(address(tokenOut)), outputAmount_)); + _redeem(builderManager, delegation_, executionContexts_); + } + + function _redeem( + MetaSwapDelegationManagerBase manager_, + Delegation memory delegation_, + bytes[] memory executionContexts_ + ) + private + { + (bytes[] memory permissionContexts_, ModeCode[] memory modes_,) = _redemptionInputs(delegation_, executionContexts_[0]); + + vm.prank(relayer); + manager_.redeemDelegations(permissionContexts_, modes_, executionContexts_); + } + + function _redemptionInputs( + Delegation memory delegation_, + bytes memory executionContext_ + ) + private + pure + returns (bytes[] memory permissionContexts_, ModeCode[] memory modes_, bytes[] memory executionContexts_) + { + Delegation[] memory delegations_ = new Delegation[](1); + delegations_[0] = delegation_; + permissionContexts_ = new bytes[](1); + permissionContexts_[0] = abi.encode(delegations_); + modes_ = new ModeCode[](1); + modes_[0] = ModeLib.encodeSimpleBatch(); + executionContexts_ = new bytes[](1); + executionContexts_[0] = executionContext_; + } + + function _terms( + address tokenIn_, + MetaSwapFlexibleSettlementManagerBase.ApprovalMode approvalMode_, + address tokenOut_, + address recipient_ + ) + private + view + returns (bytes memory) + { + return abi.encodePacked( + address(metaSwap), tokenIn_, TOKEN_IN_AMOUNT, uint8(approvalMode_), tokenOut_, recipient_, TOKEN_OUT_MIN + ); + } + + function _erc20Executions( + uint8 approvalCount_, + address swapToken_, + uint256 swapAmount_, + uint256 outputAmount_ + ) + private + view + returns (Execution[] memory executions_) + { + uint256 swapIndex_ = approvalCount_; + executions_ = new Execution[](swapIndex_ + 1); + if (approvalCount_ == 2) executions_[0] = _approvalExecution(0); + if (approvalCount_ != 0) executions_[swapIndex_ - 1] = _approvalExecution(TOKEN_IN_AMOUNT); + executions_[swapIndex_] = Execution({ + target: address(metaSwap), + value: 0, + callData: abi.encodeCall( + IMetaSwap.swap, + ("redeemer-route", IERC20(swapToken_), swapAmount_, abi.encode(IERC20(address(tokenOut)), outputAmount_)) + ) + }); + } + + function _nativeExecutions(uint256 outputAmount_) private view returns (Execution[] memory executions_) { + executions_ = new Execution[](1); + executions_[0] = Execution({ + target: address(metaSwap), + value: TOKEN_IN_AMOUNT, + callData: abi.encodeCall( + IMetaSwap.swap, + ("redeemer-route", IERC20(address(0)), TOKEN_IN_AMOUNT, abi.encode(IERC20(address(tokenOut)), outputAmount_)) + ) + }); + } + + 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 _noneMode() private pure returns (MetaSwapFlexibleSettlementManagerBase.ApprovalMode) { + return MetaSwapFlexibleSettlementManagerBase.ApprovalMode.None; + } + + function _skipApprovalMode() private pure returns (MetaSwapFlexibleSettlementManagerBase.ApprovalMode) { + return MetaSwapFlexibleSettlementManagerBase.ApprovalMode.SkipApproval; + } + + function _approveMode() private pure returns (MetaSwapFlexibleSettlementManagerBase.ApprovalMode) { + return MetaSwapFlexibleSettlementManagerBase.ApprovalMode.Approve; + } + + function _resetApproveMode() private pure returns (MetaSwapFlexibleSettlementManagerBase.ApprovalMode) { + return MetaSwapFlexibleSettlementManagerBase.ApprovalMode.ResetApprove; + } +} diff --git a/test/enforcers/MetaSwap7702CalldataEnforcer.t.sol b/test/enforcers/MetaSwap7702CalldataEnforcer.t.sol new file mode 100644 index 00000000..aa763857 --- /dev/null +++ b/test/enforcers/MetaSwap7702CalldataEnforcer.t.sol @@ -0,0 +1,164 @@ +// SPDX-License-Identifier: MIT AND Apache-2.0 +pragma solidity 0.8.23; + +import { Test } from "forge-std/Test.sol"; +import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import { ExecutionLib } from "@erc7579/lib/ExecutionLib.sol"; +import { ModeLib } from "@erc7579/lib/ModeLib.sol"; + +import { MetaSwap7702CalldataEnforcer } from "../../src/enforcers/MetaSwap7702CalldataEnforcer.sol"; +import { IMetaSwap } from "../../src/helpers/interfaces/IMetaSwap.sol"; +import { IERC7821 } from "../../src/interfaces/IERC7821.sol"; +import { Execution, ModeCode } from "../../src/utils/Types.sol"; + +contract MetaSwap7702CalldataEnforcerTest is Test { + uint256 internal constant TOKEN_IN_AMOUNT = 100 ether; + + MetaSwap7702CalldataEnforcer internal enforcer; + address internal delegator; + address internal metaSwap; + address internal tokenIn; + + ModeCode internal singleDefaultMode = ModeLib.encodeSimpleSingle(); + + function setUp() public { + enforcer = new MetaSwap7702CalldataEnforcer(); + delegator = makeAddr("Delegator"); + metaSwap = makeAddr("MetaSwap"); + tokenIn = makeAddr("TokenIn"); + } + + function test_erc20OneApproval_acceptsArbitraryDynamicRoute() public view { + _enforce(_terms(tokenIn, false), _outer(_erc20Inner(false, tokenIn, TOKEN_IN_AMOUNT, "aggregator-a", hex"01"))); + _enforce( + _terms(tokenIn, false), _outer(_erc20Inner(false, tokenIn, TOKEN_IN_AMOUNT, "different-aggregator", new bytes(512))) + ); + } + + function test_erc20ResetApproval_acceptsSignedThreeCallShape() public view { + _enforce(_terms(tokenIn, true), _outer(_erc20Inner(true, tokenIn, TOKEN_IN_AMOUNT, "aggregator", new bytes(96)))); + } + + function test_nativeInput_acceptsOneSwapWithExactValue() public view { + Execution[] memory executions_ = new Execution[](1); + executions_[0] = _swapExecution(address(0), TOKEN_IN_AMOUNT, TOKEN_IN_AMOUNT, "aggregator", hex"1234"); + + _enforce(_terms(address(0), false), _outer(executions_)); + } + + function test_revertsOnDifferentSwapTokenFrom() public { + Execution memory outer_ = _outer(_erc20Inner(false, makeAddr("OtherToken"), TOKEN_IN_AMOUNT, "aggregator", hex"")); + + vm.expectRevert("MetaSwap7702CalldataEnforcer:invalid-swap"); + _enforce(_terms(tokenIn, false), outer_); + } + + function test_revertsOnDifferentSwapAmount() public { + Execution memory outer_ = _outer(_erc20Inner(false, tokenIn, TOKEN_IN_AMOUNT - 1, "aggregator", hex"")); + + vm.expectRevert("MetaSwap7702CalldataEnforcer:invalid-swap"); + _enforce(_terms(tokenIn, false), outer_); + } + + function test_revertsWhenResetShapeDoesNotMatchTerms() public { + Execution memory outer_ = _outer(_erc20Inner(true, tokenIn, TOKEN_IN_AMOUNT, "aggregator", hex"")); + + vm.expectRevert("MetaSwap7702CalldataEnforcer:invalid-batch-length"); + _enforce(_terms(tokenIn, false), outer_); + } + + function test_revertsWhenApprovalAmountDoesNotMatch() public { + Execution[] memory executions_ = _erc20Inner(false, tokenIn, TOKEN_IN_AMOUNT, "aggregator", hex""); + executions_[0].callData = abi.encodeCall(IERC20.approve, (metaSwap, TOKEN_IN_AMOUNT - 1)); + + vm.expectRevert("MetaSwap7702CalldataEnforcer:invalid-approval"); + _enforce(_terms(tokenIn, false), _outer(executions_)); + } + + function test_revertsWhenOuterTargetIsNotDelegator() public { + Execution memory outer_ = _outer(_erc20Inner(false, tokenIn, TOKEN_IN_AMOUNT, "aggregator", hex"")); + outer_.target = makeAddr("OtherTarget"); + + vm.expectRevert("MetaSwap7702CalldataEnforcer:invalid-outer-execution"); + _enforce(_terms(tokenIn, false), outer_); + } + + function test_revertsWhenInnerModeIsNotBatchDefault() public { + Execution[] memory executions_ = _erc20Inner(false, tokenIn, TOKEN_IN_AMOUNT, "aggregator", hex""); + Execution memory outer_ = Execution({ + target: delegator, + value: 0, + callData: abi.encodeCall(IERC7821.execute, (ModeLib.encodeSimpleSingle(), ExecutionLib.encodeBatch(executions_))) + }); + + vm.expectRevert("MetaSwap7702CalldataEnforcer:invalid-inner-mode"); + _enforce(_terms(tokenIn, false), outer_); + } + + function _terms(address tokenIn_, bool resetApproval_) private view returns (bytes memory) { + return abi.encodePacked(metaSwap, tokenIn_, TOKEN_IN_AMOUNT, bytes1(resetApproval_ ? 0x01 : 0x00)); + } + + function _erc20Inner( + bool resetApproval_, + address swapToken_, + uint256 swapAmount_, + string memory aggregatorId_, + bytes memory route_ + ) + private + view + returns (Execution[] memory executions_) + { + uint256 swapIndex_ = resetApproval_ ? 2 : 1; + executions_ = new Execution[](swapIndex_ + 1); + + if (resetApproval_) { + executions_[0] = _approvalExecution(0); + } + executions_[swapIndex_ - 1] = _approvalExecution(TOKEN_IN_AMOUNT); + executions_[swapIndex_] = _swapExecution(swapToken_, swapAmount_, 0, aggregatorId_, route_); + } + + function _approvalExecution(uint256 amount_) private view returns (Execution memory) { + return Execution({ target: tokenIn, value: 0, callData: abi.encodeCall(IERC20.approve, (metaSwap, amount_)) }); + } + + function _swapExecution( + address swapToken_, + uint256 swapAmount_, + uint256 value_, + string memory aggregatorId_, + bytes memory route_ + ) + private + view + returns (Execution memory) + { + return Execution({ + target: metaSwap, + value: value_, + callData: abi.encodeCall(IMetaSwap.swap, (aggregatorId_, IERC20(swapToken_), swapAmount_, route_)) + }); + } + + function _outer(Execution[] memory executions_) private view returns (Execution memory) { + return Execution({ + target: delegator, + value: 0, + callData: abi.encodeCall(IERC7821.execute, (ModeLib.encodeSimpleBatch(), ExecutionLib.encodeBatch(executions_))) + }); + } + + function _enforce(bytes memory terms_, Execution memory outer_) private view { + enforcer.beforeHook( + terms_, + hex"", + singleDefaultMode, + ExecutionLib.encodeSingle(outer_.target, outer_.value, outer_.callData), + bytes32(0), + delegator, + address(0) + ); + } +} diff --git a/test/enforcers/MetaSwapApproveSwapEnforcer.t.sol b/test/enforcers/MetaSwapApproveSwapEnforcer.t.sol new file mode 100644 index 00000000..ff7b5baa --- /dev/null +++ b/test/enforcers/MetaSwapApproveSwapEnforcer.t.sol @@ -0,0 +1,805 @@ +// 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 { IMetaSwap } from "../../src/helpers/interfaces/IMetaSwap.sol"; +import { MetaSwapApproveSwapEnforcer } from "../../src/enforcers/MetaSwapApproveSwapEnforcer.sol"; +import { RedeemerEnforcer } from "../../src/enforcers/RedeemerEnforcer.sol"; +import { Caveat, Delegation, Execution, ModeCode } from "../../src/utils/Types.sol"; +import { BasicERC20 } from "../utils/BasicERC20.t.sol"; +import { CaveatEnforcerBaseTest } from "./CaveatEnforcerBaseTest.t.sol"; +import { ICaveatEnforcer } from "../../src/interfaces/ICaveatEnforcer.sol"; + +contract MockMetaSwap is IMetaSwap { + using SafeERC20 for IERC20; + + IERC20 internal immutable tokenOut; + + constructor(IERC20 _tokenOut) { + tokenOut = _tokenOut; + } + + function swap(string calldata, IERC20 _tokenFrom, uint256 _amount, bytes calldata _data) external payable { + (,,,, uint256 amountTo_,,,,) = abi.decode( + abi.encodePacked(abi.encode(address(0)), _data), + (address, IERC20, IERC20, uint256, uint256, bytes, uint256, address, bool) + ); + _tokenFrom.safeTransferFrom(msg.sender, address(this), _amount); + tokenOut.safeTransfer(msg.sender, amountTo_); + } + + function setAdapter(string calldata, address, bytes4, bytes calldata) external { } + function removeAdapter(string calldata) external { } + + function adapters(string memory) external pure returns (Adapter memory) { + return Adapter({ addr: address(0), selector: bytes4(0), data: hex"" }); + } +} + +contract MockMetaSwapUnderpay is IMetaSwap { + using SafeERC20 for IERC20; + + IERC20 internal immutable tokenOut; + uint256 internal immutable payoutAmount; + + constructor(IERC20 _tokenOut, uint256 _payoutAmount) { + tokenOut = _tokenOut; + payoutAmount = _payoutAmount; + } + + function swap(string calldata, IERC20 _tokenFrom, uint256 _amount, bytes calldata) external payable { + _tokenFrom.safeTransferFrom(msg.sender, address(this), _amount); + tokenOut.safeTransfer(msg.sender, payoutAmount); + } + + function setAdapter(string calldata, address, bytes4, bytes calldata) external { } + function removeAdapter(string calldata) external { } + + function adapters(string memory) external pure returns (Adapter memory) { + return Adapter({ addr: address(0), selector: bytes4(0), data: hex"" }); + } +} + +contract MockMetaSwapNoPull is IMetaSwap { + using SafeERC20 for IERC20; + + IERC20 internal immutable tokenOut; + + constructor(IERC20 _tokenOut) { + tokenOut = _tokenOut; + } + + function swap(string calldata, IERC20, uint256, bytes calldata _data) external payable { + (,,,, uint256 amountTo_,,,,) = abi.decode( + abi.encodePacked(abi.encode(address(0)), _data), + (address, IERC20, IERC20, uint256, uint256, bytes, uint256, address, bool) + ); + tokenOut.safeTransfer(msg.sender, amountTo_); + } + + function setAdapter(string calldata, address, bytes4, bytes calldata) external { } + function removeAdapter(string calldata) external { } + + function adapters(string memory) external pure returns (Adapter memory) { + return Adapter({ addr: address(0), selector: bytes4(0), data: hex"" }); + } +} + +contract MetaSwapApproveSwapEnforcerTest is CaveatEnforcerBaseTest { + uint256 internal constant TOKEN_IN_AMOUNT = 100 ether; + uint256 internal constant MIN_TOKEN_OUT = 190 ether; + uint256 internal constant ACTUAL_TOKEN_OUT = 200 ether; + + BasicERC20 internal tokenIn; + BasicERC20 internal tokenOut; + MockMetaSwap internal metaSwap; + MetaSwapApproveSwapEnforcer internal enforcer; + RedeemerEnforcer internal redeemerEnforcer; + + address internal automation; + + function setUp() public override { + super.setUp(); + tokenIn = new BasicERC20(address(this), "Token In", "TIN", 0); + tokenOut = new BasicERC20(address(this), "Token Out", "TOUT", 0); + metaSwap = new MockMetaSwap(tokenOut); + enforcer = new MetaSwapApproveSwapEnforcer(); + redeemerEnforcer = new RedeemerEnforcer(); + automation = makeAddr("metamask-automation"); + + tokenIn.mint(address(users.alice.deleGator), TOKEN_IN_AMOUNT); + tokenOut.mint(address(metaSwap), 1_000 ether); + + vm.label(address(enforcer), "MetaSwap Approve Swap Enforcer"); + vm.label(address(metaSwap), "Mock MetaSwap"); + } + + function _getEnforcer() internal view override returns (ICaveatEnforcer) { + return ICaveatEnforcer(address(enforcer)); + } + + ////////////////////// Valid cases ////////////////////// + + function test_validBatchExecution() public { + (bytes memory terms_, bytes memory executionCallData_) = _buildValidBatch(MIN_TOKEN_OUT); + + vm.prank(address(delegationManager)); + enforcer.beforeHook(terms_, hex"", batchDefaultMode, executionCallData_, keccak256("test"), address(0), address(0)); + } + + function test_validBatchExecutionWithBetterOutput() public { + (bytes memory terms_, bytes memory executionCallData_) = _buildValidBatch(ACTUAL_TOKEN_OUT); + + vm.prank(address(delegationManager)); + enforcer.beforeHook(terms_, hex"", batchDefaultMode, executionCallData_, keccak256("test"), address(0), address(0)); + } + + function test_revertWithIdenticalTokens() public { + MetaSwapApproveSwapEnforcer.Terms memory terms_ = MetaSwapApproveSwapEnforcer.Terms({ + metaSwap: address(metaSwap), + tokenIn: address(tokenIn), + tokenOut: address(tokenIn), + tokenInAmount: TOKEN_IN_AMOUNT, + minTokenOut: MIN_TOKEN_OUT + }); + (, bytes memory executionCallData_) = _buildValidBatch(MIN_TOKEN_OUT); + + vm.prank(address(delegationManager)); + vm.expectRevert("MetaSwapApproveSwapEnforcer:identical-tokens"); + enforcer.beforeHook( + abi.encode(terms_), hex"", batchDefaultMode, executionCallData_, keccak256("test"), address(0), address(0) + ); + } + + function test_validResetBatchExecution() public { + (bytes memory terms_, bytes memory executionCallData_) = _buildValidResetBatch(ACTUAL_TOKEN_OUT); + + vm.prank(address(delegationManager)); + enforcer.beforeHook(terms_, hex"", batchDefaultMode, executionCallData_, keccak256("test"), address(0), address(0)); + } + + function test_getTermsInfo() public { + MetaSwapApproveSwapEnforcer.Terms memory terms_ = MetaSwapApproveSwapEnforcer.Terms({ + metaSwap: address(metaSwap), + tokenIn: address(tokenIn), + tokenOut: address(tokenOut), + tokenInAmount: TOKEN_IN_AMOUNT, + minTokenOut: MIN_TOKEN_OUT + }); + bytes memory encoded_ = abi.encode(terms_); + MetaSwapApproveSwapEnforcer.Terms memory decoded_ = enforcer.getTermsInfo(encoded_); + + assertEq(decoded_.metaSwap, address(metaSwap)); + assertEq(decoded_.tokenIn, address(tokenIn)); + assertEq(decoded_.tokenOut, address(tokenOut)); + assertEq(decoded_.tokenInAmount, TOKEN_IN_AMOUNT); + assertEq(decoded_.minTokenOut, MIN_TOKEN_OUT); + assertEq(keccak256(enforcer.encodeTerms(terms_)), keccak256(encoded_)); + } + + function test_emitsDelegationExecuted() public { + (bytes memory terms_, bytes memory executionCallData_) = _buildValidBatch(MIN_TOKEN_OUT); + bytes32 delegationHash_ = keccak256("event"); + address delegator_ = address(users.alice.deleGator); + + vm.prank(address(delegationManager)); + vm.expectEmit(true, true, true, true, address(enforcer)); + emit MetaSwapApproveSwapEnforcer.DelegationExecuted(address(delegationManager), delegationHash_, delegator_); + enforcer.beforeHook(terms_, hex"", batchDefaultMode, executionCallData_, delegationHash_, delegator_, address(0)); + } + + function test_allowsFeeTakenFromInput() public { + (bytes memory terms_, bytes memory executionCallData_) = _buildValidBatch(MIN_TOKEN_OUT); + Execution[] memory executions_ = abi.decode(executionCallData_, (Execution[])); + executions_[1].callData = abi.encodeWithSelector( + IMetaSwap.swap.selector, + "mock-aggregator", + tokenIn, + TOKEN_IN_AMOUNT, + _encodeSwapData( + address(tokenIn), address(tokenOut), TOKEN_IN_AMOUNT - 1 ether, MIN_TOKEN_OUT, 1 ether, address(this), false + ) + ); + + vm.prank(address(delegationManager)); + enforcer.beforeHook( + terms_, hex"", batchDefaultMode, ExecutionLib.encodeBatch(executions_), keccak256("fee-in"), address(0), address(0) + ); + } + + function test_allowsFeeToTrueWithUnmatchedInputSplit() public { + (bytes memory terms_, bytes memory executionCallData_) = _buildValidBatch(MIN_TOKEN_OUT); + Execution[] memory executions_ = abi.decode(executionCallData_, (Execution[])); + executions_[1].callData = abi.encodeWithSelector( + IMetaSwap.swap.selector, + "mock-aggregator", + tokenIn, + TOKEN_IN_AMOUNT, + _encodeSwapData( + address(tokenIn), address(tokenOut), TOKEN_IN_AMOUNT - 1 ether, MIN_TOKEN_OUT, 1 ether, address(this), true + ) + ); + + vm.prank(address(delegationManager)); + enforcer.beforeHook( + terms_, hex"", batchDefaultMode, ExecutionLib.encodeBatch(executions_), keccak256("fee-out"), address(0), address(0) + ); + } + + ////////////////////// Invalid cases ////////////////////// + + function test_revertWithInvalidCallTypeMode() public { + (bytes memory terms_, bytes memory executionCallData_) = _buildValidBatch(MIN_TOKEN_OUT); + + vm.prank(address(delegationManager)); + vm.expectRevert("CaveatEnforcer:invalid-call-type"); + enforcer.beforeHook(terms_, hex"", singleDefaultMode, executionCallData_, keccak256("test"), address(0), address(0)); + } + + function test_revertWithInvalidExecutionMode() public { + vm.prank(address(delegationManager)); + vm.expectRevert("CaveatEnforcer:invalid-execution-type"); + enforcer.beforeHook(hex"", hex"", batchTryMode, hex"", bytes32(0), address(0), address(0)); + } + + function test_revertOnDelegationReuse() public { + (bytes memory terms_, bytes memory executionCallData_) = _buildValidBatch(MIN_TOKEN_OUT); + bytes32 delegationHash_ = keccak256("test"); + + vm.startPrank(address(delegationManager)); + enforcer.beforeHook(terms_, hex"", batchDefaultMode, executionCallData_, delegationHash_, address(0), address(0)); + vm.expectRevert("MetaSwapApproveSwapEnforcer:delegation-already-used"); + enforcer.beforeHook(terms_, hex"", batchDefaultMode, executionCallData_, delegationHash_, address(0), address(0)); + vm.stopPrank(); + } + + function test_revertWithInvalidBatchSize() public { + Execution[] memory executions_ = new Execution[](1); + executions_[0] = Execution({ + target: address(tokenIn), value: 0, callData: abi.encodeCall(IERC20.approve, (address(metaSwap), TOKEN_IN_AMOUNT)) + }); + (bytes memory terms_,) = _buildValidBatch(MIN_TOKEN_OUT); + + vm.prank(address(delegationManager)); + vm.expectRevert("MetaSwapApproveSwapEnforcer:invalid-batch-length"); + enforcer.beforeHook( + terms_, hex"", batchDefaultMode, ExecutionLib.encodeBatch(executions_), keccak256("test"), address(0), address(0) + ); + } + + function test_revertWithInvalidBatchSizeFour() public { + (bytes memory terms_, bytes memory executionCallData_) = _buildValidBatch(MIN_TOKEN_OUT); + Execution[] memory executions_ = abi.decode(executionCallData_, (Execution[])); + Execution[] memory fourExecutions_ = new Execution[](4); + fourExecutions_[0] = executions_[0]; + fourExecutions_[1] = executions_[0]; + fourExecutions_[2] = executions_[0]; + fourExecutions_[3] = executions_[1]; + + vm.prank(address(delegationManager)); + vm.expectRevert("MetaSwapApproveSwapEnforcer:invalid-batch-length"); + enforcer.beforeHook( + terms_, hex"", batchDefaultMode, ExecutionLib.encodeBatch(fourExecutions_), keccak256("test"), address(0), address(0) + ); + } + + function test_revertWithInvalidResetAmount() public { + (bytes memory terms_, bytes memory executionCallData_) = _buildValidResetBatch(MIN_TOKEN_OUT); + Execution[] memory executions_ = abi.decode(executionCallData_, (Execution[])); + executions_[0].callData = abi.encodeCall(IERC20.approve, (address(metaSwap), 1)); + + vm.prank(address(delegationManager)); + vm.expectRevert("MetaSwapApproveSwapEnforcer:invalid-approve-call"); + enforcer.beforeHook( + terms_, hex"", batchDefaultMode, ExecutionLib.encodeBatch(executions_), keccak256("test"), address(0), address(0) + ); + } + + function test_revertWithInvalidResetSecondApproveAmount() public { + (bytes memory terms_, bytes memory executionCallData_) = _buildValidResetBatch(MIN_TOKEN_OUT); + Execution[] memory executions_ = abi.decode(executionCallData_, (Execution[])); + executions_[1].callData = abi.encodeCall(IERC20.approve, (address(metaSwap), TOKEN_IN_AMOUNT - 1)); + + vm.prank(address(delegationManager)); + vm.expectRevert("MetaSwapApproveSwapEnforcer:invalid-approve-call"); + enforcer.beforeHook( + terms_, hex"", batchDefaultMode, ExecutionLib.encodeBatch(executions_), keccak256("test"), address(0), address(0) + ); + } + + function test_revertWithInvalidResetSwapTarget() public { + (bytes memory terms_, bytes memory executionCallData_) = _buildValidResetBatch(MIN_TOKEN_OUT); + Execution[] memory executions_ = abi.decode(executionCallData_, (Execution[])); + executions_[2].target = address(tokenIn); + + vm.prank(address(delegationManager)); + vm.expectRevert("MetaSwapApproveSwapEnforcer:invalid-swap-call"); + enforcer.beforeHook( + terms_, hex"", batchDefaultMode, ExecutionLib.encodeBatch(executions_), keccak256("test"), address(0), address(0) + ); + } + + function test_afterHook_revertsInsufficientOutput() public { + (bytes memory terms_, bytes memory executionCallData_) = _buildValidBatch(ACTUAL_TOKEN_OUT); + bytes32 delegationHash_ = keccak256("after-hook"); + address delegator_ = address(users.alice.deleGator); + + vm.startPrank(address(delegationManager)); + enforcer.beforeHook(terms_, hex"", batchDefaultMode, executionCallData_, delegationHash_, delegator_, address(0)); + vm.expectRevert("MetaSwapApproveSwapEnforcer:insufficient-output"); + enforcer.afterHook(terms_, hex"", batchDefaultMode, executionCallData_, delegationHash_, delegator_, address(0)); + vm.stopPrank(); + } + + function test_revertWithInvalidApproveTarget() public { + (bytes memory terms_, bytes memory executionCallData_) = _buildValidBatch(MIN_TOKEN_OUT); + Execution[] memory executions_ = abi.decode(executionCallData_, (Execution[])); + executions_[0].target = address(tokenOut); + + vm.prank(address(delegationManager)); + vm.expectRevert("MetaSwapApproveSwapEnforcer:invalid-approve-call"); + enforcer.beforeHook( + terms_, hex"", batchDefaultMode, ExecutionLib.encodeBatch(executions_), keccak256("test"), address(0), address(0) + ); + } + + function test_revertWithInvalidApproveValue() public { + (bytes memory terms_, bytes memory executionCallData_) = _buildValidBatch(MIN_TOKEN_OUT); + Execution[] memory executions_ = abi.decode(executionCallData_, (Execution[])); + executions_[0].value = 1 ether; + + vm.prank(address(delegationManager)); + vm.expectRevert("MetaSwapApproveSwapEnforcer:invalid-approve-call"); + enforcer.beforeHook( + terms_, hex"", batchDefaultMode, ExecutionLib.encodeBatch(executions_), keccak256("test"), address(0), address(0) + ); + } + + function test_revertWithInvalidApproveSelector() public { + (bytes memory terms_, bytes memory executionCallData_) = _buildValidBatch(MIN_TOKEN_OUT); + Execution[] memory executions_ = abi.decode(executionCallData_, (Execution[])); + executions_[0].callData = abi.encodeCall(IERC20.transfer, (address(metaSwap), TOKEN_IN_AMOUNT)); + + vm.prank(address(delegationManager)); + vm.expectRevert("MetaSwapApproveSwapEnforcer:invalid-approve-call"); + enforcer.beforeHook( + terms_, hex"", batchDefaultMode, ExecutionLib.encodeBatch(executions_), keccak256("test"), address(0), address(0) + ); + } + + function test_revertWithInvalidApproveSpender() public { + (bytes memory terms_, bytes memory executionCallData_) = _buildValidBatch(MIN_TOKEN_OUT); + Execution[] memory executions_ = abi.decode(executionCallData_, (Execution[])); + executions_[0].callData = abi.encodeCall(IERC20.approve, (address(this), TOKEN_IN_AMOUNT)); + + vm.prank(address(delegationManager)); + vm.expectRevert("MetaSwapApproveSwapEnforcer:invalid-approve-call"); + enforcer.beforeHook( + terms_, hex"", batchDefaultMode, ExecutionLib.encodeBatch(executions_), keccak256("test"), address(0), address(0) + ); + } + + function test_revertWithInvalidApproveAmount() public { + (bytes memory terms_, bytes memory executionCallData_) = _buildValidBatch(MIN_TOKEN_OUT); + Execution[] memory executions_ = abi.decode(executionCallData_, (Execution[])); + executions_[0].callData = abi.encodeCall(IERC20.approve, (address(metaSwap), TOKEN_IN_AMOUNT - 1)); + + vm.prank(address(delegationManager)); + vm.expectRevert("MetaSwapApproveSwapEnforcer:invalid-approve-call"); + enforcer.beforeHook( + terms_, hex"", batchDefaultMode, ExecutionLib.encodeBatch(executions_), keccak256("test"), address(0), address(0) + ); + } + + function test_revertWithInvalidSwapTarget() public { + (bytes memory terms_, bytes memory executionCallData_) = _buildValidBatch(MIN_TOKEN_OUT); + Execution[] memory executions_ = abi.decode(executionCallData_, (Execution[])); + executions_[1].target = address(tokenIn); + + vm.prank(address(delegationManager)); + vm.expectRevert("MetaSwapApproveSwapEnforcer:invalid-swap-call"); + enforcer.beforeHook( + terms_, hex"", batchDefaultMode, ExecutionLib.encodeBatch(executions_), keccak256("test"), address(0), address(0) + ); + } + + function test_revertWithInvalidSwapValue() public { + (bytes memory terms_, bytes memory executionCallData_) = _buildValidBatch(MIN_TOKEN_OUT); + Execution[] memory executions_ = abi.decode(executionCallData_, (Execution[])); + executions_[1].value = 1 ether; + + vm.prank(address(delegationManager)); + vm.expectRevert("MetaSwapApproveSwapEnforcer:invalid-swap-call"); + enforcer.beforeHook( + terms_, hex"", batchDefaultMode, ExecutionLib.encodeBatch(executions_), keccak256("test"), address(0), address(0) + ); + } + + function test_revertWithInvalidSwapSelector() public { + (bytes memory terms_, bytes memory executionCallData_) = _buildValidBatch(MIN_TOKEN_OUT); + Execution[] memory executions_ = abi.decode(executionCallData_, (Execution[])); + executions_[1].callData = abi.encodeCall(IERC20.transfer, (address(tokenOut), TOKEN_IN_AMOUNT)); + + vm.prank(address(delegationManager)); + vm.expectRevert("MetaSwapApproveSwapEnforcer:invalid-swap-call"); + enforcer.beforeHook( + terms_, hex"", batchDefaultMode, ExecutionLib.encodeBatch(executions_), keccak256("test"), address(0), address(0) + ); + } + + function test_revertWithInvalidOuterTokenIn() public { + BasicERC20 wrongTokenIn_ = new BasicERC20(address(this), "Wrong In", "WIN", 0); + (bytes memory terms_, bytes memory executionCallData_) = _buildValidBatch(MIN_TOKEN_OUT); + Execution[] memory executions_ = abi.decode(executionCallData_, (Execution[])); + executions_[1].callData = abi.encodeWithSelector( + IMetaSwap.swap.selector, + "mock-aggregator", + wrongTokenIn_, + TOKEN_IN_AMOUNT, + _encodeSwapData(address(wrongTokenIn_), address(tokenOut), TOKEN_IN_AMOUNT, MIN_TOKEN_OUT) + ); + + vm.prank(address(delegationManager)); + vm.expectRevert("MetaSwapApproveSwapEnforcer:invalid-swap-call"); + enforcer.beforeHook( + terms_, hex"", batchDefaultMode, ExecutionLib.encodeBatch(executions_), keccak256("test"), address(0), address(0) + ); + } + + function test_revertWithInvalidOuterAmount() public { + (bytes memory terms_, bytes memory executionCallData_) = _buildValidBatch(MIN_TOKEN_OUT); + Execution[] memory executions_ = abi.decode(executionCallData_, (Execution[])); + executions_[1].callData = abi.encodeWithSelector( + IMetaSwap.swap.selector, + "mock-aggregator", + tokenIn, + TOKEN_IN_AMOUNT - 1, + _encodeSwapData(address(tokenIn), address(tokenOut), TOKEN_IN_AMOUNT - 1, MIN_TOKEN_OUT) + ); + + vm.prank(address(delegationManager)); + vm.expectRevert("MetaSwapApproveSwapEnforcer:invalid-swap-call"); + enforcer.beforeHook( + terms_, hex"", batchDefaultMode, ExecutionLib.encodeBatch(executions_), keccak256("test"), address(0), address(0) + ); + } + + function test_revertWithInvalidInnerTokenOut() public { + BasicERC20 wrongTokenOut_ = new BasicERC20(address(this), "Wrong Out", "WOUT", 0); + (bytes memory terms_, bytes memory executionCallData_) = _buildValidBatch(MIN_TOKEN_OUT); + Execution[] memory executions_ = abi.decode(executionCallData_, (Execution[])); + executions_[1].callData = abi.encodeWithSelector( + IMetaSwap.swap.selector, + "mock-aggregator", + tokenIn, + TOKEN_IN_AMOUNT, + _encodeSwapData(address(tokenIn), address(wrongTokenOut_), TOKEN_IN_AMOUNT, MIN_TOKEN_OUT) + ); + + vm.prank(address(delegationManager)); + vm.expectRevert("MetaSwapApproveSwapEnforcer:invalid-swap-call"); + enforcer.beforeHook( + terms_, hex"", batchDefaultMode, ExecutionLib.encodeBatch(executions_), keccak256("test"), address(0), address(0) + ); + } + + function test_revertWithInvalidInnerTokenIn() public { + BasicERC20 wrongTokenIn_ = new BasicERC20(address(this), "Wrong In", "WIN", 0); + (bytes memory terms_, bytes memory executionCallData_) = _buildValidBatch(MIN_TOKEN_OUT); + Execution[] memory executions_ = abi.decode(executionCallData_, (Execution[])); + executions_[1].callData = abi.encodeWithSelector( + IMetaSwap.swap.selector, + "mock-aggregator", + tokenIn, + TOKEN_IN_AMOUNT, + _encodeSwapData(address(wrongTokenIn_), address(tokenOut), TOKEN_IN_AMOUNT, MIN_TOKEN_OUT) + ); + + vm.prank(address(delegationManager)); + vm.expectRevert("MetaSwapApproveSwapEnforcer:invalid-swap-call"); + enforcer.beforeHook( + terms_, hex"", batchDefaultMode, ExecutionLib.encodeBatch(executions_), keccak256("test"), address(0), address(0) + ); + } + + function test_revertWithAmountFromMismatch() public { + (bytes memory terms_, bytes memory executionCallData_) = _buildValidBatch(MIN_TOKEN_OUT); + Execution[] memory executions_ = abi.decode(executionCallData_, (Execution[])); + executions_[1].callData = abi.encodeWithSelector( + IMetaSwap.swap.selector, + "mock-aggregator", + tokenIn, + TOKEN_IN_AMOUNT, + _encodeSwapData(address(tokenIn), address(tokenOut), TOKEN_IN_AMOUNT - 1 ether, MIN_TOKEN_OUT) + ); + + vm.prank(address(delegationManager)); + vm.expectRevert("MetaSwapApproveSwapEnforcer:amount-from-mismatch"); + enforcer.beforeHook( + terms_, hex"", batchDefaultMode, ExecutionLib.encodeBatch(executions_), keccak256("test"), address(0), address(0) + ); + } + + function test_revertWithBelowMinimumOutput() public { + (bytes memory terms_, bytes memory executionCallData_) = _buildValidBatch(MIN_TOKEN_OUT - 1); + + vm.prank(address(delegationManager)); + vm.expectRevert("MetaSwapApproveSwapEnforcer:invalid-swap-call"); + enforcer.beforeHook(terms_, hex"", batchDefaultMode, executionCallData_, keccak256("test"), address(0), address(0)); + } + + ////////////////////// Integration ////////////////////// + + function test_integration_happyPath() public { + (Delegation memory delegation_, bytes memory executionCallData_) = + _buildDelegation(ACTUAL_TOKEN_OUT, automation, automation); + + _redeem(delegation_, executionCallData_, automation); + + assertEq(tokenIn.balanceOf(address(users.alice.deleGator)), 0); + assertEq(tokenOut.balanceOf(address(users.alice.deleGator)), ACTUAL_TOKEN_OUT); + assertEq(tokenIn.balanceOf(address(metaSwap)), TOKEN_IN_AMOUNT); + } + + function test_integration_resetApprovalHappyPath() public { + (Delegation memory delegation_, bytes memory executionCallData_) = + _buildDelegation(ACTUAL_TOKEN_OUT, automation, automation, true); + + vm.prank(address(delegationManager)); + users.alice.deleGator + .executeFromExecutor( + singleDefaultMode, + ExecutionLib.encodeSingle(address(tokenIn), 0, abi.encodeCall(IERC20.approve, (address(metaSwap), uint256(1)))) + ); + assertEq(tokenIn.allowance(address(users.alice.deleGator), address(metaSwap)), 1); + + _redeem(delegation_, executionCallData_, automation); + + assertEq(tokenIn.balanceOf(address(users.alice.deleGator)), 0); + assertEq(tokenOut.balanceOf(address(users.alice.deleGator)), ACTUAL_TOKEN_OUT); + assertEq(tokenIn.allowance(address(users.alice.deleGator), address(metaSwap)), 0); + } + + function test_integration_revertsUnauthorizedRedeemer() public { + (Delegation memory delegation_, bytes memory executionCallData_) = + _buildDelegation(ACTUAL_TOKEN_OUT, ANY_DELEGATE, automation); + + vm.expectRevert("RedeemerEnforcer:unauthorized-redeemer"); + _redeem(delegation_, executionCallData_, users.bob.addr); + } + + function test_integration_revertsReplay() public { + (Delegation memory delegation_, bytes memory executionCallData_) = + _buildDelegation(ACTUAL_TOKEN_OUT, automation, automation); + _redeem(delegation_, executionCallData_, automation); + + vm.expectRevert("MetaSwapApproveSwapEnforcer:delegation-already-used"); + _redeem(delegation_, executionCallData_, automation); + } + + function test_integration_revertsInsufficientOutput() public { + MockMetaSwapUnderpay underpayMetaSwap_ = new MockMetaSwapUnderpay(tokenOut, MIN_TOKEN_OUT - 1); + tokenOut.mint(address(underpayMetaSwap_), 1_000 ether); + + (Delegation memory delegation_, bytes memory executionCallData_) = + _buildDelegationWithMetaSwap(underpayMetaSwap_, ACTUAL_TOKEN_OUT, automation, automation); + + vm.expectRevert("MetaSwapApproveSwapEnforcer:insufficient-output"); + _redeem(delegation_, executionCallData_, automation); + } + + function test_integration_revertsRemainingAllowance() public { + MockMetaSwapNoPull noPullMetaSwap_ = new MockMetaSwapNoPull(tokenOut); + tokenOut.mint(address(noPullMetaSwap_), 1_000 ether); + + (Delegation memory delegation_, bytes memory executionCallData_) = + _buildDelegationWithMetaSwap(noPullMetaSwap_, ACTUAL_TOKEN_OUT, automation, automation); + + vm.expectRevert("MetaSwapApproveSwapEnforcer:remaining-allowance"); + _redeem(delegation_, executionCallData_, automation); + } + + ////////////////////// Helpers ////////////////////// + + function _buildValidBatch(uint256 _amountTo) private view returns (bytes memory terms_, bytes memory executionCallData_) { + MetaSwapApproveSwapEnforcer.Terms memory termsData_ = MetaSwapApproveSwapEnforcer.Terms({ + metaSwap: address(metaSwap), + tokenIn: address(tokenIn), + tokenOut: address(tokenOut), + tokenInAmount: TOKEN_IN_AMOUNT, + minTokenOut: MIN_TOKEN_OUT + }); + terms_ = abi.encode(termsData_); + executionCallData_ = _encodeBatchExecution(_amountTo); + } + + function _buildValidResetBatch(uint256 _amountTo) private view returns (bytes memory terms_, bytes memory executionCallData_) { + MetaSwapApproveSwapEnforcer.Terms memory termsData_ = MetaSwapApproveSwapEnforcer.Terms({ + metaSwap: address(metaSwap), + tokenIn: address(tokenIn), + tokenOut: address(tokenOut), + tokenInAmount: TOKEN_IN_AMOUNT, + minTokenOut: MIN_TOKEN_OUT + }); + terms_ = abi.encode(termsData_); + executionCallData_ = _encodeResetBatchExecution(_amountTo); + } + + function _buildDelegation( + uint256 _amountTo, + address _delegate, + address _allowedRedeemer + ) + private + view + returns (Delegation memory delegation_, bytes memory executionCallData_) + { + return _buildDelegation(_amountTo, _delegate, _allowedRedeemer, false); + } + + function _buildDelegation( + uint256 _amountTo, + address _delegate, + address _allowedRedeemer, + bool _resetApproval + ) + private + view + returns (Delegation memory delegation_, bytes memory executionCallData_) + { + MetaSwapApproveSwapEnforcer.Terms memory termsData_ = MetaSwapApproveSwapEnforcer.Terms({ + metaSwap: address(metaSwap), + tokenIn: address(tokenIn), + tokenOut: address(tokenOut), + tokenInAmount: TOKEN_IN_AMOUNT, + minTokenOut: MIN_TOKEN_OUT + }); + + Caveat[] memory caveats_ = new Caveat[](2); + caveats_[0] = Caveat({ enforcer: address(enforcer), terms: abi.encode(termsData_), args: hex"" }); + caveats_[1] = Caveat({ enforcer: address(redeemerEnforcer), terms: abi.encodePacked(_allowedRedeemer), args: hex"" }); + + delegation_ = signDelegation( + users.alice, + Delegation({ + delegate: _delegate, + delegator: address(users.alice.deleGator), + authority: ROOT_AUTHORITY, + caveats: caveats_, + salt: 42, + signature: hex"" + }) + ); + executionCallData_ = _resetApproval ? _encodeResetBatchExecution(_amountTo) : _encodeBatchExecution(_amountTo); + } + + function _buildDelegationWithMetaSwap( + IMetaSwap _metaSwap, + uint256 _amountTo, + address _delegate, + address _allowedRedeemer + ) + private + view + returns (Delegation memory delegation_, bytes memory executionCallData_) + { + MetaSwapApproveSwapEnforcer.Terms memory termsData_ = MetaSwapApproveSwapEnforcer.Terms({ + metaSwap: address(_metaSwap), + tokenIn: address(tokenIn), + tokenOut: address(tokenOut), + tokenInAmount: TOKEN_IN_AMOUNT, + minTokenOut: MIN_TOKEN_OUT + }); + + Caveat[] memory caveats_ = new Caveat[](2); + caveats_[0] = Caveat({ enforcer: address(enforcer), terms: abi.encode(termsData_), args: hex"" }); + caveats_[1] = Caveat({ enforcer: address(redeemerEnforcer), terms: abi.encodePacked(_allowedRedeemer), args: hex"" }); + + delegation_ = signDelegation( + users.alice, + Delegation({ + delegate: _delegate, + delegator: address(users.alice.deleGator), + authority: ROOT_AUTHORITY, + caveats: caveats_, + salt: 43, + signature: hex"" + }) + ); + executionCallData_ = _encodeBatchExecution(_metaSwap, _amountTo); + } + + function _encodeBatchExecution(uint256 _amountTo) private view returns (bytes memory) { + return _encodeBatchExecution(metaSwap, _amountTo); + } + + function _encodeBatchExecution(IMetaSwap _metaSwap, uint256 _amountTo) private view returns (bytes memory) { + Execution[] memory executions_ = new Execution[](2); + executions_[0] = Execution({ + target: address(tokenIn), value: 0, callData: abi.encodeCall(IERC20.approve, (address(_metaSwap), TOKEN_IN_AMOUNT)) + }); + executions_[1] = Execution({ + target: address(_metaSwap), + value: 0, + callData: abi.encodeWithSelector( + IMetaSwap.swap.selector, + "mock-aggregator", + tokenIn, + TOKEN_IN_AMOUNT, + _encodeSwapData(address(tokenIn), address(tokenOut), TOKEN_IN_AMOUNT, _amountTo) + ) + }); + return ExecutionLib.encodeBatch(executions_); + } + + function _encodeResetBatchExecution(uint256 _amountTo) private view returns (bytes memory) { + Execution[] memory executions_ = new Execution[](3); + executions_[0] = + Execution({ target: address(tokenIn), value: 0, callData: abi.encodeCall(IERC20.approve, (address(metaSwap), 0)) }); + executions_[1] = Execution({ + target: address(tokenIn), value: 0, callData: abi.encodeCall(IERC20.approve, (address(metaSwap), TOKEN_IN_AMOUNT)) + }); + executions_[2] = Execution({ + target: address(metaSwap), + value: 0, + callData: abi.encodeWithSelector( + IMetaSwap.swap.selector, + "mock-aggregator", + tokenIn, + TOKEN_IN_AMOUNT, + _encodeSwapData(address(tokenIn), address(tokenOut), TOKEN_IN_AMOUNT, _amountTo) + ) + }); + return ExecutionLib.encodeBatch(executions_); + } + + function _encodeSwapData( + address _tokenFrom, + address _tokenTo, + uint256 _amountFrom, + uint256 _amountTo + ) + private + pure + returns (bytes memory) + { + return _encodeSwapData(_tokenFrom, _tokenTo, _amountFrom, _amountTo, 0, address(0), false); + } + + function _encodeSwapData( + address _tokenFrom, + address _tokenTo, + uint256 _amountFrom, + uint256 _amountTo, + uint256 _fee, + address _feeWallet, + bool _feeTo + ) + private + pure + returns (bytes memory) + { + return abi.encode(_tokenFrom, _tokenTo, _amountFrom, _amountTo, hex"", _fee, _feeWallet, _feeTo); + } + + function _redeem(Delegation memory _delegation, bytes memory _execution, address _redeemer) private { + Delegation[] memory delegations_ = new Delegation[](1); + delegations_[0] = _delegation; + + bytes[] memory contexts_ = new bytes[](1); + contexts_[0] = abi.encode(delegations_); + + ModeCode[] memory modes_ = new ModeCode[](1); + modes_[0] = ModeLib.encodeSimpleBatch(); + + bytes[] memory executions_ = new bytes[](1); + executions_[0] = _execution; + + vm.prank(_redeemer); + delegationManager.redeemDelegations(contexts_, modes_, executions_); + } +} diff --git a/test/enforcers/MetaSwapBatchCalldataEnforcer.t.sol b/test/enforcers/MetaSwapBatchCalldataEnforcer.t.sol new file mode 100644 index 00000000..6cb1b383 --- /dev/null +++ b/test/enforcers/MetaSwapBatchCalldataEnforcer.t.sol @@ -0,0 +1,129 @@ +// SPDX-License-Identifier: MIT AND Apache-2.0 +pragma solidity 0.8.23; + +import { Test } from "forge-std/Test.sol"; +import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import { ExecutionLib } from "@erc7579/lib/ExecutionLib.sol"; +import { ModeLib } from "@erc7579/lib/ModeLib.sol"; + +import { MetaSwapBatchCalldataEnforcer } from "../../src/enforcers/MetaSwapBatchCalldataEnforcer.sol"; +import { IMetaSwap } from "../../src/helpers/interfaces/IMetaSwap.sol"; +import { Execution, ModeCode } from "../../src/utils/Types.sol"; + +contract MetaSwapBatchCalldataEnforcerTest is Test { + uint256 internal constant TOKEN_IN_AMOUNT = 100 ether; + + MetaSwapBatchCalldataEnforcer internal enforcer; + address internal metaSwap; + address internal tokenIn; + ModeCode internal batchDefaultMode = ModeLib.encodeSimpleBatch(); + + function setUp() public { + enforcer = new MetaSwapBatchCalldataEnforcer(); + metaSwap = makeAddr("MetaSwap"); + tokenIn = makeAddr("TokenIn"); + } + + function test_erc20OneApproval_acceptsFlexibleRoute() public view { + _enforce(_terms(tokenIn, false), _erc20Batch(false, tokenIn, TOKEN_IN_AMOUNT, "a", hex"01")); + _enforce(_terms(tokenIn, false), _erc20Batch(false, tokenIn, TOKEN_IN_AMOUNT, "different", new bytes(512))); + } + + function test_erc20ResetApproval_acceptsSignedShape() public view { + _enforce(_terms(tokenIn, true), _erc20Batch(true, tokenIn, TOKEN_IN_AMOUNT, "aggregator", new bytes(96))); + } + + function test_nativeInput_acceptsSingleSwap() public view { + Execution[] memory executions_ = new Execution[](1); + executions_[0] = _swap(address(0), TOKEN_IN_AMOUNT, TOKEN_IN_AMOUNT, "aggregator", hex"1234"); + _enforce(_terms(address(0), false), executions_); + } + + function test_revertsOnSingleDelegationManagerMode() public { + Execution[] memory executions_ = _erc20Batch(false, tokenIn, TOKEN_IN_AMOUNT, "aggregator", hex""); + + vm.expectRevert("CaveatEnforcer:invalid-call-type"); + enforcer.beforeHook( + _terms(tokenIn, false), + hex"", + ModeLib.encodeSimpleSingle(), + ExecutionLib.encodeBatch(executions_), + bytes32(0), + address(0), + address(0) + ); + } + + function test_revertsOnDifferentSwapToken() public { + vm.expectRevert("MetaSwapBatchCalldataEnforcer:invalid-swap"); + _enforce(_terms(tokenIn, false), _erc20Batch(false, makeAddr("OtherToken"), TOKEN_IN_AMOUNT, "aggregator", hex"")); + } + + function test_revertsOnDifferentSwapAmount() public { + vm.expectRevert("MetaSwapBatchCalldataEnforcer:invalid-swap"); + _enforce(_terms(tokenIn, false), _erc20Batch(false, tokenIn, TOKEN_IN_AMOUNT - 1, "aggregator", hex"")); + } + + function test_revertsOnWrongApprovalSpender() public { + Execution[] memory executions_ = _erc20Batch(false, tokenIn, TOKEN_IN_AMOUNT, "aggregator", hex""); + executions_[0].callData = abi.encodeCall(IERC20.approve, (makeAddr("OtherSpender"), TOKEN_IN_AMOUNT)); + + vm.expectRevert("MetaSwapBatchCalldataEnforcer:invalid-approval"); + _enforce(_terms(tokenIn, false), executions_); + } + + function test_revertsWhenApprovalShapeDoesNotMatchTerms() public { + vm.expectRevert("MetaSwapBatchCalldataEnforcer:invalid-batch-length"); + _enforce(_terms(tokenIn, false), _erc20Batch(true, tokenIn, TOKEN_IN_AMOUNT, "aggregator", hex"")); + } + + function _terms(address tokenIn_, bool resetApproval_) private view returns (bytes memory) { + return abi.encodePacked(metaSwap, tokenIn_, TOKEN_IN_AMOUNT, bytes1(resetApproval_ ? 0x01 : 0x00)); + } + + function _erc20Batch( + bool resetApproval_, + address swapToken_, + uint256 swapAmount_, + string memory aggregatorId_, + bytes memory route_ + ) + private + view + returns (Execution[] memory executions_) + { + uint256 swapIndex_ = resetApproval_ ? 2 : 1; + executions_ = new Execution[](swapIndex_ + 1); + if (resetApproval_) executions_[0] = _approval(0); + executions_[swapIndex_ - 1] = _approval(TOKEN_IN_AMOUNT); + executions_[swapIndex_] = _swap(swapToken_, swapAmount_, 0, aggregatorId_, route_); + } + + function _approval(uint256 amount_) private view returns (Execution memory) { + return Execution({ target: tokenIn, value: 0, callData: abi.encodeCall(IERC20.approve, (metaSwap, amount_)) }); + } + + function _swap( + address swapToken_, + uint256 swapAmount_, + uint256 value_, + string memory aggregatorId_, + bytes memory route_ + ) + private + view + returns (Execution memory) + { + return Execution({ + target: metaSwap, + value: value_, + callData: abi.encodeCall(IMetaSwap.swap, (aggregatorId_, IERC20(swapToken_), swapAmount_, route_)) + }); + } + + function _enforce(bytes memory terms_, Execution[] memory executions_) private view { + enforcer.beforeHook( + terms_, hex"", batchDefaultMode, ExecutionLib.encodeBatch(executions_), bytes32(0), address(0), address(0) + ); + } +} diff --git a/test/enforcers/MetaSwapFlexibleSettlementEnforcer.t.sol b/test/enforcers/MetaSwapFlexibleSettlementEnforcer.t.sol new file mode 100644 index 00000000..53bbe448 --- /dev/null +++ b/test/enforcers/MetaSwapFlexibleSettlementEnforcer.t.sol @@ -0,0 +1,737 @@ +// 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; + + MetaSwapFlexibleSettlementEnforcer internal enforcer; + BasicERC20 internal tokenIn; + BasicERC20 internal tokenOut; + FlexibleSettlementMetaSwapMock internal metaSwap; + address internal alice; + address internal relayer; + + event SettlementConsumed(address indexed delegationManager, bytes32 indexed delegationHash, address indexed redeemer); + + 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"); + + 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); + } + + 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( + _terms(address(tokenIn), APPROVE, address(tokenOut), alice), + _erc20Executions(1, address(tokenIn), TOKEN_IN_AMOUNT, "a", hex"01"), + keccak256("first") + ); + _before( + _terms(address(tokenIn), APPROVE, address(tokenOut), alice), + _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( + _terms(address(tokenIn), SKIP, address(tokenOut), alice), + _erc20Executions(0, address(tokenIn), TOKEN_IN_AMOUNT, "skip", hex""), + keccak256("skip") + ); + _before( + _terms(address(tokenIn), APPROVE, address(tokenOut), alice), + _erc20Executions(1, address(tokenIn), TOKEN_IN_AMOUNT, "approve", hex""), + keccak256("approve") + ); + _before( + _terms(address(tokenIn), RESET, address(tokenOut), alice), + _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(144)); + + vm.expectRevert("MetaSwapFlexibleSettlementEnforcer:invalid-terms"); + enforcer.getTermsInfo(new bytes(146)); + } + + 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) + ); + } + + 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"); + bytes32 settlementKey_ = enforcer.getSettlementKey(address(delegationManager), delegationHash_); + 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.consumedSettlements(settlementKey_)); + + vm.prank(address(delegationManager)); + vm.expectRevert("MetaSwapFlexibleSettlementEnforcer:settlement-already-used"); + 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 + ); + } + + function test_identicalDelegationHashIsIsolatedAcrossDelegationManagers() 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_); + + bytes32 firstKey_ = enforcer.getSettlementKey(address(delegationManager), delegationHash_); + bytes32 secondKey_ = enforcer.getSettlementKey(address(secondDelegationManager_), delegationHash_); + assertNotEq(firstKey_, secondKey_); + assertTrue(enforcer.consumedSettlements(firstKey_)); + assertTrue(enforcer.consumedSettlements(secondKey_)); + } + + function test_afterHookRevertsForInvalidTermsLength() public { + vm.expectRevert("MetaSwapFlexibleSettlementEnforcer:invalid-terms"); + enforcer.afterHook(new bytes(144), 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); + enforcer.afterHook(terms_, hex"", batchDefaultMode, hex"", delegationHash_, alice, relayer); + + assertTrue(enforcer.consumedSettlements(enforcer.getSettlementKey(address(delegationManager), delegationHash_))); + } + + 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_); + bytes32 delegationHash_ = EncoderLib._getDelegationHash(delegation_); + 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.consumedSettlements(enforcer.getSettlementKey(address(delegationManager), delegationHash_))); + + _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_); + + vm.expectRevert("MetaSwapFlexibleSettlementEnforcer:settlement-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 _rawTerms(address(metaSwap), tokenIn_, TOKEN_IN_AMOUNT, uint8(approvalMode_), tokenOut_, recipient_, TOKEN_OUT_MIN); + } + + function _rawTerms( + address metaSwap_, + address tokenIn_, + uint256 tokenInAmount_, + uint8 approvalMode_, + address tokenOut_, + address recipient_, + uint256 tokenOutMin_ + ) + private + pure + returns (bytes memory) + { + return abi.encodePacked(metaSwap_, tokenIn_, tokenInAmount_, approvalMode_, tokenOut_, recipient_, tokenOutMin_); + } + + 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_); + } + + function _beforeAs( + address delegationManager_, + bytes memory terms_, + Execution[] memory executions_, + bytes32 delegationHash_ + ) + private + { + vm.prank(delegationManager_); + enforcer.beforeHook(terms_, hex"", batchDefaultMode, ExecutionLib.encodeBatch(executions_), delegationHash_, alice, relayer); + } + + function _sign(bytes memory terms_) 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: 0, 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)); + } +} diff --git a/test/experiments/MetaSwapBatchDesignGasComparison.t.sol b/test/experiments/MetaSwapBatchDesignGasComparison.t.sol new file mode 100644 index 00000000..7189c7b5 --- /dev/null +++ b/test/experiments/MetaSwapBatchDesignGasComparison.t.sol @@ -0,0 +1,844 @@ +// SPDX-License-Identifier: MIT AND Apache-2.0 +pragma solidity 0.8.23; + +import { Test, console2 } from "forge-std/Test.sol"; +import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import { ExecutionLib } from "@erc7579/lib/ExecutionLib.sol"; +import { ModeLib } from "@erc7579/lib/ModeLib.sol"; + +import { CaveatEnforcer } from "../../src/enforcers/CaveatEnforcer.sol"; +import { ERC20BalanceChangeEnforcer } from "../../src/enforcers/ERC20BalanceChangeEnforcer.sol"; +import { LimitedCallsEnforcer } from "../../src/enforcers/LimitedCallsEnforcer.sol"; +import { MetaSwapBatchCalldataEnforcer } from "../../src/enforcers/MetaSwapBatchCalldataEnforcer.sol"; +import { IDelegationManager } from "../../src/interfaces/IDelegationManager.sol"; +import { IMetaSwap } from "../../src/helpers/interfaces/IMetaSwap.sol"; +import { Caveat, Delegation, Execution, ModeCode } from "../../src/utils/Types.sol"; +import { BaseTest } from "../utils/BaseTest.t.sol"; +import { BasicERC20 } from "../utils/BasicERC20.t.sol"; +import { Implementation, SignatureType } from "../utils/Types.t.sol"; +import { MockLimitOrderRouter } from "../utils/MockLimitOrderRouter.sol"; + +abstract contract MetaSwapDesignValidationBase is CaveatEnforcer { + uint256 internal constant APPROVE_CALL_LENGTH = 68; + uint256 internal constant SWAP_CALL_MIN_LENGTH = 132; + + function _validateApproval( + Execution calldata execution_, + address tokenIn_, + address metaSwap_, + uint256 expectedAmount_ + ) + internal + pure + { + bytes calldata callData_ = execution_.callData; + require( + execution_.target == tokenIn_ && execution_.value == 0 && callData_.length == APPROVE_CALL_LENGTH + && bytes4(callData_[0:4]) == IERC20.approve.selector + && address(uint160(uint256(bytes32(callData_[4:36])))) == metaSwap_ + && uint256(bytes32(callData_[36:68])) == expectedAmount_, + "MetaSwapDesignValidationBase:invalid-approval" + ); + } + + function _validateSwap( + address target_, + uint256 value_, + bytes calldata callData_, + address metaSwap_, + address tokenIn_, + uint256 tokenInAmount_, + uint256 expectedValue_ + ) + internal + pure + { + require( + target_ == metaSwap_ && value_ == expectedValue_ && callData_.length >= SWAP_CALL_MIN_LENGTH + && bytes4(callData_[0:4]) == IMetaSwap.swap.selector + && address(uint160(uint256(bytes32(callData_[36:68])))) == tokenIn_ + && uint256(bytes32(callData_[68:100])) == tokenInAmount_, + "MetaSwapDesignValidationBase:invalid-swap" + ); + } +} + +/// @notice ERC-20-only copy of PR 201 with the native branch removed. +contract MetaSwapERC20OnlyEnforcer is MetaSwapDesignValidationBase { + using ExecutionLib for bytes; + + uint256 private constant TERMS_LENGTH = 73; + + function beforeHook( + bytes calldata terms_, + bytes calldata, + ModeCode mode_, + bytes calldata executionCallData_, + bytes32, + address, + address + ) + public + pure + override + onlyBatchCallTypeMode(mode_) + onlyDefaultExecutionMode(mode_) + { + require(terms_.length == TERMS_LENGTH, "MetaSwapERC20OnlyEnforcer:invalid-terms"); + address metaSwap_ = address(bytes20(terms_[0:20])); + address tokenIn_ = address(bytes20(terms_[20:40])); + uint256 tokenInAmount_ = uint256(bytes32(terms_[40:72])); + uint8 resetApproval_ = uint8(terms_[72]); + require( + metaSwap_ != address(0) && tokenIn_ != address(0) && tokenInAmount_ != 0 && resetApproval_ <= 1, + "MetaSwapERC20OnlyEnforcer:invalid-terms" + ); + + Execution[] calldata executions_ = executionCallData_.decodeBatch(); + if (resetApproval_ == 1) { + require(executions_.length == 3, "MetaSwapERC20OnlyEnforcer:invalid-batch-length"); + _validateApproval(executions_[0], tokenIn_, metaSwap_, 0); + _validateApproval(executions_[1], tokenIn_, metaSwap_, tokenInAmount_); + _validateSwap( + executions_[2].target, executions_[2].value, executions_[2].callData, metaSwap_, tokenIn_, tokenInAmount_, 0 + ); + } else { + require(executions_.length == 2, "MetaSwapERC20OnlyEnforcer:invalid-batch-length"); + _validateApproval(executions_[0], tokenIn_, metaSwap_, tokenInAmount_); + _validateSwap( + executions_[1].target, executions_[1].value, executions_[1].callData, metaSwap_, tokenIn_, tokenInAmount_, 0 + ); + } + } +} + +/// @notice Native-only copy retaining PR 201's one-element batch mode. +contract MetaSwapNativeBatchOnlyEnforcer is MetaSwapDesignValidationBase { + using ExecutionLib for bytes; + + uint256 private constant TERMS_LENGTH = 52; + + function beforeHook( + bytes calldata terms_, + bytes calldata, + ModeCode mode_, + bytes calldata executionCallData_, + bytes32, + address, + address + ) + public + pure + override + onlyBatchCallTypeMode(mode_) + onlyDefaultExecutionMode(mode_) + { + require(terms_.length == TERMS_LENGTH, "MetaSwapNativeBatchOnlyEnforcer:invalid-terms"); + address metaSwap_ = address(bytes20(terms_[0:20])); + uint256 tokenInAmount_ = uint256(bytes32(terms_[20:52])); + require(metaSwap_ != address(0) && tokenInAmount_ != 0, "MetaSwapNativeBatchOnlyEnforcer:invalid-terms"); + + Execution[] calldata executions_ = executionCallData_.decodeBatch(); + require(executions_.length == 1, "MetaSwapNativeBatchOnlyEnforcer:invalid-batch-length"); + _validateSwap( + executions_[0].target, + executions_[0].value, + executions_[0].callData, + metaSwap_, + address(0), + tokenInAmount_, + tokenInAmount_ + ); + } +} + +/// @notice Native-only copy using DelegationManager single mode. +contract MetaSwapNativeSingleOnlyEnforcer is MetaSwapDesignValidationBase { + using ExecutionLib for bytes; + + uint256 private constant TERMS_LENGTH = 52; + + function beforeHook( + bytes calldata terms_, + bytes calldata, + ModeCode mode_, + bytes calldata executionCallData_, + bytes32, + address, + address + ) + public + pure + override + onlySingleCallTypeMode(mode_) + onlyDefaultExecutionMode(mode_) + { + require(terms_.length == TERMS_LENGTH, "MetaSwapNativeSingleOnlyEnforcer:invalid-terms"); + address metaSwap_ = address(bytes20(terms_[0:20])); + uint256 tokenInAmount_ = uint256(bytes32(terms_[20:52])); + require(metaSwap_ != address(0) && tokenInAmount_ != 0, "MetaSwapNativeSingleOnlyEnforcer:invalid-terms"); + + (address target_, uint256 value_, bytes calldata callData_) = executionCallData_.decodeSingle(); + _validateSwap(target_, value_, callData_, metaSwap_, address(0), tokenInAmount_, tokenInAmount_); + } +} + +/** + * @notice PR 201 copy where the final signed byte is an approval policy. + * @dev Batch length selects the mode, avoiding unsigned caveat args: + * bit 0 = swap only, bit 1 = approve(amount) + swap, bit 2 = approve(0) + approve(amount) + swap. + */ +contract MetaSwapApprovalPolicyEnforcer is MetaSwapDesignValidationBase { + using ExecutionLib for bytes; + + uint8 public constant ALLOW_SKIP_APPROVAL = 1; + uint8 public constant ALLOW_APPROVAL = 2; + uint8 public constant ALLOW_RESET_APPROVAL = 4; + uint256 private constant TERMS_LENGTH = 73; + + function beforeHook( + bytes calldata terms_, + bytes calldata, + ModeCode mode_, + bytes calldata executionCallData_, + bytes32, + address, + address + ) + public + pure + override + onlyBatchCallTypeMode(mode_) + onlyDefaultExecutionMode(mode_) + { + require(terms_.length == TERMS_LENGTH, "MetaSwapApprovalPolicyEnforcer:invalid-terms"); + address metaSwap_ = address(bytes20(terms_[0:20])); + address tokenIn_ = address(bytes20(terms_[20:40])); + uint256 tokenInAmount_ = uint256(bytes32(terms_[40:72])); + uint8 policy_ = uint8(terms_[72]); + require( + metaSwap_ != address(0) && tokenInAmount_ != 0 && policy_ <= 7, + "MetaSwapApprovalPolicyEnforcer:invalid-terms" + ); + + Execution[] calldata executions_ = executionCallData_.decodeBatch(); + if (tokenIn_ == address(0)) { + require(policy_ == 0 && executions_.length == 1, "MetaSwapApprovalPolicyEnforcer:shape-not-allowed"); + _validateSwap( + executions_[0].target, + executions_[0].value, + executions_[0].callData, + metaSwap_, + address(0), + tokenInAmount_, + tokenInAmount_ + ); + return; + } + + require(policy_ != 0, "MetaSwapApprovalPolicyEnforcer:invalid-terms"); + uint256 swapIndex_; + if (executions_.length == 1) { + require(policy_ & ALLOW_SKIP_APPROVAL != 0, "MetaSwapApprovalPolicyEnforcer:shape-not-allowed"); + } else if (executions_.length == 2) { + require(policy_ & ALLOW_APPROVAL != 0, "MetaSwapApprovalPolicyEnforcer:shape-not-allowed"); + _validateApproval(executions_[0], tokenIn_, metaSwap_, tokenInAmount_); + swapIndex_ = 1; + } else { + require( + executions_.length == 3 && policy_ & ALLOW_RESET_APPROVAL != 0, "MetaSwapApprovalPolicyEnforcer:shape-not-allowed" + ); + _validateApproval(executions_[0], tokenIn_, metaSwap_, 0); + _validateApproval(executions_[1], tokenIn_, metaSwap_, tokenInAmount_); + swapIndex_ = 2; + } + + _validateSwap( + executions_[swapIndex_].target, + executions_[swapIndex_].value, + executions_[swapIndex_].callData, + metaSwap_, + tokenIn_, + tokenInAmount_, + 0 + ); + } +} + +/** + * @notice Experimental complete one-shot limit-order enforcer. + * @dev It combines PR 201 validation, minimum output, and one-shot consumption in one storage slot per delegation. + * State is `0` before use, `balanceBefore + 1` during execution, and `type(uint256).max` after success. + */ +contract MetaSwapIntegratedOneShotEnforcer is MetaSwapDesignValidationBase { + using ExecutionLib for bytes; + + struct Terms { + address metaSwap; + address tokenIn; + uint256 tokenInAmount; + bool resetApproval; + address tokenOut; + address recipient; + uint256 tokenOutMin; + } + + uint256 private constant TERMS_LENGTH = 145; + mapping(bytes32 key => uint256 state) public delegationState; + + function beforeHook( + bytes calldata terms_, + bytes calldata, + ModeCode mode_, + bytes calldata executionCallData_, + bytes32 delegationHash_, + address, + address + ) + public + override + onlyBatchCallTypeMode(mode_) + onlyDefaultExecutionMode(mode_) + { + Terms memory info_ = _getTerms(terms_); + Execution[] calldata executions_ = executionCallData_.decodeBatch(); + _validateExecutions(executions_, info_); + + bytes32 key_ = _key(msg.sender, delegationHash_); + require(delegationState[key_] == 0, "MetaSwapIntegratedOneShotEnforcer:already-used"); + uint256 balanceBefore_ = _balanceOf(info_.tokenOut, info_.recipient); + require(balanceBefore_ != type(uint256).max, "MetaSwapIntegratedOneShotEnforcer:balance-overflow"); + delegationState[key_] = balanceBefore_ + 1; + } + + function afterHook( + bytes calldata terms_, + bytes calldata, + ModeCode, + bytes calldata, + bytes32 delegationHash_, + address, + address + ) + public + override + { + require(terms_.length == TERMS_LENGTH, "MetaSwapIntegratedOneShotEnforcer:invalid-terms"); + address tokenOut_ = address(bytes20(terms_[73:93])); + address recipient_ = address(bytes20(terms_[93:113])); + uint256 tokenOutMin_ = uint256(bytes32(terms_[113:145])); + bytes32 key_ = _key(msg.sender, delegationHash_); + uint256 cachedState_ = delegationState[key_]; + require(cachedState_ != 0 && cachedState_ != type(uint256).max, "MetaSwapIntegratedOneShotEnforcer:invalid-state"); + + uint256 balanceBefore_ = cachedState_ - 1; + uint256 balanceAfter_ = _balanceOf(tokenOut_, recipient_); + require( + balanceAfter_ >= balanceBefore_ && balanceAfter_ - balanceBefore_ >= tokenOutMin_, + "MetaSwapIntegratedOneShotEnforcer:insufficient-output" + ); + delegationState[key_] = type(uint256).max; + } + + function _getTerms(bytes calldata terms_) private pure returns (Terms memory info_) { + require(terms_.length == TERMS_LENGTH, "MetaSwapIntegratedOneShotEnforcer:invalid-terms"); + info_.metaSwap = address(bytes20(terms_[0:20])); + info_.tokenIn = address(bytes20(terms_[20:40])); + info_.tokenInAmount = uint256(bytes32(terms_[40:72])); + uint8 resetApproval_ = uint8(terms_[72]); + info_.resetApproval = resetApproval_ == 1; + info_.tokenOut = address(bytes20(terms_[73:93])); + info_.recipient = address(bytes20(terms_[93:113])); + info_.tokenOutMin = uint256(bytes32(terms_[113:145])); + require( + info_.metaSwap != address(0) && info_.tokenInAmount != 0 && resetApproval_ <= 1 && info_.recipient != address(0) + && info_.tokenOutMin != 0, + "MetaSwapIntegratedOneShotEnforcer:invalid-terms" + ); + } + + function _validateExecutions(Execution[] calldata executions_, Terms memory info_) private pure { + if (info_.tokenIn == address(0)) { + require(!info_.resetApproval && executions_.length == 1, "MetaSwapIntegratedOneShotEnforcer:invalid-batch-length"); + _validateSwap( + executions_[0].target, + executions_[0].value, + executions_[0].callData, + info_.metaSwap, + address(0), + info_.tokenInAmount, + info_.tokenInAmount + ); + } else if (info_.resetApproval) { + require(executions_.length == 3, "MetaSwapIntegratedOneShotEnforcer:invalid-batch-length"); + _validateApproval(executions_[0], info_.tokenIn, info_.metaSwap, 0); + _validateApproval(executions_[1], info_.tokenIn, info_.metaSwap, info_.tokenInAmount); + _validateSwap( + executions_[2].target, + executions_[2].value, + executions_[2].callData, + info_.metaSwap, + info_.tokenIn, + info_.tokenInAmount, + 0 + ); + } else { + require(executions_.length == 2, "MetaSwapIntegratedOneShotEnforcer:invalid-batch-length"); + _validateApproval(executions_[0], info_.tokenIn, info_.metaSwap, info_.tokenInAmount); + _validateSwap( + executions_[1].target, + executions_[1].value, + executions_[1].callData, + info_.metaSwap, + info_.tokenIn, + info_.tokenInAmount, + 0 + ); + } + } + + function _balanceOf(address token_, address recipient_) private view returns (uint256) { + return token_ == address(0) ? recipient_.balance : IERC20(token_).balanceOf(recipient_); + } + + function _key(address caller_, bytes32 delegationHash_) private pure returns (bytes32) { + return keccak256(abi.encode(caller_, delegationHash_)); + } +} + +contract MetaSwapBatchDesignGasComparisonTest is BaseTest { + uint256 private constant TOKEN_IN_AMOUNT = 100 ether; + uint256 private constant TOKEN_OUT_MIN = 190 ether; + uint256 private constant TOKEN_OUT_AMOUNT = 200 ether; + uint256 private constant INITIAL_TOKEN_OUT_BALANCE = 1 ether; + uint256 private constant INTRINSIC_GAS = 21_000; + uint8 private constant ALL_APPROVAL_MODES = 7; + + struct GasMeasurement { + uint256 executionGas; + uint256 calldataBytes; + uint256 calldataGas; + uint256 estimatedTransactionGas; + } + + MetaSwapBatchCalldataEnforcer private baseline; + MetaSwapERC20OnlyEnforcer private erc20Only; + MetaSwapNativeBatchOnlyEnforcer private nativeBatchOnly; + MetaSwapNativeSingleOnlyEnforcer private nativeSingleOnly; + MetaSwapApprovalPolicyEnforcer private approvalPolicy; + MetaSwapIntegratedOneShotEnforcer private integratedOneShot; + LimitedCallsEnforcer private limitedCalls; + ERC20BalanceChangeEnforcer private balanceChange; + MockLimitOrderRouter private router; + BasicERC20 private tokenIn; + BasicERC20 private tokenOut; + address private relayer; + + constructor() { + IMPLEMENTATION = Implementation.MultiSig; + SIGNATURE_TYPE = SignatureType.MultiSig; + } + + function setUp() public override { + super.setUp(); + + baseline = new MetaSwapBatchCalldataEnforcer(); + erc20Only = new MetaSwapERC20OnlyEnforcer(); + nativeBatchOnly = new MetaSwapNativeBatchOnlyEnforcer(); + nativeSingleOnly = new MetaSwapNativeSingleOnlyEnforcer(); + approvalPolicy = new MetaSwapApprovalPolicyEnforcer(); + integratedOneShot = new MetaSwapIntegratedOneShotEnforcer(); + limitedCalls = new LimitedCallsEnforcer(); + balanceChange = new ERC20BalanceChangeEnforcer(); + router = new MockLimitOrderRouter(); + tokenIn = new BasicERC20(address(this), "Token In", "TIN", 0); + tokenOut = new BasicERC20(address(this), "Token Out", "TOUT", 0); + relayer = makeAddr("Relayer"); + + tokenIn.mint(address(users.alice.deleGator), 1_000 ether); + tokenOut.mint(address(router), 10_000 ether); + tokenOut.mint(address(users.alice.deleGator), INITIAL_TOKEN_OUT_BALANCE); + vm.deal(address(users.alice.deleGator), 1_000 ether); + } + + function test_gas_split_baselineNativeBatch() public { + _report( + "split / baseline combined native batch", + _measure(_oneCaveat(address(baseline), _baselineTerms(address(0), false)), _nativeBatch(), true) + ); + } + + function test_gas_split_nativeOnlyBatch() public { + _report("split / native-only batch", _measure(_oneCaveat(address(nativeBatchOnly), _nativeTerms()), _nativeBatch(), true)); + } + + function test_gas_split_nativeOnlySingle() public { + Execution memory execution_ = _swap(address(0), TOKEN_IN_AMOUNT); + _report( + "split / native-only single", + _measureRaw( + _oneCaveat(address(nativeSingleOnly), _nativeTerms()), + ModeLib.encodeSimpleSingle(), + ExecutionLib.encodeSingle(execution_.target, execution_.value, execution_.callData) + ) + ); + } + + function test_gas_split_baselineERC20Approval() public { + _report( + "split / baseline combined approve(amount)", + _measure(_oneCaveat(address(baseline), _baselineTerms(address(tokenIn), false)), _erc20Batch(false, false), true) + ); + } + + function test_gas_split_erc20OnlyApproval() public { + _report( + "split / ERC20-only approve(amount)", + _measure(_oneCaveat(address(erc20Only), _baselineTerms(address(tokenIn), false)), _erc20Batch(false, false), true) + ); + } + + function test_gas_split_baselineResetApproval() public { + _setAllowance(1); + _report( + "split / baseline combined reset approval", + _measure(_oneCaveat(address(baseline), _baselineTerms(address(tokenIn), true)), _erc20Batch(true, false), true) + ); + } + + function test_gas_split_erc20OnlyResetApproval() public { + _setAllowance(1); + _report( + "split / ERC20-only reset approval", + _measure(_oneCaveat(address(erc20Only), _baselineTerms(address(tokenIn), true)), _erc20Batch(true, false), true) + ); + } + + function test_gas_policy_baselineApproval() public { + _report( + "policy / baseline approve(amount)", + _measure(_oneCaveat(address(baseline), _baselineTerms(address(tokenIn), false)), _erc20Batch(false, false), true) + ); + } + + function test_gas_policy_native() public { + _report( + "policy / mask native", + _measure(_oneCaveat(address(approvalPolicy), _baselineTerms(address(0), false)), _nativeBatch(), true) + ); + } + + function test_gas_policy_allowsApproval() public { + _report( + "policy / mask approve(amount)", + _measure(_oneCaveat(address(approvalPolicy), _policyTerms()), _erc20Batch(false, false), true) + ); + } + + function test_gas_policy_baselineResetApproval() public { + _setAllowance(1); + _report( + "policy / baseline reset approval", + _measure(_oneCaveat(address(baseline), _baselineTerms(address(tokenIn), true)), _erc20Batch(true, false), true) + ); + } + + function test_gas_policy_allowsResetApproval() public { + _setAllowance(1); + _report( + "policy / mask reset approval", + _measure(_oneCaveat(address(approvalPolicy), _policyTerms()), _erc20Batch(true, false), true) + ); + } + + function test_gas_policy_skipsApprovalWhenAllowanceExists() public { + _setAllowance(TOKEN_IN_AMOUNT); + _report( + "policy / mask swap-only with existing allowance", + _measure(_oneCaveat(address(approvalPolicy), _policyTerms()), _erc20Batch(false, true), true) + ); + } + + function test_gas_integrated_baselineBundleNative() public { + _report( + "integrated / baseline + limited + balance native", _measure(_baselineBundle(address(0), false), _nativeBatch(), true) + ); + } + + function test_gas_integrated_oneShotNative() public { + _report( + "integrated / one caveat native", + _measure(_oneCaveat(address(integratedOneShot), _integratedTerms(address(0), false)), _nativeBatch(), true) + ); + } + + function test_gas_integrated_baselineBundleERC20Approval() public { + _report( + "integrated / baseline + limited + balance approve(amount)", + _measure(_baselineBundle(address(tokenIn), false), _erc20Batch(false, false), true) + ); + } + + function test_gas_integrated_oneShotERC20Approval() public { + _report( + "integrated / one caveat approve(amount)", + _measure( + _oneCaveat(address(integratedOneShot), _integratedTerms(address(tokenIn), false)), _erc20Batch(false, false), true + ) + ); + } + + function test_gas_integrated_baselineBundleResetApproval() public { + _setAllowance(1); + _report( + "integrated / baseline + limited + balance reset approval", + _measure(_baselineBundle(address(tokenIn), true), _erc20Batch(true, false), true) + ); + } + + function test_gas_integrated_oneShotResetApproval() public { + _setAllowance(1); + _report( + "integrated / one caveat reset approval", + _measure( + _oneCaveat(address(integratedOneShot), _integratedTerms(address(tokenIn), true)), _erc20Batch(true, false), true + ) + ); + } + + function test_integratedOneShotRejectsSecondRedemption() public { + Caveat[] memory caveats_ = _oneCaveat(address(integratedOneShot), _integratedTerms(address(tokenIn), false)); + Delegation memory delegation_ = _sign(caveats_); + _redeem(delegation_, ModeLib.encodeSimpleBatch(), ExecutionLib.encodeBatch(_erc20Batch(false, false))); + + vm.expectRevert("MetaSwapIntegratedOneShotEnforcer:already-used"); + _redeem(delegation_, ModeLib.encodeSimpleBatch(), ExecutionLib.encodeBatch(_erc20Batch(false, false))); + } + + function test_integratedOneShotRevertsAtomicallyForInsufficientOutputAndAllowsRetry() public { + Caveat[] memory caveats_ = _oneCaveat(address(integratedOneShot), _integratedTerms(address(tokenIn), false)); + Delegation memory delegation_ = _sign(caveats_); + + vm.expectRevert("MetaSwapIntegratedOneShotEnforcer:insufficient-output"); + _redeem(delegation_, ModeLib.encodeSimpleBatch(), ExecutionLib.encodeBatch(_erc20Batch(false, false, TOKEN_OUT_MIN - 1))); + + _redeem(delegation_, ModeLib.encodeSimpleBatch(), ExecutionLib.encodeBatch(_erc20Batch(false, false))); + } + + function test_policyRejectsUnsignedShape() public { + uint8 onlyApproval_ = approvalPolicy.ALLOW_APPROVAL(); + Caveat[] memory caveats_ = _oneCaveat( + address(approvalPolicy), abi.encodePacked(address(router), address(tokenIn), TOKEN_IN_AMOUNT, onlyApproval_) + ); + Delegation memory delegation_ = _sign(caveats_); + + vm.expectRevert("MetaSwapApprovalPolicyEnforcer:shape-not-allowed"); + _redeem(delegation_, ModeLib.encodeSimpleBatch(), ExecutionLib.encodeBatch(_erc20Batch(false, true))); + } + + function test_reportRuntimeSizes() public view { + console2.log("baseline combined runtime bytes", address(baseline).code.length); + console2.log("ERC20-only runtime bytes", address(erc20Only).code.length); + console2.log("native batch-only runtime bytes", address(nativeBatchOnly).code.length); + console2.log("native single-only runtime bytes", address(nativeSingleOnly).code.length); + console2.log("approval-policy runtime bytes", address(approvalPolicy).code.length); + console2.log("integrated one-shot runtime bytes", address(integratedOneShot).code.length); + } + + function _measure( + Caveat[] memory caveats_, + Execution[] memory executions_, + bool batch_ + ) + private + returns (GasMeasurement memory) + { + require(batch_, "MetaSwapBatchDesignGasComparisonTest:batch-required"); + return _measureRaw(caveats_, ModeLib.encodeSimpleBatch(), ExecutionLib.encodeBatch(executions_)); + } + + function _measureRaw( + Caveat[] memory caveats_, + ModeCode mode_, + bytes memory executionCallData_ + ) + private + returns (GasMeasurement memory measurement_) + { + Delegation memory delegation_ = _sign(caveats_); + bytes memory redeemCallData_ = _encodeRedeem(delegation_, mode_, executionCallData_); + measurement_.calldataBytes = redeemCallData_.length; + measurement_.calldataGas = _calldataGas(redeemCallData_); + + vm.prank(relayer); + uint256 gasBefore_ = gasleft(); + (bool success_, bytes memory returnData_) = address(delegationManager).call(redeemCallData_); + measurement_.executionGas = gasBefore_ - gasleft(); + assertTrue(success_, string(returnData_)); + + measurement_.estimatedTransactionGas = INTRINSIC_GAS + measurement_.calldataGas + measurement_.executionGas; + assertEq(tokenOut.balanceOf(address(users.alice.deleGator)), INITIAL_TOKEN_OUT_BALANCE + TOKEN_OUT_AMOUNT); + } + + function _baselineBundle(address tokenIn_, bool resetApproval_) private view returns (Caveat[] memory caveats_) { + caveats_ = new Caveat[](3); + caveats_[0] = Caveat({ enforcer: address(baseline), terms: _baselineTerms(tokenIn_, resetApproval_), args: hex"" }); + caveats_[1] = Caveat({ enforcer: address(limitedCalls), terms: abi.encode(uint256(1)), args: hex"" }); + caveats_[2] = Caveat({ + enforcer: address(balanceChange), + terms: abi.encodePacked(false, address(tokenOut), address(users.alice.deleGator), TOKEN_OUT_MIN), + args: hex"" + }); + } + + function _oneCaveat(address enforcer_, bytes memory terms_) private pure returns (Caveat[] memory caveats_) { + caveats_ = new Caveat[](1); + caveats_[0] = Caveat({ enforcer: enforcer_, terms: terms_, args: hex"" }); + } + + function _sign(Caveat[] memory caveats_) private view returns (Delegation memory delegation_) { + delegation_ = Delegation({ + delegate: ANY_DELEGATE, + delegator: address(users.alice.deleGator), + authority: ROOT_AUTHORITY, + caveats: caveats_, + salt: 0, + signature: hex"" + }); + delegation_ = signDelegation(users.alice, delegation_); + } + + function _redeem(Delegation memory delegation_, ModeCode mode_, bytes memory executionCallData_) 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] = mode_; + bytes[] memory executionCallDatas_ = new bytes[](1); + executionCallDatas_[0] = executionCallData_; + + vm.prank(relayer); + delegationManager.redeemDelegations(permissionContexts_, modes_, executionCallDatas_); + } + + function _encodeRedeem( + Delegation memory delegation_, + ModeCode mode_, + bytes memory executionCallData_ + ) + private + pure + returns (bytes memory) + { + 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] = mode_; + bytes[] memory executionCallDatas_ = new bytes[](1); + executionCallDatas_[0] = executionCallData_; + return + abi.encodeWithSelector(IDelegationManager.redeemDelegations.selector, permissionContexts_, modes_, executionCallDatas_); + } + + function _nativeBatch() private view returns (Execution[] memory executions_) { + executions_ = new Execution[](1); + executions_[0] = _swap(address(0), TOKEN_IN_AMOUNT); + } + + function _erc20Batch(bool resetApproval_, bool skipApproval_) private view returns (Execution[] memory) { + return _erc20Batch(resetApproval_, skipApproval_, TOKEN_OUT_AMOUNT); + } + + function _erc20Batch( + bool resetApproval_, + bool skipApproval_, + uint256 tokenOutAmount_ + ) + private + view + returns (Execution[] memory executions_) + { + if (skipApproval_) { + executions_ = new Execution[](1); + executions_[0] = _swap(address(tokenIn), 0, tokenOutAmount_); + return executions_; + } + + uint256 swapIndex_ = resetApproval_ ? 2 : 1; + executions_ = new Execution[](swapIndex_ + 1); + if (resetApproval_) executions_[0] = _approval(0); + executions_[swapIndex_ - 1] = _approval(TOKEN_IN_AMOUNT); + executions_[swapIndex_] = _swap(address(tokenIn), 0, tokenOutAmount_); + } + + function _approval(uint256 amount_) private view returns (Execution memory) { + return + Execution({ target: address(tokenIn), value: 0, callData: abi.encodeCall(IERC20.approve, (address(router), amount_)) }); + } + + function _swap(address tokenIn_, uint256 value_) private view returns (Execution memory) { + return _swap(tokenIn_, value_, TOKEN_OUT_AMOUNT); + } + + function _swap(address tokenIn_, uint256 value_, uint256 tokenOutAmount_) private view returns (Execution memory) { + return Execution({ + target: address(router), + value: value_, + callData: abi.encodeCall( + IMetaSwap.swap, + ( + "route-selected-by-redeemer", + IERC20(tokenIn_), + TOKEN_IN_AMOUNT, + abi.encode(IERC20(address(tokenOut)), tokenOutAmount_) + ) + ) + }); + } + + function _baselineTerms(address tokenIn_, bool resetApproval_) private view returns (bytes memory) { + return abi.encodePacked(address(router), tokenIn_, TOKEN_IN_AMOUNT, bytes1(resetApproval_ ? 0x01 : 0x00)); + } + + function _nativeTerms() private view returns (bytes memory) { + return abi.encodePacked(address(router), TOKEN_IN_AMOUNT); + } + + function _policyTerms() private view returns (bytes memory) { + return abi.encodePacked(address(router), address(tokenIn), TOKEN_IN_AMOUNT, bytes1(ALL_APPROVAL_MODES)); + } + + function _integratedTerms(address tokenIn_, bool resetApproval_) private view returns (bytes memory) { + return abi.encodePacked( + address(router), + tokenIn_, + TOKEN_IN_AMOUNT, + bytes1(resetApproval_ ? 0x01 : 0x00), + address(tokenOut), + address(users.alice.deleGator), + TOKEN_OUT_MIN + ); + } + + function _setAllowance(uint256 amount_) private { + vm.prank(address(users.alice.deleGator)); + tokenIn.approve(address(router), amount_); + } + + function _calldataGas(bytes memory data_) private pure returns (uint256 gas_) { + for (uint256 i_; i_ < data_.length; ++i_) { + gas_ += data_[i_] == 0 ? 4 : 16; + } + } + + function _report(string memory label_, GasMeasurement memory measurement_) private pure { + console2.log(label_); + console2.log(" execution gas", measurement_.executionGas); + console2.log(" calldata bytes", measurement_.calldataBytes); + console2.log(" calldata gas", measurement_.calldataGas); + console2.log(" estimated transaction gas", measurement_.estimatedTransactionGas); + } +} diff --git a/test/experiments/MetaSwapBatchHashGasComparison.t.sol b/test/experiments/MetaSwapBatchHashGasComparison.t.sol new file mode 100644 index 00000000..88e2941b --- /dev/null +++ b/test/experiments/MetaSwapBatchHashGasComparison.t.sol @@ -0,0 +1,861 @@ +// SPDX-License-Identifier: MIT AND Apache-2.0 +pragma solidity 0.8.23; + +import { Test, console2 } from "forge-std/Test.sol"; +import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import { ExecutionLib } from "@erc7579/lib/ExecutionLib.sol"; +import { ModeLib } from "@erc7579/lib/ModeLib.sol"; + +import { CaveatEnforcer } from "../../src/enforcers/CaveatEnforcer.sol"; +import { MetaSwapBatchCalldataEnforcer } from "../../src/enforcers/MetaSwapBatchCalldataEnforcer.sol"; +import { IDelegationManager } from "../../src/interfaces/IDelegationManager.sol"; +import { IMetaSwap } from "../../src/helpers/interfaces/IMetaSwap.sol"; +import { Caveat, Delegation, Execution, ModeCode } from "../../src/utils/Types.sol"; +import { BaseTest } from "../utils/BaseTest.t.sol"; +import { BasicERC20 } from "../utils/BasicERC20.t.sol"; +import { Implementation, SignatureType } from "../utils/Types.t.sol"; +import { MockLimitOrderRouter } from "../utils/MockLimitOrderRouter.sol"; + +abstract contract MetaSwapHashEnforcerBase is CaveatEnforcer { + using ExecutionLib for bytes; + + bytes32 internal constant APPROVAL_TYPEHASH = keccak256("MetaSwapApproval(address target,uint256 value,bytes32 callDataHash)"); + bytes32 internal constant SWAP_TYPEHASH = + keccak256("MetaSwapSwap(address target,uint256 value,bytes4 selector,address tokenFrom,uint256 amount)"); + bytes32 internal constant APPROVAL_SEQUENCE_TYPEHASH = + keccak256("MetaSwapApprovalSequence(bytes32 firstApproval,bytes32 secondApproval)"); + bytes32 internal constant BATCH_TYPEHASH = + keccak256("MetaSwapBatch(uint8 executionCount,bytes32 approvalSequenceHash,bytes32 swapHash)"); + + uint256 internal constant SWAP_CALL_MIN_LENGTH = 132; + + function _approvalHash(Execution calldata execution_) internal pure returns (bytes32) { + return keccak256(abi.encode(APPROVAL_TYPEHASH, execution_.target, execution_.value, keccak256(execution_.callData))); + } + + function _swapHash(Execution calldata execution_) internal pure returns (bytes32) { + bytes calldata callData_ = execution_.callData; + require(callData_.length >= SWAP_CALL_MIN_LENGTH, "MetaSwapHashEnforcer:invalid-swap"); + return keccak256( + abi.encode( + SWAP_TYPEHASH, + execution_.target, + execution_.value, + bytes4(callData_[0:4]), + address(uint160(uint256(bytes32(callData_[36:68])))), + uint256(bytes32(callData_[68:100])) + ) + ); + } + + function _approvalSequenceAndSwap(Execution[] calldata executions_) + internal + pure + returns (bytes32 approvalSequenceHash_, bytes32 swapHash_) + { + uint256 length_ = executions_.length; + require(length_ >= 1 && length_ <= 3, "MetaSwapHashEnforcer:invalid-batch-length"); + + if (length_ == 1) { + swapHash_ = _swapHash(executions_[0]); + } else if (length_ == 2) { + approvalSequenceHash_ = _approvalHash(executions_[0]); + swapHash_ = _swapHash(executions_[1]); + } else { + approvalSequenceHash_ = + keccak256(abi.encode(APPROVAL_SEQUENCE_TYPEHASH, _approvalHash(executions_[0]), _approvalHash(executions_[1]))); + swapHash_ = _swapHash(executions_[2]); + } + } +} + +/// @notice Compares every approval hash independently and compares one partial swap hash. +contract IndividualExecutionHashEnforcer is MetaSwapHashEnforcerBase { + using ExecutionLib for bytes; + + function beforeHook( + bytes calldata terms_, + bytes calldata, + ModeCode mode_, + bytes calldata executionCallData_, + bytes32, + address, + address + ) + public + pure + override + onlyBatchCallTypeMode(mode_) + onlyDefaultExecutionMode(mode_) + { + Execution[] calldata executions_ = executionCallData_.decodeBatch(); + uint256 length_ = executions_.length; + require(length_ >= 1 && length_ <= 3, "IndividualExecutionHashEnforcer:invalid-batch-length"); + require(terms_.length == length_ * 32, "IndividualExecutionHashEnforcer:invalid-terms"); + + if (length_ == 1) { + require(_swapHash(executions_[0]) == bytes32(terms_[0:32]), "IndividualExecutionHashEnforcer:invalid-swap"); + } else if (length_ == 2) { + require(_approvalHash(executions_[0]) == bytes32(terms_[0:32]), "IndividualExecutionHashEnforcer:invalid-approval"); + require(_swapHash(executions_[1]) == bytes32(terms_[32:64]), "IndividualExecutionHashEnforcer:invalid-swap"); + } else { + require(_approvalHash(executions_[0]) == bytes32(terms_[0:32]), "IndividualExecutionHashEnforcer:invalid-approval"); + require(_approvalHash(executions_[1]) == bytes32(terms_[32:64]), "IndividualExecutionHashEnforcer:invalid-approval"); + require(_swapHash(executions_[2]) == bytes32(terms_[64:96]), "IndividualExecutionHashEnforcer:invalid-swap"); + } + } +} + +/// @notice Compares one approval-sequence hash and one partial swap hash. +contract ApprovalSequenceHashEnforcer is MetaSwapHashEnforcerBase { + using ExecutionLib for bytes; + + uint256 private constant TERMS_LENGTH = 64; + + function beforeHook( + bytes calldata terms_, + bytes calldata, + ModeCode mode_, + bytes calldata executionCallData_, + bytes32, + address, + address + ) + public + pure + override + onlyBatchCallTypeMode(mode_) + onlyDefaultExecutionMode(mode_) + { + require(terms_.length == TERMS_LENGTH, "ApprovalSequenceHashEnforcer:invalid-terms"); + Execution[] calldata executions_ = executionCallData_.decodeBatch(); + (bytes32 approvalSequenceHash_, bytes32 swapHash_) = _approvalSequenceAndSwap(executions_); + require(approvalSequenceHash_ == bytes32(terms_[0:32]), "ApprovalSequenceHashEnforcer:invalid-approval-sequence"); + require(swapHash_ == bytes32(terms_[32:64]), "ApprovalSequenceHashEnforcer:invalid-swap"); + } +} + +/// @notice Compares one hash committing to the execution count, approval sequence, and partial swap hash. +contract CombinedConstraintHashEnforcer is MetaSwapHashEnforcerBase { + using ExecutionLib for bytes; + + uint256 private constant TERMS_LENGTH = 32; + + function beforeHook( + bytes calldata terms_, + bytes calldata, + ModeCode mode_, + bytes calldata executionCallData_, + bytes32, + address, + address + ) + public + pure + override + onlyBatchCallTypeMode(mode_) + onlyDefaultExecutionMode(mode_) + { + require(terms_.length == TERMS_LENGTH, "CombinedConstraintHashEnforcer:invalid-terms"); + bytes32 constraintHash_ = _constraintHash(executionCallData_); + require(constraintHash_ == bytes32(terms_[0:32]), "CombinedConstraintHashEnforcer:invalid-constraints"); + } + + function _constraintHash(bytes calldata executionCallData_) private pure returns (bytes32) { + Execution[] calldata executions_ = executionCallData_.decodeBatch(); + (bytes32 approvalSequenceHash_, bytes32 swapHash_) = _approvalSequenceAndSwap(executions_); + return keccak256(abi.encode(BATCH_TYPEHASH, uint8(executions_.length), approvalSequenceHash_, swapHash_)); + } +} + +/// @notice Uses the swap hash directly for native and one combined constraint hash for ERC-20 batches. +contract HybridConstraintHashEnforcer is MetaSwapHashEnforcerBase { + using ExecutionLib for bytes; + + uint256 private constant TERMS_LENGTH = 32; + + function beforeHook( + bytes calldata terms_, + bytes calldata, + ModeCode mode_, + bytes calldata executionCallData_, + bytes32, + address, + address + ) + public + pure + override + onlyBatchCallTypeMode(mode_) + onlyDefaultExecutionMode(mode_) + { + require(terms_.length == TERMS_LENGTH, "HybridConstraintHashEnforcer:invalid-terms"); + bytes32 constraintHash_ = _constraintHash(executionCallData_); + require(constraintHash_ == bytes32(terms_[0:32]), "HybridConstraintHashEnforcer:invalid-constraints"); + } + + function _constraintHash(bytes calldata executionCallData_) private pure returns (bytes32) { + Execution[] calldata executions_ = executionCallData_.decodeBatch(); + (bytes32 approvalSequenceHash_, bytes32 swapHash_) = _approvalSequenceAndSwap(executions_); + return executions_.length == 1 + ? swapHash_ + : keccak256(abi.encode(BATCH_TYPEHASH, uint8(executions_.length), approvalSequenceHash_, swapHash_)); + } +} + +/// @notice Uses one fixed-field hash per approval instead of hashing approval calldata separately. +contract CombinedFieldHashEnforcer is MetaSwapHashEnforcerBase { + using ExecutionLib for bytes; + + bytes32 private constant APPROVAL_FIELDS_TYPEHASH = keccak256( + "MetaSwapApprovalFields(address target,uint256 value,bytes4 selector,address spender,uint256 amount)" + ); + uint256 private constant TERMS_LENGTH = 32; + uint256 private constant APPROVAL_CALL_LENGTH = 68; + + function beforeHook( + bytes calldata terms_, + bytes calldata, + ModeCode mode_, + bytes calldata executionCallData_, + bytes32, + address, + address + ) + public + pure + override + onlyBatchCallTypeMode(mode_) + onlyDefaultExecutionMode(mode_) + { + require(terms_.length == TERMS_LENGTH, "CombinedFieldHashEnforcer:invalid-terms"); + bytes32 constraintHash_ = _constraintHash(executionCallData_); + require(constraintHash_ == bytes32(terms_[0:32]), "CombinedFieldHashEnforcer:invalid-constraints"); + } + + function _constraintHash(bytes calldata executionCallData_) private pure returns (bytes32) { + Execution[] calldata executions_ = executionCallData_.decodeBatch(); + uint256 length_ = executions_.length; + require(length_ >= 1 && length_ <= 3, "CombinedFieldHashEnforcer:invalid-batch-length"); + + bytes32 approvalSequenceHash_; + bytes32 swapHash_; + if (length_ == 1) { + swapHash_ = _swapHash(executions_[0]); + } else if (length_ == 2) { + approvalSequenceHash_ = _approvalFieldsHash(executions_[0]); + swapHash_ = _swapHash(executions_[1]); + } else { + approvalSequenceHash_ = keccak256( + abi.encode( + APPROVAL_SEQUENCE_TYPEHASH, + _approvalFieldsHash(executions_[0]), + _approvalFieldsHash(executions_[1]) + ) + ); + swapHash_ = _swapHash(executions_[2]); + } + return keccak256(abi.encode(BATCH_TYPEHASH, uint8(length_), approvalSequenceHash_, swapHash_)); + } + + function _approvalFieldsHash(Execution calldata execution_) private pure returns (bytes32) { + bytes calldata callData_ = execution_.callData; + require(callData_.length == APPROVAL_CALL_LENGTH, "CombinedFieldHashEnforcer:invalid-approval"); + return keccak256( + abi.encode( + APPROVAL_FIELDS_TYPEHASH, + execution_.target, + execution_.value, + bytes4(callData_[0:4]), + address(uint160(uint256(bytes32(callData_[4:36])))), + uint256(bytes32(callData_[36:68])) + ) + ); + } +} + +/// @notice Keeps domain separation only on the final signed constraint commitment. +contract OuterTypehashConstraintEnforcer is CaveatEnforcer { + using ExecutionLib for bytes; + + bytes32 private constant BATCH_TYPEHASH = + keccak256("MetaSwapBatch(uint8 executionCount,bytes32 approvalSequenceHash,bytes32 swapHash)"); + uint256 private constant TERMS_LENGTH = 32; + uint256 private constant SWAP_CALL_MIN_LENGTH = 132; + + function beforeHook( + bytes calldata terms_, + bytes calldata, + ModeCode mode_, + bytes calldata executionCallData_, + bytes32, + address, + address + ) + public + pure + override + onlyBatchCallTypeMode(mode_) + onlyDefaultExecutionMode(mode_) + { + require(terms_.length == TERMS_LENGTH, "OuterTypehashConstraintEnforcer:invalid-terms"); + bytes32 constraintHash_ = _constraintHash(executionCallData_); + require(constraintHash_ == bytes32(terms_[0:32]), "OuterTypehashConstraintEnforcer:invalid-constraints"); + } + + function _constraintHash(bytes calldata executionCallData_) private pure returns (bytes32) { + Execution[] calldata executions_ = executionCallData_.decodeBatch(); + uint256 length_ = executions_.length; + require(length_ >= 1 && length_ <= 3, "OuterTypehashConstraintEnforcer:invalid-batch-length"); + + bytes32 approvalSequenceHash_; + bytes32 swapHash_; + if (length_ == 1) { + swapHash_ = _swapHash(executions_[0]); + } else if (length_ == 2) { + approvalSequenceHash_ = _approvalHash(executions_[0]); + swapHash_ = _swapHash(executions_[1]); + } else { + approvalSequenceHash_ = + keccak256(abi.encode(_approvalHash(executions_[0]), _approvalHash(executions_[1]))); + swapHash_ = _swapHash(executions_[2]); + } + return keccak256(abi.encode(BATCH_TYPEHASH, uint8(length_), approvalSequenceHash_, swapHash_)); + } + + function _approvalHash(Execution calldata execution_) private pure returns (bytes32) { + return keccak256(abi.encode(execution_.target, execution_.value, keccak256(execution_.callData))); + } + + function _swapHash(Execution calldata execution_) private pure returns (bytes32) { + bytes calldata callData_ = execution_.callData; + require(callData_.length >= SWAP_CALL_MIN_LENGTH, "OuterTypehashConstraintEnforcer:invalid-swap"); + return keccak256( + abi.encode( + execution_.target, + execution_.value, + bytes4(callData_[0:4]), + address(uint160(uint256(bytes32(callData_[36:68])))), + uint256(bytes32(callData_[68:100])) + ) + ); + } +} + +contract MetaSwapBatchHashGasComparisonTest is BaseTest { + uint256 private constant TOKEN_IN_AMOUNT = 100 ether; + uint256 private constant TOKEN_OUT_AMOUNT = 200 ether; + uint256 private constant INTRINSIC_GAS = 21_000; + + bytes32 private constant APPROVAL_TYPEHASH = keccak256("MetaSwapApproval(address target,uint256 value,bytes32 callDataHash)"); + bytes32 private constant SWAP_TYPEHASH = + keccak256("MetaSwapSwap(address target,uint256 value,bytes4 selector,address tokenFrom,uint256 amount)"); + bytes32 private constant APPROVAL_SEQUENCE_TYPEHASH = + keccak256("MetaSwapApprovalSequence(bytes32 firstApproval,bytes32 secondApproval)"); + bytes32 private constant APPROVAL_FIELDS_TYPEHASH = keccak256( + "MetaSwapApprovalFields(address target,uint256 value,bytes4 selector,address spender,uint256 amount)" + ); + bytes32 private constant BATCH_TYPEHASH = + keccak256("MetaSwapBatch(uint8 executionCount,bytes32 approvalSequenceHash,bytes32 swapHash)"); + + struct GasMeasurement { + uint256 executionGas; + uint256 calldataBytes; + uint256 calldataGas; + uint256 estimatedTransactionGas; + } + + MetaSwapBatchCalldataEnforcer private baseline; + IndividualExecutionHashEnforcer private individualHashes; + ApprovalSequenceHashEnforcer private sequenceHash; + CombinedConstraintHashEnforcer private combinedHash; + HybridConstraintHashEnforcer private hybridHash; + CombinedFieldHashEnforcer private combinedFieldHash; + OuterTypehashConstraintEnforcer private outerTypehash; + MockLimitOrderRouter private router; + BasicERC20 private tokenIn; + BasicERC20 private tokenOut; + address private relayer; + + constructor() { + IMPLEMENTATION = Implementation.MultiSig; + SIGNATURE_TYPE = SignatureType.MultiSig; + } + + function setUp() public override { + super.setUp(); + + router = new MockLimitOrderRouter(); + baseline = new MetaSwapBatchCalldataEnforcer(); + individualHashes = new IndividualExecutionHashEnforcer(); + sequenceHash = new ApprovalSequenceHashEnforcer(); + combinedHash = new CombinedConstraintHashEnforcer(); + hybridHash = new HybridConstraintHashEnforcer(); + combinedFieldHash = new CombinedFieldHashEnforcer(); + outerTypehash = new OuterTypehashConstraintEnforcer(); + tokenIn = new BasicERC20(address(users.alice.deleGator), "Token In", "TIN", 1_000 ether); + tokenOut = new BasicERC20(address(router), "Token Out", "TOUT", 10_000 ether); + relayer = makeAddr("Relayer"); + vm.deal(address(users.alice.deleGator), 1_000 ether); + } + + function test_gas_baseline_native() public { + Execution[] memory executions_ = _nativeBatch("route-a"); + _report("baseline / native", _measure(address(baseline), _baselineTerms(address(0), false), executions_)); + } + + function test_gas_individualHashes_native() public { + Execution[] memory executions_ = _nativeBatch("route-a"); + _report("individual hashes / native", _measure(address(individualHashes), _individualTerms(executions_), executions_)); + } + + function test_gas_sequenceHash_native() public { + Execution[] memory executions_ = _nativeBatch("route-a"); + _report( + "approval-sequence + swap hashes / native", _measure(address(sequenceHash), _sequenceTerms(executions_), executions_) + ); + } + + function test_gas_combinedHash_native() public { + Execution[] memory executions_ = _nativeBatch("route-a"); + _report("combined hash / native", _measure(address(combinedHash), _combinedTerms(executions_), executions_)); + } + + function test_gas_hybridHash_native() public { + Execution[] memory executions_ = _nativeBatch("route-a"); + _report("hybrid hash / native", _measure(address(hybridHash), _hybridTerms(executions_), executions_)); + } + + function test_gas_combinedFieldHash_native() public { + Execution[] memory executions_ = _nativeBatch("route-a"); + _report( + "combined field hash / native", + _measure(address(combinedFieldHash), _combinedFieldTerms(executions_), executions_) + ); + } + + function test_gas_outerTypehash_native() public { + Execution[] memory executions_ = _nativeBatch("route-a"); + _report( + "outer typehash only / native", + _measure(address(outerTypehash), _outerTypehashTerms(executions_), executions_) + ); + } + + function test_gas_baseline_oneApproval() public { + Execution[] memory executions_ = _erc20Batch(false, "route-a"); + _report("baseline / approve(amount)", _measure(address(baseline), _baselineTerms(address(tokenIn), false), executions_)); + } + + function test_gas_individualHashes_oneApproval() public { + Execution[] memory executions_ = _erc20Batch(false, "route-a"); + _report( + "individual hashes / approve(amount)", _measure(address(individualHashes), _individualTerms(executions_), executions_) + ); + } + + function test_gas_sequenceHash_oneApproval() public { + Execution[] memory executions_ = _erc20Batch(false, "route-a"); + _report( + "approval-sequence + swap hashes / approve(amount)", + _measure(address(sequenceHash), _sequenceTerms(executions_), executions_) + ); + } + + function test_gas_combinedHash_oneApproval() public { + Execution[] memory executions_ = _erc20Batch(false, "route-a"); + _report("combined hash / approve(amount)", _measure(address(combinedHash), _combinedTerms(executions_), executions_)); + } + + function test_gas_hybridHash_oneApproval() public { + Execution[] memory executions_ = _erc20Batch(false, "route-a"); + _report("hybrid hash / approve(amount)", _measure(address(hybridHash), _hybridTerms(executions_), executions_)); + } + + function test_gas_combinedFieldHash_oneApproval() public { + Execution[] memory executions_ = _erc20Batch(false, "route-a"); + _report( + "combined field hash / approve(amount)", + _measure(address(combinedFieldHash), _combinedFieldTerms(executions_), executions_) + ); + } + + function test_gas_outerTypehash_oneApproval() public { + Execution[] memory executions_ = _erc20Batch(false, "route-a"); + _report( + "outer typehash only / approve(amount)", + _measure(address(outerTypehash), _outerTypehashTerms(executions_), executions_) + ); + } + + function test_gas_baseline_resetApproval() public { + _setAllowance(1); + Execution[] memory executions_ = _erc20Batch(true, "route-a"); + _report( + "baseline / approve(0) + approve(amount)", + _measure(address(baseline), _baselineTerms(address(tokenIn), true), executions_) + ); + } + + function test_gas_individualHashes_resetApproval() public { + _setAllowance(1); + Execution[] memory executions_ = _erc20Batch(true, "route-a"); + _report( + "individual hashes / approve(0) + approve(amount)", + _measure(address(individualHashes), _individualTerms(executions_), executions_) + ); + } + + function test_gas_sequenceHash_resetApproval() public { + _setAllowance(1); + Execution[] memory executions_ = _erc20Batch(true, "route-a"); + _report( + "approval-sequence + swap hashes / approve(0) + approve(amount)", + _measure(address(sequenceHash), _sequenceTerms(executions_), executions_) + ); + } + + function test_gas_combinedHash_resetApproval() public { + _setAllowance(1); + Execution[] memory executions_ = _erc20Batch(true, "route-a"); + _report( + "combined hash / approve(0) + approve(amount)", + _measure(address(combinedHash), _combinedTerms(executions_), executions_) + ); + } + + function test_gas_hybridHash_resetApproval() public { + _setAllowance(1); + Execution[] memory executions_ = _erc20Batch(true, "route-a"); + _report("hybrid hash / approve(0) + approve(amount)", _measure(address(hybridHash), _hybridTerms(executions_), executions_)); + } + + function test_gas_combinedFieldHash_resetApproval() public { + _setAllowance(1); + Execution[] memory executions_ = _erc20Batch(true, "route-a"); + _report( + "combined field hash / approve(0) + approve(amount)", + _measure(address(combinedFieldHash), _combinedFieldTerms(executions_), executions_) + ); + } + + function test_gas_outerTypehash_resetApproval() public { + _setAllowance(1); + Execution[] memory executions_ = _erc20Batch(true, "route-a"); + _report( + "outer typehash only / approve(0) + approve(amount)", + _measure(address(outerTypehash), _outerTypehashTerms(executions_), executions_) + ); + } + + function test_hashTermsAllowDifferentDynamicRouteData() public { + Execution[] memory signedExecutions_ = _erc20Batch(false, "route-a"); + Execution[] memory redeemedExecutions_ = _erc20Batch(false, "a-much-longer-route-name"); + redeemedExecutions_[1].callData = + abi.encodeCall(IMetaSwap.swap, ("different-aggregator", tokenIn, TOKEN_IN_AMOUNT, _route())); + + _enforce(address(individualHashes), _individualTerms(signedExecutions_), redeemedExecutions_); + _enforce(address(sequenceHash), _sequenceTerms(signedExecutions_), redeemedExecutions_); + _enforce(address(combinedHash), _combinedTerms(signedExecutions_), redeemedExecutions_); + _enforce(address(hybridHash), _hybridTerms(signedExecutions_), redeemedExecutions_); + _enforce(address(combinedFieldHash), _combinedFieldTerms(signedExecutions_), redeemedExecutions_); + _enforce(address(outerTypehash), _outerTypehashTerms(signedExecutions_), redeemedExecutions_); + } + + function test_hashTermsRejectChangedApproval() public { + Execution[] memory signedExecutions_ = _erc20Batch(false, "route-a"); + Execution[] memory tamperedExecutions_ = _erc20Batch(false, "route-a"); + tamperedExecutions_[0].callData = abi.encodeCall(IERC20.approve, (makeAddr("OtherSpender"), TOKEN_IN_AMOUNT)); + + vm.expectRevert("IndividualExecutionHashEnforcer:invalid-approval"); + _enforce(address(individualHashes), _individualTerms(signedExecutions_), tamperedExecutions_); + + vm.expectRevert("ApprovalSequenceHashEnforcer:invalid-approval-sequence"); + _enforce(address(sequenceHash), _sequenceTerms(signedExecutions_), tamperedExecutions_); + + vm.expectRevert("CombinedConstraintHashEnforcer:invalid-constraints"); + _enforce(address(combinedHash), _combinedTerms(signedExecutions_), tamperedExecutions_); + + vm.expectRevert("HybridConstraintHashEnforcer:invalid-constraints"); + _enforce(address(hybridHash), _hybridTerms(signedExecutions_), tamperedExecutions_); + + vm.expectRevert("CombinedFieldHashEnforcer:invalid-constraints"); + _enforce(address(combinedFieldHash), _combinedFieldTerms(signedExecutions_), tamperedExecutions_); + + vm.expectRevert("OuterTypehashConstraintEnforcer:invalid-constraints"); + _enforce(address(outerTypehash), _outerTypehashTerms(signedExecutions_), tamperedExecutions_); + } + + function test_hashTermsRejectChangedSwapStaticFields() public { + Execution[] memory signedExecutions_ = _erc20Batch(false, "route-a"); + Execution[] memory tamperedExecutions_ = _erc20Batch(false, "route-a"); + tamperedExecutions_[1].callData = + abi.encodeCall(IMetaSwap.swap, ("route-a", IERC20(makeAddr("OtherToken")), TOKEN_IN_AMOUNT, _route())); + + vm.expectRevert("IndividualExecutionHashEnforcer:invalid-swap"); + _enforce(address(individualHashes), _individualTerms(signedExecutions_), tamperedExecutions_); + + vm.expectRevert("ApprovalSequenceHashEnforcer:invalid-swap"); + _enforce(address(sequenceHash), _sequenceTerms(signedExecutions_), tamperedExecutions_); + + vm.expectRevert("CombinedConstraintHashEnforcer:invalid-constraints"); + _enforce(address(combinedHash), _combinedTerms(signedExecutions_), tamperedExecutions_); + + vm.expectRevert("HybridConstraintHashEnforcer:invalid-constraints"); + _enforce(address(hybridHash), _hybridTerms(signedExecutions_), tamperedExecutions_); + + vm.expectRevert("CombinedFieldHashEnforcer:invalid-constraints"); + _enforce(address(combinedFieldHash), _combinedFieldTerms(signedExecutions_), tamperedExecutions_); + + vm.expectRevert("OuterTypehashConstraintEnforcer:invalid-constraints"); + _enforce(address(outerTypehash), _outerTypehashTerms(signedExecutions_), tamperedExecutions_); + } + + function test_runtimeCodeSizes() public view { + console2.log("baseline runtime bytes", address(baseline).code.length); + console2.log("individual hashes runtime bytes", address(individualHashes).code.length); + console2.log("sequence hash runtime bytes", address(sequenceHash).code.length); + console2.log("combined hash runtime bytes", address(combinedHash).code.length); + console2.log("hybrid hash runtime bytes", address(hybridHash).code.length); + console2.log("combined field hash runtime bytes", address(combinedFieldHash).code.length); + console2.log("outer typehash only runtime bytes", address(outerTypehash).code.length); + } + + function _measure( + address enforcer_, + bytes memory terms_, + Execution[] memory executions_ + ) + private + returns (GasMeasurement memory measurement_) + { + Delegation memory delegation_ = _delegation(enforcer_, terms_); + Delegation[] memory delegations_ = new Delegation[](1); + delegations_[0] = delegation_; + bytes memory redeemCallData_ = _encodeRedeem(delegations_, ExecutionLib.encodeBatch(executions_)); + + measurement_.calldataBytes = redeemCallData_.length; + measurement_.calldataGas = _calldataGas(redeemCallData_); + + vm.prank(relayer); + uint256 gasBefore_ = gasleft(); + (bool success_,) = address(delegationManager).call(redeemCallData_); + measurement_.executionGas = gasBefore_ - gasleft(); + assertTrue(success_); + + measurement_.estimatedTransactionGas = INTRINSIC_GAS + measurement_.calldataGas + measurement_.executionGas; + assertEq(tokenOut.balanceOf(address(users.alice.deleGator)), TOKEN_OUT_AMOUNT); + } + + function _delegation(address enforcer_, bytes memory terms_) private view returns (Delegation memory delegation_) { + Caveat[] memory caveats_ = new Caveat[](1); + caveats_[0] = Caveat({ enforcer: enforcer_, terms: terms_, args: hex"" }); + delegation_ = Delegation({ + delegate: ANY_DELEGATE, + delegator: address(users.alice.deleGator), + authority: ROOT_AUTHORITY, + caveats: caveats_, + salt: 0, + signature: hex"" + }); + delegation_ = signDelegation(users.alice, delegation_); + } + + function _encodeRedeem(Delegation[] memory delegations_, bytes memory executionCallData_) private pure returns (bytes memory) { + 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] = executionCallData_; + return + abi.encodeWithSelector(IDelegationManager.redeemDelegations.selector, permissionContexts_, modes_, executionCallDatas_); + } + + function _nativeBatch(string memory aggregatorId_) private view returns (Execution[] memory executions_) { + executions_ = new Execution[](1); + executions_[0] = _swap(address(0), TOKEN_IN_AMOUNT, aggregatorId_); + } + + function _erc20Batch(bool resetApproval_, string memory aggregatorId_) private view returns (Execution[] memory executions_) { + uint256 swapIndex_ = resetApproval_ ? 2 : 1; + executions_ = new Execution[](swapIndex_ + 1); + if (resetApproval_) executions_[0] = _approval(0); + executions_[swapIndex_ - 1] = _approval(TOKEN_IN_AMOUNT); + executions_[swapIndex_] = _swap(address(tokenIn), 0, aggregatorId_); + } + + function _approval(uint256 amount_) private view returns (Execution memory) { + return + Execution({ target: address(tokenIn), value: 0, callData: abi.encodeCall(IERC20.approve, (address(router), amount_)) }); + } + + function _swap(address tokenIn_, uint256 value_, string memory aggregatorId_) private view returns (Execution memory) { + return Execution({ + target: address(router), + value: value_, + callData: abi.encodeCall(IMetaSwap.swap, (aggregatorId_, IERC20(tokenIn_), TOKEN_IN_AMOUNT, _route())) + }); + } + + function _route() private view returns (bytes memory) { + return abi.encode(IERC20(address(tokenOut)), TOKEN_OUT_AMOUNT); + } + + function _baselineTerms(address tokenIn_, bool resetApproval_) private view returns (bytes memory) { + return abi.encodePacked(address(router), tokenIn_, TOKEN_IN_AMOUNT, bytes1(resetApproval_ ? 0x01 : 0x00)); + } + + function _individualTerms(Execution[] memory executions_) private view returns (bytes memory terms_) { + uint256 length_ = executions_.length; + if (length_ == 1) { + return abi.encodePacked(_swapHash(executions_[0], address(0))); + } + if (length_ == 2) { + return abi.encodePacked(_approvalHash(executions_[0]), _swapHash(executions_[1], address(tokenIn))); + } + return abi.encodePacked( + _approvalHash(executions_[0]), _approvalHash(executions_[1]), _swapHash(executions_[2], address(tokenIn)) + ); + } + + function _sequenceTerms(Execution[] memory executions_) private view returns (bytes memory) { + (bytes32 approvalSequenceHash_, bytes32 swapHash_) = _approvalSequenceAndSwap(executions_); + return abi.encodePacked(approvalSequenceHash_, swapHash_); + } + + function _combinedTerms(Execution[] memory executions_) private view returns (bytes memory) { + (bytes32 approvalSequenceHash_, bytes32 swapHash_) = _approvalSequenceAndSwap(executions_); + return abi.encodePacked(keccak256(abi.encode(BATCH_TYPEHASH, uint8(executions_.length), approvalSequenceHash_, swapHash_))); + } + + function _hybridTerms(Execution[] memory executions_) private view returns (bytes memory) { + if (executions_.length == 1) return abi.encodePacked(_swapHash(executions_[0], address(0))); + return _combinedTerms(executions_); + } + + function _combinedFieldTerms(Execution[] memory executions_) private view returns (bytes memory) { + uint256 length_ = executions_.length; + bytes32 approvalSequenceHash_; + bytes32 swapHash_; + if (length_ == 1) { + swapHash_ = _swapHash(executions_[0], address(0)); + } else if (length_ == 2) { + approvalSequenceHash_ = _approvalFieldsHash(TOKEN_IN_AMOUNT); + swapHash_ = _swapHash(executions_[1], address(tokenIn)); + } else { + approvalSequenceHash_ = keccak256( + abi.encode( + APPROVAL_SEQUENCE_TYPEHASH, + _approvalFieldsHash(0), + _approvalFieldsHash(TOKEN_IN_AMOUNT) + ) + ); + swapHash_ = _swapHash(executions_[2], address(tokenIn)); + } + return abi.encodePacked(keccak256(abi.encode(BATCH_TYPEHASH, uint8(length_), approvalSequenceHash_, swapHash_))); + } + + function _outerTypehashTerms(Execution[] memory executions_) private view returns (bytes memory) { + uint256 length_ = executions_.length; + bytes32 approvalSequenceHash_; + bytes32 swapHash_; + if (length_ == 1) { + swapHash_ = _swapHashWithoutTypehash(executions_[0], address(0)); + } else if (length_ == 2) { + approvalSequenceHash_ = _approvalHashWithoutTypehash(executions_[0]); + swapHash_ = _swapHashWithoutTypehash(executions_[1], address(tokenIn)); + } else { + approvalSequenceHash_ = keccak256( + abi.encode( + _approvalHashWithoutTypehash(executions_[0]), + _approvalHashWithoutTypehash(executions_[1]) + ) + ); + swapHash_ = _swapHashWithoutTypehash(executions_[2], address(tokenIn)); + } + return abi.encodePacked(keccak256(abi.encode(BATCH_TYPEHASH, uint8(length_), approvalSequenceHash_, swapHash_))); + } + + function _approvalSequenceAndSwap(Execution[] memory executions_) + private + view + returns (bytes32 approvalSequenceHash_, bytes32 swapHash_) + { + if (executions_.length == 1) { + swapHash_ = _swapHash(executions_[0], address(0)); + } else if (executions_.length == 2) { + approvalSequenceHash_ = _approvalHash(executions_[0]); + swapHash_ = _swapHash(executions_[1], address(tokenIn)); + } else { + approvalSequenceHash_ = + keccak256(abi.encode(APPROVAL_SEQUENCE_TYPEHASH, _approvalHash(executions_[0]), _approvalHash(executions_[1]))); + swapHash_ = _swapHash(executions_[2], address(tokenIn)); + } + } + + function _approvalHash(Execution memory execution_) private pure returns (bytes32) { + return keccak256(abi.encode(APPROVAL_TYPEHASH, execution_.target, execution_.value, keccak256(execution_.callData))); + } + + function _approvalHashWithoutTypehash(Execution memory execution_) private pure returns (bytes32) { + return keccak256(abi.encode(execution_.target, execution_.value, keccak256(execution_.callData))); + } + + function _approvalFieldsHash(uint256 amount_) private view returns (bytes32) { + return keccak256( + abi.encode( + APPROVAL_FIELDS_TYPEHASH, + address(tokenIn), + uint256(0), + IERC20.approve.selector, + address(router), + amount_ + ) + ); + } + + function _swapHash(Execution memory execution_, address tokenIn_) private pure returns (bytes32) { + return keccak256( + abi.encode(SWAP_TYPEHASH, execution_.target, execution_.value, IMetaSwap.swap.selector, tokenIn_, TOKEN_IN_AMOUNT) + ); + } + + function _swapHashWithoutTypehash(Execution memory execution_, address tokenIn_) private pure returns (bytes32) { + return keccak256( + abi.encode(execution_.target, execution_.value, IMetaSwap.swap.selector, tokenIn_, TOKEN_IN_AMOUNT) + ); + } + + function _setAllowance(uint256 amount_) private { + vm.prank(address(users.alice.deleGator)); + tokenIn.approve(address(router), amount_); + } + + function _enforce(address enforcer_, bytes memory terms_, Execution[] memory executions_) private { + CaveatEnforcer(enforcer_) + .beforeHook( + terms_, + hex"", + ModeLib.encodeSimpleBatch(), + ExecutionLib.encodeBatch(executions_), + bytes32(0), + address(users.alice.deleGator), + relayer + ); + } + + function _calldataGas(bytes memory data_) private pure returns (uint256 gas_) { + for (uint256 i_; i_ < data_.length; ++i_) { + gas_ += data_[i_] == 0 ? 4 : 16; + } + } + + function _report(string memory label_, GasMeasurement memory measurement_) private pure { + console2.log(label_); + console2.log(" execution gas", measurement_.executionGas); + console2.log(" calldata bytes", measurement_.calldataBytes); + console2.log(" calldata gas", measurement_.calldataGas); + console2.log(" estimated transaction gas", measurement_.estimatedTransactionGas); + } +} diff --git a/test/experiments/allowed-calldata-limit-order/AllowedCalldataLimitOrder.t.sol b/test/experiments/allowed-calldata-limit-order/AllowedCalldataLimitOrder.t.sol new file mode 100644 index 00000000..6bc7a398 --- /dev/null +++ b/test/experiments/allowed-calldata-limit-order/AllowedCalldataLimitOrder.t.sol @@ -0,0 +1,554 @@ +// 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 { BaseTest } from "../../utils/BaseTest.t.sol"; +import { BasicERC20 } from "../../utils/BasicERC20.t.sol"; +import { Implementation, SignatureType } from "../../utils/Types.t.sol"; +import { AllowedCalldataEnforcer } from "../../../src/enforcers/AllowedCalldataEnforcer.sol"; +import { AllowedTargetsEnforcer } from "../../../src/enforcers/AllowedTargetsEnforcer.sol"; +import { ERC20BalanceChangeEnforcer } from "../../../src/enforcers/ERC20BalanceChangeEnforcer.sol"; +import { LimitedCallsEnforcer } from "../../../src/enforcers/LimitedCallsEnforcer.sol"; +import { MetaSwapBatchCalldataEnforcer } from "../../../src/enforcers/MetaSwapBatchCalldataEnforcer.sol"; +import { MetaSwap7702CalldataEnforcer } from "../../../src/enforcers/MetaSwap7702CalldataEnforcer.sol"; +import { NativeBalanceChangeEnforcer } from "../../../src/enforcers/NativeBalanceChangeEnforcer.sol"; +import { NativeTokenTransferAmountEnforcer } from "../../../src/enforcers/NativeTokenTransferAmountEnforcer.sol"; +import { IERC7821 } from "../../../src/interfaces/IERC7821.sol"; +import { IMetaSwap } from "../../../src/helpers/interfaces/IMetaSwap.sol"; +import { Caveat, Delegation, Execution, ModeCode } from "../../../src/utils/Types.sol"; + +contract FlexibleMetaSwapMock 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, bytes)); + + if (address(tokenFrom_) == address(0)) { + require(msg.value == amount_, "invalid-native-input"); + } else { + require(msg.value == 0, "unexpected-value"); + tokenFrom_.safeTransferFrom(msg.sender, address(this), amount_); + } + + if (address(tokenOut_) == address(0)) { + (bool success_,) = msg.sender.call{ value: amountOut_ }(""); + require(success_, "native-output-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"" }); + } +} + +/** + * @notice Proof that disjoint AllowedCalldata checks can secure a flexible MetaSwap route inside one outer 7702 execution. + * @dev This intentionally uses the canonical manager. It validates the caveat composition before changing the specialized manager. + */ +contract AllowedCalldataLimitOrderTest is BaseTest { + using SafeERC20 for IERC20; + + uint256 internal constant TOKEN_IN_AMOUNT = 100 ether; + uint256 internal constant TOKEN_OUT_MIN = 190 ether; + uint256 internal constant ACTUAL_TOKEN_OUT = 200 ether; + + // IERC7821.execute(ModeCode,bytes): selector + mode + bytes offset. + uint256 internal constant OUTER_HEADER_LENGTH = 68; + // The dynamic batch bytes begin after selector + two ABI head words + bytes length. + uint256 internal constant INNER_BATCH_START = 100; + + AllowedCalldataEnforcer internal allowedCalldataEnforcer; + AllowedTargetsEnforcer internal allowedTargetsEnforcer; + NativeTokenTransferAmountEnforcer internal outerValueEnforcer; + LimitedCallsEnforcer internal limitedCallsEnforcer; + MetaSwapBatchCalldataEnforcer internal metaSwapBatchCalldataEnforcer; + MetaSwap7702CalldataEnforcer internal metaSwap7702CalldataEnforcer; + ERC20BalanceChangeEnforcer internal erc20BalanceChangeEnforcer; + NativeBalanceChangeEnforcer internal nativeBalanceChangeEnforcer; + + BasicERC20 internal tokenIn; + BasicERC20 internal tokenOut; + BasicERC20 internal preapprovedToken; + FlexibleMetaSwapMock internal metaSwap; + + address internal alice; + address internal relayer; + + constructor() { + IMPLEMENTATION = Implementation.EIP7702Stateless; + SIGNATURE_TYPE = SignatureType.EOA; + } + + function setUp() public override { + super.setUp(); + + allowedCalldataEnforcer = new AllowedCalldataEnforcer(); + allowedTargetsEnforcer = new AllowedTargetsEnforcer(); + outerValueEnforcer = new NativeTokenTransferAmountEnforcer(); + limitedCallsEnforcer = new LimitedCallsEnforcer(); + metaSwapBatchCalldataEnforcer = new MetaSwapBatchCalldataEnforcer(); + metaSwap7702CalldataEnforcer = new MetaSwap7702CalldataEnforcer(); + erc20BalanceChangeEnforcer = new ERC20BalanceChangeEnforcer(); + nativeBalanceChangeEnforcer = new NativeBalanceChangeEnforcer(); + + alice = address(users.alice.deleGator); + relayer = makeAddr("Relayer"); + + tokenIn = new BasicERC20(address(this), "Token In", "TIN", 0); + tokenOut = new BasicERC20(address(this), "Token Out", "TOUT", 0); + preapprovedToken = new BasicERC20(address(this), "Preapproved Token", "OLD", 0); + metaSwap = new FlexibleMetaSwapMock(); + + tokenIn.mint(alice, 1_000 ether); + preapprovedToken.mint(alice, 1_000 ether); + tokenOut.mint(address(metaSwap), 10_000 ether); + vm.deal(address(metaSwap), 10_000 ether); + } + + function test_erc20OneApproval_routeAndAggregatorRemainFlexible() public { + Execution memory template_ = + _wrap(_erc20Inner(false, tokenIn, TOKEN_IN_AMOUNT, "template", _route(tokenOut, ACTUAL_TOKEN_OUT, hex"01"))); + Delegation memory delegation_ = _sign(_secureCaveats(template_, tokenOut, TOKEN_OUT_MIN)); + + // Different dynamic string and bytes lengths are accepted after the user signs. + Execution memory fill_ = _wrap( + _erc20Inner( + false, + tokenIn, + TOKEN_IN_AMOUNT, + "a-different-aggregator", + _route(tokenOut, ACTUAL_TOKEN_OUT, hex"010203040506070809") + ) + ); + + _redeem(delegation_, fill_); + + assertEq(tokenIn.balanceOf(alice), 900 ether); + assertEq(tokenOut.balanceOf(alice), ACTUAL_TOKEN_OUT); + } + + function test_erc20ResetAndApproval_routeRemainsFlexible() public { + vm.prank(alice); + tokenIn.approve(address(metaSwap), 1); + + Execution memory template_ = + _wrap(_erc20Inner(true, tokenIn, TOKEN_IN_AMOUNT, "template", _route(tokenOut, ACTUAL_TOKEN_OUT, hex""))); + Delegation memory delegation_ = _sign(_secureCaveats(template_, tokenOut, TOKEN_OUT_MIN)); + Execution memory fill_ = + _wrap(_erc20Inner(true, tokenIn, TOKEN_IN_AMOUNT, "new-route", _route(tokenOut, ACTUAL_TOKEN_OUT, new bytes(96)))); + + _redeem(delegation_, fill_); + + assertEq(tokenIn.balanceOf(alice), 900 ether); + assertEq(tokenOut.balanceOf(alice), ACTUAL_TOKEN_OUT); + assertEq(tokenIn.allowance(alice, address(metaSwap)), 0); + } + + function test_customEnforcer_erc20OneApproval_sameCanonicalManager() public { + Delegation memory delegation_ = _sign(_customCaveats(false, tokenOut, TOKEN_OUT_MIN)); + Execution memory fill_ = _wrap( + _erc20Inner( + false, tokenIn, TOKEN_IN_AMOUNT, "a-different-aggregator", _route(tokenOut, ACTUAL_TOKEN_OUT, new bytes(96)) + ) + ); + + _redeem(delegation_, fill_); + + assertEq(tokenIn.balanceOf(alice), 900 ether); + assertEq(tokenOut.balanceOf(alice), ACTUAL_TOKEN_OUT); + } + + function test_customEnforcer_erc20ResetApproval_sameCanonicalManager() public { + vm.prank(alice); + tokenIn.approve(address(metaSwap), 1); + + Delegation memory delegation_ = _sign(_customCaveats(true, tokenOut, TOKEN_OUT_MIN)); + Execution memory fill_ = + _wrap(_erc20Inner(true, tokenIn, TOKEN_IN_AMOUNT, "new-route", _route(tokenOut, ACTUAL_TOKEN_OUT, new bytes(96)))); + + _redeem(delegation_, fill_); + + assertEq(tokenIn.balanceOf(alice), 900 ether); + assertEq(tokenOut.balanceOf(alice), ACTUAL_TOKEN_OUT); + } + + function test_directBatchEnforcer_erc20OneApproval_sameCanonicalManager() public { + Delegation memory delegation_ = _sign(_directBatchCaveats(false, tokenOut, TOKEN_OUT_MIN)); + Execution[] memory executions_ = + _erc20Inner(false, tokenIn, TOKEN_IN_AMOUNT, "best-route", _route(tokenOut, ACTUAL_TOKEN_OUT, new bytes(96))); + + _redeemBatch(delegation_, executions_); + + assertEq(tokenIn.balanceOf(alice), 900 ether); + assertEq(tokenOut.balanceOf(alice), ACTUAL_TOKEN_OUT); + } + + function test_directBatchEnforcer_erc20ResetApproval_sameCanonicalManager() public { + vm.prank(alice); + tokenIn.approve(address(metaSwap), 1); + + Delegation memory delegation_ = _sign(_directBatchCaveats(true, tokenOut, TOKEN_OUT_MIN)); + Execution[] memory executions_ = + _erc20Inner(true, tokenIn, TOKEN_IN_AMOUNT, "best-route", _route(tokenOut, ACTUAL_TOKEN_OUT, new bytes(96))); + + _redeemBatch(delegation_, executions_); + + assertEq(tokenIn.balanceOf(alice), 900 ether); + assertEq(tokenOut.balanceOf(alice), ACTUAL_TOKEN_OUT); + } + + function test_nativeInput_singleInnerExecutionAndFlexibleRoute() public { + Execution memory template_ = _wrap(_nativeInner("template", _route(tokenOut, ACTUAL_TOKEN_OUT, hex"01"))); + Delegation memory delegation_ = _sign(_secureCaveats(template_, tokenOut, TOKEN_OUT_MIN)); + Execution memory fill_ = + _wrap(_nativeInner("better-aggregator", _route(tokenOut, ACTUAL_TOKEN_OUT, hex"010203040506070809"))); + + uint256 nativeBefore_ = alice.balance; + _redeem(delegation_, fill_); + + assertEq(alice.balance, nativeBefore_ - TOKEN_IN_AMOUNT); + assertEq(tokenOut.balanceOf(alice), ACTUAL_TOKEN_OUT); + } + + function test_nativeOutput_balanceCaveatWorksWithFlexibleRoute() public { + Execution memory template_ = + _wrap(_erc20Inner(false, tokenIn, TOKEN_IN_AMOUNT, "template", _route(IERC20(address(0)), ACTUAL_TOKEN_OUT, hex""))); + Delegation memory delegation_ = _sign(_secureNativeOutputCaveats(template_, TOKEN_OUT_MIN)); + Execution memory fill_ = _wrap( + _erc20Inner( + false, tokenIn, TOKEN_IN_AMOUNT, "better-aggregator", _route(IERC20(address(0)), ACTUAL_TOKEN_OUT, new bytes(64)) + ) + ); + + uint256 nativeBefore_ = alice.balance; + _redeem(delegation_, fill_); + + assertEq(alice.balance, nativeBefore_ + ACTUAL_TOKEN_OUT); + } + + function test_insufficientOutputRevertsButDifferentRouteCanRetry() public { + Execution memory template_ = + _wrap(_erc20Inner(false, tokenIn, TOKEN_IN_AMOUNT, "template", _route(tokenOut, TOKEN_OUT_MIN, hex""))); + Delegation memory delegation_ = _sign(_secureCaveats(template_, tokenOut, TOKEN_OUT_MIN)); + + Execution memory badFill_ = + _wrap(_erc20Inner(false, tokenIn, TOKEN_IN_AMOUNT, "bad", _route(tokenOut, TOKEN_OUT_MIN - 1, bytes("bad")))); + vm.expectRevert("ERC20BalanceChangeEnforcer:insufficient-balance-increase"); + _redeem(delegation_, badFill_); + + Execution memory goodFill_ = + _wrap(_erc20Inner(false, tokenIn, TOKEN_IN_AMOUNT, "good", _route(tokenOut, TOKEN_OUT_MIN, bytes("good")))); + _redeem(delegation_, goodFill_); + + assertEq(tokenOut.balanceOf(alice), TOKEN_OUT_MIN); + } + + function test_secureSlicesRejectDifferentTokenIn() public { + Execution memory template_ = + _wrap(_erc20Inner(false, tokenIn, TOKEN_IN_AMOUNT, "template", _route(tokenOut, ACTUAL_TOKEN_OUT, hex""))); + Delegation memory delegation_ = _sign(_secureCaveats(template_, tokenOut, TOKEN_OUT_MIN)); + Execution memory tampered_ = + _wrap(_erc20Inner(false, preapprovedToken, TOKEN_IN_AMOUNT, "route", _route(tokenOut, ACTUAL_TOKEN_OUT, hex""))); + + vm.expectRevert("AllowedCalldataEnforcer:invalid-calldata"); + _redeem(delegation_, tampered_); + } + + function test_singlePrefixCannotKeepDifferentLengthRouteFlexible() public { + Execution memory template_ = + _wrap(_erc20Inner(false, tokenIn, TOKEN_IN_AMOUNT, "template", _route(tokenOut, ACTUAL_TOKEN_OUT, hex""))); + Execution memory fill_ = _wrap( + _erc20Inner( + false, tokenIn, TOKEN_IN_AMOUNT, "longer-aggregator-name", _route(tokenOut, ACTUAL_TOKEN_OUT, new bytes(96)) + ) + ); + + uint256 selectorOffset_ = _indexOf(template_.callData, IMetaSwap.swap.selector); + bytes memory onePrefixTerms_ = abi.encodePacked(uint256(0), _slice(template_.callData, 0, selectorOffset_ + 4)); + bytes memory fillExecutionCallData_ = ExecutionLib.encodeSingle(fill_.target, fill_.value, fill_.callData); + + vm.prank(address(delegationManager)); + vm.expectRevert("AllowedCalldataEnforcer:invalid-calldata"); + allowedCalldataEnforcer.beforeHook( + onePrefixTerms_, hex"", singleDefaultMode, fillExecutionCallData_, bytes32(0), alice, relayer + ); + } + + function test_oneApprovalTermsDoNotAlsoPermitResetApprovalShape() public { + Execution memory template_ = + _wrap(_erc20Inner(false, tokenIn, TOKEN_IN_AMOUNT, "template", _route(tokenOut, ACTUAL_TOKEN_OUT, hex""))); + Delegation memory delegation_ = _sign(_secureCaveats(template_, tokenOut, TOKEN_OUT_MIN)); + Execution memory resetFill_ = + _wrap(_erc20Inner(true, tokenIn, TOKEN_IN_AMOUNT, "template", _route(tokenOut, ACTUAL_TOKEN_OUT, hex""))); + + vm.expectRevert("AllowedCalldataEnforcer:invalid-calldata"); + _redeem(delegation_, resetFill_); + } + + function test_ignoringSwapInputsCanDrainAnotherPreapprovedToken() public { + vm.prank(alice); + preapprovedToken.approve(address(metaSwap), TOKEN_IN_AMOUNT); + + Execution memory template_ = + _wrap(_erc20Inner(false, tokenIn, TOKEN_IN_AMOUNT, "template", _route(tokenOut, ACTUAL_TOKEN_OUT, hex""))); + // Deliberately omit the tokenFrom + amount slice, matching the proposed "ignore swap inputs" version. + Delegation memory unsafeDelegation_ = _sign(_unsafeCaveats(template_, tokenOut, TOKEN_OUT_MIN)); + Execution memory maliciousFill_ = _wrap( + _erc20Inner(false, preapprovedToken, TOKEN_IN_AMOUNT, "malicious-route", _route(tokenOut, ACTUAL_TOKEN_OUT, hex"")) + ); + + _redeem(unsafeDelegation_, maliciousFill_); + + assertEq(tokenIn.balanceOf(alice), 1_000 ether, "intended token was not spent"); + assertEq(preapprovedToken.balanceOf(alice), 900 ether, "unbound preapproved token was drained"); + assertEq(tokenOut.balanceOf(alice), ACTUAL_TOKEN_OUT, "output check still passed"); + } + + function _secureCaveats( + Execution memory template_, + IERC20 tokenOut_, + uint256 minOut_ + ) + private + view + returns (Caveat[] memory caveats_) + { + Caveat[] memory calldataCaveats_ = _calldataCaveats(template_, true); + caveats_ = _commonCaveats(calldataCaveats_, calldataCaveats_.length + 4); + caveats_[caveats_.length - 2] = + Caveat({ enforcer: address(limitedCallsEnforcer), terms: abi.encode(uint256(1)), args: hex"" }); + caveats_[caveats_.length - 1] = Caveat({ + enforcer: address(erc20BalanceChangeEnforcer), + terms: abi.encodePacked(false, address(tokenOut_), alice, minOut_), + args: hex"" + }); + } + + function _customCaveats(bool resetApproval_, IERC20 tokenOut_, uint256 minOut_) + private + view + returns (Caveat[] memory caveats_) + { + caveats_ = new Caveat[](3); + caveats_[0] = Caveat({ + enforcer: address(metaSwap7702CalldataEnforcer), + terms: abi.encodePacked(address(metaSwap), address(tokenIn), TOKEN_IN_AMOUNT, bytes1(resetApproval_ ? 0x01 : 0x00)), + args: hex"" + }); + caveats_[1] = Caveat({ enforcer: address(limitedCallsEnforcer), terms: abi.encode(uint256(1)), args: hex"" }); + caveats_[2] = Caveat({ + enforcer: address(erc20BalanceChangeEnforcer), + terms: abi.encodePacked(false, address(tokenOut_), alice, minOut_), + args: hex"" + }); + } + + function _directBatchCaveats( + bool resetApproval_, + IERC20 tokenOut_, + uint256 minOut_ + ) + private + view + returns (Caveat[] memory caveats_) + { + caveats_ = new Caveat[](3); + caveats_[0] = Caveat({ + enforcer: address(metaSwapBatchCalldataEnforcer), + terms: abi.encodePacked(address(metaSwap), address(tokenIn), TOKEN_IN_AMOUNT, bytes1(resetApproval_ ? 0x01 : 0x00)), + args: hex"" + }); + caveats_[1] = Caveat({ enforcer: address(limitedCallsEnforcer), terms: abi.encode(uint256(1)), args: hex"" }); + caveats_[2] = Caveat({ + enforcer: address(erc20BalanceChangeEnforcer), + terms: abi.encodePacked(false, address(tokenOut_), alice, minOut_), + args: hex"" + }); + } + + function _secureNativeOutputCaveats( + Execution memory template_, + uint256 minOut_ + ) + private + view + returns (Caveat[] memory caveats_) + { + Caveat[] memory calldataCaveats_ = _calldataCaveats(template_, true); + caveats_ = _commonCaveats(calldataCaveats_, calldataCaveats_.length + 4); + caveats_[caveats_.length - 2] = + Caveat({ enforcer: address(limitedCallsEnforcer), terms: abi.encode(uint256(1)), args: hex"" }); + caveats_[caveats_.length - 1] = + Caveat({ enforcer: address(nativeBalanceChangeEnforcer), terms: abi.encodePacked(false, alice, minOut_), args: hex"" }); + } + + function _unsafeCaveats( + Execution memory template_, + IERC20 tokenOut_, + uint256 minOut_ + ) + private + view + returns (Caveat[] memory caveats_) + { + Caveat[] memory calldataCaveats_ = _calldataCaveats(template_, false); + caveats_ = _commonCaveats(calldataCaveats_, calldataCaveats_.length + 4); + caveats_[caveats_.length - 2] = + Caveat({ enforcer: address(limitedCallsEnforcer), terms: abi.encode(uint256(1)), args: hex"" }); + caveats_[caveats_.length - 1] = Caveat({ + enforcer: address(erc20BalanceChangeEnforcer), + terms: abi.encodePacked(false, address(tokenOut_), alice, minOut_), + args: hex"" + }); + } + + function _commonCaveats(Caveat[] memory calldataCaveats_, uint256 totalLength_) + private + view + returns (Caveat[] memory caveats_) + { + caveats_ = new Caveat[](totalLength_); + caveats_[0] = Caveat({ enforcer: address(allowedTargetsEnforcer), terms: abi.encodePacked(alice), args: hex"" }); + caveats_[1] = Caveat({ enforcer: address(outerValueEnforcer), terms: abi.encode(uint256(0)), args: hex"" }); + for (uint256 i; i < calldataCaveats_.length; ++i) { + caveats_[i + 2] = calldataCaveats_[i]; + } + } + + function _calldataCaveats(Execution memory template_, bool bindSwapInputs_) private view returns (Caveat[] memory caveats_) { + uint256 selectorOffset_ = _indexOf(template_.callData, IMetaSwap.swap.selector); + uint256 swapCallDataLengthOffset_ = selectorOffset_ - 32; + uint256 count_ = bindSwapInputs_ ? 4 : 3; + caveats_ = new Caveat[](count_); + + // Skip the outer dynamic-bytes length at [68:100], which changes with route length. + caveats_[0] = _allowedCalldataCaveat(0, _slice(template_.callData, 0, OUTER_HEADER_LENGTH)); + // Bind inner count, offsets, exact approvals, and the final MetaSwap target/value; stop before swap calldata length. + caveats_[1] = _allowedCalldataCaveat( + INNER_BATCH_START, _slice(template_.callData, INNER_BATCH_START, swapCallDataLengthOffset_ - INNER_BATCH_START) + ); + caveats_[2] = _allowedCalldataCaveat(selectorOffset_, abi.encodePacked(IMetaSwap.swap.selector)); + + if (bindSwapInputs_) { + // IMetaSwap.swap head: selector | string offset | tokenFrom | amount | bytes offset. + caveats_[3] = _allowedCalldataCaveat(selectorOffset_ + 36, _slice(template_.callData, selectorOffset_ + 36, 64)); + } + } + + function _allowedCalldataCaveat(uint256 offset_, bytes memory expected_) private view returns (Caveat memory caveat_) { + caveat_ = Caveat({ enforcer: address(allowedCalldataEnforcer), terms: abi.encodePacked(offset_, expected_), args: hex"" }); + } + + function _erc20Inner( + bool resetApproval_, + IERC20 swapToken_, + uint256 swapAmount_, + string memory aggregatorId_, + bytes memory route_ + ) + private + view + returns (Execution[] memory executions_) + { + uint256 swapIndex_ = resetApproval_ ? 2 : 1; + executions_ = new Execution[](swapIndex_ + 1); + if (resetApproval_) { + executions_[0] = + Execution({ target: address(tokenIn), value: 0, callData: abi.encodeCall(IERC20.approve, (address(metaSwap), 0)) }); + } + executions_[swapIndex_ - 1] = Execution({ + target: address(tokenIn), value: 0, callData: abi.encodeCall(IERC20.approve, (address(metaSwap), TOKEN_IN_AMOUNT)) + }); + executions_[swapIndex_] = Execution({ + target: address(metaSwap), + value: 0, + callData: abi.encodeCall(IMetaSwap.swap, (aggregatorId_, swapToken_, swapAmount_, route_)) + }); + } + + function _nativeInner(string memory aggregatorId_, bytes memory route_) private view returns (Execution[] memory executions_) { + executions_ = new Execution[](1); + executions_[0] = Execution({ + target: address(metaSwap), + value: TOKEN_IN_AMOUNT, + callData: abi.encodeCall(IMetaSwap.swap, (aggregatorId_, IERC20(address(0)), TOKEN_IN_AMOUNT, route_)) + }); + } + + function _route(IERC20 tokenOut_, uint256 amountOut_, bytes memory routeData_) private pure returns (bytes memory) { + return abi.encode(tokenOut_, amountOut_, routeData_); + } + + function _wrap(Execution[] memory inner_) private view returns (Execution memory execution_) { + execution_ = Execution({ + target: alice, + value: 0, + callData: abi.encodeCall(IERC7821.execute, (ModeLib.encodeSimpleBatch(), ExecutionLib.encodeBatch(inner_))) + }); + } + + function _sign(Caveat[] memory caveats_) private view returns (Delegation memory delegation_) { + delegation_ = Delegation({ + delegate: ANY_DELEGATE, delegator: alice, authority: ROOT_AUTHORITY, caveats: caveats_, salt: 0, signature: hex"" + }); + delegation_ = signDelegation(users.alice, delegation_); + } + + function _redeem(Delegation memory delegation_, Execution memory execution_) 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] = singleDefaultMode; + bytes[] memory executionCallDatas_ = new bytes[](1); + executionCallDatas_[0] = ExecutionLib.encodeSingle(execution_.target, execution_.value, execution_.callData); + + vm.prank(relayer); + delegationManager.redeemDelegations(permissionContexts_, modes_, executionCallDatas_); + } + + function _redeemBatch(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] = batchDefaultMode; + bytes[] memory executionCallDatas_ = new bytes[](1); + executionCallDatas_[0] = ExecutionLib.encodeBatch(executions_); + + vm.prank(relayer); + delegationManager.redeemDelegations(permissionContexts_, modes_, executionCallDatas_); + } + + function _indexOf(bytes memory data_, bytes4 needle_) private pure returns (uint256 index_) { + for (uint256 i; i + 4 <= data_.length; ++i) { + bytes4 candidate_; + assembly { + candidate_ := mload(add(add(data_, 0x20), i)) + } + if (candidate_ == needle_) return i; + } + revert("selector-not-found"); + } + + function _slice(bytes memory data_, uint256 start_, uint256 length_) private pure returns (bytes memory result_) { + result_ = new bytes(length_); + for (uint256 i; i < length_; ++i) { + result_[i] = data_[start_ + i]; + } + } +} diff --git a/test/utils/MockLimitOrderRouter.sol b/test/utils/MockLimitOrderRouter.sol new file mode 100644 index 00000000..e0e4dd70 --- /dev/null +++ b/test/utils/MockLimitOrderRouter.sol @@ -0,0 +1,58 @@ +// 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"; + +/** + * @notice Adjustable-output swap stand-in used to prove post-execution limit-order checks. + */ +contract MockLimitOrderRouter { + using SafeERC20 for IERC20; + + uint256 public erc20AmountOut; + uint256 public nativeAmountOut; + + error NativeTransferFailed(); + error InvalidNativeValue(); + error UnexpectedNativeValue(); + + receive() external payable { } + + function setERC20AmountOut(uint256 amountOut_) external { + erc20AmountOut = amountOut_; + } + + function setNativeAmountOut(uint256 amountOut_) external { + nativeAmountOut = amountOut_; + } + + function swapNativeForERC20(IERC20 tokenOut_, address recipient_) external payable { + tokenOut_.safeTransfer(recipient_, erc20AmountOut); + } + + function swapERC20ForNative(IERC20 tokenIn_, uint256 amountIn_, address payable recipient_) external { + tokenIn_.safeTransferFrom(msg.sender, address(this), amountIn_); + (bool success_,) = recipient_.call{ value: nativeAmountOut }(""); + if (!success_) revert NativeTransferFailed(); + } + + /// @notice IMetaSwap-compatible entry point with intentionally flexible aggregator and route data. + function swap(string calldata, IERC20 tokenFrom_, uint256 amount_, bytes calldata route_) external payable { + (IERC20 tokenOut_, uint256 amountOut_) = abi.decode(route_, (IERC20, uint256)); + + if (address(tokenFrom_) == address(0)) { + if (msg.value != amount_) revert InvalidNativeValue(); + } else { + if (msg.value != 0) revert UnexpectedNativeValue(); + tokenFrom_.safeTransferFrom(msg.sender, address(this), amount_); + } + + if (address(tokenOut_) == address(0)) { + (bool success_,) = msg.sender.call{ value: amountOut_ }(""); + if (!success_) revert NativeTransferFailed(); + } else { + tokenOut_.safeTransfer(msg.sender, amountOut_); + } + } +}