If you browse the DEX Tracker on Etherscan, you’ll see a lot of activity going through UniswapV3. If you drill into it further by visiting the contract page for Router and Router 2, you’ll see a lot of familiar methods.
“Exect Input”, “Exact Input Single”, “Exact Output” and “Exact Output Single” are very familiar to us now after exploring the Router contract. But if you look for a bit, you’ll notice an odd one named “Multicall”
What is Multicall? The interface documentation says it plainly:
Enables calling multiple methods in a single call to the contract
We’ve already used multicall in several ways. Our first exposure to multicall was via the Multicall Requests lesson. The second exposure was when I built a generic bundle executor contract, which used multicall to execute state-changing transactions instead of just retrieving read-only data from the blockchain.
The UniswapV3 Multicall implementation functions like the second case, allowing us to execute multiple state-changing steps inside of a single transaction.
I’m going to pick a random Multicall transaction from Etherscan, identify what swap steps it takes, reconstruct the inputs to illustrate how to use Multicall directly, then “replay” the transaction on a local fork.
Example Multicall
Selected at random, transaction 0x5179d5430f7217356829c913036b187d910cd71080f6cb30c2d3ae96671a2bff involves several steps:
User sends 0.081 Ether directly to UniswapV3’s Router2
Router2 wraps that Ether to WETH
Router2 swaps WETH for USDC
Router2 swaps the USDC for Okinami on UniswapV2, which sends it to the original user
I did not realize that the UniswapV3 contracts had the ability to execute swaps on UniswapV2, so this is cool!
Console Exploration
This transaction was executed in block height 15763615, so we will start a local fork at the previous block and start digging in.
(.venv) devil@hades:~/bots$ BLOCK=15763614 brownie console --network mainnet-fork-atblock
Brownie v1.19.1 - Python development framework for Ethereum
BotsProject is the active project.
Launching 'ganache-cli --chain.vmErrorsOnRPCResponse true --wallet.totalAccounts 10 --hardfork istanbul --fork.url https://rpc.ankr.com/eth@15763614 --miner.blockGasLimit 12000000 --wallet.mnemonic brownie --server.port 6969'...
Brownie environment is ready.
Now load the Router2 contract at address 0x68b3465833fb72a70ecdf485e0e4c7bd8665fc45:
>>> router2 = Contract.from_explorer(
'0x68b3465833fb72a70ecdf485e0e4c7bd8665fc45'
)
Let’s test the transaction to confirm that the inputs found from Etherscan are valid:
>>> tx = router2.multicall(
1665960539,
[0xb858183f000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000800000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000011fc51222ce80000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002bc02aaa39b223fe8d0a0e5c4f27ead9083c756cc20001f4a0b86991c6218b36c1d19d4a2e9eb0ce3606eb48000000000000000000000000000000000000000000, 0x472b43f3000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000bc329223e0a00000000000000000000000000000000000000000000000000000000000000800000000000000000000000006705ac49a7f18700d2f00571936bd7d03f3e424c0000000000000000000000000000000000000000000000000000000000000002000000000000000000000000a0b86991c6218b36c1d19d4a2e9eb0ce3606eb480000000000000000000000001c4853ec0d55e420002c5efabc7ed8e0ba7a4121],
{'from':accounts[0],'value':81000000000000000})
File "<console>", line 1, in <module>
File "brownie/network/contract.py", line 1486, in __call__
fn = self._get_fn_from_args(args)
File "brownie/network/contract.py", line 1463, in _get_fn_from_args
raise ValueError(
ValueError: Contract has more than one function 'SwapRouter02.multicall' requiring 2 arguments. You must explicitly declare which function you are calling, e.g. SwapRouter02.multicall['bytes32,bytes[]'](*args)
OK, first snag. The multicall
function is overloaded, which means that it has been defined more than once with different arguments. Brownie cannot easily distinguish the difference from the inputs, so it needs the proper function definition. Easy enough, we want the version specified in ExtendedMulticall.sol with a deadline and a byte array for payloads:
>>> tx = router2.multicall['uint256,bytes[]'](
1665960539, [0xb858183f00000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000080000000000000000000000000000000000000000000000000000000000000000200000000000
0000000000000000000000000000000000000011fc51222ce80000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002bc02aaa39b223fe8d0a0e5c4f27ead9083c756cc20001f4a0b86991c6218b36c1d19d4a2e9eb0ce3606eb48000000000000000000000000000000000000000000, 0x472b43f3000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000bc329223e0a00000000000000000000000000000000000000000000000000000000000000800000000000000000000000006705ac49a7f18700d2f00571936bd7d03f3e424c0000000000000000000000000000000000000000000000000000000000000002000000000000000000000000a0b86991c6218b36c1d19d4a2e9eb0ce3606eb480000000000000000000000001c4853ec0d55e420002c5efabc7ed8e0ba7a4121],{'from':accounts[0],'value':81000000000000000})
Transaction sent: 0x2ebc7f2c4eb501959bdf5e940888d0683209f06a8d896a1ca46287c0ab8c08ea
Gas price: 0.0 gwei Gas limit: 12000000 Nonce: 6
Transaction confirmed (Transaction too old) Block: 15763616 Gas used: 26492 (0.22%)
OK, so the original timestamp I re-used has expired. No problem, just bump the deadline to an impractically big number and try again:
>>> tx = router2.multicall['uint256,bytes[]'](
99999999999999999999999, [0xb858183f000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000800000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000011fc51222ce80000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002bc02aaa39b223fe8d0a0e5c4f27ead9083c756cc20001f4a0b86991c6218b36c1d19d4a2e9eb0ce3606eb48000000000000000000000000000000000000000000, 0x472b43f3000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000bc329223e0a00000000000000000000000000000000000000000000000000000000000000800000000000000000000000006705ac49a7f18700d2f00571936bd7d03f3e424c0000000000000000000000000000000000000000000000000000000000000002000000000000000000000000a0b86991c6218b36c1d19d4a2e9eb0ce3606eb480000000000000000000000001c4853ec0d55e420002c5efabc7ed8e0ba7a4121],{'from':accounts[0],'value':81000000000000000})
Transaction sent: 0xfcda3aeebf4d1a0a283eb4f37af2af48a8ff9295889a32bd45da6430e5d6a5af
Gas price: 0.0 gwei Gas limit: 12000000 Nonce: 7
Transaction confirmed Block: 15763617 Gas used: 285027 (2.38%)
Decode the Calldata
We know from the bundle executor lesson that the magic lies within the calldata payload.
In this case, the multicall executes these calldata payloads:
Bundle 1:
0xb858183f000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000800000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000011fc51222ce80000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002bc02aaa39b223fe8d0a0e5c4f27ead9083c756cc20001f4a0b86991c6218b36c1d19d4a2e9eb0ce3606eb48000000000000000000000000000000000000000000
Bundle 2:
0x472b43f3000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000bc329223e0a00000000000000000000000000000000000000000000000000000000000000800000000000000000000000006705ac49a7f18700d2f00571936bd7d03f3e424c0000000000000000000000000000000000000000000000000000000000000002000000000000000000000000a0b86991c6218b36c1d19d4a2e9eb0ce3606eb480000000000000000000000001c4853ec0d55e420002c5efabc7ed8e0ba7a4121
We will decode both here.
Recall that any calldata begins with the function selector, which is the first four bytes of the function prototype. We will use the Ethereum Signature Database to help look up functions more easily.
Bundle 1
The function selector is 0xb858183f
, which the Ethereum Signature Database reveals as exactInput((bytes,address,uint256,uint256))
(LINK)
We recognize this as a UniswapV3 function that swaps an exact amount of Ether for another ERC-20 token.
We can use the Brownie built-in function decode_input
to decode the inputs associated with this function call:
>>> router2.decode_input( '0xb858183f000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000800000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000011fc51222ce80000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002bc02aaa39b223fe8d0a0e5c4f27ead9083c756cc20001f4a0b86991c6218b36c1d19d4a2e9eb0ce3606eb48000000000000000000000000000000000000000000')
("exactInput((bytes,address,uint256,uint256))", [[0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc20001f4a0b86991c6218b36c1d19d4a2e9eb0ce3606eb48, '0x0000000000000000000000000000000000000002', 81000000000000000, 0]])
Refer to the lesson on the UniswapV3 Router if the exactInput
function does not look familiar to you.
Here are the inputs for exactInput
:
path
=0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc20001f4a0b86991c6218b36c1d19d4a2e9eb0ce3606eb48
recipient
=0x0000000000000000000000000000000000000002
amountIn
=81000000000000000
amountOutMinimum
=0
You’ll notice two odd things about this one:
The recipient argument is set to
0x0000000000000000000000000000000000000002
amountOutMinimum
is set to 0
If you check the Solidity contract for Router2, you’ll see this on line 149:
// for intermediate swaps, this contract custodies
hasMultiplePools ? address(this) : params.recipient,
If the proposed swap runs through multiple pools, it will ignore the recipient
value and hold the intermediate tokens directly. The weird value above is used for gas savings, since long blocks of zeroes are more efficient to pass.
However this proposed swap does not use multiple pools (only the WETH/USDC pool), so we need to dig a little further.
The library Constants.sol defines several values:
uint256 internal constant CONTRACT_BALANCE = 0;
/// @dev Used as a flag for identifying msg.sender, saves gas by sending more 0 bytes
address internal constant MSG_SENDER = address(1);
/// @dev Used as a flag for identifying address(this), saves gas by sending more 0 bytes
address internal constant ADDRESS_THIS = address(2);
These are used by the exactInputInternal
and exactOutputInternal
functions, which do the actual swapping after the external function checks and sanitizes the inputs:
// find and replace recipient addresses
if (recipient == Constants.MSG_SENDER) recipient = msg.sender;
else if (recipient == Constants.ADDRESS_THIS) recipient = address(this);
We know that ADDRESS_THIS = address(2)
, which evaluates to 0x0000000000000000000000000000000000000002
.
In summary: whenever the router sees a destination address of 0x0000000000000000000000000000000000000002
, it replaces the recipient address with its own and holds the balance of the swap.
As for amountOutMinimum
, setting it to zero would be dangerous if this was the only swap in the transactions. Since there is another transaction afterwards where the proposed amount is swapped using swapExactTokensForTokens
. If this first swap failed to deliver this exact amount, it would simply revert instead of giving you a reduced output. Again, this is fine but I’d prefer to set amountOutMinimum
for safety.
Bundle 2
The function selector is 0x472b43f3
, which the Ethereum Signature Database reveals as swapExactTokensForTokens(uint256,uint256,address[],address)
(LINK)
We recognize this as a UniswapV2 function that swaps an exact amount of input tokens for a minimum amount of another token.
We can use the Brownie built-in function decode_input
to decode the inputs associated with this function call:
>>> router2.decode_input(
'0x472b43f3000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000bc329223e0a00000000000000000000000000000000000000000000000000000000000000800000000000000000000000006705ac49a7f18700d2f00571936bd7d03f3e424c0000000000000000000000000000000000000000000000000000000000000002000000000000000000000000a0b86991c6218b36c1d19d4a2e9eb0ce3606eb480000000000000000000000001c4853ec0d55e420002c5efabc7ed8e0ba7a4121')
("swapExactTokensForTokens(uint256,uint256,address[],address)",
[0, 12932836638218, ['0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', '0x1C4853Ec0d55e420002c5eFaBc7Ed8e0bA7A4121'], '0x6705aC49a7f18700d2F00571936bD7d03F3e424c'])
This is so subtle you might not even notice. Swapping via UniswapV2 pools can be done directly through the UniswapV3 router!
This is because the UniswapV3 Router2 contract has both UniswapV2 and UniswapV3 functions.
Here is the breakdown of Uniswap and router versions:
UniswapV2 — Router: can perform basic ERC-20 swaps
UniswapV2 — Router2: can perform basic ERC-20 plus fee on transfer swaps
UniswapV3 — Router: can perform ERC-20 swaps of any kind, limited to UniswapV3 pools
UniswapV3 — Router2: can perform ERC-20 swaps of any kind through both UniswapV2 and UniswapV3 pools
Rebuild the Transaction
I’m taking the calldata directly from Etherscan, which is OK as a proof-of-concept test. However the best way to ensure understanding is to rebuild the transaction with our own address instead of our unknown EOA (Externally-Owned Address).