Welcome back to Vyper for Beginners! This lesson covers interfaces, the primary gateway that your smart contract will use to share data with other deployed contracts on Ethereum (or compatible blockchains).
This builds off the previous lessons, so please review the series below if you need a refresher or encounter anything here that is unfamiliar.
As a bonus, you’ll also learn how to launch a local fork using Ganache for gas-less contract testing and debugging!
Vyper for Beginners
- Part 1 — Introduction
- Part 2 — Toolkit
- Part 3 — Variables, Flow Control, Functions, Hello World!
- Part 4 — Interfaces
- Part 5 — Dynamic Arrays
Interfaces
The concept of an “interface” comes from Solidity, one of the high-level languages created to simplify smart contract programming on Ethereum. An interface is an abstraction that simplifies inter-contract communication. An interface gives us two important advantages:
- A defined structure that the compiler uses to verify input and output types
- An easy “mental model” for the programmer to visualize how data moves between smart contracts
The Ethereum Virtual Machine (EVM) does not have an internal concept of an interface, it simply executes various operations as instructed by the stored bytecode of a contract. All data within the blockchain is available at any time to any node on the network. When we use an interface, it is useful to pretend that some “request” leaves our contract, is routed appropriately to the destination contract, then is processed there before returning.
EVM does this “under the hood”, so we largely do not have to worry about it. It may be interesting to you, so if you want to learn more please read the Ethereum Yellow Paper and read through the discussions on the Vyper GitHub and Discord.
Interfaces in Vyper
A Vyper interface has a defined structure, requiring at minimum the following pieces:
- An interface name
- At least one function definition, providing the following:
- A function name
- All function inputs and their types
- All function outputs and their types
- Any function type modifiers (
view
,payable
,nonpayable
, etc.)
At a high level, an interface is simply a list of function names with their inputs and outputs. Whenever a smart contract intends to execute the function of another smart contract, it does so through the interface. The compiler will check that the function is being called correctly, with the appropriate inputs, and that any stored output of that function is done correctly within the original smart contract.
My niche is building arbitrage and trading bots on Ethereum and EVM-compatible blockchains, so I often interact with Uniswap contracts and their many derivatives.
For this lesson, we will write a Vyper contract with two interfaces. The first interface will allow us to call some functions for the Uniswap Factory contract. The second will allow us to call some functions for the Uniswap Pair contract. I will be adapting the Uniswap V2 core interface definitions from GitHub for use in Vyper.
A Brief Overview of Uniswap V2 Core Contracts
There are five core contracts defined in their GitHub:
- IERC20.sol — the standardized interface for interacting with an ERC-20 token asset
- IUniswapV2Callee.sol — an interface for executing the UniswapV2Call function, which is used for flash borrowing
- IUniswapV2ERC20.sol — an interface for interacting with Uniswap V2 liquidity provider tokens
- IUniswapV2Factory.sol — an interface for interacting with the Uniswap V2 factory, a master contract responsible for automatically deploying and funding liquidity pool contracts
- IUniswapV2Pair.sol — an interface for interacting with the individual liquidity pool contracts
Here are the Solidity definitions of both interfaces:
# IUniswapV2Factory.sol
pragma solidity >=0.5.0;
interface IUniswapV2Factory {
event PairCreated(address indexed token0, address indexed token1, address pair, uint);
function feeTo() external view returns (address);
function feeToSetter() external view returns (address);
function getPair(address tokenA, address tokenB) external view returns (address pair);
function allPairs(uint) external view returns (address pair);
function allPairsLength() external view returns (uint);
function createPair(address tokenA, address tokenB) external returns (address pair);
function setFeeTo(address) external;
function setFeeToSetter(address) external;
}
# IUniswapV2Pair.sol
pragma solidity >=0.5.0;
interface IUniswapV2Pair {
event Approval(address indexed owner, address indexed spender, uint value);
event Transfer(address indexed from, address indexed to, uint value);
function name() external pure returns (string memory);
function symbol() external pure returns (string memory);
function decimals() external pure returns (uint8);
function totalSupply() external view returns (uint);
function balanceOf(address owner) external view returns (uint);
function allowance(address owner, address spender) external view returns (uint);
function approve(address spender, uint value) external returns (bool);
function transfer(address to, uint value) external returns (bool);
function transferFrom(address from, address to, uint value) external returns (bool);
function DOMAIN_SEPARATOR() external view returns (bytes32);
function PERMIT_TYPEHASH() external pure returns (bytes32);
function nonces(address owner) external view returns (uint);
function permit(address owner, address spender, uint value, uint deadline, uint8 v, bytes32 r, bytes32 s) external;
event Mint(address indexed sender, uint amount0, uint amount1);
event Burn(address indexed sender, uint amount0, uint amount1, address indexed to);
event Swap(
address indexed sender,
uint amount0In,
uint amount1In,
uint amount0Out,
uint amount1Out,
address indexed to
);
event Sync(uint112 reserve0, uint112 reserve1);
function MINIMUM_LIQUIDITY() external pure returns (uint);
function factory() external view returns (address);
function token0() external view returns (address);
function token1() external view returns (address);
function getReserves() external view returns (uint112 reserve0, uint112 reserve1, uint32 blockTimestampLast);
function price0CumulativeLast() external view returns (uint);
function price1CumulativeLast() external view returns (uint);
function kLast() external view returns (uint);
function mint(address to) external returns (uint liquidity);
function burn(address to) external returns (uint amount0, uint amount1);
function swap(uint amount0Out, uint amount1Out, address to, bytes calldata data) external;
function skim(address to) external;
function sync() external;
function initialize(address, address) external;
}
Note that we do not have to fully redefine the interface within our Vyper contract, which is a huge time-saver. We only need to define the functions that our contract will use.
We will begin with the Factory interface, then define the Pair interface, then finally write some functions that our contract will use to retrieve information using those interfaces.
Factory Interface
The only function I care about is getPair
(for the purpose of this lesson at least, all functions are unique and special and I love them very much). We will use the getPair
function to retrieve the liquidity pool address for a given pair of tokens.
Here is the Solidity version:
interface IUniswapV2Factory {
function getPair(address tokenA, address tokenB) external view returns (address pair);
}
And the Vyper equivalent:
interface IUniswapV2Factory:
def getPair(tokenA: address, tokenB: address) -> address: view
Note that the interface is defined with a capital “I” prefix. This is a common convention within the smart contract world that clues the programmer that the definition refers to an interface. I have repeated the convention here.
The function definition for getPair
is similar to the Solidity version above, with some differences:
- Vyper expects the variable type to be declared after the name, versus Solidity that expects it before the name.
- Vyper does not require the use of explicit
external
visibility type for an interface definition
Pair Interface
For the Pair contract, I will limit the scope to three functions:
token0
— returns the address of the first token held by the pair contracttoken1
— returns the address of the second token held by the pair contractgetReserves
— returns the quantities of each token held by the pair contract, plus the timestamp of the last transaction
Here is the interface definition in Solidity:
interface IUniswapV2Pair {
function token0() external view returns (address);
function token1() external view returns (address);
function getReserves() external view returns (uint112 reserve0, uint112 reserve1, uint32 blockTimestampLast);
}
And the Vyper equivalent:
interface IUniswapV2Pair
def token0() -> address: view
def token1() -> address: view
def getReserves() -> (uint112, uint112, uint32): view
Vyper 0.3.2 (just released!) now provides full ABIv2 compatibility with Solidity for integer and bytes data types. Previously only uint8 and uint256 were offered, but as of 0.3.2 you may use any bit depth in multiples of 8 (e.g. uint8
, uint16
, uint24
, …, uint248
, uint256
)
Factory-Pair Proxy Contract
Now we can define some of our own functions to read and report values using our interfaces.
Let’s start with a function to proxy some requests to the Factory:
@external
@view
def get_pair_from_factory(
factory_address: address,
tokenA: address,
tokenB: address,
) -> address:
assert tokenA != tokenB, "token addresses must be different!"
return IUniswapV2Factory(factory_address).getPair(tokenA, tokenB)
This new function get_pair_from_factory
accepts three inputs:
factory_address
— an Ethereum address for some deployed Factory contracttokenA
— an Ethereum address for an ERC-20 token contracttokenB
— an Ethereum address for a different ERC-20 token contract
It also includes the first example of an assert
statement, which is the Vyper equivalent of Solidity’s require
. Whenever an assert
is encountered, the expression must evaluate to True
, otherwise the function will revert (cancel, roll back all changes made to that point, and terminate any further operation) with the given string displayed as the revert message.
Simply put — calling get_pair_from_factory
with the same token address for tokenA
and tokenB
will result in the contract sending a useful error message, providing no return values, and stopping immediately.
Also take note of the interface used in the return
statement. It has the format InterfaceName(address).function(inputs)
. To call another contract’s function, Vyper must know the proper interface, the address of the contract, the proper function defined at that address, and all values passed into that function.
So IUniswapV2Factory(factory_address).getPair(tokenA, tokenB)
asks Vyper to execute an external call to the function getPair()
at the address factory_address
with the inputs tokenA
and tokenB
, and to check all inputs and outputs against the interface named IUniswapV2Factory
within this contract.
You’ll also notice that the two variables sent to getPair
were copied from the last two arguments to our function, and the address where that function’s bytecode is stored was copied from the first argument.
Now let’s define a function to get the reserves from a Pair contract, but with a twist! The Pair contract returns a third value representing the timestamp of the last change in reserves, which I do not care about. So I will store the three values in memory, then ignore the timestamp and return just the reserves:
@external
@view
def get_reserves_from_liquidity_pool(pool_address: address) -> (uint112, uint112):
reserve0: uint112 = 0
reserve1: uint112 = 0
time_stamp: uint32 = 0
reserve0, reserve1, time_stamp = IUniswapV2Pair(pool_address).getReserves()
return reserve0, reserve1
Recall from Vyper for Beginners, Part 3 that memory variables must be declared with an initial value. I cannot declare and assign them in the same statement like in Python, e.g. reserve0: uint112, reserve1: uint112, time_stamp: uint32 = IUniswapV2Pair(pool_address).getReserves()
Finally, we define one more function to check that the tokens held by the Pair contract match our expectations:
@external
@view
def get_tokens_in_pool(pool_address:address) -> (address, address):
return IUniswapV2Pair(pool_address).token0(), IUniswapV2Pair(pool_address).token1()
This one is a bit fancier, since it returns two different address values from separate function calls to the same interface.
Local Fork Testing
We used Remix last time as a means to test a standalone Vyper contract (Hello World!)
We cannot use Remix this time because our proxy contract needs a real blockchain with real addresses to pull information from. The Uniswap contracts are not available in Remix, so we need to use a more robust tool.
Enter the local fork!
A local fork is a technique developed to mitigate this problem. With a local fork, you start a program on your local machine that does three critical things:
- Provides an EVM for executing smart contracts
- Maintains a connection to a real node for retrieving historical blockchain data
- Allows you to manipulate the state without affecting the live blockchain
The most popular tools for this purpose are Ganache and Hardhat. I believe Hardhat is the most popular, but Ganache currently has better integration with Brownie (my preferred tool).
Ganache by default creates ten accounts for testing purposes, each with a native gas token balance of 1000 for that particular network (ETH in this case). You can access these accounts from the accounts
object, with the first at accounts[0]
and the last at accounts[9]
.
Installing Ganache on Linux is fairly simple. Visit the Ganache GitHub and follow the installation instructions using npm
. Once you have it installed, verify it runs correctly by running ganache --version
, and you should see output similar to this:
devil@hades:~$ ganache-cli --version
ganache v7.0.4 (@ganache/cli: 0.1.5, @ganache/core: 0.1.5)
Once this has been done, load the Brownie console with the option --network mainnet-fork
like this:
devil@hades:~$ brownie console --network mainnet-fork
Brownie v1.18.1 - Python development framework for Ethereum
No project was loaded.
Launching 'ganache-cli --chain.vmErrorsOnRPCResponse true --server.port 8545 --miner.blockGasLimit 12000000 --wallet.totalAccounts 10 --hardfork istanbul --wallet.mnemonic brownie --fork.url https://rpc.ankr.com/eth --chain.chainId 1'...
Brownie environment is ready.
>>>
Note that I have changed the default host for mainnet-fork
within Brownie, using Ankr instead of Infura. This is purely to avoid having to set the Infura API key repeatedly from the console. If you get error messages with a default Brownie install about missing Infura API keys, search for a guide on generating and setting an API key or find me on Twitter.
Deploy and Test
Create a new Brownie project using brownie init
and save this source code as pair_factory_proxy.vy
inside the contracts
directory:
# @version >=0.3.2
interface IUniswapV2Factory:
def getPair(
tokenA: address,
tokenB: address,
) -> address: view
interface IUniswapV2Pair:
def token0() -> address: view
def token1() -> address: view
def getReserves() -> (uint112, uint112, uint32): view
@external
@view
def get_pair_from_factory(
factory_address: address,
tokenA: address,
tokenB: address,
) -> address:
assert tokenA != tokenB, "token addresses must be different!"
return IUniswapV2Factory(factory_address).getPair(tokenA, tokenB)
@external
@view
def get_reserves_from_liquidity_pool(pool_address: address) -> (uint112, uint112):
reserve0: uint112 = 0
reserve1: uint112 = 0
time_stamp: uint32 = 0
reserve0, reserve1, time_stamp = IUniswapV2Pair(pool_address).getReserves()
return reserve0, reserve1
@external
@view
def get_tokens_in_pool(pool_address:address) -> (address, address):
return IUniswapV2Pair(pool_address).token0(), IUniswapV2Pair(pool_address).token1()
Now launch brownie and deploy it to your local fork using the deploy
method from account[0]
:
devil@hades:~/vyper_pair_factory_proxy$ brownie console --network mainnet-fork
Brownie v1.18.1 - Python development framework for Ethereum
Compiling contracts...
Vyper version: 0.3.2+commit.fbed83c5
Generating build data...
- pair_factory_proxy
VyperPairFactoryProxyProject is the active project.
Attached to local RPC client listening at '127.0.0.1:8545'...
Brownie environment is ready.
>>> pair_factory_proxy.deploy({'from':accounts[0]})
Transaction sent: 0x1ac4d935cfb1d97a966dc9443e21e71b1e37e3513cba8d8c64e067d79279ccba
Gas price: 0.0 gwei Gas limit: 12000000 Nonce: 2
pair_factory_proxy.constructor confirmed Block: 14600971 Gas used: 180206 (1.50%)
pair_factory_proxy deployed at: 0xE7eD6747FaC5360f88a2EFC03E00d25789F69291
<pair_factory_proxy Contract '0xE7eD6747FaC5360f88a2EFC03E00d25789F69291'>
With this contract deployed to your (forked) Ethereum Virtual Machine environment, we can access all of its functions directly from pair_factory_proxy[0]
.
Now let’s send some requests to it, using contract addresses that actually exist on Ethereum. The following addresses will form a test case:
- UniswapV2 Factory —
0x5C69bEe701ef814a2B6a3EDD4B1652CB9cc5aA6f
(Etherscan) - DAI —
0x6B175474E89094C44Da98b954EedeAC495271d0F
(Etherscan) - WETH —
0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2
(Etherscan)
>>> pair_factory_proxy[0].get_pair_from_factory('0x5C69bEe701ef814a2B6a3EDD4B1652CB9cc5aA6f','0x6B175474E89094C44Da98b95
4EedeAC495271d0F','0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2')
'0xA478c2975Ab1Ea89e8196811F51A7B7Ade33eB11'
If we look up the address 0xA478c2975Ab1Ea89e8196811F51A7B7Ade33eB11
(Etherscan), we see that this is the Uniswap V2 LP contract for the DAI/WETH pair. Cool!
Now we check the token addresses for that pool:
>>> pair_factory_proxy[0].get_tokens_in_pool('0xA478c2975Ab1Ea89e8196811F51A7B7Ade33eB11')
("0x6B175474E89094C44Da98b954EedeAC495271d0F", "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2")
The two addresses match the DAI and WETH tokens from earlier.
Now let’s check the reserves:
>>> pair_factory_proxy[0].get_reserves_from_liquidity_pool('0xA478c2975Ab1Ea89e8196811F51A7B7Ade33eB11')
(12318578655687116141642448, 4040466652084716788288)
Checking the balances on Etherscan confirms the reserve values are correct (after correcting for the 18 decimal places) — the pool holds roughly 12,318,578 DAI and 4040 WETH.
I encourage you to browse Etherscan, find familiar ERC-20 token addresses to test, and confirm that pools exist with the expected balances reported by the contract.
Next Steps
In the next lesson we will explore the newly-introduced dynamic array type and build some DeFi-focused functions that can accept inputs and outputs of variable size.