Welcome back to Vyper for Beginners! This lesson covers two data structure types: structs and mappings.
The major difference between these structures and the simple data types covered previously is that structs and mappings are designed to hold differing data types “within” themselves. The closest simple data type is a Dynamic Array, which is limited to storing elements of a single type.
Vyper for Beginners
- Part 1 — Introduction
- Part 2 — Toolkit
- Part 3 — Variables, Flow Control, Functions, Hello World!
- Part 4 — Interfaces
- Part 5 — Dynamic Arrays
- Part 6 — Structs and Mappings
Structs
A struct
is unique among the built-in Vyper data types. It is a custom data type that can hold (almost) any variable type within.
It’s most useful to think of a struct
as a container. There is nothing particularly important about the container itself, the only interesting thing about it is what is contained within.
As an example, consider that we are doing some DeFi work and want to collect pieces of data about a particular ERC-20 token for use later. Rather than using an interface to retrieve information about this token every time it is accessed, you decide to store that information inside of a struct
.
Define the struct
like so:
struct Erc20Token:
addr: address
decimals: uint256
symbol: String[16]
name: String[128]
Then declare the struct with values and a variable reference. Here I’ll populate the variable weth
with values taken directly from the token contract (Etherscan link):
weth: Erc20Token = Erc20Token(
{
addr: 0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2,
decimals: 18,
symbol: 'WETH',
name: 'Wrapped Ether',
}
)
If I need to use any of the data inside this struct later, I reference it using the syntax struct.variable_name
. This is similar to a generic Python object with named variables:
weth.decimals # equal to 18
weth.symbol # equal to 'WETH'
Values can be updated later using direct assignment:
weth.decimals = 69 # lol
There is one limitation on what can be stored inside a struct
: mappings are not allowed. All other types are allowed.
Mappings
A mapping is a hash table that assigns outpt values to one or more inputs. The most similar Python analog is a dict
, which stores values associated with a particular ‘key’. The same value can be stored at different keys, but one key can only ever access a particular value.
A mapping is a fixed structure that must be declared as a state variable (outside of the scope of a function, stored directly on the blockchain).
To initialize a mapping in Vyper, declare it using the HashMap
keyword followed by brackets with data type keywords for the key and value.
Here let’s presume I want to do some more DeFi-centric stuff, and want to associate a particular user address with a token balance:
balance_map = HashMap[address, uint256]
An interesting point about hash maps is that they do not store the ‘key’ value directly. Instead, a hash for all possible ‘key’ values is generated at declaration, and associated with the default value for the mapped type.
In other words, my balance_map
variable above will have many hashes associated with all possible address
variable types, all pointing to 0
(the default value for a uint256
). In this way, updating a ‘value’ for a HashMap
only modifies one piece. The ‘key’ exists immediately after the mapping is initialized.
Keys
A mapping key can be any base type (passed by value, not by reference). Mappings, interfaces, and structs are not allowed as keys.
Values
There is no restriction on the data types allowed to be stored as a mapping ‘value’. You can even store another mapping as a value inside of another mapping.
Python users have likely see the “dictionary of dictionaries” design pattern before, which works similarly to the Vyper “mapping of mappings”.
Example Vyper Contract
Now let’s build a very simple Vyper contract that makes use of the struct
above called Erc20Token
, then store that struct
inside a mapping called token_map
. The contract will store the struct
inside a HashMap
of user addresses.
In essence, the contract will store a bundle of information about an arbitrary token (the struct
), then associate that token container with an arbitrary user address.
The contract does not use any special data types (like DynArray
, introduced in 0.3.3) so I am specifying the version pragma as any version in the 0.3 series.
I am omitting the __init__()
constructor, since I do not need to set any storage values on deployment.
# @version ^0.3
struct Erc20Token:
addr: address
decimals: uint256
symbol: String[16]
name: String[128]
token_map: HashMap[address, Erc20Token]
owner: address
@external
@nonpayable
def set_token_for_user(
owner_address: address,
token_address: address,
token_decimals: uint256,
token_symbol: String[16],
token_name: String[128]
):
_token: Erc20Token = Erc20Token(
{
addr: token_address,
decimals: token_decimals,
symbol: token_symbol,
name: token_name,
}
)
self.token_map[owner_address] = _token
@external
@view
def get_token_for_user(user_address: address) -> Erc20Token:
return self.token_map[user_address]
Now let’s deploy this contract to a local fork using Brownie. Please review the lesson on Vyper interfaces, which contains an introduction to local forks using Ganache.
(.venv) devil@hades:~/vyper_for_beginners$ brownie console --network mainnet-fork
Brownie v1.19.0 - Python development framework for Ethereum
Compiling contracts...
Vyper version: 0.3.3
Generating build data...
- struct_mapping
VyperForBeginnersProject is the active project.
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.
>>> struct_mapping.deploy({'from':accounts[0]})
Transaction sent: 0xc9702b4b450bb91bdd6ebecffd13a251490b50229bcc140c09443ec9ebed9be1
Gas price: 0.0 gwei Gas limit: 12000000 Nonce: 2
struct_mapping.constructor confirmed Block: 14900849 Gas used: 181058 (1.51%)
struct_mapping deployed at: 0xE7eD6747FaC5360f88a2EFC03E00d25789F69291
<struct_mapping Contract '0xE7eD6747FaC5360f88a2EFC03E00d25789F69291'>
>>> struct_mapping[0].set_token_for_user(accounts[0], '0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2', 18, 'WETH', 'Wrapped Ethe
r', {'from':accounts[0]})
Transaction sent: 0x378a012ca2c9d052a310ee978ecaa6e0c858c1e9a0dffa38ab056bd6d0c98d3c
Gas price: 0.0 gwei Gas limit: 12000000 Nonce: 3
struct_mapping.set_token_for_user confirmed Block: 14900850 Gas used: 144426 (1.20%)
<Transaction '0x378a012ca2c9d052a310ee978ecaa6e0c858c1e9a0dffa38ab056bd6d0c98d3c'>
>>> struct_mapping[0].get_token_for_user(accounts[0])
("0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2", 18, "WETH", "Wrapped Ether")
You can see how I have deployed the contract, then using the set_token_for_user()
function, stored a new token definition inside the token_map
mapping. I could repeat this for any number of arbitrary token definitions and addresses.
Moving Forward
Now that we’ve learned how to define and manipulate structs and mappings, we will take a deeper look at function types and their associated decorators.