diff --git a/script/DeployCaveatEnforcers.s.sol b/script/DeployCaveatEnforcers.s.sol index 629da134..9cc77a45 100644 --- a/script/DeployCaveatEnforcers.s.sol +++ b/script/DeployCaveatEnforcers.s.sol @@ -13,6 +13,7 @@ import { ArgsEqualityCheckEnforcer } from "../src/enforcers/ArgsEqualityCheckEnf import { BlockNumberEnforcer } from "../src/enforcers/BlockNumberEnforcer.sol"; import { DeployedEnforcer } from "../src/enforcers/DeployedEnforcer.sol"; import { ERC20BalanceChangeEnforcer } from "../src/enforcers/ERC20BalanceChangeEnforcer.sol"; +import { ERC20SwapEnforcer } from "../src/enforcers/ERC20SwapEnforcer.sol"; import { ERC20TransferAmountEnforcer } from "../src/enforcers/ERC20TransferAmountEnforcer.sol"; import { ERC20StreamingEnforcer } from "../src/enforcers/ERC20StreamingEnforcer.sol"; import { ERC20PeriodTransferEnforcer } from "../src/enforcers/ERC20PeriodTransferEnforcer.sol"; @@ -28,6 +29,7 @@ import { LimitedCallsEnforcer } from "../src/enforcers/LimitedCallsEnforcer.sol" import { LogicalOrWrapperEnforcer } from "../src/enforcers/LogicalOrWrapperEnforcer.sol"; import { MultiTokenPeriodEnforcer } from "../src/enforcers/MultiTokenPeriodEnforcer.sol"; import { NativeBalanceChangeEnforcer } from "../src/enforcers/NativeBalanceChangeEnforcer.sol"; +import { NativeSwapEnforcer } from "../src/enforcers/NativeSwapEnforcer.sol"; import { NativeTokenPaymentEnforcer } from "../src/enforcers/NativeTokenPaymentEnforcer.sol"; import { NativeTokenPeriodTransferEnforcer } from "../src/enforcers/NativeTokenPeriodTransferEnforcer.sol"; import { NativeTokenStreamingEnforcer } from "../src/enforcers/NativeTokenStreamingEnforcer.sol"; @@ -94,6 +96,9 @@ contract DeployCaveatEnforcers is Script { deployedAddress = address(new ERC20BalanceChangeEnforcer{ salt: salt }()); console2.log("ERC20BalanceChangeEnforcer: %s", deployedAddress); + deployedAddress = address(new ERC20SwapEnforcer{ salt: salt }()); + console2.log("ERC20SwapEnforcer: %s", deployedAddress); + deployedAddress = address(new ERC20TransferAmountEnforcer{ salt: salt }()); console2.log("ERC20TransferAmountEnforcer: %s", deployedAddress); @@ -139,6 +144,9 @@ contract DeployCaveatEnforcers is Script { deployedAddress = address(new NativeBalanceChangeEnforcer{ salt: salt }()); console2.log("NativeBalanceChangeEnforcer: %s", deployedAddress); + deployedAddress = address(new NativeSwapEnforcer{ salt: salt }()); + console2.log("NativeSwapEnforcer: %s", deployedAddress); + address argsEqualityCheckEnforcer = address(new ArgsEqualityCheckEnforcer{ salt: salt }()); console2.log("ArgsEqualityCheckEnforcer: %s", argsEqualityCheckEnforcer); diff --git a/src/enforcers/ERC20SwapEnforcer.sol b/src/enforcers/ERC20SwapEnforcer.sol new file mode 100644 index 00000000..946b7b57 --- /dev/null +++ b/src/enforcers/ERC20SwapEnforcer.sol @@ -0,0 +1,218 @@ +// 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 { Execution, ModeCode } from "../utils/Types.sol"; + +/** + * @title ERC20SwapEnforcer + * @notice Allows an ERC-20-funded swap through a specified Uniswap Universal Router-compatible contract while + * requiring the recipient to receive a minimum amount of an ERC-20 or native token. + * + * @dev The execution MUST use batch call type and default execution mode. Depending on `_args`, the batch is: + * - Approval included (`_args` empty or `0x00`): + * 1. `sourceToken.approve(swapTarget, amount)`, where `amount > 0`. + * 2. `swapTarget.execute(...)`. + * - Approval skipped (`_args == 0x01`): + * 1. `swapTarget.execute(...)`. + * + * The router calldata after the selector is intentionally unrestricted. Both Universal Router `execute` overloads + * (`execute(bytes,bytes[])` and `execute(bytes,bytes[],uint256)`) are supported. All executions MUST transfer zero + * native value, so native-source swaps require a separate permission. + * + * The destination token is encoded in signed terms. `address(0)` represents the native token. The source token, + * destination token, swap target, recipient, and minimum output amount are all fixed by the delegation terms. + * + * @dev Security considerations: + * - The approved source amount and router calldata are chosen by the redeemer. This enforcer does not cap source + * token expenditure; compose it with an ERC-20 decrease enforcer when a maximum input amount is required. + * - Skipping approval only validates the batch shape. It does not prove which existing router allowance is consumed. + * - The swap target must be trusted. Universal Router commands can perform actions beyond a simple swap. + * - Any unused allowance created by the approval remains after execution. + */ +contract ERC20SwapEnforcer is CaveatEnforcer { + using ExecutionLib for bytes; + + ////////////////////////////// Constants ////////////////////////////// + + /// @dev `execute(bytes,bytes[])`. + bytes4 public constant EXECUTE_SELECTOR = 0x24856bc3; + + /// @dev `execute(bytes,bytes[],uint256)`. + bytes4 public constant EXECUTE_WITH_DEADLINE_SELECTOR = 0x3593564c; + + ////////////////////////////// Structs ////////////////////////////// + + struct TermsData { + address sourceToken; + address destinationToken; + address swapTarget; + address recipient; + uint256 minAmountOut; + } + + ////////////////////////////// State ////////////////////////////// + + mapping(bytes32 hashKey => uint256 balance) public balanceCache; + mapping(bytes32 hashKey => bool lock) public isLocked; + + ////////////////////////////// External Methods ////////////////////////////// + + /** + * @notice Generates the key used to isolate an active swap validation. + */ + function getHashKey(address _caller, bytes32 _delegationHash) external pure returns (bytes32) { + return _getHashKey(_caller, _delegationHash); + } + + ////////////////////////////// Public Methods ////////////////////////////// + + /** + * @notice Validates the swap batch and caches the recipient's destination-token balance. + * @param _terms 112 packed bytes: + * - source token (20 bytes; MUST NOT be address(0)) + * - destination token (20 bytes; address(0) means native token) + * - swap target (20 bytes) + * - output recipient (20 bytes) + * - minimum destination amount (32 bytes) + * @param _args Empty or `0x00` when approval is included; `0x01` when approval is skipped. + * @param _mode MUST be batch call type and default execution mode. + * @param _executionCallData The approval-and-swap or swap-only batch. + * @param _delegationHash The hash of the delegation carrying this caveat. + */ + function beforeHook( + bytes calldata _terms, + bytes calldata _args, + ModeCode _mode, + bytes calldata _executionCallData, + bytes32 _delegationHash, + address, + address + ) + public + override + onlyBatchCallTypeMode(_mode) + onlyDefaultExecutionMode(_mode) + { + TermsData memory terms_ = getTermsInfo(_terms); + bool skipApproval_ = getArgsInfo(_args); + Execution[] calldata executions_ = _executionCallData.decodeBatch(); + + uint256 swapIndex_; + if (skipApproval_) { + require(executions_.length == 1, "ERC20SwapEnforcer:invalid-batch-size"); + } else { + require(executions_.length == 2, "ERC20SwapEnforcer:invalid-batch-size"); + _validateApproval(executions_[0], terms_); + swapIndex_ = 1; + } + _validateSwap(executions_[swapIndex_], terms_.swapTarget); + + bytes32 hashKey_ = _getHashKey(msg.sender, _delegationHash); + require(!isLocked[hashKey_], "ERC20SwapEnforcer:enforcer-is-locked"); + isLocked[hashKey_] = true; + balanceCache[hashKey_] = _getBalance(terms_.destinationToken, terms_.recipient); + } + + /** + * @notice Requires the recipient's destination-token balance to have increased by at least the signed minimum. + */ + function afterHook( + bytes calldata _terms, + bytes calldata, + ModeCode, + bytes calldata, + bytes32 _delegationHash, + address, + address + ) + public + override + { + TermsData memory terms_ = getTermsInfo(_terms); + bytes32 hashKey_ = _getHashKey(msg.sender, _delegationHash); + require(isLocked[hashKey_], "ERC20SwapEnforcer:enforcer-not-locked"); + + uint256 balanceBefore_ = balanceCache[hashKey_]; + uint256 balanceAfter_ = _getBalance(terms_.destinationToken, terms_.recipient); + + delete isLocked[hashKey_]; + delete balanceCache[hashKey_]; + + require( + balanceAfter_ >= balanceBefore_ && balanceAfter_ - balanceBefore_ >= terms_.minAmountOut, + "ERC20SwapEnforcer:insufficient-output" + ); + } + + /** + * @notice Decodes and validates this enforcer's signed terms. + */ + function getTermsInfo(bytes calldata _terms) public pure returns (TermsData memory termsData_) { + require(_terms.length == 112, "ERC20SwapEnforcer:invalid-terms-length"); + + termsData_.sourceToken = address(bytes20(_terms[0:20])); + termsData_.destinationToken = address(bytes20(_terms[20:40])); + termsData_.swapTarget = address(bytes20(_terms[40:60])); + termsData_.recipient = address(bytes20(_terms[60:80])); + termsData_.minAmountOut = uint256(bytes32(_terms[80:112])); + + require(termsData_.sourceToken != address(0), "ERC20SwapEnforcer:invalid-source-token"); + require(termsData_.swapTarget != address(0), "ERC20SwapEnforcer:invalid-swap-target"); + require(termsData_.recipient != address(0), "ERC20SwapEnforcer:invalid-recipient"); + require(termsData_.minAmountOut != 0, "ERC20SwapEnforcer:zero-min-amount-out"); + require( + termsData_.destinationToken == address(0) || termsData_.sourceToken != termsData_.destinationToken, + "ERC20SwapEnforcer:identical-tokens" + ); + } + + /** + * @notice Decodes whether the approval execution should be skipped. + * @dev Empty args and `0x00` include approval. `0x01` skips approval. + */ + function getArgsInfo(bytes calldata _args) public pure returns (bool skipApproval_) { + if (_args.length == 0) return false; + require(_args.length == 1, "ERC20SwapEnforcer:invalid-args-length"); + uint8 skipApprovalFlag_ = uint8(_args[0]); + require(skipApprovalFlag_ <= 1, "ERC20SwapEnforcer:invalid-args"); + return skipApprovalFlag_ == 1; + } + + ////////////////////////////// Private Methods ////////////////////////////// + + function _validateApproval(Execution calldata _execution, TermsData memory _terms) private pure { + require(_execution.target == _terms.sourceToken, "ERC20SwapEnforcer:invalid-approval-target"); + require(_execution.value == 0, "ERC20SwapEnforcer:invalid-approval-value"); + require(_execution.callData.length == 68, "ERC20SwapEnforcer:invalid-approval-calldata"); + require(bytes4(_execution.callData[0:4]) == IERC20.approve.selector, "ERC20SwapEnforcer:invalid-approval-method"); + + address spender_ = address(uint160(uint256(bytes32(_execution.callData[4:36])))); + uint256 amount_ = uint256(bytes32(_execution.callData[36:68])); + require(spender_ == _terms.swapTarget, "ERC20SwapEnforcer:invalid-approval-spender"); + require(amount_ != 0, "ERC20SwapEnforcer:zero-approval-amount"); + } + + function _validateSwap(Execution calldata _execution, address _swapTarget) private pure { + require(_execution.target == _swapTarget, "ERC20SwapEnforcer:invalid-swap-target"); + require(_execution.value == 0, "ERC20SwapEnforcer:invalid-swap-value"); + require(_execution.callData.length >= 4, "ERC20SwapEnforcer:invalid-swap-calldata"); + + bytes4 selector_ = bytes4(_execution.callData[0:4]); + require( + selector_ == EXECUTE_SELECTOR || selector_ == EXECUTE_WITH_DEADLINE_SELECTOR, "ERC20SwapEnforcer:invalid-swap-method" + ); + } + + function _getBalance(address _token, address _recipient) private view returns (uint256) { + if (_token == address(0)) return _recipient.balance; + return IERC20(_token).balanceOf(_recipient); + } + + function _getHashKey(address _caller, bytes32 _delegationHash) private pure returns (bytes32) { + return keccak256(abi.encode(_caller, _delegationHash)); + } +} diff --git a/src/enforcers/NativeSwapEnforcer.sol b/src/enforcers/NativeSwapEnforcer.sol new file mode 100644 index 00000000..2fbaab3e --- /dev/null +++ b/src/enforcers/NativeSwapEnforcer.sol @@ -0,0 +1,172 @@ +// 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 { ModeCode } from "../utils/Types.sol"; + +/** + * @title NativeSwapEnforcer + * @notice Allows a limited amount of native ETH to be swapped for an ERC-20 token through a specified Uniswap + * Universal Router-compatible contract while requiring a minimum output balance increase. + * + * @dev The execution MUST use single call type and default execution mode. It MUST target the signed swap contract, + * transfer a non-zero native value, and call either Universal Router `execute` overload. Router calldata after the + * selector is intentionally unrestricted. + * + * The signed native allowance is cumulative across every successful use of a delegation. The swap target must be + * trusted because Universal Router commands can perform actions beyond a simple swap. + */ +contract NativeSwapEnforcer is CaveatEnforcer { + using ExecutionLib for bytes; + + ////////////////////////////// Constants ////////////////////////////// + + /// @dev `execute(bytes,bytes[])`. + bytes4 public constant EXECUTE_SELECTOR = 0x24856bc3; + + /// @dev `execute(bytes,bytes[],uint256)`. + bytes4 public constant EXECUTE_WITH_DEADLINE_SELECTOR = 0x3593564c; + + ////////////////////////////// Structs ////////////////////////////// + + struct TermsData { + address destinationToken; + address swapTarget; + address recipient; + uint256 maxAmountIn; + uint256 minAmountOut; + } + + ////////////////////////////// State ////////////////////////////// + + mapping(bytes32 hashKey => uint256 balance) public balanceCache; + mapping(bytes32 hashKey => bool lock) public isLocked; + mapping(address sender => mapping(bytes32 delegationHash => uint256 amount)) public spentMap; + + ////////////////////////////// Events ////////////////////////////// + + event IncreasedSpentMap( + address indexed sender, address indexed redeemer, bytes32 indexed delegationHash, uint256 limit, uint256 spent + ); + + ////////////////////////////// External Methods ////////////////////////////// + + /** + * @notice Generates the key used to isolate an active swap validation. + */ + function getHashKey(address _caller, bytes32 _delegationHash) external pure returns (bytes32) { + return _getHashKey(_caller, _delegationHash); + } + + ////////////////////////////// Public Methods ////////////////////////////// + + /** + * @notice Validates the native-funded router execution and caches the recipient's destination-token balance. + * @param _terms 124 packed bytes: + * - destination ERC-20 token (20 bytes) + * - swap target (20 bytes) + * - output recipient (20 bytes) + * - maximum cumulative native input amount (32 bytes) + * - minimum destination amount (32 bytes) + * @param _mode MUST be single call type and default execution mode. + * @param _executionCallData The native-funded router execution. + * @param _delegationHash The hash of the delegation carrying this caveat. + */ + function beforeHook( + bytes calldata _terms, + bytes calldata, + ModeCode _mode, + bytes calldata _executionCallData, + bytes32 _delegationHash, + address, + address _redeemer + ) + public + override + onlySingleCallTypeMode(_mode) + onlyDefaultExecutionMode(_mode) + { + TermsData memory terms_ = getTermsInfo(_terms); + (address target_, uint256 value_, bytes calldata callData_) = _executionCallData.decodeSingle(); + + require(target_ == terms_.swapTarget, "NativeSwapEnforcer:invalid-swap-target"); + require(value_ != 0, "NativeSwapEnforcer:zero-swap-value"); + require(callData_.length >= 4, "NativeSwapEnforcer:invalid-swap-calldata"); + + bytes4 selector_ = bytes4(callData_[0:4]); + require( + selector_ == EXECUTE_SELECTOR || selector_ == EXECUTE_WITH_DEADLINE_SELECTOR, "NativeSwapEnforcer:invalid-swap-method" + ); + + bytes32 hashKey_ = _getHashKey(msg.sender, _delegationHash); + require(!isLocked[hashKey_], "NativeSwapEnforcer:enforcer-is-locked"); + + uint256 spent_ = spentMap[msg.sender][_delegationHash] + value_; + require(spent_ <= terms_.maxAmountIn, "NativeSwapEnforcer:allowance-exceeded"); + spentMap[msg.sender][_delegationHash] = spent_; + + isLocked[hashKey_] = true; + balanceCache[hashKey_] = IERC20(terms_.destinationToken).balanceOf(terms_.recipient); + + emit IncreasedSpentMap(msg.sender, _redeemer, _delegationHash, terms_.maxAmountIn, spent_); + } + + /** + * @notice Requires the recipient's destination-token balance to have increased by at least the signed minimum. + */ + function afterHook( + bytes calldata _terms, + bytes calldata, + ModeCode, + bytes calldata, + bytes32 _delegationHash, + address, + address + ) + public + override + { + TermsData memory terms_ = getTermsInfo(_terms); + bytes32 hashKey_ = _getHashKey(msg.sender, _delegationHash); + require(isLocked[hashKey_], "NativeSwapEnforcer:enforcer-not-locked"); + + uint256 balanceBefore_ = balanceCache[hashKey_]; + uint256 balanceAfter_ = IERC20(terms_.destinationToken).balanceOf(terms_.recipient); + + delete isLocked[hashKey_]; + delete balanceCache[hashKey_]; + + require( + balanceAfter_ >= balanceBefore_ && balanceAfter_ - balanceBefore_ >= terms_.minAmountOut, + "NativeSwapEnforcer:insufficient-output" + ); + } + + /** + * @notice Decodes and validates this enforcer's signed terms. + */ + function getTermsInfo(bytes calldata _terms) public pure returns (TermsData memory termsData_) { + require(_terms.length == 124, "NativeSwapEnforcer:invalid-terms-length"); + + termsData_.destinationToken = address(bytes20(_terms[0:20])); + termsData_.swapTarget = address(bytes20(_terms[20:40])); + termsData_.recipient = address(bytes20(_terms[40:60])); + termsData_.maxAmountIn = uint256(bytes32(_terms[60:92])); + termsData_.minAmountOut = uint256(bytes32(_terms[92:124])); + + require(termsData_.destinationToken != address(0), "NativeSwapEnforcer:invalid-destination-token"); + require(termsData_.swapTarget != address(0), "NativeSwapEnforcer:invalid-swap-target"); + require(termsData_.recipient != address(0), "NativeSwapEnforcer:invalid-recipient"); + require(termsData_.maxAmountIn != 0, "NativeSwapEnforcer:zero-max-amount-in"); + require(termsData_.minAmountOut != 0, "NativeSwapEnforcer:zero-min-amount-out"); + } + + ////////////////////////////// Private Methods ////////////////////////////// + + function _getHashKey(address _caller, bytes32 _delegationHash) private pure returns (bytes32) { + return keccak256(abi.encode(_caller, _delegationHash)); + } +} diff --git a/test/enforcers/ERC20SwapEnforcer.t.sol b/test/enforcers/ERC20SwapEnforcer.t.sol new file mode 100644 index 00000000..de6d1e22 --- /dev/null +++ b/test/enforcers/ERC20SwapEnforcer.t.sol @@ -0,0 +1,541 @@ +// SPDX-License-Identifier: MIT AND Apache-2.0 +pragma solidity 0.8.23; + +import "forge-std/Test.sol"; +import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import { ExecutionLib } from "@erc7579/lib/ExecutionLib.sol"; + +import { ERC20SwapEnforcer } from "../../src/enforcers/ERC20SwapEnforcer.sol"; +import { ICaveatEnforcer } from "../../src/interfaces/ICaveatEnforcer.sol"; +import { Caveat, Delegation, Execution, ModeCode } from "../../src/utils/Types.sol"; +import { CaveatEnforcerBaseTest } from "./CaveatEnforcerBaseTest.t.sol"; +import { BasicERC20 } from "../utils/BasicERC20.t.sol"; + +contract ERC20SwapEnforcerTest is CaveatEnforcerBaseTest { + ////////////////////////////// Constants ////////////////////////////// + + uint256 internal constant AMOUNT_IN = 10 ether; + uint256 internal constant MIN_AMOUNT_OUT = 5 ether; + uint256 internal constant ROUTER_LIQUIDITY = 100 ether; + bytes4 internal constant EXECUTE_SELECTOR = 0x24856bc3; + bytes4 internal constant EXECUTE_WITH_DEADLINE_SELECTOR = 0x3593564c; + + ////////////////////////////// State ////////////////////////////// + + ERC20SwapEnforcer public enforcer; + MockUniversalRouter public router; + BasicERC20 public sourceToken; + BasicERC20 public destinationToken; + + address public delegator; + address public redeemer; + address public delegationManagerAddress; + + ////////////////////////////// Set up ////////////////////////////// + + function setUp() public override { + super.setUp(); + + delegator = address(users.alice.deleGator); + redeemer = address(users.bob.deleGator); + delegationManagerAddress = address(delegationManager); + + enforcer = new ERC20SwapEnforcer(); + router = new MockUniversalRouter(); + sourceToken = new BasicERC20(address(this), "Source Token", "SRC", 0); + destinationToken = new BasicERC20(address(this), "Destination Token", "DST", 0); + + sourceToken.mint(delegator, ROUTER_LIQUIDITY); + destinationToken.mint(address(router), ROUTER_LIQUIDITY); + vm.deal(address(router), ROUTER_LIQUIDITY); + + vm.label(address(enforcer), "ERC20 Swap Enforcer"); + vm.label(address(router), "Mock Universal Router"); + vm.label(address(sourceToken), "Source Token"); + vm.label(address(destinationToken), "Destination Token"); + } + + ////////////////////////////// Terms and args ////////////////////////////// + + function test_decodesTerms() public { + ERC20SwapEnforcer.TermsData memory terms_ = enforcer.getTermsInfo(_terms(address(destinationToken))); + + assertEq(terms_.sourceToken, address(sourceToken)); + assertEq(terms_.destinationToken, address(destinationToken)); + assertEq(terms_.swapTarget, address(router)); + assertEq(terms_.recipient, delegator); + assertEq(terms_.minAmountOut, MIN_AMOUNT_OUT); + } + + function test_decodesArgs() public { + assertFalse(enforcer.getArgsInfo(hex"")); + assertFalse(enforcer.getArgsInfo(hex"00")); + assertTrue(enforcer.getArgsInfo(hex"01")); + } + + function test_getHashKey() public { + bytes32 delegationHash_ = keccak256("swap"); + assertEq( + enforcer.getHashKey(delegationManagerAddress, delegationHash_), + keccak256(abi.encode(delegationManagerAddress, delegationHash_)) + ); + } + + function test_revertWithInvalidTerms() public { + vm.expectRevert("ERC20SwapEnforcer:invalid-terms-length"); + enforcer.getTermsInfo(hex""); + + vm.expectRevert("ERC20SwapEnforcer:invalid-source-token"); + enforcer.getTermsInfo(_rawTerms(address(0), address(destinationToken), address(router), delegator, MIN_AMOUNT_OUT)); + + vm.expectRevert("ERC20SwapEnforcer:invalid-swap-target"); + enforcer.getTermsInfo(_rawTerms(address(sourceToken), address(destinationToken), address(0), delegator, MIN_AMOUNT_OUT)); + + vm.expectRevert("ERC20SwapEnforcer:invalid-recipient"); + enforcer.getTermsInfo( + _rawTerms(address(sourceToken), address(destinationToken), address(router), address(0), MIN_AMOUNT_OUT) + ); + + vm.expectRevert("ERC20SwapEnforcer:zero-min-amount-out"); + enforcer.getTermsInfo(_rawTerms(address(sourceToken), address(destinationToken), address(router), delegator, 0)); + + vm.expectRevert("ERC20SwapEnforcer:identical-tokens"); + enforcer.getTermsInfo(_rawTerms(address(sourceToken), address(sourceToken), address(router), delegator, MIN_AMOUNT_OUT)); + } + + function test_revertWithInvalidArgs() public { + vm.expectRevert("ERC20SwapEnforcer:invalid-args-length"); + enforcer.getArgsInfo(hex"0000"); + + vm.expectRevert("ERC20SwapEnforcer:invalid-args"); + enforcer.getArgsInfo(hex"02"); + } + + ////////////////////////////// Valid batch shapes ////////////////////////////// + + function test_allowsApprovalAndSwapBatch() public { + bytes32 delegationHash_ = keccak256("approval-and-swap"); + Execution[] memory executions_ = _executions(true, address(destinationToken), MIN_AMOUNT_OUT); + + _before(executions_, hex"", _terms(address(destinationToken)), delegationHash_); + destinationToken.mint(delegator, MIN_AMOUNT_OUT); + _after(_terms(address(destinationToken)), delegationHash_); + + bytes32 hashKey_ = enforcer.getHashKey(delegationManagerAddress, delegationHash_); + assertFalse(enforcer.isLocked(hashKey_)); + assertEq(enforcer.balanceCache(hashKey_), 0); + } + + function test_allowsSwapBatchWithoutApproval() public { + bytes32 delegationHash_ = keccak256("swap-only"); + Execution[] memory executions_ = _executions(false, address(destinationToken), MIN_AMOUNT_OUT); + + _before(executions_, hex"01", _terms(address(destinationToken)), delegationHash_); + destinationToken.mint(delegator, MIN_AMOUNT_OUT); + _after(_terms(address(destinationToken)), delegationHash_); + } + + function test_allowsExecuteWithoutDeadlineOverload() public { + bytes32 delegationHash_ = keccak256("without-deadline"); + Execution[] memory executions_ = _executions(false, address(destinationToken), MIN_AMOUNT_OUT); + executions_[0].callData = _routerCalldata(EXECUTE_SELECTOR, address(destinationToken), delegator, AMOUNT_IN, MIN_AMOUNT_OUT); + + _before(executions_, hex"01", _terms(address(destinationToken)), delegationHash_); + destinationToken.mint(delegator, MIN_AMOUNT_OUT); + _after(_terms(address(destinationToken)), delegationHash_); + } + + function test_allowsNativeDestination() public { + bytes32 delegationHash_ = keccak256("native-destination"); + Execution[] memory executions_ = _executions(true, address(0), MIN_AMOUNT_OUT); + uint256 balanceBefore_ = delegator.balance; + + _before(executions_, hex"", _terms(address(0)), delegationHash_); + vm.deal(delegator, balanceBefore_ + MIN_AMOUNT_OUT); + _after(_terms(address(0)), delegationHash_); + } + + function test_allowsOutputAboveMinimum() public { + bytes32 delegationHash_ = keccak256("extra-output"); + Execution[] memory executions_ = _executions(true, address(destinationToken), MIN_AMOUNT_OUT + 1); + + _before(executions_, hex"", _terms(address(destinationToken)), delegationHash_); + destinationToken.mint(delegator, MIN_AMOUNT_OUT + 1); + _after(_terms(address(destinationToken)), delegationHash_); + } + + ////////////////////////////// Invalid batch shapes ////////////////////////////// + + function test_revertWithWrongBatchSize() public { + Execution[] memory oneExecution_ = _executions(false, address(destinationToken), MIN_AMOUNT_OUT); + vm.expectRevert("ERC20SwapEnforcer:invalid-batch-size"); + _before(oneExecution_, hex"", _terms(address(destinationToken)), keccak256("missing-approval")); + + Execution[] memory twoExecutions_ = _executions(true, address(destinationToken), MIN_AMOUNT_OUT); + vm.expectRevert("ERC20SwapEnforcer:invalid-batch-size"); + _before(twoExecutions_, hex"01", _terms(address(destinationToken)), keccak256("unexpected-approval")); + } + + function test_revertWithInvalidApprovalTarget() public { + Execution[] memory executions_ = _executions(true, address(destinationToken), MIN_AMOUNT_OUT); + executions_[0].target = address(destinationToken); + + vm.expectRevert("ERC20SwapEnforcer:invalid-approval-target"); + _before(executions_, hex"", _terms(address(destinationToken)), keccak256("approval-target")); + } + + function test_revertWithInvalidApprovalValue() public { + Execution[] memory executions_ = _executions(true, address(destinationToken), MIN_AMOUNT_OUT); + executions_[0].value = 1; + + vm.expectRevert("ERC20SwapEnforcer:invalid-approval-value"); + _before(executions_, hex"", _terms(address(destinationToken)), keccak256("approval-value")); + } + + function test_revertWithInvalidApprovalCalldata() public { + Execution[] memory executions_ = _executions(true, address(destinationToken), MIN_AMOUNT_OUT); + executions_[0].callData = abi.encodeWithSelector(IERC20.approve.selector, address(router)); + + vm.expectRevert("ERC20SwapEnforcer:invalid-approval-calldata"); + _before(executions_, hex"", _terms(address(destinationToken)), keccak256("approval-calldata")); + } + + function test_revertWithInvalidApprovalMethod() public { + Execution[] memory executions_ = _executions(true, address(destinationToken), MIN_AMOUNT_OUT); + executions_[0].callData = abi.encodeWithSelector(IERC20.transfer.selector, address(router), AMOUNT_IN); + + vm.expectRevert("ERC20SwapEnforcer:invalid-approval-method"); + _before(executions_, hex"", _terms(address(destinationToken)), keccak256("approval-method")); + } + + function test_revertWithInvalidApprovalSpender() public { + Execution[] memory executions_ = _executions(true, address(destinationToken), MIN_AMOUNT_OUT); + executions_[0].callData = abi.encodeCall(IERC20.approve, (redeemer, AMOUNT_IN)); + + vm.expectRevert("ERC20SwapEnforcer:invalid-approval-spender"); + _before(executions_, hex"", _terms(address(destinationToken)), keccak256("approval-spender")); + } + + function test_revertWithZeroApprovalAmount() public { + Execution[] memory executions_ = _executions(true, address(destinationToken), MIN_AMOUNT_OUT); + executions_[0].callData = abi.encodeCall(IERC20.approve, (address(router), 0)); + + vm.expectRevert("ERC20SwapEnforcer:zero-approval-amount"); + _before(executions_, hex"", _terms(address(destinationToken)), keccak256("approval-amount")); + } + + function test_revertWithInvalidSwapTarget() public { + Execution[] memory executions_ = _executions(true, address(destinationToken), MIN_AMOUNT_OUT); + executions_[1].target = redeemer; + + vm.expectRevert("ERC20SwapEnforcer:invalid-swap-target"); + _before(executions_, hex"", _terms(address(destinationToken)), keccak256("swap-target")); + } + + function test_revertWithNativeSourceValue() public { + Execution[] memory executions_ = _executions(true, address(destinationToken), MIN_AMOUNT_OUT); + executions_[1].value = 1 ether; + + vm.expectRevert("ERC20SwapEnforcer:invalid-swap-value"); + _before(executions_, hex"", _terms(address(destinationToken)), keccak256("native-source")); + } + + function test_revertWithInvalidSwapCalldata() public { + Execution[] memory executions_ = _executions(true, address(destinationToken), MIN_AMOUNT_OUT); + executions_[1].callData = hex"1234"; + + vm.expectRevert("ERC20SwapEnforcer:invalid-swap-calldata"); + _before(executions_, hex"", _terms(address(destinationToken)), keccak256("swap-calldata")); + } + + function test_revertWithInvalidSwapMethod() public { + Execution[] memory executions_ = _executions(true, address(destinationToken), MIN_AMOUNT_OUT); + executions_[1].callData = abi.encodeWithSelector(bytes4(keccak256("notExecute()"))); + + vm.expectRevert("ERC20SwapEnforcer:invalid-swap-method"); + _before(executions_, hex"", _terms(address(destinationToken)), keccak256("swap-method")); + } + + ////////////////////////////// Balance validation ////////////////////////////// + + function test_revertWithInsufficientERC20Output() public { + bytes32 delegationHash_ = keccak256("insufficient-erc20"); + Execution[] memory executions_ = _executions(true, address(destinationToken), MIN_AMOUNT_OUT - 1); + + _before(executions_, hex"", _terms(address(destinationToken)), delegationHash_); + destinationToken.mint(delegator, MIN_AMOUNT_OUT - 1); + + vm.expectRevert("ERC20SwapEnforcer:insufficient-output"); + _after(_terms(address(destinationToken)), delegationHash_); + } + + function test_revertWithInsufficientNativeOutput() public { + bytes32 delegationHash_ = keccak256("insufficient-native"); + Execution[] memory executions_ = _executions(true, address(0), MIN_AMOUNT_OUT - 1); + uint256 balanceBefore_ = delegator.balance; + + _before(executions_, hex"", _terms(address(0)), delegationHash_); + vm.deal(delegator, balanceBefore_ + MIN_AMOUNT_OUT - 1); + + vm.expectRevert("ERC20SwapEnforcer:insufficient-output"); + _after(_terms(address(0)), delegationHash_); + } + + function test_revertWhenEnforcerIsAlreadyLocked() public { + bytes32 delegationHash_ = keccak256("locked"); + Execution[] memory executions_ = _executions(true, address(destinationToken), MIN_AMOUNT_OUT); + + _before(executions_, hex"", _terms(address(destinationToken)), delegationHash_); + + vm.expectRevert("ERC20SwapEnforcer:enforcer-is-locked"); + _before(executions_, hex"", _terms(address(destinationToken)), delegationHash_); + } + + function test_revertWhenAfterHookIsNotLocked() public { + vm.expectRevert("ERC20SwapEnforcer:enforcer-not-locked"); + _after(_terms(address(destinationToken)), keccak256("not-locked")); + } + + function test_revertWithInvalidModes() public { + Execution[] memory executions_ = _executions(true, address(destinationToken), MIN_AMOUNT_OUT); + bytes memory executionCallData_ = ExecutionLib.encodeBatch(executions_); + + vm.prank(delegationManagerAddress); + vm.expectRevert("CaveatEnforcer:invalid-call-type"); + enforcer.beforeHook( + _terms(address(destinationToken)), + hex"", + singleDefaultMode, + executionCallData_, + keccak256("single"), + delegator, + redeemer + ); + + vm.prank(delegationManagerAddress); + vm.expectRevert("CaveatEnforcer:invalid-execution-type"); + enforcer.beforeHook( + _terms(address(destinationToken)), hex"", batchTryMode, executionCallData_, keccak256("try"), delegator, redeemer + ); + } + + ////////////////////////////// Integration tests ////////////////////////////// + + function test_integrationSwapsERC20WithApproval() public { + uint256 sourceBefore_ = sourceToken.balanceOf(delegator); + uint256 destinationBefore_ = destinationToken.balanceOf(delegator); + Execution[] memory executions_ = _executions(true, address(destinationToken), MIN_AMOUNT_OUT + 1); + + _redeem(executions_, _terms(address(destinationToken)), hex""); + + assertEq(sourceToken.balanceOf(delegator), sourceBefore_ - AMOUNT_IN); + assertEq(destinationToken.balanceOf(delegator), destinationBefore_ + MIN_AMOUNT_OUT + 1); + assertEq(sourceToken.allowance(delegator, address(router)), 0); + } + + function test_integrationSwapsERC20WithExistingApproval() public { + vm.prank(delegator); + sourceToken.approve(address(router), AMOUNT_IN); + + uint256 sourceBefore_ = sourceToken.balanceOf(delegator); + uint256 destinationBefore_ = destinationToken.balanceOf(delegator); + Execution[] memory executions_ = _executions(false, address(destinationToken), MIN_AMOUNT_OUT); + + _redeem(executions_, _terms(address(destinationToken)), hex"01"); + + assertEq(sourceToken.balanceOf(delegator), sourceBefore_ - AMOUNT_IN); + assertEq(destinationToken.balanceOf(delegator), destinationBefore_ + MIN_AMOUNT_OUT); + assertEq(sourceToken.allowance(delegator, address(router)), 0); + } + + function test_integrationSwapsERC20ForNativeToken() public { + uint256 sourceBefore_ = sourceToken.balanceOf(delegator); + uint256 nativeBefore_ = delegator.balance; + Execution[] memory executions_ = _executions(true, address(0), MIN_AMOUNT_OUT); + + _redeem(executions_, _terms(address(0)), hex""); + + assertEq(sourceToken.balanceOf(delegator), sourceBefore_ - AMOUNT_IN); + assertEq(delegator.balance, nativeBefore_ + MIN_AMOUNT_OUT); + } + + function test_integrationRevertsAtomicallyWhenOutputIsInsufficient() public { + uint256 sourceBefore_ = sourceToken.balanceOf(delegator); + uint256 destinationBefore_ = destinationToken.balanceOf(delegator); + Execution[] memory executions_ = _executions(true, address(destinationToken), MIN_AMOUNT_OUT - 1); + (bytes[] memory permissionContexts_, ModeCode[] memory modes_, bytes[] memory executionCallDatas_) = + _redemptionData(executions_, _terms(address(destinationToken)), hex""); + + vm.expectRevert("ERC20SwapEnforcer:insufficient-output"); + vm.prank(redeemer); + delegationManager.redeemDelegations(permissionContexts_, modes_, executionCallDatas_); + + assertEq(sourceToken.balanceOf(delegator), sourceBefore_); + assertEq(destinationToken.balanceOf(delegator), destinationBefore_); + assertEq(sourceToken.allowance(delegator, address(router)), 0); + } + + ////////////////////////////// Helpers ////////////////////////////// + + function _terms(address _destinationToken) internal view returns (bytes memory) { + return _rawTerms(address(sourceToken), _destinationToken, address(router), delegator, MIN_AMOUNT_OUT); + } + + function _rawTerms( + address _sourceToken, + address _destinationToken, + address _router, + address _recipient, + uint256 _minAmountOut + ) + internal + pure + returns (bytes memory) + { + return abi.encodePacked(_sourceToken, _destinationToken, _router, _recipient, _minAmountOut); + } + + function _executions( + bool _includeApproval, + address _destinationToken, + uint256 _amountOut + ) + internal + view + returns (Execution[] memory executions_) + { + uint256 swapIndex_; + if (_includeApproval) { + executions_ = new Execution[](2); + executions_[0] = Execution({ + target: address(sourceToken), value: 0, callData: abi.encodeCall(IERC20.approve, (address(router), AMOUNT_IN)) + }); + swapIndex_ = 1; + } else { + executions_ = new Execution[](1); + } + + executions_[swapIndex_] = Execution({ + target: address(router), + value: 0, + callData: _routerCalldata(EXECUTE_WITH_DEADLINE_SELECTOR, _destinationToken, delegator, AMOUNT_IN, _amountOut) + }); + } + + function _routerCalldata( + bytes4 _selector, + address _destinationToken, + address _recipient, + uint256 _amountIn, + uint256 _amountOut + ) + internal + view + returns (bytes memory) + { + bytes memory commands_ = hex"00"; + bytes[] memory inputs_ = new bytes[](1); + inputs_[0] = abi.encode(address(sourceToken), _destinationToken, _recipient, _amountIn, _amountOut); + + if (_selector == EXECUTE_WITH_DEADLINE_SELECTOR) { + return abi.encodeWithSelector(_selector, commands_, inputs_, block.timestamp + 1 hours); + } + return abi.encodeWithSelector(_selector, commands_, inputs_); + } + + function _before( + Execution[] memory _batchExecutions, + bytes memory _args, + bytes memory _termsData, + bytes32 _delegationHash + ) + internal + { + vm.prank(delegationManagerAddress); + enforcer.beforeHook( + _termsData, _args, batchDefaultMode, ExecutionLib.encodeBatch(_batchExecutions), _delegationHash, delegator, redeemer + ); + } + + function _after(bytes memory _termsData, bytes32 _delegationHash) internal { + vm.prank(delegationManagerAddress); + enforcer.afterHook(_termsData, hex"", batchDefaultMode, hex"", _delegationHash, delegator, redeemer); + } + + function _redeem(Execution[] memory _batchExecutions, bytes memory _termsData, bytes memory _args) internal { + (bytes[] memory permissionContexts_, ModeCode[] memory modes_, bytes[] memory executionCallDatas_) = + _redemptionData(_batchExecutions, _termsData, _args); + + vm.prank(redeemer); + delegationManager.redeemDelegations(permissionContexts_, modes_, executionCallDatas_); + } + + function _redemptionData( + Execution[] memory _batchExecutions, + bytes memory _termsData, + bytes memory _args + ) + internal + view + returns (bytes[] memory permissionContexts_, ModeCode[] memory modes_, bytes[] memory executionCallDatas_) + { + Caveat[] memory caveats_ = new Caveat[](1); + caveats_[0] = Caveat({ enforcer: address(enforcer), terms: _termsData, args: _args }); + + Delegation memory delegation_ = Delegation({ + delegate: redeemer, delegator: delegator, authority: ROOT_AUTHORITY, caveats: caveats_, salt: 0, signature: hex"" + }); + delegation_ = signDelegation(users.alice, delegation_); + + Delegation[] memory delegations_ = new Delegation[](1); + delegations_[0] = delegation_; + + permissionContexts_ = new bytes[](1); + permissionContexts_[0] = abi.encode(delegations_); + + modes_ = new ModeCode[](1); + modes_[0] = batchDefaultMode; + + executionCallDatas_ = new bytes[](1); + executionCallDatas_[0] = ExecutionLib.encodeBatch(_batchExecutions); + } + + function _getEnforcer() internal view override returns (ICaveatEnforcer) { + return ICaveatEnforcer(address(enforcer)); + } +} + +/** + * @dev Minimal Uniswap Universal Router-shaped test double. Each command input encodes: + * `(tokenIn, tokenOut, recipient, amountIn, amountOut)`, where `tokenOut == address(0)` means native token. + */ +contract MockUniversalRouter { + receive() external payable { } + + function execute(bytes calldata _commands, bytes[] calldata _inputs, uint256 _deadline) external payable { + require(block.timestamp <= _deadline, "MockUniversalRouter:expired"); + _execute(_commands, _inputs); + } + + function execute(bytes calldata _commands, bytes[] calldata _inputs) external payable { + _execute(_commands, _inputs); + } + + function _execute(bytes calldata _commands, bytes[] calldata _inputs) private { + require(_commands.length == _inputs.length, "MockUniversalRouter:invalid-inputs"); + + for (uint256 i = 0; i < _inputs.length; ++i) { + (address tokenIn_, address tokenOut_, address recipient_, uint256 amountIn_, uint256 amountOut_) = + abi.decode(_inputs[i], (address, address, address, uint256, uint256)); + + require(IERC20(tokenIn_).transferFrom(msg.sender, address(this), amountIn_), "MockUniversalRouter:transfer-in-failed"); + + if (tokenOut_ == address(0)) { + (bool success_,) = payable(recipient_).call{ value: amountOut_ }(""); + require(success_, "MockUniversalRouter:native-transfer-failed"); + } else { + require(IERC20(tokenOut_).transfer(recipient_, amountOut_), "MockUniversalRouter:transfer-out-failed"); + } + } + } +} diff --git a/test/enforcers/NativeSwapEnforcer.t.sol b/test/enforcers/NativeSwapEnforcer.t.sol new file mode 100644 index 00000000..be0ac9c6 --- /dev/null +++ b/test/enforcers/NativeSwapEnforcer.t.sol @@ -0,0 +1,404 @@ +// SPDX-License-Identifier: MIT AND Apache-2.0 +pragma solidity 0.8.23; + +import "forge-std/Test.sol"; +import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import { ExecutionLib } from "@erc7579/lib/ExecutionLib.sol"; + +import { NativeSwapEnforcer } from "../../src/enforcers/NativeSwapEnforcer.sol"; +import { ICaveatEnforcer } from "../../src/interfaces/ICaveatEnforcer.sol"; +import { EncoderLib } from "../../src/libraries/EncoderLib.sol"; +import { Caveat, Delegation, Execution, ModeCode } from "../../src/utils/Types.sol"; +import { CaveatEnforcerBaseTest } from "./CaveatEnforcerBaseTest.t.sol"; +import { BasicERC20 } from "../utils/BasicERC20.t.sol"; + +contract NativeSwapEnforcerTest is CaveatEnforcerBaseTest { + ////////////////////////////// Constants ////////////////////////////// + + uint256 internal constant AMOUNT_IN = 1 ether; + uint256 internal constant MAX_AMOUNT_IN = 3 ether; + uint256 internal constant MIN_AMOUNT_OUT = 5 ether; + uint256 internal constant ROUTER_LIQUIDITY = 100 ether; + bytes4 internal constant EXECUTE_SELECTOR = 0x24856bc3; + bytes4 internal constant EXECUTE_WITH_DEADLINE_SELECTOR = 0x3593564c; + + ////////////////////////////// State ////////////////////////////// + + NativeSwapEnforcer public enforcer; + NativeSwapRouterMock public router; + BasicERC20 public destinationToken; + + address public delegator; + address public redeemer; + address public delegationManagerAddress; + + ////////////////////////////// Set up ////////////////////////////// + + function setUp() public override { + super.setUp(); + + delegator = address(users.alice.deleGator); + redeemer = address(users.bob.deleGator); + delegationManagerAddress = address(delegationManager); + + enforcer = new NativeSwapEnforcer(); + router = new NativeSwapRouterMock(); + destinationToken = new BasicERC20(address(this), "Destination Token", "DST", 0); + + destinationToken.mint(address(router), ROUTER_LIQUIDITY); + + vm.label(address(enforcer), "Native Swap Enforcer"); + vm.label(address(router), "Native Swap Router Mock"); + vm.label(address(destinationToken), "Destination Token"); + } + + ////////////////////////////// Terms ////////////////////////////// + + function test_decodesTerms() public { + NativeSwapEnforcer.TermsData memory terms_ = enforcer.getTermsInfo(_terms()); + + assertEq(terms_.destinationToken, address(destinationToken)); + assertEq(terms_.swapTarget, address(router)); + assertEq(terms_.recipient, delegator); + assertEq(terms_.maxAmountIn, MAX_AMOUNT_IN); + assertEq(terms_.minAmountOut, MIN_AMOUNT_OUT); + } + + function test_getHashKey() public { + bytes32 delegationHash_ = keccak256("native-swap"); + assertEq( + enforcer.getHashKey(delegationManagerAddress, delegationHash_), + keccak256(abi.encode(delegationManagerAddress, delegationHash_)) + ); + } + + function test_revertWithInvalidTerms() public { + vm.expectRevert("NativeSwapEnforcer:invalid-terms-length"); + enforcer.getTermsInfo(hex""); + + vm.expectRevert("NativeSwapEnforcer:invalid-destination-token"); + enforcer.getTermsInfo(_rawTerms(address(0), address(router), delegator, MAX_AMOUNT_IN, MIN_AMOUNT_OUT)); + + vm.expectRevert("NativeSwapEnforcer:invalid-swap-target"); + enforcer.getTermsInfo(_rawTerms(address(destinationToken), address(0), delegator, MAX_AMOUNT_IN, MIN_AMOUNT_OUT)); + + vm.expectRevert("NativeSwapEnforcer:invalid-recipient"); + enforcer.getTermsInfo(_rawTerms(address(destinationToken), address(router), address(0), MAX_AMOUNT_IN, MIN_AMOUNT_OUT)); + + vm.expectRevert("NativeSwapEnforcer:zero-max-amount-in"); + enforcer.getTermsInfo(_rawTerms(address(destinationToken), address(router), delegator, 0, MIN_AMOUNT_OUT)); + + vm.expectRevert("NativeSwapEnforcer:zero-min-amount-out"); + enforcer.getTermsInfo(_rawTerms(address(destinationToken), address(router), delegator, MAX_AMOUNT_IN, 0)); + } + + ////////////////////////////// Valid executions ////////////////////////////// + + function test_allowsNativeSwapWithDeadline() public { + bytes32 delegationHash_ = keccak256("with-deadline"); + Execution memory execution_ = _execution(EXECUTE_WITH_DEADLINE_SELECTOR, AMOUNT_IN, MIN_AMOUNT_OUT); + + _before(execution_, delegationHash_); + destinationToken.mint(delegator, MIN_AMOUNT_OUT); + _after(delegationHash_); + + bytes32 hashKey_ = enforcer.getHashKey(delegationManagerAddress, delegationHash_); + assertFalse(enforcer.isLocked(hashKey_)); + assertEq(enforcer.balanceCache(hashKey_), 0); + assertEq(enforcer.spentMap(delegationManagerAddress, delegationHash_), AMOUNT_IN); + } + + function test_allowsNativeSwapWithoutDeadline() public { + bytes32 delegationHash_ = keccak256("without-deadline"); + Execution memory execution_ = _execution(EXECUTE_SELECTOR, AMOUNT_IN, MIN_AMOUNT_OUT); + + _before(execution_, delegationHash_); + destinationToken.mint(delegator, MIN_AMOUNT_OUT); + _after(delegationHash_); + } + + function test_allowsOutputAboveMinimum() public { + bytes32 delegationHash_ = keccak256("extra-output"); + Execution memory execution_ = _execution(EXECUTE_WITH_DEADLINE_SELECTOR, AMOUNT_IN, MIN_AMOUNT_OUT + 1); + + _before(execution_, delegationHash_); + destinationToken.mint(delegator, MIN_AMOUNT_OUT + 1); + _after(delegationHash_); + } + + function test_tracksCumulativeNativeSpent() public { + bytes32 delegationHash_ = keccak256("cumulative-spend"); + Execution memory execution_ = _execution(EXECUTE_WITH_DEADLINE_SELECTOR, AMOUNT_IN, MIN_AMOUNT_OUT); + + for (uint256 i = 1; i <= 3; ++i) { + vm.expectEmit(true, true, true, true, address(enforcer)); + emit NativeSwapEnforcer.IncreasedSpentMap( + delegationManagerAddress, redeemer, delegationHash_, MAX_AMOUNT_IN, AMOUNT_IN * i + ); + _before(execution_, delegationHash_); + destinationToken.mint(delegator, MIN_AMOUNT_OUT); + _after(delegationHash_); + + assertEq(enforcer.spentMap(delegationManagerAddress, delegationHash_), AMOUNT_IN * i); + } + } + + ////////////////////////////// Invalid executions ////////////////////////////// + + function test_revertWithInvalidSwapTarget() public { + Execution memory execution_ = _execution(EXECUTE_WITH_DEADLINE_SELECTOR, AMOUNT_IN, MIN_AMOUNT_OUT); + execution_.target = redeemer; + + vm.expectRevert("NativeSwapEnforcer:invalid-swap-target"); + _before(execution_, keccak256("swap-target")); + } + + function test_revertWithZeroSwapValue() public { + Execution memory execution_ = _execution(EXECUTE_WITH_DEADLINE_SELECTOR, 0, MIN_AMOUNT_OUT); + + vm.expectRevert("NativeSwapEnforcer:zero-swap-value"); + _before(execution_, keccak256("swap-value")); + } + + function test_revertWhenSingleSwapExceedsAllowance() public { + bytes32 delegationHash_ = keccak256("single-allowance-exceeded"); + Execution memory execution_ = _execution(EXECUTE_WITH_DEADLINE_SELECTOR, MAX_AMOUNT_IN + 1, MIN_AMOUNT_OUT); + + vm.expectRevert("NativeSwapEnforcer:allowance-exceeded"); + _before(execution_, delegationHash_); + + assertEq(enforcer.spentMap(delegationManagerAddress, delegationHash_), 0); + } + + function test_revertWhenCumulativeSwapAllowanceIsExceeded() public { + bytes32 delegationHash_ = keccak256("cumulative-allowance-exceeded"); + Execution memory execution_ = _execution(EXECUTE_WITH_DEADLINE_SELECTOR, 2 ether, MIN_AMOUNT_OUT); + + _before(execution_, delegationHash_); + destinationToken.mint(delegator, MIN_AMOUNT_OUT); + _after(delegationHash_); + + vm.expectRevert("NativeSwapEnforcer:allowance-exceeded"); + _before(execution_, delegationHash_); + + assertEq(enforcer.spentMap(delegationManagerAddress, delegationHash_), 2 ether); + } + + function test_revertWithInvalidSwapCalldata() public { + Execution memory execution_ = Execution({ target: address(router), value: AMOUNT_IN, callData: hex"1234" }); + + vm.expectRevert("NativeSwapEnforcer:invalid-swap-calldata"); + _before(execution_, keccak256("swap-calldata")); + } + + function test_revertWithInvalidSwapMethod() public { + Execution memory execution_ = Execution({ + target: address(router), value: AMOUNT_IN, callData: abi.encodeWithSelector(bytes4(keccak256("notExecute()"))) + }); + + vm.expectRevert("NativeSwapEnforcer:invalid-swap-method"); + _before(execution_, keccak256("swap-method")); + } + + function test_revertWhenEnforcerIsAlreadyLocked() public { + bytes32 delegationHash_ = keccak256("locked"); + Execution memory execution_ = _execution(EXECUTE_WITH_DEADLINE_SELECTOR, AMOUNT_IN, MIN_AMOUNT_OUT); + + _before(execution_, delegationHash_); + + vm.expectRevert("NativeSwapEnforcer:enforcer-is-locked"); + _before(execution_, delegationHash_); + } + + function test_revertWhenAfterHookIsNotLocked() public { + vm.expectRevert("NativeSwapEnforcer:enforcer-not-locked"); + _after(keccak256("not-locked")); + } + + function test_revertWithInvalidModes() public { + Execution memory execution_ = _execution(EXECUTE_WITH_DEADLINE_SELECTOR, AMOUNT_IN, MIN_AMOUNT_OUT); + bytes memory executionCallData_ = ExecutionLib.encodeSingle(execution_.target, execution_.value, execution_.callData); + + vm.prank(delegationManagerAddress); + vm.expectRevert("CaveatEnforcer:invalid-call-type"); + enforcer.beforeHook(_terms(), hex"", batchDefaultMode, executionCallData_, keccak256("batch"), delegator, redeemer); + + vm.prank(delegationManagerAddress); + vm.expectRevert("CaveatEnforcer:invalid-execution-type"); + enforcer.beforeHook(_terms(), hex"", singleTryMode, executionCallData_, keccak256("try"), delegator, redeemer); + } + + ////////////////////////////// Balance validation ////////////////////////////// + + function test_revertWithInsufficientOutput() public { + bytes32 delegationHash_ = keccak256("insufficient-output"); + Execution memory execution_ = _execution(EXECUTE_WITH_DEADLINE_SELECTOR, AMOUNT_IN, MIN_AMOUNT_OUT - 1); + + _before(execution_, delegationHash_); + destinationToken.mint(delegator, MIN_AMOUNT_OUT - 1); + + vm.expectRevert("NativeSwapEnforcer:insufficient-output"); + _after(delegationHash_); + } + + ////////////////////////////// Integration tests ////////////////////////////// + + function test_integrationSwapsNativeTokenForERC20() public { + uint256 nativeBefore_ = delegator.balance; + uint256 destinationBefore_ = destinationToken.balanceOf(delegator); + Execution memory execution_ = _execution(EXECUTE_WITH_DEADLINE_SELECTOR, AMOUNT_IN, MIN_AMOUNT_OUT + 1); + + _redeem(execution_); + + assertEq(delegator.balance, nativeBefore_ - AMOUNT_IN); + assertEq(destinationToken.balanceOf(delegator), destinationBefore_ + MIN_AMOUNT_OUT + 1); + assertEq(address(router).balance, AMOUNT_IN); + assertEq(enforcer.spentMap(delegationManagerAddress, _nativeSwapDelegationHash()), AMOUNT_IN); + } + + function test_integrationRevertsAtomicallyWhenOutputIsInsufficient() public { + uint256 nativeBefore_ = delegator.balance; + uint256 destinationBefore_ = destinationToken.balanceOf(delegator); + Execution memory execution_ = _execution(EXECUTE_WITH_DEADLINE_SELECTOR, AMOUNT_IN, MIN_AMOUNT_OUT - 1); + (bytes[] memory permissionContexts_, ModeCode[] memory modes_, bytes[] memory executionCallDatas_) = + _redemptionData(execution_); + + vm.expectRevert("NativeSwapEnforcer:insufficient-output"); + vm.prank(redeemer); + delegationManager.redeemDelegations(permissionContexts_, modes_, executionCallDatas_); + + assertEq(delegator.balance, nativeBefore_); + assertEq(destinationToken.balanceOf(delegator), destinationBefore_); + assertEq(address(router).balance, 0); + assertEq(enforcer.spentMap(delegationManagerAddress, _nativeSwapDelegationHash()), 0); + } + + ////////////////////////////// Helpers ////////////////////////////// + + function _terms() internal view returns (bytes memory) { + return _rawTerms(address(destinationToken), address(router), delegator, MAX_AMOUNT_IN, MIN_AMOUNT_OUT); + } + + function _rawTerms( + address _destinationToken, + address _router, + address _recipient, + uint256 _maxAmountIn, + uint256 _minAmountOut + ) + internal + pure + returns (bytes memory) + { + return abi.encodePacked(_destinationToken, _router, _recipient, _maxAmountIn, _minAmountOut); + } + + function _execution(bytes4 _selector, uint256 _value, uint256 _amountOut) internal view returns (Execution memory execution_) { + execution_ = + Execution({ target: address(router), value: _value, callData: _routerCalldata(_selector, delegator, _amountOut) }); + } + + function _routerCalldata(bytes4 _selector, address _recipient, uint256 _amountOut) internal view returns (bytes memory) { + bytes memory commands_ = hex"00"; + bytes[] memory inputs_ = new bytes[](1); + inputs_[0] = abi.encode(address(destinationToken), _recipient, _amountOut); + + if (_selector == EXECUTE_WITH_DEADLINE_SELECTOR) { + return abi.encodeWithSelector(_selector, commands_, inputs_, block.timestamp + 1 hours); + } + return abi.encodeWithSelector(_selector, commands_, inputs_); + } + + function _before(Execution memory _executionData, bytes32 _delegationHash) internal { + vm.prank(delegationManagerAddress); + enforcer.beforeHook( + _terms(), + hex"", + singleDefaultMode, + ExecutionLib.encodeSingle(_executionData.target, _executionData.value, _executionData.callData), + _delegationHash, + delegator, + redeemer + ); + } + + function _after(bytes32 _delegationHash) internal { + vm.prank(delegationManagerAddress); + enforcer.afterHook(_terms(), hex"", singleDefaultMode, hex"", _delegationHash, delegator, redeemer); + } + + function _redeem(Execution memory _executionData) internal { + (bytes[] memory permissionContexts_, ModeCode[] memory modes_, bytes[] memory executionCallDatas_) = + _redemptionData(_executionData); + + vm.prank(redeemer); + delegationManager.redeemDelegations(permissionContexts_, modes_, executionCallDatas_); + } + + function _redemptionData(Execution memory _executionData) + internal + view + returns (bytes[] memory permissionContexts_, ModeCode[] memory modes_, bytes[] memory executionCallDatas_) + { + Caveat[] memory caveats_ = new Caveat[](1); + caveats_[0] = Caveat({ enforcer: address(enforcer), terms: _terms(), args: hex"" }); + + Delegation memory delegation_ = Delegation({ + delegate: redeemer, delegator: delegator, authority: ROOT_AUTHORITY, caveats: caveats_, salt: 0, signature: hex"" + }); + delegation_ = signDelegation(users.alice, delegation_); + + Delegation[] memory delegations_ = new Delegation[](1); + delegations_[0] = delegation_; + + permissionContexts_ = new bytes[](1); + permissionContexts_[0] = abi.encode(delegations_); + + modes_ = new ModeCode[](1); + modes_[0] = singleDefaultMode; + + executionCallDatas_ = new bytes[](1); + executionCallDatas_[0] = ExecutionLib.encodeSingle(_executionData.target, _executionData.value, _executionData.callData); + } + + function _nativeSwapDelegationHash() internal view returns (bytes32) { + Caveat[] memory caveats_ = new Caveat[](1); + caveats_[0] = Caveat({ enforcer: address(enforcer), terms: _terms(), args: hex"" }); + + Delegation memory delegation_ = Delegation({ + delegate: redeemer, delegator: delegator, authority: ROOT_AUTHORITY, caveats: caveats_, salt: 0, signature: hex"" + }); + return EncoderLib._getDelegationHash(delegation_); + } + + function _getEnforcer() internal view override returns (ICaveatEnforcer) { + return ICaveatEnforcer(address(enforcer)); + } +} + +/** + * @dev Minimal native-input Uniswap Universal Router-shaped test double. Each command input encodes: + * `(tokenOut, recipient, amountOut)`. + */ +contract NativeSwapRouterMock { + receive() external payable { } + + function execute(bytes calldata _commands, bytes[] calldata _inputs, uint256 _deadline) external payable { + require(block.timestamp <= _deadline, "NativeSwapRouterMock:expired"); + _execute(_commands, _inputs); + } + + function execute(bytes calldata _commands, bytes[] calldata _inputs) external payable { + _execute(_commands, _inputs); + } + + function _execute(bytes calldata _commands, bytes[] calldata _inputs) private { + require(msg.value != 0, "NativeSwapRouterMock:zero-value"); + require(_commands.length == _inputs.length, "NativeSwapRouterMock:invalid-inputs"); + + for (uint256 i = 0; i < _inputs.length; ++i) { + (address tokenOut_, address recipient_, uint256 amountOut_) = abi.decode(_inputs[i], (address, address, uint256)); + require(IERC20(tokenOut_).transfer(recipient_, amountOut_), "NativeSwapRouterMock:transfer-out-failed"); + } + } +}