Uniswap Protocol Adapter

The Uniswap adapter enables the Yieldinator Facet to integrate with Uniswap's liquidity pools (both V2 and V3), allowing vaults to earn trading fees and optimize yield through concentrated liquidity positions.

Overview

Uniswap is the largest decentralized exchange by volume, offering two distinct liquidity provision mechanisms:

  • Uniswap V2: Traditional automated market maker with 50/50 liquidity pools

  • Uniswap V3: Advanced concentrated liquidity that allows LPs to provide liquidity within specific price ranges

The Uniswap adapter facilitates deposits into both V2 and V3 pools, manages LP token balances for V2, handles NFT positions for V3, and optimizes yield by strategically setting price ranges for concentrated liquidity positions.

Implementation Details

Contract: UniswapAdapter.sol

The adapter implements the standard YieldinatorAdapter interface with Uniswap-specific functionality:

contract UniswapAdapter is YieldinatorAdapter {
    using SafeERC20 for IERC20;
    
    // Uniswap contracts
    IUniswapV2Router02 public v2Router;
    IUniswapV2Factory public v2Factory;
    INonfungiblePositionManager public positionManager;
    IUniswapV3Factory public v3Factory;
    
    // Pool version enum
    enum PoolVersion { V2, V3 }
    
    // Pool configuration
    struct PoolConfig {
        address pair;          // V2 pair address or V3 pool address
        address token0;        // First token in the pair
        address token1;        // Second token in the pair
        uint24 fee;            // Fee tier for V3 (0 for V2)
        PoolVersion version;   // V2 or V3
        int24 tickLower;       // Lower tick for V3 position (0 for V2)
        int24 tickUpper;       // Upper tick for V3 position (0 for V2)
        bool active;           // Whether the pool is active
    }
    
    // Position tracking for V3
    struct Position {
        uint256 tokenId;       // NFT token ID for the position
        uint128 liquidity;     // Current liquidity amount
        int24 tickLower;       // Position's lower tick
        int24 tickUpper;       // Position's upper tick
    }
    
    // Mapping from token to pool configuration
    mapping(address => PoolConfig) public tokenToPool;
    
    // Mapping from token to V3 positions
    mapping(address => Position[]) public tokenToPositions;
    
    // Deposited amounts per token
    mapping(address => uint256) public depositedAmount;
    
    // Constructor
    constructor(
        address _admin,
        address _v2Router,
        address _v2Factory,
        address _positionManager,
        address _v3Factory
    ) YieldinatorAdapter("Uniswap", _admin) {
        require(_v2Router != address(0), "UniswapAdapter: v2Router cannot be zero address");
        require(_v2Factory != address(0), "UniswapAdapter: v2Factory cannot be zero address");
        require(_positionManager != address(0), "UniswapAdapter: positionManager cannot be zero address");
        require(_v3Factory != address(0), "UniswapAdapter: v3Factory cannot be zero address");
        
        v2Router = IUniswapV2Router02(_v2Router);
        v2Factory = IUniswapV2Factory(_v2Factory);
        positionManager = INonfungiblePositionManager(_positionManager);
        v3Factory = IUniswapV3Factory(_v3Factory);
    }
}

Key Functions

Pool Registration

Before using the adapter for a specific token, the pool must be registered:

function registerV2Pool(
    address _token,
    address _pairedToken
) external onlyRole(ADMIN_ROLE) {
    require(_token != address(0), "UniswapAdapter: token cannot be zero address");
    require(_pairedToken != address(0), "UniswapAdapter: paired token cannot be zero address");
    
    // Get the pair address from the factory
    address pair = v2Factory.getPair(_token, _pairedToken);
    require(pair != address(0), "UniswapAdapter: pair does not exist");
    
    // Determine token order
    (address token0, address token1) = _token < _pairedToken 
        ? (_token, _pairedToken) 
        : (_pairedToken, _token);
    
    // Create and store pool configuration
    PoolConfig memory config = PoolConfig({
        pair: pair,
        token0: token0,
        token1: token1,
        fee: 0,
        version: PoolVersion.V2,
        tickLower: 0,
        tickUpper: 0,
        active: true
    });
    
    tokenToPool[_token] = config;
    
    emit V2PoolRegistered(_token, pair);
}

function registerV3Pool(
    address _token,
    address _pairedToken,
    uint24 _fee,
    int24 _tickLower,
    int24 _tickUpper
) external onlyRole(ADMIN_ROLE) {
    require(_token != address(0), "UniswapAdapter: token cannot be zero address");
    require(_pairedToken != address(0), "UniswapAdapter: paired token cannot be zero address");
    require(_fee > 0, "UniswapAdapter: fee must be greater than 0");
    require(_tickLower < _tickUpper, "UniswapAdapter: tickLower must be less than tickUpper");
    
    // Determine token order
    (address token0, address token1) = _token < _pairedToken 
        ? (_token, _pairedToken) 
        : (_pairedToken, _token);
    
    // Get the pool address from the factory
    address pool = v3Factory.getPool(token0, token1, _fee);
    require(pool != address(0), "UniswapAdapter: pool does not exist");
    
    // Create and store pool configuration
    PoolConfig memory config = PoolConfig({
        pair: pool,
        token0: token0,
        token1: token1,
        fee: _fee,
        version: PoolVersion.V3,
        tickLower: _tickLower,
        tickUpper: _tickUpper,
        active: true
    });
    
    tokenToPool[_token] = config;
    
    emit V3PoolRegistered(_token, pool, _fee, _tickLower, _tickUpper);
}

Deposit

The deposit function handles adding liquidity to Uniswap V2 or V3 based on the pool configuration:

function deposit(address _token, uint256 _amount)
    external
    override
    onlyRole(YIELDINATOR_ROLE)
    nonReentrant
    returns (bool)
{
    require(_amount > 0, "UniswapAdapter: amount must be greater than 0");
    PoolConfig memory config = tokenToPool[_token];
    require(config.active, "UniswapAdapter: pool not active for token");
    
    // Transfer token from caller
    IERC20(_token).safeTransferFrom(msg.sender, address(this), _amount);
    
    if (config.version == PoolVersion.V2) {
        return _depositV2(_token, _amount, config);
    } else {
        return _depositV3(_token, _amount, config);
    }
}

function _depositV2(address _token, uint256 _amount, PoolConfig memory _config) 
    internal 
    returns (bool) 
{
    // Determine other token in pair and amount needed
    address otherToken = (_token == _config.token0) ? _config.token1 : _config.token0;
    uint256 otherAmount = _calculateRequiredAmount(_token, otherToken, _amount);
    
    // Approve router to use tokens
    IERC20(_token).safeApprove(address(v2Router), _amount);
    IERC20(otherToken).safeApprove(address(v2Router), otherAmount);
    
    // Add liquidity to Uniswap V2
    (uint256 amountA, uint256 amountB, uint256 liquidity) = v2Router.addLiquidity(
        _token,
        otherToken,
        _amount,
        otherAmount,
        _amount * 95 / 100, // 5% slippage
        otherAmount * 95 / 100, // 5% slippage
        address(this),
        block.timestamp + 1800 // 30 minutes deadline
    );
    
    // Update deposited amount
    depositedAmount[_token] += amountA;
    
    emit Deposited(_token, amountA);
    return true;
}

function _depositV3(address _token, uint256 _amount, PoolConfig memory _config) 
    internal 
    returns (bool) 
{
    // Determine other token in pair and amount needed
    address otherToken = (_token == _config.token0) ? _config.token1 : _config.token0;
    uint256 otherAmount = _calculateRequiredAmountV3(_token, otherToken, _amount, _config);
    
    // Approve position manager to use tokens
    IERC20(_token).safeApprove(address(positionManager), _amount);
    IERC20(otherToken).safeApprove(address(positionManager), otherAmount);
    
    // Prepare mint parameters
    INonfungiblePositionManager.MintParams memory params = INonfungiblePositionManager.MintParams({
        token0: _config.token0,
        token1: _config.token1,
        fee: _config.fee,
        tickLower: _config.tickLower,
        tickUpper: _config.tickUpper,
        amount0Desired: _token == _config.token0 ? _amount : otherAmount,
        amount1Desired: _token == _config.token0 ? otherAmount : _amount,
        amount0Min: _token == _config.token0 ? _amount * 95 / 100 : otherAmount * 95 / 100,
        amount1Min: _token == _config.token0 ? otherAmount * 95 / 100 : _amount * 95 / 100,
        recipient: address(this),
        deadline: block.timestamp + 1800 // 30 minutes deadline
    });
    
    // Mint new position
    (uint256 tokenId, uint128 liquidity, uint256 amount0, uint256 amount1) = positionManager.mint(params);
    
    // Store position information
    Position memory position = Position({
        tokenId: tokenId,
        liquidity: liquidity,
        tickLower: _config.tickLower,
        tickUpper: _config.tickUpper
    });
    
    tokenToPositions[_token].push(position);
    
    // Update deposited amount
    uint256 depositedTokenAmount = _token == _config.token0 ? amount0 : amount1;
    depositedAmount[_token] += depositedTokenAmount;
    
    emit DepositedV3(_token, depositedTokenAmount, tokenId, liquidity);
    return true;
}

Withdraw

The withdraw function handles removing liquidity from Uniswap V2 or V3 based on the pool configuration:

function withdraw(address _token, uint256 _amount)
    external
    override
    onlyRole(YIELDINATOR_ROLE)
    nonReentrant
    returns (uint256)
{
    require(_amount > 0, "UniswapAdapter: amount must be greater than 0");
    require(_amount <= depositedAmount[_token], "UniswapAdapter: insufficient balance");
    
    PoolConfig memory config = tokenToPool[_token];
    require(config.active, "UniswapAdapter: pool not active for token");
    
    if (config.version == PoolVersion.V2) {
        return _withdrawV2(_token, _amount, config);
    } else {
        return _withdrawV3(_token, _amount, config);
    }
}

function _withdrawV2(address _token, uint256 _amount, PoolConfig memory _config) 
    internal 
    returns (uint256) 
{
    // Calculate proportion of LP tokens to withdraw
    address pair = _config.pair;
    uint256 lpBalance = IERC20(pair).balanceOf(address(this));
    uint256 lpToWithdraw = lpBalance * _amount / depositedAmount[_token];
    
    // Determine other token in pair
    address otherToken = (_token == _config.token0) ? _config.token1 : _config.token0;
    
    // Approve router to use LP tokens
    IERC20(pair).safeApprove(address(v2Router), lpToWithdraw);
    
    // Remove liquidity from Uniswap V2
    (uint256 amountA, uint256 amountB) = v2Router.removeLiquidity(
        _token,
        otherToken,
        lpToWithdraw,
        _amount * 95 / 100, // 5% slippage
        0, // Min amount for other token
        address(this),
        block.timestamp + 1800 // 30 minutes deadline
    );
    
    // Update deposited amount
    depositedAmount[_token] -= amountA;
    
    // Transfer withdrawn token to caller
    IERC20(_token).safeTransfer(msg.sender, amountA);
    
    emit Withdrawn(_token, amountA);
    return amountA;
}

function _withdrawV3(address _token, uint256 _amount, PoolConfig memory _config) 
    internal 
    returns (uint256) 
{
    // Find positions to withdraw from
    Position[] storage positions = tokenToPositions[_token];
    require(positions.length > 0, "UniswapAdapter: no positions found");
    
    uint256 remainingAmount = _amount;
    uint256 totalWithdrawn = 0;
    
    // Loop through positions and withdraw until requested amount is reached
    for (uint256 i = 0; i < positions.length && remainingAmount > 0; i++) {
        Position storage position = positions[i];
        
        // Calculate proportion of liquidity to withdraw from this position
        uint128 liquidityToWithdraw = uint128(uint256(position.liquidity) * remainingAmount / depositedAmount[_token]);
        
        if (liquidityToWithdraw > 0) {
            // Prepare decrease liquidity parameters
            INonfungiblePositionManager.DecreaseLiquidityParams memory params = INonfungiblePositionManager.DecreaseLiquidityParams({
                tokenId: position.tokenId,
                liquidity: liquidityToWithdraw,
                amount0Min: 0,
                amount1Min: 0,
                deadline: block.timestamp + 1800 // 30 minutes deadline
            });
            
            // Decrease liquidity
            (uint256 amount0, uint256 amount1) = positionManager.decreaseLiquidity(params);
            
            // Prepare collect parameters
            INonfungiblePositionManager.CollectParams memory collectParams = INonfungiblePositionManager.CollectParams({
                tokenId: position.tokenId,
                recipient: address(this),
                amount0Max: type(uint128).max,
                amount1Max: type(uint128).max
            });
            
            // Collect tokens
            (uint256 collected0, uint256 collected1) = positionManager.collect(collectParams);
            
            // Update position liquidity
            position.liquidity -= liquidityToWithdraw;
            
            // Calculate withdrawn amount of requested token
            uint256 withdrawnAmount = _token == _config.token0 ? collected0 : collected1;
            
            // Update remaining amount to withdraw
            if (withdrawnAmount >= remainingAmount) {
                totalWithdrawn += remainingAmount;
                remainingAmount = 0;
            } else {
                totalWithdrawn += withdrawnAmount;
                remainingAmount -= withdrawnAmount;
            }
        }
    }
    
    // Update deposited amount
    depositedAmount[_token] -= totalWithdrawn;
    
    // Transfer withdrawn token to caller
    IERC20(_token).safeTransfer(msg.sender, totalWithdrawn);
    
    emit Withdrawn(_token, totalWithdrawn);
    return totalWithdrawn;
}

Harvest Yield

The harvestYield function collects fees from V2 and V3 positions:

function harvestYield(address _token)
    external
    override
    onlyRole(YIELDINATOR_ROLE)
    nonReentrant
    returns (uint256)
{
    PoolConfig memory config = tokenToPool[_token];
    require(config.active, "UniswapAdapter: pool not active for token");
    
    if (config.version == PoolVersion.V2) {
        // V2 has no explicit yield harvesting - fees are automatically added to the pool
        return 0;
    } else {
        return _harvestV3Fees(_token, config);
    }
}

function _harvestV3Fees(address _token, PoolConfig memory _config)
    internal
    returns (uint256)
{
    Position[] storage positions = tokenToPositions[_token];
    uint256 totalHarvested = 0;
    
    for (uint256 i = 0; i < positions.length; i++) {
        Position storage position = positions[i];
        
        if (position.liquidity > 0) {
            // Prepare collect parameters
            INonfungiblePositionManager.CollectParams memory params = INonfungiblePositionManager.CollectParams({
                tokenId: position.tokenId,
                recipient: address(this),
                amount0Max: type(uint128).max,
                amount1Max: type(uint128).max
            });
            
            // Collect fees
            (uint256 collected0, uint256 collected1) = positionManager.collect(params);
            
            // Calculate harvested amount of requested token
            uint256 harvestedAmount = _token == _config.token0 ? collected0 : collected1;
            totalHarvested += harvestedAmount;
        }
    }
    
    // Transfer harvested tokens to caller
    if (totalHarvested > 0) {
        IERC20(_token).safeTransfer(msg.sender, totalHarvested);
    }
    
    emit YieldHarvested(_token, totalHarvested);
    return totalHarvested;
}

Emergency Withdraw

The emergencyWithdraw function provides a way to recover funds in case of emergencies:

function emergencyWithdraw(address _token)
    external
    override
    onlyRole(YIELDINATOR_ROLE)
    nonReentrant
    returns (uint256)
{
    PoolConfig memory config = tokenToPool[_token];
    require(config.active, "UniswapAdapter: pool not active for token");
    
    uint256 withdrawnAmount = 0;
    
    if (config.version == PoolVersion.V2) {
        // Emergency withdraw from V2
        address pair = config.pair;
        uint256 lpBalance = IERC20(pair).balanceOf(address(this));
        
        if (lpBalance > 0) {
            // Determine other token in pair
            address otherToken = (_token == config.token0) ? config.token1 : config.token0;
            
            // Approve router to use LP tokens
            IERC20(pair).safeApprove(address(v2Router), lpBalance);
            
            // Remove all liquidity from Uniswap V2
            (uint256 amountA, uint256 amountB) = v2Router.removeLiquidity(
                _token,
                otherToken,
                lpBalance,
                0, // Min amount for token
                0, // Min amount for other token
                address(this),
                block.timestamp + 1800 // 30 minutes deadline
            );
            
            withdrawnAmount = amountA;
        }
    } else {
        // Emergency withdraw from V3
        Position[] storage positions = tokenToPositions[_token];
        
        for (uint256 i = 0; i < positions.length; i++) {
            Position storage position = positions[i];
            
            if (position.liquidity > 0) {
                // Decrease all liquidity
                INonfungiblePositionManager.DecreaseLiquidityParams memory params = INonfungiblePositionManager.DecreaseLiquidityParams({
                    tokenId: position.tokenId,
                    liquidity: position.liquidity,
                    amount0Min: 0,
                    amount1Min: 0,
                    deadline: block.timestamp + 1800 // 30 minutes deadline
                });
                
                positionManager.decreaseLiquidity(params);
                
                // Collect all tokens
                INonfungiblePositionManager.CollectParams memory collectParams = INonfungiblePositionManager.CollectParams({
                    tokenId: position.tokenId,
                    recipient: address(this),
                    amount0Max: type(uint128).max,
                    amount1Max: type(uint128).max
                });
                
                (uint256 collected0, uint256 collected1) = positionManager.collect(collectParams);
                
                // Add to withdrawn amount
                withdrawnAmount += _token == config.token0 ? collected0 : collected1;
                
                // Update position liquidity
                position.liquidity = 0;
            }
        }
    }
    
    // Reset deposited amount
    depositedAmount[_token] = 0;
    
    // Transfer all tokens to caller
    uint256 balance = IERC20(_token).balanceOf(address(this));
    if (balance > 0) {
        IERC20(_token).safeTransfer(msg.sender, balance);
    }
    
    emit EmergencyWithdrawn(_token, balance);
    return balance;
}

Usage Examples

Registering a Uniswap V2 Pool

// Register a WETH-USDC V2 pool for WETH deposits
uniswapAdapter.registerV2Pool(
    WETH_ADDRESS,
    USDC_ADDRESS
);

Registering a Uniswap V3 Pool with Concentrated Liquidity

// Register a WETH-USDC V3 pool with 0.3% fee and specific price range
// Price range from 1500 USDC to 2000 USDC per ETH
int24 tickLower = -887220; // Corresponds to ~1500 USDC per ETH
int24 tickUpper = -887020; // Corresponds to ~2000 USDC per ETH

uniswapAdapter.registerV3Pool(
    WETH_ADDRESS,
    USDC_ADDRESS,
    3000, // 0.3% fee tier
    tickLower,
    tickUpper
);

Depositing into Uniswap

// Deposit 1 ETH into the registered pool
uint256 amount = 1 ether;
yieldinator.deposit(WETH_ADDRESS, amount, uniswapAdapter);

Withdrawing from Uniswap

// Withdraw 0.5 ETH from the registered pool
uint256 amount = 0.5 ether;
yieldinator.withdraw(WETH_ADDRESS, amount, uniswapAdapter);

Harvesting Yield

// Harvest trading fees for WETH
yieldinator.harvestYield(WETH_ADDRESS, uniswapAdapter);

Security Considerations

Price Impact and Slippage

When depositing or withdrawing, the adapter uses a default slippage tolerance of 5%. This can be adjusted by the protocol administrator based on market conditions.

Concentrated Liquidity Risks

For V3 positions, there's a risk of providing liquidity outside the active trading range, resulting in impermanent loss without earning fees. The adapter mitigates this by:

  1. Allowing administrators to set appropriate tick ranges

  2. Supporting multiple positions with different ranges for the same token

  3. Providing functions to adjust tick ranges as market conditions change

Position Management

V3 positions are represented as NFTs, which the adapter manages internally. The contract includes safeguards to ensure these NFTs cannot be transferred out of the adapter except through the proper withdrawal process.

Oracle Manipulation

The adapter relies on Uniswap's time-weighted average price (TWAP) oracles for certain calculations. To mitigate manipulation risks, the adapter:

  1. Uses multiple oracle observations

  2. Implements circuit breakers for extreme price movements

  3. Allows administrators to pause specific pools during market turbulence

Conclusion

The Uniswap adapter provides comprehensive integration with both Uniswap V2 and V3, allowing the Yieldinator Facet to optimize yield through trading fees and concentrated liquidity strategies. The adapter's flexible design accommodates different risk profiles and market conditions, making it a powerful addition to the Vaultinator Protocol's yield generation capabilities.