Using the new Uniswap v3 in your contracts

What's new in Uniswap v3 and how to integrate Uniswap v3

If you're not familiar with Uniswap yet, it's a fully decentralized protocol for automated liquidity provision on Ethereum. An easier-to-understand description would be that it's a decentralized exchange (DEX) relying on external liquidity providers that can add tokens to smart contract pools and users can trade those directly.

Since it's running on Ethereum, what we can trade are Ethereum ERC-20 tokens. For each token there is its own smart contract and liquidity pool. Uniswap - being fully decentralized - has no restrictions to which tokens can be added. If no contracts for a token pair exist yet, anyone can create one using their factory and anyone can provide liquidity to a pool. A fee of 0.3% for each trade is given to those liquidity providers as incentive.

The price of a token is determined by the liquidity in a pool. For example if a user is buying TOKEN1 with TOKEN2, the supply of TOKEN1 in the pool will decrease while the supply of TOKEN2 will increase and the price of TOKEN1 will increase. Likewise, if a user is selling TOKEN1, the price of TOKEN1 will decrease. Therefore the token price always reflects the supply and demand.

And of course a user doesn't have to be a person, it can be a smart contract. That allows us to add Uniswap to our own contracts for adding additional payment options for users of our contracts. Uniswap makes this process very convenient, see below for how to integrate it.

Uniswap UI

What is new in UniSwap v3?

We've discussed what's new in Uniswap v2 here, but now let's see what's new in Uniswap v3:

  • A new functionality for liquidity providers that allows them to define a valid price range. Whenever the pool is currently outside of the range, their liquidity is ignored. This not only reduces the risk of impermanent loss for liquidity providers, it also is much more capital efficient since …

  • Different fee tiers which are determined by the risk level of the pool. There are three different levels:
    • Stable Pairs: 0.05%. Those fees are supposed to be for pairs which are at a low risk for fluctuations like USDT/DAI. Since both are stable coins, the potential impermanent loss of these is very low. This is particularly interesting for traders as it will allow for very cheap swaps between stable coins.
    • Medium Risk Pairs: 0.30%. The medium risk are considered any non-related pairs which have a high trading volume/popularity, Popular pairs tend to have a slightly lower risk in volatility.
    • High Risk Pairs: 1.00%. Any other exotic pairs will be considered high risk for liquidity providers and incur the highest trading fee of 1%. 


  • Improved Uniswap v2 TWAP oracle mechanism where a single on-chain call can retrieve the TWAP price with the the last 9 days. To achieve this instead of only storing one cumulative price sum, all relevant ones are stored in a fixed size array. This arguably increases gas costs slightly, but all in all worth it for the large oracle enhancement. 

Further Uniswap v3 resources

What happens to UniSwap v2?

"

Uniswap is an automated, decentralized set of smart contracts. It will continue functioning for as long as Ethereum exists.

Hayden Adams

Integrating UniSwap v3

One of the reasons Uniswap is so popular may be the simple way of integrating them into your own smart contract. Let's say you have a system where users pay with DAI. With Uniswap in just a few lines of code, you could add the option for them to also pay in ETH. The ETH can be automatically converted into DAI before the actual logic. It would look something like this

function pay(uint paymentAmountInDai) public payable {
      if (msg.value > 0) {
          convertEthToExactDai(paymentAmountInDai);
      } else {
          require(daiToken.transferFrom(msg.sender, address(this), paymentAmountInDai);
      }
      // do something with that DAI
      ...
}

A simple check at the beginning of your function will be enough. Now as for the convertEthToExactDai function, it will look like something this:

function convertEthToExactDai(uint256 daiAmount) external payable {
    require(daiAmount > 0, "Must pass non 0 DAI amount");
    require(msg.value > 0, "Must pass non 0 ETH amount");
      
    uint256 deadline = block.timestamp + 15; // using 'now' for convenience, for mainnet pass deadline from frontend!
    address tokenIn = WETH9;
    address tokenOut = multiDaiKovan;
    uint24 fee = 3000;
    address recipient = msg.sender;
    uint256 amountOut = daiAmount;
    uint256 amountInMaximum = msg.value;
    uint160 sqrtPriceLimitX96 = 0;
    
    ISwapRouter.ExactOutputSingleParams memory params = ISwapRouter.ExactOutputSingleParams(
        tokenIn,
        tokenOut,
        fee,
        recipient,
        deadline,
        amountOut,
        amountInMaximum,
        sqrtPriceLimitX96
    );
    
    uniswapRouter.exactOutputSingle{ value: msg.value }(params);
    uniswapRouter.refundETH();
    
    // refund leftover ETH to user
    (bool success,) = msg.sender.call{ value: address(this).balance }("");
    require(success, "refund failed");
}

There are several things to unpack here.

  • Swap Router: The SwapRouter will be a wrapper contract provided by Uniswap that has several safety mechanisms and convenience functions. You can instantiate it using ISwapRouter(0xE592427A0AEce92De3Edee1F18E0157C05861564) for any main or testnet. The interface code can be found here.
  • WETH: You might notice that we are using ETH here. In Uniswap there are no more direct ETH pairs, all ETH must be converted to WETH (which is ETH wrapped as ERC-20) first. In our case this is done by the router.
  • exactOutputSingle: This function can be used to use ETH and receive and exact amount of tokens for it. Any leftover ETH will be refunded, but not automatically! I didn't realize this first myself and the ETH ended up in the swap router contract. So don't forget to call uniswapRouter.refundETH() after a swap! And make sure you have a fallback function in your contract to receive ETH: receive() payable external {}.  The deadline parameter will ensure that miners cannot withhold a swap and use it at a later, more profitable time. Make sure to pass this UNIX timestamp from your frontend, don't use now inside the contract.
  • Refund: Once the trade is finished, we can return any leftover ETH to the user. This sends out all ETH from the contract, so if your contract might have an ETH balance for other reasons, make sure to change this.
  • Fee: It's a non-stable, but popular pair, so the fee we are using here is 0.3% (see fee section above).
  • sqrtPriceLimitX96: Can be used to determine limits on the pool prices which cannot  be exceeded by the swap. If you set it to 0, it's ignored.

How to use it in the frontend

One issue we have now is when a user calls the pay function and wants to pay in ETH, we don't know how much ETH he needs. We can use the quoteExactOutputSingle function to compute exactly that.

function getEstimatedETHforDAI(uint daiAmount) external payable returns (uint256) {
    address tokenIn = WETH9;
    address tokenOut = multiDaiKovan;
    uint24 fee = 500;
    uint160 sqrtPriceLimitX96 = 0;

    return quoter.quoteExactOutputSingle(
        tokenIn,
        tokenOut,
        fee,
        daiAmount,
        sqrtPriceLimitX96
    );
 }

But note that we didn't declare this as a view function, but do not call this function on-chain. It's still meant to be called as a view function, but since it's using non-view functions under the hood to compute the result, it's not possible to declare it a view function itself (Solidity feature request?). Use for example Web3's call() functionality to read the result in the frontend.

Now we can call getEstimatedETHforDAI in our frontend. To ensure we are sending enough ETH and that the transaction won't get reverted, we can increase the estimated amount of ETH by a little bit:

const requiredEth = (await myContract.getEstimatedETHforDAI(daiAmount).call())[0];
const sendEth = requiredEth * 1.1;

What if there is no direct pool for a swap available?

In this case you can use the exactInput and exactOutput functions which take a path as parameter. This path is bytes encoded data (encoded for gas efficiency) of the token addresses.

Any swap needs to have a starting and end path. While in Uniswap you can have direct token to token pairs, it is not always guaranteed that such a pool actually exists. But you may still be able to trade them as long as you can find a path, e.g., Token1 → Token2 → WETH → Token3. In that case you can still trade Token1 for Token3, it will only cost a little bit more gas than a direct swap.

On the right you can see the Uniswap example code for how to compute this path in the frontend.

function encodePath(tokenAddresses, fees) {
  const FEE_SIZE = 3

  if (path.length != fees.length + 1) {
    throw new Error('path/fee lengths do not match')
  }

  let encoded = '0x'
  for (let i = 0; i < fees.length; i++) {
    // 20 byte encoding of the address
    encoded += path[i].slice(2)
    // 3 byte encoding of the fee
    encoded += fees[i].toString(16).padStart(2 * FEE_SIZE, '0')
  }
  // encode the final token
  encoded += path[path.length - 1].slice(2)

  return encoded.toLowerCase()
}

Fully working example for Remix

Here's a fully working example you can use directly on Remix. It allows you to trade ETH for Multi-collaterized Kovan DAI. It further includes the alternative to exactOutputSingle which is exactInputSingle and allows you to trade ETH for however much DAI you'll receive for it.

// SPDX-License-Identifier: MIT
pragma solidity =0.7.6;
pragma abicoder v2;

import "https://github.com/Uniswap/uniswap-v3-periphery/blob/main/contracts/interfaces/ISwapRouter.sol";
import "https://github.com/Uniswap/uniswap-v3-periphery/blob/main/contracts/interfaces/IQuoter.sol";

interface IUniswapRouter is ISwapRouter {
    function refundETH() external payable;
}

contract Uniswap3 {
  IUniswapRouter public constant uniswapRouter = IUniswapRouter(0xE592427A0AEce92De3Edee1F18E0157C05861564);
  IQuoter public constant quoter = IQuoter(0xb27308f9F90D607463bb33eA1BeBb41C27CE5AB6);
  address private constant multiDaiKovan = 0x4F96Fe3b7A6Cf9725f59d353F723c1bDb64CA6Aa;
  address private constant WETH9 = 0xd0A1E359811322d97991E03f863a0C30C2cF029C;

  function convertExactEthToDai() external payable {
    require(msg.value > 0, "Must pass non 0 ETH amount");

    uint256 deadline = block.timestamp + 15; // using 'now' for convenience, for mainnet pass deadline from frontend!
    address tokenIn = WETH9;
    address tokenOut = multiDaiKovan;
    uint24 fee = 3000;
    address recipient = msg.sender;
    uint256 amountIn = msg.value;
    uint256 amountOutMinimum = 1;
    uint160 sqrtPriceLimitX96 = 0;
    
    ISwapRouter.ExactInputSingleParams memory params = ISwapRouter.ExactInputSingleParams(
        tokenIn,
        tokenOut,
        fee,
        recipient,
        deadline,
        amountIn,
        amountOutMinimum,
        sqrtPriceLimitX96
    );
    
    uniswapRouter.exactInputSingle{ value: msg.value }(params);
    uniswapRouter.refundETH();
    
    // refund leftover ETH to user
    (bool success,) = msg.sender.call{ value: address(this).balance }("");
    require(success, "refund failed");
  }
  
  function convertEthToExactDai(uint256 daiAmount) external payable {
    require(daiAmount > 0, "Must pass non 0 DAI amount");
    require(msg.value > 0, "Must pass non 0 ETH amount");
      
    uint256 deadline = block.timestamp + 15; // using 'now' for convenience, for mainnet pass deadline from frontend!
    address tokenIn = WETH9;
    address tokenOut = multiDaiKovan;
    uint24 fee = 3000;
    address recipient = msg.sender;
    uint256 amountOut = daiAmount;
    uint256 amountInMaximum = msg.value;
    uint160 sqrtPriceLimitX96 = 0;

    ISwapRouter.ExactOutputSingleParams memory params = ISwapRouter.ExactOutputSingleParams(
        tokenIn,
        tokenOut,
        fee,
        recipient,
        deadline,
        amountOut,
        amountInMaximum,
        sqrtPriceLimitX96
    );

    uniswapRouter.exactOutputSingle{ value: msg.value }(params);
    uniswapRouter.refundETH();

    // refund leftover ETH to user
    (bool success,) = msg.sender.call{ value: address(this).balance }("");
    require(success, "refund failed");
  }
  
  // do not used on-chain, gas inefficient!
  function getEstimatedETHforDAI(uint daiAmount) external payable returns (uint256) {
    address tokenIn = WETH9;
    address tokenOut = multiDaiKovan;
    uint24 fee = 3000;
    uint160 sqrtPriceLimitX96 = 0;

    return quoter.quoteExactOutputSingle(
        tokenIn,
        tokenOut,
        fee,
        daiAmount,
        sqrtPriceLimitX96
    );
  }
  
  // important to receive ETH
  receive() payable external {}
}

The difference between exactInput and exactOutput

Once you execute the functions and look at them in Etherscan, the difference becomes immediately obvious. Here we are trading with exactOutput. We provide 1 ETH and want to receive 100 DAI in return. Any excess amount of ETH is refunded to us.

Buy with exact DAI

And here we are trading using exactInput. We are providing 1 ETH and want to receive however much DAI we can get for it which happens to be 196 DAI.

Buy with exact ETH

Note that if you are confused why the price is so different, it's a small pool on the testnet and the first trade heavily influenced the price in the pool. Not many people arbitrage trading in a testnet. ;)

Arbitrage everywhere except testnet

Markus Waas

Solidity Developer

More great blog posts from Markus Waas

© 2024 Solidity Dev Studio. All rights reserved.

This website is powered by Scrivito, the next generation React CMS.