diff --git a/documents/CaveatEnforcers.md b/documents/CaveatEnforcers.md index de8093b6..30ed093a 100644 --- a/documents/CaveatEnforcers.md +++ b/documents/CaveatEnforcers.md @@ -46,6 +46,64 @@ less than the approved amount. Add `LimitedCallsEnforcer` for one-shot orders an --- +### MetaSwapOneShotLimitOrderEnforcer + +Combines `MetaSwapBatchCalldataEnforcer`, `LimitedCallsEnforcer(1)`, and minimum-output balance enforcement into one +purpose-specific enforcer. A successful order 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 using existing allowance: `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) | approvalPolicy(1) | +tokenOut(20) | recipient(20) | tokenOutMin(32) +``` + +`address(0)` represents the native token. Native input requires an approval policy of zero. For ERC-20 input, the signed +policy is a bitmask: `0x01` allows using an existing allowance, `0x02` allows `approve(amount)`, and `0x04` allows +`approve(0) + approve(amount)`. Combine bits to authorize multiple shapes. The batch length selects a signed shape; caveat +args are not used. + +Example terms for an ERC-20 order that permits every approval shape: + +```solidity +bytes memory terms = abi.encodePacked( + metaSwap, + tokenIn, + tokenInAmount, + uint8(0x01 | 0x02 | 0x04), + tokenOut, + recipient, + tokenOutMin +); +``` + +The enforcer stores the pre-execution output balance, execution lock, and consumed marker in one storage slot keyed by +the DelegationManager and delegation hash. This avoids separate call-count, lock, and balance-cache mappings. + +#### 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/script/DeployCaveatEnforcers.s.sol b/script/DeployCaveatEnforcers.s.sol index f7ab2c1f..fc5297cb 100644 --- a/script/DeployCaveatEnforcers.s.sol +++ b/script/DeployCaveatEnforcers.s.sol @@ -27,6 +27,7 @@ import { IdEnforcer } from "../src/enforcers/IdEnforcer.sol"; import { LimitedCallsEnforcer } from "../src/enforcers/LimitedCallsEnforcer.sol"; import { LogicalOrWrapperEnforcer } from "../src/enforcers/LogicalOrWrapperEnforcer.sol"; import { MetaSwapBatchCalldataEnforcer } from "../src/enforcers/MetaSwapBatchCalldataEnforcer.sol"; +import { MetaSwapOneShotLimitOrderEnforcer } from "../src/enforcers/MetaSwapOneShotLimitOrderEnforcer.sol"; import { MultiTokenPeriodEnforcer } from "../src/enforcers/MultiTokenPeriodEnforcer.sol"; import { NativeBalanceChangeEnforcer } from "../src/enforcers/NativeBalanceChangeEnforcer.sol"; import { NativeTokenPaymentEnforcer } from "../src/enforcers/NativeTokenPaymentEnforcer.sol"; @@ -137,6 +138,9 @@ contract DeployCaveatEnforcers is Script { deployedAddress = address(new MetaSwapBatchCalldataEnforcer{ salt: salt }()); console2.log("MetaSwapBatchCalldataEnforcer: %s", deployedAddress); + deployedAddress = address(new MetaSwapOneShotLimitOrderEnforcer{ salt: salt }()); + console2.log("MetaSwapOneShotLimitOrderEnforcer: %s", deployedAddress); + deployedAddress = address(new MultiTokenPeriodEnforcer{ salt: salt }()); console2.log("MultiTokenPeriodEnforcer: %s", deployedAddress); diff --git a/src/enforcers/MetaSwapOneShotLimitOrderEnforcer.sol b/src/enforcers/MetaSwapOneShotLimitOrderEnforcer.sol new file mode 100644 index 00000000..df2c8fe9 --- /dev/null +++ b/src/enforcers/MetaSwapOneShotLimitOrderEnforcer.sol @@ -0,0 +1,261 @@ +// 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 MetaSwapOneShotLimitOrderEnforcer + * @notice Authorizes one MetaSwap order with an exact input and a minimum recipient balance increase. + * @dev The enforcer 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 policy selects which ERC-20 shapes are allowed. MetaSwap's dynamic `aggregatorId` and route `data` + * remain unrestricted. The configured MetaSwap contract and its adapters must therefore be trusted. + */ +contract MetaSwapOneShotLimitOrderEnforcer is CaveatEnforcer { + using ExecutionLib for bytes; + + struct Terms { + address metaSwap; + address tokenIn; + uint256 tokenInAmount; + uint8 approvalPolicy; + address tokenOut; + address recipient; + uint256 tokenOutMin; + } + + /// @notice Allows an ERC-20 swap to use an existing allowance. + uint8 public constant ALLOW_SKIP_APPROVAL = 1; + + /// @notice Allows `approve(tokenInAmount)` before the swap. + uint8 public constant ALLOW_APPROVAL = 2; + + /// @notice Allows `approve(0)` followed by `approve(tokenInAmount)` before the swap. + uint8 public constant ALLOW_RESET_APPROVAL = 4; + + uint8 private constant ALL_APPROVAL_MODES = ALLOW_SKIP_APPROVAL | ALLOW_APPROVAL | ALLOW_RESET_APPROVAL; + uint256 private constant TERMS_LENGTH = 145; + uint256 private constant APPROVE_CALL_LENGTH = 68; + uint256 private constant SWAP_CALL_MIN_LENGTH = 132; + uint256 private constant CONSUMED = type(uint256).max; + uint256 private constant MAX_CACHEABLE_BALANCE = CONSUMED - 1; + + /** + * @dev One slot represents the complete lifecycle: + * - `0`: unused + * - `balanceBefore + 1`: executing and balance cached + * - `type(uint256).max`: consumed + */ + mapping(bytes32 orderKey => uint256 state) public orderStates; + + /** + * @notice Emitted after an order satisfies its minimum output and is permanently consumed. + * @param delegationManager DelegationManager that redeemed the order. + * @param delegationHash Hash identifying the signed delegation. + * @param redeemer Address that submitted the redemption. + */ + event OrderConsumed(address indexed delegationManager, bytes32 indexed delegationHash, address indexed redeemer); + + /** + * @notice Returns the storage key used to isolate an order. + * @param delegationManager_ DelegationManager that redeems the delegation. + * @param delegationHash_ Hash identifying the delegation. + */ + function getOrderKey(address delegationManager_, bytes32 delegationHash_) external pure returns (bytes32) { + return _getOrderKey(delegationManager_, delegationHash_); + } + + /** + * @notice Validates the batch, caches the output balance, and locks the order against reuse. + * @param terms_ Packed order 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 orderKey_ = _getOrderKey(msg.sender, delegationHash_); + require(orderStates[orderKey_] == 0, "MetaSwapOneShotLimitOrderEnforcer:order-already-used"); + + uint256 balanceBefore_ = _balanceOf(termsInfo_.tokenOut, termsInfo_.recipient); + require(balanceBefore_ < MAX_CACHEABLE_BALANCE, "MetaSwapOneShotLimitOrderEnforcer:balance-overflow"); + orderStates[orderKey_] = balanceBefore_ + 1; + } + + /** + * @notice Enforces the minimum output and permanently consumes the successful order. + * @param terms_ Packed order 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, "MetaSwapOneShotLimitOrderEnforcer:invalid-terms"); + + bytes32 orderKey_ = _getOrderKey(msg.sender, delegationHash_); + uint256 cachedState_ = orderStates[orderKey_]; + require(cachedState_ != 0 && cachedState_ != CONSUMED, "MetaSwapOneShotLimitOrderEnforcer:order-not-executing"); + + address tokenOut_ = address(bytes20(terms_[73:93])); + address recipient_ = address(bytes20(terms_[93:113])); + uint256 tokenOutMin_ = uint256(bytes32(terms_[113:145])); + uint256 balanceBefore_ = cachedState_ - 1; + uint256 balanceAfter_ = _balanceOf(tokenOut_, recipient_); + + require( + balanceAfter_ >= balanceBefore_ && balanceAfter_ - balanceBefore_ >= tokenOutMin_, + "MetaSwapOneShotLimitOrderEnforcer:insufficient-output" + ); + + orderStates[orderKey_] = CONSUMED; + emit OrderConsumed(msg.sender, delegationHash_, redeemer_); + } + + /** + * @notice Decodes and validates signed order terms. + * @param terms_ Packed as + * `metaSwap(20) | tokenIn(20) | tokenInAmount(32) | approvalPolicy(1) | tokenOut(20) | recipient(20) | tokenOutMin(32)`. + */ + function getTermsInfo(bytes calldata terms_) public pure returns (Terms memory termsInfo_) { + require(terms_.length == TERMS_LENGTH, "MetaSwapOneShotLimitOrderEnforcer:invalid-terms"); + + termsInfo_.metaSwap = address(bytes20(terms_[0:20])); + termsInfo_.tokenIn = address(bytes20(terms_[20:40])); + termsInfo_.tokenInAmount = uint256(bytes32(terms_[40:72])); + termsInfo_.approvalPolicy = 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, + "MetaSwapOneShotLimitOrderEnforcer:invalid-terms" + ); + + if (termsInfo_.tokenIn == address(0)) { + require(termsInfo_.approvalPolicy == 0, "MetaSwapOneShotLimitOrderEnforcer:invalid-approval-policy"); + } else { + require( + termsInfo_.approvalPolicy != 0 && termsInfo_.approvalPolicy <= ALL_APPROVAL_MODES, + "MetaSwapOneShotLimitOrderEnforcer:invalid-approval-policy" + ); + } + } + + function _validateExecutions(Execution[] calldata executions_, Terms memory termsInfo_) private pure { + if (termsInfo_.tokenIn == address(0)) { + require(executions_.length == 1, "MetaSwapOneShotLimitOrderEnforcer:invalid-batch-length"); + _validateSwap(executions_[0], termsInfo_.metaSwap, address(0), termsInfo_.tokenInAmount, termsInfo_.tokenInAmount); + return; + } + + uint256 swapIndex_ = 0; + if (executions_.length == 1) { + require( + (termsInfo_.approvalPolicy & ALLOW_SKIP_APPROVAL) != 0, + "MetaSwapOneShotLimitOrderEnforcer:approval-shape-not-allowed" + ); + } else if (executions_.length == 2) { + require( + (termsInfo_.approvalPolicy & ALLOW_APPROVAL) != 0, "MetaSwapOneShotLimitOrderEnforcer:approval-shape-not-allowed" + ); + _validateApproval(executions_[0], termsInfo_.tokenIn, termsInfo_.metaSwap, termsInfo_.tokenInAmount); + swapIndex_ = 1; + } else { + require( + executions_.length == 3 && (termsInfo_.approvalPolicy & ALLOW_RESET_APPROVAL) != 0, + "MetaSwapOneShotLimitOrderEnforcer:approval-shape-not-allowed" + ); + _validateApproval(executions_[0], termsInfo_.tokenIn, termsInfo_.metaSwap, 0); + _validateApproval(executions_[1], termsInfo_.tokenIn, termsInfo_.metaSwap, termsInfo_.tokenInAmount); + swapIndex_ = 2; + } + + _validateSwap(executions_[swapIndex_], termsInfo_.metaSwap, termsInfo_.tokenIn, termsInfo_.tokenInAmount, 0); + } + + function _validateApproval( + Execution calldata execution_, + address tokenIn_, + address metaSwap_, + uint256 expectedAmount_ + ) + private + pure + { + bytes calldata callData_ = execution_.callData; + if ( + execution_.target != tokenIn_ || execution_.value != 0 || callData_.length != APPROVE_CALL_LENGTH + || bytes4(callData_[0:4]) != IERC20.approve.selector + || address(uint160(uint256(bytes32(callData_[4:36])))) != metaSwap_ + || uint256(bytes32(callData_[36:68])) != expectedAmount_ + ) { + revert("MetaSwapOneShotLimitOrderEnforcer: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("MetaSwapOneShotLimitOrderEnforcer:invalid-swap"); + } + } + + function _balanceOf(address token_, address recipient_) private view returns (uint256) { + return token_ == address(0) ? recipient_.balance : IERC20(token_).balanceOf(recipient_); + } + + function _getOrderKey(address delegationManager_, bytes32 delegationHash_) private pure returns (bytes32) { + return keccak256(abi.encode(delegationManager_, delegationHash_)); + } +} diff --git a/test/enforcers/MetaSwapOneShotLimitOrderEnforcer.t.sol b/test/enforcers/MetaSwapOneShotLimitOrderEnforcer.t.sol new file mode 100644 index 00000000..39cfb3a9 --- /dev/null +++ b/test/enforcers/MetaSwapOneShotLimitOrderEnforcer.t.sol @@ -0,0 +1,632 @@ +// 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 { MetaSwapOneShotLimitOrderEnforcer } from "../../src/enforcers/MetaSwapOneShotLimitOrderEnforcer.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 OneShotMetaSwapMock 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_, "OneShotMetaSwapMock:invalid-native-value"); + } else { + require(msg.value == 0, "OneShotMetaSwapMock:unexpected-native-value"); + tokenFrom_.safeTransferFrom(msg.sender, address(this), amount_); + } + + if (address(tokenOut_) == address(0)) { + (bool success_,) = msg.sender.call{ value: amountOut_ }(""); + require(success_, "OneShotMetaSwapMock: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 MaxBalanceToken { + uint256 private immutable BALANCE; + + constructor(uint256 balance_) { + BALANCE = balance_; + } + + function balanceOf(address) external view returns (uint256) { + return BALANCE; + } +} + +contract MetaSwapOneShotLimitOrderEnforcerTest 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; + uint8 internal constant SKIP = 1; + uint8 internal constant APPROVE = 2; + uint8 internal constant RESET = 4; + uint8 internal constant ALL_MODES = SKIP | APPROVE | RESET; + + MetaSwapOneShotLimitOrderEnforcer internal enforcer; + BasicERC20 internal tokenIn; + BasicERC20 internal tokenOut; + OneShotMetaSwapMock internal metaSwap; + address internal alice; + address internal relayer; + + event OrderConsumed(address indexed delegationManager, bytes32 indexed delegationHash, address indexed redeemer); + + function setUp() public override { + super.setUp(); + + enforcer = new MetaSwapOneShotLimitOrderEnforcer(); + tokenIn = new BasicERC20(address(this), "Token In", "TIN", 0); + tokenOut = new BasicERC20(address(this), "Token Out", "TOUT", 0); + metaSwap = new OneShotMetaSwapMock(); + 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_getTermsInfoDecodesERC20Order() public { + MetaSwapOneShotLimitOrderEnforcer.Terms memory info_ = + enforcer.getTermsInfo(_terms(address(tokenIn), ALL_MODES, address(tokenOut), alice)); + + assertEq(info_.metaSwap, address(metaSwap)); + assertEq(info_.tokenIn, address(tokenIn)); + assertEq(info_.tokenInAmount, TOKEN_IN_AMOUNT); + assertEq(info_.approvalPolicy, ALL_MODES); + assertEq(info_.tokenOut, address(tokenOut)); + assertEq(info_.recipient, alice); + assertEq(info_.tokenOutMin, TOKEN_OUT_MIN); + } + + function test_getTermsInfoDecodesNativeInputOrder() public { + MetaSwapOneShotLimitOrderEnforcer.Terms memory info_ = + enforcer.getTermsInfo(_terms(address(0), 0, address(tokenOut), alice)); + + assertEq(info_.tokenIn, address(0)); + assertEq(info_.approvalPolicy, 0); + } + + function test_getOrderKeyUsesDelegationManagerAndDelegationHash() public { + bytes32 delegationHash_ = keccak256("delegation"); + assertEq( + enforcer.getOrderKey(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_acceptsEverySignedERC20ApprovalShape() public { + bytes memory terms_ = _terms(address(tokenIn), ALL_MODES, address(tokenOut), alice); + _before(terms_, _erc20Executions(0, address(tokenIn), TOKEN_IN_AMOUNT, "skip", hex""), keccak256("skip")); + _before(terms_, _erc20Executions(1, address(tokenIn), TOKEN_IN_AMOUNT, "approve", hex""), keccak256("approve")); + _before(terms_, _erc20Executions(2, address(tokenIn), TOKEN_IN_AMOUNT, "reset", hex""), keccak256("reset")); + } + + function test_acceptsNativeInputShape() public { + _before( + _terms(address(0), 0, 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("MetaSwapOneShotLimitOrderEnforcer:invalid-terms"); + enforcer.getTermsInfo(new bytes(144)); + + vm.expectRevert("MetaSwapOneShotLimitOrderEnforcer:invalid-terms"); + enforcer.getTermsInfo(new bytes(146)); + } + + function test_revertsForInvalidRequiredTerms() public { + vm.expectRevert("MetaSwapOneShotLimitOrderEnforcer:invalid-terms"); + enforcer.getTermsInfo( + _rawTerms(address(0), address(tokenIn), TOKEN_IN_AMOUNT, APPROVE, address(tokenOut), alice, TOKEN_OUT_MIN) + ); + + vm.expectRevert("MetaSwapOneShotLimitOrderEnforcer:invalid-terms"); + enforcer.getTermsInfo(_rawTerms(address(metaSwap), address(tokenIn), 0, APPROVE, address(tokenOut), alice, TOKEN_OUT_MIN)); + + vm.expectRevert("MetaSwapOneShotLimitOrderEnforcer:invalid-terms"); + enforcer.getTermsInfo( + _rawTerms(address(metaSwap), address(tokenIn), TOKEN_IN_AMOUNT, APPROVE, address(tokenOut), address(0), TOKEN_OUT_MIN) + ); + + vm.expectRevert("MetaSwapOneShotLimitOrderEnforcer:invalid-terms"); + enforcer.getTermsInfo(_rawTerms(address(metaSwap), address(tokenIn), TOKEN_IN_AMOUNT, APPROVE, address(tokenOut), alice, 0)); + + vm.expectRevert("MetaSwapOneShotLimitOrderEnforcer:invalid-terms"); + enforcer.getTermsInfo( + _rawTerms(address(metaSwap), address(tokenIn), TOKEN_IN_AMOUNT, APPROVE, address(tokenIn), alice, TOKEN_OUT_MIN) + ); + } + + function test_revertsForInvalidApprovalPolicy() public { + vm.expectRevert("MetaSwapOneShotLimitOrderEnforcer:invalid-approval-policy"); + enforcer.getTermsInfo(_terms(address(0), APPROVE, address(tokenOut), alice)); + + vm.expectRevert("MetaSwapOneShotLimitOrderEnforcer:invalid-approval-policy"); + enforcer.getTermsInfo(_terms(address(tokenIn), 0, address(tokenOut), alice)); + + vm.expectRevert("MetaSwapOneShotLimitOrderEnforcer:invalid-approval-policy"); + enforcer.getTermsInfo(_terms(address(tokenIn), 8, address(tokenOut), alice)); + } + + function test_revertsWhenApprovalShapeIsNotSigned() public { + vm.expectRevert("MetaSwapOneShotLimitOrderEnforcer: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("MetaSwapOneShotLimitOrderEnforcer: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("MetaSwapOneShotLimitOrderEnforcer: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("MetaSwapOneShotLimitOrderEnforcer:approval-shape-not-allowed"); + _before(_terms(address(tokenIn), ALL_MODES, address(tokenOut), alice), empty_, bytes32(0)); + + Execution[] memory tooLong_ = new Execution[](4); + vm.expectRevert("MetaSwapOneShotLimitOrderEnforcer:approval-shape-not-allowed"); + _before(_terms(address(tokenIn), ALL_MODES, address(tokenOut), alice), tooLong_, bytes32(0)); + + Execution[] memory nativeTooLong_ = new Execution[](2); + vm.expectRevert("MetaSwapOneShotLimitOrderEnforcer:invalid-batch-length"); + _before(_terms(address(0), 0, 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.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("MetaSwapOneShotLimitOrderEnforcer: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_); + + _expectInvalidSwap(_erc20Executions(1, makeAddr("OtherToken"), TOKEN_IN_AMOUNT, "route", hex"")); + _expectInvalidSwap(_erc20Executions(1, address(tokenIn), TOKEN_IN_AMOUNT - 1, "route", hex"")); + } + + function test_revertsForNativeSwapWithWrongValue() public { + Execution[] memory executions_ = _nativeExecutions(TOKEN_IN_AMOUNT - 1, address(tokenOut), TOKEN_OUT_AMOUNT); + vm.expectRevert("MetaSwapOneShotLimitOrderEnforcer:invalid-swap"); + _before(_terms(address(0), 0, address(tokenOut), alice), executions_, bytes32(0)); + } + + function test_beforeHookCachesBalanceAndLocksOrder() public { + tokenOut.mint(alice, 10); + bytes32 delegationHash_ = keccak256("order"); + bytes32 orderKey_ = enforcer.getOrderKey(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 + ); + + assertEq(enforcer.orderStates(orderKey_), 11); + + vm.prank(address(delegationManager)); + vm.expectRevert("MetaSwapOneShotLimitOrderEnforcer:order-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_beforeHookRevertsForMaximumBalance() public { + _expectBalanceOverflow(new MaxBalanceToken(type(uint256).max), keccak256("max-balance")); + _expectBalanceOverflow(new MaxBalanceToken(type(uint256).max - 1), keccak256("reserved-sentinel")); + } + + function _expectBalanceOverflow(MaxBalanceToken token_, bytes32 delegationHash_) private { + vm.prank(address(delegationManager)); + vm.expectRevert("MetaSwapOneShotLimitOrderEnforcer:balance-overflow"); + enforcer.beforeHook( + _rawTerms(address(metaSwap), address(tokenIn), TOKEN_IN_AMOUNT, APPROVE, address(token_), alice, TOKEN_OUT_MIN), + hex"", + batchDefaultMode, + ExecutionLib.encodeBatch(_erc20Executions(1, address(tokenIn), TOKEN_IN_AMOUNT, "route", hex"")), + delegationHash_, + alice, + relayer + ); + } + + function test_afterHookRevertsWithoutActiveOrder() public { + vm.prank(address(delegationManager)); + vm.expectRevert("MetaSwapOneShotLimitOrderEnforcer:order-not-executing"); + enforcer.afterHook( + _terms(address(tokenIn), APPROVE, address(tokenOut), alice), + hex"", + batchDefaultMode, + hex"", + keccak256("inactive"), + alice, + relayer + ); + } + + function test_afterHookRevertsForInvalidTermsLength() public { + vm.expectRevert("MetaSwapOneShotLimitOrderEnforcer:invalid-terms"); + enforcer.afterHook(new bytes(144), hex"", batchDefaultMode, hex"", bytes32(0), alice, relayer); + } + + function test_afterHookConsumesOrderAndEmitsEvent() public { + bytes32 delegationHash_ = keccak256("successful-order"); + 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 OrderConsumed(address(delegationManager), delegationHash_, relayer); + enforcer.afterHook(terms_, hex"", batchDefaultMode, hex"", delegationHash_, alice, relayer); + + assertEq(enforcer.orderStates(enforcer.getOrderKey(address(delegationManager), delegationHash_)), type(uint256).max); + } + + function test_afterHookRevertsForInsufficientOutput() public { + bytes32 delegationHash_ = keccak256("insufficient-order"); + 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("MetaSwapOneShotLimitOrderEnforcer:insufficient-output"); + enforcer.afterHook(terms_, hex"", batchDefaultMode, hex"", delegationHash_, alice, relayer); + } + + function test_redeemsERC20ApprovalOrder() 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_redeemsERC20ResetApprovalOrder() 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_redeemsERC20OrderUsingExistingAllowance() 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_redeemsNativeInputOrder() public { + uint256 nativeBefore_ = alice.balance; + _redeem( + _sign(_terms(address(0), 0, 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("MetaSwapOneShotLimitOrderEnforcer:insufficient-output"); + _redeem(delegation_, insufficient_); + + assertEq(tokenIn.balanceOf(alice), 1_000 ether); + assertEq(tokenOut.balanceOf(alice), 0); + assertEq(enforcer.orderStates(enforcer.getOrderKey(address(delegationManager), delegationHash_)), 0); + + _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_successfulOrderCannotBeRedeemedAgain() 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("MetaSwapOneShotLimitOrderEnforcer:order-already-used"); + _redeem(delegation_, executions_); + } + + function _expectInvalidApproval(Execution[] memory executions_) private { + vm.expectRevert("MetaSwapOneShotLimitOrderEnforcer:invalid-approval"); + _before(_terms(address(tokenIn), APPROVE, address(tokenOut), alice), executions_, bytes32(0)); + } + + function _expectInvalidSwap(Execution[] memory executions_) private { + vm.expectRevert("MetaSwapOneShotLimitOrderEnforcer:invalid-swap"); + _before(_terms(address(tokenIn), APPROVE, address(tokenOut), alice), executions_, bytes32(0)); + } + + function _terms(address tokenIn_, uint8 policy_, address tokenOut_, address recipient_) private view returns (bytes memory) { + return _rawTerms(address(metaSwap), tokenIn_, TOKEN_IN_AMOUNT, policy_, tokenOut_, recipient_, TOKEN_OUT_MIN); + } + + function _rawTerms( + address metaSwap_, + address tokenIn_, + uint256 tokenInAmount_, + uint8 policy_, + address tokenOut_, + address recipient_, + uint256 tokenOutMin_ + ) + private + pure + returns (bytes memory) + { + return abi.encodePacked(metaSwap_, tokenIn_, tokenInAmount_, policy_, 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 _before(bytes memory terms_, Execution[] memory executions_, bytes32 delegationHash_) private { + vm.prank(address(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)); + } +}