Skip to content
Pre-Release: Fe is under active development. This documentation covers the upcoming release. Follow progress on GitHub

Complete ERC20

This chapter presents a complete ERC20 token implementation called CoolCoin. This example demonstrates real Fe patterns including contract-level effects, storage structs, access control, events, and message handling.

// roles
const MINTER: u256 = 1
const BURNER: u256 = 2
pub contract CoolCoin uses (ctx: mut Ctx, log: mut Log) {
// Storage fields. These act as effects within the contract.
mut store: TokenStore,
mut auth: AccessControl,
// Initialize the token with name, symbol, decimals, and initial supply
init(initial_supply: u256, owner: Address)
uses (mut store, mut auth, mut ctx, mut log)
{
auth.grant(role: MINTER, to: owner)
auth.grant(role: BURNER, to: owner)
if initial_supply > 0 {
store.mint(to: owner, amount: initial_supply)
}
}
recv Erc20 {
Transfer { to, amount } -> bool uses (ctx, mut store, mut log) {
store.transfer(from: ctx.caller(), to, amount)
true
}
Approve { spender, amount } -> bool uses (ctx, mut store, mut log) {
store.approve(owner: ctx.caller(), spender, amount)
true
}
TransferFrom { from, to, amount } -> bool uses (ctx, mut store, mut log) {
store.spend_allowance(owner: from, spender: ctx.caller(), amount)
store.transfer(from, to, amount)
true
}
BalanceOf { account } -> u256 uses store {
store.balances[account]
}
Allowance { owner, spender } -> u256 uses (store) {
store.allowances[(owner, spender)]
}
TotalSupply {} -> u256 uses store {
store.total_supply
}
Name {} -> String<32> { "CoolCoin" }
Symbol {} -> String<8> { "COOL" }
Decimals {} -> u8 { 18 }
}
// Extended functionality (minting and burning)
recv Erc20Extended {
Mint { to, amount } -> bool uses (ctx, mut store, mut log, auth) {
auth.require(role: MINTER)
store.mint(to, amount)
true
}
// Burns tokens from caller's balance
Burn { amount } -> bool uses (ctx, mut store, mut log) {
store.burn(from: ctx.caller(), amount)
true
}
// Burns tokens from an account using allowance (requires BURNER or allowance)
BurnFrom { from, amount } -> bool uses (ctx, mut store, mut log) {
store.spend_allowance(owner: from, spender: ctx.caller(), amount)
store.burn(from, amount)
true
}
IncreaseAllowance { spender, added_value } -> bool
uses (ctx, mut store, mut log)
{
let owner = ctx.caller()
let current = store.allowances[(owner, spender)]
store.approve(owner, spender, amount: current + added_value)
true
}
DecreaseAllowance { spender, subtracted_value } -> bool
uses (ctx, mut store, mut log) {
let owner = ctx.caller()
let current = store.allowances[(owner, spender)]
assert(current >= subtracted_value, "decreased allowance below zero")
store.approve(owner, spender, amount: current - subtracted_value)
true
}
}
}
struct TokenStore {
total_supply: u256,
balances: Map<Address, u256>,
allowances: Map<(Address, Address), u256>,
}
impl TokenStore {
fn transfer(mut self, from: Address, to: Address, amount: u256) uses (log: mut Log) {
assert(from != Address::zero(), "transfer from zero address")
assert(to != Address::zero(), "transfer to zero address")
let from_balance = self.balances[from]
assert(from_balance >= amount, "transfer amount exceeds balance")
self.balances[from] = from_balance - amount
self.balances[to] += amount
log.emit(TransferEvent { from, to, value: amount })
}
fn mint(mut self, to: Address, amount: u256) uses (log: mut Log) {
assert(to != Address::zero(), "mint to zero address")
self.total_supply += amount
self.balances[to] += amount
log.emit(TransferEvent { from: Address::zero(), to, value: amount })
}
fn burn(mut self, from: Address, amount: u256) uses (log: mut Log) {
assert(from != Address::zero(), "burn from zero address")
let from_balance = self.balances[from]
assert(from_balance >= amount, "burn amount exceeds balance")
self.balances[from] = from_balance - amount
self.total_supply -= amount
log.emit(TransferEvent { from, to: Address::zero(), value: amount })
}
fn approve(mut self, owner: Address, spender: Address, amount: u256) uses (log: mut Log) {
assert(owner != Address::zero(), "approve from zero address")
assert(spender != Address::zero(), "approve to zero address")
self.allowances[(owner, spender)] = amount
log.emit(ApprovalEvent { owner, spender, value: amount })
}
fn spend_allowance(mut self, owner: Address, spender: Address, amount: u256) {
let current = self.allowances[(owner, spender)]
// if current != u256::MAX { // TODO: define ::MAX constants
assert(current >= amount, "insufficient allowance")
self.allowances[(owner, spender)] = current - amount
// }
}
}
pub struct AccessControl {
roles: Map<(u256, Address), bool>,
}
impl AccessControl {
pub fn new() -> Self {
AccessControl {
roles: Map::new(),
}
}
pub fn has_role(self, role: u256, account: Address) -> bool {
core::ops::Index<(u256, Address)>::index(self.roles, (role, account))
}
pub fn require(self, role: u256) uses (ctx: Ctx) {
let caller = ctx.caller()
assert(self.has_role(role, caller), "access denied: missing role")
}
pub fn grant(mut self, role: u256, to: Address) {
self.roles[(role, to)] = true
}
pub fn revoke(mut self, role: u256, from: Address) {
self.roles[(role, from)] = false
}
}
// ERC20 standard message types
msg Erc20 {
#[selector = sol("name()")]
Name -> String<32>,
#[selector = sol("symbol()")]
Symbol -> String<8>,
#[selector = sol("decimals()")]
Decimals -> u8,
#[selector = sol("totalSupply()")]
TotalSupply -> u256,
#[selector = sol("balanceOf(address)")]
BalanceOf { account: Address } -> u256,
#[selector = sol("allowance(address,address)")]
Allowance { owner: Address, spender: Address } -> u256,
#[selector = sol("transfer(address,uint256)")]
Transfer { to: Address, amount: u256 } -> bool,
#[selector = sol("approve(address,uint256)")]
Approve { spender: Address, amount: u256 } -> bool,
#[selector = sol("transferFrom(address,address,uint256)")]
TransferFrom { from: Address, to: Address, amount: u256 } -> bool,
}
// Extended ERC20 message types (minting, burning, allowance helpers)
msg Erc20Extended {
#[selector = sol("mint(address,uint256)")]
Mint { to: Address, amount: u256 } -> bool,
#[selector = sol("burn(uint256)")]
Burn { amount: u256 } -> bool,
#[selector = sol("burnFrom(address,uint256)")]
BurnFrom { from: Address, amount: u256 } -> bool,
#[selector = sol("increaseAllowance(address,uint256)")]
IncreaseAllowance { spender: Address, added_value: u256 } -> bool,
#[selector = sol("decreaseAllowance(address,uint256)")]
DecreaseAllowance { spender: Address, subtracted_value: u256 } -> bool,
}
// ERC20 events
#[event]
struct TransferEvent {
#[indexed]
from: Address,
#[indexed]
to: Address,
value: u256,
}
#[event]
struct ApprovalEvent {
#[indexed]
owner: Address,
#[indexed]
spender: Address,
value: u256,
}

The contract declares effects at the contract level:

pub contract CoolCoin uses (ctx: mut Ctx, log: mut Log) {
mut store: TokenStore
mut auth: AccessControl

Key points:

  • uses (ctx: mut Ctx, log: mut Log) declares contract-wide effects
  • Ctx provides execution context (caller address, block info)
  • Log provides event emission capability
  • store and auth are storage fields that act as effects within the contract

The contract uses two storage structs:

struct TokenStore {
total_supply: u256,
balances: StorageMap<Address, u256>,
allowances: StorageMap<(Address, Address), u256>,
}
pub struct AccessControl {
roles: StorageMap<(u256, Address), bool>,
}

TokenStore holds all ERC20 state:

  • total_supply: Total tokens in circulation
  • balances: Maps addresses to their token balances
  • allowances: Maps (owner, spender) pairs to approved amounts

AccessControl manages role-based permissions using a map from (role, address) to boolean.

Messages define the external interface with ABI-compatible selectors:

msg Erc20 {
#[selector = sol("name()")]
Name -> String<32>,
#[selector = sol("transfer(address,uint256)")]
Transfer { to: Address, amount: u256 } -> bool,
// ...
}

The Erc20 message group covers standard ERC20 functions. The Erc20Extended group adds minting, burning, and allowance helpers.

Each selector matches the standard Solidity function selector, ensuring ABI compatibility.

init(initial_supply: u256, owner: Address)
uses (mut store, mut auth, mut ctx, mut log)
{
auth.grant(role: MINTER, to: owner)
auth.grant(role: BURNER, to: owner)
if initial_supply > 0 {
store.mint(to: owner, amount: initial_supply)
}
}

The constructor:

  1. Grants MINTER and BURNER roles to the owner
  2. Mints initial supply to the owner if non-zero
  3. Declares which effects it uses from the contract

Each handler declares its specific effect requirements:

recv Erc20 {
Transfer { to, amount } -> bool uses (ctx, mut store, mut log) {
store.transfer(from: ctx.caller(), to, amount)
true
}
BalanceOf { account } -> u256 uses store {
store.balances[account]
}
}

Notice:

  • Transfer needs ctx (for caller), mut store (to modify balances), and mut log (to emit event)
  • BalanceOf only needs store (read-only) - no mut required
  • Handlers call methods on the storage struct

Core logic lives in impl TokenStore. Methods use self/mut self for storage access and uses for additional effects:

impl TokenStore {
fn transfer(mut self, from: Address, to: Address, amount: u256) uses (log: mut Log) {
assert(from != Address::zero(), "transfer from zero address")
assert(to != Address::zero(), "transfer to zero address")
let from_balance = self.balances[from]
assert(from_balance >= amount, "transfer amount exceeds balance")
self.balances[from] = from_balance - amount
self.balances[to] += amount
log.emit(TransferEvent { from, to, value: amount })
}
}

This pattern:

  • Validates inputs with assertions
  • Updates state through mut self
  • Emits events through additional effects (uses (log: mut Log))
  • Keeps storage logic cohesive within the struct

The mint and burn methods follow the same pattern, using Address::zero() as the source/destination to indicate minting/burning.

Events are structs with #[indexed] fields for filtering:

#[event]
struct TransferEvent {
#[indexed]
from: Address,
#[indexed]
to: Address,
value: u256,
}
#[event]
struct ApprovalEvent {
#[indexed]
owner: Address,
#[indexed]
spender: Address,
value: u256,
}

Events are emitted via the Log effect:

log.emit(TransferEvent { from, to, value: amount })

Role-based access control is implemented as a struct with methods:

const MINTER: u256 = 1
const BURNER: u256 = 2
impl AccessControl {
pub fn require(self, role: u256) uses (ctx: Ctx) {
let caller = ctx.caller()
assert(self.has_role(role, caller), "access denied: missing role")
}
pub fn grant(mut self, role: u256, to: Address) {
self.roles[(role, to)] = true
}
}

Usage in handlers:

Mint { to, amount } -> bool uses (ctx, mut store, mut log, auth) {
auth.require(role: MINTER)
store.mint(to, amount)
true
}

The require method checks if the caller has the specified role, reverting if not.

PatternExample
Contract-level effectscontract CoolCoin uses (ctx: mut Ctx, log: mut Log)
Storage as fieldsmut store: TokenStore
Handler-specific effectsuses (ctx, mut store, mut log)
Storage methodsimpl TokenStore { fn transfer(mut self, ...) uses (log: mut Log) }
Event emissionlog.emit(TransferEvent { ... })
Role-based accessauth.require(role: MINTER)
Zero address checksassert(to != Address::zero(), "...")

CoolCoin demonstrates how to build a production-quality ERC20 token in Fe:

  1. Explicit effects make capabilities visible in signatures
  2. Storage structs organize related state
  3. Message groups define ABI-compatible interfaces
  4. Impl blocks keep storage logic cohesive within structs
  5. Access control protects privileged operations
  6. Events record state changes for off-chain indexing

This pattern scales to more complex contracts while maintaining clarity about what each component can do.