Part II of the Curve StableSwap series outlined several challenges of developing an accurate abstraction for swap calculations. Now we will take a lower level look at executing swaps at the pool contract.
This lesson is similar in spirit to the Uniswap V2 and V3 contract swap lessons. We will launch a local fork, play with some test accounts, throw fake tokens around, and explore the inputs and outputs of a typical Curve pool.
This lesson will use Ape Framework. Please review the introductory lesson on getting that installed and running before continuing.
Getting Started
I have set up a virtual environment for Ape using PyEnv. It is called “ape” and it uses Python 3.11. I have it set to activate whenever I’m in the ~/code/ape_sandbox
directory, which you will see frequently below. You don’t have to use the same version or path, but I recommend setting up a similar environment.
At the time of writing, I am using the version 0.6.27 release of Ape, installed through pip
via PyPI.
This lesson will cover swapping stablecoins through the DAI/USDC/USDT tripool at address 0xbEbc44782C7dB0a1A60Cb6fe97d0b483032FF1C7. Other Curve pools may have slightly different calculation behavior, but the events, interface, and operations should be similar enough to learn everything you need by testing against tripool.
Ape Setup
I am using the Ape Foundry plugin, which makes a provider available to Ape that launches a local fork using Anvil.
I’ve covered local forking and Anvil before, so please review these lessons if you are unfamiliar.
The Ape configuration file in the ~/code/ape_sandbox
directory sets the following options for any Ape processed launched from there.
ape-config.yaml
name: Ape Sandbox
default_ecosystem: ethereum
ethereum:
mainnet:
default_provider: geth
mainnet_fork:
default_provider: foundry
geth:
ethereum:
mainnet:
uri: http://localhost:8545
foundry:
base_fee: 0
priority_fee: 0
host: auto
fork:
ethereum:
mainnet:
upstream_provider: alchemy
I have an API key for Alchemy set in my .bashrc
file which exports the key whenever a shell session is started by my user:
.bashrc
[...]
# Alchemy
export WEB3_ALCHEMY_API_KEY=[redacted]
Ape Console Exploration
Launch an Ape console against the mainnet-fork
network, which will use the defaults from the config file above:
(ape) btd@main:~/code/ape_sandbox$ ape console --network ethereum:mainnet-fork
INFO: Starting 'anvil' process.
In [1]: tripool = Contract('0xbEbc44782C7dB0a1A60Cb6fe97d0b483032FF1C7')
With the contract object build, verify that the tokens held by the pool match our expectations:
In [2]: tripool.coins(0)
Out[2]: '0x6B175474E89094C44Da98b954EedeAC495271d0F'
In [3]: tripool.coins(1)
Out[3]: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48'
In [4]: tripool.coins(2)
Out[4]: '0xdAC17F958D2ee523a2206206994597C13D831ec7'
On Etherscan, verify that the canonical DAI, USDC, and USDT token contracts are at these addresses.
When connected to an appropriate testing provider, Ape provides a set of accounts with 1000 ETH balances. These are available from the TestAccountManager
class, accessible at the accounts.test_accounts
attribute.
Create a variable that references the first test account:
In [5]: acct = accounts.test_accounts[0]
Now we’ll use a little trick that Ape provides to impersonate an account. USDT is operated by an account whose address can be retrieved by the owner()
function. The accounts
manager will create an impersonated account when you access a particular address via index.
We need some tokens, so let’s impersonate the Binance #8 hot wallet, which is listed as the top holder of USDT on Etherscan, give it some extra Ether for gas money, then use it to transfer USDT to our test account:
In [6]: binance_8 = accounts[
'0xC6CDE7C39eB2f0F0095F41570af89eFC2C1Ea828'
]
In [7]: binance_8.balance = 100*10**18
In [8]: usdt.transfer(acct.address, 100_000 * 10**6, sender=binance_8)
INFO: Confirmed 0x6c08a9bb6f8041586b57960d59886cb99ea7d4c64551ce04674bd7caeb984072 (total fees paid = 0)
Out[8]: <Receipt 0x6c08a9bb6f8041586b57960d59886cb99ea7d4c64551ce04674bd7caeb984072>
Now confirm that our test account holds 100,000 USDT:
In [9]: usdt.balanceOf(acct)
Out[9]: 100000000000
With a balance, we can start playing around in the tripool. First, let’s swap 1/3 of our USDT for USDC, and 1/3 into DAI. This is done using the exchange()
function, which has this function signature:
@external
@nonreentrant('lock')
def exchange(i: int128, j: int128, dx: uint256, min_dy: uint256):
[...]
The variable i
represents the index of the token to swap in, the variable j
represents the index of the token to swap out, dx
represents the amount swapped, and min_dy
is the minimum acceptable amount received.
Savvy readers will recognize the min_dy
functionality is very similar to AmountOutMin
from the Uniswap routers. The major difference is that the Curve pool allows this to be set directly, instead of being enforced by an external contract.
Before making the first swap, we must grant approval for the tripool contract to transfer tokens:
In [10]: usdt.approve(tripool, 100_000*10**6,sender=acct)
INFO: Confirmed 0x026c774c99ded29a613143ad0c2c67568e2044309d41d07f210a205a0e3b0732 (total fees paid = 0)
Out[10]: <Receipt 0x026c774c99ded29a613143ad0c2c67568e2044309d41d07f210a205a0e3b0732>
Execute the first swap (USDT → DAI) using a min_dy of zero, since there is no competition on the fork that would cause slippage:
In [11]: tx = tripool.exchange(2, 0, 33_333*10**6, 0, sender=acct)
INFO: Confirmed 0x0729d4c51bd064e506b8ea4f493862906045b3ee5c56f9572171aacec25a4960 (total fees paid = 0)
Let’s pause briefly to review the information offered from the receipt, which is returned by the transaction call and held in variable tx
:
In [12]: tx.logs
Out[12]:
[{'address': '0xdAC17F958D2ee523a2206206994597C13D831ec7',
'topics': [HexBytes('0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef'),
HexBytes('0x0000000000000000000000001e59ce931b4cfea3fe4b875411e280e173cb7a9c'),
HexBytes('0x000000000000000000000000bebc44782c7db0a1a60cb6fe97d0b483032ff1c7')],
'data': HexBytes('0x00000000000000000000000000000000000000000000000000000007c2cd3740'),
'blockHash': HexBytes('0xeb2618d3d50801a1d43dd9c402240eb68953935d02f3ec6e42d5d120c98e1341'),
'blockNumber': 18811020,
'transactionHash': HexBytes('0x0729d4c51bd064e506b8ea4f493862906045b3ee5c56f9572171aacec25a4960'),
'transactionIndex': 0,
'logIndex': 0,
'transactionLogIndex': '0x0',
'removed': False},
{'address': '0x6B175474E89094C44Da98b954EedeAC495271d0F',
'topics': [HexBytes('0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef'),
HexBytes('0x000000000000000000000000bebc44782c7db0a1a60cb6fe97d0b483032ff1c7'),
HexBytes('0x0000000000000000000000001e59ce931b4cfea3fe4b875411e280e173cb7a9c')],
'data': HexBytes('0x00000000000000000000000000000000000000000000070d3e581d18b95ade1c'),
'blockHash': HexBytes('0xeb2618d3d50801a1d43dd9c402240eb68953935d02f3ec6e42d5d120c98e1341'),
'blockNumber': 18811020,
'transactionHash': HexBytes('0x0729d4c51bd064e506b8ea4f493862906045b3ee5c56f9572171aacec25a4960'),
'transactionIndex': 0,
'logIndex': 1,
'transactionLogIndex': '0x1',
'removed': False},
{'address': '0xbEbc44782C7dB0a1A60Cb6fe97d0b483032FF1C7',
'topics': [HexBytes('0x8b3e96f2b889fa771c53c981b40daf005f63f637f1869f707052d15a3dd97140'),
HexBytes('0x0000000000000000000000001e59ce931b4cfea3fe4b875411e280e173cb7a9c')],
'data': HexBytes('0x000000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000007c2cd3740000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000070d3e581d18b95ade1c'),
'blockHash': HexBytes('0xeb2618d3d50801a1d43dd9c402240eb68953935d02f3ec6e42d5d120c98e1341'),
'blockNumber': 18811020,
'transactionHash': HexBytes('0x0729d4c51bd064e506b8ea4f493862906045b3ee5c56f9572171aacec25a4960'),
'transactionIndex': 0,
'logIndex': 2,
'transactionLogIndex': '0x2',
'removed': False}]
In [13]: tx.decode_logs()
Out[13]:
[<Transfer from=0x1e59ce931B4CFea3fe4B875411e280e173cB7A9C to=0xbEbc44782C7dB0a1A60Cb6fe97d0b483032FF1C7 value=33333000000>,
<Transfer src=0xbEbc44782C7dB0a1A60Cb6fe97d0b483032FF1C7 dst=0x1e59ce931B4CFea3fe4B875411e280e173cB7A9C wad=33300865425666068897308>,
<TokenExchange buyer=0x1e59ce931B4CFea3fe4B875411e280e173cB7A9C sold_id=2 tokens_sold=33333000000 bought_id=0 tokens_bought=33300865425666068897308>]
In [14]: tx.transaction
Out[14]: <DynamicFeeTransaction chainId=1, to=0xbEbc44782C7dB0a1A60Cb6fe97d0b483032FF1C7, from=0x1e59ce931B4CFea3fe4B875411e280e173cB7A9C, gas=30000000, nonce=1, value=0, data=b'=\xf0!$\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x02\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x07\xc2\xcd7@\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00', type=2, maxFeePerGas=0, maxPriorityFeePerGas=0, accessList=[]>
In [15]: tx.call_tree
Out[15]: Vyper_contract.exchange(i=2, j=0, dx=33333000000, min_dy=0) [128730 gas]
├── USDT.balanceOf(who=Vyper_contract) -> 113663894444288 [5031 gas]
├── 4.()
├── 4.()
├── USDT.transferFrom(_from=tx.origin, _to=Vyper_contract, _value=33333000000)
│ [25724 gas]
├── 4.()
├── USDT.balanceOf(who=Vyper_contract) -> 113697227444288 [1031 gas]
├── 4.()
├── 4.()
├── DAI.transfer(dst=tx.origin, wad=33300865425666068897308) -> True [30174
│ gas]
└── 4.()
If you really want to get geeky, you can inspect the EVM trace by iterating over the lines yielded from the tx.trace()
generator. Let’s inspect the first 25 here:
In [16]: for i,trace in enumerate(tx.trace):
...: print(trace)
...: if i == 25:
...: break
...:
pc=0 op='PUSH1' gas=29978352 gas_cost=3 depth=1 contract_address=None
pc=2 op='CALLDATASIZE' gas=29978349 gas_cost=2 depth=1 contract_address=None
pc=3 op='LT' gas=29978347 gas_cost=3 depth=1 contract_address=None
pc=4 op='ISZERO' gas=29978344 gas_cost=3 depth=1 contract_address=None
pc=5 op='PUSH2' gas=29978341 gas_cost=3 depth=1 contract_address=None
pc=8 op='JUMPI' gas=29978338 gas_cost=10 depth=1 contract_address=None
pc=13 op='JUMPDEST' gas=29978328 gas_cost=1 depth=1 contract_address=None
pc=14 op='PUSH1' gas=29978327 gas_cost=3 depth=1 contract_address=None
pc=16 op='CALLDATALOAD' gas=29978324 gas_cost=3 depth=1 contract_address=None
pc=17 op='PUSH1' gas=29978321 gas_cost=3 depth=1 contract_address=None
pc=19 op='MSTORE' gas=29978318 gas_cost=9 depth=1 contract_address=None
pc=20 op='PUSH21' gas=29978309 gas_cost=3 depth=1 contract_address=None
pc=42 op='PUSH1' gas=29978306 gas_cost=3 depth=1 contract_address=None
pc=44 op='MSTORE' gas=29978303 gas_cost=3 depth=1 contract_address=None
pc=45 op='PUSH16' gas=29978300 gas_cost=3 depth=1 contract_address=None
pc=62 op='PUSH1' gas=29978297 gas_cost=3 depth=1 contract_address=None
pc=64 op='MSTORE' gas=29978294 gas_cost=6 depth=1 contract_address=None
pc=65 op='PUSH32' gas=29978288 gas_cost=3 depth=1 contract_address=None
pc=98 op='PUSH1' gas=29978285 gas_cost=3 depth=1 contract_address=None
pc=100 op='MSTORE' gas=29978282 gas_cost=6 depth=1 contract_address=None
pc=101 op='PUSH21' gas=29978276 gas_cost=3 depth=1 contract_address=None
pc=123 op='PUSH1' gas=29978273 gas_cost=3 depth=1 contract_address=None
pc=125 op='MSTORE' gas=29978270 gas_cost=6 depth=1 contract_address=None
pc=126 op='PUSH32' gas=29978264 gas_cost=3 depth=1 contract_address=None
pc=159 op='PUSH1' gas=29978261 gas_cost=3 depth=1 contract_address=None
pc=161 op='MSTORE' gas=29978258 gas_cost=6 depth=1 contract_address=None
In [17]: tx.dict()
Out[17]:
{'block_number': 18811020,
'gas_used': 150378,
'logs': [{'address': '0xdAC17F958D2ee523a2206206994597C13D831ec7',
'topics': [HexBytes('0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef'),
HexBytes('0x0000000000000000000000001e59ce931b4cfea3fe4b875411e280e173cb7a9c'),
HexBytes('0x000000000000000000000000bebc44782c7db0a1a60cb6fe97d0b483032ff1c7')],
'data': HexBytes('0x00000000000000000000000000000000000000000000000000000007c2cd3740'),
'blockHash': HexBytes('0xeb2618d3d50801a1d43dd9c402240eb68953935d02f3ec6e42d5d120c98e1341'),
'blockNumber': 18811020,
'transactionHash': HexBytes('0x0729d4c51bd064e506b8ea4f493862906045b3ee5c56f9572171aacec25a4960'),
'transactionIndex': 0,
'logIndex': 0,
'transactionLogIndex': '0x0',
'removed': False},
{'address': '0x6B175474E89094C44Da98b954EedeAC495271d0F',
'topics': [HexBytes('0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef'),
HexBytes('0x000000000000000000000000bebc44782c7db0a1a60cb6fe97d0b483032ff1c7'),
HexBytes('0x0000000000000000000000001e59ce931b4cfea3fe4b875411e280e173cb7a9c')],
'data': HexBytes('0x00000000000000000000000000000000000000000000070d3e581d18b95ade1c'),
'blockHash': HexBytes('0xeb2618d3d50801a1d43dd9c402240eb68953935d02f3ec6e42d5d120c98e1341'),
'blockNumber': 18811020,
'transactionHash': HexBytes('0x0729d4c51bd064e506b8ea4f493862906045b3ee5c56f9572171aacec25a4960'),
'transactionIndex': 0,
'logIndex': 1,
'transactionLogIndex': '0x1',
'removed': False},
{'address': '0xbEbc44782C7dB0a1A60Cb6fe97d0b483032FF1C7',
'topics': [HexBytes('0x8b3e96f2b889fa771c53c981b40daf005f63f637f1869f707052d15a3dd97140'),
HexBytes('0x0000000000000000000000001e59ce931b4cfea3fe4b875411e280e173cb7a9c')],
'data': HexBytes('0x000000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000007c2cd3740000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000070d3e581d18b95ade1c'),
'blockHash': HexBytes('0xeb2618d3d50801a1d43dd9c402240eb68953935d02f3ec6e42d5d120c98e1341'),
'blockNumber': 18811020,
'transactionHash': HexBytes('0x0729d4c51bd064e506b8ea4f493862906045b3ee5c56f9572171aacec25a4960'),
'transactionIndex': 0,
'logIndex': 2,
'transactionLogIndex': '0x2',
'removed': False}],
'status': <TransactionStatusEnum.NO_ERROR: 1>,
'txn_hash': '0x0729d4c51bd064e506b8ea4f493862906045b3ee5c56f9572171aacec25a4960',
'transaction': {'chainId': 1,
'to': '0xbEbc44782C7dB0a1A60Cb6fe97d0b483032FF1C7',
'from': '0x1e59ce931B4CFea3fe4B875411e280e173cB7A9C',
'gas': 30000000,
'nonce': 1,
'value': 0,
'data': HexBytes('0x3df021240000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000007c2cd37400000000000000000000000000000000000000000000000000000000000000000'),
'type': 2,
'maxFeePerGas': 0,
'maxPriorityFeePerGas': 0,
'accessList': []},
'gas_limit': 30000000,
'gas_price': 0,
'call_tree': {'contract_id': 'Vyper_contract',
'method_id': 'exchange',
'txn_hash': '0x0729d4c51bd064e506b8ea4f493862906045b3ee5c56f9572171aacec25a4960',
'failed': False,
'inputs': {'i': 2, 'j': 0, 'dx': 33333000000, 'min_dy': 0},
'outputs': (),
'gas_cost': 128730,
'call_type': 'CALL',
'calls': [{'contract_id': 'USDT',
'method_id': 'balanceOf',
'txn_hash': '0x0729d4c51bd064e506b8ea4f493862906045b3ee5c56f9572171aacec25a4960',
'failed': False,
'inputs': {'who': 'Vyper_contract'},
'outputs': 113663894444288,
'gas_cost': 5031,
'call_type': 'STATICCALL',
'calls': []},
{'contract_id': '4', 'failed': False, 'calls': []},
{'contract_id': '4', 'failed': False, 'calls': []},
{'contract_id': 'USDT',
'method_id': 'transferFrom',
'txn_hash': '0x0729d4c51bd064e506b8ea4f493862906045b3ee5c56f9572171aacec25a4960',
'failed': False,
'inputs': {'_from': 'tx.origin',
'_to': 'Vyper_contract',
'_value': 33333000000},
'outputs': (),
'gas_cost': 25724,
'call_type': 'CALL',
'calls': []},
{'contract_id': '4', 'failed': False, 'calls': []},
{'contract_id': 'USDT',
'method_id': 'balanceOf',
'txn_hash': '0x0729d4c51bd064e506b8ea4f493862906045b3ee5c56f9572171aacec25a4960',
'failed': False,
'inputs': {'who': 'Vyper_contract'},
'outputs': 113697227444288,
'gas_cost': 1031,
'call_type': 'STATICCALL',
'calls': []},
{'contract_id': '4', 'failed': False, 'calls': []},
{'contract_id': '4', 'failed': False, 'calls': []},
{'contract_id': 'DAI',
'method_id': 'transfer',
'txn_hash': '0x0729d4c51bd064e506b8ea4f493862906045b3ee5c56f9572171aacec25a4960',
'failed': False,
'inputs': {'dst': 'tx.origin', 'wad': 33300865425666068897308},
'outputs': True,
'gas_cost': 30174,
'call_type': 'CALL',
'calls': []},
{'contract_id': '4', 'failed': False, 'calls': []}]},
'events': [{'event_name': 'Transfer',
'contract_address': '0xdAC17F958D2ee523a2206206994597C13D831ec7',
'event_arguments': {'from': '0x1e59ce931B4CFea3fe4B875411e280e173cB7A9C',
'to': '0xbEbc44782C7dB0a1A60Cb6fe97d0b483032FF1C7',
'value': 33333000000},
'transaction_hash': HexBytes('0x0729d4c51bd064e506b8ea4f493862906045b3ee5c56f9572171aacec25a4960'),
'block_number': 18811020,
'block_hash': HexBytes('0xeb2618d3d50801a1d43dd9c402240eb68953935d02f3ec6e42d5d120c98e1341'),
'log_index': 0,
'transaction_index': 0},
{'event_name': 'Transfer',
'contract_address': '0x6B175474E89094C44Da98b954EedeAC495271d0F',
'event_arguments': {'src': '0xbEbc44782C7dB0a1A60Cb6fe97d0b483032FF1C7',
'dst': '0x1e59ce931B4CFea3fe4B875411e280e173cB7A9C',
'wad': 33300865425666068897308},
'transaction_hash': HexBytes('0x0729d4c51bd064e506b8ea4f493862906045b3ee5c56f9572171aacec25a4960'),
'block_number': 18811020,
'block_hash': HexBytes('0xeb2618d3d50801a1d43dd9c402240eb68953935d02f3ec6e42d5d120c98e1341'),
'log_index': 1,
'transaction_index': 0},
{'event_name': 'TokenExchange',
'contract_address': '0xbEbc44782C7dB0a1A60Cb6fe97d0b483032FF1C7',
'event_arguments': {'buyer': '0x1e59ce931B4CFea3fe4B875411e280e173cB7A9C',
'sold_id': 2,
'tokens_sold': 33333000000,
'bought_id': 0,
'tokens_bought': 33300865425666068897308},
'transaction_hash': HexBytes('0x0729d4c51bd064e506b8ea4f493862906045b3ee5c56f9572171aacec25a4960'),
'block_number': 18811020,
'block_hash': HexBytes('0xeb2618d3d50801a1d43dd9c402240eb68953935d02f3ec6e42d5d120c98e1341'),
'log_index': 2,
'transaction_index': 0}]}
This is not an exhaustive review of the attributes provided from the transaction receipt, but this should highlight how Ape does a nice job exposing the internals of a particular transaction.
Now do the final USDT → USDC swap:
In [18]: tx = tripool.exchange(2, 1, 33_333*10**6, 0, sender=acct)
INFO: Confirmed 0xe71cc7f2750d0338ad840d173abb92229f3191e3c823a0a499ef8c2e611587b0 (total fees paid = 0)
Create contract objects for DAI and USDC, and confirm that the balances are within expectations:
In [19]: dai = Contract(tripool.coins(0))
In [20]: usdc = Contract(tripool.coins(1))
In [21]: dai.balanceOf(acct)
Out[21]: 33300865425666068897308
In [22]: usdc.balanceOf(acct)
Out[22]: 33303533304
In [23]: usdt.balanceOf(acct)
Out[23]: 33334000000
Reminder: USDC and USDC use 6 decimal points, DAI uses 18. The nominal value of “1 DAI” is 1*10^18. A nominal value of “1 USDC” is equivalent to 1*10^6.
Degenbot Verification
End the session with CTRL+D or exit()
. We will now load the degenbot pool helper into Ape to check the accuracy of the swap calculation.