In the previous post we dipped our toes into loading a smart contract in Brownie, learned a bit about how Ethereum (and its derivatives) manages its native and ERC-20 token balances.
Today we will explore the foundation of token-for-token swaps, the venerable Uniswap V2 Router contract.
I’ll tell you a dirty secret about DEXes — they’re almost all copy-paste clones of Uniswap. Even Uniswap’s primary Ethereum competitor, Sushiswap, started as a simple fork.
So if you can learn how to use Uniswap V2, and you can very quickly move your knowledge to almost any other DEX.
For You? Best Price!
The router contract does a lot of things, but we are going to limit our exploration today to just one: getAmountsOut()
.
The Uniswap documentation says this about it:
Given an input asset amount and an array of token addresses, calculates all subsequent maximum output token amounts by calling getReserves for each pair of token addresses in the path in turn, and using these to call getAmountOut.
Useful for calculating optimal token amounts before calling swap.
In the manner typical of programmers, the documentation authors have given you a very accurate description of what getAmountsOut()
does, but not exactly what you should do with it.
But never fear, all will become clear.
Let’s get into Brownie and see what the Router can do. For this lesson we will be using Avalanche again (the avax-main
network within Brownie) and interacting with the TraderJoe Router contract, which is a derivative of Sushiswap, which is a derivative of Uniswap V2! You can and should review the contract addresses listed in their documentation.
The one we care about is JoeRouter
, stored at address '0x60aE616a2155Ee3d9A68541Ba4544862310933d4'
on Avalanche mainnset. You can and should view the contract on Snowtrace.
We will also load a pair of new token addresses that you’ll probably recognize: USDC at '0xb97ef9ef8734c71904d8002f8b6bc66dd9c48a6e'
and USDT at '0xc7198437980c041c805a1edcba50c1ce5db95118'
. Our old friend WAVAX will also make an appearance at ‘0xb31f66aa3c1e785363f0875a1b74e27b85fd66c7’
Brownie Hands-On
I won’t explain as much here, so please review the previous lesson if you don’t recognize the commands that I’m entering below.
devil@hades:~/brownie_test$ source .venv/bin/activate
(.venv) devil@hades:~/brownie_test$ brownie console --network avax-main
Brownie v1.17.2 - Python development framework for Ethereum
No project was loaded.
Brownie environment is ready.
>>> user = accounts.load('test_account')
Enter password for "test_account":
>>> router_contract = Contract.from_explorer('0x60aE616a2155Ee3d9A68541Ba4544862310933d4')
[...]
Fetching source of 0x60aE616a2155Ee3d9A68541Ba4544862310933d4 from api.snowtrace.io...
/home/devil/brownie_test/.venv/lib/python3.9/site-packages/brownie/network/contract.py:1165: BrownieCompilerWarning: 0x60aE616a2155Ee3d9A68541Ba4544862310933d4: Locally compiled and on-chain bytecode do not match!
warnings.warn(
>>> usdc_contract = Contract.from_explorer('0xb97ef9ef8734c71904d8002f8b6bc
66dd9c48a6e')
/home/devil/brownie_test/.venv/lib/python3.9/site-packages/brownie/network/contract.py:1821:
[...]
Fetching source of 0xB97EF9Ef8734C71904D8002F8b6Bc66Dd9c48a6E from api.snowtrace.io...
Fetching source of 0xa3fa3D254bf6aF295b5B22cC6730b04144314890 from api.snowtrace.io...
>>> usdt_contract = Contract.from_explorer('0xc7198437980c041c805a1edcba50c1ce5db95118')
[...]
Fetching source of 0xc7198437980c041c805A1EDcbA50c1Ce5db95118 from api.snowtrace.io...
>>> wavax_contract = Contract.from_explorer('0xb31f66aa3c1e785363f0875a1b74e27b85fd66c7')
[...]
Fetching source of 0xB31f66AA3C1e785363F0875A1B74E27b85FD66c7 from api.snowtrace.io...
I’ve trimmed out the warnings about the Snowtrace API key, if you see them you can safely ignore. The warning about locally compiled and on-chain bytecode not matching is fairly common. Since we are merely loading the contract’s public interface (aka ABI) into our script for convenience, this is not a problem. All interaction will occur through the deployed smart contract in the end.
Now that we have contract objects loaded, we can use them as arguments to the getAmountsOut()
function.
First let’s review the documentation to see what arguments getAmountsOut()
requires:
function getAmountsOut(uint amountIn, address[] memory path) internal view returns (uint[] memory amounts)
The function above is formatted for Solidity, but we’re in Python so the input will be formatted slightly differently. It should look close enough to not give you any trouble.
Basically the function above takes the following arguments in order:
An unsigned integer specifying the number of tokens sent to the router.
A Solidity array containing addresses of all tokens involved in the proposed swap, starting from the one that you want to swap in, and ending with the token that you want to receive out. Brownie will automatically translate the built-in list data structure into a proper Solidity array input.
A special note about the address array — a token swap can only be done by passing the input token through a liquidity pool, or series of liquidity pools. Some token pairs have dedicated pools that you can swap with directly, but most go through an intermediate wrapper pool such as WAVAX. This reduces the number of pools needed to swap an “infinite” number of tokens, since any token can pass into the first pool, be converted to WAVAX, then that WAVAX can be sent into another pool for your desired token.
This is handled for you automatically by the web frontend, but the intermediate token address must be added in your Brownie calls.
>>> router_contract.getAmountsOut(1*( 10**usdt_contract.decimals()), [usdt_contract.address, wavax_contract.address, usdc_contract.address ])
(1000000, 9641292951465110, 946744)
A few notes on the above code:
We are used to thinking of 1 USDT as having a quantity 1, but remember from the previous installment that Ethereum and its derivatives store balances as very high bit unsigned integers. Therefore whenever we want to describe a token quantity, we must always convert it by the appropriate power of 10. When I write
1*(10**usdt_contract.decimals())
I am using thedecimals()
method from that USDT contract “in-line” with my expression to save a few lines of code.Instead of writing out the string for each address, I use a similar “in-line” technique by calling the
address
variable directly from the contract. Whenever you get the chance to pull info from a blockchain object instead of hard-coding, take the opportunity! It will make your code more reusable.
The end result of this function is a Python tuple with integers that represent the quantity of tokens that are output from each stage of the swap. In this case, 1000000
USDT becomes 9641292951465110
WAVAX which becomes 944807
USDC.
If the middle number looks strangely high, you’re onto something. Here’s why:
>>> usdt_contract.decimals()
6
>>> usdc_contract.decimals()
6
>>> wavax_contract.decimals()
18
What the hell?!
USDT and USDC tokens are only stored with 6 decimal places, where WAVAX is stored with 18. That seems odd and arbitrary, so it’s another illustration why you should use the built-in decimals()
method whenever possible.
The equivalent “human readable” amounts should be 1000000 / (10 ** 6)
, 9641292951465110 / (10 ** 18)
, and 944807 / (10 ** 6)
. But since we get the full integers from getAmountsOut()
, we have to be sure to account for them later when we’re working with simple human brains.
Let’s do one more thing that will look odd if you’ve not learned Python:
>>> router_contract.getAmountsOut(1*(10**usdt_contract.decimals()), [usdt_contract.address, wavax_contract.address, usdc_contract.address ])[-1]
946744
All I did here was add a [-1]
to the end of the previous express, which instructs Python to display only the last item in the output of that function. This is useful because I don’t really care how many WAVAX are being used along the way, and I already know that I’m sending (1 * 10**6)
USDT so the first two numbers are either redundant or unneeded.
I’ll make one more change to the above function to make the output even more readable:
>>> router_contract.getAmountsOut(1*(10**usdt_contract.decimals()), [usdt_contract.ad
dress, wavax_contract.address, usdc_contract.address ])[-1]/(10**usdc_contract.decimals())
0.946744
So what does this mean? It means if I want to swap 1 USDT, I get 0.946744 USDC.
Not very exciting after all that work, but I assert that knowing how to query a Router contract for fluctuating exchange rates is the key to successful arbitrage.
A thought experiment: if the same function above gave you a result of 1.01, would that be good? What would that imply?
If you answered “yes, that would be good” and then “free money!” you’re catching on.
After you’ve finished with the lesson, I recommend that you press the UP key and repeat the above command a few times a minute and observe how the swap rate changes.
Going Forward
We’ve learned how the getAmountsOut()
function can establish how “good” a token swap is using simple math, we will build our first watcher bot to monitor a simple stablecoin pair for opportunities.
you're such a great teacher...thank you so much!
"In the manner typical of programmers,..."; this itself may be a clue on how to better extract the value one is seeking from documentation.
Here's a thing that: does...when... only if... except when... unless.....otherwise; ....
Now compose a few handful of these seemingly cryptic definitions into a larger panic inducing monstrosity & viola! You've got yourself some code magic;
Thanks for taking the time to expose the necessary mechanics for those of us whose attention might blink thus disassembling the house of cards constructing method definitions, use cases, gotchas & forever battling the remainder of will left after endless $#%#$!%!#@!! & hair pulling.