Token (ERC20-like)
A fungible token with HashMap balances, nested allowances, named
errors, and event / emit. The chapter below is a pedagogical merge
of patterns from several fixtures — it is not a line-for-line copy of a
single file in the tree.
| Source fixture | What this chapter borrows |
|---|---|
contracts/token.cam | Core transfer / approve / balance surface |
contracts/erc20_events_evm.cam | event + emit |
contracts/erc20_errors_evm.cam | Named error + throw Error(…) |
The canonical minimal token in the workspace still uses numeric throws and
no events — open contracts/token.cam when you need that baseline.
Helpers and surface
pure fn balance_of(balances: HashMap<address, U256>, owner: address) -> U256 {
if balances.exists(owner) { balances[owner] } else { 0 }
}
entity Token {
const ZERO: address = 0x0000000000000000000000000000000000000000
event Transfer(indexed src: address, indexed dst: address, value: U256);
event Approval(indexed owner: address, indexed spender: address, value: U256);
error InsufficientBalance(have: U256, need: U256);
error ZeroAmount();
routes {
#[factory_only]
constructor(holder: address, initial_supply: U256) => [
emit Transfer(ZERO, holder, initial_supply);
]
transfer(to: address, amount: U256)
where amount > 0 : throw ZeroAmount() &&
balance_of(m_balances, msg::sender) >= amount
: throw InsufficientBalance(balance_of(m_balances, msg::sender), amount)
=> [
emit Transfer(msg::sender, to, amount);
]
approve(spender: address, amount: U256) => [
emit Approval(msg::sender, spender, amount);
]
view balanceOf(owner: address) -> U256 => [
return(balance_of(m_balances, owner))
]
view totalSupply() -> U256 => [
return(m_total_supply)
]
}
m_total_supply: U256 {
in constructor(_, initial_supply) => initial_supply
}
// m_balances and m_allowances below…
}
where / throw Name(…) pair with declared errors; emit pairs with
declared events. Up to three indexed fields per event on EVM (V36).
The balance check lives in where, which runs before any member
transform; a let or emit in the route body would already see the updated
map.
The initial holder is an explicit constructor parameter and the constructor
is #[factory_only]: on EVM the token is deployed through the generated
CambrianFactory, so msg::sender inside the constructor would be the
factory, not the deployer.
HashMap balances
m_balances: HashMap<address, U256> {
in constructor(holder, initial_supply) => {
{}.insert(holder, initial_supply)
}
in transfer(to, amount) => {
let sender = msg::sender;
let sender_bal = balance_of(m_balances, sender);
let to_bal = balance_of(m_balances, to);
if to == sender { m_balances } else {
m_balances
.update(sender, sender_bal - amount)
.update(to, to_bal + amount)
}
}
}
Maps are persistent values: .insert / .update return a new map; the
transform’s final expression becomes the stored member. Empty map
literal: {}. Both balances are read before either is written, so a
self-transfer (to == sender) must return the map unchanged — otherwise the
credit would overwrite the debit and mint amount out of nothing.
Nested allowances
m_allowances: HashMap<address, HashMap<address, U256>> {
in approve(spender, amount) => {
let sender = msg::sender;
let inner = if m_allowances.exists(sender) {
m_allowances[sender]
} else {
{}
};
m_allowances.update(sender, inner.update(spender, amount))
}
}
transferFrom decrements the inner map the same way (see
contracts/token.cam for the full mint / burn / allowance path).
Patterns worth copying
| Pattern | Where |
|---|---|
pure fn for map lookups used in where and transforms | balance_of |
Named error + throw Error(…) | erc20_errors_evm.cam |
event + emit | erc20_events_evm.cam |
Unphased CEI: update maps, then emit | transfer body above |
For a production-shaped ERC-20 with EIP-2612 permit and optional
multi-instance identity, use the shipped
Contract Standard Library
(stdlib/token/ERC20.cam / ERC20Multi.cam). The Uniswap V2 example
keeps its own pair token in examples/uniswap-v2/ERC20.cam.
Tests in the workspace
| File | Role |
|---|---|
contracts/token.cam + contracts/token.test.cam | Baseline entity (numeric throws, no events) and its unit tests |
contracts/erc20_events_evm.cam | Event / emit patterns |
contracts/erc20_errors_evm.cam | Named errors |
contracts/token.cam follows the factory rules like the merged Token
above: its #[factory_only] constructor takes the owner as an explicit
first parameter, constructor(owner, name, symbol, decimals, initial_supply).
List token.cam and token.test.cam under sources: in a project.yaml
and build it the same way as Counter: with target: evm
the 12 unit tests pass under forge test, with target: lean the theorems
build with lake build.
The two erc20_*_evm.cam fixtures predate factory deployment: their
constructors read msg::sender and carry no #[factory_only], so EVM
project mode rejects them (single-file mode only warns). Read them for the
event and error patterns.