Welcome. This article covers something very simple: how to create a cryptocurrency using Vyper and Python. This guide is designed for beginners or enthusiasts who already know a bit about blockchain and want to dive deeper.
This is not an introduction to programming (we won't cover basic variables or functions) or fundamental blockchain concepts. It assumes you already understand what a cryptocurrency and a wallet address are. By the end, we will have a cryptocurrency written in Vyper ready for the Arbitrum network.
Throughout this article, I will use the terms "cryptocurrency" and "ERC20" interchangeably.
What is a Cryptocurrency? What is an ERC20?
An ERC20 is the technical standard used for smart contracts on the Ethereum Virtual Machine (EVM). EIP-20 defines the rules a contract must follow to be considered a cryptocurrency and be compatible with exchanges and wallets.
Essentially, we need to implement 6 basic functions:
-
totalSupply: The total number of coins in circulation. -
balanceOf: Shows the balance of a specific address. -
transfer: Sends coins from the owner to a destination. -
allowance: The amount a third party is permitted to move on behalf of the owner. -
approve: Authorizes an external address to move coins on behalf of the owner. -
transferFrom: Allows an authorized address to execute the movement of funds.
Additionally, we will implement 3 optional functions that improve the user experience:
-
name: Name of the cryptocurrency (e.g., Bitcoin). -
symbol: Cryptocurrency symbol (e.g., BTC). -
decimals: Number of decimal places (usually 18).
Setup
We will use a minimal setup with uv as a dependency manager and Titanoboa to compile Vyper.
- Install UV.
- Create the project:
uv init <project_name> - Create a virtual environment:
uv venv - Activate the environment:
- Linux/macOS:
source .venv/bin/activate - Windows:
.venv\Scripts\activate - Install Titanoboa:
uv add titanoboa
Project Structure
📂 contracts/: Where we will store the smart contract.
📂 scripts/: Python scripts for deployment.
📂 tests/: Unit tests for our ERC20.
Developing our Cryptocurrency
Create the file contracts/ERC20.vy.
Pragma
The pragma indicates which compiler version was used. We will use 0.4.3.
# pragma version ==0.4.3
Implementing totalSupply and Immutables
We will define the name and symbol as state variables. For the total supply, we will use a public variable.
name: public(String[32])
symbol: public(String[10])
decimals: public(uint8)
totalSupply: public(uint256)
@deploy
def __init__(_name: String[32], _symbol: String[10]):
self.name = _name
self.symbol = _symbol
self.decimals = 18
By using public(...), Vyper automatically creates the getter function for us.
Implementing balanceOf
We will use a HashMap to track how much each wallet holds.
balanceOf: public(HashMap[address, uint256])
💡 It is a good idea to mark internal variables with an _ (underscore), but by using public(), Vyper handles external visibility for us efficiently.
Implementing transfer
The transfer function is the heart of our coin. It allows moving value from point A to point B.
In Vyper, the logic is straightforward: we subtract from the sender and add to the receiver.
@external
def transfer(_to: address, _value: uint256) -> bool:
"""
@dev Transfers tokens to a specified address.
@param _to The address to transfer to.
@param _value The amount to transfer.
"""
self.balanceOf[msg.sender] -= _value
self.balanceOf[_to] += _value
return True
What is happening here?
-
@external: Allows anyone or any contract to call this function. -
msg.sender: A global variable representing the address of whoever is executing the transaction. - Extra security: Unlike older versions of other languages, Vyper natively handles overflow. If you try to send more than you have, the transaction automatically fails without extra code.
Delegating Power: approve and transferFrom
The concept of Allowance is what makes ERC20s so powerful. It allows you to authorize an address (for example, a Decentralized Exchange) to withdraw a specific amount of tokens from your wallet.
1. The Allowance Mapping
First, we need a structure to store who has permission to withdraw from whom. We will use a nested HashMap:
# Owner -> (Spends for me -> Amount)
allowance: public(HashMap[address, HashMap[address, uint256]])
2. approve Function
With this function, the user says: "I authorize this application to spend X amount of my tokens."
@external
def approve(_spender: address, _value: uint256) -> bool:
"""
@dev Authorizes `_spender` to transfer up to `_value` tokens.
"""
self.allowance[msg.sender][_spender] = _value
return True
3. transferFrom Function
This is the function called by the authorized application. Note that here the sender is not the token owner, but the "spender".
@external
def transferFrom(_from: address, _to: address, _value: uint256) -> bool:
"""
@dev Moves tokens from `_from` to `_to` using the allowance mechanism.
"""
# If there is not enough permission, Vyper will throw an overflow error
self.allowance[_from][msg.sender] -= _value
# Move the balances
self.balanceOf[_from] -= _value
self.balanceOf[_to] += _value
return True
Events: The Final Touch
For interfaces like MetaMask or Etherscan to show your transactions in real-time, you need Events. Without them, the contract works, but it will be "invisible" to many applications.
Add this to the top of your file, below the pragma:
event Transfer:
sender: indexed(address)
receiver: indexed(address)
value: uint256
event Approval:
owner: indexed(address)
spender: indexed(address)
value: uint256
Important: Make sure to call
log Transfer(...)andlog Approval(...)inside their respective functions so the standard is 100% official.
@external
def transfer(...) -> bool:
# ...
log Transfer(_from=msg.sender, _to=_to, _value=_value)
return True
@external
def approve(...) -> bool:
# ...
log Approval(_owner=msg.sender, _spender=_spender, _value=_value)
return True
@external
def transferFrom(...) -> bool:
# ...
log Transfer(_from=_from, _to=_to, _value=_value)
return True
You now have a complete and functional ERC20 contract. It is minimalist, secure, and follows Vyper 0.4.3 best practices.
Optimizations: Saving Gas with immutable
If you are launching your coin on a network, every unit of gas counts. In the version we wrote above, the name, symbol, and decimals are saved in the blockchain's storage. Reading from storage is one of the most expensive operations in terms of gas.
For values that never change after deploying the contract, Vyper offers the immutable wrapper.
Why use immutable?
By marking a variable as immutable, Vyper does not save it in expensive storage space. Instead, the value is embedded directly into the contract's bytecode during deployment. This makes reading this data extremely cheap (almost free).
The Optimized Code
Here is what the implementation looks like using immutables in Vyper 0.4.3:
# Declare immutable variables (UPPERCASE is recommended)
NAME: immutable(String[10])
SYMBOL: immutable(String[5])
DECIMALS: immutable(uint8)
@deploy
def __init__(_name: String[10], _symbol: String[5]):
# Assigned only once in the constructor
NAME = _name
SYMBOL = _symbol
DECIMALS = 18
# Create external functions to comply with the ERC20 standard
@external
@view
def name() -> String[10]:
return NAME
@external
@view
def symbol() -> String[5]:
return SYMBOL
@external
@view
def decimals() -> uint8:
return DECIMALS
When to use it?
- YES: For the name, symbol, decimals, or an initial owner's address that will not change.
- NO: For balances or any value you need to update in the future.
Mint Implementation
We implement this logic by dividing it into parts: an immutable variable to know who the smart contract creator is, an external function for access control, and an internal one for accounting logic.
OWNER: immutable(address)
@deploy
def __init__(...):
# ...
OWNER = msg.sender
@external
def mint(_to: address, _value: uint256) -> bool:
assert msg.sender == OWNER, "Only the OWNER can mint"
self._mint(_to, _value)
return True
@internal
def _mint(_to: address, _value: uint256):
self.balanceOf[_to] += _value
self.totalSupply += _value
log Transfer(_from=empty(address), _to=_to, _value=_value)
Key points of this implementation:
- Access Control: We use assert msg.sender == OWNER to ensure no one else can arbitrarily inflate the token supply.
- Total Supply Management: Unlike a standard transfer, mint increases the self.totalSupply variable, maintaining global balance integrity.
- Emission Convention: Following best practices, we emit the Transfer event using empty(address) (the 0x00... address) as the origin. This indicates to block explorers that the tokens didn't come from another user but were just created.
- Logic Separation: Defining _mint as an @internal function is a recommended practice. It allows us to reuse the minting logic from other contract functions in future versions without repeating code or exposing access publicly.
💡 Adding functions like mint doesn't break compatibility with the standard. The ERC20 defines a minimum mandatory interface; as long as those base functions exist and behave as expected, you can add all the extra logic you need.
Full Code (Vyper 0.4.3)
Here is the final, optimized contract ready for deployment:
# pragma version ==0.4.3
event Transfer:
_from: indexed(address)
_to: indexed(address)
_value: uint256
event Approval:
_owner: indexed(address)
_spender: indexed(address)
_value: uint256
# Immutables
NAME: immutable(String[10])
SYMBOL: immutable(String[5])
DECIMALS: immutable(uint8)
OWNER: immutable(address)
# Storage Variables
balanceOf: public(HashMap[address, uint256])
allowance: public(HashMap[address, HashMap[address, uint256]])
totalSupply: public(uint256)
@deploy
def __init__(_name: String[10], _symbol: String[5]):
NAME = _name
SYMBOL = _symbol
DECIMALS = 18
OWNER = msg.sender
@external
def transfer(_to: address, _value: uint256) -> bool:
"""
@dev Transfers tokens to a specified address.
@param _to The address to transfer to.
@param _value The amount to transfer.
"""
self.balanceOf[msg.sender] -= _value
self.balanceOf[_to] += _value
log Transfer(_from=msg.sender, _to=_to, _value=_value)
return True
@external
def approve(_spender: address, _value: uint256) -> bool:
"""
@dev Authorizes `_spender` to transfer up to `_value` tokens.
"""
self.allowance[msg.sender][_spender] = _value
log Approval(_owner=msg.sender, _spender=_spender, _value=_value)
return True
@external
def transferFrom(_from: address, _to: address, _value: uint256) -> bool:
"""
@dev Moves tokens from `_from` to `_to` using the allowance mechanism.
"""
self.allowance[_from][msg.sender] -= _value
self.balanceOf[_from] -= _value
self.balanceOf[_to] += _value
log Transfer(_from=_from, _to=_to, _value=_value)
return True
@external
def mint(_to: address, _value: uint256) -> bool:
"""
@dev Mints `_value` amount of coins to `_to`
"""
assert msg.sender == OWNER, "Only the OWNER can mint"
self._mint(_to, _value)
return True
@external
@view
def name() -> String[10]:
return NAME
@external
@view
def symbol() -> String[5]:
return SYMBOL
@external
@view
def decimals() -> uint8:
return DECIMALS
@internal
def _mint(_to: address, _value: uint256):
self.balanceOf[_to] += _value
self.totalSupply += _value
log Transfer(_from=empty(address), _to=_to, _value=_value)
Python Script: Blockchain Deployment
For the final touch, we will use Titanoboa (or boa), an extremely lightweight and powerful framework for deploying and testing Vyper contracts using pure Python.
1. Script Setup
Create a folder called scripts/ and, inside it, a file named deploy.py.
Install eth-account to manage our keys securely: uv add eth-account
2. Security First: The Keystore
Golden Rule: Never leave your private key in plain text in your code or in .env files. It's the easiest way to get your funds stolen.
We will use a Keystore JSON file (a password-encrypted file). If you use tools like Ape or Foundry, you already have them; if not, make sure to generate one with eth-account.
3. The Deployment Code
Here is the complete script. It is configured for the Arbitrum Mainnet RPC, but you can change it to Sepolia if you prefer to test first.
import getpass
import boa
from eth_account import Account
def load_keystore_account():
"""Securely loads an account from a keystore file."""
with open("account.keystore.json", "r") as acc:
password = getpass.getpass("\t Enter your Keystore password: ")
encrypted_account = acc.read()
account_pk = Account.decrypt(encrypted_account, password)
return Account.from_key(account_pk)
def main():
# 1. Define the network (Arbitrum in this case)
# You can use a provider like Alchemy/Infura if you want
rpc_url = "https://arb1.arbitrum.io/rpc"
with boa.set_network_env(rpc_url):
# 2. Load the account and add it to the boa environment
account = load_keystore_account()
boa.env.add_account(account)
print(f"Deploying contract with account: {account.address}...")
# 3. Deploy the contract
# boa.load compiles and sends the deployment transaction
erc20_contract = boa.load(
"contracts/ERC20.vy",
"My Token", # _name
"MT" # _symbol
)
print(f"Success! Contract deployed at: {erc20_contract.address}")
return erc20_contract
if __name__ == "__main__":
main()
4. Launching the Token
To execute it, simply run in your terminal: uv run scripts/deploy.py
Titanoboa will compile your Vyper code on the fly, sign the transaction with your encrypted account, and send it to the Arbitrum network.
Top comments (0)