Vyper for Beginners — Interfaces

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

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 contract
  • token1 — returns the address of the second token held by the pair contract
  • getReserves — 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 contract
  • tokenA — an Ethereum address for an ERC-20 token contract
  • tokenB — 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.

Photo of author

Written By BowTiedDevil

Degenerate coder, open source software maximalist, engineer, turbo autist.

Disclosure

This article may contain links to third-party websites or other content for information purposes. BowTiedIsland may receive a commission at no cost to you if you purchase a product after clicking one of these links. The Third-Party Sites are not under the control of BowTiedIsland, and BowTiedIsland is not responsible for the content of any Third-Party Site. All information contained herein is the opinion of the writer and does not constitute financial advice. We aim to act as a neutral third party and aid in your research and analysis.


The Jungle


Crypto, Investing, and E-Commerce with BowTied Bull

The future is internet based, therefore we have a triangle based approach with crypto, e-commerce business making and Investing in traditional assets

Break Into a 6-Figure Wall Street Career

Learn how to efficiently break into investment banking and private equity. The Banker’s Bible is a definitive resource that offers an   A-Z blueprint and also gives you 24/7 access to real finance professionals. 

Fitness With BowTiedOx

BowTiedOx provides you a place to find all of his latest programs and guides.

Weekly newsletters that cover fitness, health, and mindset, all grounded in the fundamentals of physiology.

Media Production with BowTied Turkey and BowTied Tamarin

Video is no longer optional.

Don’t get left behind.

Your brand deserves professional videos to engage your audience.

Art & Graphic Design with BowTied Patriot

BowTied Patriot is a graphic artist who specializes in photography, mixed medium custom artwork, and NFT creation.

Join BowTiedPatriot as he dives into making Art in Web3.0 and The Metaverse.

Cooking with BowTiedOctopod

Learn secrets from a fine dining chef for maximum flavor and time-saving efficiency

Newsletters on Ingredients, Techniques and Flavor hacks that will have you eating better. We will never eat bugs!

Meme Warfare with DgenFren

Increase your online engagement, organically influence narratives, and build your online persona by using marketing that your target audience actually wants: memes.

Learn How to Sell with BowTiedSalesGuy

Sales is one of the most transferrable life skills, yet few know how to actually sell.

Traditional sales tactics don’t cut it in today’s hyper competitive world.

Learn the secrets from a Chad Salesman and change your Life forever.

Ecommerce with BowTiedOpossum

Learn the skills to start and build your first online business.

Want to build a business that travels with you?

Learn from an industry veteran that has worked on and with brands you already know.