Post

Trading on Uniswap V3 using Python

Introduction

Let’s implement a simple1 Python script using web3.py that demonstrates how to swap ERC-20 tokens using Uniswap V3.

We’ll do this on the Sepolia network. This is one of many Ethereum Testnets, networks created for testing/development which follow the same protocol rules as the Ethereum mainnet. This way, we avoid having to use any real funds to follow this tutorial.

Specifically, we’ll buy 0.01 units of token TOK using WETH.

Here’s a summary of what we’ll do:

  1. Fund a fresh Sepolia wallet with WETH
  2. Approve spending of some of our WETH
  3. Spend WETH to buy TOK

Setting up a development environment

A GitHub repo containing the full code for this tutorial can be found here.

First, install the Python requirements specified in requirements.txt.

We’ll need access to a Sepolia testnet node. Using a 3rd party node (from services like Infura or Alchemy) is a straightforward option2. This provides us with an endpoint to Sepolia’s API. The figure below shows where you’d get this endpoint if using Infura.

sepolia_endpoint Grabbing an endpoint URL from Infura

Finally we’ll need to grab the account address and private key of our Sepolia account. If you need a primer on how to get an Ethereum account, check this. If you’re using Metamask, here’s a guide to add the Sepolia testnet to your wallet and instructions to export private keys.

Next, we’ll add everything to an .env file. Assuming we cloned or forked the fnery/uniswap-v3-swap-demo repo, we’ll create the .env file in its root directory (where the main.py file lives). It should look like this:

1
2
3
PROVIDER=https://sepolia.infura.io/v3/046c4fb87c1347c4acee712d6dc561b5
ADDRESS=0x0FFB...
PRIVATE_KEY=0c0aab...

The above contains sensitive information which should not be shared or pushed to a GitHub repo. Adding this info to a .env file (in combination with our .gitignore file) prevents us from doing so.

Funding a wallet with WETH

First, we’ll get test ETH from a faucet (such as this one). Here’s the resulting transaction. Then, we deposit some of the ETH into the WETH contract. An easy way to achieve this is by using Etherscan’s write contract feature (which requires you to connect your account), as demonstrated in the figure below.

deposit_eth Depositing ETH into the WETH contract. Resulting transaction

Once the transaction is confirmed, we should have 0.02 WETH tokens in our wallet. We’re now ready to implement the code for swapping WETH for TOK.

Implementing the swap

Swap preview on the Uniswap UI

Before starting the implementation, let’s navigate to the Uniswap UI, connect our Sepolia account, and preview the swap.

swap_uniswap Previewing the swap on the Uniswap UI

In summary:

  • We’ll buy 0.01 units of TOKEN.
  • We expect to pay 0.000226784 WETH.
  • Our trade will have a price impact of ~1.5%.
  • The trade will only execute if the slippage does not exceed 10%.

Implementation

First, let’s add our imports and constants.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import os
from web3 import Web3
from dotenv import load_dotenv

# User parameters
OUT_AMOUNT_READABLE = 0.01
POOL_ADDRESS = "0x41E3F1A4F715A5C05b1B90144902db17CA91BF5c"
MAX_SLIPPAGE = 0.1

# Other constants
load_dotenv()
PRIVATE_KEY = os.getenv("PRIVATE_KEY")
PROVIDER = os.getenv("PROVIDER")
WALLET_ADDRESS = os.getenv("ADDRESS")

POOL_ABI_PATH = "UniswapV3PoolABI.json"
QUOTER_ADDRESS = "0xEd1f6473345F45b75F8179591dd5bA1888cf2FB3"
QUOTER_ABI_PATH = "UniswapV3QuoterV2ABI.json"
ROUTER_ADDRESS = "0x3bFA4769FB09eefC5a80d6E87c3B9C650f7Ae48E"
ROUTER_ABI_PATH = "UniswapV3SwapRouter02ABI.json"
ERC20_ABI_PATH = "ERC20ABI.json"

The main swap parameters are set by the OUT_AMOUNT_READABLE (the amount of token0 we’ll purchase), POOL_ADDRESS (pool we’ll swap against) and MAX_SLIPPAGE (0.1 for a maximum allowable slippage of 10%).

Note that to keep the Python script as simple as possible, we confirmed3 that TOK (the token we want to purchase) is the pool’s token0.

Here, we also read the variables on our .env file and initialize a few required Uniswap contract addresses and paths to their Application Binary Interface files (ABIs), which we got from Etherscan (example). All the ABI files can be found here.

Let’s establish a connection with the node provider:

1
web3 = Web3(Web3.HTTPProvider(PROVIDER))

We’ll initialize the pool contract and read some of its data (stored on the blockchain): the pool fee and sqrtPriceX96 (we’ll get the price of TOK in terms of WETH from the latter4).

1
2
3
pool = web3.eth.contract(address=POOL_ADDRESS, abi=load_abi(POOL_ABI_PATH))
fee = pool.functions.fee().call()
sqrtPriceX96 = pool.functions.slot0().call()[0]

Above, load_abi is just a simple function to read the ABI files:

1
2
3
def load_abi(abi_path):
    with open(abi_path, "r") as file:
        return file.read()

Now let’s retrieve and derive data related to the tokens in the pool:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# Assume token going "out" of the pool (being purchased) is token0
out_address = pool.functions.token0().call()
out_contract = web3.eth.contract(address=out_address, abi=load_abi(ERC20_ABI_PATH))
out_symbol = out_contract.functions.symbol().call()
out_decimals = out_contract.functions.decimals().call()
out_amount = int(OUT_AMOUNT_READABLE * 10**out_decimals)
out_price = (sqrtPriceX96 / (2**96)) ** 2  # price of token0 in terms of token1

# Assume token going "in" to the pool (being sold) is token1
in_address = pool.functions.token1().call()
in_contract = web3.eth.contract(address=in_address, abi=load_abi(ERC20_ABI_PATH))
in_symbol = in_contract.functions.symbol().call()
in_decimals = in_contract.functions.decimals().call()
in_amount = int(out_amount * out_price)
in_amount_readable = in_amount / 10**in_decimals

For each token, we’ll get its address, token symbol and swap amounts. We’ll also get the token decimals, which we’ll use to convert human-readable token amounts (variables with _readable suffix) to and from their corresponding amounts used within the smart contract’s logic (large integer values)5.

The exact price we’ll pay for the TOK tokens is not exactly what we obtain in out_price because our trade will itself move the prices within the pool. Let’s use the QuoterV2 contract to get an estimate of the amount of WETH we’ll have to pay for the desired amount of TOK tokens, which will also allow us to estimate the price impact of our swap.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
quoter = web3.eth.contract(address=QUOTER_ADDRESS, abi=load_abi(QUOTER_ABI_PATH))
quote = quoter.functions.quoteExactOutputSingle(
    [
        in_address,
        out_address,
        out_amount,
        fee,
        0,  # sqrtPriceLimitX96: a value of 0 makes this parameter inactive
    ]
).call()

in_amount_expected = quote[0]
in_amount_expected_readable = in_amount_expected / 10**in_decimals

price_impact = (in_amount_expected - in_amount) / in_amount

print(
    f"Expected amount to pay: {in_amount_expected_readable} {in_symbol} (price impact: {price_impact:.2%})"
)

Note the last statement, which prints:

Expected amount to pay: 0.000226784946144417 WETH (price impact: 1.55%)

This is consistent with what we saw in the Uniswap UI in the figure above.

Now let’s specify the maximum WETH amount we’re willing to pay for the desired TOK tokens. We’ll derive this from the price of TOK before executing the swap and the maximum allowed slippage we defined with MAX_SLIPPAGE:

1
2
3
4
5
6
in_amount_max = int(out_amount * out_price * (1 + MAX_SLIPPAGE))
in_amount_max_readable = in_amount_max / 10**in_decimals

print(
    f"Max. amount to pay: {in_amount_max_readable} {in_symbol} (max. slippage: {MAX_SLIPPAGE:.2%})"
)

Max. amount to pay: 0.000245650082067667 WETH (max. slippage: 10.00%)

Therefore, if anything (including factors beyond our own swap6) causes the TOK price to change in such a way that we’d have to pay more than ~0.00024 WETH for the desired TOK tokens, the swap will not be executed.

At this stage, only one thing is left to do before we can execute the swap, which is to approve spending of our WETH tokens by the Uniswap SwapRouter02 contract (whose address is given by ROUTER_ADDRESS)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
# Approval transaction
transaction = in_contract.functions.approve(
    ROUTER_ADDRESS, in_amount_max
).build_transaction(
    {
        "chainId": web3.eth.chain_id,
        "gas": int(1e7),
        "gasPrice": web3.eth.gas_price,
        "nonce": web3.eth.get_transaction_count(WALLET_ADDRESS),
    }
)
signed_txn = web3.eth.account.sign_transaction(transaction, private_key=PRIVATE_KEY)
tx_hash = web3.eth.send_raw_transaction(signed_txn.rawTransaction)
tx_receipt = web3.eth.wait_for_transaction_receipt(tx_hash)

Above, we call the approve method in the contract of the token going into the pool (i.e. WETH) to specify that we allow the router contract to spend WETH up to the amount given by in_amount_max, which accounts for the maximum allowable slippage.

Finally, we can execute the swap:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
# Swap transactions
router = web3.eth.contract(address=ROUTER_ADDRESS, abi=load_abi(ROUTER_ABI_PATH))
transaction = router.functions.exactOutputSingle(
    [
        in_address,
        out_address,
        fee,
        WALLET_ADDRESS,
        out_amount,
        in_amount_max,
        0,  # sqrtPriceLimitX96: a value of 0 makes this parameter inactive
    ]
).build_transaction(
    {
        "chainId": web3.eth.chain_id,
        "gas": int(1e7),
        "gasPrice": web3.eth.gas_price,
        "nonce": web3.eth.get_transaction_count(WALLET_ADDRESS),
    }
)
signed_txn = web3.eth.account.sign_transaction(transaction, private_key=PRIVATE_KEY)
tx_hash = web3.eth.send_raw_transaction(signed_txn.rawTransaction)
tx_receipt = web3.eth.wait_for_transaction_receipt(tx_hash)

We want to buy (i.e. for the pool to output) an exact amount of TOK, so we call the exactOutputSingle method from the SwapRouter02 contract. Parameters include the addresses of both tokens involved in the swap and the pool fee (these allow the router to determine the pool to use), the address of our wallet, as well as the exact amount TOK we’ll buy and maximum amount of WETH we’re willing to pay. For simplicity, the parameter sqrtPriceLimitX96 is left inactive (not advisable for production environments).

Executing the swap

Putting everything together (as well as some helper functions used in the snippets above), we arrive at the final main.py script that lives here.

Let’s run it:

1
2
3
4
5
6
7
$ python3 main.py
web3 connection successful
Goal: Buy 0.01 TOK (worth 0.000223318256425151 WETH)
Expected amount to pay: 0.000226784946144417 WETH (price impact: 1.55%)
Max. amount to pay: 0.000245650082067667 WETH (max. slippage: 10.00%)
Approval transaction 0xb221cc3ba27da14d5053ed11d82a739b9d69e52950488c30306a7541b26e3580 successful
Swap transaction 0xec95f9a985b82a3dd2195b4256a1a7cb270c655647c635e3acefdebb84789672 successful

Two transactions were successfully submitted and executed:


  1. This is a simple toy implementation and should not be used to trade tokens with any value! 

  2. If you’re curious about running your own node, check this out. 

  3. To confirm, call the token0 method on the pool’s contract, e.g. on Etherscan, which returns the address of TOK

  4. Here’s a primer on Uniswap V3 math. 

  5. More on decimals here

  6. Onchain activity can change the market conditions within the time between transaction submission and its verification (e.g. frontrunning attacks). 

This post is licensed under CC BY 4.0 by the author.

© Fabio Nery. Some rights reserved.

‏‏‎ ‎