From abb2c8d105d8588811cf9bd33575fddd9f14be50 Mon Sep 17 00:00:00 2001 From: Mikko Ohtamaa Date: Wed, 16 Aug 2023 15:10:28 +0200 Subject: [PATCH] A Python tutorial to make a swap on Uniswap DEX or other DEX (#149) --- docs/source/tutorials/index.rst | 22 +- docs/source/tutorials/live-price.rst | 2 + docs/source/tutorials/live-swap.rst | 2 + .../tutorials/make-uniswap-swap-in-python.rst | 54 +++++ docs/source/tutorials/transfer.rst | 8 +- eth_defi/confirmation.py | 2 + eth_defi/uniswap_v2/swap.py | 7 +- scripts/make-swap-on-pancake.py | 229 ++++++++++++++++++ tests/aave_v3/test_aave_v3_reserve_data.py | 2 +- 9 files changed, 312 insertions(+), 16 deletions(-) create mode 100644 docs/source/tutorials/make-uniswap-swap-in-python.rst create mode 100644 scripts/make-swap-on-pancake.py diff --git a/docs/source/tutorials/index.rst b/docs/source/tutorials/index.rst index 0da3615f..904016ef 100644 --- a/docs/source/tutorials/index.rst +++ b/docs/source/tutorials/index.rst @@ -1,6 +1,8 @@ .. meta:: :description: Python and Pandas examples for blockchain data research + :title: Web3 tutorials for Python +.. _tutorials: Tutorials ========= @@ -13,22 +15,21 @@ Examples include * Performing ERC-20 token transfers +* Making swaps on Uniswap, Pancakeswap, others + * Data research with Jupyter Notebooks and Pandas +`For any questions please join to Discord chat `__. + Prerequisites ------------- -Make sure you know how to install packages (pip, poetry) -and use Python virtual environments. - -To run the scripts you need to be able to understand -how Python packaging works and how to install additional modules. - -Install the package with data addons: - -.. code-block:: shell +- You need to know UNIX command line basics, like how to use environment variables. + Microsoft Windows is fine, but we do not test or write any instructions for it, so please + consult your local Windows expert for any Windows specific questions. - pip install "web3-ethereum-defi[data]" +- Make sure you know how to install packages (pip, poetry) + and use Python virtual environments. `See Github README for details `__. Example tutorials ----------------- @@ -37,6 +38,7 @@ Example tutorials :maxdepth: 1 transfer + make-uniswap-swap-in-python multithread-reader verify-node-integrity live-price diff --git a/docs/source/tutorials/live-price.rst b/docs/source/tutorials/live-price.rst index 0ac707f8..6ff8bd3c 100644 --- a/docs/source/tutorials/live-price.rst +++ b/docs/source/tutorials/live-price.rst @@ -29,5 +29,7 @@ Sample output: Block 19,937,848 at 2022-07-28 06:16:16 current price:269.3162 WBNB/BUSD TWAP:269.3539 WBNB/BUSD Oracle data updates: Counter({'created': 6, 'discarded': 1, 'reorgs': 0}), trades in TWAP buffer:144, oldest:2022-07-28 06:11:16, newest:2022-07-28 06:16:13 +`For any questions please join to Discord chat `__. + .. literalinclude:: ../../../scripts/uniswap-v2-swaps-live.py :language: python diff --git a/docs/source/tutorials/live-swap.rst b/docs/source/tutorials/live-swap.rst index d79e6e60..6007b557 100644 --- a/docs/source/tutorials/live-swap.rst +++ b/docs/source/tutorials/live-swap.rst @@ -37,6 +37,8 @@ good free RPC nodes which makes running the example code easy. the startup is a bit slow as the pair details cache is warming up. +- `For any questions please join to Discord chat `__. + To run for Polygon (and QuickSwap): .. code-block:: shell diff --git a/docs/source/tutorials/make-uniswap-swap-in-python.rst b/docs/source/tutorials/make-uniswap-swap-in-python.rst new file mode 100644 index 00000000..9eb58825 --- /dev/null +++ b/docs/source/tutorials/make-uniswap-swap-in-python.rst @@ -0,0 +1,54 @@ +.. meta:: + :title: How to swap tokens on Uniswap and DEXes using Python + :description: Python token swap tutorial with slippage protection + + +Swap tokens on Uniswap v2 compatible DEXes +------------------------------------------ + +This is an simple example script to swap one token to another securely. +It works on any `Uniswap v2 compatible DEX `__. +For this particular example, we use PancakeSwap on Binance Smart Chain, +but you can reconfigure the script for any Uniswap v2 compatible protocol +on any `EVM-compatible `__ blockchain. +The script is set up to swap from BUSD to Binance-custodied ETH. + +- First :ref:`Read tutorials section for required Python knowledge, version and how to install related packages `. +- In order to run this example script, you need to have + - Private key on BNB Smart Chain with BNB balance, `you can generate a private key on a command line using these instructions `__. + - `Binance Smart Chain JSON-RPC node `__. You can use public ones. + - BUSD balance (you can swap some BNB on BUSD manually by importing your private key to a wallet). + - Easy way to get few dollars worth of starting tokens is `Transak `__. Buy popular tokens with debit card - Transak supports buying tokens natively for many blockchains. + - Easy way to to manually swap between BUSD/BNB/other native gas token is to import your private key to `Rabby desktop wallet `__ and use Rabby's built-in swap and trade aggregator function. + +This script will + +- Sets up a private key with BNB gas money + +- Sets up a PancakeSwap instance + +- Perform a swap from BUSD (`base token `__) to + Binance-custodied ETH (`quote token `__) for any amount of tokens you enter + +- `Uses slippage protection `__ + for the swap so that you do not get exploited by `MEV bots `__ + +- Wait for the transactions to complete and display the reason if the trade failed + +To find tokens to trade you can use `Trading Strategy search `__ +or `market data listings `__. +`For any questions please join to Discord chat `__. + +To run: + +.. code-block:: shell + + export JSON_RPC_BINANCE="https://bsc-dataseed.bnbchain.org" + export PRIVATE_KEY="your private key here" + python scripts/make-swap-on-pancake.py + +Example script +~~~~~~~~~~~~~~ + +.. literalinclude:: ../../../scripts/make-swap-on-pancake.py + :language: python diff --git a/docs/source/tutorials/transfer.rst b/docs/source/tutorials/transfer.rst index 08abb464..2d0a9fcc 100644 --- a/docs/source/tutorials/transfer.rst +++ b/docs/source/tutorials/transfer.rst @@ -19,6 +19,8 @@ You need - Understanding how to operate command line and command line applications +`For any questions please join to Discord chat `__. + Set up ~~~~~~ @@ -59,7 +61,7 @@ Then create the following script: from eth_defi.abi import get_deployed_contract from eth_defi.token import fetch_erc20_details - from eth_defi.txmonitor import wait_transactions_to_complete + from eth_defi.confirmation import wait_transactions_to_complete # What is the token we are transferring. # Replace with your own token address. @@ -84,7 +86,7 @@ Then create the following script: print(f"Token details are {token_details}") balance = erc_20.functions.balanceOf(account.address).call() - eth_balance = web3.eth.getBalance(account.address) + eth_balance = web3.eth.get_balance(account.address) print(f"Your balance is: {token_details.convert_to_decimals(balance)} {token_details.symbol}") print(f"Your have {eth_balance/(10**18)} ETH for gas fees") @@ -99,7 +101,7 @@ Then create the following script: except ValueError as e: raise AssertionError(f"Not a good decimal amount: {decimal_amount}") from e - assert web3.isChecksumAddress(to_address), f"Not a valid address: {to_address}" + assert web3.is_checksum_address(to_address), f"Not a checksummed Ethereum address: {to_address}" # Fat-fingering check print(f"Confirm transferring {decimal_amount} {token_details.symbol} to {to_address}") diff --git a/eth_defi/confirmation.py b/eth_defi/confirmation.py index b68fabb3..f7ec830d 100644 --- a/eth_defi/confirmation.py +++ b/eth_defi/confirmation.py @@ -51,9 +51,11 @@ def wait_transactions_to_complete( :param txs: List of transaction hashes + :param confirmation_block_count: How many blocks wait for the transaction receipt to settle. Set to zero to return as soon as we see the first transaction receipt. + :return: Map of transaction hashes -> receipt """ diff --git a/eth_defi/uniswap_v2/swap.py b/eth_defi/uniswap_v2/swap.py index 4c3c7fb3..575df367 100644 --- a/eth_defi/uniswap_v2/swap.py +++ b/eth_defi/uniswap_v2/swap.py @@ -4,6 +4,7 @@ from eth_typing import HexAddress from web3.contract import Contract +from web3.contract.contract import ContractFunction from eth_defi.uniswap_v2.deployment import FOREVER_DEADLINE, UniswapV2Deployment from eth_defi.uniswap_v2.fees import estimate_buy_price, estimate_sell_price @@ -21,7 +22,7 @@ def swap_with_slippage_protection( amount_out: Optional[int] = None, fee: int = 30, deadline: int = FOREVER_DEADLINE, -) -> Callable: +) -> ContractFunction: """Helper function to prepare a swap from quote token to base token (buy base token with quote token) with price estimation and slippage protection baked in. @@ -97,10 +98,12 @@ def swap_with_slippage_protection( :param amount_in: How much of the quote token we want to pay, this has to be `None` if `amount_out` is specified. + Must be in raw quote token units. :param amount_out: How much of the base token we want to receive, this has to be `None` if `amount_in` is specified + Must be in raw base token units. :param max_slippage: @@ -113,7 +116,7 @@ def swap_with_slippage_protection( Time limit of the swap transaction, by default = forever (no deadline) :return: - Prepared swap function which can be used directly to build transaction + Bound ContractFunction that can be used to build a transaction """ assert fee > 0, "fee must be non-zero" diff --git a/scripts/make-swap-on-pancake.py b/scripts/make-swap-on-pancake.py new file mode 100644 index 00000000..6b542570 --- /dev/null +++ b/scripts/make-swap-on-pancake.py @@ -0,0 +1,229 @@ +"""Make a swap on PancakeSwap Python example script. + +This is an simple example script to swap one token to another securely. +It works on any `Uniswap v2 compatible DEX `__. +For this particular example, we use PancakeSwap on Binance Smart Chain, +but you can reconfigure the script for any Uniswap v2 compatible protocol +on any `EVM-compatible `__ blockchain. + +- :ref:`Read tutorials section for required Python knowledge, version and how to install related packages ` + +- In order to run this example script, you need to have + - Private key on BNB Smart Chain with BNB balance, + `you can generate a private key on a command line using these instructions `__. + - `Binance Smart Chain JSON-RPC node `. You can use public ones. + - BUSD balance (you can swap some BNB on BUSD manually by importing your private key to a wallet) + - Easy way to get few dollars worth of starting tokens is https://global.transak.com/ + with debit card - they support buying tokens natively for many blockchains. + - Easy way to to manually swap is to import your private key to `Rabby desktop wallet `__. + +This script will + +- Sets up a private key with BNB gas money + +- Sets up PancakeSwap instance + +- Makes a swap from BUSD (base token) to Binance custodied ETH (quote token) for + any amount of tokens you input + +- `Uses slippage protection `__ + for the swap so that you do not get exploited by `MEV bots `__ + +- Wait for the transaction to complete and display the reason if the trade succeeded or failed + +To run: + +.. code-block:: shell + + export JSON_RPC_BINANCE="https://bsc-dataseed.bnbchain.org" + export PRIVATE_KEY="your private key here" + python scripts/make-swap-on-pancake.py + +""" + +import datetime +import os +import sys +from decimal import Decimal + +from eth_account import Account +from eth_account.signers.local import LocalAccount +from web3 import HTTPProvider, Web3 +from web3.middleware import construct_sign_and_send_raw_middleware + +from eth_defi.chain import install_chain_middleware +from eth_defi.gas import node_default_gas_price_strategy +from eth_defi.revert_reason import fetch_transaction_revert_reason +from eth_defi.token import fetch_erc20_details +from eth_defi.confirmation import wait_transactions_to_complete +from eth_defi.uniswap_v2.deployment import fetch_deployment +from eth_defi.uniswap_v2.swap import swap_with_slippage_protection + +# The address of a token we are going to swap out +# +# Use https://tradingstrategy.ai/search to find your token +# +# For quote terminology see https://tradingstrategy.ai/glossary/quote-token +# +QUOTE_TOKEN_ADDRESS = "0xe9e7cea3dedca5984780bafc599bd69add087d56" # BUSD + +# The address of a token we are going to receive +# +# Use https://tradingstrategy.ai/search to find your token +# +# For base terminology see https://tradingstrategy.ai/glossary/base-token +BASE_TOKEN_ADDRESS = "0x2170ed0880ac9a755fd29b2688956bd959f933f8" # Binance custodied ETH on BNB Chain + +# Connect to JSON-RPC node +json_rpc_url = os.environ.get("JSON_RPC_BINANCE") +assert json_rpc_url, "You need to give JSON_RPC_BINANCE node URL. Check https://docs.bnbchain.org/docs/rpc for options" + +web3 = Web3(HTTPProvider(json_rpc_url)) + +# Proof-of-authority middleware is needed to connect non-Ethereum mainnet chains +# (BNB Smart Chain, Polygon, etc...) +# +# Note that you might need to make a pull request to update +# POA_MIDDLEWARE_NEEDED_CHAIN_IDS for any new blockchain +# https://github.com/tradingstrategy-ai/web3-ethereum-defi/blob/ca29529b3b4306623273a40a85c9d155834cf249/eth_defi/chain.py#L25 +# +install_chain_middleware(web3) + +# Depending on a blockchain, it may or may not use EIP-1559 +# based gas pricing and we may need to adjust gas price strategy +# for the outgoing transaction +web3.eth.set_gas_price_strategy(node_default_gas_price_strategy) + +print(f"Connected to blockchain, chain id is {web3.eth.chain_id}. the latest block is {web3.eth.block_number:,}") + +# PancakeSwap data - all smart contract addresses +# and hashes we need to know from off-chain sources. +# Consult your DEX documentation for addresses. +dex = fetch_deployment( + web3, + factory_address="0xcA143Ce32Fe78f1f7019d7d551a6402fC5350c73", + router_address="0x10ED43C718714eb63d5aA57B78B54704E256024E", + init_code_hash="0x00fb7f630766e6a796048ea87d01acd3068e8ff67d078148a3fa3f4a84f69bd5", +) + +print(f"Uniwap v2 compatible router set to {dex.router.address}") + +# Read and setup a local private key +private_key = os.environ.get("PRIVATE_KEY") +assert private_key is not None, "You must set PRIVATE_KEY environment variable" +assert private_key.startswith("0x"), "Private key must start with 0x hex prefix" +account: LocalAccount = Account.from_key(private_key) +my_address = account.address + +# Enable eth_sendTransaction using this private key +web3.middleware_onion.add(construct_sign_and_send_raw_middleware(account)) + +# Read on-chain ERC-20 token data (name, symbol, etc.) +base = fetch_erc20_details(web3, BASE_TOKEN_ADDRESS) +quote = fetch_erc20_details(web3, QUOTE_TOKEN_ADDRESS) + +# Native token balance +# See https://tradingstrategy.ai/glossary/native-token +gas_balance = web3.eth.get_balance(account.address) + +print(f"Your address is {my_address}") +print(f"Your have {base.fetch_balance_of(my_address)} {base.symbol}") +print(f"Your have {quote.fetch_balance_of(my_address)} {quote.symbol}") +print(f"Your have {gas_balance / (10 ** 18)} for gas fees") + +assert quote.fetch_balance_of(my_address) > 0, f"Cannot perform swap, as you have zero {quote.symbol} needed to swap" + +# Ask for transfer details +decimal_amount = input(f"How many {quote.symbol} tokens you wish to swap to {base.symbol}? ") + +# Some input validation +try: + decimal_amount = Decimal(decimal_amount) +except ValueError as e: + raise AssertionError(f"Not a good decimal amount: {decimal_amount}") from e + +# Fat-fingering check +print(f"Confirm swap amount {decimal_amount} {quote.symbol} to {base.symbol}") +confirm = input("Ok [y/n]?") +if not confirm.lower().startswith("y"): + print("Aborted") + sys.exit(1) + +# Convert a human-readable number to fixed decimal with 18 decimal places +raw_amount = quote.convert_to_raw(decimal_amount) + +# Each DEX trade is two transactions +# - ERC-20.approve() +# - swap (various functions) +# This is due to bad design of ERC-20 tokens, +# more here https://twitter.com/moo9000/status/1619319039230197760 + +# Uniswap router must be allowed to spent our quote token +approve = quote.contract.functions.approve(dex.router.address, raw_amount) + +tx_1 = approve.build_transaction( + { + # approve() may take more than 500,000 gas on Arbitrum One + "gas": 850_000, + "from": my_address, + } +) + +# Build a swap transaction with slippage protection +# +# Slippage protection is very important, or you +# get instantly overrun by MEV bots with +# sandwitch attacks +# +# https://tradingstrategy.ai/glossary/mev +# +# +bound_solidity_func = swap_with_slippage_protection( + dex, + base_token=base, + quote_token=quote, + max_slippage=5, # Allow 5 BPS slippage before tx reverts + amount_in=raw_amount, + recipient_address=my_address, +) + +tx_2 = bound_solidity_func.build_transaction( + { + # Uniswap v2 swap should not take more than 1M gas units. + # We do not use automatic gas estimation, as it is unreliable + # and the number here is the maximum value only. + # Only way to know this number is by trial and error + # and experience. + "gas": 1_000_000, + "from": my_address, + } +) + +# Sign and broadcast the transaction using our private key +tx_hash_1 = web3.eth.send_transaction(tx_1) +tx_hash_2 = web3.eth.send_transaction(tx_2) + +# This will raise an exception if we do not confirm within the timeout. +# If the timeout occurs the script abort and you need to +# manually check the transaction hash in a blockchain explorer +# whether the transaction completed or not. +tx_wait_minutes = 2.5 +print(f"Broadcasted transactions {tx_hash_1.hex()}, {tx_hash_2.hex()}, now waiting {tx_wait_minutes} minutes for it to be included in a new block") +receipts = wait_transactions_to_complete( + web3, + [tx_hash_1, tx_hash_2], + max_timeout=datetime.timedelta(minutes=tx_wait_minutes), + confirmation_block_count=1, +) + +# Check if any our transactions failed +# and display the reason +for completed_tx_hash, receipt in receipts.items(): + if receipt["status"] == 0: + revert_reason = fetch_transaction_revert_reason(web3, completed_tx_hash) + raise AssertionError(f"Our transaction {completed_tx_hash.hex()} failed because of: {revert_reason}") + +print("All ok!") +print(f"After swap, you have {base.fetch_balance_of(my_address)} {base.symbol}") +print(f"After swap, you have {quote.fetch_balance_of(my_address)} {quote.symbol}") +print(f"After swap, you have {gas_balance / (10 ** 18)} native token left") diff --git a/tests/aave_v3/test_aave_v3_reserve_data.py b/tests/aave_v3/test_aave_v3_reserve_data.py index 1ce39508..7072799c 100644 --- a/tests/aave_v3/test_aave_v3_reserve_data.py +++ b/tests/aave_v3/test_aave_v3_reserve_data.py @@ -62,7 +62,7 @@ def test_aave_v3_fetch_reserve_snapshot( assert snapshot["chain_id"] == 137 assert snapshot["timestamp"] > 0 assert snapshot["block_number"] > 0 - assert snapshot["reserves"]["0x8f3Cf7ad23Cd3CaDbD9735AFf958023239c6A063"]["symbol"] == "DAI" + assert snapshot["reserves"]["0x8f3cf7ad23cd3cadbd9735aff958023239c6a063"]["symbol"] == "DAI" serialised = json.dumps(snapshot) unserialised = json.loads(serialised)