Skip to content

feat: Curve-routed execution adapters #145

@matteoettam09

Description

@matteoettam09

Reference implementations

// SPDX-License-Identifier: BUSL-1.1
pragma solidity ^0.8.28;

import { ISwapExecutor } from "../../interfaces/ISwapExecutor.sol";
import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import { SafeERC20 } from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
import { ErrorsLib } from "../../libraries/ErrorsLib.sol";

/**
 * @title ICurvePool
 * @notice Interface for Curve Finance pool contracts
 * @author Orion Finance
 * @dev Uses Curve's native naming conventions (snake_case)
 */
// solhint-disable func-name-mixedcase, var-name-mixedcase, use-natspec
interface ICurvePool {
    /// @notice Exchange tokens in the pool
    /// @param i Index of input token
    /// @param j Index of output token
    /// @param dx Amount of input token
    /// @param min_dy Minimum amount of output token
    /// @return Amount of output token received
    function exchange(int128 i, int128 j, uint256 dx, uint256 min_dy) external returns (uint256);

    /// @notice Exchange underlying tokens in the pool
    /// @param i Index of input token
    /// @param j Index of output token
    /// @param dx Amount of input token
    /// @param min_dy Minimum amount of output token
    /// @return Amount of output token received
    function exchange_underlying(int128 i, int128 j, uint256 dx, uint256 min_dy) external returns (uint256);
}

/**
 * @title CurveSwapExecutor
 * @notice Executes token swaps via Curve Finance pools
 * @author Orion Finance
 * @dev Primarily optimized for stablecoin swaps (USDC/USDT/DAI)
 *
 * Route Parameters Format (abi.encode):
 * - address pool: Curve pool address
 * - int128 i: Index of input token in pool
 * - int128 j: Index of output token in pool
 * - bool useUnderlying: Whether to use exchange_underlying (for wrapped tokens)
 *
 * Note on exact-output:
 * Curve doesn't natively support exact-output swaps. For stablecoins,
 * we approximate using 1:1 + small buffer, then refund excess.
 * For volatile pairs, this executor may be less accurate.
 *
 * Security:
 * - Respects min_dy limits set by caller
 * - All approvals are transient and zeroed after use
 * - Refunds excess output tokens to caller
 *
 * @custom:security-contact security@orionfinance.ai
 */
contract CurveSwapExecutor is ISwapExecutor {
    using SafeERC20 for IERC20;

    /// @notice Buffer for exact-output approximation (0.2% = 20 bps)
    uint256 public constant EXACT_OUTPUT_BUFFER = 20;
    /// @notice Basis points denominator for percentage calculations
    uint256 public constant BASIS_POINTS = 10000;

    /// @inheritdoc ISwapExecutor
    /// @param tokenIn Address of the input token
    /// @param tokenOut Address of the output token
    /// @param amountOut Exact amount of output tokens desired
    /// @param amountInMax Maximum amount of input tokens to spend
    /// @param routeParams Encoded Curve pool parameters (pool, i, j, useUnderlying)
    /// @return amountIn Actual amount of input tokens spent
    function swapExactOutput(
        address tokenIn,
        address tokenOut,
        uint256 amountOut,
        uint256 amountInMax,
        bytes calldata routeParams
    ) external returns (uint256 amountIn) {
        (address pool, int128 i, int128 j, bool useUnderlying) = abi.decode(
            routeParams,
            (address, int128, int128, bool)
        );

        // Estimate input needed (1:1 + buffer for stablecoins)
        amountIn = (amountOut * (BASIS_POINTS + EXACT_OUTPUT_BUFFER)) / BASIS_POINTS;
        if (amountIn > amountInMax) {
            amountIn = amountInMax;
        }

        // Pull and approve
        IERC20(tokenIn).safeTransferFrom(msg.sender, address(this), amountIn);
        IERC20(tokenIn).forceApprove(pool, amountIn);

        // Execute swap
        uint256 receivedOut = useUnderlying
            ? ICurvePool(pool).exchange_underlying(i, j, amountIn, amountOut)
            : ICurvePool(pool).exchange(i, j, amountIn, amountOut);

        // Clean up approval
        IERC20(tokenIn).forceApprove(pool, 0);

        // Verify output
        if (receivedOut < amountOut) {
            revert ErrorsLib.InsufficientSwapOutput(receivedOut, amountOut);
        }

        // Transfer exact output + any excess
        IERC20(tokenOut).safeTransfer(msg.sender, receivedOut);

        // Refund unused input
        uint256 balance = IERC20(tokenIn).balanceOf(address(this));
        if (balance > 0) {
            IERC20(tokenIn).safeTransfer(msg.sender, balance);
            amountIn -= balance;
        }
    }

    /// @inheritdoc ISwapExecutor
    /// @param tokenIn Address of the input token
    /// @param tokenOut Address of the output token
    /// @param amountIn Exact amount of input tokens to spend
    /// @param amountOutMin Minimum amount of output tokens to receive
    /// @param routeParams Encoded Curve pool parameters (pool, i, j, useUnderlying)
    /// @return amountOut Actual amount of output tokens received
    function swapExactInput(
        address tokenIn,
        address tokenOut,
        uint256 amountIn,
        uint256 amountOutMin,
        bytes calldata routeParams
    ) external returns (uint256 amountOut) {
        // Decode route parameters
        (address pool, int128 i, int128 j, bool useUnderlying) = abi.decode(
            routeParams,
            (address, int128, int128, bool)
        );

        ICurvePool curvePool = ICurvePool(pool);

        // Pull tokenIn from caller
        IERC20(tokenIn).safeTransferFrom(msg.sender, address(this), amountIn);

        // Approve pool to spend tokenIn
        IERC20(tokenIn).forceApprove(pool, amountIn);

        // Execute swap
        if (useUnderlying) {
            amountOut = curvePool.exchange_underlying(i, j, amountIn, amountOutMin);
        } else {
            amountOut = curvePool.exchange(i, j, amountIn, amountOutMin);
        }

        // Clean up approval
        IERC20(tokenIn).forceApprove(pool, 0);

        // Send all output to caller
        IERC20(tokenOut).safeTransfer(msg.sender, amountOut);

        // Verify minimum output was met (pool should revert, but double-check)
        if (amountOut < amountOutMin) {
            revert ErrorsLib.InsufficientSwapOutput(amountOut, amountOutMin);
        }
    }
}
// SPDX-License-Identifier: BUSL-1.1
pragma solidity ^0.8.28;

import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import { SafeERC20 } from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";

/**
 * @title MockCurvePool
 * @notice Mock Curve pool for testing swap executors
 * @dev Simulates both exchange and exchange_underlying functions
 */
contract MockCurvePool {
    using SafeERC20 for IERC20;

    // Test configuration
    uint256 public nextExchangeResult;
    bool public shouldRevert;
    bool public lastUsedUnderlying;

    // Track token addresses for transfers
    address public tokenOut;

    function setNextExchangeResult(uint256 _result) external {
        nextExchangeResult = _result;
    }

    function setShouldRevert(bool _shouldRevert) external {
        shouldRevert = _shouldRevert;
    }

    function setTokenOut(address _tokenOut) external {
        tokenOut = _tokenOut;
    }

    function exchange(int128, int128, uint256, uint256 min_dy) external returns (uint256) {
        if (shouldRevert) revert("Mock revert");

        lastUsedUnderlying = false;

        uint256 dy = nextExchangeResult;
        require(dy >= min_dy, "Insufficient output");

        // Mock: mint output tokens to the caller (executor)
        if (tokenOut != address(0)) {
            _mintOrTransfer(tokenOut, msg.sender, dy);
        }

        return dy;
    }

    function exchange_underlying(int128, int128, uint256, uint256 min_dy) external returns (uint256) {
        if (shouldRevert) revert("Mock revert");

        lastUsedUnderlying = true;

        uint256 dy = nextExchangeResult;
        require(dy >= min_dy, "Insufficient output");

        // Mock: mint output tokens to the caller (executor)
        if (tokenOut != address(0)) {
            _mintOrTransfer(tokenOut, msg.sender, dy);
        }

        return dy;
    }

    function _mintOrTransfer(address token, address to, uint256 amount) internal {
        // Try to mint tokens (for testing with MockUnderlyingAsset)
        (bool success, ) = token.call(abi.encodeWithSignature("mint(address,uint256)", to, amount));
        if (!success) {
            // If mint fails, try to transfer from pool balance
            IERC20(token).safeTransfer(to, amount);
        }
    }

    function get_dy(int128 i, int128 j, uint256 dx) external view returns (uint256) {
        // For mocking, just return configured result
        return nextExchangeResult;
    }

    function get_dy_underlying(int128 i, int128 j, uint256 dx) external view returns (uint256) {
        return nextExchangeResult;
    }
}

Tests

  • USDS in investment universe
  • USDS-based ERC4626 in investment universe

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions