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);
}
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
)
)
)
)
);
}
}
β οΈThe
computeAddress
function in the above code is different than that of the source. I had to further wrap the hash withuint160
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