Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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 fixtureWhat this chapter borrows
contracts/token.camCore transfer / approve / balance surface
contracts/erc20_events_evm.camevent + emit
contracts/erc20_errors_evm.camNamed 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

PatternWhere
pure fn for map lookups used in where and transformsbalance_of
Named error + throw Error(…)erc20_errors_evm.cam
event + emiterc20_events_evm.cam
Unphased CEI: update maps, then emittransfer 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

FileRole
contracts/token.cam + contracts/token.test.camBaseline entity (numeric throws, no events) and its unit tests
contracts/erc20_events_evm.camEvent / emit patterns
contracts/erc20_errors_evm.camNamed 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.