Create a Flash Loan with Uniswap V3

Create a Flash Loan with Uniswap V3

Β·

13 min read

In this article, I will show you how to create a flash loan with Uniswap v3.

Before getting started, I am going to assume you already have node and npm installed and are somewhat familiar with the solidity programming language.

What will you learn from this article?

  • Setting up a hardhat project from scratch

  • Using Alchemy and Hardhat to fork mainnet

  • How to leverage Uniswap v3 Flash Swaps to create a flash loan.

Let's get started! Fire up a terminal πŸ–₯️

Setting up a hardhat project

First, create a directory for your project cd into it and use npm init -y to generate a package.json file.

> mkdir uniswapv3-flashloan
> cd uniswapv3-flashloan
> npm init -y

Next, go ahead and install Hardhat.

> npm install --save-dev hardhat

You can now use npx hardhat to generate a Hardhat project. Following the instructions create a TypeScript project and answer yes to the rest of the questions. This will generate the necessary files and install the required dependencies.

> npx hardhat      
888    888                      888 888               888
888    888                      888 888               888
888    888                      888 888               888
8888888888  8888b.  888d888 .d88888 88888b.   8888b.  888888
888    888     "88b 888P"  d88" 888 888 "88b     "88b 888
888    888 .d888888 888    888  888 888  888 .d888888 888
888    888 888  888 888    Y88b 888 888  888 888  888 Y88b.
888    888 "Y888888 888     "Y88888 888  888 "Y888888  "Y888

Welcome to Hardhat v2.12.3

βœ” What do you want to do? Β· Create a TypeScript project
βœ” Hardhat project root: Β· /home/alex/Code/Sandbox/uniswapv3-flashloan
βœ” Do you want to add a .gitignore? (Y/n) Β· y
βœ” Do you want to install this sample project's dependencies with npm (@nomicfoundation/hardhat-toolbox)? (Y/n) Β· y

At this point, you should have a directory structure that looks like this.

.
β”œβ”€β”€ contracts
β”œβ”€β”€ .gitignore
β”œβ”€β”€ hardhat.config.ts
β”œβ”€β”€ node_modules
β”œβ”€β”€ package.json
β”œβ”€β”€ package-lock.json
β”œβ”€β”€ README.md
β”œβ”€β”€ scripts
β”œβ”€β”€ test
└── tsconfig.json

If you'd like you can delete the sample code that was generated in the contracts , scripts and test directories.

Great, you now have a TypeScript Hardhat project, ready to go!

Forking Minnet

Hardhat allows you to create a fork of mainnet locally. Essentially what this means is you can interact with contracts that are on the mainnet using your local set-up.

In this section, you'll create an application in Alchemy, generate an API key and make some modifications to the hardhat.config.ts file.

1. Create an Alchemy Application

If you haven't already created an account on Alchemy, go ahead and sign up. Once you have logged in, click the create app button.

Make sure you selected Ethereum for the chain and Mainnet for the network.

Once you have created the application click on View Details and then View Key.

Copy the HTTPS URL. Keep this handy you will use it in the section that follows.

2. Create a .env file

You wouldn't want to share your API key with the whole world. To avoid this you will create a .env file, this SHOULD NOT be committed to version control. Double-check that you have .gitignore a file at the root of your project with the following content.

node_modules
.env
coverage
coverage.json
typechain
typechain-types

# Hardhat files
cache
artifacts

Create a .env file with only one environment variable MAINNET_URL and paste the URL that you copied from Alchemy in the previous section. You should end up with something like this.

MAINNET_URL=https://eth-mainnet.g.alchemy.com/v2/<api_key>

Remember to replace <api_key> with the actual API key, you copied before.

3. Modify the hardhat.config.ts file

To read environment variables from a .env file you can make use of a package called dotenv. Go ahead and install that package now.

> npm install --save-dev dotenv

Finally, modify the hardhat.config.ts file to look like this.

import * as dotenv from "dotenv";

import { HardhatUserConfig } from "hardhat/config";
import "@nomicfoundation/hardhat-toolbox";

dotenv.config();

const config: HardhatUserConfig = {
  solidity: "0.8.17",
  networks: {
    hardhat: {
      forking: {
        url: process.env.MAINNET_URL || "",
      },
    },
  },
};

export default config;

And voila you now have a Hardhat project that forks mainnet!

Creating a Flash Loan Contract

Finally, it's time for the juicy parts! In this section, you will build the contract that will perform the flash loan on Uniswap V3.

Before diving into creating the contract a little bit of theory might help you better understand what we doing here. For each token pair, Uniswap has what is called a liquidity pool. For example, let's say you have ETH/USDC token pair, you could think of the liquidity pool as one big vault that is split into two sections. One section would hold X amount of ETH while the other section would hold the equivalent amount in USDC.

When you create a flash loan on Uniswap essentially what you doing is taking liquidity out of the pool and putting it back in a single transaction. Using the vault example above, you borrow some ETH or USDC from the vault and immediately put it back before anyone notices.

You could also borrow both ETH and USDC in the same transaction, as long as you have enough of the same token to pay the cost of the fees.

So as promised now for the juice!

The complete contract including the supporting interface and libraries will look like the below.

pragma solidity 0.8.17;

import "hardhat/console.sol";

import "./interfaces/IERC20.sol";
import "./interfaces/IUniswapV3Pool.sol";
import "./libraries/PoolAddress.sol";

contract Flashloan {
    address private constant FACTORY =
        0x1F98431c8aD98523631AE4a59f267346ea31F984;

    struct FlashCallbackData {
        uint256 amount0;
        uint256 amount1;
        address caller;
    }

    IERC20 private immutable token0;
    IERC20 private immutable token1;

    IUniswapV3Pool private immutable pool;

    constructor(
        address _token0,
        address _token1,
        uint24 _fee
    ) {
        token0 = IERC20(_token0);
        token1 = IERC20(_token1);
        pool = IUniswapV3Pool(getPool(_token0, _token1, _fee));
    }

    function getPool(
        address _token0,
        address _token1,
        uint24 _fee
    ) public pure returns (address) {
        PoolAddress.PoolKey memory poolKey = PoolAddress.getPoolKey(
            _token0,
            _token1,
            _fee
        );
        return PoolAddress.computeAddress(FACTORY, poolKey);
    }

    function flash(uint256 amount0, uint256 amount1) external {
        bytes memory data = abi.encode(
            FlashCallbackData({
                amount0: amount0,
                amount1: amount1,
                caller: msg.sender
            })
        );
        IUniswapV3Pool(pool).flash(address(this), amount0, amount1, data);
    }

    function uniswapV3FlashCallback(
        uint256 fee0,
        uint256 fee1,
        bytes calldata data
    ) external {
        require(msg.sender == address(pool), "not authorized");

        FlashCallbackData memory decoded = abi.decode(
            data,
            (FlashCallbackData)
        );

        // Do your abitrage below...

        console.log(token0.balanceOf(address(this)));
        console.log(token1.balanceOf(address(this)));

        // Repay borrow
        if (fee0 > 0) {
            token0.transferFrom(decoded.caller, address(this), fee0);
            token0.transfer(address(pool), decoded.amount0 + fee0);
        }
        if (fee1 > 0) {
            token1.transferFrom(decoded.caller, address(this), fee1);
            token1.transfer(address(pool), decoded.amount1 + fee1);
        }
    }
}

Inside the contracts directory create a new file called Flashloan.sol and copy the above contents into it. While you are at it go ahead and create two new directories called interfaces and libraries you will make use of this shortly.

Let's walk through the important parts of the contract.

address private constant FACTORY = 0x1F98431c8aD98523631AE4a59f267346ea31F984;

This is the address of the UniswapV3Factory contract. You can find a complete list of deployed contracts and their address in the Uniswap documentation here.

Moving on to the constructor.

    ...
    constructor(
        address _token0,
        address _token1,
        uint24 _fee
    ) {
        token0 = IERC20(_token0);
        token1 = IERC20(_token1);
        pool = IUniswapV3Pool(getPool(_token0, _token1, _fee));
    }
    ...

When deploying the contract the constructor takes three parameters. _token0 and _token1 are the addresses of the tokens that will form a pair, as mentioned at the beginning of this section. The _fee refers to the swapping fee of the token pair pool. As of Uniswap v3, there are four fee levels 0.01%, 0.05%, 0.30%, and 1%.

You will also notice the use of two interfaces in the constructor IERC20 and IUniswapV3Pool.

Inside of the interfaces directory you created previously two new files, IERC20.sol and IUniswapV3Pool.sol.

The IERC20 interface will allow you to interact with all tokens that have implemented the ERC-20 standard. Copy the below content into your IER20.sol file.

// SPDX-License-Identifier: MIT
// OpenZeppelin Contracts (last updated v4.6.0) (token/ERC20/IERC20.sol)

pragma solidity ^0.8.0;

/**
 * @dev Interface of the ERC20 standard as defined in the EIP.
 */
interface IERC20 {
    /**
     * @dev Emitted when `value` tokens are moved from one account (`from`) to
     * another (`to`).
     *
     * Note that `value` may be zero.
     */
    event Transfer(address indexed from, address indexed to, uint256 value);

    /**
     * @dev Emitted when the allowance of a `spender` for an `owner` is set by
     * a call to {approve}. `value` is the new allowance.
     */
    event Approval(
        address indexed owner,
        address indexed spender,
        uint256 value
    );

    /**
     * @dev Returns the amount of tokens in existence.
     */
    function totalSupply() external view returns (uint256);

    /**
     * @dev Returns the amount of tokens owned by `account`.
     */
    function balanceOf(address account) external view returns (uint256);

    /**
     * @dev Moves `amount` tokens from the caller's account to `to`.
     *
     * Returns a boolean value indicating whether the operation succeeded.
     *
     * Emits a {Transfer} event.
     */
    function transfer(address to, uint256 amount) external returns (bool);

    /**
     * @dev Returns the remaining number of tokens that `spender` will be
     * allowed to spend on behalf of `owner` through {transferFrom}. This is
     * zero by default.
     *
     * This value changes when {approve} or {transferFrom} are called.
     */
    function allowance(address owner, address spender)
        external
        view
        returns (uint256);

    /**
     * @dev Sets `amount` as the allowance of `spender` over the caller's tokens.
     *
     * Returns a boolean value indicating whether the operation succeeded.
     *
     * IMPORTANT: Beware that changing an allowance with this method brings the risk
     * that someone may use both the old and the new allowance by unfortunate
     * transaction ordering. One possible solution to mitigate this race
     * condition is to first reduce the spender's allowance to 0 and set the
     * desired value afterwards:
     * https://github.com/ethereum/EIPs/issues/20#issuecomment-263524729
     *
     * Emits an {Approval} event.
     */
    function approve(address spender, uint256 amount) external returns (bool);

    /**
     * @dev Moves `amount` tokens from `from` to `to` using the
     * allowance mechanism. `amount` is then deducted from the caller's
     * allowance.
     *
     * Returns a boolean value indicating whether the operation succeeded.
     *
     * Emits a {Transfer} event.
     */
    function transferFrom(
        address from,
        address to,
        uint256 amount
    ) external returns (bool);
}

source

Similar to the IERC20 interface, the IUniswapV3Pool interface is used to interact with theUniswapV3Pool contract. You can find a reference to this on Uniswaps' GitHub repository here. However, the only action you should be interested in is the flash function, which is what you will use to perform the flash loan. A more simplified version of this interface would look like this.

// SPDX-License-Identifier: GPL-2.0-or-later
pragma solidity >=0.5.0;

/**
 * @dev Simplified IUniswapV3Pool interface with only the flash action.
 */
interface IUniswapV3Pool {
    /// @notice Receive token0 and/or token1 and pay it back, plus a fee, in the callback
    /// @dev The caller of this method receives a callback in the form of IUniswapV3FlashCallback#uniswapV3FlashCallback
    /// @dev Can be used to donate underlying tokens pro-rata to currently in-range liquidity providers by calling
    /// with 0 amount{0,1} and sending the donation amount(s) from the callback
    /// @param recipient The address which will receive the token0 and token1 amounts
    /// @param amount0 The amount of token0 to send
    /// @param amount1 The amount of token1 to send
    /// @param data Any data to be passed through to the callback
    function flash(
        address recipient,
        uint256 amount0,
        uint256 amount1,
        bytes calldata data
    ) external;
}

Go ahead and copy the content above into your IUniswapV3Pool.sol file.

The getPool function is responsible for calculating the token pair pool address.

    ...
    function getPool(
        address _token0,
        address _token1,
        uint24 _fee
    ) public pure returns (address) {
        PoolAddress.PoolKey memory poolKey = PoolAddress.getPoolKey(
            _token0,
            _token1,
            _fee
        );
        return PoolAddress.computeAddress(FACTORY, poolKey);
    }
    ...

To do this you need to make use of the PoolAddress library provided by Uniswap. Inside the libraries directory, create a new file called PoolAddress.sol. Copy the content below into the file you just creates.

// SPDX-License-Identifier: GPL-2.0-or-later
pragma solidity >=0.5.0;

/// @title Provides functions for deriving a pool address from the factory, tokens, and the fee
library PoolAddress {
    bytes32 internal constant POOL_INIT_CODE_HASH = 0xe34f199b19b2b4f47f68442619d555527d244f78a3297ea89325f843f87b8b54;

    /// @notice The identifying key of the pool
    struct PoolKey {
        address token0;
        address token1;
        uint24 fee;
    }

    /// @notice Returns PoolKey: the ordered tokens with the matched fee levels
    /// @param tokenA The first token of a pool, unsorted
    /// @param tokenB The second token of a pool, unsorted
    /// @param fee The fee level of the pool
    /// @return Poolkey The pool details with ordered token0 and token1 assignments
    function getPoolKey(
        address tokenA,
        address tokenB,
        uint24 fee
    ) internal pure returns (PoolKey memory) {
        if (tokenA > tokenB) (tokenA, tokenB) = (tokenB, tokenA);
        return PoolKey({token0: tokenA, token1: tokenB, fee: fee});
    }

    /// @notice Deterministically computes the pool address given the factory and PoolKey
    /// @param factory The Uniswap V3 factory contract address
    /// @param key The PoolKey
    /// @return pool The contract address of the V3 pool
    function computeAddress(address factory, PoolKey memory key) internal pure returns (address pool) {
        require(key.token0 < key.token1);
        pool = address(
            uint160(
                uint256(
                    keccak256(
                        abi.encodePacked(
                            hex'ff',
                            factory,
                            keccak256(abi.encode(key.token0, key.token1, key.fee)),
                            POOL_INIT_CODE_HASH
                        )
                    )
                )
            )
        );
    }
}

source.

⚠️The computeAddress function in the above code is different than that of the source. I had to further wrap the hash with uint160 to get the contract to correctly compile. Further info can be found here.

Next is the flash function, this is the actual function that creates the flash loan.

    ...
    function flash(uint256 amount0, uint256 amount1) external {
        bytes memory data = abi.encode(
            FlashCallbackData({
                amount0: amount0,
                amount1: amount1,
                caller: msg.sender
            })
        );
        IUniswapV3Pool(pool).flash(address(this), amount0, amount1, data);
    }
    ...

The amount0 and amount1 correspond directly to the amount of token0 and/or token1 you wish to borrow. Essentially all this function does is call flash on the token pair pool.

To successfully get the amount you requested in your flash loan your contract must include a callback function name uniswapV3FlashCallback . This leads us to the final part of the contract.

    ...
    function uniswapV3FlashCallback(
        uint256 fee0,
        uint256 fee1,
        bytes calldata data
    ) external {
        require(msg.sender == address(pool), "not authorized");

        FlashCallbackData memory decoded = abi.decode(
            data,
            (FlashCallbackData)
        );

        // Do your abitrage below...

        console.log(token0.balanceOf(address(this)));
        console.log(token1.balanceOf(address(this)));

        // Repay borrow
        if (fee0 > 0) {
            token0.transferFrom(decoded.caller, address(this), fee0);
            token0.transfer(address(pool), decoded.amount0 + fee0);
        }
        if (fee1 > 0) {
            token1.transferFrom(decoded.caller, address(this), fee1);
            token1.transfer(address(pool), decoded.amount1 + fee1);
        }
    }
    ...

At this point, you are free to do what you want with the borrowed amount. Maybe leverage your position while yield farming or taking advantage of an arbitrage opportunity. As long as you cover the gas fees and pay back the amount borrowed in a single transaction.

As a final step to see that everything works you going to create a script that deploys the contract and executes the flash function locally.

Inside the scripts directory, create a file and call it flashloan.ts with the below contents.

import { ethers } from "hardhat";

async function main() {
  const WETH_ADDRESS = "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2";
  const USDC_ADDRESS = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48";
  const POOL_FEE = 3000; // 0.30% tier

  // Need this to convert ETH into WETH (Wrapped Ether) to cover the fees
  const weth = await ethers.getContractAt("IWETH", WETH_ADDRESS);

  // Deploy Flashloan contract
  const Flashloan = await ethers.getContractFactory("Flashloan");
  const flashloan = await Flashloan.deploy(
    USDC_ADDRESS,
    WETH_ADDRESS,
    POOL_FEE
  );

  // Get some WETH to cover fee and approve Flashloan contract to use it.
  // Fee: 1 ETH * 0.3% = 0.003 ETH
  await weth.deposit({ value: ethers.utils.parseEther("0.003") });
  await weth.approve(flashloan.address, ethers.utils.parseEther("0.003"));

  // Execute flashloan to borrow 1 ETH.
  await flashloan.flash(0, ethers.utils.parseEther("1"));
}

// We recommend this pattern to be able to use async/await everywhere
// and properly handle errors.
main().catch((error) => {
  console.error(error);
  process.exitCode = 1;
});

Open up your terminal cd to the project root and run npx hardhat run scripts/flashloan.ts. With any luck, you should see the console logs from within the contract.

❯ npx hardhat run scripts/flashloan.ts          
0
1000000000000000000

Conclusion

Give yourself a hand πŸ‘ for making it this far, you just created a flash loan contract using Uniswap v3!

As a quick recap, you created a Hardhat project, set things up to fork mainnet and finally deployed and executed your contract locally.

Happy hacking!

If you enjoyed reading this article and would like to stay tuned for more, or just want to connect, follow me on Twitter @alexvanzyl

Β