tycho-execution 0.300.3

Provides tools for encoding and executing swaps against Tycho router and protocol executors.
Documentation
// SPDX-License-Identifier: BUSL-1.1
pragma solidity ^0.8.26;

import {
    SafeERC20,
    IERC20
} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
import {
    IAllowanceTransfer
} from "@permit2/src/interfaces/IAllowanceTransfer.sol";
import {Address} from "@openzeppelin/contracts/utils/Address.sol";
import {Vault} from "./Vault.sol";
import {ETH_ADDRESS} from "../lib/NativeETH.sol";

error TransferManager__AddressZero();
error TransferManager__NotAContract(address addr);
error TransferManager__ExceededTransferFromAllowance(
    uint256 allowedAmount, uint256 amountAttempted
);
error TransferManager__DifferentTokenIn(
    address tokenIn, address tokenInStorage
);
error TransferManager__UnknownTransferType();

/**
 * @title TransferManager
 * @dev Orchestrates all token transfers during swap execution. Inherits from Vault
 * (ERC6909) and sits between TychoRouter and Dispatcher in the inheritance chain.
 *
 * Responsibilities:
 * - Routes each transfer through one of 6 scenarios based on: TransferType returned
 *   by the executor, whether it is the first swap, whether it is a split swap,
 *   whether the call is inside a callback, and whether vault funds are in use.
 * - Caps `transferFrom` to the declared input amount (stored in transient storage at
 *   swap start). This prevents maliciously encoded split swaps from withdrawing more
 *   than the user authorised. Reverts if the cap is exceeded or a different token is
 *   attempted.
 * - Supports both standard ERC20 `transferFrom` and Permit2's `transferFrom`.
 * - Handles native ETH accounting for executors that forward ETH as `msg.value`
 *   directly to the protocol (e.g. Fluid, Rocketpool, Lido).
 * - When vault-funded, skips `transferFrom` entirely and relies on vault delta
 *   accounting (`_updateDeltaAccounting`) to settle balances at the end of the swap.
 *
 * Per-swap state is kept in transient storage (EIP-1153): token in, allowed amount,
 * Permit2 flag, original sender, and vault-in-use flag. These are written once at
 * swap entry (`_tstoreTransferFromInfo`) and consumed throughout the swap.
 */
contract TransferManager is Vault {
    using SafeERC20 for IERC20;

    IAllowanceTransfer public immutable permit2;
    // keccak256("TransferManager#TOKEN_IN_SLOT")
    uint256 private constant _TOKEN_IN_SLOT =
        0xaf58e9f1b0923d55d0ec6a57763d43cd9bb1d6bd8bad5ce9522fbe772c6ec42b;
    // keccak256("TransferManager#AMOUNT_ALLOWED_SLOT")
    uint256 private constant _AMOUNT_ALLOWED_SLOT =
        0x28d0e2684c9341fec58f816e0c375a4e51f9f34ec034a29c97467df00497c8d9;
    // keccak256("TransferManager#IS_PERMIT2_SLOT")
    uint256 private constant _IS_PERMIT2_SLOT =
        0xd847dc13274ab371f6244234290f56fa8650ec9dceff86e20efe1ddc141bdb03;
    // keccak256("TransferManager#SENDER_SLOT")
    uint256 private constant _SENDER_SLOT =
        0x99298391997747e556e81b2d36d99151315b6c1b92e826ca8d37acad5fddaf70;

    constructor(address permit2_) {
        if (permit2_.code.length == 0) {
            revert TransferManager__NotAContract(permit2_);
        }
        permit2 = IAllowanceTransfer(permit2_);
    }

    enum TransferType {
        Transfer,
        TransferNativeInExecutor,
        ProtocolWillDebit,
        None
    }

    /**
     * @dev This function is used to store the transfer information in the
     * contract's storage. This is done as the first step in the swap process in TychoRouter.
     */
    // slither-disable-next-line assembly
    function _tstoreTransferFromInfo(
        address tokenIn,
        uint256 amountIn,
        bool isPermit2,
        bool useVault
    ) internal {
        uint256 amountAllowed = useVault ? 0 : amountIn;
        assembly {
            tstore(_TOKEN_IN_SLOT, tokenIn)
            tstore(_AMOUNT_ALLOWED_SLOT, amountAllowed)
            tstore(_IS_PERMIT2_SLOT, isPermit2)
            tstore(_SENDER_SLOT, caller())
            tstore(_USE_VAULT_SLOT, useVault)
        }
    }

    /**
     * @dev Transfers tokens based on the transfer type and swap context.
     * This function is called within the Dispatcher before calling executors or in callbacks.
     *
     * The function determines the appropriate transfer strategy based on:
     * - transferType: The base transfer requirement from the executor
     * - isFirstSwap: Whether this is the first swap or subsequent
     * - isSplitSwap: Whether this is part of a split swap strategy
     * - inCallback: Whether being called from within a callback (e.g., UniswapV3)
     * - useVault: Whether using vault funds
     *
     * Handles 6 transfer scenarios (in order of execution):
     * 1. None - no transfer needed
     * 2. Native ETH sent via executor - perform accounting but do no transfers
     * 3. TransferFrom user wallet to router, then approve protocol to debit
     * 4. Protocol debits from router/vault (with approval if needed). No transferFrom needed
     * 5. TransferFrom user wallet directly to protocol
     * 6. Transfer from router balance to protocol (either from vault or previous swap funds)
     */
    function _transfer(
        address receiver,
        TransferType transferType,
        address tokenIn,
        uint256 amount,
        bool isFirstSwap,
        bool isSplitSwap,
        bool inCallback
    ) internal returns (uint256) {
        // Scenario 1: No transfer needed. Likely called from outside the callback,
        // when funds are only transferred in callback for this protocol (e.g.
        // UniswapV3).
        if (transferType == TransferType.None) {
            return amount;
        }

        // Scenario 2: Native ETH sent via executor - accounting only
        if (transferType == TransferType.TransferNativeInExecutor) {
            // Protocols like Fluid or Lido require us to send the ETH as
            // msg.value when calling the swap function from inside the executor.
            _updateDeltaAccounting(tokenIn, -int256(amount));
            return amount;
        }

        // Determine if we need to transfer from user wallet (first swap + not using vault)
        bool useVault;
        // slither-disable-next-line assembly
        assembly {
            useVault := tload(_USE_VAULT_SLOT)
        }
        bool needsTransferFromUser = isFirstSwap && !useVault;

        // Scenario 3 & 4: Protocol will debit tokens from router
        if (transferType == TransferType.ProtocolWillDebit) {
            if (needsTransferFromUser) {
                // Scenario 3: First swap with user funds - transfer to router, then approve
                amount = _transferFromUser(tokenIn, address(this), amount);
                _approveIfNeeded(tokenIn, receiver, amount);
            } else {
                // Scenario 4: Funds already in router (from vault or previous swap)
                _updateDeltaAccounting(tokenIn, -int256(amount));
                _approveIfNeeded(tokenIn, receiver, amount);
            }
            return amount;
        }

        if (transferType == TransferType.Transfer) {
            // Scenario 1 optimization: Tokens already at pool from previous swap.
            // This optimization assumes that the previous swap sent tokens
            // directly to the current pool (e.g. UniswapV2: Pool1 -> Pool2 -> Pool3).
            // We must NOT apply this optimization when in a callback context, because
            // callback-constrained protocols (UniswapV3, BalancerV3, etc.) hold tokens in the
            // router between swaps, and do not account for transfers before the callback.
            bool canUseSequentialSwapOptimization =
                !isFirstSwap && !isSplitSwap && !inCallback;
            if (canUseSequentialSwapOptimization) {
                return amount;
            }

            if (needsTransferFromUser) {
                // Scenario 5: First swap with user funds - transfer directly to pool
                amount = _transferFromUser(tokenIn, receiver, amount);
            } else {
                // Scenario 6: Transfer from router balance to pool
                // This could mean the funds come from the user's vault (first swap with vault)
                // or funds are in the router from the previous swap.
                _updateDeltaAccounting(tokenIn, -int256(amount));
                amount = _transferOut(tokenIn, receiver, amount);
            }
            return amount;
        }

        revert TransferManager__UnknownTransferType();
    }

    /**
     * @dev Approves a receiver to spend tokens if receiver is not this contract.
     * For special cases like Rocketpool, the contract burns the user's balance
     * without physically transferring the input token, so an approval is not
     * always needed.
     */
    function _approveIfNeeded(address token, address receiver, uint256 amount)
        internal
    {
        if (receiver != address(this)) {
            IERC20(token).forceApprove(receiver, amount);
        }
    }

    /**
     * @dev Revokes a token approval if the spender didn't fully consume it.
     * In the normal case the protocol consumed the approval, so we skip the expensive
     * forceApprove.
     */
    // slither-disable-next-line calls-loop
    function _revokeUnconsumedApproval(address token, address spender)
        internal
    {
        if (IERC20(token).allowance(address(this), spender) > 0) {
            IERC20(token).forceApprove(spender, 0);
        }
    }

    /**
     * @dev Transfers tokens from user wallet using either Permit2 or regular transferFrom.
     * Validates the transfer doesn't exceed allowed amount and updates allowance tracking.
     * @return The actual amount received by the receiver, measured as the balance
     * difference before and after the transfer. This may differ from `amount` for
     * rebasing or fee-on-transfer tokens.
     */
    function _transferFromUser(address token, address receiver, uint256 amount)
        internal
        returns (uint256)
    {
        // Validate and track allowance to prevent badly encoded split swaps from
        // taking more than the input amount out of the user's wallet
        address tokenInStorage;
        uint256 amountAllowed;
        address sender;
        bool isPermit2;

        // slither-disable-next-line assembly
        assembly {
            tokenInStorage := tload(_TOKEN_IN_SLOT)
            amountAllowed := tload(_AMOUNT_ALLOWED_SLOT)
            sender := tload(_SENDER_SLOT)
            isPermit2 := tload(_IS_PERMIT2_SLOT)
        }

        if (amount > amountAllowed) {
            revert TransferManager__ExceededTransferFromAllowance(
                amountAllowed, amount
            );
        }
        if (token != tokenInStorage) {
            revert TransferManager__DifferentTokenIn(token, tokenInStorage);
        }

        // Update remaining allowance
        amountAllowed -= amount;
        assembly {
            tstore(_AMOUNT_ALLOWED_SLOT, amountAllowed)
        }

        uint256 balanceBefore = _balanceOf(token, receiver);
        // Perform the actual transfer
        if (isPermit2) {
            // Permit2.permit is already called from the TychoRouter
            // slither-disable-next-line calls-loop
            permit2.transferFrom(sender, receiver, uint160(amount), token);
        } else {
            // slither-disable-next-line arbitrary-send-erc20
            IERC20(token).safeTransferFrom(sender, receiver, amount);
        }
        uint256 balanceAfter = _balanceOf(token, receiver);
        return balanceAfter - balanceBefore;
    }

    /**
     * @dev Gets balance of a token for a given address. Supports both native ETH and ERC20 tokens.
     */
    function _balanceOf(address token, address owner)
        internal
        view
        returns (uint256)
    {
        // slither-disable-next-line calls-loop
        return
            token == ETH_ADDRESS
                ? owner.balance
                : IERC20(token).balanceOf(owner);
    }

    /**
     * @dev Transfers tokens from this contract to a recipient. Supports both
     * native ETH (via `sendValue`) and ERC20 tokens (via `safeTransfer`).
     * @return The actual amount received by `to`, measured as the balance
     * difference before and after the transfer. This may differ from `amount`
     * for rebasing or fee-on-transfer tokens.
     */
    function _transferOut(address token, address to, uint256 amount)
        internal
        returns (uint256)
    {
        uint256 balanceBefore = _balanceOf(token, to);
        if (token == ETH_ADDRESS) {
            Address.sendValue(payable(to), amount);
        } else {
            IERC20(token).safeTransfer(to, amount);
        }
        uint256 balanceAfter = _balanceOf(token, to);
        return balanceAfter - balanceBefore;
    }
}